Initial commit - Horux Despachos NL
This commit is contained in:
769
apps/api/src/services/cfdi.service.ts
Normal file
769
apps/api/src/services/cfdi.service.ts
Normal file
@@ -0,0 +1,769 @@
|
||||
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",
|
||||
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 fecha_emision >= $${paramIndex++}::date`;
|
||||
params.push(filters.fechaInicio);
|
||||
}
|
||||
|
||||
if (filters.fechaFin) {
|
||||
whereClause += ` AND fecha_emision <= ($${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;
|
||||
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 c.fecha_emision >= $${paramIndex++}::date`;
|
||||
params.push(filters.fechaInicio);
|
||||
}
|
||||
if (filters.fechaFin) {
|
||||
whereClause += ` AND c.fecha_emision <= ($${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}%`);
|
||||
}
|
||||
|
||||
// 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 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) {
|
||||
let whereClause = `WHERE status NOT IN ('Cancelado', '0') AND year = $1 AND month = $2`;
|
||||
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}
|
||||
`, [String(año), String(mes).padStart(2, '0')]);
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user