Files
HoruxDespachosNuevo/apps/api/src/services/conciliacion.service.ts
Horux Dev e21ccd6860 fix(sat,conciliacion): propagar contribuyenteId en sync SAT y campos faltantes en visor de conciliacion
- sat-sync.job.ts: cron diario e incremental ahora iteran contribuyentes
  por tenant y pasan contribuyenteId a startSync(). Evita que CFDIs
  importados del SAT queden con contribuyente_id = NULL.

- sat.service.ts: retryJob() ahora reintenta con job.contribuyenteId.

- conciliacion.service.ts: agrega campos faltantes al SELECT de CFDIs:
  status, formaPago, serie, folio, usoCfdi, subtotal, descuento,
  moneda, tipoCambio, ivaTraslado, ivaRetencion, isrRetencion,
  fechaCertSat. Antes el visor mostraba 'CANCELADO' para todos los
  CFDIs (status era undefined) y faltaban datos de forma de pago,
  impuestos, serie/folio, etc.

Refs: docs/CAMBIOS-2026-05-09.md secciones 6 y 7
2026-05-11 03:58:53 +00:00

295 lines
9.4 KiB
TypeScript

import type { Pool } from 'pg';
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
export interface ConciliacionCfdi {
id: number;
uuid: string;
type: string;
serie: string | null;
folio: string | null;
fechaEmision: string;
rfcEmisor: string;
nombreEmisor: string;
rfcReceptor: string;
nombreReceptor: string;
total: number;
totalMxn: number;
subtotal: number;
descuento: number;
moneda: string;
tipoCambio: number;
tipoComprobante: string | null;
metodoPago: string | null;
formaPago: string | null;
usoCfdi: string | null;
status: string | null;
fechaCertSat: string | null;
ivaTraslado: number;
ivaRetencion: number;
isrRetencion: number;
conciliado: string | null;
idConciliacion: number | null;
conciliacion: {
id: number;
fechaDePago: string;
banco: string;
terminacionCuenta: string;
} | null;
}
export async function getCfdisConConciliacion(
pool: Pool,
filters: {
tipo: string;
fechaInicio?: string;
fechaFin?: string;
regimen?: string;
estado?: string;
contribuyenteId?: string;
}
): Promise<ConciliacionCfdi[]> {
const params: any[] = [];
let idx = 1;
let where = `WHERE c.type = $${idx++} AND c.${VIGENTE}`;
params.push(filters.tipo);
// Excluir PPD en recibidos
if (filters.tipo === 'RECIBIDO') {
where += ` AND (c.metodo_pago IS NULL OR c.metodo_pago != 'PPD')`;
}
// Excluir PPD en emitidos para todos los regimenes excepto 605 y 616
if (filters.tipo === 'EMITIDO') {
where += ` AND NOT (c.metodo_pago = 'PPD' AND (c.regimen_fiscal_emisor IS NULL OR c.regimen_fiscal_emisor NOT IN ('605','616')))`;
}
if (filters.fechaInicio) {
where += ` AND c.fecha_emision >= $${idx++}::date`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
where += ` AND c.fecha_emision <= ($${idx++}::date + interval '1 day')`;
params.push(filters.fechaFin);
}
if (filters.regimen) {
const regimenCol = filters.tipo === 'EMITIDO' ? 'regimen_fiscal_emisor' : 'regimen_fiscal_receptor';
where += ` AND c.${regimenCol} = $${idx++}`;
params.push(filters.regimen);
}
if (filters.estado === 'conciliado') {
where += ` AND c.conciliado = 'true'`;
} else if (filters.estado === 'pendiente') {
where += ` AND (c.conciliado IS NULL OR c.conciliado != 'true')`;
}
if (filters.contribuyenteId) {
const safeId = filters.contribuyenteId.replace(/[^a-f0-9-]/gi, '');
where += ` AND c.contribuyente_id = '${safeId}'`;
}
const { rows } = await pool.query(`
SELECT
c.id, c.uuid, c.type,
c.serie, c.folio,
c.fecha_emision as "fechaEmision",
c.rfc_emisor as "rfcEmisor", c.nombre_emisor as "nombreEmisor",
c.rfc_receptor as "rfcReceptor", c.nombre_receptor as "nombreReceptor",
c.total, c.total_mxn as "totalMxn",
c.subtotal, c.descuento,
c.moneda, c.tipo_cambio as "tipoCambio",
c.tipo_comprobante as "tipoComprobante",
c.monto_pago_mxn as "montoPagoMxn",
c.metodo_pago as "metodoPago",
c.forma_pago as "formaPago",
c.uso_cfdi as "usoCfdi",
c.status,
c.fecha_cert_sat as "fechaCertSat",
c.iva_traslado as "ivaTraslado",
c.iva_retencion as "ivaRetencion",
c.isr_retencion as "isrRetencion",
c.conciliado,
c.id_conciliacion as "idConciliacion",
con.id as "conId",
con.fecha_de_pago as "conFechaDePago",
b.banco as "conBanco",
b.terminacion_cuenta as "conTerminacionCuenta"
FROM cfdis c
LEFT JOIN conciliaciones con ON con.id_cfdi = c.id
LEFT JOIN bancos b ON b.id = con.id_banco
${where}
ORDER BY c.fecha_emision DESC
`, params);
return rows.map((r: any) => ({
id: r.id,
uuid: r.uuid,
type: r.type,
serie: r.serie,
folio: r.folio,
fechaEmision: r.fechaEmision,
rfcEmisor: r.rfcEmisor,
nombreEmisor: r.nombreEmisor,
rfcReceptor: r.rfcReceptor,
nombreReceptor: r.nombreReceptor,
total: Number(r.total),
totalMxn: Number(r.totalMxn),
subtotal: Number(r.subtotal || 0),
descuento: Number(r.descuento || 0),
moneda: r.moneda || 'MXN',
tipoCambio: Number(r.tipoCambio || 1),
tipoComprobante: r.tipoComprobante,
montoPagoMxn: Number(r.montoPagoMxn || 0),
// P usa monto_pago_mxn, PPD conciliada no suma (evitar duplicar con su P), resto usa total_mxn
montoMxn: r.tipoComprobante === 'P'
? Number(r.montoPagoMxn || 0)
: (r.metodoPago === 'PPD' && r.conciliado === 'true') ? 0 : Number(r.totalMxn || 0),
metodoPago: r.metodoPago,
formaPago: r.formaPago,
usoCfdi: r.usoCfdi,
status: r.status,
fechaCertSat: r.fechaCertSat,
ivaTraslado: Number(r.ivaTraslado || 0),
ivaRetencion: Number(r.ivaRetencion || 0),
isrRetencion: Number(r.isrRetencion || 0),
conciliado: r.conciliado,
idConciliacion: r.idConciliacion,
conciliacion: r.conId ? {
id: r.conId,
fechaDePago: r.conFechaDePago,
banco: r.conBanco,
terminacionCuenta: r.conTerminacionCuenta,
} : null,
}));
}
export async function conciliar(
pool: Pool,
data: { cfdiIds: number[]; fechaDePago: string; idBanco: number },
tenantCreatedYear: number,
): Promise<number> {
const fechaPago = new Date(data.fechaDePago + 'T12:00:00');
const anio = String(fechaPago.getFullYear());
const mes = String(fechaPago.getMonth() + 1).padStart(2, '0');
if (fechaPago.getFullYear() < tenantCreatedYear) {
throw new Error(`Solo se puede conciliar del año ${tenantCreatedYear} en adelante`);
}
const { rows: bancoRows } = await pool.query(`SELECT id FROM bancos WHERE id = $1`, [data.idBanco]);
if (bancoRows.length === 0) throw new Error('Banco no encontrado');
const { rows: cfdis } = await pool.query(`
SELECT id, conciliado FROM cfdis
WHERE id = ANY($1) AND ${VIGENTE}
`, [data.cfdiIds]);
if (cfdis.length !== data.cfdiIds.length) {
throw new Error('Algunos CFDIs no existen o estan cancelados');
}
const yaConc = cfdis.filter((c: any) => c.conciliado === 'true');
if (yaConc.length > 0) {
throw new Error(`${yaConc.length} CFDIs ya estan conciliados`);
}
let count = 0;
for (const cfdiId of data.cfdiIds) {
const { rows: inserted } = await pool.query(`
INSERT INTO conciliaciones (anio, mes, id_cfdi, fecha_de_pago, id_banco)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, [anio, mes, cfdiId, data.fechaDePago, data.idBanco]);
await pool.query(`
UPDATE cfdis SET conciliado = 'true', id_conciliacion = $1 WHERE id = $2
`, [inserted[0].id, cfdiId]);
count++;
// Auto-conciliar PPD si esta factura tipo P lleva saldo pendiente a 0
await autoConciliarPpd(pool, cfdiId, anio, mes, data.fechaDePago, data.idBanco);
}
return count;
}
/**
* Cuando se concilia una factura tipo P con saldo_pendiente = 0,
* auto-concilia la factura PPD original relacionada con los mismos datos.
*/
async function autoConciliarPpd(
pool: Pool,
cfdiId: number,
anio: string,
mes: string,
fechaDePago: string,
idBanco: number,
): Promise<void> {
// Verificar si es tipo P con saldo pendiente 0
const { rows: pRows } = await pool.query(`
SELECT tipo_comprobante, uuid_relacionado, saldo_pendiente
FROM cfdis WHERE id = $1
`, [cfdiId]);
const pCfdi = pRows[0];
if (!pCfdi || pCfdi.tipo_comprobante !== 'P') return;
if (!pCfdi.uuid_relacionado) return;
const saldoPendiente = Number(pCfdi.saldo_pendiente || 0);
if (saldoPendiente !== 0) return;
// Buscar la factura PPD original por UUID
const { rows: ppdRows } = await pool.query(`
SELECT id, conciliado FROM cfdis
WHERE uuid = $1 AND metodo_pago = 'PPD' AND ${VIGENTE}
`, [pCfdi.uuid_relacionado]);
if (ppdRows.length === 0) return;
const ppd = ppdRows[0];
if (ppd.conciliado === 'true') return; // ya conciliada
// Auto-conciliar la PPD con los mismos datos
const { rows: inserted } = await pool.query(`
INSERT INTO conciliaciones (anio, mes, id_cfdi, fecha_de_pago, id_banco)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, [anio, mes, ppd.id, fechaDePago, idBanco]);
await pool.query(`
UPDATE cfdis SET conciliado = 'true', id_conciliacion = $1 WHERE id = $2
`, [inserted[0].id, ppd.id]);
}
export async function desconciliar(pool: Pool, conciliacionId: number): Promise<void> {
// Buscar la conciliacion y el CFDI asociado
const { rows } = await pool.query(`
SELECT con.id_cfdi, c.tipo_comprobante, c.uuid_relacionado
FROM conciliaciones con
JOIN cfdis c ON c.id = con.id_cfdi
WHERE con.id = $1
`, [conciliacionId]);
if (rows.length === 0) throw new Error('Conciliacion no encontrada');
const cfdi = rows[0];
// Desconciliar el CFDI principal
await pool.query(`UPDATE cfdis SET conciliado = NULL, id_conciliacion = NULL WHERE id_conciliacion = $1`, [conciliacionId]);
await pool.query(`DELETE FROM conciliaciones WHERE id = $1`, [conciliacionId]);
// Si es tipo P, también desconciliar la PPD auto-conciliada
if (cfdi.tipo_comprobante === 'P' && cfdi.uuid_relacionado) {
const { rows: ppdRows } = await pool.query(`
SELECT c.id_conciliacion FROM cfdis c
WHERE c.uuid = $1 AND c.conciliado = 'true' AND c.metodo_pago = 'PPD'
`, [cfdi.uuid_relacionado]);
if (ppdRows.length > 0 && ppdRows[0].id_conciliacion) {
const ppdConcId = ppdRows[0].id_conciliacion;
await pool.query(`UPDATE cfdis SET conciliado = NULL, id_conciliacion = NULL WHERE id_conciliacion = $1`, [ppdConcId]);
await pool.query(`DELETE FROM conciliaciones WHERE id = $1`, [ppdConcId]);
}
}
}