- 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
295 lines
9.4 KiB
TypeScript
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]);
|
|
}
|
|
}
|
|
}
|