import type { Pool } from 'pg'; import type { Cfdi, CfdiFilters, CfdiListResponse } from '@horux/shared'; import { markForInvalidation } from './metricas.service.js'; import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../utils/saldo.js'; // Common SELECT columns mapping DB → camelCase const CFDI_SELECT = ` id, year, month, type, uuid, serie, folio, status, fecha_emision as "fechaEmision", rfc_emisor_id as "rfcEmisorId", rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", rfc_receptor_id as "rfcReceptorId", rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", subtotal, subtotal_mxn as "subtotalMxn", descuento, descuento_mxn as "descuentoMxn", total, total_mxn as "totalMxn", saldo_insoluto as "saldoInsoluto", moneda, tipo_cambio as "tipoCambio", tipo_comprobante as "tipoComprobante", metodo_pago as "metodoPago", forma_pago as "formaPago", uso_cfdi as "usoCfdi", pac, fecha_cert_sat as "fechaCertSat", fecha_cancelacion as "fechaCancelacion", uuid_relacionado as "uuidRelacionado", isr_retencion as "isrRetencion", isr_retencion_mxn as "isrRetencionMxn", iva_traslado as "ivaTraslado", iva_traslado_mxn as "ivaTrasladoMxn", iva_retencion as "ivaRetencion", iva_retencion_mxn as "ivaRetencionMxn", ieps_traslado as "iepsTraslado", ieps_traslado_mxn as "iepsTrasladoMxn", ieps_retencion as "iepsRetencion", ieps_retencion_mxn as "iepsRetencionMxn", impuestos_locales_trasladado as "impuestosLocalesTrasladado", impuestos_locales_trasladado_mxn as "impuestosLocalesTrasladoMxn", impuestos_locales_retenidos as "impuestosLocalesRetenidos", impuestos_locales_retenidos_mxn as "impuestosLocalesRetenidosMxn", monto_pago as "montoPago", monto_pago_mxn as "montoPagoMxn", fecha_pago_p as "fechaPagoP", num_parcialidad as "numParcialidad", isr_retencion_pago as "isrRetencionPago", isr_retencion_pago_mxn as "isrRetencionPagoMxn", iva_traslado_pago as "ivaTrasladoPago", iva_traslado_pago_mxn as "ivaTrasladoPagoMxn", iva_retencion_pago as "ivaRetencionPago", iva_retencion_pago_mxn as "ivaRetencionPagoMxn", ieps_traslado_pago as "iepsTrasladoPago", ieps_traslado_pago_mxn as "iepsTrasladoPagoMxn", ieps_retencion_pago as "iepsRetencionPago", ieps_retencion_pago_mxn as "iepsRetencionPagoMxn", saldo_pendiente as "saldoPendiente", saldo_pendiente_mxn as "saldoPendienteMxn", fecha_liquidacion as "fechaLiquidacion", fecha_pago as "fechaPago", fecha_inicial_pago as "fechaInicialPago", fecha_final_pago as "fechaFinalPago", num_dias_pagados as "numDiasPagados", num_seguro_social as "numSeguroSocial", puesto, salario_base_cot_apor as "salarioBaseCotApor", salario_base_cot_apor_mxn as "salarioBaseCotAporMxn", salario_diario_integrado as "salarioDiarioIntegrado", salario_diario_integrado_mxn as "salarioDiarioIntegradoMxn", total_percepciones as "totalPercepciones", total_percepciones_mxn as "totalPercepcionesMxn", total_deducciones as "totalDeducciones", total_deducciones_mxn as "totalDeduccionesMxn", imp_retenidos_nomina as "impRetenidosNomina", imp_retenidos_nomina_mxn as "impRetenidosNominaMxn", otras_deducciones_nomina as "otrasDeduccionesNomina", otras_deducciones_nomina_mxn as "otrasDeduccionesNominaMxn", subsidio_causado as "subsidioCausado", subsidio_causado_mxn as "subsidioCausadoMxn", conciliado, regimen_fiscal_emisor as "regimenFiscalEmisor", regimen_fiscal_receptor as "regimenFiscalReceptor", codigo_postal_receptor as "codigoPostalReceptor", xml_url as "xmlUrl", pdf_url as "pdfUrl", xml_original as "xmlOriginal", cfdi_tipo_relacion as "cfdiTipoRelacion", cfdis_relacionados as "cfdisRelacionados", last_sat_sync as "lastSatSync", sat_sync_job_id as "satSyncJobId", source, facturapi_id as "facturapiId", creado_en as "creadoEn", actualizado_en as "actualizadoEn", contribuyente_id AS "contribuyenteId" `; export async function getCfdis(pool: Pool, filters: CfdiFilters): Promise { const page = filters.page || 1; const limit = filters.limit || 20; const offset = (page - 1) * limit; let whereClause = 'WHERE 1=1'; const params: any[] = []; let paramIndex = 1; // El filtro "tipo" (EMITIDO / RECIBIDO) usa la posición del RFC del // contribuyente cuando viene contribuyenteId — más confiable que la // columna `type`, que puede quedar inconsistente cuando dos // contribuyentes del mismo tenant se facturan entre sí. Se aplica // abajo cuando ya conocemos el RFC vía la subquery. if (filters.tipo && !filters.contribuyenteId) { whereClause += ` AND type = $${paramIndex++}`; params.push(filters.tipo); } if (filters.tipoComprobante) { whereClause += ` AND tipo_comprobante = $${paramIndex++}`; params.push(filters.tipoComprobante); } if (filters.estado) { whereClause += ` AND status = $${paramIndex++}`; params.push(filters.estado); } if (filters.fechaInicio) { whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}::date`; params.push(filters.fechaInicio); } if (filters.fechaFin) { whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`; params.push(filters.fechaFin); } if (filters.rfc) { whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`; params.push(`%${filters.rfc}%`); } if (filters.emisor) { whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex++})`; params.push(`%${filters.emisor}%`); } if (filters.receptor) { whereClause += ` AND (rfc_receptor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`; params.push(`%${filters.receptor}%`); } if (filters.search) { whereClause += ` AND (uuid ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex} OR rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`; params.push(`%${filters.search}%`); } if (filters.contribuyenteId) { // Lado del contribuyente: si filters.tipo viene, restringe a EMITIDO // (rfc_emisor = X) o RECIBIDO (rfc_receptor = X). Si no, ambos lados // (OR contribuyente_id = X para casos donde el RFC no quedó // correctamente asignado pero el tenant lo poseía). if (filters.tipo === 'EMITIDO') { whereClause += ` AND rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`; params.push(filters.contribuyenteId); } else if (filters.tipo === 'RECIBIDO') { whereClause += ` AND rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`; params.push(filters.contribuyenteId); } else { whereClause += ` AND (contribuyente_id = $${paramIndex} OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex}) OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++}))`; params.push(filters.contribuyenteId); } } params.push(limit, offset); const { rows: dataWithCount } = await pool.query(` SELECT ${CFDI_SELECT}, COUNT(*) OVER() as total_count FROM cfdis ${whereClause} ORDER BY fecha_emision DESC LIMIT $${paramIndex++} OFFSET $${paramIndex} `, params); const total = Number(dataWithCount[0]?.total_count || 0); const data = dataWithCount.map(({ total_count, ...cfdi }: any) => cfdi) as Cfdi[]; return { data, total, page, limit, totalPages: Math.ceil(total / limit), }; } /** * Lista paginada de conceptos (cfdi_conceptos) — reusa los mismos filtros de * `getCfdis` aplicados contra la tabla `cfdis` joined. Devuelve TODAS las * columnas non-MXN del concepto + fecha/uuid/RFCs del CFDI padre, para * alimentar la pestaña "Conceptos" en /cfdi y su export a Excel. */ export async function getConceptosList( pool: Pool, filters: CfdiFilters & { uuidLike?: string; claveProdServ?: string; descripcionConcepto?: string; noIdentificacion?: string; orderBy?: 'fecha' | 'importe'; orderDir?: 'asc' | 'desc'; }, ): Promise<{ data: any[]; total: number; page: number; limit: number; totalPages: number; }> { const page = filters.page || 1; const limit = filters.limit || 50; const offset = (page - 1) * limit; let whereClause = 'WHERE 1=1'; const params: any[] = []; let paramIndex = 1; if (filters.tipo && !filters.contribuyenteId) { whereClause += ` AND c.type = $${paramIndex++}`; params.push(filters.tipo); } if (filters.tipoComprobante) { whereClause += ` AND c.tipo_comprobante = $${paramIndex++}`; params.push(filters.tipoComprobante); } if (filters.estado) { whereClause += ` AND c.status = $${paramIndex++}`; params.push(filters.estado); } if (filters.fechaInicio) { whereClause += ` AND COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') >= $${paramIndex++}::date`; params.push(filters.fechaInicio); } if (filters.fechaFin) { whereClause += ` AND COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`; params.push(filters.fechaFin); } if (filters.rfc) { whereClause += ` AND (c.rfc_emisor ILIKE $${paramIndex} OR c.rfc_receptor ILIKE $${paramIndex++})`; params.push(`%${filters.rfc}%`); } if (filters.emisor) { whereClause += ` AND (c.rfc_emisor ILIKE $${paramIndex} OR c.nombre_emisor ILIKE $${paramIndex++})`; params.push(`%${filters.emisor}%`); } if (filters.receptor) { whereClause += ` AND (c.rfc_receptor ILIKE $${paramIndex} OR c.nombre_receptor ILIKE $${paramIndex++})`; params.push(`%${filters.receptor}%`); } if (filters.search) { whereClause += ` AND (c.uuid ILIKE $${paramIndex} OR c.nombre_emisor ILIKE $${paramIndex} OR c.nombre_receptor ILIKE $${paramIndex} OR c.rfc_emisor ILIKE $${paramIndex} OR c.rfc_receptor ILIKE $${paramIndex} OR cc.descripcion ILIKE $${paramIndex++})`; params.push(`%${filters.search}%`); } if (filters.contribuyenteId) { if (filters.tipo === 'EMITIDO') { whereClause += ` AND c.rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`; params.push(filters.contribuyenteId); } else if (filters.tipo === 'RECIBIDO') { whereClause += ` AND c.rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`; params.push(filters.contribuyenteId); } else { whereClause += ` AND (c.contribuyente_id = $${paramIndex} OR c.rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex}) OR c.rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++}))`; params.push(filters.contribuyenteId); } } // Filtros específicos de la tabla Conceptos (popovers en headers). if (filters.uuidLike) { whereClause += ` AND c.uuid ILIKE $${paramIndex++}`; params.push(`%${filters.uuidLike}%`); } if (filters.claveProdServ) { whereClause += ` AND cc.clave_prod_serv ILIKE $${paramIndex++}`; params.push(`%${filters.claveProdServ}%`); } if (filters.descripcionConcepto) { whereClause += ` AND cc.descripcion ILIKE $${paramIndex++}`; params.push(`%${filters.descripcionConcepto}%`); } if (filters.noIdentificacion) { whereClause += ` AND cc.no_identificacion ILIKE $${paramIndex++}`; params.push(`%${filters.noIdentificacion}%`); } // Ordenamiento configurable. Default: fecha DESC, id ASC (estable). const orderDir = filters.orderDir === 'asc' ? 'ASC' : 'DESC'; let orderClause = `ORDER BY c.fecha_emision ${orderDir}, cc.id ASC`; if (filters.orderBy === 'importe') { orderClause = `ORDER BY cc.importe ${orderDir}, cc.id ASC`; } params.push(limit, offset); // SELECT * de cfdi_conceptos para devolver todas las columnas non-MXN // (las MXN también se traen por simplicidad — el frontend las ignora al // exportar; el filtro "no terminen en _mxn" se aplica en el cliente). const { rows: dataWithCount } = await pool.query(` SELECT c.fecha_emision AS "fechaEmision", c.uuid AS "uuid", c.rfc_emisor AS "rfcEmisor", c.rfc_receptor AS "rfcReceptor", c.nombre_emisor AS "nombreEmisor", c.nombre_receptor AS "nombreReceptor", c.tipo_comprobante AS "tipoComprobante", c.status AS "status", cc.*, COUNT(*) OVER() AS total_count FROM cfdi_conceptos cc JOIN cfdis c ON c.id = cc.cfdi_id ${whereClause} ${orderClause} LIMIT $${paramIndex++} OFFSET $${paramIndex} `, params); const total = Number(dataWithCount[0]?.total_count || 0); const data = dataWithCount.map(({ total_count, ...row }: any) => row); return { data, total, page, limit, totalPages: Math.ceil(total / limit), }; } export async function getCfdiById(pool: Pool, id: string): Promise { const { rows } = await pool.query(` SELECT ${CFDI_SELECT} FROM cfdis WHERE id = $1 `, [id]); return rows[0] || null; } export async function getConceptos(pool: Pool, cfdiId: string): Promise { const { rows } = await pool.query(` SELECT id, cfdi_id as "cfdiId", clave_prod_serv as "claveProdServ", no_identificacion as "noIdentificacion", descripcion, cantidad, clave_unidad as "claveUnidad", unidad, valor_unitario as "valorUnitario", valor_unitario_mxn as "valorUnitarioMxn", importe, importe_mxn as "importeMxn", descuento, descuento_mxn as "descuentoMxn", isr_retencion as "isrRetencion", isr_retencion_mxn as "isrRetencionMxn", iva_traslado as "ivaTraslado", iva_traslado_mxn as "ivaTrasladoMxn", iva_retencion as "ivaRetencion", iva_retencion_mxn as "ivaRetencionMxn", ieps_traslado as "iepsTraslado", ieps_traslado_mxn as "iepsTrasladoMxn", ieps_retencion as "iepsRetencion", ieps_retencion_mxn as "iepsRetencionMxn" FROM cfdi_conceptos WHERE cfdi_id = $1 ORDER BY id `, [cfdiId]); return rows; } export async function getXmlById(pool: Pool, id: string): Promise { const { rows } = await pool.query(` SELECT xml_original FROM cfdis WHERE id = $1 `, [id]); return rows[0]?.xml_original || null; } export async function getXmlsByIds(pool: Pool, ids: number[]): Promise<{ id: number; uuid: string; xml: string | null }[]> { const { rows } = await pool.query(` SELECT id, uuid, xml_original FROM cfdis WHERE id = ANY($1) `, [ids]); return rows.map((r: any) => ({ id: r.id, uuid: r.uuid, xml: r.xml_original || null })); } export async function getCfdiXmlsForZip( pool: Pool, filters: CfdiFilters ): Promise<{ uuid: string; xml: string | null }[]> { let whereClause = 'WHERE xml_original IS NOT NULL'; const params: any[] = []; let paramIndex = 1; if (filters.tipo && !filters.contribuyenteId) { whereClause += ` AND type = $${paramIndex++}`; params.push(filters.tipo); } if (filters.tipoComprobante) { whereClause += ` AND tipo_comprobante = $${paramIndex++}`; params.push(filters.tipoComprobante); } if (filters.estado) { whereClause += ` AND status = $${paramIndex++}`; params.push(filters.estado); } if (filters.fechaInicio) { whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}::date`; params.push(filters.fechaInicio); } if (filters.fechaFin) { whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`; params.push(filters.fechaFin); } if (filters.rfc) { whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`; params.push(`%${filters.rfc}%`); } if (filters.emisor) { whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex++})`; params.push(`%${filters.emisor}%`); } if (filters.receptor) { whereClause += ` AND (rfc_receptor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`; params.push(`%${filters.receptor}%`); } if (filters.search) { whereClause += ` AND (uuid ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex} OR rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`; params.push(`%${filters.search}%`); } if (filters.contribuyenteId) { if (filters.tipo === 'EMITIDO') { whereClause += ` AND rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`; params.push(filters.contribuyenteId); } else if (filters.tipo === 'RECIBIDO') { whereClause += ` AND rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`; params.push(filters.contribuyenteId); } else { whereClause += ` AND (contribuyente_id = $${paramIndex} OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex}) OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++}))`; params.push(filters.contribuyenteId); } } params.push(1000); const { rows } = await pool.query(` SELECT uuid, xml_original FROM cfdis ${whereClause} ORDER BY fecha_emision DESC LIMIT $${paramIndex++} `, params); return rows.map((r: any) => ({ uuid: r.uuid, xml: r.xml_original || null })); } export interface CreateCfdiData { uuid: string; type: 'EMITIDO' | 'RECIBIDO'; serie?: string; folio?: string; status?: string; fechaEmision: string; rfcEmisor: string; nombreEmisor: string; rfcReceptor: string; nombreReceptor: string; subtotal: number; subtotalMxn?: number; descuento?: number; descuentoMxn?: number; total: number; totalMxn?: number; saldoInsoluto?: string; moneda?: string; tipoCambio?: number; tipoComprobante?: string; metodoPago?: string; formaPago?: string; usoCfdi?: string; pac?: string; fechaCertSat?: string; fechaCancelacion?: string; uuidRelacionado?: string; isrRetencion?: number; isrRetencionMxn?: number; ivaTraslado?: number; ivaTrasladoMxn?: number; ivaRetencion?: number; ivaRetencionMxn?: number; iepsTraslado?: number; iepsTrasladoMxn?: number; iepsRetencion?: number; iepsRetencionMxn?: number; impuestosLocalesTrasladado?: number; impuestosLocalesTrasladoMxn?: number; impuestosLocalesRetenidos?: number; impuestosLocalesRetenidosMxn?: number; montoPago?: number; montoPagoMxn?: number; fechaPagoP?: string; numParcialidad?: string; isrRetencionPago?: number; isrRetencionPagoMxn?: number; ivaTrasladoPago?: number; ivaTrasladoPagoMxn?: number; ivaRetencionPago?: number; ivaRetencionPagoMxn?: number; iepsTrasladoPago?: number; iepsTrasladoPagoMxn?: number; iepsRetencionPago?: number; iepsRetencionPagoMxn?: number; saldoPendiente?: number; saldoPendienteMxn?: number; fechaLiquidacion?: string; fechaPago?: string; fechaInicialPago?: string; fechaFinalPago?: string; numDiasPagados?: number; numSeguroSocial?: string; puesto?: string; salarioBaseCotApor?: number; salarioBaseCotAporMxn?: number; salarioDiarioIntegrado?: number; salarioDiarioIntegradoMxn?: number; totalPercepciones?: number; totalPercepcionesMxn?: number; totalDeducciones?: number; totalDeduccionesMxn?: number; impRetenidosNomina?: number; impRetenidosNominaMxn?: number; otrasDeduccionesNomina?: number; otrasDeduccionesNominaMxn?: number; subsidioCausado?: number; subsidioCausadoMxn?: number; conciliado?: string; regimenFiscalEmisor?: string; regimenFiscalReceptor?: string; xmlUrl?: string; pdfUrl?: string; xmlOriginal?: string; cfdiTipoRelacion?: string; cfdisRelacionados?: string; source?: string; contribuyenteId?: string; } function computeMxn(value: number | undefined, tipoCambio: number): number { return (value || 0) * tipoCambio; } export async function createCfdi(pool: Pool, data: CreateCfdiData): Promise { if (!data.uuid) throw new Error('UUID es requerido'); if (!data.fechaEmision) throw new Error('Fecha de emisión es requerida'); if (!data.rfcEmisor) throw new Error('RFC Emisor es requerido'); if (!data.rfcReceptor) throw new Error('RFC Receptor es requerido'); const dateStr = typeof data.fechaEmision === 'string' && data.fechaEmision.match(/^\d{4}-\d{2}-\d{2}$/) ? `${data.fechaEmision}T12:00:00` : data.fechaEmision; const fechaEmision = new Date(dateStr); if (isNaN(fechaEmision.getTime())) { throw new Error(`Fecha de emisión inválida: ${data.fechaEmision}`); } const year = String(fechaEmision.getFullYear()); const month = String(fechaEmision.getMonth() + 1).padStart(2, '0'); const tc = data.tipoCambio || 1; const { rows } = await pool.query(` INSERT INTO cfdis ( year, month, type, uuid, serie, folio, status, fecha_emision, rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor, subtotal, subtotal_mxn, descuento, descuento_mxn, total, total_mxn, saldo_insoluto, moneda, tipo_cambio, tipo_comprobante, metodo_pago, forma_pago, uso_cfdi, pac, fecha_cert_sat, fecha_cancelacion, uuid_relacionado, isr_retencion, isr_retencion_mxn, iva_traslado, iva_traslado_mxn, iva_retencion, iva_retencion_mxn, ieps_traslado, ieps_traslado_mxn, ieps_retencion, ieps_retencion_mxn, impuestos_locales_trasladado, impuestos_locales_trasladado_mxn, impuestos_locales_retenidos, impuestos_locales_retenidos_mxn, monto_pago, monto_pago_mxn, fecha_pago_p, num_parcialidad, isr_retencion_pago, isr_retencion_pago_mxn, iva_traslado_pago, iva_traslado_pago_mxn, iva_retencion_pago, iva_retencion_pago_mxn, ieps_traslado_pago, ieps_traslado_pago_mxn, ieps_retencion_pago, ieps_retencion_pago_mxn, saldo_pendiente, saldo_pendiente_mxn, fecha_liquidacion, fecha_pago, fecha_inicial_pago, fecha_final_pago, num_dias_pagados, num_seguro_social, puesto, salario_base_cot_apor, salario_base_cot_apor_mxn, salario_diario_integrado, salario_diario_integrado_mxn, total_percepciones, total_percepciones_mxn, total_deducciones, total_deducciones_mxn, imp_retenidos_nomina, imp_retenidos_nomina_mxn, otras_deducciones_nomina, otras_deducciones_nomina_mxn, subsidio_causado, subsidio_causado_mxn, conciliado, regimen_fiscal_emisor, regimen_fiscal_receptor, xml_url, pdf_url, xml_original, cfdi_tipo_relacion, cfdis_relacionados, source, contribuyente_id ) VALUES ( $1,$2,$3,$4,$5,$6,$7,$8,$9,$10, $11,$12,$13,$14,$15,$16,$17,$18,$19,$20, $21,$22,$23,$24,$25,$26,$27,$28,$29,$30, $31,$32,$33,$34,$35,$36,$37,$38,$39,$40, $41,$42,$43,$44,$45,$46,$47,$48,$49,$50, $51,$52,$53,$54,$55,$56,$57,$58,$59,$60, $61,$62,$63,$64,$65,$66,$67,$68,$69,$70, $71,$72,$73,$74,$75,$76,$77,$78,$79,$80, $81,$82,$83,$84,$85,$86, $87,$88, $89 ) RETURNING ${CFDI_SELECT} `, [ year, month, data.type || 'ingreso', data.uuid, data.serie || null, data.folio || null, data.status || 'vigente', fechaEmision, data.rfcEmisor, data.nombreEmisor || 'Sin nombre', data.rfcReceptor, data.nombreReceptor || 'Sin nombre', data.subtotal || 0, data.subtotalMxn ?? computeMxn(data.subtotal, tc), data.descuento || 0, data.descuentoMxn ?? computeMxn(data.descuento, tc), data.total || 0, data.totalMxn ?? computeMxn(data.total, tc), data.saldoInsoluto || null, data.moneda || 'MXN', tc, data.tipoComprobante || null, data.metodoPago || null, data.formaPago || null, data.usoCfdi || null, data.pac || null, data.fechaCertSat || null, data.fechaCancelacion || null, data.uuidRelacionado || null, data.isrRetencion || 0, data.isrRetencionMxn ?? computeMxn(data.isrRetencion, tc), data.ivaTraslado || 0, data.ivaTrasladoMxn ?? computeMxn(data.ivaTraslado, tc), data.ivaRetencion || 0, data.ivaRetencionMxn ?? computeMxn(data.ivaRetencion, tc), data.iepsTraslado || 0, data.iepsTrasladoMxn ?? computeMxn(data.iepsTraslado, tc), data.iepsRetencion || 0, data.iepsRetencionMxn ?? computeMxn(data.iepsRetencion, tc), data.impuestosLocalesTrasladado || 0, data.impuestosLocalesTrasladoMxn ?? computeMxn(data.impuestosLocalesTrasladado, tc), data.impuestosLocalesRetenidos || 0, data.impuestosLocalesRetenidosMxn ?? computeMxn(data.impuestosLocalesRetenidos, tc), data.montoPago || 0, data.montoPagoMxn ?? computeMxn(data.montoPago, tc), data.fechaPagoP || null, data.numParcialidad || null, data.isrRetencionPago || 0, data.isrRetencionPagoMxn ?? computeMxn(data.isrRetencionPago, tc), data.ivaTrasladoPago || 0, data.ivaTrasladoPagoMxn ?? computeMxn(data.ivaTrasladoPago, tc), data.ivaRetencionPago || 0, data.ivaRetencionPagoMxn ?? computeMxn(data.ivaRetencionPago, tc), data.iepsTrasladoPago || 0, data.iepsTrasladoPagoMxn ?? computeMxn(data.iepsTrasladoPago, tc), data.iepsRetencionPago || 0, data.iepsRetencionPagoMxn ?? computeMxn(data.iepsRetencionPago, tc), data.saldoPendiente || 0, data.saldoPendienteMxn ?? computeMxn(data.saldoPendiente, tc), data.fechaLiquidacion || null, data.fechaPago || null, data.fechaInicialPago || null, data.fechaFinalPago || null, data.numDiasPagados || 0, data.numSeguroSocial || null, data.puesto || null, data.salarioBaseCotApor || 0, data.salarioBaseCotAporMxn ?? computeMxn(data.salarioBaseCotApor, tc), data.salarioDiarioIntegrado || 0, data.salarioDiarioIntegradoMxn ?? computeMxn(data.salarioDiarioIntegrado, tc), data.totalPercepciones || 0, data.totalPercepcionesMxn ?? computeMxn(data.totalPercepciones, tc), data.totalDeducciones || 0, data.totalDeduccionesMxn ?? computeMxn(data.totalDeducciones, tc), data.impRetenidosNomina || 0, data.impRetenidosNominaMxn ?? computeMxn(data.impRetenidosNomina, tc), data.otrasDeduccionesNomina || 0, data.otrasDeduccionesNominaMxn ?? computeMxn(data.otrasDeduccionesNomina, tc), data.subsidioCausado || 0, data.subsidioCausadoMxn ?? computeMxn(data.subsidioCausado, tc), data.conciliado || null, data.regimenFiscalEmisor || null, data.regimenFiscalReceptor || null, data.xmlUrl || null, data.pdfUrl || null, data.xmlOriginal || null, data.cfdiTipoRelacion || null, data.cfdisRelacionados || null, data.source || 'manual', data.contribuyenteId ?? null, ]); // Retroactive invalidation hook: mark cached metrics stale for prior-year CFDIs try { const cfdiDate = new Date(data.fechaEmision || new Date()); const cfdiYear = cfdiDate.getFullYear(); const currentYear = new Date().getFullYear(); if (cfdiYear < currentYear && data.contribuyenteId) { await markForInvalidation(pool, data.contribuyenteId, cfdiYear, cfdiDate.getMonth() + 1, 'CFDI_INSERT'); } } catch (err) { console.error('[Metricas] Invalidation hook failed (non-blocking):', err); } // Recompute saldo_pendiente_mxn de los CFDIs afectados por este insert. // Un I PPD recalcula su propio saldo (considera anticipo si es I/07); un // P o E no-07 recalcula los I PPD que referencia. try { const afectados = uuidsAfectadosPorCfdi({ uuid: data.uuid!, tipoComprobante: data.tipoComprobante ?? null, metodoPago: data.metodoPago ?? null, cfdiTipoRelacion: data.cfdiTipoRelacion ?? null, uuidRelacionado: data.uuidRelacionado ?? null, cfdisRelacionados: data.cfdisRelacionados ?? null, }); if (afectados.length > 0) { await recomputarSaldoPendiente(pool, afectados); } } catch (err) { console.error('[Saldo] Recompute hook failed (non-blocking):', err); } return rows[0]; } export interface BatchInsertResult { inserted: number; duplicates: number; errors: number; errorMessages: string[]; } export async function createManyCfdis(pool: Pool, cfdis: CreateCfdiData[]): Promise { const result = await createManyCfdisBatch(pool, cfdis); return result.inserted; } export async function createManyCfdisBatch(pool: Pool, cfdis: CreateCfdiData[]): Promise { const result: BatchInsertResult = { inserted: 0, duplicates: 0, errors: 0, errorMessages: [] }; if (cfdis.length === 0) return result; for (const cfdi of cfdis) { try { await createCfdi(pool, cfdi); result.inserted++; } catch (error: any) { const errorMsg = error.message || 'Error desconocido'; if (errorMsg.includes('duplicate') || errorMsg.includes('unique')) { result.duplicates++; } else { result.errors++; if (result.errorMessages.length < 10) { result.errorMessages.push(`${cfdi.uuid?.substring(0, 8) || 'N/A'}: ${errorMsg}`); } } } } return result; } export async function deleteCfdi(pool: Pool, id: string): Promise { // Fetch before deleting so we can fire the invalidation hook const { rows: pre } = await pool.query( `SELECT fecha_emision, contribuyente_id FROM cfdis WHERE id = $1`, [id] ); await pool.query(`DELETE FROM cfdis WHERE id = $1`, [id]); // Retroactive invalidation hook: mark cached metrics stale for prior-year CFDIs try { if (pre[0]) { const cfdiDate = new Date(pre[0].fecha_emision || new Date()); const cfdiYear = cfdiDate.getFullYear(); const currentYear = new Date().getFullYear(); if (cfdiYear < currentYear && pre[0].contribuyente_id) { await markForInvalidation(pool, pre[0].contribuyente_id, cfdiYear, cfdiDate.getMonth() + 1, 'CFDI_INSERT'); } } } catch (err) { console.error('[Metricas] Invalidation hook failed (non-blocking):', err); } } export async function getEmisores(pool: Pool, search: string, limit: number = 10, contribuyenteId?: string): Promise<{ rfc: string; nombre: string }[]> { let whereClause = 'WHERE rfc_emisor ILIKE $1 OR nombre_emisor ILIKE $1'; const params: any[] = [`%${search}%`, limit]; if (contribuyenteId) { const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); whereClause += ` AND (contribuyente_id = '${safeId}' OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}') OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}'))`; } const { rows } = await pool.query(` SELECT DISTINCT rfc_emisor as rfc, nombre_emisor as nombre FROM cfdis ${whereClause} ORDER BY nombre_emisor LIMIT $2 `, params); return rows; } export async function getReceptores(pool: Pool, search: string, limit: number = 10, contribuyenteId?: string): Promise<{ rfc: string; nombre: string }[]> { let whereClause = 'WHERE rfc_receptor ILIKE $1 OR nombre_receptor ILIKE $1'; const params: any[] = [`%${search}%`, limit]; if (contribuyenteId) { const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); whereClause += ` AND (contribuyente_id = '${safeId}' OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}') OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}'))`; } const { rows } = await pool.query(` SELECT DISTINCT rfc_receptor as rfc, nombre_receptor as nombre FROM cfdis ${whereClause} ORDER BY nombre_receptor LIMIT $2 `, params); return rows; } export async function getResumenCfdis(pool: Pool, año: number, mes: number, contribuyenteId?: string) { const fi = `${año}-${String(mes).padStart(2, '0')}-01`; const lastDay = new Date(año, mes, 0).getDate(); const ff = `${año}-${String(mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; let whereClause = `WHERE status NOT IN ('Cancelado', '0') AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= $2::date`; if (contribuyenteId) { const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); whereClause += ` AND (contribuyente_id = '${safeId}' OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}') OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}'))`; } const { rows } = await pool.query(` SELECT COALESCE(SUM(CASE WHEN type = 'EMITIDO' AND tipo_comprobante = 'I' THEN total_mxn ELSE 0 END), 0) as total_ingresos, COALESCE(SUM(CASE WHEN type = 'RECIBIDO' AND tipo_comprobante = 'I' THEN total_mxn ELSE 0 END), 0) as total_egresos, COUNT(CASE WHEN type = 'EMITIDO' AND tipo_comprobante = 'I' THEN 1 END) as count_ingresos, COUNT(CASE WHEN type = 'RECIBIDO' AND tipo_comprobante = 'I' THEN 1 END) as count_egresos, COALESCE(SUM(CASE WHEN type = 'EMITIDO' THEN iva_traslado_mxn ELSE 0 END), 0) as iva_trasladado, COALESCE(SUM(CASE WHEN type = 'RECIBIDO' THEN iva_traslado_mxn ELSE 0 END), 0) as iva_acreditable FROM cfdis ${whereClause} `, [fi, ff]); const r = rows[0]; return { totalIngresos: Number(r?.total_ingresos || 0), totalEgresos: Number(r?.total_egresos || 0), countIngresos: Number(r?.count_ingresos || 0), countEgresos: Number(r?.count_egresos || 0), ivaTrasladado: Number(r?.iva_trasladado || 0), ivaAcreditable: Number(r?.iva_acreditable || 0), }; }