Files
HoruxDespachosNuevo/apps/api/src/services/cfdi.service.ts
Horux Dev e35eae2a72 refactor(cfdi): descarga masiva de XMLs por filtros en lugar de checkboxes
- Backend: POST /cfdi/download-xmls acepta CfdiFilters, usa getXmlsByFilters con LIMIT 1000
- Frontend: eliminados checkboxes y estado selectedIds; botón Descargar XMLs usa filtros activos
- Si >1000 resultados, muestra confirm() de advertencia pero permite proceder
- Agregada documentación técnica y changelog
2026-05-24 21:40:08 +00:00

854 lines
33 KiB
TypeScript

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<CfdiListResponse> {
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<Cfdi | null> {
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<any[]> {
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<string | null> {
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<Cfdi> {
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<number> {
const result = await createManyCfdisBatch(pool, cfdis);
return result.inserted;
}
export async function createManyCfdisBatch(pool: Pool, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
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<void> {
// 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),
};
}