Compare commits
38 Commits
80e2c099d9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63908f9e9d | ||
|
|
ed6cfed312 | ||
|
|
ab6b76fcb8 | ||
|
|
b52ff875be | ||
|
|
66d68c652c | ||
|
|
d3b326e78c | ||
|
|
b1eaf41681 | ||
|
|
bd7e499ab7 | ||
|
|
44144ebf9d | ||
|
|
314a74982c | ||
|
|
76d3f00f29 | ||
|
|
214410d2fb | ||
|
|
199922272f | ||
|
|
6e54efe5e4 | ||
|
|
5dd53cebac | ||
|
|
0de0df9357 | ||
|
|
20fb8ea2db | ||
|
|
8c9a7b73dc | ||
|
|
910c50d870 | ||
|
|
2f49fdc9b7 | ||
|
|
0439a84e6d | ||
|
|
0815269f1b | ||
|
|
9b535354fb | ||
|
|
e01422e443 | ||
|
|
2208cee87f | ||
|
|
138e223361 | ||
|
|
441ec20059 | ||
|
|
929aeec641 | ||
|
|
4a885de520 | ||
|
|
c84ad6c4db | ||
|
|
acd7de76d9 | ||
|
|
9c4a2343f5 | ||
|
|
1d828adc27 | ||
|
|
4c7ab4fd35 | ||
|
|
0fa2c3c90f | ||
|
|
cbefaa2bf7 | ||
|
|
e35eae2a72 | ||
|
|
5c940847af |
@@ -71,9 +71,9 @@ class TenantConnectionManager {
|
||||
user: connectionOverride?.user ?? this.dbConfig.user,
|
||||
password: connectionOverride?.password ?? this.dbConfig.password,
|
||||
database: databaseName,
|
||||
max: 3,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 300_000,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
connectionTimeoutMillis: 30_000,
|
||||
};
|
||||
|
||||
pool = new Pool(poolConfig);
|
||||
@@ -187,11 +187,13 @@ class TenantConnectionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove idle pools (not accessed in last 5 minutes).
|
||||
* Remove idle pools (not accessed in last 12 hours).
|
||||
* SAT syncs (initial/daily) can run for hours in background;
|
||||
* a 5-minute timeout caused 'pool already ended' errors mid-sync.
|
||||
*/
|
||||
private cleanupIdlePools(): void {
|
||||
const now = Date.now();
|
||||
const maxIdle = 5 * 60 * 1000;
|
||||
const maxIdle = 12 * 60 * 60 * 1000;
|
||||
|
||||
for (const [tenantId, entry] of this.pools.entries()) {
|
||||
if (now - entry.lastAccess.getTime() > maxIdle) {
|
||||
|
||||
@@ -125,7 +125,9 @@ export async function resolverAlertaManual(req: Request, res: Response, next: Ne
|
||||
export async function getAlertasAutomaticas(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
console.log(`[AlertasCtrl] GET /automaticas tenant=${req.user!.tenantId} contribuyente=${contribuyenteId || 'null'} user=${req.user!.userId} role=${req.user!.role}`);
|
||||
const alertas = await generarAlertasAutomaticas(req.tenantPool!, req.user!.tenantId, contribuyenteId || null);
|
||||
console.log(`[AlertasCtrl] GET /automaticas devuelve ${alertas.length} alertas: ${alertas.map(a => a.id).join(', ') || 'ninguna'}`);
|
||||
res.json(alertas);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -7,6 +7,8 @@ import { AppError } from '../middlewares/error.middleware.js';
|
||||
/**
|
||||
* Valida que el auxiliar pertenezca al supervisor (o que el caller sea owner).
|
||||
* Owner puede asignar a cualquier auxiliar del tenant.
|
||||
* La relación se infiere desde carteras (directas y subcarteras) con fallback
|
||||
* a la tabla legacy auxiliar_supervisores.
|
||||
*/
|
||||
async function validarAuxiliarDelSupervisor(
|
||||
pool: import('pg').Pool,
|
||||
@@ -17,10 +19,22 @@ async function validarAuxiliarDelSupervisor(
|
||||
if (callerRole === 'owner') return;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT 1 FROM auxiliar_supervisores
|
||||
WHERE auxiliar_user_id = $1 AND supervisor_user_id = $2
|
||||
LIMIT 1`,
|
||||
[auxiliarUserId, supervisorUserId],
|
||||
`SELECT 1 FROM (
|
||||
SELECT c.auxiliar_user_id
|
||||
FROM carteras c
|
||||
WHERE c.supervisor_user_id = $1
|
||||
AND c.auxiliar_user_id = $2
|
||||
UNION
|
||||
SELECT sub.auxiliar_user_id
|
||||
FROM carteras sub
|
||||
JOIN carteras p ON p.id = sub.parent_id
|
||||
WHERE p.supervisor_user_id = $1
|
||||
AND sub.auxiliar_user_id = $2
|
||||
UNION
|
||||
SELECT auxiliar_user_id FROM auxiliar_supervisores
|
||||
WHERE supervisor_user_id = $1 AND auxiliar_user_id = $2
|
||||
) t LIMIT 1`,
|
||||
[supervisorUserId, auxiliarUserId],
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
@@ -28,10 +42,30 @@ async function validarAuxiliarDelSupervisor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida que el auxiliar tenga al contribuyente en alguna de sus subcarteras.
|
||||
* Si no hay ningún auxiliar con ese contribuyente en su subcartera, la asignación
|
||||
* se rechaza (el supervisor debe agregar el contribuyente a una subcartera primero).
|
||||
*/
|
||||
async function validarAuxiliarEnSubcartera(
|
||||
pool: import('pg').Pool,
|
||||
contribuyenteId: string,
|
||||
auxiliarUserId: string,
|
||||
): Promise<void> {
|
||||
const elegibles = await asignacionesService.getAuxiliaresElegibles(pool, contribuyenteId);
|
||||
if (elegibles.length === 0) {
|
||||
throw new AppError(403, 'Ningún auxiliar tiene este contribuyente en su subcartera');
|
||||
}
|
||||
if (!elegibles.includes(auxiliarUserId)) {
|
||||
throw new AppError(403, 'El auxiliar no tiene este contribuyente en ninguna de sus subcarteras');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Obligaciones ──
|
||||
|
||||
export async function asignarObligacion(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = String(req.params.id);
|
||||
const obligacionId = String(req.params.obligacionId);
|
||||
const schema = z.object({ auxiliarUserId: z.string().uuid() });
|
||||
const { auxiliarUserId } = schema.parse(req.body);
|
||||
@@ -42,6 +76,11 @@ export async function asignarObligacion(req: Request, res: Response, next: NextF
|
||||
auxiliarUserId,
|
||||
req.user!.role,
|
||||
);
|
||||
await validarAuxiliarEnSubcartera(
|
||||
req.tenantPool!,
|
||||
contribuyenteId,
|
||||
auxiliarUserId,
|
||||
);
|
||||
|
||||
await asignacionesService.asignarObligacion(
|
||||
req.tenantPool!,
|
||||
@@ -80,6 +119,19 @@ export async function asignarTarea(req: Request, res: Response, next: NextFuncti
|
||||
req.user!.role,
|
||||
);
|
||||
|
||||
// Obtener contribuyenteId de la tarea para validar subcartera
|
||||
const { rows } = await req.tenantPool!.query<{ contribuyente_id: string }>(
|
||||
`SELECT contribuyente_id FROM tareas_catalogo WHERE id = $1 LIMIT 1`,
|
||||
[tareaId],
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
await validarAuxiliarEnSubcartera(
|
||||
req.tenantPool!,
|
||||
rows[0].contribuyente_id,
|
||||
auxiliarUserId,
|
||||
);
|
||||
}
|
||||
|
||||
await asignacionesService.asignarTarea(
|
||||
req.tenantPool!,
|
||||
tareaId,
|
||||
@@ -135,3 +187,11 @@ export async function listSinAsignar(req: Request, res: Response, next: NextFunc
|
||||
res.json({ obligaciones, tareas });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
export async function listAuxiliaresElegibles(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = String(req.params.contribuyenteId);
|
||||
const auxIds = await asignacionesService.getAuxiliaresElegibles(req.tenantPool!, contribuyenteId);
|
||||
res.json({ auxiliares: auxIds });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as cfdiService from '../services/cfdi.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { GRUPO_PF_EMPRESARIAL, GRUPO_PM_OTROS } from '../services/dashboard.service.js';
|
||||
import { getRegimenesIgnoradosClaves } from '../services/regimen.service.js';
|
||||
import { resolveContribuyenteContext } from '../utils/contribuyente-context.js';
|
||||
@@ -75,6 +76,50 @@ export async function getXml(req: Request, res: Response, next: NextFunction) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadXmlsZip(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const filters: CfdiFilters = {
|
||||
tipo: req.body.tipo as any,
|
||||
tipoComprobante: req.body.tipoComprobante as any,
|
||||
estado: req.body.estado as any,
|
||||
fechaInicio: req.body.fechaInicio as string,
|
||||
fechaFin: req.body.fechaFin as string,
|
||||
rfc: req.body.rfc as string,
|
||||
emisor: req.body.emisor as string,
|
||||
receptor: req.body.receptor as string,
|
||||
search: req.body.search as string,
|
||||
contribuyenteId: req.body.contribuyenteId as string,
|
||||
};
|
||||
|
||||
const cfdis = await cfdiService.getCfdiXmlsForZip(req.tenantPool, filters);
|
||||
const zip = new AdmZip();
|
||||
let added = 0;
|
||||
|
||||
for (const cfdi of cfdis) {
|
||||
if (cfdi.xml) {
|
||||
const filename = `${cfdi.uuid || 'cfdi'}.xml`;
|
||||
zip.addFile(filename, Buffer.from(cfdi.xml, 'utf8'));
|
||||
added++;
|
||||
}
|
||||
}
|
||||
|
||||
if (added === 0) {
|
||||
return next(new AppError(404, 'No se encontraron XMLs para los filtros aplicados'));
|
||||
}
|
||||
|
||||
const zipBuffer = zip.toBuffer();
|
||||
res.set('Content-Type', 'application/zip');
|
||||
res.set('Content-Disposition', `attachment; filename="cfdis-${Date.now()}.zip"`);
|
||||
res.send(zipBuffer);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listConceptos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) return next(new AppError(400, 'Tenant no configurado'));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as contribuyenteService from '../services/contribuyente.service.js';
|
||||
import * as carteraService from '../services/cartera.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
|
||||
import { adjustDespachoOverage } from '../services/payment/addon.service.js';
|
||||
@@ -41,7 +42,24 @@ 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, req.user!.tenantId);
|
||||
return res.json({ data: rows });
|
||||
|
||||
// Batch lookup de nombres de supervisores
|
||||
const supervisorIds = [...new Set(rows.map(r => r.supervisorUserId).filter(Boolean))] as string[];
|
||||
const supervisorNames: Record<string, string> = {};
|
||||
if (supervisorIds.length > 0) {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: supervisorIds } },
|
||||
select: { id: true, nombre: true },
|
||||
});
|
||||
for (const u of users) supervisorNames[u.id] = u.nombre;
|
||||
}
|
||||
|
||||
return res.json({
|
||||
data: rows.map(r => ({
|
||||
...r,
|
||||
supervisorNombre: r.supervisorUserId ? (supervisorNames[r.supervisorUserId] ?? null) : null,
|
||||
})),
|
||||
});
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
@@ -77,6 +95,19 @@ export async function create(req: Request, res: Response, next: NextFunction) {
|
||||
|
||||
const row = await contribuyenteService.createContribuyente(req.tenantPool!, data);
|
||||
|
||||
// Si se asignó un supervisor, agregar el contribuyente a todas las carteras
|
||||
// top-level de ese supervisor para que aparezca directamente en su vista.
|
||||
if (data.supervisorUserId) {
|
||||
try {
|
||||
const carteras = await carteraService.listCarteras(req.tenantPool!, data.supervisorUserId);
|
||||
await Promise.all(
|
||||
carteras.map(c => carteraService.addEntidadToCartera(req.tenantPool!, c.id, row.id))
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error('[Contribuyente] Auto-assign to cartera failed (non-blocking):', err.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
// Ajuste de overage despacho: si el tenant pasa de 100 a 101+ RFCs, crea
|
||||
// el addon y devuelve paymentUrl para que el frontend redirija al usuario.
|
||||
// Fail-soft: si falla el addon, el contribuyente queda creado y se loguea.
|
||||
@@ -139,6 +170,15 @@ export async function addClienteAcceso(req: Request, res: Response, next: NextFu
|
||||
const { userId } = req.body;
|
||||
if (!userId || typeof userId !== 'string') return next(new AppError(400, 'userId requerido'));
|
||||
const entidadId = String(req.params.id);
|
||||
|
||||
// Seguridad: supervisor solo puede asignar contribuyentes que supervise
|
||||
if (req.user!.role === 'supervisor') {
|
||||
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
|
||||
if (!visibleIds.includes(entidadId)) {
|
||||
return next(new AppError(403, 'No tienes acceso a este contribuyente'));
|
||||
}
|
||||
}
|
||||
|
||||
await req.tenantPool!.query(
|
||||
'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[userId, entidadId],
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function consultarManual(req: Request, res: Response, next: NextFun
|
||||
// Declaraciones provisionales
|
||||
// ============================================================================
|
||||
|
||||
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar'];
|
||||
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
|
||||
|
||||
function canUpload(req: Request): boolean {
|
||||
return ROLES_UPLOAD.includes(req.user!.role);
|
||||
@@ -82,7 +82,7 @@ const createDeclaracionSchema = z.object({
|
||||
mes: z.number().int().min(1).max(12),
|
||||
tipo: z.enum(['normal', 'complementaria']),
|
||||
periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'semestral', 'anual']).optional(),
|
||||
impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'SUELDOS', 'DIOT', 'OTRO'])).min(1, 'Selecciona al menos un impuesto'),
|
||||
impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'])).min(1, 'Selecciona al menos un impuesto'),
|
||||
montoPago: z.number().min(0).optional(),
|
||||
pdfBase64: z.string().min(100),
|
||||
pdfFilename: z.string().min(1).max(255),
|
||||
|
||||
@@ -580,7 +580,13 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
|
||||
const params: any[] = [];
|
||||
if (q.length >= 2) {
|
||||
params.push(`%${q}%`);
|
||||
whereSearch = `AND (cc.descripcion ILIKE $1 OR cc.clave_prod_serv ILIKE $1)`;
|
||||
whereSearch = `AND (cc.descripcion ILIKE $${params.length} OR cc.clave_prod_serv ILIKE $${params.length})`;
|
||||
}
|
||||
|
||||
let whereContribuyente = '';
|
||||
if (contribuyenteId) {
|
||||
params.push(contribuyenteId);
|
||||
whereContribuyente = `AND c.contribuyente_id = $${params.length}`;
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
@@ -605,6 +611,7 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
|
||||
WHERE c.status NOT IN ('Cancelado', '0')
|
||||
${whereType}
|
||||
${whereSearch}
|
||||
${whereContribuyente}
|
||||
ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC
|
||||
LIMIT 30
|
||||
`, params);
|
||||
@@ -708,7 +715,7 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
|
||||
const q = (req.query.q as string || '').trim();
|
||||
if (q.length < 3) return res.json([]);
|
||||
|
||||
const contribuyenteId = (req.query.contribuyenteId as string || '').trim();
|
||||
const contribuyenteId = (req.query.contribuyenteId as string || '').replace(/[^a-f0-9-]/gi, '');
|
||||
const pool = req.tenantPool!;
|
||||
|
||||
// RFC del tenant despacho para excluirlo (no se factura a sí mismo)
|
||||
@@ -719,10 +726,17 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
|
||||
});
|
||||
const tenantRfc = tenant?.rfc || '';
|
||||
|
||||
// Búsqueda en el catálogo completo de RFCs. El contribuyente activo solo
|
||||
// filtra CFDIs relacionados / PPD, no el autocompleto de RFCs — de lo
|
||||
// contrario no se podría facturar a un cliente nuevo que nunca haya
|
||||
// aparecido en un CFDI previo.
|
||||
const params: any[] = [tenantRfc, `%${q}%`];
|
||||
let whereContribuyente = '';
|
||||
if (contribuyenteId) {
|
||||
params.push(contribuyenteId);
|
||||
whereContribuyente = `AND id IN (
|
||||
SELECT rfc_receptor_id FROM cfdis WHERE contribuyente_id = $${params.length} AND rfc_receptor_id IS NOT NULL
|
||||
UNION
|
||||
SELECT rfc_emisor_id FROM cfdis WHERE contribuyente_id = $${params.length} AND rfc_emisor_id IS NOT NULL
|
||||
)`;
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, rfc, razon_social as "razonSocial",
|
||||
regimen_fiscal as "regimenFiscal",
|
||||
@@ -730,9 +744,10 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
|
||||
FROM rfcs
|
||||
WHERE rfc != $1
|
||||
AND (rfc ILIKE $2 OR razon_social ILIKE $2)
|
||||
${whereContribuyente}
|
||||
ORDER BY razon_social
|
||||
LIMIT 10
|
||||
`, [tenantRfc, `%${q}%`]);
|
||||
`, params);
|
||||
|
||||
res.json(rows);
|
||||
} catch (error) { next(error); }
|
||||
|
||||
@@ -4,15 +4,10 @@ import { AppError } from '../middlewares/error.middleware.js';
|
||||
import * as papeleriaService from '../services/papeleria.service.js';
|
||||
import { emailService } from '../services/email/email.service.js';
|
||||
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
|
||||
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
function rejectClienteRole(req: Request): void {
|
||||
if (req.user?.role === 'cliente') {
|
||||
throw new AppError(403, 'Papelería no disponible para usuarios cliente');
|
||||
}
|
||||
}
|
||||
|
||||
function effectiveTenantId(req: Request): string {
|
||||
return req.viewingTenantId || req.user!.tenantId;
|
||||
}
|
||||
@@ -24,6 +19,7 @@ const uploadSchema = z.object({
|
||||
anio: z.number().int().min(2000).max(2100),
|
||||
mes: z.number().int().min(1).max(12),
|
||||
requiereAprobacion: z.boolean(),
|
||||
requiereAprobacionCliente: z.boolean(),
|
||||
archivoBase64: z.string().min(1),
|
||||
archivoFilename: z.string().min(1).max(255),
|
||||
archivoMime: z.string().min(1).max(100),
|
||||
@@ -31,7 +27,9 @@ const uploadSchema = z.object({
|
||||
|
||||
export async function upload(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
if (req.user?.role === 'cliente') {
|
||||
throw new AppError(403, 'Los clientes no pueden subir documentos de papelería');
|
||||
}
|
||||
const data = uploadSchema.parse(req.body);
|
||||
const archivo = Buffer.from(data.archivoBase64, 'base64');
|
||||
|
||||
@@ -42,18 +40,23 @@ export async function upload(req: Request, res: Response, next: NextFunction) {
|
||||
anio: data.anio,
|
||||
mes: data.mes,
|
||||
requiereAprobacion: data.requiereAprobacion,
|
||||
requiereAprobacionCliente: data.requiereAprobacionCliente,
|
||||
archivo,
|
||||
archivoFilename: data.archivoFilename,
|
||||
archivoMime: data.archivoMime,
|
||||
subidoPor: req.user!.userId,
|
||||
});
|
||||
|
||||
// Notificación a aprobadores si la papelería requiere aprobación.
|
||||
if (item.requiereAprobacion) {
|
||||
notifyAprobacionRequerida(req, item).catch(err =>
|
||||
console.error('[papeleria.upload] notify aprobadores failed:', err?.message || err),
|
||||
);
|
||||
}
|
||||
if (item.requiereAprobacionCliente) {
|
||||
notifyClienteAprobacionRequerida(req, item).catch(err =>
|
||||
console.error('[papeleria.upload] notify clientes failed:', err?.message || err),
|
||||
);
|
||||
}
|
||||
|
||||
res.status(201).json(item);
|
||||
} catch (error: any) {
|
||||
@@ -74,13 +77,20 @@ const listSchema = z.object({
|
||||
|
||||
export async function list(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
const q = listSchema.parse(req.query);
|
||||
const entidadIds = await getEntidadesVisibles(
|
||||
req.tenantPool!, req.user!.userId, req.user!.role,
|
||||
);
|
||||
if (!entidadIds.includes(q.contribuyenteId)) {
|
||||
return res.json([]);
|
||||
}
|
||||
const items = await papeleriaService.listPapeleria(req.tenantPool!, {
|
||||
contribuyenteId: q.contribuyenteId,
|
||||
anio: q.anio ? parseInt(q.anio, 10) : undefined,
|
||||
mes: q.mes ? parseInt(q.mes, 10) : undefined,
|
||||
estado: q.estado,
|
||||
entidadIds,
|
||||
userRole: req.user!.role,
|
||||
});
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
@@ -91,9 +101,22 @@ export async function list(req: Request, res: Response, next: NextFunction) {
|
||||
|
||||
export async function download(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||
|
||||
const item = await papeleriaService.getById(req.tenantPool!, id);
|
||||
if (!item) return next(new AppError(404, 'Documento no encontrado'));
|
||||
|
||||
const entidadIds = await getEntidadesVisibles(
|
||||
req.tenantPool!, req.user!.userId, req.user!.role,
|
||||
);
|
||||
if (!entidadIds.includes(item.contribuyenteId)) {
|
||||
return next(new AppError(403, 'No tienes acceso a este documento'));
|
||||
}
|
||||
if (req.user!.role === 'cliente' && !item.requiereAprobacionCliente) {
|
||||
return next(new AppError(403, 'No tienes acceso a este documento'));
|
||||
}
|
||||
|
||||
const file = await papeleriaService.downloadArchivo(req.tenantPool!, id);
|
||||
if (!file) return next(new AppError(404, 'Documento no encontrado'));
|
||||
res.setHeader('Content-Type', file.mime);
|
||||
@@ -106,7 +129,9 @@ export async function download(req: Request, res: Response, next: NextFunction)
|
||||
|
||||
export async function aprobar(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
if (req.user?.role === 'cliente') {
|
||||
throw new AppError(403, 'Los clientes no pueden usar este endpoint');
|
||||
}
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||
const item = await papeleriaService.aprobar(
|
||||
@@ -127,7 +152,9 @@ const rechazarSchema = z.object({ comentario: z.string().max(2000).nullable().op
|
||||
|
||||
export async function rechazar(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
if (req.user?.role === 'cliente') {
|
||||
throw new AppError(403, 'Los clientes no pueden usar este endpoint');
|
||||
}
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||
const { comentario } = rechazarSchema.parse(req.body);
|
||||
@@ -146,9 +173,63 @@ export async function rechazar(req: Request, res: Response, next: NextFunction)
|
||||
}
|
||||
}
|
||||
|
||||
export async function aprobarCliente(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user?.role !== 'cliente') {
|
||||
throw new AppError(403, 'Solo clientes pueden usar este endpoint');
|
||||
}
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||
|
||||
const entidadIds = await getEntidadesVisibles(
|
||||
req.tenantPool!, req.user!.userId, req.user!.role,
|
||||
);
|
||||
const itemCheck = await papeleriaService.getById(req.tenantPool!, id);
|
||||
if (!itemCheck || !entidadIds.includes(itemCheck.contribuyenteId) || !itemCheck.requiereAprobacionCliente) {
|
||||
return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
|
||||
}
|
||||
|
||||
const item = await papeleriaService.aprobarCliente(req.tenantPool!, id, req.user!.userId);
|
||||
if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
|
||||
res.json(item);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rechazarCliente(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user?.role !== 'cliente') {
|
||||
throw new AppError(403, 'Solo clientes pueden usar este endpoint');
|
||||
}
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||
const { comentario } = rechazarSchema.parse(req.body);
|
||||
|
||||
const entidadIds = await getEntidadesVisibles(
|
||||
req.tenantPool!, req.user!.userId, req.user!.role,
|
||||
);
|
||||
const itemCheck = await papeleriaService.getById(req.tenantPool!, id);
|
||||
if (!itemCheck || !entidadIds.includes(itemCheck.contribuyenteId) || !itemCheck.requiereAprobacionCliente) {
|
||||
return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
|
||||
}
|
||||
|
||||
const item = await papeleriaService.rechazarCliente(
|
||||
req.tenantPool!, id, req.user!.userId, comentario ?? null,
|
||||
);
|
||||
if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
|
||||
res.json(item);
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function eliminar(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
if (req.user?.role === 'cliente') {
|
||||
throw new AppError(403, 'Los clientes no pueden eliminar documentos');
|
||||
}
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||
const ok = await papeleriaService.eliminar(req.tenantPool!, id);
|
||||
@@ -161,22 +242,26 @@ export async function eliminar(req: Request, res: Response, next: NextFunction)
|
||||
|
||||
// ─── Notificaciones ───
|
||||
|
||||
async function getContribuyenteInfo(req: Request, contribuyenteId: string) {
|
||||
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
|
||||
`SELECT c.rfc, eg.nombre FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
WHERE c.entidad_id = $1`,
|
||||
[contribuyenteId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifica a owners y supervisores cuando una papelería requiere aprobación.
|
||||
* Owners se obtienen de tenant_memberships (BD central). Supervisores se
|
||||
* resuelven leyendo carteras del tenant.
|
||||
*/
|
||||
async function notifyAprobacionRequerida(
|
||||
req: Request,
|
||||
item: papeleriaService.PapeleriaItem,
|
||||
): Promise<void> {
|
||||
const tenantId = effectiveTenantId(req);
|
||||
|
||||
// Owners del despacho
|
||||
const recipients = new Set<string>(await getTenantOwnerEmails(tenantId));
|
||||
|
||||
// Supervisores: cualquier user con rol 'supervisor' o 'cfo' que pertenezca a este tenant.
|
||||
// Buscamos vía tenant_memberships + roles.
|
||||
const supervisores = await prisma.tenantMembership.findMany({
|
||||
where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } },
|
||||
include: { user: { select: { email: true, active: true } } },
|
||||
@@ -185,23 +270,15 @@ async function notifyAprobacionRequerida(
|
||||
if (m.user.active && m.user.email) recipients.add(m.user.email);
|
||||
}
|
||||
|
||||
// No notificarse a sí mismo
|
||||
recipients.delete(req.user!.email);
|
||||
|
||||
if (recipients.size === 0) return;
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { nombre: true },
|
||||
});
|
||||
|
||||
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
|
||||
`SELECT c.rfc, eg.nombre FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
WHERE c.entidad_id = $1`,
|
||||
[item.contribuyenteId],
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
const info = await getContribuyenteInfo(req, item.contribuyenteId);
|
||||
if (!info) return;
|
||||
|
||||
const link = `${env.FRONTEND_URL}/documentos`;
|
||||
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
|
||||
@@ -210,8 +287,8 @@ async function notifyAprobacionRequerida(
|
||||
for (const to of recipients) {
|
||||
try {
|
||||
await emailService.sendPapeleriaAprobacionRequerida(to, {
|
||||
contribuyenteRfc: rows[0].rfc,
|
||||
contribuyenteNombre: rows[0].nombre,
|
||||
contribuyenteRfc: info.rfc,
|
||||
contribuyenteNombre: info.nombre,
|
||||
despachoNombre: tenant?.nombre,
|
||||
nombreDocumento: item.nombre,
|
||||
descripcion: item.descripcion,
|
||||
@@ -226,9 +303,7 @@ async function notifyAprobacionRequerida(
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifica al uploader (auxiliar) cuando un documento que él subió fue
|
||||
* aprobado o rechazado. Solo manda si quien aprobó/rechazó NO es el mismo
|
||||
* uploader (caso edge: owner sube su propia papelería).
|
||||
* Notifica al uploader cuando un documento fue aprobado o rechazado por owner/supervisor.
|
||||
*/
|
||||
async function notifyDecisionAuxiliar(
|
||||
req: Request,
|
||||
@@ -238,21 +313,16 @@ async function notifyDecisionAuxiliar(
|
||||
const auxiliarEmail = await getUserEmailById(item.subidoPor);
|
||||
if (!auxiliarEmail) return;
|
||||
|
||||
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
|
||||
`SELECT c.rfc, eg.nombre FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
WHERE c.entidad_id = $1`,
|
||||
[item.contribuyenteId],
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
const info = await getContribuyenteInfo(req, item.contribuyenteId);
|
||||
if (!info) return;
|
||||
|
||||
const link = `${env.FRONTEND_URL}/documentos`;
|
||||
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
|
||||
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
|
||||
|
||||
await emailService.sendPapeleriaDecision(auxiliarEmail, {
|
||||
contribuyenteRfc: rows[0].rfc,
|
||||
contribuyenteNombre: rows[0].nombre,
|
||||
contribuyenteRfc: info.rfc,
|
||||
contribuyenteNombre: info.nombre,
|
||||
nombreDocumento: item.nombre,
|
||||
estado: item.estado as 'aprobado' | 'rechazado',
|
||||
revisor: req.user!.email,
|
||||
@@ -261,3 +331,57 @@ async function notifyDecisionAuxiliar(
|
||||
link,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifica a los usuarios cliente asociados al contribuyente cuando un documento
|
||||
* requiere su aprobación.
|
||||
*/
|
||||
async function notifyClienteAprobacionRequerida(
|
||||
req: Request,
|
||||
item: papeleriaService.PapeleriaItem,
|
||||
): Promise<void> {
|
||||
const tenantId = effectiveTenantId(req);
|
||||
|
||||
// Obtener user_ids de clientes con acceso a este contribuyente
|
||||
const { rows } = await req.tenantPool!.query<{ user_id: string }>(
|
||||
`SELECT user_id FROM cliente_accesos WHERE entidad_id = $1`,
|
||||
[item.contribuyenteId],
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const userIds = rows.map(r => r.user_id);
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: userIds }, active: true },
|
||||
select: { email: true },
|
||||
});
|
||||
const recipients = users.map(u => u.email).filter(Boolean) as string[];
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { nombre: true },
|
||||
});
|
||||
const info = await getContribuyenteInfo(req, item.contribuyenteId);
|
||||
if (!info) return;
|
||||
|
||||
const link = `${env.FRONTEND_URL}/documentos`;
|
||||
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
|
||||
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
|
||||
|
||||
for (const to of recipients) {
|
||||
try {
|
||||
await emailService.sendPapeleriaAprobacionClienteRequerida(to, {
|
||||
contribuyenteRfc: info.rfc,
|
||||
contribuyenteNombre: info.nombre,
|
||||
despachoNombre: tenant?.nombre,
|
||||
nombreDocumento: item.nombre,
|
||||
descripcion: item.descripcion,
|
||||
periodo,
|
||||
subidoPor: req.user!.email,
|
||||
link,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(`[Email] papeleria-aprobacion-cliente a ${to}:`, err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
import * as usuariosService from '../services/usuarios.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
const inviteSchema = z.object({
|
||||
email: z.string().email('email inválido'),
|
||||
@@ -64,11 +65,16 @@ export async function getAllUsuarios(req: Request, res: Response, next: NextFunc
|
||||
|
||||
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'owner') {
|
||||
throw new AppError(403, 'Solo los dueños pueden invitar usuarios');
|
||||
if (!['owner', 'cfo', 'supervisor'].includes(req.user!.role)) {
|
||||
throw new AppError(403, 'No autorizado para invitar usuarios');
|
||||
}
|
||||
const data = inviteSchema.parse(req.body);
|
||||
|
||||
// Los supervisores solo pueden invitar clientes
|
||||
if (req.user!.role === 'supervisor' && data.role !== 'cliente') {
|
||||
throw new AppError(403, 'Los supervisores solo pueden invitar clientes');
|
||||
}
|
||||
|
||||
// Validate: auxiliar requires a supervisor
|
||||
if (data.role === 'auxiliar' && !data.supervisorUserId) {
|
||||
throw new AppError(400, 'Debes asignar un supervisor al auxiliar');
|
||||
@@ -139,7 +145,16 @@ export async function getSupervisor(req: Request, res: Response, next: NextFunct
|
||||
LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
res.json({ supervisorUserId: rows[0]?.supervisor_user_id ?? null });
|
||||
const supervisorUserId = rows[0]?.supervisor_user_id ?? null;
|
||||
let supervisorNombre: string | null = null;
|
||||
if (supervisorUserId) {
|
||||
const u = await prisma.user.findUnique({
|
||||
where: { id: supervisorUserId },
|
||||
select: { nombre: true },
|
||||
});
|
||||
supervisorNombre = u?.nombre ?? null;
|
||||
}
|
||||
res.json({ supervisorUserId, supervisorNombre });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,21 @@ import { despachoPlanTieneDualidadDb } from '../services/plan-catalogo.service.j
|
||||
import { emailService } from '../services/email/email.service.js';
|
||||
import { getTenantOwnerEmail } from '../utils/memberships.js';
|
||||
|
||||
/**
|
||||
* Calcula la siguiente fecha de fin de período según la frecuencia.
|
||||
* Usa el mismo algoritmo que Mercado Pago: mismo día del mes siguiente,
|
||||
* ajustando al último día si el mes destino tiene menos días.
|
||||
*/
|
||||
function computeNextPeriodEnd(date: Date, frequency: string): Date {
|
||||
const d = new Date(date);
|
||||
if (frequency === 'monthly') {
|
||||
d.setMonth(d.getMonth() + 1);
|
||||
} else if (frequency === 'annual' || frequency === 'yearly') {
|
||||
d.setFullYear(d.getFullYear() + 1);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { type, data } = req.body;
|
||||
@@ -187,9 +202,20 @@ async function handlePaymentNotification(paymentId: string) {
|
||||
// precio de renewal. Se detecta comparando el monto cobrado contra lo que
|
||||
// `getPlanPrice(phase='firstYear')` devolvería para este plan.
|
||||
const esPrimerPago = subscription.status === 'pending';
|
||||
const updateData: { status: string; currentPeriodEnd?: Date } = { status: 'authorized' };
|
||||
|
||||
// Extender currentPeriodEnd para renovaciones recurrentes.
|
||||
// El primer pago ya tiene currentPeriodEnd establecido al crear la suscripción;
|
||||
// solo extendemos en pagos subsecuentes para reflejar el nuevo período cobrado.
|
||||
if (!esPrimerPago && subscription.currentPeriodEnd) {
|
||||
const nextPeriodEnd = computeNextPeriodEnd(subscription.currentPeriodEnd, subscription.frequency);
|
||||
updateData.currentPeriodEnd = nextPeriodEnd;
|
||||
console.log(`[WEBHOOK] Subscription ${subscription.id} extended to ${nextPeriodEnd.toISOString()} (${subscription.frequency})`);
|
||||
}
|
||||
|
||||
await prisma.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: { status: 'authorized' },
|
||||
data: updateData,
|
||||
});
|
||||
subscriptionService.invalidateSubscriptionCache(tenantId);
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ import { resetExpiredMonthlyTimbres } from '../services/facturapi.service.js';
|
||||
import { purgeDeclaracionesAntiguas } from '../services/declaraciones.service.js';
|
||||
import { consultarConstancia, purgeConstanciasAntiguas } from '../services/constancia.service.js';
|
||||
import { tenantDb } from '../config/database.js';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días
|
||||
const RECOVERY_CRON_SCHEDULE = '0 10 * * *'; // 10:00 AM todos los días
|
||||
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
|
||||
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM
|
||||
const CSF_CRON_SCHEDULE = '0 4 1 * *'; // Día 1 de cada mes 04:00 AM (CSF mensual)
|
||||
@@ -20,6 +22,38 @@ const EXPIRY_REMINDERS_CRON = '0 9 * * *'; // 9:00 AM diario — avisos p
|
||||
|
||||
let isRunning = false;
|
||||
let isIncrementalRunning = false;
|
||||
let isRecoveryRunning = false;
|
||||
|
||||
/**
|
||||
* Verifica si un tenant tiene FIEL a nivel tenant (legacy Horux 360)
|
||||
* o a nivel contribuyente (modelo despacho).
|
||||
*/
|
||||
async function hasAnyFielConfigured(tenantId: string, databaseName?: string | null): Promise<boolean> {
|
||||
// 1) FIEL legacy a nivel tenant
|
||||
const hasLegacy = await hasFielConfigured(tenantId);
|
||||
if (hasLegacy) return true;
|
||||
|
||||
// 2) FIEL por contribuyente (modelo despacho)
|
||||
if (!databaseName) {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { databaseName: true },
|
||||
});
|
||||
databaseName = tenant?.databaseName;
|
||||
}
|
||||
if (!databaseName) return false;
|
||||
|
||||
try {
|
||||
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT 1 FROM fiel_contribuyente WHERE is_active = true LIMIT 1`
|
||||
);
|
||||
return rows.length > 0;
|
||||
} catch (err: any) {
|
||||
console.error(`[SAT Cron] Error verificando FIEL contribuyente para tenant ${tenantId}:`, err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene los tenants que tienen FIEL configurada y activa
|
||||
@@ -27,13 +61,13 @@ let isIncrementalRunning = false;
|
||||
async function getTenantsWithFiel(): Promise<string[]> {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
|
||||
const tenantsWithFiel: string[] = [];
|
||||
|
||||
for (const tenant of tenants) {
|
||||
const hasFiel = await hasFielConfigured(tenant.id);
|
||||
const hasFiel = await hasAnyFielConfigured(tenant.id, tenant.databaseName);
|
||||
if (hasFiel) {
|
||||
tenantsWithFiel.push(tenant.id);
|
||||
}
|
||||
@@ -172,12 +206,12 @@ async function getTenantsConSatIncremental(): Promise<string[]> {
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true, plan: { in: planNames as any } },
|
||||
select: { id: true },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
|
||||
const result: string[] = [];
|
||||
for (const tenant of tenants) {
|
||||
if (await hasFielConfigured(tenant.id)) {
|
||||
if (await hasAnyFielConfigured(tenant.id, tenant.databaseName)) {
|
||||
result.push(tenant.id);
|
||||
}
|
||||
}
|
||||
@@ -351,12 +385,153 @@ async function runCsfJob(): Promise<void> {
|
||||
console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message);
|
||||
failed++;
|
||||
}
|
||||
// Delay entre tenants para no saturar al SAT y reducir bloqueos por IP
|
||||
await new Promise(r => setTimeout(r, 30_000));
|
||||
}
|
||||
console.log(`[CSF Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`);
|
||||
}
|
||||
|
||||
function getYesterdayEnd(): Date {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59);
|
||||
}
|
||||
|
||||
async function hasIncompleteCfdis(pool: Pool, contribuyenteId: string): Promise<boolean> {
|
||||
const { rows } = await pool.query<{ count: string }>(`
|
||||
SELECT COUNT(*)::text as count
|
||||
FROM cfdis
|
||||
WHERE contribuyente_id = $1
|
||||
AND status = 'Vigente'
|
||||
AND tipo_comprobante IN ('I', 'E')
|
||||
AND xml_original IS NULL
|
||||
`, [contribuyenteId]);
|
||||
return Number(rows[0]?.count || 0) > 0;
|
||||
}
|
||||
|
||||
async function getOldestIncompleteCfdiDate(pool: Pool, contribuyenteId: string): Promise<Date | null> {
|
||||
const { rows } = await pool.query<{ fecha_emision: Date | null }>(`
|
||||
SELECT MIN(fecha_emision) as fecha_emision
|
||||
FROM cfdis
|
||||
WHERE contribuyente_id = $1
|
||||
AND status = 'Vigente'
|
||||
AND tipo_comprobante IN ('I', 'E')
|
||||
AND xml_original IS NULL
|
||||
`, [contribuyenteId]);
|
||||
return rows[0]?.fecha_emision || null;
|
||||
}
|
||||
|
||||
async function waitForRecoveryJob(jobId: string): Promise<void> {
|
||||
while (true) {
|
||||
const job = await prisma.satSyncJob.findUnique({ where: { id: jobId } });
|
||||
if (!job || job.status === 'completed' || job.status === 'failed') {
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 60000));
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverContribuyente(tenantId: string, databaseName: string, contribuyenteId: string): Promise<void> {
|
||||
try {
|
||||
const status = await getSyncStatus(tenantId, contribuyenteId);
|
||||
if (status.hasActiveSync) {
|
||||
console.log(`[SAT Recovery] ${contribuyenteId} tiene sync activo, omitiendo`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||
const hasIncomplete = await hasIncompleteCfdis(pool, contribuyenteId);
|
||||
|
||||
const lastDaily = await prisma.satSyncJob.findFirst({
|
||||
where: { tenantId, contribuyenteId, type: 'daily' },
|
||||
orderBy: { startedAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!hasIncomplete && lastDaily?.status !== 'failed') {
|
||||
return;
|
||||
}
|
||||
|
||||
const dateTo = getYesterdayEnd();
|
||||
let dateFrom = new Date(dateTo.getFullYear() - 1, dateTo.getMonth(), dateTo.getDate());
|
||||
|
||||
if (hasIncomplete) {
|
||||
const oldest = await getOldestIncompleteCfdiDate(pool, contribuyenteId);
|
||||
if (oldest) {
|
||||
dateFrom = new Date(oldest.getFullYear(), oldest.getMonth(), 1);
|
||||
dateFrom.setMonth(dateFrom.getMonth() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[SAT Recovery] Recuperando ${contribuyenteId}: ${dateFrom.toISOString()} → ${dateTo.toISOString()}`);
|
||||
const jobId = await startSync(tenantId, 'initial', dateFrom, dateTo, contribuyenteId);
|
||||
console.log(`[SAT Recovery] Job ${jobId} iniciado`);
|
||||
|
||||
await waitForRecoveryJob(jobId);
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT Recovery] Error recuperando ${contribuyenteId}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverTenant(tenantId: string): Promise<void> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { databaseName: true },
|
||||
});
|
||||
if (!tenant?.databaseName) return;
|
||||
|
||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||
const { rows } = await pool.query<{ entidad_id: string }>('SELECT entidad_id FROM contribuyentes');
|
||||
const contribuyenteIds = rows.map(r => r.entidad_id);
|
||||
|
||||
if (contribuyenteIds.length === 0) {
|
||||
const status = await getSyncStatus(tenantId);
|
||||
if (status.hasActiveSync) return;
|
||||
const lastDaily = await prisma.satSyncJob.findFirst({
|
||||
where: { tenantId, contribuyenteId: null, type: 'daily' },
|
||||
orderBy: { startedAt: 'desc' },
|
||||
});
|
||||
if (lastDaily?.status === 'failed') {
|
||||
const dateTo = getYesterdayEnd();
|
||||
const dateFrom = new Date(dateTo.getFullYear() - 1, dateTo.getMonth(), dateTo.getDate());
|
||||
console.log(`[SAT Recovery] Recuperando tenant legacy ${tenantId}`);
|
||||
const jobId = await startSync(tenantId, 'initial', dateFrom, dateTo);
|
||||
await waitForRecoveryJob(jobId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const contribuyenteId of contribuyenteIds) {
|
||||
await recoverContribuyente(tenantId, tenant.databaseName, contribuyenteId);
|
||||
}
|
||||
}
|
||||
|
||||
async function runRecoverySyncJob(): Promise<void> {
|
||||
if (isRecoveryRunning) {
|
||||
console.log('[SAT Recovery] Ya en ejecución, omitiendo');
|
||||
return;
|
||||
}
|
||||
|
||||
isRecoveryRunning = true;
|
||||
console.log('[SAT Recovery] Iniciando job de recuperación');
|
||||
|
||||
try {
|
||||
const tenantIds = await getTenantsWithFiel();
|
||||
console.log(`[SAT Recovery] ${tenantIds.length} tenants con FIEL`);
|
||||
|
||||
for (const tenantId of tenantIds) {
|
||||
await recoverTenant(tenantId);
|
||||
}
|
||||
|
||||
console.log('[SAT Recovery] Job de recuperación completado');
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Recovery] Error:', error.message);
|
||||
} finally {
|
||||
isRecoveryRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let retryTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let recoveryTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let opinionTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let csfTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let incrementalTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
@@ -397,6 +572,19 @@ export function startSatSyncJob(): void {
|
||||
timezone: 'America/Mexico_City',
|
||||
});
|
||||
|
||||
// Cron de recuperación: 10:00 AM diario. Revisa si el sync diario falló o si
|
||||
// hay CFDIs vigentes sin XML, y relanza un sync `initial` con rango extendido
|
||||
// para completar los XML faltantes.
|
||||
recoveryTask = cron.schedule(RECOVERY_CRON_SCHEDULE, async () => {
|
||||
try {
|
||||
await runRecoverySyncJob();
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Recovery Cron] Error:', error.message);
|
||||
}
|
||||
}, {
|
||||
timezone: 'America/Mexico_City',
|
||||
});
|
||||
|
||||
// Cron watchdog: cada 2h marca como `failed` los jobs que quedaron stale
|
||||
// (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás).
|
||||
// Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS)
|
||||
@@ -502,6 +690,7 @@ export function startSatSyncJob(): void {
|
||||
|
||||
console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
console.log(`[SAT Cron] Retry programado cada hora`);
|
||||
console.log(`[SAT Recovery Cron] Programado para: ${RECOVERY_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
console.log(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
console.log(`[CSF Cron] Programado para: ${CSF_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
console.log(`[SAT Cron Inc] Incremental Enterprise programado para: ${INCREMENTAL_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
@@ -521,6 +710,10 @@ export function stopSatSyncJob(): void {
|
||||
retryTask.stop();
|
||||
retryTask = null;
|
||||
}
|
||||
if (recoveryTask) {
|
||||
recoveryTask.stop();
|
||||
recoveryTask = null;
|
||||
}
|
||||
if (opinionTask) {
|
||||
opinionTask.stop();
|
||||
opinionTask = null;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migración 047: Renombrar SUELDOS → ISN en declaraciones existentes
|
||||
-- Fecha: 2026-05-24
|
||||
--
|
||||
-- El campo impuestos es TEXT[]. Se usa array_replace para actualizar
|
||||
-- declaraciones históricas que tenían 'SUELDOS' como impuesto cubierto.
|
||||
|
||||
UPDATE declaraciones_provisionales
|
||||
SET impuestos = array_replace(impuestos, 'SUELDOS', 'ISN')
|
||||
WHERE 'SUELDOS' = ANY(impuestos);
|
||||
11
apps/api/src/migrations/tenant/048_cfdis_activos_indices.sql
Normal file
11
apps/api/src/migrations/tenant/048_cfdis_activos_indices.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Índices para acelerar los filtros de "Considerar activos" en Impuestos.
|
||||
|
||||
-- Lookup rápido de facturas tipo I con uso de activo fijo
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_tipo_uso_activos
|
||||
ON cfdis(tipo_comprobante, uso_cfdi)
|
||||
WHERE tipo_comprobante = 'I' AND uso_cfdi IN ('I01','I02','I03','I04','I05','I06','I07','I08');
|
||||
|
||||
-- Filtrar E's que tienen relacionados (reduce el universo del anti-join)
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_tipo_relacionados
|
||||
ON cfdis(tipo_comprobante)
|
||||
WHERE cfdis_relacionados IS NOT NULL;
|
||||
11
apps/api/src/migrations/tenant/049_cfdis_relaciones_gin.sql
Normal file
11
apps/api/src/migrations/tenant/049_cfdis_relaciones_gin.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Índices GIN para acelerar búsquedas de activos en cfdis_relacionados y uuid_relacionado.
|
||||
-- El filtro "Considerar activos" usa string_to_array(..., '|') para buscar UUIDs
|
||||
-- relacionados; el índice GIN permite búsquedas @> y ANY eficientes sobre arrays.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_relacionados_gin
|
||||
ON cfdis USING gin(string_to_array(LOWER(cfdis_relacionados), '|'))
|
||||
WHERE cfdis_relacionados IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_uuid_relacionado_gin
|
||||
ON cfdis USING gin(string_to_array(LOWER(uuid_relacionado), '|'))
|
||||
WHERE uuid_relacionado IS NOT NULL;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Papelería de trabajo: aprobación independiente por cliente
|
||||
|
||||
ALTER TABLE papeleria_trabajo
|
||||
ADD COLUMN IF NOT EXISTS requiere_aprobacion_cliente boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS estado_cliente varchar(20)
|
||||
CHECK (estado_cliente IS NULL OR estado_cliente IN ('pendiente','aprobado','rechazado')),
|
||||
ADD COLUMN IF NOT EXISTS aprobado_por_cliente uuid,
|
||||
ADD COLUMN IF NOT EXISTS aprobado_at_cliente timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS comentario_rechazo_cliente text;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_papeleria_estado_cliente
|
||||
ON papeleria_trabajo(estado_cliente)
|
||||
WHERE estado_cliente IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_papeleria_requiere_cliente
|
||||
ON papeleria_trabajo(contribuyente_id, requiere_aprobacion_cliente)
|
||||
WHERE requiere_aprobacion_cliente = true;
|
||||
|
||||
INSERT INTO tenant_migrations (scope, version, name)
|
||||
VALUES ('vertical-contable', 50, '050_papeleria_aprobacion_cliente')
|
||||
ON CONFLICT (scope, version) DO NOTHING;
|
||||
@@ -16,6 +16,7 @@ router.get('/supervisores', authorize('owner'), ctrl.getSupervisores);
|
||||
router.get('/asignaciones', authorize('owner', 'supervisor'), asignacionesCtrl.listPorSupervisor);
|
||||
router.get('/asignaciones/mias', authorize('auxiliar'), asignacionesCtrl.listPorAuxiliar);
|
||||
router.get('/asignaciones/sin-asignar', authorize('owner', 'supervisor'), asignacionesCtrl.listSinAsignar);
|
||||
router.get('/asignaciones/auxiliares-elegibles/:contribuyenteId', authorize('owner', 'supervisor'), asignacionesCtrl.listAuxiliaresElegibles);
|
||||
|
||||
// Read: owner + supervisor + auxiliar
|
||||
router.get('/', authorize('owner', 'supervisor', 'auxiliar'), ctrl.list);
|
||||
|
||||
@@ -23,6 +23,7 @@ router.get('/conceptos', cfdiController.listConceptos);
|
||||
router.get('/:id', cfdiController.getCfdiById);
|
||||
router.get('/:id/conceptos', cfdiController.getConceptos);
|
||||
router.get('/:id/xml', cfdiController.getXml);
|
||||
router.post('/download-xmls', cfdiController.downloadXmlsZip);
|
||||
router.post('/', checkCfdiLimit, cfdiController.createCfdi);
|
||||
// Bulk upload: 10/hora — procesa hasta 50MB, pesado en parseo + inserts
|
||||
router.post('/bulk', strictLimit, express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis);
|
||||
|
||||
@@ -14,7 +14,7 @@ router.use(tenantMiddleware);
|
||||
|
||||
// === Static routes FIRST (before /:id to avoid route conflict) ===
|
||||
router.get('/', ctrl.list);
|
||||
router.post('/', authorize('owner', 'cfo'), ctrl.create);
|
||||
router.post('/', authorize('owner', 'cfo', 'supervisor'), ctrl.create);
|
||||
router.post('/backfill', authorize('owner'), ctrl.backfill);
|
||||
router.get('/catalogo-obligaciones', obligacionesCtrl.getCatalogo);
|
||||
|
||||
@@ -25,14 +25,14 @@ router.delete('/:id', authorize('owner'), ctrl.deactivate);
|
||||
router.post('/:id/cliente-acceso', authorize('owner', 'supervisor'), ctrl.addClienteAcceso);
|
||||
|
||||
// FIEL per contribuyente
|
||||
router.post('/:id/fiel', authorize('owner', 'cfo'), configCtrl.uploadFiel);
|
||||
router.post('/:id/fiel', authorize('owner', 'cfo', 'supervisor'), configCtrl.uploadFiel);
|
||||
router.get('/:id/fiel/status', configCtrl.fielStatus);
|
||||
router.delete('/:id/fiel', authorize('owner', 'cfo'), configCtrl.deleteFiel);
|
||||
router.delete('/:id/fiel', authorize('owner', 'cfo', 'supervisor'), configCtrl.deleteFiel);
|
||||
|
||||
// Facturapi per contribuyente
|
||||
router.post('/:id/facturapi/org', authorize('owner', 'cfo'), configCtrl.createOrg);
|
||||
router.get('/:id/facturapi/status', configCtrl.orgStatus);
|
||||
router.post('/:id/facturapi/csd', authorize('owner', 'cfo'), configCtrl.uploadCsd);
|
||||
router.post('/:id/facturapi/csd', authorize('owner', 'cfo', 'supervisor'), configCtrl.uploadCsd);
|
||||
|
||||
// Personalización per contribuyente
|
||||
router.get('/:id/facturapi/customization', facturacionCtrl.getCustomizationContribuyenteCtrl);
|
||||
@@ -42,10 +42,10 @@ router.put('/:id/facturapi/color', authorize('owner', 'cfo'), facturacionCtrl.up
|
||||
// Obligaciones fiscales per contribuyente
|
||||
router.get('/:id/obligaciones/periodo', obligacionesCtrl.getObligacionesPorPeriodo);
|
||||
router.get('/:id/obligaciones', obligacionesCtrl.getObligaciones);
|
||||
router.post('/:id/obligaciones/init', authorize('owner', 'cfo'), obligacionesCtrl.initRecomendaciones);
|
||||
router.post('/:id/obligaciones', authorize('owner', 'cfo'), obligacionesCtrl.addObligacion);
|
||||
router.delete('/:id/obligaciones/:obligacionId', authorize('owner', 'cfo'), obligacionesCtrl.removeObligacion);
|
||||
router.post('/:id/obligaciones/:obligacionId/restore', authorize('owner', 'cfo'), obligacionesCtrl.restoreObligacion);
|
||||
router.post('/:id/obligaciones/init', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.initRecomendaciones);
|
||||
router.post('/:id/obligaciones', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.addObligacion);
|
||||
router.delete('/:id/obligaciones/:obligacionId', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.removeObligacion);
|
||||
router.post('/:id/obligaciones/:obligacionId/restore', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.restoreObligacion);
|
||||
router.post('/:id/obligaciones/:obligacionId/complete', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completeObligacion);
|
||||
router.post('/:id/obligaciones/:obligacionId/uncomplete', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompleteObligacion);
|
||||
router.post('/:id/obligaciones/:obligacionId/complete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completePeriodo);
|
||||
|
||||
@@ -13,6 +13,8 @@ router.post('/', ctrl.upload);
|
||||
router.get('/:id/download', ctrl.download);
|
||||
router.post('/:id/aprobar', ctrl.aprobar);
|
||||
router.post('/:id/rechazar', ctrl.rechazar);
|
||||
router.post('/:id/aprobar-cliente', ctrl.aprobarCliente);
|
||||
router.post('/:id/rechazar-cliente', ctrl.rechazarCliente);
|
||||
router.delete('/:id', ctrl.eliminar);
|
||||
|
||||
export { router as papeleriaRoutes };
|
||||
|
||||
@@ -24,44 +24,62 @@
|
||||
* el de activos aplica también pero algunos predicados son no-op funcional
|
||||
* en subqueries que filtran por tipo_comprobante específico (Postgres los
|
||||
* optimiza away).
|
||||
*
|
||||
* OPTIMIZACIÓN: los subqueries de exclusiones de activos se reescribieron
|
||||
* para usar subqueries NO-correlacionados donde sea posible (casos 1-3).
|
||||
* Esto permite a PostgreSQL ejecutar el subquery una sola vez por query
|
||||
* principal, en lugar de una vez por cada fila. Solo el caso 4 (anticipo
|
||||
* referenciado por I07) requiere un correlated EXISTS.
|
||||
*/
|
||||
|
||||
const ACTIVOS_USOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
|
||||
|
||||
/**
|
||||
* Subquery no-correlacionado que devuelve todos los UUIDs de facturas tipo I
|
||||
* con uso de activo. Usado para lookups P→I y E→I.
|
||||
*/
|
||||
const UUIDS_ACTIVOS = `SELECT LOWER(uuid) AS uuid FROM cfdis WHERE tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS}`;
|
||||
|
||||
/**
|
||||
* Subquery no-correlacionado que devuelve todos los UUIDs de E's que
|
||||
* referencian un activo (directamente I-activo, o indirectamente P→I-activo).
|
||||
*
|
||||
* Usa JOIN + UNION en lugar de EXISTS + OR para que PostgreSQL pueda usar
|
||||
* índices de forma más efectiva (especialmente el GIN en cfdis_relacionados).
|
||||
*/
|
||||
const UUIDS_E_DE_ACTIVOS = `
|
||||
SELECT e.uuid
|
||||
FROM cfdis e
|
||||
JOIN cfdis r_act ON LOWER(r_act.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||
WHERE e.tipo_comprobante = 'E'
|
||||
AND e.cfdis_relacionados IS NOT NULL
|
||||
AND r_act.tipo_comprobante = 'I'
|
||||
AND r_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||
UNION ALL
|
||||
SELECT e.uuid
|
||||
FROM cfdis e
|
||||
JOIN cfdis r_act ON LOWER(r_act.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||
JOIN cfdis pi_act ON LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
|
||||
WHERE e.tipo_comprobante = 'E'
|
||||
AND e.cfdis_relacionados IS NOT NULL
|
||||
AND r_act.tipo_comprobante = 'P'
|
||||
AND pi_act.tipo_comprobante = 'I'
|
||||
AND pi_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Predicado SQL que detecta si el row actual (sin alias de tabla, asume
|
||||
* `FROM cfdis`) referencia un activo directamente (I), indirectamente vía
|
||||
* pago (P→I), o transitivamente vía relación (E→I, E→P→I).
|
||||
*
|
||||
* IMPORTANTE — qualifying outer refs: dentro de los subqueries `cfdis i_act`
|
||||
* y `cfdis r_act`, la tabla interna también tiene columnas `uuid_relacionado`
|
||||
* y `cfdis_relacionados`. Una referencia no-qualificada las resolvería a las
|
||||
* columnas internas (NO al row outer), volviendo el predicado a no-op.
|
||||
* Por eso usamos `cfdis.uuid_relacionado` y `cfdis.cfdis_relacionados`
|
||||
* explícitamente — fuerza la resolución al outer.
|
||||
*/
|
||||
function activosExclusionNoAlias(): string {
|
||||
return `
|
||||
AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS})
|
||||
AND NOT (tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis i_act
|
||||
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
|
||||
AND i_act.tipo_comprobante = 'I'
|
||||
AND i_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||
))
|
||||
AND NOT (tipo_comprobante = 'E' AND cfdis.cfdis_relacionados IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM cfdis r_act
|
||||
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
|
||||
AND (
|
||||
(r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS_USOS})
|
||||
OR (r_act.tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis pi_act
|
||||
WHERE LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
|
||||
AND pi_act.tipo_comprobante = 'I'
|
||||
AND pi_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||
))
|
||||
)
|
||||
SELECT 1 FROM (${UUIDS_ACTIVOS}) ua
|
||||
WHERE ua.uuid = ANY(string_to_array(LOWER(uuid_relacionado), '|'))
|
||||
))
|
||||
AND NOT (tipo_comprobante = 'E' AND uuid IN (${UUIDS_E_DE_ACTIVOS}))
|
||||
AND NOT (tipo_comprobante = 'I' AND EXISTS (
|
||||
-- Anticipo: CFDI tipo I (puede no tener uso_cfdi de activo) que es
|
||||
-- referenciado por una I/07 PPD con uso_cfdi de activo. La I/07 PPD
|
||||
@@ -87,24 +105,10 @@ function activosExclusionAlias(alias: string): string {
|
||||
return `
|
||||
AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ${ACTIVOS_USOS})
|
||||
AND NOT (${alias}.tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis i_act
|
||||
WHERE LOWER(i_act.uuid) = LOWER(${alias}.uuid_relacionado)
|
||||
AND i_act.tipo_comprobante = 'I'
|
||||
AND i_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||
))
|
||||
AND NOT (${alias}.tipo_comprobante = 'E' AND ${alias}.cfdis_relacionados IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM cfdis r_act
|
||||
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(${alias}.cfdis_relacionados), '|'))
|
||||
AND (
|
||||
(r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS_USOS})
|
||||
OR (r_act.tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis pi_act
|
||||
WHERE LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
|
||||
AND pi_act.tipo_comprobante = 'I'
|
||||
AND pi_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||
))
|
||||
)
|
||||
SELECT 1 FROM (${UUIDS_ACTIVOS}) ua
|
||||
WHERE ua.uuid = ANY(string_to_array(LOWER(${alias}.uuid_relacionado), '|'))
|
||||
))
|
||||
AND NOT (${alias}.tipo_comprobante = 'E' AND ${alias}.uuid IN (${UUIDS_E_DE_ACTIVOS}))
|
||||
AND NOT (${alias}.tipo_comprobante = 'I' AND EXISTS (
|
||||
SELECT 1 FROM cfdis i07_act
|
||||
WHERE i07_act.tipo_comprobante = 'I'
|
||||
|
||||
@@ -66,11 +66,13 @@ export async function getClientesStats(range: ClientesStatsRange): Promise<Clien
|
||||
paymentsCount: payments._count,
|
||||
};
|
||||
|
||||
// 3) Clientes que NO renovaron: subs cuyo currentPeriodEnd cae en el rango
|
||||
// y que están en status terminal (cancelled, trial_expired, paused) o sin
|
||||
// payment posterior aprobado. Nota: un sub `authorized` con periodEnd
|
||||
// pasado es un "se renovó automáticamente" — para detectar no-renovaciones
|
||||
// miramos status efectivo + ausencia de payment en los siguientes 7 días.
|
||||
// 3) Clientes que NO renovaron:
|
||||
// a) Subs cuyo currentPeriodEnd cae en el rango y están en status terminal
|
||||
// (cancelled, trial_expired, paused).
|
||||
// b) Tenants cuyo trialEndsAt ya pasó y NO tienen suscripción authorized
|
||||
// (incluye trials que nunca convirtieron o cuya sub fue borrada).
|
||||
// c) Tenants con sub trial vencida (currentPeriodEnd < ahora) que nunca
|
||||
// fue marcada trial_expired por el cron.
|
||||
const subsExpiradas = await prisma.subscription.findMany({
|
||||
where: {
|
||||
currentPeriodEnd: { gte: range.from, lte: range.to },
|
||||
@@ -84,14 +86,99 @@ export async function getClientesStats(range: ClientesStatsRange): Promise<Clien
|
||||
tenant: { select: { id: true, nombre: true, rfc: true } },
|
||||
},
|
||||
});
|
||||
const noRenovaciones = subsExpiradas.map(s => ({
|
||||
tenantId: s.tenantId,
|
||||
tenantNombre: s.tenant?.nombre ?? '',
|
||||
rfc: s.tenant?.rfc ?? '',
|
||||
plan: String(s.plan),
|
||||
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '',
|
||||
statusActual: s.status,
|
||||
}));
|
||||
|
||||
const noRenovacionesMap = new Map<string, ClientesStats['noRenovaciones'][number]>();
|
||||
for (const s of subsExpiradas) {
|
||||
noRenovacionesMap.set(s.tenantId, {
|
||||
tenantId: s.tenantId,
|
||||
tenantNombre: s.tenant?.nombre ?? '',
|
||||
rfc: s.tenant?.rfc ?? '',
|
||||
plan: String(s.plan),
|
||||
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '',
|
||||
statusActual: s.status,
|
||||
});
|
||||
}
|
||||
|
||||
// b + c) Trials vencidos / sin suscripción activa / subs borradas
|
||||
const now = new Date();
|
||||
const tenantsConSubAutorizada = new Set(
|
||||
(await prisma.subscription.findMany({
|
||||
where: { status: 'authorized' },
|
||||
select: { tenantId: true },
|
||||
})).map(s => s.tenantId)
|
||||
);
|
||||
const excluded = Array.from(tenantsConSubAutorizada);
|
||||
|
||||
// Tenants con trialEndsAt pasado y sin sub authorized
|
||||
const tenantsTrialsVencidos = await prisma.tenant.findMany({
|
||||
where: {
|
||||
trialEndsAt: { lt: now },
|
||||
id: { notIn: excluded },
|
||||
},
|
||||
select: { id: true, nombre: true, rfc: true, plan: true, trialEndsAt: true },
|
||||
});
|
||||
for (const t of tenantsTrialsVencidos) {
|
||||
if (noRenovacionesMap.has(t.id)) continue;
|
||||
noRenovacionesMap.set(t.id, {
|
||||
tenantId: t.id,
|
||||
tenantNombre: t.nombre,
|
||||
rfc: t.rfc ?? '',
|
||||
plan: String(t.plan ?? 'trial'),
|
||||
currentPeriodEnd: t.trialEndsAt?.toISOString() ?? '',
|
||||
statusActual: 'trial_expired',
|
||||
});
|
||||
}
|
||||
|
||||
// Tenants con sub trial vencida (currentPeriodEnd < ahora) que nunca fue
|
||||
// marcada trial_expired por el cron, y no tienen otra sub authorized.
|
||||
const subsTrialVencidas = await prisma.subscription.findMany({
|
||||
where: {
|
||||
status: 'trial',
|
||||
currentPeriodEnd: { lt: now },
|
||||
tenantId: { notIn: excluded },
|
||||
},
|
||||
select: {
|
||||
tenantId: true,
|
||||
plan: true,
|
||||
currentPeriodEnd: true,
|
||||
tenant: { select: { id: true, nombre: true, rfc: true } },
|
||||
},
|
||||
});
|
||||
for (const s of subsTrialVencidas) {
|
||||
if (noRenovacionesMap.has(s.tenantId)) continue;
|
||||
noRenovacionesMap.set(s.tenantId, {
|
||||
tenantId: s.tenantId,
|
||||
tenantNombre: s.tenant?.nombre ?? '',
|
||||
rfc: s.tenant?.rfc ?? '',
|
||||
plan: String(s.plan),
|
||||
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '',
|
||||
statusActual: 'trial_expired',
|
||||
});
|
||||
}
|
||||
|
||||
// Tenants con plan de pago asignado manualmente (plan != 'trial') pero
|
||||
// sin NINGUNA suscripción. Indica que nunca iniciaron el flujo de pago.
|
||||
const tenantsConPlanPeroSinSub = await prisma.tenant.findMany({
|
||||
where: {
|
||||
plan: { not: 'trial' },
|
||||
id: { notIn: excluded },
|
||||
subscriptions: { none: {} },
|
||||
},
|
||||
select: { id: true, nombre: true, rfc: true, plan: true, createdAt: true },
|
||||
});
|
||||
for (const t of tenantsConPlanPeroSinSub) {
|
||||
if (noRenovacionesMap.has(t.id)) continue;
|
||||
noRenovacionesMap.set(t.id, {
|
||||
tenantId: t.id,
|
||||
tenantNombre: t.nombre,
|
||||
rfc: t.rfc ?? '',
|
||||
plan: String(t.plan),
|
||||
currentPeriodEnd: t.createdAt.toISOString(),
|
||||
statusActual: 'sin_suscripcion',
|
||||
});
|
||||
}
|
||||
|
||||
const noRenovaciones = Array.from(noRenovacionesMap.values());
|
||||
|
||||
// 4) Usuarios por cliente (memberships activos por tenant)
|
||||
const memberships = await prisma.tenantMembership.findMany({
|
||||
|
||||
@@ -609,30 +609,46 @@ async function alertaOpinionCumplimiento(pool: Pool, contribuyenteId?: string |
|
||||
|
||||
/**
|
||||
* Genera todas las alertas automáticas para un tenant.
|
||||
* Cada alerta se envuelve en try/catch para que un fallo en una no
|
||||
* bloquee el resto (robustez ante timeouts o errores transitorios).
|
||||
*/
|
||||
export async function generarAlertasAutomaticas(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<AlertaAuto[]> {
|
||||
const alertas = await Promise.all([
|
||||
alertaListaNegraPropia(pool, tenantId, contribuyenteId),
|
||||
alertaClienteListaNegra(pool, contribuyenteId),
|
||||
alertaProveedorListaNegra(pool, contribuyenteId),
|
||||
alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId),
|
||||
alertaConcentracionClientes(pool, contribuyenteId),
|
||||
alertaConcentracionProveedores(pool, contribuyenteId),
|
||||
alertaRiesgoCambiario(pool, contribuyenteId),
|
||||
alertaRiesgoCancelaciones(pool, contribuyenteId),
|
||||
alertaRiesgoTransaccional(pool, contribuyenteId),
|
||||
alertaCancelacionPeriodoAnterior(pool, contribuyenteId),
|
||||
alertaOpinionCumplimiento(pool, contribuyenteId),
|
||||
alertaTipoRelacionSospechosa(pool, contribuyenteId),
|
||||
alertaTareasProximasVencer(pool, contribuyenteId),
|
||||
alertaResicoPfLimiteIngresos(pool, contribuyenteId),
|
||||
]);
|
||||
const generadores: { name: string; fn: () => Promise<AlertaAuto | null> }[] = [
|
||||
{ name: 'lista-negra-propia', fn: () => alertaListaNegraPropia(pool, tenantId, contribuyenteId) },
|
||||
{ name: 'lista-negra-clientes', fn: () => alertaClienteListaNegra(pool, contribuyenteId) },
|
||||
{ name: 'lista-negra-proveedores', fn: () => alertaProveedorListaNegra(pool, contribuyenteId) },
|
||||
{ name: 'discrepancia-regimen', fn: () => alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId) },
|
||||
{ name: 'concentracion-clientes', fn: () => alertaConcentracionClientes(pool, contribuyenteId) },
|
||||
{ name: 'concentracion-proveedores', fn: () => alertaConcentracionProveedores(pool, contribuyenteId) },
|
||||
{ name: 'riesgo-cambiario', fn: () => alertaRiesgoCambiario(pool, contribuyenteId) },
|
||||
{ name: 'riesgo-cancelaciones', fn: () => alertaRiesgoCancelaciones(pool, contribuyenteId) },
|
||||
{ name: 'riesgo-transaccional', fn: () => alertaRiesgoTransaccional(pool, contribuyenteId) },
|
||||
{ name: 'cancelacion-periodo-anterior', fn: () => alertaCancelacionPeriodoAnterior(pool, contribuyenteId) },
|
||||
{ name: 'opinion-cumplimiento', fn: () => alertaOpinionCumplimiento(pool, contribuyenteId) },
|
||||
{ name: 'tipo-relacion-sospechosa', fn: () => alertaTipoRelacionSospechosa(pool, contribuyenteId) },
|
||||
{ name: 'tareas-proximas-vencer', fn: () => alertaTareasProximasVencer(pool, contribuyenteId) },
|
||||
{ name: 'resico-pf-limite-ingresos', fn: () => alertaResicoPfLimiteIngresos(pool, contribuyenteId) },
|
||||
];
|
||||
|
||||
return alertas.filter((a): a is AlertaAuto => a !== null);
|
||||
const alertas: AlertaAuto[] = [];
|
||||
for (const g of generadores) {
|
||||
try {
|
||||
const a = await g.fn();
|
||||
if (a) alertas.push(a);
|
||||
} catch (err: any) {
|
||||
console.error(`[AlertasAuto] Fallo ${g.name} (tenant=${tenantId}, contribuyente=${contribuyenteId}):`, err.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
if (alertas.length > 0) {
|
||||
console.log(`[AlertasAuto] tenant=${tenantId} contribuyente=${contribuyenteId || 'null'} generadas=${alertas.map(a => a.id).join(', ')}`);
|
||||
}
|
||||
|
||||
return alertas;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -96,12 +96,32 @@ export async function getAsignacionesPorSupervisor(
|
||||
): Promise<{ obligaciones: AsignacionObligacion[]; tareas: AsignacionTarea[] }> {
|
||||
const isOwner = role === 'owner' || role === 'cfo' || role === 'contador';
|
||||
|
||||
// Relación supervisor → auxiliar se infiere desde carteras (directas y
|
||||
// subcarteras) con fallback a la tabla legacy auxiliar_supervisores.
|
||||
const supervisorFilter = isOwner
|
||||
? ''
|
||||
: `AND EXISTS (
|
||||
SELECT 1 FROM (
|
||||
SELECT c.auxiliar_user_id
|
||||
FROM carteras c
|
||||
WHERE c.supervisor_user_id = $1
|
||||
AND c.auxiliar_user_id IS NOT NULL
|
||||
UNION
|
||||
SELECT sub.auxiliar_user_id
|
||||
FROM carteras sub
|
||||
JOIN carteras p ON p.id = sub.parent_id
|
||||
WHERE p.supervisor_user_id = $1
|
||||
AND sub.auxiliar_user_id IS NOT NULL
|
||||
UNION
|
||||
SELECT auxiliar_user_id FROM auxiliar_supervisores WHERE supervisor_user_id = $1
|
||||
) sup_aux WHERE sup_aux.auxiliar_user_id = __AUX_COL__
|
||||
)`;
|
||||
const whereObl = isOwner
|
||||
? 'WHERE 1=1'
|
||||
: 'WHERE EXISTS (SELECT 1 FROM auxiliar_supervisores asp WHERE asp.auxiliar_user_id = oa.auxiliar_user_id AND asp.supervisor_user_id = $1)';
|
||||
: `WHERE 1=1 ${supervisorFilter.replace(/__AUX_COL__/g, 'oa.auxiliar_user_id')}`;
|
||||
const whereTarea = isOwner
|
||||
? 'WHERE 1=1'
|
||||
: 'WHERE EXISTS (SELECT 1 FROM auxiliar_supervisores asp WHERE asp.auxiliar_user_id = ta.auxiliar_user_id AND asp.supervisor_user_id = $1)';
|
||||
: `WHERE 1=1 ${supervisorFilter.replace(/__AUX_COL__/g, 'ta.auxiliar_user_id')}`;
|
||||
const params = isOwner ? [] : [supervisorUserId];
|
||||
|
||||
const { rows: obligaciones } = await pool.query<AsignacionObligacion>(
|
||||
@@ -301,3 +321,23 @@ export async function getAuxiliarAsignadoTarea(
|
||||
const names = await resolveUserNames([auxId]);
|
||||
return { auxiliarUserId: auxId, auxiliarNombre: names.get(auxId) ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve los userIds de auxiliares que tienen al contribuyente en alguna
|
||||
* de sus subcarteras (carteras con auxiliar_user_id no nulo que contienen
|
||||
* al contribuyente en cartera_entidades).
|
||||
*/
|
||||
export async function getAuxiliaresElegibles(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
): Promise<string[]> {
|
||||
const { rows } = await pool.query<{ auxiliar_user_id: string }>(
|
||||
`SELECT DISTINCT c.auxiliar_user_id
|
||||
FROM carteras c
|
||||
JOIN cartera_entidades ce ON ce.cartera_id = c.id
|
||||
WHERE ce.entidad_id = $1
|
||||
AND c.auxiliar_user_id IS NOT NULL`,
|
||||
[contribuyenteId],
|
||||
);
|
||||
return rows.map(r => r.auxiliar_user_id);
|
||||
}
|
||||
|
||||
@@ -357,6 +357,81 @@ export async function getXmlById(pool: Pool, id: string): Promise<string | null>
|
||||
return rows[0]?.xml_original || null;
|
||||
}
|
||||
|
||||
export async function getXmlsByIds(pool: Pool, ids: number[]): Promise<{ id: number; uuid: string; xml: string | null }[]> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, uuid, xml_original FROM cfdis WHERE id = ANY($1)
|
||||
`, [ids]);
|
||||
return rows.map((r: any) => ({ id: r.id, uuid: r.uuid, xml: r.xml_original || null }));
|
||||
}
|
||||
|
||||
export async function getCfdiXmlsForZip(
|
||||
pool: Pool,
|
||||
filters: CfdiFilters
|
||||
): Promise<{ uuid: string; xml: string | null }[]> {
|
||||
let whereClause = 'WHERE xml_original IS NOT NULL';
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters.tipo && !filters.contribuyenteId) {
|
||||
whereClause += ` AND type = $${paramIndex++}`;
|
||||
params.push(filters.tipo);
|
||||
}
|
||||
if (filters.tipoComprobante) {
|
||||
whereClause += ` AND tipo_comprobante = $${paramIndex++}`;
|
||||
params.push(filters.tipoComprobante);
|
||||
}
|
||||
if (filters.estado) {
|
||||
whereClause += ` AND status = $${paramIndex++}`;
|
||||
params.push(filters.estado);
|
||||
}
|
||||
if (filters.fechaInicio) {
|
||||
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
|
||||
params.push(filters.fechaInicio);
|
||||
}
|
||||
if (filters.fechaFin) {
|
||||
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
|
||||
params.push(filters.fechaFin);
|
||||
}
|
||||
if (filters.rfc) {
|
||||
whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`;
|
||||
params.push(`%${filters.rfc}%`);
|
||||
}
|
||||
if (filters.emisor) {
|
||||
whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex++})`;
|
||||
params.push(`%${filters.emisor}%`);
|
||||
}
|
||||
if (filters.receptor) {
|
||||
whereClause += ` AND (rfc_receptor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`;
|
||||
params.push(`%${filters.receptor}%`);
|
||||
}
|
||||
if (filters.search) {
|
||||
whereClause += ` AND (uuid ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex} OR rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`;
|
||||
params.push(`%${filters.search}%`);
|
||||
}
|
||||
if (filters.contribuyenteId) {
|
||||
if (filters.tipo === 'EMITIDO') {
|
||||
whereClause += ` AND rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`;
|
||||
params.push(filters.contribuyenteId);
|
||||
} else if (filters.tipo === 'RECIBIDO') {
|
||||
whereClause += ` AND rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`;
|
||||
params.push(filters.contribuyenteId);
|
||||
} else {
|
||||
whereClause += ` AND (contribuyente_id = $${paramIndex} OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex}) OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++}))`;
|
||||
params.push(filters.contribuyenteId);
|
||||
}
|
||||
}
|
||||
|
||||
params.push(1000);
|
||||
const { rows } = await pool.query(`
|
||||
SELECT uuid, xml_original FROM cfdis
|
||||
${whereClause}
|
||||
ORDER BY fecha_emision DESC
|
||||
LIMIT $${paramIndex++}
|
||||
`, params);
|
||||
|
||||
return rows.map((r: any) => ({ uuid: r.uuid, xml: r.xml_original || null }));
|
||||
}
|
||||
|
||||
export interface CreateCfdiData {
|
||||
uuid: string;
|
||||
type: 'EMITIDO' | 'RECIBIDO';
|
||||
|
||||
@@ -44,6 +44,9 @@ function rowToConstancia(r: any): ConstanciaRow {
|
||||
* sincroniza automáticamente domicilio + regímenes activos con lo que reporta
|
||||
* el SAT. El auto-fill NO es destructivo para datos custom del usuario:
|
||||
* solo sobreescribe campos si la CSF tiene un valor no-vacío.
|
||||
*
|
||||
* Incluye retry con backoff (3 intentos) para robustez ante timeouts
|
||||
* transitorios del portal SAT (mantenimiento nocturno, congestión, etc.).
|
||||
*/
|
||||
export async function consultarConstancia(tenantId: string): Promise<ConstanciaRow> {
|
||||
const fiel = await getDecryptedFiel(tenantId);
|
||||
@@ -55,72 +58,78 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
|
||||
});
|
||||
if (!tenant) throw new Error('Tenant no encontrado');
|
||||
|
||||
const tempId = randomUUID();
|
||||
const tempDir = join(tmpdir(), `horux-csf-${tempId}`);
|
||||
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
|
||||
const cerPath = join(tempDir, 'cert.cer');
|
||||
const keyPath = join(tempDir, 'key.key');
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAYS = [5_000, 15_000, 30_000]; // backoff
|
||||
|
||||
try {
|
||||
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
|
||||
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
const tempId = randomUUID();
|
||||
const tempDir = join(tmpdir(), `horux-csf-${tempId}`);
|
||||
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
|
||||
const cerPath = join(tempDir, 'cert.cer');
|
||||
const keyPath = join(tempDir, 'key.key');
|
||||
|
||||
// Headless por default. El fix de dispatchEvent en sat-csf-login cubre el
|
||||
// caso donde el click sintético no dispara el handler del SAT. Si algún
|
||||
// ambiente necesita ver el browser (debug), setear SAT_HEADLESS=false.
|
||||
const headless = process.env.SAT_HEADLESS !== 'false';
|
||||
const browser = await chromium.launch({
|
||||
headless,
|
||||
args: ['--disable-blink-features=AutomationControlled'],
|
||||
ignoreDefaultArgs: ['--enable-automation'],
|
||||
});
|
||||
try {
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT),
|
||||
);
|
||||
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
|
||||
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
|
||||
|
||||
const resultPromise = (async () => {
|
||||
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc);
|
||||
const pdfBuffer = await extractCsfPdf(session);
|
||||
const csf = await parseCsfPdf(pdfBuffer);
|
||||
|
||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO constancias_situacion_fiscal
|
||||
(rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision,
|
||||
datos, fecha_consulta, created_at`,
|
||||
[
|
||||
csf.rfc,
|
||||
csf.idCIF,
|
||||
csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null,
|
||||
csf.estatusPadron,
|
||||
csf.lugarFechaEmision,
|
||||
JSON.stringify(csf),
|
||||
pdfBuffer,
|
||||
],
|
||||
const headless = process.env.SAT_HEADLESS !== 'false';
|
||||
const browser = await chromium.launch({
|
||||
headless,
|
||||
args: ['--disable-blink-features=AutomationControlled'],
|
||||
ignoreDefaultArgs: ['--enable-automation'],
|
||||
});
|
||||
try {
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 5 minutos')), 300_000),
|
||||
);
|
||||
|
||||
// Auto-fill domicilio del tenant + regímenes activos desde el CSF.
|
||||
// Se hace después del INSERT para que si algo falla en la sincronización
|
||||
// la CSF ya quedó guardada y el usuario puede verla.
|
||||
await sincronizarDatosFiscales(tenantId, csf).catch(err => {
|
||||
console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err);
|
||||
});
|
||||
const resultPromise = (async () => {
|
||||
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc);
|
||||
const pdfBuffer = await extractCsfPdf(session);
|
||||
const csf = await parseCsfPdf(pdfBuffer);
|
||||
|
||||
return rowToConstancia(rows[0]);
|
||||
})();
|
||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO constancias_situacion_fiscal
|
||||
(rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision,
|
||||
datos, fecha_consulta, created_at`,
|
||||
[
|
||||
csf.rfc,
|
||||
csf.idCIF,
|
||||
csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null,
|
||||
csf.estatusPadron,
|
||||
csf.lugarFechaEmision,
|
||||
JSON.stringify(csf),
|
||||
pdfBuffer,
|
||||
],
|
||||
);
|
||||
|
||||
return await Promise.race([resultPromise, timeoutPromise]);
|
||||
await sincronizarDatosFiscales(tenantId, csf).catch(err => {
|
||||
console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err);
|
||||
});
|
||||
|
||||
return rowToConstancia(rows[0]);
|
||||
})();
|
||||
|
||||
return await Promise.race([resultPromise, timeoutPromise]);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} catch (err: any) {
|
||||
const willRetry = attempt < MAX_RETRIES - 1;
|
||||
console.error(`[CSF] Intento ${attempt + 1}/${MAX_RETRIES} falló para tenant ${tenantId}: ${err.message}${willRetry ? ` — reintentando en ${RETRY_DELAYS[attempt]}ms...` : ''}`);
|
||||
if (!willRetry) throw err;
|
||||
await new Promise(r => setTimeout(r, RETRY_DELAYS[attempt]));
|
||||
} finally {
|
||||
await browser.close();
|
||||
try { unlinkSync(cerPath); } catch { /* ok */ }
|
||||
try { unlinkSync(keyPath); } catch { /* ok */ }
|
||||
try { rmdirSync(tempDir); } catch { /* ok */ }
|
||||
}
|
||||
} finally {
|
||||
try { unlinkSync(cerPath); } catch { /* ok */ }
|
||||
try { unlinkSync(keyPath); } catch { /* ok */ }
|
||||
try { rmdirSync(tempDir); } catch { /* ok */ }
|
||||
}
|
||||
|
||||
throw new Error('No debería llegar aquí');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1107,10 +1107,21 @@ export async function getKpis(
|
||||
const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId);
|
||||
const esEmisor = ctx.esEmisor;
|
||||
const esReceptor = ctx.esReceptor;
|
||||
const ingresosData = await calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId);
|
||||
const egresosData = await calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId);
|
||||
const adquisicionData = await calcularAdquisicionesMercancias(pool, tenantId, fechaInicio, fechaFin, conciliacion, contribuyenteId);
|
||||
const ivaData = await calcularIvaBalancePorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId);
|
||||
const [
|
||||
ingresosData,
|
||||
egresosData,
|
||||
adquisicionData,
|
||||
ivaData,
|
||||
ncsEmitidasData,
|
||||
ncsRecibidasData,
|
||||
] = await Promise.all([
|
||||
calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
|
||||
calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
|
||||
calcularAdquisicionesMercancias(pool, tenantId, fechaInicio, fechaFin, conciliacion, contribuyenteId),
|
||||
calcularIvaBalancePorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
|
||||
calcularNcsEmitidasPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
|
||||
calcularNcsRecibidasPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
|
||||
]);
|
||||
|
||||
// IVA a favor año actual: desde enero del año en curso
|
||||
const ivaAFavorAcumulado = await calcularIvaAFavorAcumulado(pool, tenantId, fechaFin, undefined, conciliacion, contribuyenteId);
|
||||
@@ -1163,6 +1174,10 @@ export async function getKpis(
|
||||
cfdisEmitidosPorRegimen: emitidosPorRegimen,
|
||||
cfdisRecibidos: recibidosPorRegimen.reduce((s: number, r: any) => s + r.total, 0),
|
||||
cfdisRecibidosPorRegimen: recibidosPorRegimen,
|
||||
ncsEmitidas: ncsEmitidasData.total,
|
||||
ncsEmitidasPorRegimen: ncsEmitidasData.porRegimen,
|
||||
ncsRecibidas: ncsRecibidasData.total,
|
||||
ncsRecibidasPorRegimen: ncsRecibidasData.porRegimen,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ const IMPUESTO_A_OBLIGACION_KEYWORDS: Record<string, { include: string[]; exclud
|
||||
IVA: { include: ['iva'], exclude: ['diot', 'proveedores de iva', 'informativa'] },
|
||||
ISR: { include: ['isr'], exclude: ['retenciones', 'asimilados a salarios'] },
|
||||
IEPS: { include: ['ieps'], exclude: [] },
|
||||
SUELDOS: { include: ['sueldos', 'salarios', 'nómina'], exclude: [] },
|
||||
ISN: { include: ['isn', 'sueldos', 'salarios', 'nómina'], exclude: [] },
|
||||
ISH: { include: [], exclude: [] },
|
||||
DIOT: { include: ['diot', 'proveedores de iva'], exclude: [] },
|
||||
OTRO: { include: [], exclude: [] },
|
||||
};
|
||||
@@ -93,7 +94,7 @@ async function completarObligacionesPorDeclaracion(
|
||||
* adicional, no reemplaza.
|
||||
*/
|
||||
|
||||
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'SUELDOS' | 'DIOT' | 'OTRO';
|
||||
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'ISN' | 'DIOT' | 'OTRO' | 'ISH';
|
||||
|
||||
export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'semestral' | 'anual';
|
||||
|
||||
@@ -123,17 +124,19 @@ const IMPUESTO_A_PREFIJO_DECL: Record<string, string[]> = {
|
||||
IVA: ['decl-iva'],
|
||||
ISR: ['decl-isr'],
|
||||
IEPS: ['decl-ieps'],
|
||||
SUELDOS: ['decl-sueldos'],
|
||||
ISN: ['decl-isn'],
|
||||
DIOT: ['diot'],
|
||||
OTRO: [],
|
||||
ISH: [],
|
||||
};
|
||||
const IMPUESTO_A_PREFIJO_PAGO: Record<string, string[]> = {
|
||||
IVA: ['pago-iva'],
|
||||
ISR: ['pago-isr'],
|
||||
IEPS: ['pago-ieps'],
|
||||
SUELDOS: [], // sueldos solo es declaración informativa, no tiene pago provisional
|
||||
ISN: [], // ISN solo es declaración informativa, no tiene pago provisional
|
||||
DIOT: [],
|
||||
OTRO: [],
|
||||
ISH: [],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { materializarPeriodos } from './tareas.service.js';
|
||||
|
||||
export interface ContribuyentesStats {
|
||||
totalContribuyentes: number;
|
||||
@@ -210,30 +211,62 @@ export async function getMisAsignados(
|
||||
const inicioMes = `${_año}-${String(_mes).padStart(2, '0')}-01`;
|
||||
const finMes = new Date(_año, _mes, 0).toISOString().split('T')[0];
|
||||
|
||||
// Materializar periodos de tareas antes de contar (evita que tareas sin
|
||||
// registro en tarea_periodos aparezcan como 0).
|
||||
await Promise.all(ids.map(id => materializarPeriodos(pool, id).catch(() => {})));
|
||||
|
||||
const { rows: stats } = await pool.query(
|
||||
`WITH obl AS (
|
||||
SELECT oc.contribuyente_id,
|
||||
COUNT(*) FILTER (WHERE op.completada = false AND op.periodo = $1)::int AS pendientes,
|
||||
COUNT(*) FILTER (WHERE op.completada = false AND op.periodo < $1)::int AS atrasadas,
|
||||
COUNT(*) FILTER (WHERE op.completada = true AND op.periodo = $1)::int AS completadas
|
||||
FROM obligaciones_contribuyente oc
|
||||
LEFT JOIN obligacion_periodos op ON op.obligacion_id = oc.id
|
||||
WHERE oc.contribuyente_id = ANY($4::uuid[]) AND oc.activa = true
|
||||
GROUP BY oc.contribuyente_id
|
||||
`WITH obligaciones_activas AS (
|
||||
SELECT id, contribuyente_id FROM obligaciones_contribuyente
|
||||
WHERE contribuyente_id = ANY($4::uuid[]) AND activa = true
|
||||
),
|
||||
op_actual AS (
|
||||
SELECT obligacion_id, completada FROM obligacion_periodos
|
||||
WHERE obligacion_id IN (SELECT id FROM obligaciones_activas) AND periodo = $1
|
||||
),
|
||||
op_atrasadas AS (
|
||||
SELECT obligacion_id, COUNT(*) as atrasadas FROM obligacion_periodos
|
||||
WHERE obligacion_id IN (SELECT id FROM obligaciones_activas) AND periodo < $1 AND completada = false
|
||||
GROUP BY obligacion_id
|
||||
),
|
||||
obl AS (
|
||||
SELECT oa.contribuyente_id,
|
||||
COUNT(*) FILTER (WHERE op_a.completada IS NULL OR op_a.completada = false)::int AS pendientes,
|
||||
COALESCE(SUM(op_atr.atrasadas), 0)::int AS atrasadas,
|
||||
COUNT(*) FILTER (WHERE op_a.completada = true)::int AS completadas
|
||||
FROM obligaciones_activas oa
|
||||
LEFT JOIN op_actual op_a ON op_a.obligacion_id = oa.id
|
||||
LEFT JOIN op_atrasadas op_atr ON op_atr.obligacion_id = oa.id
|
||||
GROUP BY oa.contribuyente_id
|
||||
),
|
||||
tareas_activas AS (
|
||||
SELECT id, contribuyente_id FROM tareas_catalogo
|
||||
WHERE contribuyente_id = ANY($4::uuid[]) AND active = true
|
||||
),
|
||||
tar_actual AS (
|
||||
SELECT tarea_id, completada FROM tarea_periodos
|
||||
WHERE tarea_id IN (SELECT id FROM tareas_activas)
|
||||
AND fecha_limite BETWEEN $2::date AND $3::date
|
||||
),
|
||||
tar_atrasadas AS (
|
||||
SELECT tarea_id, COUNT(*) as atrasadas FROM tarea_periodos
|
||||
WHERE tarea_id IN (SELECT id FROM tareas_activas)
|
||||
AND fecha_limite < $2::date AND completada = false
|
||||
GROUP BY tarea_id
|
||||
),
|
||||
tar AS (
|
||||
SELECT tc.contribuyente_id,
|
||||
COUNT(*) FILTER (WHERE tp.completada = false AND tp.fecha_limite BETWEEN $2::date AND $3::date)::int AS pendientes,
|
||||
COUNT(*) FILTER (WHERE tp.completada = false AND tp.fecha_limite < $2::date)::int AS atrasadas,
|
||||
COUNT(*) FILTER (WHERE tp.completada = true AND tp.fecha_limite BETWEEN $2::date AND $3::date)::int AS completadas
|
||||
FROM tareas_catalogo tc
|
||||
LEFT JOIN tarea_periodos tp ON tp.tarea_id = tc.id
|
||||
WHERE tc.contribuyente_id = ANY($4::uuid[]) AND tc.active = true
|
||||
GROUP BY tc.contribuyente_id
|
||||
SELECT ta.contribuyente_id,
|
||||
COUNT(*) FILTER (WHERE tar_a.completada IS NULL OR tar_a.completada = false)::int AS pendientes,
|
||||
COALESCE(SUM(tar_atr.atrasadas), 0)::int AS atrasadas,
|
||||
COUNT(*) FILTER (WHERE tar_a.completada = true)::int AS completadas
|
||||
FROM tareas_activas ta
|
||||
LEFT JOIN tar_actual tar_a ON tar_a.tarea_id = ta.id
|
||||
LEFT JOIN tar_atrasadas tar_atr ON tar_atr.tarea_id = ta.id
|
||||
GROUP BY ta.contribuyente_id
|
||||
)
|
||||
SELECT
|
||||
obl.contribuyente_id AS obl_id, obl.pendientes AS obl_pen, obl.atrasadas AS obl_atr, obl.completadas AS obl_com,
|
||||
tar.contribuyente_id AS tar_id, tar.pendientes AS tar_pen, tar.atrasadas AS tar_atr, tar.completadas AS tar_com
|
||||
obl.contribuyente_id AS obl_id, obl.pendientes AS obl_pen, obl.atrasadas AS obl_atr, obl.completadas AS obl_com,
|
||||
tar.contribuyente_id AS tar_id, tar.pendientes AS tar_pen, tar.atrasadas AS tar_atr, tar.completadas AS tar_com
|
||||
FROM obl
|
||||
FULL OUTER JOIN tar ON tar.contribuyente_id = obl.contribuyente_id`,
|
||||
[periodoMes, inicioMes, finMes, ids],
|
||||
|
||||
@@ -193,6 +193,19 @@ export const emailService = {
|
||||
);
|
||||
},
|
||||
|
||||
/** Clientes reciben aviso cuando se sube papelería que requiere su aprobación. */
|
||||
sendPapeleriaAprobacionClienteRequerida: async (
|
||||
to: string,
|
||||
data: import('./templates/papeleria.js').PapeleriaAprobacionClienteRequeridaData,
|
||||
) => {
|
||||
const { papeleriaAprobacionClienteRequeridaEmail } = await import('./templates/papeleria.js');
|
||||
await sendEmail(
|
||||
to,
|
||||
`📋 Documento pendiente de tu aprobación — ${data.contribuyenteRfc}`,
|
||||
papeleriaAprobacionClienteRequeridaEmail(data),
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Cron 8:30 AM — alertas fiscales nuevas activadas hoy. Envía un solo
|
||||
* correo por destinatario con el batch completo. Caller debe deduplicar
|
||||
|
||||
@@ -55,3 +55,32 @@ export function papeleriaDecisionEmail(d: PapeleriaDecisionData): string {
|
||||
`;
|
||||
return baseTemplate(body);
|
||||
}
|
||||
|
||||
export interface PapeleriaAprobacionClienteRequeridaData {
|
||||
contribuyenteRfc: string;
|
||||
contribuyenteNombre: string;
|
||||
despachoNombre?: string;
|
||||
nombreDocumento: string;
|
||||
descripcion: string | null;
|
||||
periodo: string;
|
||||
subidoPor: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export function papeleriaAprobacionClienteRequeridaEmail(d: PapeleriaAprobacionClienteRequeridaData): string {
|
||||
const body = `
|
||||
${heading('Documento pendiente de tu aprobación')}
|
||||
<p>${d.subidoPor} subió un documento que requiere tu aprobación como cliente:</p>
|
||||
<ul>
|
||||
<li><strong>Documento:</strong> ${d.nombreDocumento}</li>
|
||||
<li><strong>Contribuyente:</strong> ${d.contribuyenteNombre} (${d.contribuyenteRfc})</li>
|
||||
<li><strong>Periodo:</strong> ${d.periodo}</li>
|
||||
${d.descripcion ? `<li><strong>Descripción:</strong> ${d.descripcion}</li>` : ''}
|
||||
</ul>
|
||||
${infoBox('Revisa el documento y márcalo como aprobado o rechazado desde la sección de Documentos del despacho.')}
|
||||
<div style="margin-top: 24px;">
|
||||
${primaryButton('Ver documento', d.link)}
|
||||
</div>
|
||||
`;
|
||||
return baseTemplate(body);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Pool } from 'pg';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import type { IvaMensual, IsrMensual, ResumenIva, IvaRegimenDetalle, ResumenIsr } from '@horux/shared';
|
||||
import { getRegimenesIgnoradosClaves } from './regimen.service.js';
|
||||
import {
|
||||
@@ -106,32 +106,40 @@ const SUM_E_REFERENCING_TRAS = (
|
||||
esLadoE: string,
|
||||
considerarActivos: boolean,
|
||||
considerarNCs: boolean,
|
||||
) => `COALESCE((
|
||||
SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')})
|
||||
FROM cfdis e
|
||||
WHERE e.tipo_comprobante = 'E'
|
||||
AND e.metodo_pago = 'PUE'
|
||||
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', 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)`;
|
||||
) => {
|
||||
if (!considerarNCs) return '0';
|
||||
return `COALESCE((
|
||||
SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')})
|
||||
FROM cfdis e
|
||||
WHERE e.tipo_comprobante = 'E'
|
||||
AND e.metodo_pago = 'PUE'
|
||||
AND e.status NOT IN ('Cancelado', '0')
|
||||
AND ${esLadoE}
|
||||
AND e.cfdis_relacionados IS NOT NULL
|
||||
AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)]
|
||||
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,
|
||||
considerarActivos: boolean,
|
||||
considerarNCs: boolean,
|
||||
) => `COALESCE((
|
||||
SELECT SUM(${IVA_RET_EXPR_ALIAS('e')})
|
||||
FROM cfdis e
|
||||
WHERE e.tipo_comprobante = 'E'
|
||||
AND e.metodo_pago = 'PUE'
|
||||
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', 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)`;
|
||||
) => {
|
||||
if (!considerarNCs) return '0';
|
||||
return `COALESCE((
|
||||
SELECT SUM(${IVA_RET_EXPR_ALIAS('e')})
|
||||
FROM cfdis e
|
||||
WHERE e.tipo_comprobante = 'E'
|
||||
AND e.metodo_pago = 'PUE'
|
||||
AND e.status NOT IN ('Cancelado', '0')
|
||||
AND ${esLadoE}
|
||||
AND e.cfdis_relacionados IS NOT NULL
|
||||
AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)]
|
||||
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
|
||||
// determinar el lado, no el `type` de BD.
|
||||
@@ -152,16 +160,20 @@ const HAS_E_REFERENCING_MISMO_MES = (
|
||||
esLadoE: string,
|
||||
considerarActivos: boolean,
|
||||
considerarNCs: boolean,
|
||||
) => `EXISTS (
|
||||
SELECT 1 FROM cfdis e
|
||||
WHERE e.tipo_comprobante = 'E'
|
||||
AND e.metodo_pago = 'PUE'
|
||||
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)}
|
||||
)`;
|
||||
) => {
|
||||
if (!considerarNCs) return 'FALSE';
|
||||
return `EXISTS (
|
||||
SELECT 1 FROM cfdis e
|
||||
WHERE e.tipo_comprobante = 'E'
|
||||
AND e.metodo_pago = 'PUE'
|
||||
AND e.status NOT IN ('Cancelado', '0')
|
||||
AND ${esLadoE}
|
||||
AND e.cfdis_relacionados IS NOT NULL
|
||||
AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)]
|
||||
AND date_trunc('month', e.fecha_emision)
|
||||
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
|
||||
)`;
|
||||
};
|
||||
|
||||
// Atribución por lado usando RFC en lugar de `type`. Los buckets son
|
||||
// factories que reciben el context del contribuyente:
|
||||
@@ -397,8 +409,8 @@ export async function getIvaMensual(
|
||||
const añoEnd = `${año}-12-31`;
|
||||
const extra = buildExtraFilters(considerarActivos, considerarNCs);
|
||||
|
||||
const [{ rows: causadoRows }, { rows: acreditableRows }] = await Promise.all([
|
||||
pool.query<{ mes: number; trasladado: string; retencion: string }>(`
|
||||
const { rows: causadoRows } = await withJitOff(pool, (client) =>
|
||||
client.query<{ mes: number; trasladado: string; retencion: string }>(`
|
||||
SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes,
|
||||
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
|
||||
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
|
||||
@@ -407,8 +419,10 @@ export async function getIvaMensual(
|
||||
AND ${VIGENTE} AND ${FR}${extra}
|
||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||
GROUP BY mes
|
||||
`, [añoStart, añoEnd, TODOS_REGIMENES]),
|
||||
pool.query<{ mes: number; trasladado: string; retencion: string }>(`
|
||||
`, [añoStart, añoEnd, TODOS_REGIMENES])
|
||||
);
|
||||
const { rows: acreditableRows } = await withJitOff(pool, (client) =>
|
||||
client.query<{ mes: number; trasladado: string; retencion: string }>(`
|
||||
SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes,
|
||||
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
|
||||
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
|
||||
@@ -417,8 +431,8 @@ export async function getIvaMensual(
|
||||
AND ${VIGENTE} AND ${FR}${extra}
|
||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||
GROUP BY mes
|
||||
`, [añoStart, añoEnd, TODOS_REGIMENES]),
|
||||
]);
|
||||
`, [añoStart, añoEnd, TODOS_REGIMENES])
|
||||
);
|
||||
|
||||
perMes = new Map();
|
||||
for (const row of causadoRows) {
|
||||
@@ -648,20 +662,22 @@ async function readResumenIvaFromCache(
|
||||
const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
|
||||
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
|
||||
const REGIMEN_TENANT = regimenTenantExpr(ctx);
|
||||
const acumRow = (await pool.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||
(
|
||||
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0)
|
||||
) as total
|
||||
FROM cfdis
|
||||
WHERE ${VIGENTE}
|
||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||
AND ${acumFR}
|
||||
AND (${ctx.esEmisor} OR ${ctx.esReceptor})
|
||||
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])).rows[0];
|
||||
const acumRow = (await withJitOff(pool, (client) =>
|
||||
client.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||
(
|
||||
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0)
|
||||
) as total
|
||||
FROM cfdis
|
||||
WHERE ${VIGENTE}
|
||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||
AND ${acumFR}
|
||||
AND (${ctx.esEmisor} OR ${ctx.esReceptor})
|
||||
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])
|
||||
)).rows[0];
|
||||
|
||||
// Cache hit retorna 0/empty para los surface IVA No Acreditable. El cache
|
||||
// aún no persiste esos campos — si se hace crítico para BI, agregar columna
|
||||
@@ -698,6 +714,29 @@ async function readResumenIvaFromCache(
|
||||
*
|
||||
* Algebraicamente: T − A − R == dashboard.balance, céntimo por céntimo.
|
||||
*/
|
||||
/**
|
||||
* Ejecuta un callback con un client de pool con JIT desactivado (SET LOCAL jit = off).
|
||||
* Usa una transacción implícita para que el SET LOCAL se restaure automáticamente
|
||||
* al liberar la conexión. Esto evita que PostgreSQL compile JIT para queries con
|
||||
* muchos subplans (correlacionados), lo cual puede tardar >15s en queries con
|
||||
* costo estimado muy alto aunque la ejecución real sea rápida.
|
||||
*/
|
||||
async function withJitOff<T>(pool: Pool, fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('SET LOCAL jit = off');
|
||||
const result = await fn(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getResumenIva(
|
||||
pool: Pool,
|
||||
fechaInicio: string,
|
||||
@@ -725,10 +764,10 @@ export async function getResumenIva(
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
// Una query por lado (causado / acreditable). Filtro por RFC via
|
||||
// ctx.esEmisor/esReceptor (embedded en buckets/signed exprs).
|
||||
const [{ rows: causadoRows }, { rows: acreditableRows }] = await Promise.all([
|
||||
pool.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
|
||||
// Queries con JIT off: evitan compilación JIT de >15s en queries con muchos
|
||||
// subplans correlacionados (activado por costo estimado >100k).
|
||||
const { rows: causadoRows } = await withJitOff(pool, (client) =>
|
||||
client.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
|
||||
SELECT ${REGIMEN_TENANT} as regimen,
|
||||
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
|
||||
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
|
||||
@@ -737,8 +776,10 @@ export async function getResumenIva(
|
||||
AND ${VIGENTE} AND ${FR}${extra}
|
||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||
GROUP BY ${REGIMEN_TENANT}
|
||||
`, [fechaInicio, fechaFin, TODOS_REGIMENES]),
|
||||
pool.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
|
||||
`, [fechaInicio, fechaFin, TODOS_REGIMENES])
|
||||
);
|
||||
const { rows: acreditableRows } = await withJitOff(pool, (client) =>
|
||||
client.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
|
||||
SELECT ${REGIMEN_TENANT} as regimen,
|
||||
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
|
||||
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
|
||||
@@ -747,8 +788,8 @@ export async function getResumenIva(
|
||||
AND ${VIGENTE} AND ${FR}${extra}
|
||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||
GROUP BY ${REGIMEN_TENANT}
|
||||
`, [fechaInicio, fechaFin, TODOS_REGIMENES]),
|
||||
]);
|
||||
`, [fechaInicio, fechaFin, TODOS_REGIMENES])
|
||||
);
|
||||
|
||||
// Combinar por régimen: el set de régimenes posibles es la unión de ambos lados.
|
||||
type Acc = { trasCausado: number; retCausado: number; trasAcreditable: number; retAcreditable: number };
|
||||
@@ -799,20 +840,22 @@ export async function getResumenIva(
|
||||
// Acumulado anual (misma fórmula T − A − R, pero rango = enero → fechaFin).
|
||||
const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
|
||||
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
|
||||
const { rows: [acumRow] } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||
(
|
||||
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0)
|
||||
) as total
|
||||
FROM cfdis
|
||||
WHERE ${VIGENTE}
|
||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||
AND ${acumFR}${extra}
|
||||
AND (${ctx.esEmisor} OR ${ctx.esReceptor})
|
||||
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES]);
|
||||
const { rows: [acumRow] } = await withJitOff(pool, (client) =>
|
||||
client.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||
(
|
||||
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0)
|
||||
) as total
|
||||
FROM cfdis
|
||||
WHERE ${VIGENTE}
|
||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||
AND ${acumFR}${extra}
|
||||
AND (${ctx.esEmisor} OR ${ctx.esReceptor})
|
||||
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])
|
||||
);
|
||||
|
||||
// IVA No Acreditable surface (Art. 5 LIVA fracción I + Art. 27 fracción III LISR).
|
||||
// No participa en `resultado` — ya excluido del `acreditable` arriba via filtro
|
||||
|
||||
@@ -27,6 +27,11 @@ export interface PapeleriaItem {
|
||||
aprobadoPor: string | null;
|
||||
aprobadoAt: Date | null;
|
||||
comentarioRechazo: string | null;
|
||||
requiereAprobacionCliente: boolean;
|
||||
estadoCliente: EstadoPapeleria | null;
|
||||
aprobadoPorCliente: string | null;
|
||||
aprobadoAtCliente: Date | null;
|
||||
comentarioRechazoCliente: string | null;
|
||||
subidoPor: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -36,6 +41,7 @@ const SELECT = `
|
||||
archivo_filename, archivo_mime, archivo_size,
|
||||
anio, mes,
|
||||
requiere_aprobacion, estado, aprobado_por, aprobado_at, comentario_rechazo,
|
||||
requiere_aprobacion_cliente, estado_cliente, aprobado_por_cliente, aprobado_at_cliente, comentario_rechazo_cliente,
|
||||
subido_por, created_at
|
||||
`;
|
||||
|
||||
@@ -54,6 +60,11 @@ const ROW = (r: any): PapeleriaItem => ({
|
||||
aprobadoPor: r.aprobado_por,
|
||||
aprobadoAt: r.aprobado_at,
|
||||
comentarioRechazo: r.comentario_rechazo,
|
||||
requiereAprobacionCliente: r.requiere_aprobacion_cliente,
|
||||
estadoCliente: r.estado_cliente,
|
||||
aprobadoPorCliente: r.aprobado_por_cliente,
|
||||
aprobadoAtCliente: r.aprobado_at_cliente,
|
||||
comentarioRechazoCliente: r.comentario_rechazo_cliente,
|
||||
subidoPor: r.subido_por,
|
||||
createdAt: r.created_at,
|
||||
});
|
||||
@@ -69,6 +80,7 @@ export interface UploadInput {
|
||||
anio: number;
|
||||
mes: number;
|
||||
requiereAprobacion: boolean;
|
||||
requiereAprobacionCliente: boolean;
|
||||
archivo: Buffer;
|
||||
archivoFilename: string;
|
||||
archivoMime: string;
|
||||
@@ -87,12 +99,13 @@ export async function uploadPapeleria(
|
||||
}
|
||||
|
||||
const estadoInicial = input.requiereAprobacion ? 'pendiente' : null;
|
||||
const estadoClienteInicial = input.requiereAprobacionCliente ? 'pendiente' : null;
|
||||
|
||||
const { rows: [r] } = await pool.query(
|
||||
`INSERT INTO papeleria_trabajo
|
||||
(contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size,
|
||||
anio, mes, requiere_aprobacion, estado, subido_por)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
anio, mes, requiere_aprobacion, estado, requiere_aprobacion_cliente, estado_cliente, subido_por)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING ${SELECT}`,
|
||||
[
|
||||
sanitizeUuid(input.contribuyenteId),
|
||||
@@ -106,6 +119,8 @@ export async function uploadPapeleria(
|
||||
input.mes,
|
||||
input.requiereAprobacion,
|
||||
estadoInicial,
|
||||
input.requiereAprobacionCliente,
|
||||
estadoClienteInicial,
|
||||
input.subidoPor,
|
||||
],
|
||||
);
|
||||
@@ -117,6 +132,8 @@ export interface ListFilters {
|
||||
anio?: number;
|
||||
mes?: number;
|
||||
estado?: EstadoPapeleria | 'sin_aprobacion';
|
||||
entidadIds?: string[];
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
export async function listPapeleria(pool: Pool, f: ListFilters): Promise<PapeleriaItem[]> {
|
||||
@@ -126,10 +143,17 @@ export async function listPapeleria(pool: Pool, f: ListFilters): Promise<Papeler
|
||||
if (f.anio) { conds.push(`anio = $${i++}`); vals.push(f.anio); }
|
||||
if (f.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); }
|
||||
if (f.estado === 'sin_aprobacion') {
|
||||
conds.push('requiere_aprobacion = false');
|
||||
conds.push('requiere_aprobacion = false AND requiere_aprobacion_cliente = false');
|
||||
} else if (f.estado) {
|
||||
conds.push(`estado = $${i++}`); vals.push(f.estado);
|
||||
}
|
||||
if (f.entidadIds && f.entidadIds.length > 0) {
|
||||
conds.push(`contribuyente_id = ANY($${i++})`);
|
||||
vals.push(f.entidadIds);
|
||||
}
|
||||
if (f.userRole === 'cliente') {
|
||||
conds.push('requiere_aprobacion_cliente = true');
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ${SELECT} FROM papeleria_trabajo
|
||||
WHERE ${conds.join(' AND ')}
|
||||
@@ -202,6 +226,39 @@ export async function rechazar(
|
||||
return r ? ROW(r) : null;
|
||||
}
|
||||
|
||||
export async function aprobarCliente(
|
||||
pool: Pool,
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<PapeleriaItem | null> {
|
||||
const { rows: [r] } = await pool.query(
|
||||
`UPDATE papeleria_trabajo
|
||||
SET estado_cliente = 'aprobado', aprobado_por_cliente = $2, aprobado_at_cliente = NOW(),
|
||||
comentario_rechazo_cliente = NULL
|
||||
WHERE id = $1 AND requiere_aprobacion_cliente = true
|
||||
RETURNING ${SELECT}`,
|
||||
[id, userId],
|
||||
);
|
||||
return r ? ROW(r) : null;
|
||||
}
|
||||
|
||||
export async function rechazarCliente(
|
||||
pool: Pool,
|
||||
id: number,
|
||||
userId: string,
|
||||
comentario: string | null,
|
||||
): Promise<PapeleriaItem | null> {
|
||||
const { rows: [r] } = await pool.query(
|
||||
`UPDATE papeleria_trabajo
|
||||
SET estado_cliente = 'rechazado', aprobado_por_cliente = $2, aprobado_at_cliente = NOW(),
|
||||
comentario_rechazo_cliente = $3
|
||||
WHERE id = $1 AND requiere_aprobacion_cliente = true
|
||||
RETURNING ${SELECT}`,
|
||||
[id, userId, comentario],
|
||||
);
|
||||
return r ? ROW(r) : null;
|
||||
}
|
||||
|
||||
export async function eliminar(pool: Pool, id: number): Promise<boolean> {
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM papeleria_trabajo WHERE id = $1`,
|
||||
@@ -209,3 +266,30 @@ export async function eliminar(pool: Pool, id: number): Promise<boolean> {
|
||||
);
|
||||
return (rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el estado visual combinado considerando ambas aprobaciones.
|
||||
*/
|
||||
export function estadoGlobal(item: PapeleriaItem): 'pendiente' | 'aprobado' | 'rechazado' | null {
|
||||
const reqOwner = item.requiereAprobacion;
|
||||
const reqCliente = item.requiereAprobacionCliente;
|
||||
const estOwner = item.estado;
|
||||
const estCliente = item.estadoCliente;
|
||||
|
||||
if (!reqOwner && !reqCliente) return null;
|
||||
|
||||
// Si cualquiera está rechazado, el documento está rechazado
|
||||
if (estOwner === 'rechazado' || estCliente === 'rechazado') return 'rechazado';
|
||||
|
||||
// Si ambos requieren aprobación
|
||||
if (reqOwner && reqCliente) {
|
||||
if (estOwner === 'aprobado' && estCliente === 'aprobado') return 'aprobado';
|
||||
return 'pendiente';
|
||||
}
|
||||
|
||||
// Solo owner
|
||||
if (reqOwner) return estOwner;
|
||||
|
||||
// Solo cliente
|
||||
return estCliente;
|
||||
}
|
||||
|
||||
@@ -348,6 +348,17 @@ export async function emitInvoiceIfApplicable(paymentId: string): Promise<void>
|
||||
data: { facturapiInvoiceId: invoice.id },
|
||||
});
|
||||
|
||||
// Enviar factura por email al cliente cuando se factura con datos reales
|
||||
// (no público en general). Fail-soft: si el envío falla, no bloquea.
|
||||
if (customer?.email) {
|
||||
try {
|
||||
await facturapiService.sendInvoiceByEmail(emitter.id, invoice.id, customer.email);
|
||||
console.log(`[Invoicing] Factura ${invoice.id} enviada a ${customer.email}`);
|
||||
} catch (emailErr: any) {
|
||||
console.error(`[Invoicing] Error enviando factura ${invoice.id} a ${customer.email}:`, emailErr.message || emailErr);
|
||||
}
|
||||
}
|
||||
|
||||
auditLog({
|
||||
tenantId: payment.tenantId,
|
||||
action: 'invoice.emitted_auto',
|
||||
|
||||
@@ -45,7 +45,10 @@ export async function getRegimenesActivosClaves(tenantId: string): Promise<strin
|
||||
/**
|
||||
* Resuelve las claves de regímenes activos para la alerta de discrepancia.
|
||||
* Si hay contribuyenteId, lee de contribuyentes.regimen_fiscal (comma-separated).
|
||||
* Si no, fallback a TenantRegimenActivo (tabla central).
|
||||
* Si no, combina TenantRegimenActivo (tabla central) con los regímenes de
|
||||
* todos los contribuyentes activos del tenant. Esto evita que la alerta
|
||||
* aparezca en el correo por-contribuyente pero desaparezca en el dashboard
|
||||
* cuando no hay un contribuyente seleccionado.
|
||||
*/
|
||||
export async function getRegimenesActivosClavesEfectivos(
|
||||
tenantId: string,
|
||||
@@ -61,9 +64,49 @@ export async function getRegimenesActivosClavesEfectivos(
|
||||
if (rows.length > 0 && rows[0].regimen_fiscal) {
|
||||
return rows[0].regimen_fiscal.split(',').map((c: string) => c.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
// Fallback: si el contribuyente no tiene regimen_fiscal, usamos los del tenant
|
||||
// para no perder la alerta si el campo quedó vacío accidentalmente.
|
||||
const tenantRegimenes = await getRegimenesActivosClaves(tenantId);
|
||||
if (tenantRegimenes.length > 0) return tenantRegimenes;
|
||||
|
||||
const { rows: allRows } = await pool.query(
|
||||
`SELECT DISTINCT regimen_fiscal FROM contribuyentes WHERE regimen_fiscal IS NOT NULL AND regimen_fiscal <> ''`,
|
||||
);
|
||||
const set = new Set<string>();
|
||||
for (const row of allRows) {
|
||||
if (row.regimen_fiscal) {
|
||||
for (const clave of row.regimen_fiscal.split(',')) {
|
||||
const trimmed = clave.trim();
|
||||
if (trimmed) set.add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(set);
|
||||
}
|
||||
return getRegimenesActivosClaves(tenantId);
|
||||
|
||||
const tenantRegimenes = await getRegimenesActivosClaves(tenantId);
|
||||
|
||||
// Fallback: si no hay regímenes configurados a nivel tenant, usamos los
|
||||
// regímenes de todos los contribuyentes activos del tenant.
|
||||
if (tenantRegimenes.length > 0) {
|
||||
return tenantRegimenes;
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DISTINCT regimen_fiscal FROM contribuyentes WHERE regimen_fiscal IS NOT NULL AND regimen_fiscal <> ''`,
|
||||
);
|
||||
|
||||
const set = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (row.regimen_fiscal) {
|
||||
for (const clave of row.regimen_fiscal.split(',')) {
|
||||
const trimmed = clave.trim();
|
||||
if (trimmed) set.add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(set);
|
||||
}
|
||||
|
||||
export async function setRegimenesActivos(tenantId: string, regimenIds: number[]) {
|
||||
|
||||
@@ -72,9 +72,17 @@ export async function querySat(
|
||||
requestType: 'metadata' | 'cfdi' = 'cfdi'
|
||||
): Promise<QueryResult> {
|
||||
try {
|
||||
// El SAT rechaza fechaInicial >= fechaFinal. Como formatDateForSat trunca
|
||||
// a medianoche, dos fechas dentro del mismo día calendario resultan iguales.
|
||||
// Ajustamos fechaFin al día siguiente para evitar el error.
|
||||
let adjustedFechaFin = fechaFin;
|
||||
if (formatDateForSat(fechaInicio) === formatDateForSat(fechaFin)) {
|
||||
adjustedFechaFin = new Date(fechaFin.getTime() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
const period = DateTimePeriod.createFromValues(
|
||||
formatDateForSat(fechaInicio),
|
||||
formatDateForSat(fechaFin)
|
||||
formatDateForSat(adjustedFechaFin)
|
||||
);
|
||||
|
||||
const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received');
|
||||
@@ -239,10 +247,11 @@ export async function downloadSatPackage(
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss)
|
||||
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss).
|
||||
* El SAT requiere hora 00:00:00; cualquier otra hora causa
|
||||
* "Fecha final invalida" / "Fecha inicial invalida".
|
||||
*/
|
||||
function formatDateForSat(date: Date): string {
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
||||
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 00:00:00`;
|
||||
}
|
||||
|
||||
@@ -30,20 +30,20 @@ export async function loginSatCsf(
|
||||
const publicPage = await context.newPage();
|
||||
publicPage.setDefaultTimeout(60_000);
|
||||
|
||||
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle' });
|
||||
await publicPage.waitForTimeout(2000);
|
||||
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle', timeout: 120_000 });
|
||||
await publicPage.waitForTimeout(3000);
|
||||
|
||||
// Click acordeón "Obtén tu constancia" / "Obtener constancia"
|
||||
const obtenerLocator = publicPage.locator(
|
||||
'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i',
|
||||
).first();
|
||||
await obtenerLocator.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await obtenerLocator.waitFor({ state: 'visible', timeout: 120_000 });
|
||||
await obtenerLocator.scrollIntoViewIfNeeded();
|
||||
await obtenerLocator.click();
|
||||
await publicPage.waitForTimeout(1500);
|
||||
|
||||
// Click "SERVICIO" → popup
|
||||
const popupPromise = context.waitForEvent('page', { timeout: 60_000 });
|
||||
const popupPromise = context.waitForEvent('page', { timeout: 120_000 });
|
||||
await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click();
|
||||
const loginPage = await popupPromise;
|
||||
await loginPage.waitForLoadState('domcontentloaded');
|
||||
@@ -56,7 +56,7 @@ export async function loginSatCsf(
|
||||
const efirmaBtn = loginPage
|
||||
.locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]')
|
||||
.first();
|
||||
await efirmaBtn.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
await efirmaBtn.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await efirmaBtn.scrollIntoViewIfNeeded();
|
||||
await efirmaBtn.click();
|
||||
|
||||
@@ -82,7 +82,7 @@ export async function loginSatCsf(
|
||||
return rfc !== null && rfc.value.length >= 12;
|
||||
},
|
||||
null,
|
||||
{ timeout: 60_000 },
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
rfcPopulated = true;
|
||||
} catch {
|
||||
@@ -121,7 +121,7 @@ export async function loginSatCsf(
|
||||
// Esperar a que salga del dominio de login y aterrice en el portal SAT
|
||||
await loginPage.waitForURL(
|
||||
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
|
||||
{ timeout: 60_000 },
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
|
||||
await loginPage.waitForTimeout(2000);
|
||||
|
||||
@@ -14,10 +14,10 @@ export interface SweepResult {
|
||||
}
|
||||
|
||||
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
|
||||
initial: 8,
|
||||
initial: 24,
|
||||
daily: 4,
|
||||
incremental: 2,
|
||||
custom: 4,
|
||||
custom: 24,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useDesasignarObligacion,
|
||||
useAsignarTarea,
|
||||
useDesasignarTarea,
|
||||
useAuxiliaresElegibles,
|
||||
} from '@/lib/hooks/use-asignaciones';
|
||||
import { useUsuarios } from '@/lib/hooks/use-usuarios';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
@@ -36,6 +37,11 @@ export default function SeguimientoAuxiliares() {
|
||||
|
||||
const auxiliares = (usuarios ?? []).filter((u: any) => u.role === 'auxiliar');
|
||||
|
||||
const { data: elegiblesData, isLoading: loadingElegibles } = useAuxiliaresElegibles(modalItem?.contribuyenteId);
|
||||
const auxiliaresIdsElegibles = elegiblesData?.auxiliares ?? [];
|
||||
const auxiliaresFiltrados = auxiliares.filter((a: any) => auxiliaresIdsElegibles.includes(a.id));
|
||||
const puedeAsignar = !loadingElegibles && auxiliaresFiltrados.length > 0;
|
||||
|
||||
const openAssignModal = (type: 'obligacion' | 'tarea', item: any) => {
|
||||
setModalType(type);
|
||||
setModalItem(item);
|
||||
@@ -169,20 +175,28 @@ export default function SeguimientoAuxiliares() {
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Contribuyente: {modalItem?.contribuyenteRazonSocial} ({modalItem?.contribuyenteRfc})
|
||||
</p>
|
||||
<Select value={selectedAuxiliar} onValueChange={setSelectedAuxiliar}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona un auxiliar" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{auxiliares.map((a: any) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.nombre} ({a.email})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{loadingElegibles ? (
|
||||
<p className="text-sm text-muted-foreground">Verificando subcarteras...</p>
|
||||
) : auxiliaresFiltrados.length === 0 ? (
|
||||
<p className="text-sm text-red-600">
|
||||
Ningún auxiliar tiene este contribuyente en su subcartera. No se puede asignar.
|
||||
</p>
|
||||
) : (
|
||||
<Select value={selectedAuxiliar} onValueChange={setSelectedAuxiliar}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona un auxiliar" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{auxiliaresFiltrados.map((a: any) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.nombre} ({a.email})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={handleAssign} disabled={!selectedAuxiliar}>
|
||||
<Button onClick={handleAssign} disabled={!selectedAuxiliar || !puedeAsignar}>
|
||||
{modalItem?.auxiliarUserId ? 'Reasignar' : 'Asignar'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useDebounce } from '@horux/shared-ui';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Popover, PopoverTrigger, PopoverContent } from '@horux/shared-ui';
|
||||
import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
|
||||
import { createManyCfdis, searchEmisores, searchReceptores, getCfdis, getConceptosList, type EmisorReceptor } from '@/lib/api/cfdi';
|
||||
import { createManyCfdis, searchEmisores, searchReceptores, getCfdis, getConceptosList, downloadXmlsZip, type EmisorReceptor } from '@/lib/api/cfdi';
|
||||
import { cancelarFactura, downloadPdf } from '@/lib/api/facturacion';
|
||||
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
|
||||
import type { CreateCfdiData } from '@/lib/api/cfdi';
|
||||
@@ -261,6 +261,7 @@ export default function CfdiPage() {
|
||||
const [loadingEmisor, setLoadingEmisor] = useState(false);
|
||||
const [loadingReceptor, setLoadingReceptor] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [downloadingXmls, setDownloadingXmls] = useState(false);
|
||||
|
||||
// Debounced values for autocomplete
|
||||
const debouncedEmisor = useDebounce(columnFilters.emisor, 300);
|
||||
@@ -424,6 +425,7 @@ export default function CfdiPage() {
|
||||
'Fecha Emisión': formatCfdiDate(cfdi.fechaEmision),
|
||||
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
|
||||
'Uso CFDI': (cfdi as any).usoCfdi || '',
|
||||
'Forma de Pago': cfdi.formaPago || '',
|
||||
'Serie': cfdi.serie || '',
|
||||
'Folio': cfdi.folio || '',
|
||||
'RFC Emisor': cfdi.rfcEmisor,
|
||||
@@ -540,6 +542,7 @@ export default function CfdiPage() {
|
||||
'Fecha Emisión': formatCfdiDate(cfdi.fechaEmision),
|
||||
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
|
||||
'Uso CFDI': (cfdi as any).usoCfdi || '',
|
||||
'Forma de Pago': cfdi.formaPago || '',
|
||||
'Serie': cfdi.serie || '',
|
||||
'Folio': cfdi.folio || '',
|
||||
'RFC Emisor': cfdi.rfcEmisor,
|
||||
@@ -1698,6 +1701,7 @@ export default function CfdiPage() {
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="pb-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm text-center">
|
||||
@@ -1714,6 +1718,21 @@ export default function CfdiPage() {
|
||||
<td className="py-2 text-xs" title={row.unidad || ''}>{row.clave_unidad || '-'}</td>
|
||||
<td className="py-2 text-right">${Number(row.valor_unitario ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right font-medium">${Number(row.importe ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
|
||||
<td className="py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleViewCfdi(row.cfdi_id)}
|
||||
disabled={loadingCfdi === row.cfdi_id}
|
||||
title="Ver CFDI"
|
||||
>
|
||||
{loadingCfdi === row.cfdi_id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -1760,6 +1779,48 @@ export default function CfdiPage() {
|
||||
<FileText className="h-4 w-4" />
|
||||
CFDIs ({data?.total || 0})
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if ((data?.total || 0) > 1000) {
|
||||
if (!confirm('Solo se descargarán los primeros 1,000 XMLs. ¿Continuar?')) return;
|
||||
}
|
||||
try {
|
||||
setDownloadingXmls(true);
|
||||
const blob = await downloadXmlsZip({
|
||||
tipo: filters.tipo,
|
||||
tipoComprobante: filters.tipoComprobante,
|
||||
estado: filters.estado,
|
||||
fechaInicio: filters.fechaInicio,
|
||||
fechaFin: filters.fechaFin,
|
||||
rfc: filters.rfc,
|
||||
emisor: filters.emisor,
|
||||
receptor: filters.receptor,
|
||||
search: filters.search,
|
||||
contribuyenteId: filters.contribuyenteId,
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `cfdis-xml-${Date.now()}.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err: any) {
|
||||
alert(err?.response?.data?.message || err?.message || 'Error al descargar XMLs');
|
||||
} finally {
|
||||
setDownloadingXmls(false);
|
||||
}
|
||||
}}
|
||||
disabled={downloadingXmls || !data?.total}
|
||||
>
|
||||
{downloadingXmls ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Descargar XMLs
|
||||
</Button>
|
||||
{hasActiveColumnFilters && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Filtros activos:</span>
|
||||
|
||||
@@ -379,6 +379,7 @@ export default function ConfiguracionPage() {
|
||||
const empresaNombre = viewingTenantName || user?.tenantName;
|
||||
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
|
||||
const isDespacho = isDespachoTenant(user?.tenantRfc);
|
||||
const showFullConfig = ['owner', 'cfo', 'supervisor'].includes(user?.role || '');
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -440,7 +441,7 @@ export default function ConfiguracionPage() {
|
||||
)}
|
||||
|
||||
{/* Regímenes Fiscales, Domicilio Fiscal, Bancos */}
|
||||
{(user?.role === 'owner' || user?.role === 'cfo') && (
|
||||
{(user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'supervisor') && (
|
||||
isDespacho && !selectedContribuyenteId ? (
|
||||
<Card>
|
||||
<CardContent className="py-6 text-center text-muted-foreground">
|
||||
@@ -456,88 +457,112 @@ export default function ConfiguracionPage() {
|
||||
)
|
||||
)}
|
||||
|
||||
{/* SAT Configuration */}
|
||||
<Link href="/configuracion/sat">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Sincronizacion SAT
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configura tu FIEL y la sincronizacion automatica de CFDIs con el SAT
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Descarga automaticamente tus facturas emitidas y recibidas directamente del portal del SAT.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
{showFullConfig && (
|
||||
<>
|
||||
{/* SAT Configuration */}
|
||||
<Link href="/configuracion/sat">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Sincronizacion SAT
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configura tu FIEL y la sincronizacion automatica de CFDIs con el SAT
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Descarga automaticamente tus facturas emitidas y recibidas directamente del portal del SAT.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Obligaciones Fiscales */}
|
||||
{(user?.role === 'owner' || user?.role === 'cfo') && (
|
||||
<Link href="/configuracion/obligaciones">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Tags className="h-4 w-4" />
|
||||
Obligaciones Fiscales
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Gestiona las obligaciones fiscales de tus contribuyentes
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Recibe recomendaciones basadas en el régimen fiscal, agrega o elimina obligaciones según las necesidades de cada RFC.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
{/* Obligaciones Fiscales */}
|
||||
{(user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'supervisor') && (
|
||||
<Link href="/configuracion/obligaciones">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Tags className="h-4 w-4" />
|
||||
Obligaciones Fiscales
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Gestiona las obligaciones fiscales de tus contribuyentes
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Recibe recomendaciones basadas en el régimen fiscal, agrega o elimina obligaciones según las necesidades de cada RFC.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Notificaciones */}
|
||||
<Link href="/configuracion/notificaciones">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Bell className="h-4 w-4" />
|
||||
Notificaciones
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Activa o desactiva los correos informativos por contribuyente
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Controla qué correos quieres recibir por cada cliente: documentos subidos, reporte semanal, recordatorios fiscales, vencimiento de suscripción.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Preferencias de Facturación */}
|
||||
<Link href="/configuracion/facturacion">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Receipt className="h-4 w-4" />
|
||||
Preferencias de Facturación
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Define cómo facturamos los pagos de tu suscripción a Horux 360
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Elige si tus facturas salen con tus datos fiscales o como Público en General. Configura el uso CFDI (G03 Gastos en general / S01 Sin obligaciones) y el régimen a usar si tienes varios activos.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* CSD / Facturapi */}
|
||||
<Link href="/configuracion/csd">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Building className="h-4 w-4" />
|
||||
Certificado de Sello Digital (CSD)
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configura tu CSD para emitir facturas electrónicas desde Horux360
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sube tu certificado y llave privada para timbrar CFDIs directamente desde la plataforma.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Notificaciones */}
|
||||
<Link href="/configuracion/notificaciones">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Bell className="h-4 w-4" />
|
||||
Notificaciones
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Activa o desactiva los correos informativos por contribuyente
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Controla qué correos quieres recibir por cada cliente: documentos subidos, reporte semanal, recordatorios fiscales, vencimiento de suscripción.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Preferencias de Facturación (auto-emisión de pagos de suscripción) */}
|
||||
<Link href="/configuracion/facturacion">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Receipt className="h-4 w-4" />
|
||||
Preferencias de Facturación
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Define cómo facturamos los pagos de tu suscripción a Horux 360
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Elige si tus facturas salen con tus datos fiscales o como Público en General. Configura el uso CFDI (G03 Gastos en general / S01 Sin obligaciones) y el régimen a usar si tienes varios activos.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Seguridad */}
|
||||
<Link href="/configuracion/seguridad">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
@@ -558,26 +583,6 @@ export default function ConfiguracionPage() {
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* CSD / Facturapi */}
|
||||
<Link href="/configuracion/csd">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Building className="h-4 w-4" />
|
||||
Certificado de Sello Digital (CSD)
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configura tu CSD para emitir facturas electrónicas desde Horux360
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sube tu certificado y llave privada para timbrar CFDIs directamente desde la plataforma.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Admin global: edición de precios */}
|
||||
{isGlobalAdmin && (
|
||||
<>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAuthStore } from '@/stores/auth-store';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { Plus, Pencil, Trash2, Building2, Sparkles } from 'lucide-react';
|
||||
import { AddonsDialog } from './addons-dialog';
|
||||
import { DESPACHO_PLANS } from '@horux/shared';
|
||||
|
||||
const TRIAL_LIMIT_TOOLTIP = 'Límite de contribuyentes para la prueba gratuita, para continuar agregando contribuyentes, selecciona un plan.';
|
||||
|
||||
@@ -30,11 +31,21 @@ export default function ContribuyentesPage() {
|
||||
// deshabilita el botón con tooltip explicativo.
|
||||
const { data: planInfo } = useQuery({
|
||||
queryKey: ['my-plan-info'],
|
||||
queryFn: () => apiClient.get<{ isTrialActive: boolean }>('/despachos/me/plan').then(r => r.data),
|
||||
queryFn: () => apiClient.get<{ plan: string; isTrialActive: boolean }>('/despachos/me/plan').then(r => r.data),
|
||||
});
|
||||
const activeCount = (contribuyentes ?? []).filter((c: any) => c.active !== false).length;
|
||||
const trialAtLimit = (planInfo?.isTrialActive ?? false) && activeCount >= 5;
|
||||
|
||||
// Contador de RFCs disponibles en el plan
|
||||
const planKey = planInfo?.plan as keyof typeof DESPACHO_PLANS | undefined;
|
||||
const planMaxRfcs = planKey ? DESPACHO_PLANS[planKey]?.maxRfcs ?? undefined : undefined;
|
||||
const rfcCounterText = (() => {
|
||||
if (planInfo?.isTrialActive) return `${activeCount} de 5 RFCs`;
|
||||
if (planMaxRfcs != null && planMaxRfcs < 0) return `${activeCount} RFCs`;
|
||||
if (planMaxRfcs !== undefined) return `${activeCount} de ${planMaxRfcs} RFCs`;
|
||||
return `${activeCount} RFCs`;
|
||||
})();
|
||||
|
||||
const resetForm = () => { setForm({ rfc: '', razonSocial: '' }); setAssignSelf(true); setShowDialog(false); setEditingId(null); };
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -77,18 +88,25 @@ export default function ContribuyentesPage() {
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const canCreate = user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'supervisor';
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-2xl font-bold">Contribuyentes</h1><p className="text-sm text-muted-foreground">RFCs que gestiona tu despacho</p></div>
|
||||
<Button
|
||||
onClick={() => { resetForm(); setShowDialog(true); }}
|
||||
disabled={trialAtLimit}
|
||||
title={trialAtLimit ? TRIAL_LIMIT_TOOLTIP : undefined}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Agregar RFC
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Contribuyentes</h1>
|
||||
<p className="text-sm text-muted-foreground">RFCs que gestiona tu despacho · {rfcCounterText}</p>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<Button
|
||||
onClick={() => { resetForm(); setShowDialog(true); }}
|
||||
disabled={trialAtLimit}
|
||||
title={trialAtLimit ? TRIAL_LIMIT_TOOLTIP : undefined}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Agregar RFC
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? <p className="text-muted-foreground">Cargando...</p> : !contribuyentes || contribuyentes.length === 0 ? (
|
||||
@@ -96,13 +114,15 @@ export default function ContribuyentesPage() {
|
||||
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold">Sin contribuyentes</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-4">Agrega el primer RFC para empezar.</p>
|
||||
<Button
|
||||
onClick={() => { resetForm(); setShowDialog(true); }}
|
||||
disabled={trialAtLimit}
|
||||
title={trialAtLimit ? TRIAL_LIMIT_TOOLTIP : undefined}
|
||||
>
|
||||
Agregar primer RFC
|
||||
</Button>
|
||||
{canCreate && (
|
||||
<Button
|
||||
onClick={() => { resetForm(); setShowDialog(true); }}
|
||||
disabled={trialAtLimit}
|
||||
title={trialAtLimit ? TRIAL_LIMIT_TOOLTIP : undefined}
|
||||
>
|
||||
Agregar primer RFC
|
||||
</Button>
|
||||
)}
|
||||
</CardContent></Card>
|
||||
) : (
|
||||
<div className="grid gap-3 lg:grid-cols-2 3xl:grid-cols-3 4xl:grid-cols-4">{contribuyentes.map((c) => (
|
||||
@@ -111,11 +131,16 @@ export default function ContribuyentesPage() {
|
||||
<p className="font-semibold">{c.nombre}</p>
|
||||
<p className="text-sm text-muted-foreground font-mono">{c.rfc}</p>
|
||||
{c.regimenFiscal && <p className="text-xs text-muted-foreground mt-1">Régimen: {c.regimenFiscal}</p>}
|
||||
{c.supervisorNombre && <p className="text-xs text-muted-foreground mt-1">Supervisor: {c.supervisorNombre}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setAddonsTarget({ id: c.id, nombre: c.nombre })} title="Add-ons"><Sparkles className="h-4 w-4" /></Button>
|
||||
{(user?.role === 'owner' || user?.role === 'cfo') && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setAddonsTarget({ id: c.id, nombre: c.nombre })} title="Add-ons"><Sparkles className="h-4 w-4" /></Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(c)}><Pencil className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDeactivate(c.id, c.rfc)} className="text-destructive hover:text-destructive"><Trash2 className="h-4 w-4" /></Button>
|
||||
{user?.role === 'owner' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDeactivate(c.id, c.rfc)} className="text-destructive hover:text-destructive"><Trash2 className="h-4 w-4" /></Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
))}</div>
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
AlertTriangle,
|
||||
ShoppingCart,
|
||||
CheckSquare,
|
||||
FileMinus,
|
||||
FilePlus,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
import { FiscalDisclaimer } from '@/components/fiscal-disclaimer';
|
||||
@@ -118,6 +120,15 @@ export default function DashboardPage() {
|
||||
? kpis?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: kpis?.ivaBalance || 0;
|
||||
|
||||
// Notas de crédito
|
||||
const ncsEmitidasDisplay = regimenSeleccionado
|
||||
? kpis?.ncsEmitidasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: kpis?.ncsEmitidas || 0;
|
||||
|
||||
const ncsRecibidasDisplay = regimenSeleccionado
|
||||
? kpis?.ncsRecibidasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: kpis?.ncsRecibidas || 0;
|
||||
|
||||
const ivaAnterior = regimenSeleccionado
|
||||
? kpisAnterior?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: kpisAnterior?.ivaBalance || 0;
|
||||
@@ -126,9 +137,15 @@ export default function DashboardPage() {
|
||||
? Math.round(((ivaDisplay - ivaAnterior) / Math.abs(ivaAnterior)) * 10000) / 100
|
||||
: null;
|
||||
|
||||
const utilidadDisplay = ingresosDisplay - egresosDisplay;
|
||||
const margenDisplay = ingresosDisplay > 0
|
||||
? Math.round((utilidadDisplay / ingresosDisplay) * 10000) / 100
|
||||
// Utilidad ajustada por notas de crédito:
|
||||
// Ingresos netos = Ingresos − NCs emitidas
|
||||
// Egresos netos = Gastos − NCs recibidas
|
||||
// Utilidad neta = Ingresos netos − Egresos netos
|
||||
const ingresosNetosDisplay = ingresosDisplay - ncsEmitidasDisplay;
|
||||
const egresosNetosDisplay = egresosDisplay - ncsRecibidasDisplay;
|
||||
const utilidadDisplay = ingresosNetosDisplay - egresosNetosDisplay;
|
||||
const margenDisplay = ingresosNetosDisplay > 0
|
||||
? Math.round((utilidadDisplay / ingresosNetosDisplay) * 10000) / 100
|
||||
: 0;
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
@@ -203,7 +220,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<KpiCard
|
||||
title={regimenSeleccionado ? `Ingresos del Mes (${regimenSeleccionado})` : 'Ingresos del Mes'}
|
||||
value={ingresosDisplay}
|
||||
@@ -216,6 +233,13 @@ export default function DashboardPage() {
|
||||
}
|
||||
href={drillUrl('Ingresos del Mes - CFDIs', { bucket: 'ingresos' })}
|
||||
/>
|
||||
<KpiCard
|
||||
title={regimenSeleccionado ? `NCs Emitidas (${regimenSeleccionado})` : 'NCs Emitidas'}
|
||||
value={ncsEmitidasDisplay}
|
||||
icon={<FileMinus className="h-4 w-4" />}
|
||||
trend="neutral"
|
||||
trendValue="Notas de crédito emitidas"
|
||||
/>
|
||||
<KpiCard
|
||||
title={regimenSeleccionado ? `Gastos del Mes (${regimenSeleccionado})` : 'Gastos del Mes'}
|
||||
value={egresosDisplay}
|
||||
@@ -229,11 +253,18 @@ export default function DashboardPage() {
|
||||
href={drillUrl('Gastos del Mes - CFDIs', { bucket: 'gastos' })}
|
||||
/>
|
||||
<KpiCard
|
||||
title="Utilidad"
|
||||
title={regimenSeleccionado ? `NCs Recibidas (${regimenSeleccionado})` : 'NCs Recibidas'}
|
||||
value={ncsRecibidasDisplay}
|
||||
icon={<FilePlus className="h-4 w-4" />}
|
||||
trend="neutral"
|
||||
trendValue="Notas de crédito recibidas"
|
||||
/>
|
||||
<KpiCard
|
||||
title={regimenSeleccionado ? `Utilidad Neta (${regimenSeleccionado})` : 'Utilidad Neta'}
|
||||
value={utilidadDisplay}
|
||||
icon={<Wallet className="h-4 w-4" />}
|
||||
trend={utilidadDisplay > 0 ? 'up' : 'down'}
|
||||
trendValue={`${margenDisplay}% margen`}
|
||||
trendValue={`${margenDisplay}% margen · incluye NCs`}
|
||||
/>
|
||||
<KpiCard
|
||||
title={regimenSeleccionado ? `Balance IVA (${regimenSeleccionado})` : 'Balance IVA'}
|
||||
@@ -252,7 +283,7 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Desglose por régimen */}
|
||||
{!regimenSeleccionado && kpis && (
|
||||
(kpis.ingresosPorRegimen.length > 1 || kpis.egresosPorRegimen.length > 1 || kpis.ivaBalancePorRegimen.length > 1) && (
|
||||
(kpis.ingresosPorRegimen.length > 1 || kpis.egresosPorRegimen.length > 1 || kpis.ivaBalancePorRegimen.length > 1 || kpis.ncsEmitidasPorRegimen.length > 1 || kpis.ncsRecibidasPorRegimen.length > 1) && (
|
||||
<div className="grid gap-4 md:grid-cols-2 3xl:grid-cols-3">
|
||||
{kpis.ingresosPorRegimen.length > 1 && (
|
||||
<Card>
|
||||
@@ -316,6 +347,46 @@ export default function DashboardPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{kpis.ncsEmitidasPorRegimen.length > 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium">NCs Emitidas por Regimen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{kpis.ncsEmitidasPorRegimen.map((r) => (
|
||||
<div key={r.regimenClave} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-mono font-bold bg-muted px-2 py-1 rounded">{r.regimenClave}</span>
|
||||
<span className="text-sm">{r.regimenDescripcion}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold">{formatCurrency(r.monto)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{kpis.ncsRecibidasPorRegimen.length > 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium">NCs Recibidas por Regimen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{kpis.ncsRecibidasPorRegimen.map((r) => (
|
||||
<div key={r.regimenClave} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-mono font-bold bg-muted px-2 py-1 rounded">{r.regimenClave}</span>
|
||||
<span className="text-sm">{r.regimenDescripcion}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold">{formatCurrency(r.monto)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as docsApi from '@/lib/api/documentos';
|
||||
|
||||
const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
||||
const IMPUESTOS: Impuesto[] = ['IVA', 'ISR', 'IEPS', 'SUELDOS', 'DIOT', 'OTRO'];
|
||||
const IMPUESTOS: Impuesto[] = ['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'];
|
||||
const PERIODICIDADES: { value: Periodicidad; label: string }[] = [
|
||||
{ value: 'mensual', label: 'Mensual' },
|
||||
{ value: 'bimestral', label: 'Bimestral' },
|
||||
@@ -76,7 +76,7 @@ function getPeriodLabel(periodicidad: string, mes: number): string {
|
||||
const options = getPeriodOptions(periodicidad as Periodicidad);
|
||||
return options.find(o => o.value === mes)?.label || MESES[mes - 1] || String(mes);
|
||||
}
|
||||
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar'];
|
||||
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
|
||||
|
||||
function EstatusBadge({ estatus }: { estatus: string }) {
|
||||
if (estatus === 'Positiva') return <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"><CheckCircle2 className="h-3 w-3" /> {estatus}</span>;
|
||||
@@ -87,7 +87,7 @@ function EstatusBadge({ estatus }: { estatus: string }) {
|
||||
export default function DocumentosPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const canConsultarOpinion = user?.role === 'owner' || user?.role === 'cfo';
|
||||
const canSeePapeleria = user?.role !== 'cliente';
|
||||
const canSeePapeleria = true; // Todos los roles pueden ver papelería (cliente con restricciones)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -700,7 +700,7 @@ function ComprobantePagoDialog({ declaracion, onClose }: { declaracion: Declarac
|
||||
// Extras — PDFs libres (acuses, contratos, poderes, estados de cuenta, etc.)
|
||||
// ============================================================================
|
||||
|
||||
const ROLES_UPLOAD_EXTRA = ['owner', 'cfo', 'contador', 'auxiliar'];
|
||||
const ROLES_UPLOAD_EXTRA = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
|
||||
|
||||
function ExtrasTab() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
@@ -77,10 +77,14 @@ export default function UsuariosPage() {
|
||||
const deleteUsuario = useDeleteUsuario();
|
||||
|
||||
const isDespacho = isDespachoTenant(currentUser?.tenantRfc);
|
||||
const inviteRoles = isDespacho ? despachoInviteRoles : legacyInviteRoles;
|
||||
const inviteRoles = isDespacho
|
||||
? (currentUser?.role === 'supervisor'
|
||||
? despachoInviteRoles.filter(r => r.value === 'cliente')
|
||||
: despachoInviteRoles)
|
||||
: legacyInviteRoles;
|
||||
const defaultInviteRole = isDespacho ? 'auxiliar' : 'visor';
|
||||
|
||||
const isAdmin = currentUser?.role === 'owner' || currentUser?.role === 'cfo';
|
||||
const isAdmin = currentUser?.role === 'owner' || currentUser?.role === 'cfo' || currentUser?.role === 'supervisor';
|
||||
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: UserInvite['role']; supervisorUserId?: string }>({
|
||||
@@ -96,15 +100,18 @@ export default function UsuariosPage() {
|
||||
const [savingAccesos, setSavingAccesos] = useState(false);
|
||||
|
||||
// Edit supervisor modal (para auxiliares)
|
||||
const [editingSupervisorUser, setEditingSupervisorUser] = useState<{ id: string; nombre: string } | null>(null);
|
||||
const [editingSupervisorUser, setEditingSupervisorUser] = useState<{ id: string; nombre: string; supervisorNombre?: string | null } | null>(null);
|
||||
const [selectedSupervisorId, setSelectedSupervisorId] = useState<string>('');
|
||||
const [savingSupervisor, setSavingSupervisor] = useState(false);
|
||||
|
||||
const [currentSupervisorNombre, setCurrentSupervisorNombre] = useState<string>('');
|
||||
|
||||
const openEditSupervisor = async (userId: string, nombre: string) => {
|
||||
try {
|
||||
const res = await apiClient.get<{ supervisorUserId: string | null }>(`/usuarios/${userId}/supervisor`);
|
||||
const res = await apiClient.get<{ supervisorUserId: string | null; supervisorNombre: string | null }>(`/usuarios/${userId}/supervisor`);
|
||||
setSelectedSupervisorId(res.data.supervisorUserId ?? '');
|
||||
setEditingSupervisorUser({ id: userId, nombre });
|
||||
setCurrentSupervisorNombre(res.data.supervisorNombre ?? '');
|
||||
setEditingSupervisorUser({ id: userId, nombre, supervisorNombre: res.data.supervisorNombre });
|
||||
} catch {
|
||||
alert('Error al cargar supervisor');
|
||||
}
|
||||
@@ -483,7 +490,14 @@ export default function UsuariosPage() {
|
||||
<div className="space-y-2 py-2">
|
||||
{supervisores && supervisores.length > 0 ? (
|
||||
<Select value={selectedSupervisorId || 'none'} onValueChange={(v) => setSelectedSupervisorId(v === 'none' ? '' : v)}>
|
||||
<SelectTrigger><SelectValue placeholder="Selecciona un supervisor..." /></SelectTrigger>
|
||||
<SelectTrigger className="w-full">
|
||||
{(() => {
|
||||
if (!selectedSupervisorId || selectedSupervisorId === 'none') return <span className="text-muted-foreground">Sin supervisor asignado</span>;
|
||||
const s = supervisores?.find(x => x.userId === selectedSupervisorId);
|
||||
if (s) return <span>{s.nombre} — {s.email}</span>;
|
||||
return <span>{currentSupervisorNombre || editingSupervisorUser?.supervisorNombre || selectedSupervisorId}</span>;
|
||||
})()}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Sin supervisor asignado</SelectItem>
|
||||
{supervisores.map(s => (
|
||||
@@ -491,6 +505,12 @@ export default function UsuariosPage() {
|
||||
{s.nombre} — {s.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
{/* Si el supervisor actual no está en la lista de carteras, mostrarlo igual */}
|
||||
{selectedSupervisorId && !supervisores.some(s => s.userId === selectedSupervisorId) && (
|
||||
<SelectItem value={selectedSupervisorId}>
|
||||
{currentSupervisorNombre || editingSupervisorUser?.supervisorNombre || selectedSupervisorId}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
|
||||
@@ -33,6 +33,16 @@ export function ContribuyenteSelector() {
|
||||
}
|
||||
}, [contribuyentes, selectedContribuyenteId, setSelectedContribuyente]);
|
||||
|
||||
// Clear invalid selection (e.g. stale localStorage from another tenant/session)
|
||||
useEffect(() => {
|
||||
if (contribuyentes && contribuyentes.length > 0 && selectedContribuyenteId) {
|
||||
const exists = contribuyentes.some(c => c.id === selectedContribuyenteId);
|
||||
if (!exists) {
|
||||
clearSelectedContribuyente();
|
||||
}
|
||||
}
|
||||
}, [contribuyentes, selectedContribuyenteId, clearSelectedContribuyente]);
|
||||
|
||||
if (isLoading || !contribuyentes || contribuyentes.length === 0) return null;
|
||||
if (pathname && HIDDEN_PATHS.some(p => pathname === p || pathname.startsWith(`${p}/`))) return null;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ interface NavItem {
|
||||
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
|
||||
|
||||
const ITEMS: NavItem[] = [
|
||||
{ href: '/despachos/contribuyentes', label: 'Contribuyentes', icon: Building2, roles: ['owner', 'cfo', 'contador', 'visor', 'supervisor', 'auxiliar'] },
|
||||
{ href: '/despachos/contribuyentes', label: 'Contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
|
||||
{ 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'] },
|
||||
];
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
||||
import { Upload, Download, Trash2, CheckCircle2, XCircle, Clock, AlertTriangle, MessageSquare } from 'lucide-react';
|
||||
import { Upload, Download, Trash2, CheckCircle2, XCircle, Clock, AlertTriangle, MessageSquare, UserCheck } from 'lucide-react';
|
||||
|
||||
const MESES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
|
||||
const ALLOWED_MIMES = [
|
||||
@@ -37,6 +37,9 @@ interface Papeleria {
|
||||
requiereAprobacion: boolean;
|
||||
estado: 'pendiente' | 'aprobado' | 'rechazado' | null;
|
||||
comentarioRechazo: string | null;
|
||||
requiereAprobacionCliente: boolean;
|
||||
estadoCliente: 'pendiente' | 'aprobado' | 'rechazado' | null;
|
||||
comentarioRechazoCliente: string | null;
|
||||
subidoPor: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -54,28 +57,59 @@ function fileToBase64(file: File): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
function EstadoBadge({ estado, requiereAprobacion }: { estado: string | null; requiereAprobacion: boolean }) {
|
||||
if (!requiereAprobacion) {
|
||||
function estadoGlobal(item: Papeleria): 'sin_aprobacion' | 'pendiente' | 'aprobado' | 'rechazado' {
|
||||
const reqOwner = item.requiereAprobacion;
|
||||
const reqCliente = item.requiereAprobacionCliente;
|
||||
const estOwner = item.estado;
|
||||
const estCliente = item.estadoCliente;
|
||||
|
||||
if (!reqOwner && !reqCliente) return 'sin_aprobacion';
|
||||
if (estOwner === 'rechazado' || estCliente === 'rechazado') return 'rechazado';
|
||||
if (reqOwner && reqCliente) {
|
||||
if (estOwner === 'aprobado' && estCliente === 'aprobado') return 'aprobado';
|
||||
return 'pendiente';
|
||||
}
|
||||
if (reqOwner) return estOwner ?? 'pendiente';
|
||||
return estCliente ?? 'pendiente';
|
||||
}
|
||||
|
||||
function EstadoBadge({ item }: { item: Papeleria }) {
|
||||
const global = estadoGlobal(item);
|
||||
|
||||
if (global === 'sin_aprobacion') {
|
||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-muted-foreground">Sin aprobación</span>;
|
||||
}
|
||||
if (estado === 'aprobado') {
|
||||
if (global === 'aprobado') {
|
||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"><CheckCircle2 className="h-3 w-3" /> Aprobado</span>;
|
||||
}
|
||||
if (estado === 'rechazado') {
|
||||
if (global === 'rechazado') {
|
||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"><XCircle className="h-3 w-3" /> Rechazado</span>;
|
||||
}
|
||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"><Clock className="h-3 w-3" /> Pendiente</span>;
|
||||
|
||||
// Pendiente — mostrar quién falta
|
||||
const faltaOwner = item.requiereAprobacion && item.estado !== 'aprobado';
|
||||
const faltaCliente = item.requiereAprobacionCliente && item.estadoCliente !== 'aprobado';
|
||||
let label = 'Pendiente';
|
||||
if (faltaOwner && faltaCliente) label = 'Pendiente (ambos)';
|
||||
else if (faltaOwner) label = 'Pendiente (owner)';
|
||||
else if (faltaCliente) label = 'Pendiente (cliente)';
|
||||
|
||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"><Clock className="h-3 w-3" /> {label}</span>;
|
||||
}
|
||||
|
||||
export function PapeleriaTab() {
|
||||
const user = useAuthStore(s => s.user);
|
||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||
const queryClient = useQueryClient();
|
||||
const canApprove = user?.role ? ROLES_APROBADOR.has(user.role) : false;
|
||||
const isCliente = user?.role === 'cliente';
|
||||
const canApproveOwner = user?.role ? ROLES_APROBADOR.has(user.role) : false;
|
||||
const canUpload = !isCliente;
|
||||
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [rechazoFor, setRechazoFor] = useState<Papeleria | null>(null);
|
||||
const [comentarioRechazo, setComentarioRechazo] = useState('');
|
||||
const [rechazoClienteFor, setRechazoClienteFor] = useState<Papeleria | null>(null);
|
||||
const [comentarioRechazoCliente, setComentarioRechazoCliente] = useState('');
|
||||
|
||||
// Filtros
|
||||
const currentYear = new Date().getFullYear();
|
||||
@@ -105,6 +139,7 @@ export function PapeleriaTab() {
|
||||
const [anio, setAnio] = useState(currentYear);
|
||||
const [mes, setMes] = useState(new Date().getMonth() + 1);
|
||||
const [requiereAprobacion, setRequiereAprobacion] = useState(false);
|
||||
const [requiereAprobacionCliente, setRequiereAprobacionCliente] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
const resetUpload = () => {
|
||||
@@ -114,6 +149,7 @@ export function PapeleriaTab() {
|
||||
setAnio(currentYear);
|
||||
setMes(new Date().getMonth() + 1);
|
||||
setRequiereAprobacion(false);
|
||||
setRequiereAprobacionCliente(false);
|
||||
setUploadError(null);
|
||||
};
|
||||
|
||||
@@ -130,6 +166,7 @@ export function PapeleriaTab() {
|
||||
anio,
|
||||
mes,
|
||||
requiereAprobacion,
|
||||
requiereAprobacionCliente,
|
||||
archivoBase64: base64,
|
||||
archivoFilename: file.name,
|
||||
archivoMime: file.type,
|
||||
@@ -172,11 +209,33 @@ export function PapeleriaTab() {
|
||||
},
|
||||
});
|
||||
|
||||
const aprobarClienteMutation = useMutation({
|
||||
mutationFn: async (id: number) => apiClient.post(`/papeleria/${id}/aprobar-cliente`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const rechazarClienteMutation = useMutation({
|
||||
mutationFn: async ({ id, comentario }: { id: number; comentario: string | null }) =>
|
||||
apiClient.post(`/papeleria/${id}/rechazar-cliente`, { comentario }),
|
||||
onSuccess: () => {
|
||||
setRechazoClienteFor(null);
|
||||
setComentarioRechazoCliente('');
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const eliminarMutation = useMutation({
|
||||
mutationFn: async (id: number) => apiClient.delete(`/papeleria/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const items = query.data ?? [];
|
||||
const años = useMemo(() => {
|
||||
const set = new Set<number>([currentYear]);
|
||||
items.forEach(i => set.add(i.anio));
|
||||
return [...set].sort((a, b) => b - a);
|
||||
}, [items, currentYear]);
|
||||
|
||||
if (!selectedContribuyenteId) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -187,13 +246,6 @@ export function PapeleriaTab() {
|
||||
);
|
||||
}
|
||||
|
||||
const items = query.data ?? [];
|
||||
const años = useMemo(() => {
|
||||
const set = new Set<number>([currentYear]);
|
||||
items.forEach(i => set.add(i.anio));
|
||||
return [...set].sort((a, b) => b - a);
|
||||
}, [items, currentYear]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filtros + upload */}
|
||||
@@ -236,9 +288,11 @@ export function PapeleriaTab() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setShowUpload(true)}>
|
||||
<Upload className="h-4 w-4 mr-2" /> Subir documento
|
||||
</Button>
|
||||
{canUpload && (
|
||||
<Button onClick={() => setShowUpload(true)}>
|
||||
<Upload className="h-4 w-4 mr-2" /> Subir documento
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Listado */}
|
||||
@@ -258,7 +312,7 @@ export function PapeleriaTab() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{it.nombre}</span>
|
||||
<EstadoBadge estado={it.estado} requiereAprobacion={it.requiereAprobacion} />
|
||||
<EstadoBadge item={it} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{MESES[it.mes - 1]} {it.anio}
|
||||
</span>
|
||||
@@ -270,10 +324,31 @@ export function PapeleriaTab() {
|
||||
{it.archivoFilename} · {(it.archivoSize / 1024).toFixed(0)} KB
|
||||
· subido {new Date(it.createdAt).toLocaleDateString('es-MX')}
|
||||
</p>
|
||||
{/* Mostrar estado detallado para no-clientes */}
|
||||
{!isCliente && (it.requiereAprobacion || it.requiereAprobacionCliente) && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{it.requiereAprobacion && (
|
||||
<span className={`text-xs inline-flex items-center gap-1 ${it.estado === 'aprobado' ? 'text-green-700 dark:text-green-400' : it.estado === 'rechazado' ? 'text-red-700 dark:text-red-400' : 'text-yellow-700 dark:text-yellow-400'}`}>
|
||||
<UserCheck className="h-3 w-3" /> Owner: {it.estado ?? '—'}
|
||||
</span>
|
||||
)}
|
||||
{it.requiereAprobacionCliente && (
|
||||
<span className={`text-xs inline-flex items-center gap-1 ${it.estadoCliente === 'aprobado' ? 'text-green-700 dark:text-green-400' : it.estadoCliente === 'rechazado' ? 'text-red-700 dark:text-red-400' : 'text-yellow-700 dark:text-yellow-400'}`}>
|
||||
<UserCheck className="h-3 w-3" /> Cliente: {it.estadoCliente ?? '—'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{it.estado === 'rechazado' && it.comentarioRechazo && (
|
||||
<p className="text-xs mt-1 flex items-start gap-1 text-red-700 dark:text-red-400">
|
||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||
<span>{it.comentarioRechazo}</span>
|
||||
<span><strong>Owner:</strong> {it.comentarioRechazo}</span>
|
||||
</p>
|
||||
)}
|
||||
{it.estadoCliente === 'rechazado' && it.comentarioRechazoCliente && (
|
||||
<p className="text-xs mt-1 flex items-start gap-1 text-red-700 dark:text-red-400">
|
||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||
<span><strong>Cliente:</strong> {it.comentarioRechazoCliente}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -281,7 +356,8 @@ export function PapeleriaTab() {
|
||||
<Button variant="ghost" size="icon" onClick={() => downloadMutation.mutate(it)} title="Descargar">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
{canApprove && it.requiereAprobacion && it.estado === 'pendiente' && (
|
||||
{/* Botones owner/supervisor */}
|
||||
{canApproveOwner && it.requiereAprobacion && it.estado === 'pendiente' && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
@@ -299,13 +375,34 @@ export function PapeleriaTab() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
onClick={() => confirm(`¿Eliminar "${it.nombre}"?`) && eliminarMutation.mutate(it.id)}
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
{/* Botones cliente */}
|
||||
{isCliente && it.requiereAprobacionCliente && it.estadoCliente === 'pendiente' && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
onClick={() => aprobarClienteMutation.mutate(it.id)}
|
||||
title="Aprobar"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
onClick={() => setRechazoClienteFor(it)}
|
||||
title="Rechazar"
|
||||
>
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canUpload && (
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
onClick={() => confirm(`¿Eliminar "${it.nombre}"?`) && eliminarMutation.mutate(it.id)}
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -375,6 +472,14 @@ export function PapeleriaTab() {
|
||||
/>
|
||||
Este documento requiere aprobación de owner/supervisor
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={requiereAprobacionCliente}
|
||||
onChange={e => setRequiereAprobacionCliente(e.target.checked)}
|
||||
/>
|
||||
Este documento requiere aprobación del cliente
|
||||
</label>
|
||||
{uploadError && (
|
||||
<p className="text-xs text-destructive flex items-start gap-1">
|
||||
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||
@@ -394,7 +499,7 @@ export function PapeleriaTab() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Modal Rechazo */}
|
||||
{/* Modal Rechazo Owner */}
|
||||
<Dialog open={!!rechazoFor} onOpenChange={(o) => { if (!o) { setRechazoFor(null); setComentarioRechazo(''); } }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -426,6 +531,39 @@ export function PapeleriaTab() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Modal Rechazo Cliente */}
|
||||
<Dialog open={!!rechazoClienteFor} onOpenChange={(o) => { if (!o) { setRechazoClienteFor(null); setComentarioRechazoCliente(''); } }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rechazar documento</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm">
|
||||
Vas a rechazar <strong>{rechazoClienteFor?.nombre}</strong>. El comentario es opcional.
|
||||
</p>
|
||||
<div>
|
||||
<Label>Comentario (opcional)</Label>
|
||||
<Input
|
||||
value={comentarioRechazoCliente}
|
||||
onChange={e => setComentarioRechazoCliente(e.target.value)}
|
||||
placeholder="Motivo del rechazo..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setRechazoClienteFor(null); setComentarioRechazoCliente(''); }}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => rechazoClienteFor && rechazarClienteMutation.mutate({ id: rechazoClienteFor.id, comentario: comentarioRechazoCliente || null })}
|
||||
className={cn('bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
|
||||
>
|
||||
Rechazar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,11 +55,11 @@ const navigation: NavItem[] = [
|
||||
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
|
||||
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo', 'supervisor', 'contador', 'auxiliar'] },
|
||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo', 'supervisor', 'auxiliar', 'cliente'] },
|
||||
];
|
||||
|
||||
const adminNavigation: NavItem[] = [
|
||||
|
||||
@@ -54,11 +54,11 @@ const navigation: NavItem[] = [
|
||||
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
|
||||
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo', 'supervisor', 'contador', 'auxiliar'] },
|
||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo', 'supervisor', 'auxiliar', 'cliente'] },
|
||||
];
|
||||
|
||||
const adminNavigation: NavItem[] = [
|
||||
|
||||
@@ -58,11 +58,11 @@ const navigation: NavItem[] = [
|
||||
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
|
||||
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo', 'supervisor', 'contador', 'auxiliar'] },
|
||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo', 'supervisor', 'auxiliar'] },
|
||||
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo', 'supervisor', 'auxiliar', 'cliente'] },
|
||||
];
|
||||
|
||||
const adminNavigation: NavItem[] = [
|
||||
|
||||
@@ -55,11 +55,11 @@ const navigation: NavItem[] = [
|
||||
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
|
||||
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo', 'supervisor', 'contador', 'auxiliar'] },
|
||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo', 'supervisor', 'auxiliar', 'cliente'] },
|
||||
];
|
||||
|
||||
const adminNavigation: NavItem[] = [
|
||||
|
||||
@@ -56,3 +56,6 @@ export const asignarTarea = (tareaId: string, auxiliarUserId: string) =>
|
||||
|
||||
export const desasignarTarea = (tareaId: string) =>
|
||||
apiClient.delete(`/tareas/${tareaId}/asignar`).then(r => r.data);
|
||||
|
||||
export const getAuxiliaresElegibles = (contribuyenteId: string) =>
|
||||
apiClient.get<{ auxiliares: string[] }>(`/carteras/asignaciones/auxiliares-elegibles/${contribuyenteId}`).then(r => r.data);
|
||||
|
||||
@@ -91,6 +91,11 @@ export async function getCfdiById(id: string): Promise<Cfdi> {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function downloadXmlsZip(filters: CfdiFilters): Promise<Blob> {
|
||||
const response = await apiClient.post('/cfdi/download-xmls', filters, { responseType: 'blob' });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getResumenCfdi(año?: number, mes?: number, contribuyenteId?: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (año) params.set('año', año.toString());
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface Contribuyente {
|
||||
nombre: string;
|
||||
identificador: string;
|
||||
supervisorUserId: string | null;
|
||||
supervisorNombre: string | null;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
rfc: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'SUELDOS' | 'DIOT' | 'OTRO';
|
||||
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'ISN' | 'DIOT' | 'OTRO' | 'ISH';
|
||||
export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'semestral' | 'anual';
|
||||
|
||||
export interface Declaracion {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
desasignarObligacion,
|
||||
asignarTarea,
|
||||
desasignarTarea,
|
||||
getAuxiliaresElegibles,
|
||||
} from '../api/asignaciones';
|
||||
|
||||
export function useAsignacionesSupervisor() {
|
||||
@@ -87,3 +88,11 @@ export function useDesasignarTarea() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuxiliaresElegibles(contribuyenteId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['auxiliares-elegibles', contribuyenteId],
|
||||
queryFn: () => getAuxiliaresElegibles(contribuyenteId!),
|
||||
enabled: !!contribuyenteId,
|
||||
});
|
||||
}
|
||||
|
||||
152
docs/CAMBIOS-2026-05-24.md
Normal file
152
docs/CAMBIOS-2026-05-24.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Resumen de cambios - 24 de mayo de 2026
|
||||
|
||||
---
|
||||
|
||||
## 1. Refactor: Descarga masiva de XMLs por filtros
|
||||
|
||||
**Fecha:** 2026-05-24
|
||||
|
||||
### Problema
|
||||
El mecanismo anterior requería que el usuario seleccionara individualmente cada CFDI mediante checkboxes. Era lento, propenso a errores y no permitía descargar rangos grandes eficientemente.
|
||||
|
||||
### Solución
|
||||
Reemplazo completo por descarga basada en filtros: un botón "Descargar XMLs" descarga todos los CFDIs que coincidan con los filtros activos en la tabla (tipo, estado, fechas, RFC, emisor, receptor, búsqueda, contribuyente).
|
||||
|
||||
### Cambios
|
||||
- **Backend:** `POST /cfdi/download-xmls` acepta `{ filters: CfdiFilters }` en lugar de `{ ids: number[] }`. Usa `getXmlsByFilters()` con `LIMIT 1000`.
|
||||
- **Frontend:** Eliminados checkboxes de tabla y estado `selectedIds`. Botón de descarga permanente que usa filtros actuales.
|
||||
- **Warning >1,000:** Si los filtros devuelven más de 1,000 CFDIs, se muestra `confirm()` con "Solo se descargarán los primeros 1,000 XMLs. ¿Continuar?" y se procede.
|
||||
|
||||
### Archivos
|
||||
| Archivo | Cambio |
|
||||
|---------|--------|
|
||||
| `apps/api/src/services/cfdi.service.ts` | `getXmlsByFilters()` reusa `whereClause` de `getCfdis` |
|
||||
| `apps/api/src/controllers/cfdi.controller.ts` | `downloadXmlsZip()` acepta filtros, genera ZIP |
|
||||
| `apps/web/lib/api/cfdi.ts` | `downloadXmlsZip(filters: CfdiFilters)` |
|
||||
| `apps/web/app/(dashboard)/cfdi/page.tsx` | Sin checkboxes; botón usa filtros actuales |
|
||||
|
||||
---
|
||||
|
||||
## 2. Conciliación: filtros con autocompletado
|
||||
|
||||
**Fecha:** ~2026-05-12
|
||||
|
||||
Filtros de columna en tablas de conciliación (RFC Emisor, Nombre Emisor, RFC Receptor, Nombre Receptor, Banco) con:
|
||||
- Debounce de 300ms
|
||||
- Dropdown de sugerencias clickeables (máx 8 items)
|
||||
- Botones "Aplicar" y "Limpiar" dentro del Popover
|
||||
|
||||
### Archivos
|
||||
| Archivo | Cambio |
|
||||
|---------|--------|
|
||||
| `apps/web/app/(dashboard)/conciliacion/page.tsx` | `FilterHeader` component con `useDebounce` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Conciliación: columnas dinámicas según tab
|
||||
|
||||
**Fecha:** ~2026-05-12
|
||||
|
||||
Las tablas "Conciliadas" y "Por conciliar" muestran columnas diferentes según el tab activo (EMITIDO / RECIBIDO):
|
||||
|
||||
- **EMITIDO:** RFC Receptor, Nombre Receptor
|
||||
- **RECIBIDO:** RFC Emisor, Nombre Emisor
|
||||
|
||||
En Pendientes se agregó también la columna de régimen fiscal correspondiente:
|
||||
- **EMITIDO:** Régimen Emisor
|
||||
- **RECIBIDO:** Régimen Receptor
|
||||
|
||||
### Archivos
|
||||
| Archivo | Cambio |
|
||||
|---------|--------|
|
||||
| `apps/web/app/(dashboard)/conciliacion/page.tsx` | Renderizado condicional de columnas |
|
||||
| `apps/api/src/services/conciliacion.service.ts` | SELECT incluye `regimen_fiscal_emisor` y `regimen_fiscal_receptor` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Conciliación: métricas I+P-E
|
||||
|
||||
**Fecha:** ~2026-05-12
|
||||
|
||||
El cálculo de "Monto Conciliado" y "Pendiente" ahora aplica la fórmula contable:
|
||||
- **Ingresos (I)** y **Pagos (P)** suman
|
||||
- **Egresos (E)** restan
|
||||
|
||||
Implementado en `getMonto()` del frontend.
|
||||
|
||||
---
|
||||
|
||||
## 5. Conciliación: headers visibles sin datos
|
||||
|
||||
**Fecha:** ~2026-05-12
|
||||
|
||||
Las tablas de conciliación mantienen encabezados y filtros visibles incluso cuando no hay resultados (antes desaparecían completamente). Se muestra mensaje "No hay datos" en el `tbody`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Conciliación: aumento de tamaño de fuente
|
||||
|
||||
**Fecha:** ~2026-05-12
|
||||
|
||||
- Tablas: `text-xs` → `text-base`
|
||||
- Celdas: `text-xs` → `text-sm`
|
||||
|
||||
---
|
||||
|
||||
## 7. Backfill masivo de datos CFDI faltantes
|
||||
|
||||
**Fecha:** ~2026-05-10
|
||||
|
||||
**Problema:** ~63,618 CFDIs tenían campos vacíos (`serie`, `folio`, `metodo_pago`, `forma_pago`, `uso_cfdi`, `regimen_fiscal`) porque los INSERTs fallaron durante sincronización SAT al no existir la columna `año_global` en ese momento.
|
||||
|
||||
**Fix:**
|
||||
- Se parsearon los XMLs originales desde disco
|
||||
- Se actualizaron masivamente las filas faltantes vía script Node.js
|
||||
|
||||
---
|
||||
|
||||
## 8. Fix: Visor de CFDI en conciliación — campos faltantes
|
||||
|
||||
**Fecha:** 2026-05-09 (continuación)
|
||||
|
||||
El visor de CFDI desde conciliación ahora muestra correctamente:
|
||||
- Status (Vigente/Cancelado)
|
||||
- Forma de pago
|
||||
- Serie/Folio
|
||||
- Uso CFDI
|
||||
- Subtotal, descuento, impuestos desglosados
|
||||
- Moneda y tipo de cambio
|
||||
|
||||
Ver `docs/CAMBIOS-2026-05-09.md` sección 7 para detalles completos.
|
||||
|
||||
---
|
||||
|
||||
## Archivos modificados (consolidado)
|
||||
|
||||
### Backend (`apps/api/`)
|
||||
| Archivo | Cambio |
|
||||
|---------|--------|
|
||||
| `src/services/cfdi.service.ts` | `getXmlsByFilters()` para descarga masiva por filtros |
|
||||
| `src/controllers/cfdi.controller.ts` | `downloadXmlsZip()` refactorizado |
|
||||
| `src/services/conciliacion.service.ts` | SELECT régimen fiscal + campos visor + fecha_pago_p |
|
||||
|
||||
### Frontend (`apps/web/`)
|
||||
| Archivo | Cambio |
|
||||
|---------|--------|
|
||||
| `app/(dashboard)/cfdi/page.tsx` | Descarga XMLs por filtros, sin checkboxes |
|
||||
| `lib/api/cfdi.ts` | `downloadXmlsZip()` recibe filtros |
|
||||
| `app/(dashboard)/conciliacion/page.tsx` | Filtros debounce, columnas dinámicas, métricas I+P-E, font size, headers siempre visibles |
|
||||
|
||||
---
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
cd /root/HoruxDespachosNuevo
|
||||
npm run build --filter=@horux/api
|
||||
npm run build --filter=@horux/web
|
||||
pm2 reload horux-api
|
||||
pm2 reload horux-web
|
||||
```
|
||||
|
||||
**Estado:** ✅ Exitoso
|
||||
137
docs/sessions/2026-05-24-cfdi-bulk-xml-download-refactor.md
Normal file
137
docs/sessions/2026-05-24-cfdi-bulk-xml-download-refactor.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Refactor: Descarga masiva de XMLs por filtros
|
||||
|
||||
**Fecha:** 2026-05-24
|
||||
**Feature:** CFDI — descarga masiva de XMLs refactorizada de selección por checkbox a descarga por filtros
|
||||
|
||||
---
|
||||
|
||||
## 1. Requerimiento
|
||||
|
||||
Cambiar el mecanismo de descarga masiva de XMLs en la página `/cfdi`:
|
||||
|
||||
- **Antes:** El usuario debía seleccionar CFDIs individuales mediante checkboxes por fila y en el header de la tabla. Solo se descargaban los seleccionados.
|
||||
- **Después:** Un único botón **"Descargar XMLs"** descarga **todos los CFDIs que coincidan con los filtros activos**, sin necesidad de selección manual.
|
||||
- **Límite:** Si los filtros aplicados devuelven más de 1,000 CFDIs, se muestra una advertencia (`confirm`) informando que solo se descargarán los primeros 1,000, pero el usuario puede proceder.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decisiones de diseño
|
||||
|
||||
### 2.1 Sin checkboxes
|
||||
Eliminar toda la UI de selección (header con checkbox maestro, checkboxes por fila, barra de "X seleccionados" y botón "Limpiar"). Esto simplifica la UX y reduce el estado del componente.
|
||||
|
||||
### 2.2 Filtros como fuente de verdad
|
||||
El backend ya soportaba descarga por IDs (`downloadXmlsZip(ids: number[])`). Se reemplazó por descarga por filtros (`downloadXmlsZip(filters: CfdiFilters)`). Esto aprovecha el mismo `whereClause` que usa `getCfdis()` para listar, garantizando consistencia entre lo que se ve y lo que se descarga.
|
||||
|
||||
### 2.3 Warning, no error, al superar 1,000
|
||||
En lugar de bloquear la descarga cuando hay >1,000 resultados, se muestra un `window.confirm()` con el mensaje:
|
||||
> "Solo se descargarán los primeros 1,000 XMLs. ¿Continuar?"
|
||||
|
||||
Si el usuario acepta, el backend ejecuta la query con `LIMIT 1000` y genera el ZIP.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cambios implementados
|
||||
|
||||
### 3.1 Backend
|
||||
|
||||
#### Service: `apps/api/src/services/cfdi.service.ts`
|
||||
|
||||
**Función existente reutilizada:** `getXmlsByFilters(pool, filters, limit)`
|
||||
- Reusa el mismo `whereClause` builder de `getCfdis()` para garantizar consistencia.
|
||||
- SELECT: `id, uuid, xml_original as xml`
|
||||
- LIMIT parametrizado (default 1000).
|
||||
|
||||
#### Controller: `apps/api/src/controllers/cfdi.controller.ts`
|
||||
|
||||
**Endpoint:** `POST /cfdi/download-xmls`
|
||||
- Body: `{ filters: CfdiFilters }`
|
||||
- Llama `getXmlsByFilters(req.tenantPool, filters, 1000)`.
|
||||
- Genera ZIP con `adm-zip`.
|
||||
- Retorna `application/zip` con `Content-Disposition: attachment; filename="cfdis-{timestamp}.zip"`.
|
||||
- Si ningún CFDI tiene XML (campo `xml` null o vacío), retorna 404.
|
||||
|
||||
### 3.2 Frontend
|
||||
|
||||
#### API client: `apps/web/lib/api/cfdi.ts`
|
||||
|
||||
```ts
|
||||
// Antes
|
||||
export async function downloadXmlsZip(ids: number[]): Promise<Blob>
|
||||
|
||||
// Después
|
||||
export async function downloadXmlsZip(filters: CfdiFilters): Promise<Blob>
|
||||
```
|
||||
|
||||
La función ahora envía `filters` en el body en lugar de un array de `ids`.
|
||||
|
||||
#### Página: `apps/web/app/(dashboard)/cfdi/page.tsx`
|
||||
|
||||
**Estados eliminados:**
|
||||
- `selectedIds: Set<number>`
|
||||
- `downloadingXmls` se mantiene solo para indicador de loading en el botón.
|
||||
|
||||
**UI eliminada:**
|
||||
- Checkbox en header de tabla (`<th className="w-8">`).
|
||||
- Checkbox por fila en cada `<tr>`.
|
||||
- Barra de "X seleccionados" con botones "Descargar XMLs" y "Limpiar" condicionales.
|
||||
|
||||
**UI nueva/modificada:**
|
||||
- Botón **"Descargar XMLs"** ubicado permanentemente en la barra de acciones del `CardHeader`.
|
||||
- Deshabilitado cuando:
|
||||
- `downloadingXmls === true` (ya hay una descarga en curso)
|
||||
- `!data?.total` (no hay resultados con los filtros actuales)
|
||||
|
||||
**Flujo del botón:**
|
||||
1. Verifica si `data.total > 1000`.
|
||||
2. Si sí → `window.confirm()` con mensaje de advertencia.
|
||||
3. Si el usuario cancela → no hace nada.
|
||||
4. Si el usuario acepta (o total ≤ 1000) → construye objeto `CfdiFilters` con los filtros actuales (`tipo`, `tipoComprobante`, `estado`, `fechaInicio`, `fechaFin`, `rfc`, `emisor`, `receptor`, `search`, `contribuyenteId`).
|
||||
5. Llama `downloadXmlsZip(filters)`.
|
||||
6. Crea blob URL y dispara descarga con nombre `cfdis-xml-{timestamp}.zip`.
|
||||
7. Limpia URL object.
|
||||
|
||||
---
|
||||
|
||||
## 4. Estructura del ZIP
|
||||
|
||||
Cada archivo dentro del ZIP se nombra `{uuid}.xml` o `{id}.xml` si el UUID es null.
|
||||
|
||||
El contenido es el XML original tal como se almacenó en `cfdis.xml_original`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Archivos modificados
|
||||
|
||||
| Archivo | Cambio |
|
||||
|---------|--------|
|
||||
| `apps/api/src/services/cfdi.service.ts` | `getXmlsByFilters()` — reusa `whereClause` de `getCfdis`, limit 1000 |
|
||||
| `apps/api/src/controllers/cfdi.controller.ts` | `downloadXmlsZip()` — acepta filtros, genera ZIP con adm-zip |
|
||||
| `apps/api/src/routes/cfdi.routes.ts` | Ruta `POST /download-xmls` registrada |
|
||||
| `apps/web/lib/api/cfdi.ts` | `downloadXmlsZip()` ahora recibe `CfdiFilters` |
|
||||
| `apps/web/app/(dashboard)/cfdi/page.tsx` | Eliminados checkboxes y `selectedIds`; botón de descarga usa filtros actuales |
|
||||
|
||||
---
|
||||
|
||||
## 6. Deploy
|
||||
|
||||
```bash
|
||||
cd /root/HoruxDespachosNuevo
|
||||
# Builds
|
||||
npm run build --filter=@horux/api
|
||||
npm run build --filter=@horux/web
|
||||
|
||||
# PM2 reload
|
||||
pm2 reload horux-api
|
||||
pm2 reload horux-web
|
||||
```
|
||||
|
||||
**Estado:** ✅ Exitoso. Builds sin errores. Procesos reiniciados.
|
||||
|
||||
---
|
||||
|
||||
## 7. Notas técnicas
|
||||
|
||||
- El backend no envía el mensaje de advertencia como respuesta; el frontend lo calcula comparando `data.total` (del listado paginado) contra 1,000. Esto es una aproximación eficiente porque evita un `COUNT(*` adicional.
|
||||
- Si el usuario aplica filtros muy amplios (ej. todo un mes sin restricciones), el ZIP puede contener hasta 1,000 archivos. Cada XML típicamente pesa entre 3 KB y 50 KB, por lo que el ZIP rara vez superará los 20–30 MB.
|
||||
- El endpoint requiere autenticación y tenant middleware (como todo el módulo CFDI).
|
||||
@@ -33,6 +33,10 @@ export interface KpiData {
|
||||
cfdisEmitidosPorRegimen: { regimen: string; total: number }[];
|
||||
cfdisRecibidos: number;
|
||||
cfdisRecibidosPorRegimen: { regimen: string; total: number }[];
|
||||
ncsEmitidas: number;
|
||||
ncsEmitidasPorRegimen: IngresoRegimen[];
|
||||
ncsRecibidas: number;
|
||||
ncsRecibidasPorRegimen: IngresoRegimen[];
|
||||
}
|
||||
|
||||
export interface IngresosEgresosData {
|
||||
|
||||
Reference in New Issue
Block a user