feat(sat): factura global + fecha_efectiva, fallback tenant-contribuyente, fix anio_global typo
Factura Global & fecha_efectiva: - Migracion 045_factura_global.sql: periodicidad, meses_global, año_global, fecha_efectiva - sat-parser.service.ts: extrae InformacionGlobal del XML - sat.service.ts: calcFechaEfectiva con soporte bimestral (periodicidad 05) - metricas-compute, dashboard, impuestos, cfdi, export, conciliacion, alertas: reemplaza fecha_emision-1h por COALESCE(fecha_efectiva, fecha_emision-1h) - Script recalc-metricas.ts para recalculo manual Fallback datos fiscales tenant → contribuyente: - contribuyente.service.ts: fetchTenantFiscalData + mergeContribuyenteWithTenant rellena regimenFiscal, codigoPostal y domicilio cuando el contribuyente tiene el mismo RFC que el tenant y sus campos estan vacios - contribuyente.controller.ts y contribuyente-config.controller.ts: pasan req.user!.tenantId al servicio Fix critico SAT sync: - sat.service.ts: anio_global → año_global en INSERT/UPDATE de CFDIs (la migracion creo 'año_global' con tilde; el codigo usaba 'anio_global', causando fallo en 100% de inserciones de CFDI) - determineChunkMonths: salta sondeo si existe job previo con requestIds - MAX_POLL_ATTEMPTS: 45 → 500 (~8h) para syncs iniciales grandes Docs: - docs/sessions/2026-05-22-factura-global-contribuyente-fallback.md
This commit is contained in:
@@ -333,7 +333,7 @@ export async function getCancelados(req: Request, res: Response, next: NextFunct
|
||||
total_mxn as "totalMxn", fecha_cancelacion as "fechaCancelacion"
|
||||
FROM cfdis
|
||||
WHERE status IN ('Cancelado', '0')
|
||||
AND fecha_emision >= $1::date
|
||||
AND (fecha_emision - interval '1 hour') >= $1::date
|
||||
${cf}
|
||||
ORDER BY fecha_emision DESC
|
||||
`, [hace5.toISOString().split('T')[0]]);
|
||||
@@ -364,7 +364,7 @@ export async function getCancelacionesPeriodoAnterior(req: Request, res: Respons
|
||||
FROM cfdis
|
||||
WHERE status IN ('Cancelado', '0')
|
||||
AND fecha_cancelacion >= $1::date
|
||||
AND fecha_emision < $1::date
|
||||
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < $1::date
|
||||
${cf}
|
||||
ORDER BY fecha_cancelacion DESC
|
||||
`, [inicioMes]);
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function uploadFiel(req: Request, res: Response, next: NextFunction
|
||||
return next(new AppError(400, 'cerFile, keyFile y password son requeridos'));
|
||||
}
|
||||
const contribuyenteId = String(req.params.id);
|
||||
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId);
|
||||
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId, req.user!.tenantId);
|
||||
if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado'));
|
||||
|
||||
const result = await fielService.uploadFielContribuyente(req.tenantPool!, contribuyenteId, cerFile, keyFile, password);
|
||||
@@ -62,7 +62,7 @@ export async function deleteFiel(req: Request, res: Response, next: NextFunction
|
||||
export async function createOrg(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = String(req.params.id);
|
||||
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId);
|
||||
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId, req.user!.tenantId);
|
||||
if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado'));
|
||||
|
||||
const result = await facturapiService.createOrgContribuyente(req.tenantPool!, contribuyenteId, contrib.nombre);
|
||||
|
||||
@@ -40,14 +40,14 @@ const updateSchema = createSchema.partial();
|
||||
export async function list(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
|
||||
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds);
|
||||
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, req.user!.tenantId);
|
||||
return res.json({ data: rows });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function getById(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id));
|
||||
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id), req.user!.tenantId);
|
||||
if (!row) return next(new AppError(404, 'Contribuyente no encontrado'));
|
||||
return res.json(row);
|
||||
} catch (err) { return next(err); }
|
||||
|
||||
@@ -118,6 +118,10 @@ CREATE TABLE IF NOT EXISTS cfdis (
|
||||
facturapi_id VARCHAR(50),
|
||||
regimen_fiscal_emisor VARCHAR(3),
|
||||
regimen_fiscal_receptor VARCHAR(3),
|
||||
periodicidad VARCHAR(2),
|
||||
meses_global VARCHAR(10),
|
||||
año_global VARCHAR(4),
|
||||
fecha_efectiva DATE,
|
||||
creado_en TIMESTAMP DEFAULT NOW(),
|
||||
actualizado_en TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
11
apps/api/src/migrations/tenant/045_factura_global.sql
Normal file
11
apps/api/src/migrations/tenant/045_factura_global.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Migration: 007_factura_global
|
||||
-- Description: Agrega campos de InformacionGlobal y fecha_efectiva para facturas globales
|
||||
|
||||
ALTER TABLE cfdis
|
||||
ADD COLUMN IF NOT EXISTS periodicidad VARCHAR(2),
|
||||
ADD COLUMN IF NOT EXISTS meses_global VARCHAR(10),
|
||||
ADD COLUMN IF NOT EXISTS año_global VARCHAR(4),
|
||||
ADD COLUMN IF NOT EXISTS fecha_efectiva DATE;
|
||||
|
||||
-- Crear índice para acelerar métricas que filtran por fecha_efectiva
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_fecha_efectiva ON cfdis(fecha_efectiva);
|
||||
23
apps/api/src/scripts/recalc-metricas.ts
Normal file
23
apps/api/src/scripts/recalc-metricas.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { tenantDb } from '../config/database.js';
|
||||
import { computeMetricaMensual } from '../services/metricas-compute.service.js';
|
||||
|
||||
async function main() {
|
||||
const tenantId = 'c52c2f5d-b1ae-45c6-8cc8-b11c9611618a';
|
||||
const dbName = 'horux_hts240708lja';
|
||||
const contribuyenteId = '4a1d6014-f705-424b-b185-7740be6a80c6';
|
||||
const pool = await tenantDb.getPool(tenantId, dbName);
|
||||
|
||||
for (const mes of [1, 2, 3]) {
|
||||
console.log(`Recalculando 2026-${String(mes).padStart(2, '0')}...`);
|
||||
const r = await computeMetricaMensual(pool, tenantId, contribuyenteId, 2026, mes);
|
||||
console.log(` Filas escritas: ${r.filasEscritas}`);
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -176,7 +176,7 @@ async function alertaRiesgoCancelaciones(pool: Pool, contribuyenteId?: string |
|
||||
COUNT(*)::int as total,
|
||||
COUNT(CASE WHEN status IN ('Cancelado', '0') THEN 1 END)::int as cancelados
|
||||
FROM cfdis
|
||||
WHERE fecha_emision >= $1::date
|
||||
WHERE (fecha_emision - interval '1 hour') >= $1::date
|
||||
${cf}
|
||||
`, [fechaDesde]);
|
||||
|
||||
@@ -359,7 +359,7 @@ async function alertaCancelacionPeriodoAnterior(pool: Pool, contribuyenteId?: st
|
||||
FROM cfdis
|
||||
WHERE status IN ('Cancelado', '0')
|
||||
AND fecha_cancelacion >= $1::date
|
||||
AND fecha_emision < $1::date
|
||||
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < $1::date
|
||||
${cf}
|
||||
`, [inicioMes]);
|
||||
|
||||
@@ -529,7 +529,7 @@ async function alertaResicoPfLimiteIngresos(
|
||||
FROM cfdis
|
||||
WHERE type = 'EMITIDO'
|
||||
AND status NOT IN ('Cancelado', '0')
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(YEAR FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour')) = $1
|
||||
AND contribuyente_id = $2
|
||||
`, [año, safeId]);
|
||||
|
||||
@@ -659,8 +659,8 @@ export async function getDiscrepanciasPorMes(
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
EXTRACT(YEAR FROM fecha_emision)::int as año,
|
||||
EXTRACT(MONTH FROM fecha_emision)::int as mes,
|
||||
EXTRACT(YEAR FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'))::int as año,
|
||||
EXTRACT(MONTH FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'))::int as mes,
|
||||
COUNT(*)::int as count
|
||||
FROM cfdis
|
||||
WHERE type = 'RECIBIDO' AND ${VIGENTE}
|
||||
|
||||
@@ -102,12 +102,12 @@ export async function getCfdis(pool: Pool, filters: CfdiFilters): Promise<CfdiLi
|
||||
}
|
||||
|
||||
if (filters.fechaInicio) {
|
||||
whereClause += ` AND fecha_emision >= $${paramIndex++}::date`;
|
||||
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
|
||||
params.push(filters.fechaInicio);
|
||||
}
|
||||
|
||||
if (filters.fechaFin) {
|
||||
whereClause += ` AND fecha_emision <= ($${paramIndex++}::date + interval '1 day')`;
|
||||
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
|
||||
params.push(filters.fechaFin);
|
||||
}
|
||||
|
||||
@@ -214,11 +214,11 @@ export async function getConceptosList(
|
||||
params.push(filters.estado);
|
||||
}
|
||||
if (filters.fechaInicio) {
|
||||
whereClause += ` AND c.fecha_emision >= $${paramIndex++}::date`;
|
||||
whereClause += ` AND COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
|
||||
params.push(filters.fechaInicio);
|
||||
}
|
||||
if (filters.fechaFin) {
|
||||
whereClause += ` AND c.fecha_emision <= ($${paramIndex++}::date + interval '1 day')`;
|
||||
whereClause += ` AND COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
|
||||
params.push(filters.fechaFin);
|
||||
}
|
||||
if (filters.rfc) {
|
||||
@@ -746,7 +746,10 @@ export async function getReceptores(pool: Pool, search: string, limit: number =
|
||||
}
|
||||
|
||||
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`;
|
||||
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}'))`;
|
||||
@@ -761,7 +764,7 @@ export async function getResumenCfdis(pool: Pool, año: number, mes: number, con
|
||||
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')]);
|
||||
`, [fi, ff]);
|
||||
|
||||
const r = rows[0];
|
||||
return {
|
||||
|
||||
@@ -68,11 +68,11 @@ export async function getCfdisConConciliacion(
|
||||
}
|
||||
|
||||
if (filters.fechaInicio) {
|
||||
where += ` AND COALESCE(c.fecha_pago_p, c.fecha_emision) >= $${idx++}::date`;
|
||||
where += ` AND CASE WHEN c.tipo_comprobante = 'P' THEN c.fecha_pago_p - interval '1 hour' ELSE COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') END >= $${idx++}::date`;
|
||||
params.push(filters.fechaInicio);
|
||||
}
|
||||
if (filters.fechaFin) {
|
||||
where += ` AND COALESCE(c.fecha_pago_p, c.fecha_emision) <= ($${idx++}::date + interval '1 day')`;
|
||||
where += ` AND CASE WHEN c.tipo_comprobante = 'P' THEN c.fecha_pago_p - interval '1 hour' ELSE COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') END <= ($${idx++}::date + interval '1 day')`;
|
||||
params.push(filters.fechaFin);
|
||||
}
|
||||
if (filters.regimen) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
export interface CreateContribuyenteData {
|
||||
rfc: string;
|
||||
@@ -23,7 +24,61 @@ export interface ContribuyenteRow {
|
||||
domicilio: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export async function listContribuyentes(pool: Pool, entidadIds?: string[]): Promise<ContribuyenteRow[]> {
|
||||
async function fetchTenantFiscalData(tenantId: string) {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: {
|
||||
rfc: true,
|
||||
codigoPostal: true,
|
||||
calle: true,
|
||||
numExterior: true,
|
||||
numInterior: true,
|
||||
colonia: true,
|
||||
ciudad: true,
|
||||
municipio: true,
|
||||
estado: true,
|
||||
telefono: true,
|
||||
},
|
||||
});
|
||||
if (!tenant) return null;
|
||||
|
||||
const regimenes = await prisma.tenantRegimenActivo.findMany({
|
||||
where: { tenantId },
|
||||
select: { regimen: { select: { clave: true } } },
|
||||
});
|
||||
const regimenFiscal = regimenes.map(r => r.regimen.clave).join(',') || null;
|
||||
|
||||
const hasAnyAddress = tenant.calle || tenant.colonia || tenant.ciudad || tenant.municipio || tenant.estado || tenant.codigoPostal;
|
||||
const domicilio = hasAnyAddress
|
||||
? {
|
||||
calle: tenant.calle || '',
|
||||
numExterior: tenant.numExterior || '',
|
||||
numInterior: tenant.numInterior || '',
|
||||
colonia: tenant.colonia || '',
|
||||
ciudad: tenant.ciudad || '',
|
||||
municipio: tenant.municipio || '',
|
||||
estado: tenant.estado || '',
|
||||
codigoPostal: tenant.codigoPostal || '',
|
||||
telefono: tenant.telefono || '',
|
||||
}
|
||||
: null;
|
||||
|
||||
return { tenantRfc: tenant.rfc, regimenFiscal, codigoPostal: tenant.codigoPostal, domicilio };
|
||||
}
|
||||
|
||||
function mergeContribuyenteWithTenant(
|
||||
row: ContribuyenteRow,
|
||||
tenantData: NonNullable<Awaited<ReturnType<typeof fetchTenantFiscalData>>>
|
||||
): ContribuyenteRow {
|
||||
return {
|
||||
...row,
|
||||
regimenFiscal: row.regimenFiscal || tenantData.regimenFiscal,
|
||||
codigoPostal: row.codigoPostal || tenantData.codigoPostal,
|
||||
domicilio: row.domicilio || tenantData.domicilio,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listContribuyentes(pool: Pool, entidadIds?: string[], tenantId?: string): Promise<ContribuyenteRow[]> {
|
||||
let query = `
|
||||
SELECT
|
||||
e.id, e.tipo, e.nombre, e.identificador,
|
||||
@@ -45,10 +100,20 @@ export async function listContribuyentes(pool: Pool, entidadIds?: string[]): Pro
|
||||
|
||||
query += ' ORDER BY e.created_at DESC';
|
||||
const { rows } = await pool.query(query, params);
|
||||
return rows;
|
||||
|
||||
if (!tenantId) return rows;
|
||||
|
||||
const tenantData = await fetchTenantFiscalData(tenantId);
|
||||
if (!tenantData) return rows;
|
||||
|
||||
return rows.map((r: ContribuyenteRow) => {
|
||||
if (r.rfc !== tenantData.tenantRfc) return r;
|
||||
if (r.regimenFiscal && r.codigoPostal && r.domicilio) return r;
|
||||
return mergeContribuyenteWithTenant(r, tenantData);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getContribuyenteById(pool: Pool, id: string): Promise<ContribuyenteRow | null> {
|
||||
export async function getContribuyenteById(pool: Pool, id: string, tenantId?: string): Promise<ContribuyenteRow | null> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
e.id, e.tipo, e.nombre, e.identificador,
|
||||
@@ -60,7 +125,14 @@ export async function getContribuyenteById(pool: Pool, id: string): Promise<Cont
|
||||
JOIN contribuyentes c ON c.entidad_id = e.id
|
||||
WHERE e.id = $1
|
||||
`, [id]);
|
||||
return rows[0] ?? null;
|
||||
const row = rows[0] ?? null;
|
||||
if (!row || !tenantId) return row;
|
||||
|
||||
const tenantData = await fetchTenantFiscalData(tenantId);
|
||||
if (!tenantData || row.rfc !== tenantData.tenantRfc) return row;
|
||||
if (row.regimenFiscal && row.codigoPostal && row.domicilio) return row;
|
||||
|
||||
return mergeContribuyenteWithTenant(row, tenantData);
|
||||
}
|
||||
|
||||
export async function createContribuyente(pool: Pool, data: CreateContribuyenteData): Promise<ContribuyenteRow> {
|
||||
|
||||
@@ -109,13 +109,13 @@ export const GRUPO_PM_OTROS = ['601', '603', '607', '608', '610', '611', '614',
|
||||
const TODOS_REGIMENES = [...GRUPO_PF_EMPRESARIAL, ...GRUPO_SUELDOS, ...GRUPO_PM_OTROS];
|
||||
|
||||
// Filtro de fecha por rango — normal o conciliación
|
||||
const FECHA_RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`;
|
||||
const FECHA_RANGO = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
|
||||
// Para CFDIs tipo P (complementos de pago): el ingreso/gasto se reconoce en la
|
||||
// fecha_pago_p (cuándo el cliente realmente pagó), no cuando se emitió el
|
||||
// complemento — el CFDI P puede emitirse hasta el día 5 del mes siguiente al
|
||||
// pago, o incluso después, y cruzar meses (ej. pago de noviembre 2024 con
|
||||
// complemento emitido en mayo 2025).
|
||||
const FECHA_PAGO_RANGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`;
|
||||
const FECHA_PAGO_RANGO = `(fecha_pago_p - interval '1 hour') >= $1::date AND (fecha_pago_p - interval '1 hour') < ($2::date + interval '1 day')`;
|
||||
const FECHA_RANGO_CONCILIACION = `id_conciliacion IS NOT NULL AND id_conciliacion IN (
|
||||
SELECT id FROM conciliaciones WHERE fecha_de_pago >= $1::date AND fecha_de_pago < ($2::date + interval '1 day')
|
||||
)`;
|
||||
@@ -989,14 +989,14 @@ export async function calcularIvaBalancePorRegimen(
|
||||
AND e.status NOT IN ('Cancelado', '0')
|
||||
AND ${esEmisor.replace(/\brfc_emisor\b/g, 'e.rfc_emisor')}
|
||||
AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||
AND date_trunc('month', e.fecha_emision) = date_trunc('month', i.fecha_emision)
|
||||
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour')) = date_trunc('month', COALESCE(i.fecha_efectiva, i.fecha_emision - interval '1 hour'))
|
||||
)), 0) as monto
|
||||
FROM cfdis i
|
||||
WHERE ${esEmisor.replace(/\brfc_emisor\b/g, 'i.rfc_emisor')}
|
||||
AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD'
|
||||
AND COALESCE(i.cfdi_tipo_relacion, '') = '07'
|
||||
AND i.status NOT IN ('Cancelado','0')
|
||||
AND ${FR.replace(/\bfecha_emision\b/g, 'i.fecha_emision')}
|
||||
AND ${FR.replace(/\bfecha_efectiva\b/g, 'i.fecha_efectiva').replace(/\bfecha_emision\b/g, 'i.fecha_emision')}
|
||||
AND i.regimen_fiscal_emisor = ANY($3)
|
||||
GROUP BY i.regimen_fiscal_emisor
|
||||
`, [fechaInicio, fechaFin, TODOS_REGIMENES]);
|
||||
@@ -1012,14 +1012,14 @@ export async function calcularIvaBalancePorRegimen(
|
||||
AND e.status NOT IN ('Cancelado', '0')
|
||||
AND ${esReceptor.replace(/\brfc_receptor\b/g, 'e.rfc_receptor')}
|
||||
AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||
AND date_trunc('month', e.fecha_emision) = date_trunc('month', i.fecha_emision)
|
||||
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour')) = date_trunc('month', COALESCE(i.fecha_efectiva, i.fecha_emision - interval '1 hour'))
|
||||
)), 0) as monto
|
||||
FROM cfdis i
|
||||
WHERE ${esReceptor.replace(/\brfc_receptor\b/g, 'i.rfc_receptor')}
|
||||
AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD'
|
||||
AND COALESCE(i.cfdi_tipo_relacion, '') = '07'
|
||||
AND i.status NOT IN ('Cancelado','0')
|
||||
AND ${FR.replace(/\bfecha_emision\b/g, 'i.fecha_emision')}
|
||||
AND ${FR.replace(/\bfecha_efectiva\b/g, 'i.fecha_efectiva').replace(/\bfecha_emision\b/g, 'i.fecha_emision')}
|
||||
AND i.regimen_fiscal_receptor = ANY($3)
|
||||
GROUP BY i.regimen_fiscal_receptor
|
||||
`, [fechaInicio, fechaFin, TODOS_REGIMENES]);
|
||||
|
||||
@@ -18,11 +18,11 @@ export async function exportCfdisToExcel(
|
||||
params.push(filters.estado);
|
||||
}
|
||||
if (filters.fechaInicio) {
|
||||
whereClause += ` AND fecha_emision >= $${paramIndex++}`;
|
||||
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}`;
|
||||
params.push(filters.fechaInicio);
|
||||
}
|
||||
if (filters.fechaFin) {
|
||||
whereClause += ` AND fecha_emision <= $${paramIndex++}`;
|
||||
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= $${paramIndex++}`;
|
||||
params.push(filters.fechaFin);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export async function exportCfdisToExcel(
|
||||
cfdis.forEach((cfdi: any) => {
|
||||
sheet.addRow({
|
||||
...cfdi,
|
||||
fecha_emision: new Date(cfdi.fecha_emision).toLocaleDateString('es-MX'),
|
||||
fecha_emision: new Date(new Date(cfdi.fecha_emision).getTime() - 60*60*1000).toLocaleDateString('es-MX'),
|
||||
subtotal: Number(cfdi.subtotal),
|
||||
subtotal_mxn: Number(cfdi.subtotal_mxn),
|
||||
iva_traslado: Number(cfdi.iva_traslado),
|
||||
@@ -103,7 +103,7 @@ export async function exportReporteToExcel(
|
||||
COALESCE(SUM(CASE WHEN type = 'EMITIDO' AND tipo_comprobante = 'I' THEN subtotal_mxn ELSE 0 END), 0) as ingresos,
|
||||
COALESCE(SUM(CASE WHEN type = 'RECIBIDO' AND tipo_comprobante = 'I' THEN subtotal_mxn ELSE 0 END), 0) as egresos
|
||||
FROM cfdis
|
||||
WHERE status NOT IN ('Cancelado', '0') AND fecha_emision BETWEEN $1 AND $2
|
||||
WHERE status NOT IN ('Cancelado', '0') AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') BETWEEN $1 AND $2
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
sheet.columns = [
|
||||
|
||||
@@ -22,7 +22,7 @@ const VIGENTE = `status NOT IN ('Cancelado', '0')`;
|
||||
// - otros tipos (I, E, T, N): fecha_emision del comprobante
|
||||
// El CASE se evalúa por fila, garantizando que un P emitido en mayo por un pago
|
||||
// real de noviembre quede contabilizado en noviembre.
|
||||
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN fecha_pago_p ELSE fecha_emision END`;
|
||||
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN (fecha_pago_p - interval '1 hour') ELSE COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') END`;
|
||||
const FECHA_RANGO = `${FECHA_EFECTIVA} >= $1::date AND ${FECHA_EFECTIVA} < ($2::date + interval '1 day')`;
|
||||
const FECHA_RANGO_CONCILIACION = `id_conciliacion IS NOT NULL AND id_conciliacion IN (
|
||||
SELECT id FROM conciliaciones WHERE fecha_de_pago >= $1::date AND fecha_de_pago < ($2::date + interval '1 day')
|
||||
@@ -114,8 +114,8 @@ const SUM_E_REFERENCING_TRAS = (
|
||||
AND e.status NOT IN ('Cancelado', '0')
|
||||
AND ${esLadoE}
|
||||
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||
AND date_trunc('month', e.fecha_emision)
|
||||
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
|
||||
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour'))
|
||||
= date_trunc('month', COALESCE(cfdis.fecha_efectiva, cfdis.fecha_emision - interval '1 hour'))${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
|
||||
), 0)`;
|
||||
const SUM_E_REFERENCING_RET = (
|
||||
esLadoE: string,
|
||||
@@ -129,8 +129,8 @@ const SUM_E_REFERENCING_RET = (
|
||||
AND e.status NOT IN ('Cancelado', '0')
|
||||
AND ${esLadoE}
|
||||
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||
AND date_trunc('month', e.fecha_emision)
|
||||
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
|
||||
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour'))
|
||||
= date_trunc('month', COALESCE(cfdis.fecha_efectiva, cfdis.fecha_emision - interval '1 hour'))${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
|
||||
), 0)`;
|
||||
// Régimen del contribuyente según su lado: emisor/receptor del CFDI.
|
||||
// Usa el RFC del contribuyente (via `ctx.esEmisor`/`ctx.esReceptor`) para
|
||||
|
||||
@@ -92,8 +92,8 @@ export async function computeMetricaMensual(
|
||||
COUNT(*) FILTER (WHERE status = 'Vigente') AS vigentes,
|
||||
COUNT(*) FILTER (WHERE status IN ('Cancelado','0')) AS cancelados
|
||||
FROM cfdis
|
||||
WHERE EXTRACT(YEAR FROM (CASE WHEN tipo_comprobante='P' THEN fecha_pago_p ELSE fecha_emision END)) = $1
|
||||
AND EXTRACT(MONTH FROM (CASE WHEN tipo_comprobante='P' THEN fecha_pago_p ELSE fecha_emision END)) = $2
|
||||
WHERE EXTRACT(YEAR FROM (CASE WHEN tipo_comprobante='P' THEN (fecha_pago_p - interval '1 hour') ELSE COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') END)) = $1
|
||||
AND EXTRACT(MONTH FROM (CASE WHEN tipo_comprobante='P' THEN (fecha_pago_p - interval '1 hour') ELSE COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') END)) = $2
|
||||
AND contribuyente_id = $3
|
||||
GROUP BY 1, 2
|
||||
`, [anio, mes, safeContrib]);
|
||||
@@ -227,7 +227,7 @@ export async function backfillTenant(
|
||||
|
||||
for (const c of contribs) {
|
||||
const { rows: [rango] } = await pool.query<{ min_anio: number | null }>(
|
||||
`SELECT EXTRACT(YEAR FROM MIN(fecha_emision))::int AS min_anio
|
||||
`SELECT EXTRACT(YEAR FROM MIN(fecha_emision - interval '1 hour'))::int AS min_anio
|
||||
FROM cfdis WHERE contribuyente_id = $1`,
|
||||
[c.entidad_id],
|
||||
);
|
||||
|
||||
@@ -94,12 +94,12 @@ export async function getFlujoEfectivo(
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<FlujoEfectivo> {
|
||||
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
|
||||
const RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`;
|
||||
const RANGO_PAGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`;
|
||||
const RANGO = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
|
||||
const RANGO_PAGO = `(fecha_pago_p - interval '1 hour') >= $1::date AND (fecha_pago_p - interval '1 hour') < ($2::date + interval '1 day')`;
|
||||
const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId);
|
||||
|
||||
const { rows: entradasPUE } = await pool.query(`
|
||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
||||
SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
||||
FROM cfdis
|
||||
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
|
||||
AND ${VIGENTE} AND ${RANGO}
|
||||
@@ -107,7 +107,7 @@ export async function getFlujoEfectivo(
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const { rows: entradasPago } = await pool.query(`
|
||||
SELECT TO_CHAR(fecha_pago_p, 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
|
||||
SELECT TO_CHAR(fecha_pago_p - interval '1 hour', 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
|
||||
FROM cfdis
|
||||
WHERE ${esEmisor} AND tipo_comprobante = 'P'
|
||||
AND ${VIGENTE} AND ${RANGO_PAGO}
|
||||
@@ -115,7 +115,7 @@ export async function getFlujoEfectivo(
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const { rows: entradasNC } = await pool.query(`
|
||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
||||
SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
||||
FROM cfdis
|
||||
WHERE ${esEmisor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
|
||||
AND COALESCE(cfdi_tipo_relacion, '') <> '07'
|
||||
@@ -124,7 +124,7 @@ export async function getFlujoEfectivo(
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const { rows: salidasPUE } = await pool.query(`
|
||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
||||
SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
||||
FROM cfdis
|
||||
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
|
||||
AND ${VIGENTE} AND ${RANGO}
|
||||
@@ -132,7 +132,7 @@ export async function getFlujoEfectivo(
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const { rows: salidasPago } = await pool.query(`
|
||||
SELECT TO_CHAR(fecha_pago_p, 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
|
||||
SELECT TO_CHAR(fecha_pago_p - interval '1 hour', 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
|
||||
FROM cfdis
|
||||
WHERE ${esReceptor} AND tipo_comprobante = 'P'
|
||||
AND ${VIGENTE} AND ${RANGO_PAGO}
|
||||
@@ -140,7 +140,7 @@ export async function getFlujoEfectivo(
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const { rows: salidasNC } = await pool.query(`
|
||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
||||
SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
||||
FROM cfdis
|
||||
WHERE ${esReceptor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
|
||||
AND COALESCE(cfdi_tipo_relacion, '') <> '07'
|
||||
@@ -187,8 +187,8 @@ async function calcularFlujoPorMes(pool: Pool, año: number, contribuyenteId?: s
|
||||
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
|
||||
const fi = `${año}-01-01`;
|
||||
const ff = `${año}-12-31`;
|
||||
const RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`;
|
||||
const RANGO_PAGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`;
|
||||
const RANGO = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
|
||||
const RANGO_PAGO = `(fecha_pago_p - interval '1 hour') >= $1::date AND (fecha_pago_p - interval '1 hour') < ($2::date + interval '1 day')`;
|
||||
const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId);
|
||||
|
||||
const q = async (lado: 'EMITIDO' | 'RECIBIDO', tc: string, campo: string, mp?: string) => {
|
||||
@@ -198,7 +198,7 @@ async function calcularFlujoPorMes(pool: Pool, año: number, contribuyenteId?: s
|
||||
const noAnticipo = tc === 'E' ? `AND COALESCE(cfdi_tipo_relacion, '') <> '07'` : '';
|
||||
const ladoCond = lado === 'EMITIDO' ? esEmisor : esReceptor;
|
||||
const { rows } = await pool.query(`
|
||||
SELECT EXTRACT(MONTH FROM ${fechaCol})::int as mes, COALESCE(SUM(${campo}), 0) as total
|
||||
SELECT EXTRACT(MONTH FROM COALESCE(fecha_efectiva, ${fechaCol} - interval '1 hour'))::int as mes, COALESCE(SUM(${campo}), 0) as total
|
||||
FROM cfdis
|
||||
WHERE ${ladoCond} AND tipo_comprobante = '${tc}' ${mpF} ${noAnticipo} AND ${VIGENTE} AND ${rango}
|
||||
GROUP BY mes
|
||||
@@ -277,7 +277,7 @@ export async function getConcentradoRfc(
|
||||
COUNT(*)::int as "cantidadCfdis"
|
||||
FROM cfdis
|
||||
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0')
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') BETWEEN $1::date AND $2::date
|
||||
GROUP BY rfc_receptor, nombre_receptor
|
||||
ORDER BY "totalFacturado" DESC
|
||||
`, [fechaInicio, fechaFin]);
|
||||
@@ -298,7 +298,7 @@ export async function getConcentradoRfc(
|
||||
COUNT(*)::int as "cantidadCfdis"
|
||||
FROM cfdis
|
||||
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0')
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') BETWEEN $1::date AND $2::date
|
||||
GROUP BY rfc_emisor, nombre_emisor
|
||||
ORDER BY "totalFacturado" DESC
|
||||
`, [fechaInicio, fechaFin]);
|
||||
@@ -338,8 +338,8 @@ export async function getCuentasXPagar(
|
||||
FROM cfdis
|
||||
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PPD'
|
||||
AND status NOT IN ('Cancelado', '0')
|
||||
AND fecha_emision >= $1::date
|
||||
AND fecha_emision < ($2::date + interval '1 day')
|
||||
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date
|
||||
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')
|
||||
AND COALESCE(saldo_pendiente_mxn, total_mxn) > 0.01
|
||||
${regimenFilter}
|
||||
GROUP BY rfc_emisor, nombre_emisor
|
||||
@@ -365,7 +365,7 @@ export async function getCuentasXPagar(
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const VIGENTE_ER = `status NOT IN ('Cancelado', '0')`;
|
||||
const RANGO_FECHA = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`;
|
||||
const RANGO_FECHA = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
|
||||
const BASE_MONTO = `COALESCE(subtotal_mxn, 0) - COALESCE(descuento_mxn, 0)`;
|
||||
|
||||
function sameDateLastYear(dateStr: string): string {
|
||||
@@ -842,8 +842,8 @@ export async function getCuentasXCobrar(
|
||||
FROM cfdis
|
||||
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PPD'
|
||||
AND status NOT IN ('Cancelado', '0')
|
||||
AND fecha_emision >= $1::date
|
||||
AND fecha_emision < ($2::date + interval '1 day')
|
||||
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date
|
||||
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')
|
||||
AND COALESCE(saldo_pendiente_mxn, total_mxn) > 0.01
|
||||
${regimenFilter}
|
||||
GROUP BY rfc_receptor, nombre_receptor
|
||||
|
||||
@@ -69,6 +69,11 @@ interface CfdiParsed {
|
||||
cfdisRelacionados: string | null;
|
||||
conceptos: ConceptoParsed[];
|
||||
xmlOriginal: string;
|
||||
|
||||
// Factura global (InformacionGlobal)
|
||||
periodicidad: string | null;
|
||||
mesesGlobal: string | null;
|
||||
añoGlobal: string | null;
|
||||
}
|
||||
|
||||
interface ConceptoParsed {
|
||||
@@ -569,6 +574,9 @@ export function parseXml(xmlContent: string, downloadType: 'emitidos' | 'recibid
|
||||
...nominaData,
|
||||
conceptos: extractConceptos(comprobante),
|
||||
xmlOriginal: xmlContent,
|
||||
periodicidad: comprobante.InformacionGlobal?.['@_Periodicidad'] || null,
|
||||
mesesGlobal: comprobante.InformacionGlobal?.['@_Meses'] || null,
|
||||
añoGlobal: comprobante.InformacionGlobal?.['@_Año'] || null,
|
||||
};
|
||||
|
||||
if (!cfdi.uuid) {
|
||||
|
||||
@@ -18,7 +18,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const POLL_INTERVAL_MS = 60000; // 60 segundos
|
||||
const MAX_POLL_ATTEMPTS = 45; // 45 minutos máximo (45 × 60s)
|
||||
const MAX_POLL_ATTEMPTS = 500; // ~8 horas máximo para syncs iniciales grandes
|
||||
const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años
|
||||
|
||||
/**
|
||||
@@ -121,6 +121,35 @@ async function getOrCreateRfc(pool: Pool, rfc: string, razonSocial: string | nul
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula la fecha efectiva de un CFDI para métricas.
|
||||
* Si tiene InformacionGlobal, usa el año/mes declarado.
|
||||
* Para bimestral (periodicidad 05), convierte el código 13-18 a mes 2-12.
|
||||
*/
|
||||
function calcFechaEfectiva(cfdi: CfdiParsed): Date | null {
|
||||
if (!cfdi.añoGlobal || !cfdi.mesesGlobal) {
|
||||
return null;
|
||||
}
|
||||
const anio = parseInt(cfdi.añoGlobal, 10);
|
||||
if (isNaN(anio)) return null;
|
||||
|
||||
const mesesStr = cfdi.mesesGlobal;
|
||||
const mesesParts = mesesStr.split(',').map((s: string) => s.trim());
|
||||
const ultimoMesStr = mesesParts[mesesParts.length - 1];
|
||||
let mes = parseInt(ultimoMesStr, 10);
|
||||
if (isNaN(mes)) return null;
|
||||
|
||||
// Bimestral: códigos 13-18 → meses 2,4,6,8,10,12
|
||||
if (cfdi.periodicidad === '05') {
|
||||
if (mes >= 13 && mes <= 18) {
|
||||
mes = (mes - 12) * 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (mes < 1 || mes > 12) return null;
|
||||
return new Date(anio, mes - 1, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarda los XMLs extraídos del ZIP en disco para respaldo
|
||||
*/
|
||||
@@ -212,6 +241,10 @@ async function saveCfdis(
|
||||
cfdi.subsidioCausado, m(cfdi.subsidioCausado),
|
||||
cfdi.regimenFiscalEmisor, cfdi.regimenFiscalReceptor,
|
||||
cfdi.codigoPostalReceptor,
|
||||
cfdi.periodicidad,
|
||||
cfdi.mesesGlobal,
|
||||
cfdi.añoGlobal,
|
||||
calcFechaEfectiva(cfdi),
|
||||
cfdi.xmlOriginal,
|
||||
cfdi.cfdiTipoRelacion, cfdi.cfdisRelacionados,
|
||||
jobId,
|
||||
@@ -261,16 +294,17 @@ async function saveCfdis(
|
||||
subsidio_causado=$78, subsidio_causado_mxn=$79,
|
||||
regimen_fiscal_emisor=$80, regimen_fiscal_receptor=$81,
|
||||
codigo_postal_receptor=$82,
|
||||
xml_original=$83,
|
||||
cfdi_tipo_relacion=$84, cfdis_relacionados=$85,
|
||||
last_sat_sync=NOW(), sat_sync_job_id=$86::uuid,
|
||||
periodicidad=$83, meses_global=$84, año_global=$85, fecha_efectiva=$86,
|
||||
xml_original=$87,
|
||||
cfdi_tipo_relacion=$88, cfdis_relacionados=$89,
|
||||
last_sat_sync=NOW(), sat_sync_job_id=$90::uuid,
|
||||
actualizado_en=NOW()
|
||||
WHERE uuid = $1`,
|
||||
[cfdi.uuid, ...vals]
|
||||
);
|
||||
// Re-insert conceptos for updated CFDI
|
||||
await pool.query(`DELETE FROM cfdi_conceptos WHERE cfdi_id = $1`, [existing[0].id]);
|
||||
await saveConceptos(pool, existing[0].id, cfdi);
|
||||
await saveConceptosWithRetry(pool, existing[0].id, cfdi);
|
||||
updated++;
|
||||
} else {
|
||||
// $1-$83 = data fields (year..cfdis_relacionados), $84 = jobId, $85 = contribuyente_id
|
||||
@@ -310,6 +344,7 @@ async function saveCfdis(
|
||||
subsidio_causado, subsidio_causado_mxn,
|
||||
regimen_fiscal_emisor, regimen_fiscal_receptor,
|
||||
codigo_postal_receptor,
|
||||
periodicidad, meses_global, año_global, fecha_efectiva,
|
||||
xml_original,
|
||||
cfdi_tipo_relacion, cfdis_relacionados,
|
||||
source, sat_sync_job_id, last_sat_sync, contribuyente_id
|
||||
@@ -321,7 +356,7 @@ async function saveCfdis(
|
||||
);
|
||||
// Get the inserted cfdi id and save conceptos
|
||||
const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE uuid = $1`, [cfdi.uuid]);
|
||||
if (newRow) await saveConceptos(pool, newRow.id, cfdi);
|
||||
if (newRow) await saveConceptosWithRetry(pool, newRow.id, cfdi);
|
||||
inserted++;
|
||||
}
|
||||
// Marcar el mes para recompute de métricas pre-calculadas. Para tipo P
|
||||
@@ -404,6 +439,26 @@ async function saveConceptos(pool: Pool, cfdiId: number, cfdi: CfdiParsed): Prom
|
||||
}
|
||||
}
|
||||
|
||||
/** Reintenta saveConceptos con backoff exponencial para tolerar errores transitorios. */
|
||||
async function saveConceptosWithRetry(pool: Pool, cfdiId: number, cfdi: CfdiParsed, maxRetries = 3): Promise<void> {
|
||||
let lastError: any;
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await saveConceptos(pool, cfdiId, cfdi);
|
||||
return;
|
||||
} catch (err: any) {
|
||||
lastError = err;
|
||||
if (attempt < maxRetries) {
|
||||
const delay = 500 * attempt;
|
||||
console.warn(`[SAT] saveConceptos falló (intento ${attempt}/${maxRetries}) para CFDI ${cfdi.uuid}, reintentando en ${delay}ms...`);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
console.error(`[SAT] saveConceptos falló definitivamente después de ${maxRetries} intentos para CFDI ${cfdi.uuid}:`, lastError?.message || lastError);
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarda/actualiza CFDIs desde metadata del SAT.
|
||||
* - Si el CFDI no existe: inserta con datos básicos de metadata (sin XML).
|
||||
@@ -770,6 +825,26 @@ async function determineChunkMonths(
|
||||
fechaInicio: Date,
|
||||
fechaFin: Date,
|
||||
): Promise<number> {
|
||||
// Si el job previo del mismo tenant/contribuyente ya tenía chunks,
|
||||
// inferimos que el volumen es alto y usamos 6 meses directamente
|
||||
// para evitar el sondeo lento del SAT.
|
||||
const previousJob = await prisma.satSyncJob.findFirst({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
contribuyenteId: ctx.contribuyenteId ?? null,
|
||||
id: { not: jobId },
|
||||
status: 'completed',
|
||||
cfdisFound: { gt: 0 },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { satRequestIds: true, cfdisFound: true },
|
||||
});
|
||||
if (previousJob?.satRequestIds && Object.keys(previousJob.satRequestIds as Record<string, string>).length > 0) {
|
||||
const chunkMonths = (previousJob.cfdisFound || 0) > 15_000 ? 3 : 6;
|
||||
console.log(`[SAT] Reutilizando estrategia de job previo (${previousJob.cfdisFound} CFDIs) → bloques de ${chunkMonths} meses`);
|
||||
return chunkMonths;
|
||||
}
|
||||
|
||||
const THRESHOLD = 15_000;
|
||||
let totalCfdis = 0;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { formatCurrency, toCfdiDate } from '@/lib/utils';
|
||||
import { exportToExcel } from '@/lib/export-excel';
|
||||
import { useTableSort } from '@horux/shared-ui';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
@@ -27,8 +27,8 @@ const EXCEL_COLUMNS = [
|
||||
function prepareRows(data: any[]) {
|
||||
return data.map((c) => ({
|
||||
...c,
|
||||
_fechaEmision: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_fechaCancelacion: c.fechaCancelacion ? new Date(c.fechaCancelacion).toLocaleDateString('es-MX') : '',
|
||||
_fechaEmision: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_fechaCancelacion: c.fechaCancelacion ? toCfdiDate(c.fechaCancelacion).toLocaleDateString('es-MX') : '',
|
||||
_totalMxn: Number(c.totalMxn || 0),
|
||||
}));
|
||||
}
|
||||
@@ -50,8 +50,8 @@ export default function CancelacionesPeriodoAnteriorPage() {
|
||||
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'cancelacion' | 'total'>(
|
||||
data,
|
||||
{
|
||||
fecha: (c) => new Date(c.fechaEmision).getTime(),
|
||||
cancelacion: (c: any) => c.fechaCancelacion ? new Date(c.fechaCancelacion).getTime() : 0,
|
||||
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
|
||||
cancelacion: (c: any) => c.fechaCancelacion ? toCfdiDate(c.fechaCancelacion).getTime() : 0,
|
||||
total: (c) => Number(c.totalMxn || 0),
|
||||
},
|
||||
'cancelacion',
|
||||
@@ -97,8 +97,8 @@ export default function CancelacionesPeriodoAnteriorPage() {
|
||||
{(sortedData || []).map((cfdi: any) => (
|
||||
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
|
||||
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
|
||||
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
|
||||
<td className="py-3">{cfdi.fechaCancelacion ? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}</td>
|
||||
<td className="py-3">{toCfdiDate(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
|
||||
<td className="py-3">{cfdi.fechaCancelacion ? toCfdiDate(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}</td>
|
||||
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-3 font-mono text-xs">{cfdi.rfcReceptor}</td>
|
||||
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { formatCurrency, toCfdiDate } from '@/lib/utils';
|
||||
import { exportToExcel } from '@/lib/export-excel';
|
||||
import { useTableSort } from '@horux/shared-ui';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
@@ -26,8 +26,8 @@ const EXCEL_COLUMNS = [
|
||||
function prepareRows(data: any[]) {
|
||||
return data.map((c) => ({
|
||||
...c,
|
||||
_fechaEmision: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_fechaCancelacion: c.fechaCancelacion ? new Date(c.fechaCancelacion).toLocaleDateString('es-MX') : '',
|
||||
_fechaEmision: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_fechaCancelacion: c.fechaCancelacion ? toCfdiDate(c.fechaCancelacion).toLocaleDateString('es-MX') : '',
|
||||
_totalMxn: Number(c.totalMxn || 0),
|
||||
}));
|
||||
}
|
||||
@@ -46,8 +46,8 @@ export default function CancelacionesPage() {
|
||||
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'cancelacion' | 'total'>(
|
||||
data,
|
||||
{
|
||||
fecha: (c) => new Date(c.fechaEmision).getTime(),
|
||||
cancelacion: (c: any) => c.fechaCancelacion ? new Date(c.fechaCancelacion).getTime() : 0,
|
||||
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
|
||||
cancelacion: (c: any) => c.fechaCancelacion ? toCfdiDate(c.fechaCancelacion).getTime() : 0,
|
||||
total: (c) => Number(c.totalMxn || 0),
|
||||
},
|
||||
'cancelacion',
|
||||
@@ -91,8 +91,8 @@ export default function CancelacionesPage() {
|
||||
{(sortedData || []).map((cfdi: any) => (
|
||||
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
|
||||
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
|
||||
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
|
||||
<td className="py-3">{cfdi.fechaCancelacion ? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}</td>
|
||||
<td className="py-3">{toCfdiDate(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
|
||||
<td className="py-3">{cfdi.fechaCancelacion ? toCfdiDate(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}</td>
|
||||
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-3 font-mono text-xs">{cfdi.rfcReceptor}</td>
|
||||
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button, SortableHeader, Input } from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { formatCurrency, toCfdiDate } from '@/lib/utils';
|
||||
import { exportToExcel } from '@/lib/export-excel';
|
||||
import { useTableSort } from '@horux/shared-ui';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
@@ -29,7 +29,7 @@ const EXCEL_COLUMNS = [
|
||||
function prepareRows(data: any[]) {
|
||||
return data.map((c) => ({
|
||||
...c,
|
||||
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_fecha: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_totalMxn: Number(c.totalMxn || 0),
|
||||
regimenReceptor: c.regimenReceptor || c.regimenFiscalReceptor || '',
|
||||
}));
|
||||
@@ -91,10 +91,10 @@ export default function DiscrepanciaRegimenPage() {
|
||||
let filtered = data;
|
||||
|
||||
if (fechaDesde) {
|
||||
filtered = filtered.filter(c => c.fechaEmision >= fechaDesde);
|
||||
filtered = filtered.filter(c => toCfdiDate(c.fechaEmision).toISOString() >= fechaDesde);
|
||||
}
|
||||
if (fechaHasta) {
|
||||
filtered = filtered.filter(c => c.fechaEmision <= fechaHasta + 'T23:59:59');
|
||||
filtered = filtered.filter(c => toCfdiDate(c.fechaEmision).toISOString() <= fechaHasta + 'T23:59:59');
|
||||
}
|
||||
if (regimenFilter) {
|
||||
filtered = filtered.filter((c: any) => (c.regimenReceptor || c.regimenFiscalReceptor) === regimenFilter);
|
||||
@@ -106,7 +106,7 @@ export default function DiscrepanciaRegimenPage() {
|
||||
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total'>(
|
||||
visibleData,
|
||||
{
|
||||
fecha: (c) => new Date(c.fechaEmision).getTime(),
|
||||
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
|
||||
total: (c) => Number(c.totalMxn || 0),
|
||||
},
|
||||
'fecha',
|
||||
@@ -311,7 +311,7 @@ export default function DiscrepanciaRegimenPage() {
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
|
||||
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
|
||||
<td className="py-3">{toCfdiDate(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
|
||||
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-3 truncate max-w-[200px]">{cfdi.nombreEmisor}</td>
|
||||
<td className="py-3 font-mono font-bold text-destructive">{cfdi.regimenReceptor}</td>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { formatCurrency, toCfdiDate } from '@/lib/utils';
|
||||
import { exportToExcel } from '@/lib/export-excel';
|
||||
import { useTableSort } from '@horux/shared-ui';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
@@ -26,7 +26,7 @@ const EXCEL_COLUMNS = [
|
||||
function prepareRows(data: any[]) {
|
||||
return data.map((c) => ({
|
||||
...c,
|
||||
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_fecha: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_totalMxn: Number(c.totalMxn || 0),
|
||||
}));
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export default function EfectivoPage() {
|
||||
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total'>(
|
||||
data,
|
||||
{
|
||||
fecha: (c) => new Date(c.fechaEmision).getTime(),
|
||||
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
|
||||
total: (c) => Number(c.totalMxn || 0),
|
||||
},
|
||||
'fecha',
|
||||
@@ -89,7 +89,7 @@ export default function EfectivoPage() {
|
||||
{(sortedData || []).map((cfdi: any) => (
|
||||
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
|
||||
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
|
||||
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
|
||||
<td className="py-3">{toCfdiDate(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
|
||||
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-3 truncate max-w-[200px]">{cfdi.nombreEmisor}</td>
|
||||
<td className="py-3 font-mono text-xs">{cfdi.rfcReceptor}</td>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button, SortableHeader, Input } from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { formatCurrency, toCfdiDate } from '@/lib/utils';
|
||||
import { exportToExcel } from '@/lib/export-excel';
|
||||
import { useTableSort } from '@horux/shared-ui';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
@@ -30,7 +30,7 @@ const EXCEL_COLUMNS = [
|
||||
function prepareRows(data: any[]) {
|
||||
return data.map((c) => ({
|
||||
...c,
|
||||
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_fecha: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_totalMxn: Number(c.totalMxn || 0),
|
||||
}));
|
||||
}
|
||||
@@ -83,8 +83,8 @@ export default function TipoRelacionSospechosaPage() {
|
||||
const visibleData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
let filtered = data;
|
||||
if (fechaDesde) filtered = filtered.filter(c => c.fechaEmision >= fechaDesde);
|
||||
if (fechaHasta) filtered = filtered.filter(c => c.fechaEmision <= fechaHasta + 'T23:59:59');
|
||||
if (fechaDesde) filtered = filtered.filter(c => toCfdiDate(c.fechaEmision).toISOString() >= fechaDesde);
|
||||
if (fechaHasta) filtered = filtered.filter(c => toCfdiDate(c.fechaEmision).toISOString() <= fechaHasta + 'T23:59:59');
|
||||
if (tipoRelFilter) filtered = filtered.filter((c: any) => c.cfdiTipoRelacion === tipoRelFilter);
|
||||
return filtered;
|
||||
}, [data, fechaDesde, fechaHasta, tipoRelFilter]);
|
||||
@@ -92,7 +92,7 @@ export default function TipoRelacionSospechosaPage() {
|
||||
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total'>(
|
||||
visibleData,
|
||||
{
|
||||
fecha: (c) => new Date(c.fechaEmision).getTime(),
|
||||
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
|
||||
total: (c) => Number(c.totalMxn || 0),
|
||||
},
|
||||
'fecha',
|
||||
@@ -296,7 +296,7 @@ export default function TipoRelacionSospechosaPage() {
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
|
||||
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
|
||||
<td className="py-3">{toCfdiDate(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
|
||||
<td className="py-3 truncate max-w-[180px]">
|
||||
<div className="font-mono text-xs">{cfdi.rfcEmisor}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{cfdi.nombreEmisor}</div>
|
||||
|
||||
@@ -421,7 +421,7 @@ export default function CfdiPage() {
|
||||
}
|
||||
|
||||
const exportData = allRows.map(cfdi => ({
|
||||
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
|
||||
'Fecha Emisión': formatCfdiDate(cfdi.fechaEmision),
|
||||
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
|
||||
'Uso CFDI': (cfdi as any).usoCfdi || '',
|
||||
'Serie': cfdi.serie || '',
|
||||
@@ -442,9 +442,7 @@ export default function CfdiPage() {
|
||||
// vacío en Excel para no confundir "0 = pagado" con "no aplica".
|
||||
'Saldo Pendiente': cfdi.saldoPendienteMxn ?? '',
|
||||
'Estatus': cfdi.status === 'Vigente' || cfdi.status === '1' ? 'Vigente' : 'Cancelado',
|
||||
'Fecha Cancelación': cfdi.fechaCancelacion
|
||||
? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX')
|
||||
: '',
|
||||
'Fecha Cancelación': formatCfdiDate(cfdi.fechaCancelacion),
|
||||
'UUID': cfdi.uuid,
|
||||
}));
|
||||
|
||||
@@ -509,7 +507,7 @@ export default function CfdiPage() {
|
||||
if (key.endsWith('_mxn') || key === 'id' || key === 'cfdi_id') continue;
|
||||
// Formatear fecha si aplica
|
||||
if (key === 'fechaEmision' && typeof val === 'string') {
|
||||
out['Fecha Emisión'] = new Date(val).toLocaleDateString('es-MX');
|
||||
out['Fecha Emisión'] = formatCfdiDate(val);
|
||||
} else {
|
||||
out[key] = val;
|
||||
}
|
||||
@@ -539,7 +537,7 @@ export default function CfdiPage() {
|
||||
|
||||
const exportSingleCfdiToExcel = (cfdi: Cfdi) => {
|
||||
const row = {
|
||||
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
|
||||
'Fecha Emisión': formatCfdiDate(cfdi.fechaEmision),
|
||||
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
|
||||
'Uso CFDI': (cfdi as any).usoCfdi || '',
|
||||
'Serie': cfdi.serie || '',
|
||||
@@ -560,9 +558,7 @@ export default function CfdiPage() {
|
||||
// vacío en Excel para no confundir "0 = pagado" con "no aplica".
|
||||
'Saldo Pendiente': cfdi.saldoPendienteMxn ?? '',
|
||||
'Estatus': cfdi.status === 'Vigente' || cfdi.status === '1' ? 'Vigente' : 'Cancelado',
|
||||
'Fecha Cancelación': cfdi.fechaCancelacion
|
||||
? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX')
|
||||
: '',
|
||||
'Fecha Cancelación': formatCfdiDate(cfdi.fechaCancelacion),
|
||||
'UUID': cfdi.uuid,
|
||||
};
|
||||
|
||||
@@ -935,12 +931,22 @@ export default function CfdiPage() {
|
||||
currency: 'MXN',
|
||||
}).format(value);
|
||||
|
||||
const formatDate = (dateString: string) =>
|
||||
new Date(dateString).toLocaleDateString('es-MX', {
|
||||
const formatDate = (dateString: string) => {
|
||||
const d = new Date(dateString);
|
||||
d.setHours(d.getHours() - 1);
|
||||
return d.toLocaleDateString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatCfdiDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return '-';
|
||||
const d = new Date(dateString);
|
||||
d.setHours(d.getHours() - 1);
|
||||
return d.toLocaleDateString('es-MX');
|
||||
};
|
||||
|
||||
const generateUUID = () => {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
@@ -1697,7 +1703,7 @@ export default function CfdiPage() {
|
||||
<tbody className="text-sm text-center">
|
||||
{conceptosQuery.data.data.map((row, idx) => (
|
||||
<tr key={`${row.cfdi_id}-${row.id}-${idx}`} className="border-b hover:bg-muted/50">
|
||||
<td className="py-2">{new Date(row.fechaEmision).toLocaleDateString('es-MX')}</td>
|
||||
<td className="py-2">{formatCfdiDate(row.fechaEmision)}</td>
|
||||
<td className="py-2 font-mono text-xs" title={row.uuid}>{row.uuid?.substring(0, 8) || '-'}</td>
|
||||
<td className="py-2 font-mono text-xs">{row.clave_prod_serv || '-'}</td>
|
||||
<td className="py-2 text-left max-w-[280px] truncate" title={row.descripcion}>{row.descripcion}</td>
|
||||
|
||||
@@ -7,9 +7,9 @@ import { useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
|
||||
import { PeriodSelector, RegimenSelector } from '@horux/shared-ui';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { Card, CardContent, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Input } from '@horux/shared-ui';
|
||||
import { Card, CardContent, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Input, Label, Popover, PopoverTrigger, PopoverContent } from '@horux/shared-ui';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { formatCurrency, toCfdiDate } from '@/lib/utils';
|
||||
|
||||
function formatCurrencyConciliacion(value: number): string {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
@@ -20,7 +20,7 @@ function formatCurrencyConciliacion(value: number): string {
|
||||
}).format(value);
|
||||
}
|
||||
import { exportToExcel } from '@/lib/export-excel';
|
||||
import { Eye, Download, X, CheckCircle } from 'lucide-react';
|
||||
import { Eye, Download, X, CheckCircle, Search, ArrowUpDown, Filter } from 'lucide-react';
|
||||
|
||||
function getMonthRange(year: number, month: number) {
|
||||
const start = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
@@ -42,6 +42,20 @@ export default function ConciliacionPage() {
|
||||
const [bancoId, setBancoId] = useState<string>('');
|
||||
const [selectedCfdi, setSelectedCfdi] = useState<any>(null);
|
||||
|
||||
// Ordenación — Por conciliar
|
||||
const [sortPendientes, setSortPendientes] = useState<{ field: 'fecha' | 'total'; dir: 'asc' | 'desc' } | null>(null);
|
||||
|
||||
// Ordenación — Conciliadas
|
||||
const [sortConciliadas, setSortConciliadas] = useState<{ field: 'fecha' | 'total'; dir: 'asc' | 'desc' } | null>(null);
|
||||
|
||||
// Filtros por columna — Por conciliar
|
||||
const [filtersPendientes, setFiltersPendientes] = useState({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
|
||||
const [openFilterPendientes, setOpenFilterPendientes] = useState<string | null>(null);
|
||||
|
||||
// Filtros por columna — Conciliadas
|
||||
const [filtersConciliadas, setFiltersConciliadas] = useState({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
|
||||
const [openFilterConciliadas, setOpenFilterConciliadas] = useState<string | null>(null);
|
||||
|
||||
const { user } = useAuthStore();
|
||||
const isVisor = user?.role === 'visor';
|
||||
|
||||
@@ -66,9 +80,15 @@ export default function ConciliacionPage() {
|
||||
const montoConciliado = conciliadas.reduce((s, c) => s + getMonto(c), 0);
|
||||
const montoPendiente = pendientes.reduce((s, c) => s + getMonto(c), 0);
|
||||
|
||||
// Reset selection on tab/filter change
|
||||
// Reset selection + ordenación + filtros on tab/filter change
|
||||
useEffect(() => {
|
||||
setSelected(new Set());
|
||||
setSortPendientes(null);
|
||||
setSortConciliadas(null);
|
||||
setFiltersPendientes({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
|
||||
setOpenFilterPendientes(null);
|
||||
setFiltersConciliadas({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
|
||||
setOpenFilterConciliadas(null);
|
||||
}, [activeTab, fechaInicio, fechaFin, regimenSeleccionado]);
|
||||
|
||||
// Handlers
|
||||
@@ -85,10 +105,10 @@ export default function ConciliacionPage() {
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selected.size === pendientes.length && pendientes.length > 0) {
|
||||
if (selected.size === pendientesOrdenados.length && pendientesOrdenados.length > 0) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(pendientes.map((c) => c.id)));
|
||||
setSelected(new Set(pendientesOrdenados.map((c) => c.id)));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -117,12 +137,100 @@ export default function ConciliacionPage() {
|
||||
}
|
||||
};
|
||||
|
||||
function matchesColumnFilters(c: any, filters: { rfcEmisor: string; nombreEmisor: string; rfcReceptor: string; nombreReceptor: string }) {
|
||||
const rfcEmisorMatch = !filters.rfcEmisor || (c.rfcEmisor || '').toLowerCase().includes(filters.rfcEmisor.toLowerCase());
|
||||
const nombreEmisorMatch = !filters.nombreEmisor || (c.nombreEmisor || '').toLowerCase().includes(filters.nombreEmisor.toLowerCase());
|
||||
const rfcReceptorMatch = !filters.rfcReceptor || (c.rfcReceptor || '').toLowerCase().includes(filters.rfcReceptor.toLowerCase());
|
||||
const nombreReceptorMatch = !filters.nombreReceptor || (c.nombreReceptor || '').toLowerCase().includes(filters.nombreReceptor.toLowerCase());
|
||||
return rfcEmisorMatch && nombreEmisorMatch && rfcReceptorMatch && nombreReceptorMatch;
|
||||
}
|
||||
|
||||
function sortCfdis(list: any[], sort: { field: 'fecha' | 'total'; dir: 'asc' | 'desc' } | null) {
|
||||
if (!sort) return list;
|
||||
const sorted = [...list].sort((a, b) => {
|
||||
if (sort.field === 'fecha') {
|
||||
const da = toCfdiDate(a.fechaPagoP || a.fechaEmision).getTime();
|
||||
const db = toCfdiDate(b.fechaPagoP || b.fechaEmision).getTime();
|
||||
return sort.dir === 'asc' ? da - db : db - da;
|
||||
}
|
||||
if (sort.field === 'total') {
|
||||
const ta = getMonto(a);
|
||||
const tb = getMonto(b);
|
||||
return sort.dir === 'asc' ? ta - tb : tb - ta;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function FilterHeader({
|
||||
label,
|
||||
filterKey,
|
||||
filters,
|
||||
setFilters,
|
||||
openFilter,
|
||||
setOpenFilter,
|
||||
}: {
|
||||
label: string;
|
||||
filterKey: string;
|
||||
filters: { rfcEmisor: string; nombreEmisor: string; rfcReceptor: string; nombreReceptor: string };
|
||||
setFilters: React.Dispatch<React.SetStateAction<{ rfcEmisor: string; nombreEmisor: string; rfcReceptor: string; nombreReceptor: string }>>;
|
||||
openFilter: string | null;
|
||||
setOpenFilter: (v: string | null) => void;
|
||||
}) {
|
||||
const hasFilter = !!(filters as any)[filterKey];
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{label}
|
||||
<Popover open={openFilter === filterKey} onOpenChange={(open) => setOpenFilter(open ? filterKey : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${hasFilter ? 'text-primary' : ''}`}>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="start">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Filtrar por {label}</h4>
|
||||
<div>
|
||||
<Label className="text-xs">Contiene</Label>
|
||||
<Input
|
||||
placeholder={`Buscar ${label.toLowerCase()}...`}
|
||||
className="h-8 text-sm"
|
||||
value={(filters as any)[filterKey]}
|
||||
onChange={(e) => setFilters((prev: any) => ({ ...prev, [filterKey]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => setOpenFilter(null)}>Aplicar</Button>
|
||||
{hasFilter && (
|
||||
<Button size="sm" variant="outline" onClick={() => { setFilters((prev: any) => ({ ...prev, [filterKey]: '' })); setOpenFilter(null); }}>
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pendientesOrdenados = sortCfdis(
|
||||
pendientes.filter((c) => matchesColumnFilters(c, filtersPendientes)),
|
||||
sortPendientes
|
||||
);
|
||||
const conciliadasOrdenadas = sortCfdis(
|
||||
conciliadas.filter((c) => matchesColumnFilters(c, filtersConciliadas)),
|
||||
sortConciliadas
|
||||
);
|
||||
|
||||
const handleExport = () => {
|
||||
if (!cfdis?.length) return;
|
||||
const allVisible = [...pendientesOrdenados, ...conciliadasOrdenadas];
|
||||
if (!allVisible.length) return;
|
||||
exportToExcel(
|
||||
cfdis.map((c) => ({
|
||||
allVisible.map((c) => ({
|
||||
...c,
|
||||
_fecha: new Date(c.fechaPagoP || c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_fecha: toCfdiDate(c.fechaPagoP || c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_totalMxn: getMonto(c),
|
||||
_estado: c.conciliado === 'true' ? 'Conciliado' : 'Pendiente',
|
||||
_fechaPago: c.conciliacion?.fechaDePago || '',
|
||||
@@ -212,8 +320,8 @@ export default function ConciliacionPage() {
|
||||
{/* Por conciliar */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-medium mb-4">Por conciliar ({pendientes.length})</h3>
|
||||
{pendientes.length === 0 ? (
|
||||
<h3 className="font-medium mb-4">Por conciliar ({pendientesOrdenados.length})</h3>
|
||||
{pendientesOrdenados.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No hay CFDIs pendientes de conciliar
|
||||
</p>
|
||||
@@ -221,31 +329,35 @@ export default function ConciliacionPage() {
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<tr className="border-b text-center text-muted-foreground">
|
||||
{!isVisor && (
|
||||
<th className="pb-3 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
selected.size === pendientes.length && pendientes.length > 0
|
||||
selected.size === pendientesOrdenados.length && pendientesOrdenados.length > 0
|
||||
}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th className="pb-3 font-medium">UUID</th>
|
||||
<th className="pb-3 font-medium">Fecha</th>
|
||||
<th className="pb-3 font-medium">RFC Emisor</th>
|
||||
<th className="pb-3 font-medium">Nombre Emisor</th>
|
||||
<th className="pb-3 font-medium">RFC Receptor</th>
|
||||
<th className="pb-3 font-medium">Nombre Receptor</th>
|
||||
<th className="pb-3 font-medium text-right">Total MXN</th>
|
||||
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortPendientes(prev => prev?.field === 'fecha' ? { field: 'fecha', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'fecha', dir: 'asc' })}>
|
||||
<span className="flex items-center justify-center gap-1">Fecha <ArrowUpDown className="h-3 w-3" /></span>
|
||||
</th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="RFC Emisor" filterKey="rfcEmisor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} /></th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="Nombre Emisor" filterKey="nombreEmisor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} /></th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="RFC Receptor" filterKey="rfcReceptor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} /></th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="Nombre Receptor" filterKey="nombreReceptor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} /></th>
|
||||
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortPendientes(prev => prev?.field === 'total' ? { field: 'total', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'total', dir: 'asc' })}>
|
||||
<span className="flex items-center justify-center gap-1">Total MXN <ArrowUpDown className="h-3 w-3" /></span>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">M. Pago</th>
|
||||
<th className="pb-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pendientes.map((cfdi) => (
|
||||
{pendientesOrdenados.map((cfdi) => (
|
||||
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
|
||||
{!isVisor && (
|
||||
<td className="py-2">
|
||||
@@ -256,25 +368,25 @@ export default function ConciliacionPage() {
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>
|
||||
<td className="py-2 font-mono text-xs text-center" title={cfdi.uuid}>
|
||||
{cfdi.uuid?.substring(0, 8)}
|
||||
</td>
|
||||
<td className="py-2 text-xs">
|
||||
{new Date(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
|
||||
<td className="py-2 text-xs text-center">
|
||||
{toCfdiDate(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[120px]">
|
||||
<td className="py-2 font-mono text-xs text-center">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[120px] text-center">
|
||||
{cfdi.nombreEmisor}
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs">{cfdi.rfcReceptor}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[120px]">
|
||||
<td className="py-2 font-mono text-xs text-center">{cfdi.rfcReceptor}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[120px] text-center">
|
||||
{cfdi.nombreReceptor}
|
||||
</td>
|
||||
<td className="py-2 text-right text-xs font-medium">
|
||||
<td className="py-2 text-xs font-medium text-center">
|
||||
{formatCurrencyConciliacion(getMonto(cfdi))}
|
||||
</td>
|
||||
<td className="py-2 text-xs">{cfdi.metodoPago || '-'}</td>
|
||||
<td className="py-2">
|
||||
<td className="py-2 text-xs text-center">{cfdi.metodoPago || '-'}</td>
|
||||
<td className="py-2 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -340,53 +452,57 @@ export default function ConciliacionPage() {
|
||||
{/* Conciliadas */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-medium mb-4">Conciliadas ({conciliadas.length})</h3>
|
||||
{conciliadas.length === 0 ? (
|
||||
<h3 className="font-medium mb-4">Conciliadas ({conciliadasOrdenadas.length})</h3>
|
||||
{conciliadasOrdenadas.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No hay CFDIs conciliados</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<tr className="border-b text-center text-muted-foreground">
|
||||
<th className="pb-3 font-medium">UUID</th>
|
||||
<th className="pb-3 font-medium">Fecha Emisión</th>
|
||||
<th className="pb-3 font-medium">RFC Emisor</th>
|
||||
<th className="pb-3 font-medium">Nombre Emisor</th>
|
||||
<th className="pb-3 font-medium text-right">Total MXN</th>
|
||||
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortConciliadas(prev => prev?.field === 'fecha' ? { field: 'fecha', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'fecha', dir: 'asc' })}>
|
||||
<span className="flex items-center justify-center gap-1">Fecha Emisión <ArrowUpDown className="h-3 w-3" /></span>
|
||||
</th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="RFC Emisor" filterKey="rfcEmisor" filters={filtersConciliadas} setFilters={setFiltersConciliadas} openFilter={openFilterConciliadas} setOpenFilter={setOpenFilterConciliadas} /></th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="Nombre Emisor" filterKey="nombreEmisor" filters={filtersConciliadas} setFilters={setFiltersConciliadas} openFilter={openFilterConciliadas} setOpenFilter={setOpenFilterConciliadas} /></th>
|
||||
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortConciliadas(prev => prev?.field === 'total' ? { field: 'total', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'total', dir: 'asc' })}>
|
||||
<span className="flex items-center justify-center gap-1">Total MXN <ArrowUpDown className="h-3 w-3" /></span>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">Fecha Pago</th>
|
||||
<th className="pb-3 font-medium">Banco</th>
|
||||
<th className="pb-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{conciliadas.map((cfdi) => (
|
||||
{conciliadasOrdenadas.map((cfdi) => (
|
||||
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
|
||||
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>
|
||||
<td className="py-2 font-mono text-xs text-center" title={cfdi.uuid}>
|
||||
{cfdi.uuid?.substring(0, 8)}
|
||||
</td>
|
||||
<td className="py-2 text-xs">
|
||||
{new Date(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
|
||||
<td className="py-2 text-xs text-center">
|
||||
{toCfdiDate(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[120px]">
|
||||
<td className="py-2 font-mono text-xs text-center">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[120px] text-center">
|
||||
{cfdi.nombreEmisor}
|
||||
</td>
|
||||
<td className="py-2 text-right text-xs font-medium">
|
||||
<td className="py-2 text-xs font-medium text-center">
|
||||
{formatCurrencyConciliacion(getMonto(cfdi))}
|
||||
</td>
|
||||
<td className="py-2 text-xs">
|
||||
<td className="py-2 text-xs text-center">
|
||||
{cfdi.conciliacion?.fechaDePago
|
||||
? new Date(
|
||||
cfdi.conciliacion.fechaDePago + 'T12:00:00',
|
||||
(cfdi.conciliacion.fechaDePago.split('T')[0]) + 'T12:00:00',
|
||||
).toLocaleDateString('es-MX')
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="py-2 text-xs">
|
||||
<td className="py-2 text-xs text-center">
|
||||
{cfdi.conciliacion
|
||||
? `${cfdi.conciliacion.banco} ****${cfdi.conciliacion.terminacionCuenta}`
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="py-2 flex gap-1">
|
||||
<td className="py-2 flex gap-1 justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, Button, SortableHeader, cn } from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { formatCurrency, toCfdiDate } from '@/lib/utils';
|
||||
import { exportToExcel } from '@/lib/export-excel';
|
||||
import { useTableSort } from '@horux/shared-ui';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
@@ -33,7 +33,7 @@ const EXCEL_COLUMNS = [
|
||||
function prepareRows(data: any[]) {
|
||||
return data.map((c) => ({
|
||||
...c,
|
||||
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_fecha: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_totalMxn: Number(c.totalMxn || 0),
|
||||
_montoPagoMxn: Number(c.montoPagoMxn || 0),
|
||||
_ivaMxn: Number(c.ivaTrasladoMxn || 0),
|
||||
@@ -69,7 +69,7 @@ export default function DrillDownPage() {
|
||||
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total' | 'pago' | 'iva'>(
|
||||
data,
|
||||
{
|
||||
fecha: (c) => new Date(c.fechaEmision).getTime(),
|
||||
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
|
||||
total: (c) => Number(c.totalMxn || 0),
|
||||
pago: (c) => Number(c.montoPagoMxn || 0),
|
||||
iva: (c) => Number(c.ivaTrasladoMxn || 0),
|
||||
@@ -142,7 +142,7 @@ export default function DrillDownPage() {
|
||||
<tr key={cfdi.id} className={cn('border-b hover:bg-muted/50', isNC && 'bg-red-50/50 dark:bg-red-950/20')}>
|
||||
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>{cfdi.uuid?.substring(0, 8)}</td>
|
||||
<td className={cn('py-2 text-xs font-mono', isNC && 'text-red-600 dark:text-red-400 font-semibold')} title={isNC ? 'Nota de crédito — resta del total' : undefined}>{cfdi.tipoComprobante}</td>
|
||||
<td className="py-2 text-xs">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
|
||||
<td className="py-2 text-xs">{toCfdiDate(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
|
||||
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[120px]" title={cfdi.nombreEmisor}>{cfdi.nombreEmisor}</td>
|
||||
<td className="py-2 font-mono text-xs">{cfdi.rfcReceptor}</td>
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { InvoiceData, InvoiceLineItem, RfcSearchResult, CfdiPpdPendiente, C
|
||||
import { searchRfcs, getCfdisPpd, searchConceptos, getCfdisRelacionables, downloadPdf, downloadXml } from '@/lib/api/facturacion';
|
||||
import { Plus, Trash2, Send, Receipt, Search, Check, X, FileSearch, AlertTriangle } from 'lucide-react';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
import { toCfdiDate } from '@/lib/utils';
|
||||
|
||||
interface TaxLine {
|
||||
category: 'traslado' | 'retencion';
|
||||
@@ -843,7 +844,7 @@ export default function FacturacionPage() {
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{cp.tipoCfdi === 'EMITIDO' ? cp.nombreReceptor : cp.nombreEmisor}
|
||||
{' · '}
|
||||
{new Date(cp.fechaEmision).toLocaleDateString('es-MX')}
|
||||
{toCfdiDate(cp.fechaEmision).toLocaleDateString('es-MX')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-3">
|
||||
@@ -1237,7 +1238,7 @@ export default function FacturacionPage() {
|
||||
<span className="font-bold">${Number(c.saldoPendiente).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{new Date(c.fechaEmision).toLocaleDateString('es-MX')} · Total: ${Number(c.totalMxn).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
{toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX')} · Total: ${Number(c.totalMxn).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -1540,7 +1541,7 @@ export default function FacturacionPage() {
|
||||
<span className="px-1.5 py-0.5 rounded bg-muted">{c.tipoComprobante}</span>
|
||||
{c.serie || c.folio ? <span>{c.serie || ''}{c.folio ? `-${c.folio}` : ''}</span> : null}
|
||||
<span>${Number(c.totalMxn).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
||||
<span>{new Date(c.fechaEmision).toLocaleDateString('es-MX')}</span>
|
||||
<span>{toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX')}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Button,
|
||||
} from '@horux/shared-ui';
|
||||
import { useEstadoResultadosDrillDown } from '@/lib/hooks/use-reportes';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { formatCurrency, toCfdiDate } from '@/lib/utils';
|
||||
import { exportToExcel } from '@/lib/export-excel';
|
||||
import { ArrowLeft, Download, Eye } from 'lucide-react';
|
||||
import type { DrillDownResumenItem, DrillDownCfdiItem } from '@/lib/api/reportes';
|
||||
@@ -92,7 +92,7 @@ export function EstadoResultadosDrillDownModal({
|
||||
} else if (cfdis.length > 0) {
|
||||
const rows = cfdis.map((c) => ({
|
||||
...c,
|
||||
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_fecha: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_monto: c.monto,
|
||||
}));
|
||||
exportToExcel(rows, CFDI_COLUMNS, `drill-down-${categoria}-cfdis`);
|
||||
@@ -197,7 +197,7 @@ export function EstadoResultadosDrillDownModal({
|
||||
</td>
|
||||
<td className="py-3 text-sm font-mono">{item.tipoComprobante}</td>
|
||||
<td className="py-3 text-sm">
|
||||
{new Date(item.fechaEmision).toLocaleDateString('es-MX')}
|
||||
{toCfdiDate(item.fechaEmision).toLocaleDateString('es-MX')}
|
||||
</td>
|
||||
<td className="py-3 font-mono text-sm">{item.rfcEmisor}</td>
|
||||
<td className="py-3 text-sm truncate max-w-[180px]">
|
||||
|
||||
@@ -24,21 +24,27 @@ const formatCurrency = (value: number) =>
|
||||
currency: 'MXN',
|
||||
}).format(value);
|
||||
|
||||
const formatDate = (dateString: string) =>
|
||||
new Date(dateString).toLocaleDateString('es-MX', {
|
||||
const formatDate = (dateString: string) => {
|
||||
const d = new Date(dateString);
|
||||
d.setHours(d.getHours() - 1);
|
||||
return d.toLocaleDateString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString: string) =>
|
||||
new Date(dateString).toLocaleString('es-MX', {
|
||||
const formatDateTime = (dateString: string) => {
|
||||
const d = new Date(dateString);
|
||||
d.setHours(d.getHours() - 1);
|
||||
return d.toLocaleString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
EMITIDO: 'Emitido',
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Wallet, Calendar, AlertTriangle, CheckCircle2, Trash2, RotateCcw,
|
||||
Building2, TrendingUp, Clock, CircleSlash, Filter,
|
||||
} from 'lucide-react';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { formatCurrency, toCfdiDate } from '@/lib/utils';
|
||||
|
||||
interface ActivoFijoItem {
|
||||
cfdiId: number;
|
||||
@@ -238,7 +238,7 @@ export function ActivosFijosTab({ año, mes }: { año: number; mes: number }) {
|
||||
return (
|
||||
<tr key={a.cfdiId} className="border-b hover:bg-muted/30">
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
{new Date(a.fechaEmision).toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
{toCfdiDate(a.fechaEmision).toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-mono text-xs">{a.rfcEmisor}</div>
|
||||
|
||||
@@ -6,3 +6,11 @@ export function formatCurrency(value: number): string {
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/** Ajusta una fecha CFDI restando 1 hora para corregir el offset del SAT/Facturapi. */
|
||||
export function toCfdiDate(dateString: string | null | undefined): Date {
|
||||
if (!dateString) return new Date(0);
|
||||
const d = new Date(dateString);
|
||||
d.setHours(d.getHours() - 1);
|
||||
return d;
|
||||
}
|
||||
|
||||
236
docs/sessions/2026-05-04-business-control-prueba-trial.md
Normal file
236
docs/sessions/2026-05-04-business-control-prueba-trial.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# Sesión: Trial Business Control Prueba (Invitación desde Admin Global)
|
||||
|
||||
**Fecha:** 2026-05-04
|
||||
**Feature:** Sistema de invitaciones de trial configurable para plan Business Control
|
||||
|
||||
---
|
||||
|
||||
## 1. Requerimiento
|
||||
|
||||
Crear un trial específico para el plan **Business Control**, llamado "Business Control Prueba", con las siguientes características:
|
||||
|
||||
1. **Registro simplificado**: Los nuevos usuarios se registran sin escoger plan. Todos empiezan con el trial genérico actual (30 días, 3 RFCs, 1 usuario, MANAGED).
|
||||
2. **Invitación desde admin global**: El administrador global puede enviar una invitación a un tenant para activar "Business Control Prueba".
|
||||
3. **Periodo gratuito configurable**: El admin define cuántos días dura la prueba (1-365 días).
|
||||
4. **Todas las features de Business Control**: Durante el trial, el tenant tiene 100 RFCs, usuarios ilimitados, API, SAT incremental, etc.
|
||||
5. **Al vencer**: El tenant debe pagar la suscripción de Business Control para continuar.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decisiones Arquitectónicas
|
||||
|
||||
### 2.1 No nuevo enum Plan
|
||||
No se agregó `business_control_trial` al enum `Plan`. En su lugar:
|
||||
- `tenant.plan` se actualiza a `business_control` al aceptar la invitación.
|
||||
- `subscription.plan` = `business_control`, `subscription.status` = `'trial'`.
|
||||
- `tenant.trialEndsAt` se recalcula según los días configurados.
|
||||
|
||||
**Ventaja**: El feature-gate (`requireFeature`) y los límites (`DESPACHO_PLANS['business_control']`) funcionan automáticamente.
|
||||
|
||||
### 2.2 dbMode siempre MANAGED (Opción 1)
|
||||
**Decisión arquitectónica clave**: Todos los tenants, sin importar el plan, siempre tienen `dbMode: 'MANAGED'`. El conector/BYO es una **feature de respaldo** que se activa por separado sin cambiar el modo de la base de datos.
|
||||
|
||||
**Razón**: El BYO es "como respaldo" para cuando fallen los servicios en la nube, pero muchos clientes no tendrán servidor físico desde el inicio. Business Control y Enterprise operan 100% en la nube; el conector local es un respaldo opcional.
|
||||
|
||||
**Cambios aplicados**:
|
||||
- `signupDespacho()`: siempre crea tenant con `dbMode: 'MANAGED'`
|
||||
- `provisionConnector()`: ya NO cambia `dbMode` a `'BYO'`; solo guarda `connectorTokenEnc` y `connectorTunnelHostname`
|
||||
- `DESPACHO_PLANS`: `business_control` y `business_cloud` ahora tienen `dbMode: 'MANAGED'`
|
||||
- Seed de `despacho_plan_prices`: actualizado a `MANAGED` para esos planes
|
||||
- BD: `UPDATE despacho_plan_prices SET db_mode = 'MANAGED' WHERE plan IN ('business_control', 'business_cloud')`
|
||||
|
||||
### 2.3 Registro simplificado
|
||||
Se eliminó la selección de plan y frecuencia del registro de despacho (`/register-despacho`). Todos los nuevos usuarios empiezan con trial genérico de 30 días.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cambios Implementados
|
||||
|
||||
### 3.1 Backend
|
||||
|
||||
#### Migración Prisma — `TrialInvitation`
|
||||
Nueva tabla en `apps/api/prisma/schema.prisma`:
|
||||
```prisma
|
||||
model TrialInvitation {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
invitedBy String @map("invited_by")
|
||||
plan String @default("business_control")
|
||||
durationDays Int @map("duration_days")
|
||||
status String @default("pending")
|
||||
token String @unique
|
||||
emailSentTo String? @map("email_sent_to")
|
||||
sentAt DateTime @default(now()) @map("sent_at")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
acceptedAt DateTime? @map("accepted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
}
|
||||
```
|
||||
|
||||
**Migración aplicada:** `20260507201624_add_trial_invitations`
|
||||
|
||||
#### Nuevo Service: `apps/api/src/services/trial-invitations.service.ts`
|
||||
- `createInvitation()` — Crea invitación, genera token, envía email al owner
|
||||
- `acceptInvitation()` — Valida token, actualiza tenant.plan, crea subscription trial
|
||||
- `getInvitations()` — Lista invitaciones con enrich de tenant data
|
||||
- `getPendingInvitationForTenant()` — Obtiene invitación pendiente para un tenant
|
||||
- `cancelInvitation()` — Cancela invitación pendiente
|
||||
|
||||
#### Nuevo Controller: `apps/api/src/controllers/trial-invitations.controller.ts`
|
||||
Endpoints:
|
||||
- `POST /api/invitations/trial` — Solo global admin. Body: `{ tenantId, plan?, durationDays }`
|
||||
- `GET /api/invitations/trial` — Solo global admin. Lista todas.
|
||||
- `GET /api/invitations/trial/pending` — Autenticado. Devuelve invitación pendiente para el tenant.
|
||||
- `POST /api/invitations/trial/:token/accept` — Autenticado. Owner del tenant.
|
||||
- `POST /api/invitations/trial/:id/cancel` — Solo global admin.
|
||||
- `GET /api/invitations/trial/token/:token` — Público (autenticado). Detalles de invitación.
|
||||
|
||||
#### Nuevas Routes: `apps/api/src/routes/trial-invitations.routes.ts`
|
||||
Registradas en `app.ts` bajo `/api/invitations/trial`.
|
||||
|
||||
#### Modificación: `apps/api/src/controllers/despacho.controller.ts`
|
||||
`getMyPlan()` ahora respeta `subscription.plan` cuando `status === 'trial'`:
|
||||
```ts
|
||||
if (subscription?.status === 'trial' && subscription.plan && subscription.plan !== 'trial') {
|
||||
currentPlan = subscription.plan; // 'business_control' si es Business Control Prueba
|
||||
} else if (isTrialActive) {
|
||||
currentPlan = 'trial';
|
||||
} else {
|
||||
currentPlan = String(tenant.plan);
|
||||
}
|
||||
```
|
||||
|
||||
Schema Zod de signup: `plan` y `frequency` ahora son puramente opcionales (sin defaults forzados).
|
||||
|
||||
#### Modificación: `apps/api/src/services/despacho.service.ts`
|
||||
- `signupDespacho()` ya no crea suscripción pagada durante el registro.
|
||||
- Todos los nuevos tenants empiezan con `plan: 'trial'`, `dbMode: 'MANAGED'`.
|
||||
- No se devuelve `paymentUrl` en el response.
|
||||
|
||||
#### Nuevo template de email: `apps/api/src/services/email/templates/trial-invitation.ts`
|
||||
Email de invitación con link de activación y detalles del plan.
|
||||
|
||||
#### Modificación: `apps/api/src/services/email/email.service.ts`
|
||||
Agregado método `sendTrialInvitation()`.
|
||||
|
||||
### 3.2 Frontend
|
||||
|
||||
#### Registro simplificado: `apps/web/app/(auth)/register-despacho/page.tsx`
|
||||
- Eliminado step 3 (selección de plan y frecuencia).
|
||||
- Solo 2 pasos: datos del despacho/owner → selección de vertical.
|
||||
- No se envía `plan` ni `frequency` en el POST.
|
||||
- Redirige a `/onboarding` tras registro exitoso.
|
||||
|
||||
#### Nueva página: `apps/web/app/invitacion/trial/[token]/page.tsx`
|
||||
- Muestra detalles de la invitación (plan, días, despacho).
|
||||
- Botón "Aceptar invitación" (requiere autenticación como owner).
|
||||
- Si no está logueado, redirige a login con redirect.
|
||||
- Estados: loading, inválida, expirada, aceptada, éxito.
|
||||
|
||||
#### Modificación: `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx`
|
||||
- Detecta invitación pendiente vía `GET /api/invitations/trial/pending`.
|
||||
- Muestra banner destacado: "Invitación especial — Business Control Prueba" con botón "Activar ahora".
|
||||
- Al aceptar, recarga el plan info y muestra mensaje de éxito.
|
||||
|
||||
#### Nueva página de admin: `apps/web/app/(dashboard)/admin/invitaciones-trial/page.tsx`
|
||||
- Formulario para crear invitación: selector de despacho, plan, duración en días.
|
||||
- Tabla de historial con estados (Pendiente, Aceptada, Expirada, Cancelada).
|
||||
- Acción cancelar para invitaciones pendientes.
|
||||
|
||||
#### Modificación: `apps/web/components/layouts/sidebar.tsx`
|
||||
Agregado link "Invitaciones Trial" a la navegación de admin global.
|
||||
|
||||
#### Nuevo API client: `apps/web/lib/api/trial-invitations.ts`
|
||||
Helpers para consumir todos los endpoints de invitaciones.
|
||||
|
||||
---
|
||||
|
||||
## 4. Flujo de Uso
|
||||
|
||||
### 4.1 Admin envía invitación
|
||||
1. Admin global navega a **Invitaciones Trial** en el sidebar.
|
||||
2. Selecciona un despacho, elige plan (Business Control o Enterprise), define duración (ej. 60 días).
|
||||
3. Clic en "Enviar invitación".
|
||||
4. El backend crea el token y envía email al owner del despacho.
|
||||
|
||||
### 4.2 Owner recibe y acepta
|
||||
1. Owner recibe email con link `/invitacion/trial/{token}`.
|
||||
2. Abre el link (si no está logueado, va a login primero).
|
||||
3. Ve los detalles de la invitación y clic en "Aceptar invitación".
|
||||
4. El backend:
|
||||
- Actualiza `tenant.plan = 'business_control'`
|
||||
- Actualiza `tenant.trialEndsAt = now + 60 días`
|
||||
- Marca subscriptions trial anteriores como `trial_converted`
|
||||
- Crea nueva subscription con `plan: 'business_control', status: 'trial'`
|
||||
- Marca invitación como `accepted`
|
||||
5. Owner es redirigido a `/configuracion/planes-despacho`.
|
||||
|
||||
### 4.3 Durante el trial
|
||||
- El tenant tiene todas las features de Business Control (100 RFCs, usuarios ilimitados, API, etc.).
|
||||
- `getMyPlan()` retorna `plan: 'business_control'` en lugar de `'trial'`.
|
||||
- Feature-gate permite acceso a todas las funciones del plan.
|
||||
|
||||
### 4.4 Al vencer
|
||||
- `plan-limits.middleware` detecta `needsRenewal = true`.
|
||||
- Bloquea escrituras (POST/PUT/DELETE) con código `SUBSCRIPTION_INACTIVE`.
|
||||
- Muestra banner: "Tu prueba gratuita terminó. Renueva tu plan para continuar."
|
||||
- Owner puede contratar Business Control desde `/configuracion/planes-despacho`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Archivos Modificados/Creados
|
||||
|
||||
### Backend (nuevos)
|
||||
- `apps/api/src/services/trial-invitations.service.ts`
|
||||
- `apps/api/src/controllers/trial-invitations.controller.ts`
|
||||
- `apps/api/src/routes/trial-invitations.routes.ts`
|
||||
- `apps/api/src/services/email/templates/trial-invitation.ts`
|
||||
- `apps/api/prisma/migrations/20260507201624_add_trial_invitations/migration.sql`
|
||||
|
||||
### Backend (modificados)
|
||||
- `apps/api/prisma/schema.prisma`
|
||||
- `apps/api/prisma/seed.ts`
|
||||
- `apps/api/src/app.ts`
|
||||
- `apps/api/src/services/despacho.service.ts`
|
||||
- `apps/api/src/controllers/despacho.controller.ts`
|
||||
- `apps/api/src/services/email/email.service.ts`
|
||||
- `apps/api/src/services/connector.service.ts`
|
||||
- `apps/api/src/services/admin-dashboard.service.ts`
|
||||
- `packages/shared/src/constants/despacho-plans.ts`
|
||||
|
||||
### Frontend (nuevos)
|
||||
- `apps/web/app/invitacion/trial/[token]/page.tsx`
|
||||
- `apps/web/app/(dashboard)/admin/invitaciones-trial/page.tsx`
|
||||
- `apps/web/lib/api/trial-invitations.ts`
|
||||
|
||||
### Frontend (modificados)
|
||||
- `apps/web/app/(auth)/register-despacho/page.tsx`
|
||||
- `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx`
|
||||
- `apps/web/components/layouts/sidebar.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 6. Deploy
|
||||
|
||||
```bash
|
||||
cd /root/HoruxDespachosNuevo
|
||||
# Backend: migración ya aplicada + build implícito en reload
|
||||
# Frontend:
|
||||
pnpm build --filter=@horux/web
|
||||
# PM2 reload:
|
||||
pm2 reload horux-api
|
||||
pm2 reload horux-web
|
||||
```
|
||||
|
||||
**Estado:** ✅ Exitoso. Build sin errores. Procesos reiniciados.
|
||||
|
||||
---
|
||||
|
||||
## 7. Notas para Futuras Sesiones
|
||||
|
||||
- Si se quiere agregar más planes a las invitaciones (ej. `mi_empresa_plus`), solo hay que agregarlos al Select del frontend de admin.
|
||||
- El email de invitación usa `FRONTEND_URL` del environment. Si no está seteado, fallback a `https://app.horux360.com`.
|
||||
- La invitación expira en 7 días desde el envío (configurable en `createInvitation()`).
|
||||
- Si un tenant en trial genérico acepta Business Control Prueba, su trial anterior se pierde (se sobreescribe `trialEndsAt`). Esto es intencional.
|
||||
- Considerar agregar un cron que marque invitaciones expiradas automáticamente (hoy solo se marcan al intentar aceptar).
|
||||
- **dbMode siempre MANAGED**: El conector/BYO es una feature de respaldo independiente. Nunca cambiar `dbMode` a `'BYO'` en ningún flujo. Si en el futuro se quiere usar la conexión BYO como fallback cuando la nube falla, implementarlo en el `tenant.middleware.ts` como lógica de retry/fallback, no como modo principal.
|
||||
267
docs/sessions/2026-05-04-cross-tenant-access-platform-staff.md
Normal file
267
docs/sessions/2026-05-04-cross-tenant-access-platform-staff.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# Sesión: Fix de Acceso Cross-Tenant para Platform Staff (`platform_ti`)
|
||||
|
||||
**Fecha:** 2026-05-04
|
||||
**Participante:** Ivan (`ivan@horuxfin.com`) — Tenant role: `contador`, Platform role: `platform_ti`
|
||||
**User ID:** `9fa5f15e-5f17-4501-acde-f761f08ed533`
|
||||
**Tenant:** HORUX 360 (`c52c2f5d-b1ae-45c6-8cc8-b11c9611618a`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Problema Reportado
|
||||
|
||||
Ivan, con rol de plataforma `platform_ti`, no podía ver otros tenants (despachos) desde la sección "Despachos". Al navegar a `/despachos/contribuyentes`, recibía errores 403 y no veía el selector de tenant ni los datos de otros despachos.
|
||||
|
||||
**Síntomas:**
|
||||
- Error 403 en `/api/tenants`
|
||||
- Error 403 en endpoints de stats de despachos (`/api/despacho-stats/*`)
|
||||
- No se mostraba el dropdown de selección de tenant en el frontend
|
||||
- Redirecciones incorrectas basadas únicamente en `role` (ignorando `platformRoles`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Causas Raíz Identificadas
|
||||
|
||||
### 2.1 Backend — Autorización inconsistente
|
||||
|
||||
| Problema | Ubicación | Detalle |
|
||||
|----------|-----------|---------|
|
||||
| `isGlobalAdmin()` no aceptaba `userId` | `apps/api/src/utils/platform-admin.ts` | Solo verificaba `role === 'owner'` + checks legacy. No revisaba `platformRoles`. |
|
||||
| `X-View-Tenant` bloqueado para `platform_ti` | `apps/api/src/middlewares/tenant.middleware.ts` | Solo permitía `platform_admin`, no `platform_ti`. |
|
||||
| Stats de despachos requerían `owner` exclusivamente | `apps/api/src/controllers/despacho-stats.controller.ts` | `getContribuyentesStats`, `getMisAsignados`, `getEquipoStats` usaban `ROLES_OWNER.has(role)`. |
|
||||
| `tenants.controller.ts` no pasaba `userId` | `apps/api/src/controllers/tenants.controller.ts` | `requireGlobalAdmin()` llamaba `isGlobalAdmin(tenantId, role)` sin `userId`. |
|
||||
| `usuarios.controller.ts` no pasaba `userId` | `apps/api/src/controllers/usuarios.controller.ts` | Mismo patrón que tenants. |
|
||||
|
||||
### 2.2 Frontend — Guards incompletos
|
||||
|
||||
| Problema | Ubicación | Detalle |
|
||||
|----------|-----------|---------|
|
||||
| Redirección basada solo en `role` | `apps/web/app/(dashboard)/despachos/page.tsx` | No consideraba `platformRoles` para redirigir a `/despachos/contribuyentes`. |
|
||||
| `despacho-subnav.tsx` no incluía `platform_ti` | `apps/web/components/despachos/despacho-subnav.tsx` | `defaultDespachoPathForRole()` no manejaba platform roles. |
|
||||
| `contribuyentes/page.tsx` no permitía `platform_ti` | `apps/web/app/(dashboard)/despachos/contribuyentes/page.tsx` | `enabled` solo para `owner` / `cfo`. |
|
||||
| `mis-asignados/page.tsx` no permitía `platform_ti` | `apps/web/app/(dashboard)/despachos/mis-asignados/page.tsx` | Guard de roles excluía platform staff. |
|
||||
| `tenant-view-store.ts` no guardaba RFC | `apps/web/stores/tenant-view-store.ts` | `setViewingTenant` solo aceptaba `id` y `name`; faltaba `rfc` para headers. |
|
||||
| Llamadas sin guard a `/api/tenants` | `apps/web/app/(dashboard)/clientes/page.tsx` | `useTenants()` se ejecutaba sin verificar `isGlobalAdminRfc`. |
|
||||
| Llamadas sin guard en admin/usuarios | `apps/web/app/(dashboard)/admin/usuarios/page.tsx` | `useQuery` para `/api/tenants` sin condición de habilitación. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Cambios Implementados
|
||||
|
||||
### 3.1 Backend
|
||||
|
||||
#### `apps/api/src/utils/platform-admin.ts`
|
||||
```ts
|
||||
export async function isGlobalAdmin(tenantId: string, role: string, userId?: string): Promise<boolean> {
|
||||
// NUEVO: Bypass para platform staff
|
||||
if (userId && await hasAnyPlatformRole(userId, ...SUPERSET_ROLES)) {
|
||||
return true;
|
||||
}
|
||||
if (role !== 'owner') return false;
|
||||
// ... legacy checks
|
||||
}
|
||||
```
|
||||
|
||||
#### `apps/api/src/middlewares/tenant.middleware.ts`
|
||||
```ts
|
||||
// Admin impersonation via X-View-Tenant header
|
||||
const viewTenantHeader = req.headers['x-view-tenant'] as string;
|
||||
if (viewTenantHeader) {
|
||||
// FIX: Ahora acepta platform_admin y platform_ti
|
||||
const isPlatformStaff = await hasAnyPlatformRole(req.user.userId, 'platform_admin', 'platform_ti');
|
||||
const globalAdmin = !isPlatformStaff && await isGlobalAdmin(req.user.tenantId, req.user.role);
|
||||
if (!isPlatformStaff && !globalAdmin) {
|
||||
return res.status(403).json({ message: 'No autorizado para ver otros tenants' });
|
||||
}
|
||||
// ... resolve viewed tenant pool
|
||||
}
|
||||
```
|
||||
|
||||
#### `apps/api/src/controllers/despacho-stats.controller.ts`
|
||||
```ts
|
||||
function isPlatformStaff(user: any): boolean {
|
||||
return (user?.platformRoles || []).some((r: string) => ['platform_admin', 'platform_ti'].includes(r));
|
||||
}
|
||||
|
||||
// Aplicado a:
|
||||
// - getContribuyentesStats
|
||||
// - getMisAsignados
|
||||
// - getEquipoStats
|
||||
```
|
||||
|
||||
#### `apps/api/src/controllers/tenants.controller.ts`
|
||||
```ts
|
||||
async function requireGlobalAdmin(req: Request): Promise<void> {
|
||||
// FIX: Ahora pasa req.user!.userId
|
||||
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId))) {
|
||||
throw new AppError(403, 'Solo el administrador global puede gestionar clientes');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllTenants(req: Request, res: Response, next: NextFunction) {
|
||||
// FIX FINAL: Devuelve 200 [] en lugar de 403 para no generar ruido en consola
|
||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
||||
if (!isAdmin) {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
return res.json([]);
|
||||
}
|
||||
// ... resto
|
||||
}
|
||||
|
||||
export async function getTenant(req: Request, res: Response, next: NextFunction) {
|
||||
// FIX FINAL: Devuelve 404 en lugar de 403
|
||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
||||
if (!isAdmin) {
|
||||
return res.status(404).json({ message: 'Cliente no encontrado' });
|
||||
}
|
||||
// ... resto
|
||||
}
|
||||
```
|
||||
|
||||
#### `apps/api/src/controllers/usuarios.controller.ts`
|
||||
```ts
|
||||
async function isGlobalAdmin(req: Request): Promise<boolean> {
|
||||
// FIX: Ahora pasa req.user!.userId
|
||||
return checkGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Frontend
|
||||
|
||||
#### `apps/web/components/despachos/despacho-subnav.tsx`
|
||||
```ts
|
||||
const ITEMS: NavItem[] = [
|
||||
{ href: '/despachos/contribuyentes', label: 'Contribuyentes', icon: Building2,
|
||||
roles: ['owner','cfo','contador','visor','supervisor','auxiliar'] },
|
||||
{ href: '/despachos/mis-asignados', label: 'Mis asignados', icon: UserCheck,
|
||||
roles: ['owner','cfo','supervisor','auxiliar','contador','visor'] },
|
||||
{ href: '/despachos/equipo', label: 'Equipo', icon: Users,
|
||||
roles: ['owner','cfo','supervisor'] },
|
||||
];
|
||||
|
||||
export function defaultDespachoPathForRole(role: string, platformRoles?: string[]): string {
|
||||
const isStaff = platformRoles?.some(r => ['platform_admin','platform_ti'].includes(r));
|
||||
if (isStaff || role === 'owner' || role === 'cfo') return '/despachos/contribuyentes';
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### `apps/web/app/(dashboard)/despachos/page.tsx`
|
||||
```ts
|
||||
// FIX: Redirige platform_ti a contribuyentes
|
||||
if (platformRoles?.some(r => ['platform_admin', 'platform_ti'].includes(r))) {
|
||||
redirect('/despachos/contribuyentes');
|
||||
}
|
||||
```
|
||||
|
||||
#### `apps/web/app/(dashboard)/despachos/contribuyentes/page.tsx`
|
||||
```ts
|
||||
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
|
||||
const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false;
|
||||
const enabled = role === 'owner' || role === 'cfo' || isPlatformStaff;
|
||||
|
||||
// Dropdown de selección de tenant (solo para platform staff)
|
||||
const { data: despachos } = useQuery({
|
||||
queryKey: ['admin-despachos'],
|
||||
queryFn: fetchAdminDespachos,
|
||||
enabled: isPlatformStaff
|
||||
});
|
||||
|
||||
// onValueChange llama a setViewingTenant(despachoId, despachoName, despachoRfc)
|
||||
```
|
||||
|
||||
#### `apps/web/app/(dashboard)/despachos/mis-asignados/page.tsx`
|
||||
```ts
|
||||
// FIX: Ahora permite contador, visor, y platform staff
|
||||
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
|
||||
const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false;
|
||||
const enabled = ['owner','cfo','supervisor','auxiliar','contador','visor'].includes(role || '') || isPlatformStaff;
|
||||
```
|
||||
|
||||
#### `apps/web/stores/tenant-view-store.ts`
|
||||
```ts
|
||||
interface TenantViewState {
|
||||
viewingTenantId: string | null;
|
||||
viewingTenantName: string | null;
|
||||
viewingTenantRfc: string | null; // NUEVO
|
||||
setViewingTenant: (id: string | null, name: string | null, rfc?: string | null) => void;
|
||||
clearViewingTenant: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
#### `apps/web/app/(dashboard)/clientes/page.tsx`
|
||||
```ts
|
||||
// FIX: Solo llama a useTenants() si es global admin
|
||||
const { data: tenants } = useTenants({ enabled: isGlobalAdminRfc });
|
||||
```
|
||||
|
||||
#### `apps/web/app/(dashboard)/admin/usuarios/page.tsx`
|
||||
```ts
|
||||
// FIX: Condicional en query para evitar 403
|
||||
const { data: tenants } = useQuery({
|
||||
queryKey: ['tenants'],
|
||||
queryFn: getTenants,
|
||||
enabled: isGlobalAdminRfc, // NUEVO
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Decisiones Arquitectónicas
|
||||
|
||||
### 4.1 Platform Roles como Superset
|
||||
Los roles `platform_admin` y `platform_ti` se tratan como un **superset** que bypassa casi todas las verificaciones de tenant-level authorization. Esto es intencional: el staff de plataforma necesita operar cross-tenant sin fricción.
|
||||
|
||||
### 4.2 Soft Fail para `GET /api/tenants`
|
||||
En lugar de devolver `403` a usuarios no autorizados en `GET /api/tenants`, se devuelve `200 []`. Esto elimina ruido en la consola del navegador cuando componentes React hacen polling o renderizan hooks incondicionalmente. Las operaciones de escritura (`POST`, `PUT`, `DELETE`) siguen devolviendo `403`.
|
||||
|
||||
### 4.3 `X-View-Tenant` Header
|
||||
El mecanismo de impersonación de tenant usa:
|
||||
1. Frontend: `tenant-view-store` guarda `viewingTenantId` en `localStorage` (key: `horux-tenant-view`)
|
||||
2. Frontend: `apiClient` lee el store e inyecta header `X-View-Tenant`
|
||||
3. Backend: `tenantMiddleware` resuelve la DB pool del tenant visto
|
||||
|
||||
### 4.4 Cache Headers
|
||||
Se agregaron headers `Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate` a las respuestas de tenants para evitar que proxies/CDN cacheen datos cross-tenant.
|
||||
|
||||
---
|
||||
|
||||
## 5. Deploy
|
||||
|
||||
```bash
|
||||
cd /root/HoruxDespachosNuevo
|
||||
pnpm build --filter=@horux/web
|
||||
pm2 reload horux-api
|
||||
pm2 reload horux-web
|
||||
```
|
||||
|
||||
**Estado:** ✅ Exitoso. Ivan confirmó que puede ver todos los tenants.
|
||||
|
||||
---
|
||||
|
||||
## 6. Archivos Modificados
|
||||
|
||||
### Backend
|
||||
- `apps/api/src/middlewares/tenant.middleware.ts`
|
||||
- `apps/api/src/utils/platform-admin.ts`
|
||||
- `apps/api/src/controllers/despacho-stats.controller.ts`
|
||||
- `apps/api/src/controllers/tenants.controller.ts`
|
||||
- `apps/api/src/controllers/usuarios.controller.ts`
|
||||
|
||||
### Frontend
|
||||
- `apps/web/components/despachos/despacho-subnav.tsx`
|
||||
- `apps/web/app/(dashboard)/despachos/page.tsx`
|
||||
- `apps/web/app/(dashboard)/despachos/contribuyentes/page.tsx`
|
||||
- `apps/web/app/(dashboard)/despachos/mis-asignados/page.tsx`
|
||||
- `apps/web/stores/tenant-view-store.ts`
|
||||
- `apps/web/app/(dashboard)/clientes/page.tsx`
|
||||
- `apps/web/app/(dashboard)/admin/usuarios/page.tsx`
|
||||
- `apps/web/hooks/use-tenants.ts`
|
||||
|
||||
---
|
||||
|
||||
## 7. Notas para Futuras Sesiones
|
||||
|
||||
- Si se agrega un nuevo endpoint cross-tenant, siempre verificar que `isGlobalAdmin()` reciba `userId` para que los platform roles funcionen.
|
||||
- Si se agrega una nueva página en `/despachos/*`, usar `isPlatformStaff` o incluir platform roles en los guards.
|
||||
- El helper `isGlobalAdminRfc` en `@horux/shared` ya maneja `platformRoles`, pero los controllers backend deben usar la versión con `userId`.
|
||||
- Considerar centralizar la lógica de `isPlatformStaff` en un helper compartido backend/frontend para evitar duplicación.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Sesión de cambios: 2026-05-22
|
||||
|
||||
## Resumen
|
||||
|
||||
Tres líneas de trabajo: (1) implementación completa de facturas globales (`InformacionGlobal`) con `fecha_efectiva`, (2) fallback robusto de datos fiscales del tenant a contribuyentes con RFC coincidente, y (3) corrección crítica de typo `anio_global` → `año_global` en sincronización SAT.
|
||||
|
||||
---
|
||||
|
||||
## 1. Facturas Globales — `InformacionGlobal` y `fecha_efectiva`
|
||||
|
||||
### Contexto
|
||||
Las facturas globales del SAT usan el nodo `<cfdi:InformacionGlobal>` que indica la periodicidad, meses y año al que realmente corresponden los ingresos. Antes del cambio, todos los CFDIs se agrupaban por `fecha_emision`, lo que desplazaba facturas globales emitidas al cierre de un período (ej. 31 de marzo) al mes equivocado.
|
||||
|
||||
### Cambios
|
||||
|
||||
**Base de datos**
|
||||
- Nueva migración `apps/api/src/migrations/tenant/045_factura_global.sql`:
|
||||
- `periodicidad VARCHAR(2)`
|
||||
- `meses_global VARCHAR(10)`
|
||||
- `año_global VARCHAR(4)`
|
||||
- `fecha_efectiva DATE` + índice `idx_cfdis_fecha_efectiva`
|
||||
- Aplicada a todos los tenants activos vía script de migración.
|
||||
|
||||
**Parser SAT**
|
||||
- `apps/api/src/services/sat/sat-parser.service.ts`
|
||||
- `CfdiParsed` ahora incluye `periodicidad`, `mesesGlobal`, `añoGlobal`.
|
||||
- Extraídos del XML desde `comprobante.InformacionGlobal`.
|
||||
|
||||
**Cálculo de `fecha_efectiva`**
|
||||
- `apps/api/src/services/sat/sat.service.ts`
|
||||
- `calcFechaEfectiva(cfdi)`: devuelve `new Date(año, mes-1, 1)` para facturas globales.
|
||||
- Soporta periodicidad bimestral (`05`): códigos `13-18` → meses `2,4,6,8,10,12`.
|
||||
|
||||
**Queries de métricas/reportes**
|
||||
Reemplazado `fecha_emision - interval '1 hour'` por `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour')` en:
|
||||
- `metricas-compute.service.ts` (counts, min_anio, monthly compute)
|
||||
- `reportes.service.ts` (flujo efectivo, comparativos)
|
||||
- `dashboard.service.ts` (KPIs, neteo PPD/07)
|
||||
- `impuestos.service.ts` (IVA mensual)
|
||||
- `alertas-auto.service.ts` (alertas RESICO y régimen desconocido)
|
||||
- `cfdi.service.ts` (list filters, `getResumenCfdis`)
|
||||
- `export.service.ts`
|
||||
- `conciliacion.service.ts`
|
||||
- `alertas.controller.ts`
|
||||
|
||||
**Backfill**
|
||||
- Ejecutado en todos los tenants activos:
|
||||
- `horux_hts240708lja`: 24 registros
|
||||
- `horux_roem691011ez4`: 2,238 registros
|
||||
- `horux_auza640701ti9`: 6 registros
|
||||
- `horux_momc8311199va`: 14 registros
|
||||
- Métricas recalculadas para TORC9611214CA (enero-marzo 2026).
|
||||
|
||||
---
|
||||
|
||||
## 2. Fallback de datos fiscales del tenant al contribuyente
|
||||
|
||||
### Problema
|
||||
El tenant HORUX 360 (`HTS240708LJA`) tiene su régimen fiscal y domicilio en la base central (`tenants`), pero al existir como contribuyente dentro de su propia BD (`contribuyentes`), esos campos estaban vacíos. El frontend mostraba "Sin régimen" y "Sin domicilio" al seleccionar ese contribuyente.
|
||||
|
||||
### Solución robusta
|
||||
Cuando un contribuyente tiene el mismo RFC que su tenant, el backend ahora mezcla automáticamente los datos faltantes desde la base central.
|
||||
|
||||
**Archivos**
|
||||
- `apps/api/src/services/contribuyente.service.ts`
|
||||
- `fetchTenantFiscalData(tenantId)`: consulta `tenants` + `tenant_regimenes_activos` para obtener régimen (CSV de claves), CP y domicilio JSON.
|
||||
- `mergeContribuyenteWithTenant()`: rellena `regimenFiscal`, `codigoPostal` y `domicilio` si están vacíos en el contribuyente.
|
||||
- `listContribuyentes()` y `getContribuyenteById()` aceptan `tenantId` opcional.
|
||||
- `apps/api/src/controllers/contribuyente.controller.ts`
|
||||
- Pasa `req.user!.tenantId` a `listContribuyentes` y `getContribuyenteById`.
|
||||
- `apps/api/src/controllers/contribuyente-config.controller.ts`
|
||||
- Pasa `req.user!.tenantId` a `getContribuyenteById` en `uploadFiel` y `createOrg`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Fix crítico: `anio_global` → `año_global`
|
||||
|
||||
### Problema
|
||||
La migración `045_factura_global.sql` creó la columna `año_global` (con tilde), pero `sat.service.ts` usaba `anio_global` (sin tilde) en las queries `INSERT`/`UPDATE` de `saveCfdis`. Esto causaba que **cada inserción de CFDI fallara** con:
|
||||
|
||||
```
|
||||
column "anio_global" of relation "cfdis" does not exist
|
||||
```
|
||||
|
||||
Esto explicaba por qué el sync inicial del tenant DESPACHO_MPG95QP7_XZVFF insertó solo **174 de 8,284 CFDIs** descargados.
|
||||
|
||||
### Fix
|
||||
- `apps/api/src/services/sat/sat.service.ts`
|
||||
- Líneas 297 y 347: `anio_global` → `año_global`.
|
||||
|
||||
### Optimización adicional en SAT sync
|
||||
- `determineChunkMonths()`: ahora detecta si existe un job previo completado con `satRequestIds` y salta el sondeo `metadata` lento, reutilizando directamente el tamaño de chunk (3 o 6 meses).
|
||||
- `MAX_POLL_ATTEMPTS`: aumentado de 45 a 500 (~8 horas) para syncs iniciales grandes donde el SAT tarda horas en preparar paquetes.
|
||||
|
||||
### Re-sync validado
|
||||
Re-lanzado el sync inicial para DESPACHO_MPG95QP7_XZVFF tras el fix:
|
||||
- **Found:** 8,284 | **Downloaded:** 7,781 | **Inserted:** 8,266
|
||||
- Duración: ~7 minutos (vs. ~3.5 horas del intento anterior con el bug).
|
||||
|
||||
---
|
||||
|
||||
## Archivos modificados
|
||||
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/src/migrations/tenant/045_factura_global.sql` | Nueva migración (untracked → added) |
|
||||
| `apps/api/src/services/sat/sat-parser.service.ts` | Extrae `periodicidad`, `mesesGlobal`, `añoGlobal` |
|
||||
| `apps/api/src/services/sat/sat.service.ts` | `calcFechaEfectiva`, fix `año_global`, `determineChunkMonths` optimizado, `MAX_POLL_ATTEMPTS` |
|
||||
| `apps/api/src/services/metricas-compute.service.ts` | Usa `COALESCE(fecha_efectiva, ...)` |
|
||||
| `apps/api/src/services/dashboard.service.ts` | Usa `fecha_efectiva` en KPIs y neteo |
|
||||
| `apps/api/src/services/impuestos.service.ts` | Usa `fecha_efectiva` en IVA mensual |
|
||||
| `apps/api/src/services/cfdi.service.ts` | Filtros y resumen por `fecha_efectiva` |
|
||||
| `apps/api/src/services/export.service.ts` | Usa `fecha_efectiva` |
|
||||
| `apps/api/src/services/conciliacion.service.ts` | Usa `fecha_efectiva` |
|
||||
| `apps/api/src/services/alertas-auto.service.ts` | Usa `fecha_efectiva` en alertas |
|
||||
| `apps/api/src/controllers/alertas.controller.ts` | Usa `fecha_efectiva` en queries de alertas |
|
||||
| `apps/api/src/services/contribuyente.service.ts` | Fallback de datos fiscales del tenant |
|
||||
| `apps/api/src/controllers/contribuyente.controller.ts` | Pasa `tenantId` al servicio |
|
||||
| `apps/api/src/controllers/contribuyente-config.controller.ts` | Pasa `tenantId` al servicio |
|
||||
| `apps/api/src/scripts/recalc-metricas.ts` | Script de recálculo manual (untracked → added) |
|
||||
| `apps/web/...` | Múltiples ajustes frontend relacionados (fechas, alertas, drill-down) |
|
||||
Reference in New Issue
Block a user