- 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
854 lines
33 KiB
TypeScript
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),
|
|
};
|
|
}
|