Compare commits

...

38 Commits

Author SHA1 Message Date
Horux Dev
63908f9e9d feat(sat): agregar cron de recuperación diaria a las 10:00 AM
- Revisa si el sync diario falló o si hay CFDIs vigentes sin xml_original.
- Si detecta facturas incompletas, lanza un sync initial con rango extendido
  (desde un mes antes de la factura incompleta más antigua hasta ayer).
- Corre secuencialmente por contribuyente para no saturar al SAT.
- Incluye soporte para tenants legacy sin contribuyentes.
2026-06-14 04:07:11 +00:00
Horux Dev
ed6cfed312 feat(dashboard): utilidad neta ajustada por notas de crédito
- La utilidad del dashboard ahora descuenta NCs emitidas de ingresos y NCs recibidas de gastos.
- El margen se calcula sobre ingresos netos.
- Solo afecta la UI del dashboard; no modifica el backend ni otros reportes.
2026-06-13 21:04:25 +00:00
Horux Dev
ab6b76fcb8 ui(dashboard): reordenar scorecards de notas de crédito
- NCs Emitidas ahora aparece después de Ingresos del Mes.
- NCs Recibidas ahora aparece después de Gastos del Mes.
2026-06-13 20:54:40 +00:00
Horux Dev
b52ff875be feat(dashboard): agregar scorecards de notas de crédito emitidas y recibidas
- Extiende KpiData con ncsEmitidas, ncsEmitidasPorRegimen, ncsRecibidas y ncsRecibidasPorRegimen.
- En getKpis se reutilizan calcularNcsEmitidasPorRegimen y calcularNcsRecibidasPorRegimen en paralelo.
- En el dashboard se agregan dos KpiCard y su desglose por régimen.
2026-06-13 20:46:57 +00:00
Horux Dev
66d68c652c Revert "feat(ui): make dashboard responsive for iPhone and mobile devices"
This reverts commit d3b326e.

The deployment caused reports of blank screens and 400 errors. Reverting to restore stable state while investigating root cause.
2026-06-13 20:16:04 +00:00
Horux Dev
d3b326e78c feat(ui): make dashboard responsive for iPhone and mobile devices
- Add Sheet primitive component for mobile drawers
- Add MobileNav with hamburger menu for dashboard layout
- Hide desktop sidebars on mobile; show mobile header
- Make dashboard header responsive with stacked layout on small screens
- Hide selector text on mobile, show icons only
- Convert fixed-width filters to responsive widths (CFDI, Clientes, Admin, Documentos, Alertas)
- Cap dialog widths to 95vw on mobile (CFDI viewer, Documentos, Reportes, Contribuyentes, Facturación)
- Make calendar grid smaller and use single-letter weekdays on mobile
- Update viewport to include viewport-fit=cover for Samsung safe areas
2026-06-13 19:55:06 +00:00
Horux Dev
b1eaf41681 fix(sat, payments, admin): multiple production fixes
- sat sweep-stale-jobs: increase initial/custom sync threshold 8h→24h to prevent watchdog killing long historical syncs
- sat-client: fix formatDateForSat same-day rejection by auto-adjusting fechaFin
- sat-sync job: check fiel_contribuyente in addition to fiel_credentials for cron eligibility
- database: extend pool idle cleanup from 5min to 12h to prevent pool closure during long syncs
- webhook controller: auto-extend currentPeriodEnd on recurring MercadoPago payments
- invoicing service: auto-send FacturAPI invoice by email after creation
- admin-clientes: fix no-renovaciones detection to include expired trials and deleted subscriptions
2026-06-10 18:11:47 +00:00
Horux Dev
bd7e499ab7 fix(csf): retry con backoff, delays entre tenants, timeouts aumentados 2026-06-01 23:43:43 +00:00
Horux Dev
44144ebf9d fix(contribuyente-selector): limpiar selección inválida de localStorage 2026-06-01 20:13:36 +00:00
Horux Dev
314a74982c fix(regimen): fallback a tenant/contribuyentes cuando un contribuyente no tiene regimen_fiscal 2026-06-01 20:07:59 +00:00
Horux Dev
76d3f00f29 debug(alertas): logging en generador y endpoint /automaticas; wrap cada alerta en try/catch 2026-06-01 19:59:57 +00:00
Horux Dev
214410d2fb fix(alertas): combinar regímenes de contribuyentes cuando no hay config a nivel tenant 2026-06-01 17:55:01 +00:00
Horux Dev
199922272f fix(sidebar): mostrar Usuarios para supervisor y auxiliar 2026-05-29 22:06:01 +00:00
Horux Dev
6e54efe5e4 feat(usuarios): supervisor puede invitar usuarios cliente
- Backend inviteUsuario: permite owner, cfo y supervisor
- Backend valida que supervisor solo pueda invitar rol cliente
- Backend addClienteAcceso: supervisor solo puede asignar contribuyentes
  que tenga visibles (getEntidadesVisibles)
- Frontend: supervisor ve botón Invitar Usuario y solo puede seleccionar
  rol Cliente en el dropdown
2026-05-29 21:32:12 +00:00
Horux Dev
5dd53cebac chore(usuarios): limpiar debug hardcodeado de supervisorNombre 2026-05-29 19:27:59 +00:00
Horux Dev
0de0df9357 fix(usuarios): mostrar nombre del supervisor en dropdown de forma robusta
- Backend: getSupervisor devuelve supervisorNombre desde Prisma
- Frontend: usa SelectTrigger con renderizado manual del label seleccionado
  en lugar de depender de SelectValue, que no siempre encontraba el texto
  del SelectItem cuando el supervisor no estaba en la lista de carteras
2026-05-29 19:03:36 +00:00
Horux Dev
20fb8ea2db debug(usuarios): agregar console.log para diagnosticar supervisorNombre 2026-05-29 18:10:33 +00:00
Horux Dev
8c9a7b73dc fix(usuarios): agregar import faltante de prisma en getSupervisor 2026-05-29 17:43:13 +00:00
Horux Dev
910c50d870 fix(usuarios): mostrar nombre del supervisor al editar auxiliar
- Backend getSupervisor ahora devuelve supervisorNombre buscando en Prisma
- Frontend usa supervisorNombre para mostrar en Select cuando el supervisor
  no está en la lista de carteras/supervisores
2026-05-29 17:24:18 +00:00
Horux Dev
2f49fdc9b7 fix(contribuyentes): agregar supervisorNombre al tipo Contribuyente 2026-05-29 17:12:58 +00:00
Horux Dev
0439a84e6d feat(contribuyentes): mostrar nombre del supervisor en card 2026-05-29 16:57:04 +00:00
Horux Dev
0815269f1b fix(papeleria): mover useMemo antes del return condicional para evitar error #310 2026-05-29 16:42:37 +00:00
Horux Dev
9b535354fb feat(papeleria): aprobación independiente por cliente
- Agrega migración 050 con columnas de aprobación de cliente
  (requiere_aprobacion_cliente, estado_cliente, aprobado_por_cliente, etc.)
- Backend: endpoints /aprobar-cliente y /rechazar-cliente con validación de permisos
- Backend: list/download permiten acceso a clientes filtrando por entidades visibles
- Backend: notificación por email a clientes cuando se les solicita aprobación
- Frontend: checkbox independiente para solicitar aprobación del cliente
- Frontend: badge de estado combinado (owner + cliente)
- Frontend: botones de aprobar/rechazar para clientes en su propio flujo
2026-05-29 00:36:33 +00:00
Horux Dev
e01422e443 fix(facturacion): filtrar searchConceptos y searchRfcs por contribuyenteId
- searchConceptos: agrega AND c.contribuyente_id =  cuando se recibe contribuyenteId
- searchRfcs: restringe el catálogo global de rfcs a aquellos que aparecen en CFDIs del contribuyente (como emisor o receptor)
- Usa parametrización dinámica (3800099{params.length}) para evitar errores de índice
2026-05-28 21:44:32 +00:00
Horux Dev
2208cee87f fix(impuestos): desactivar JIT en queries con subplans correlacionados
- Agrega helper withJitOff en impuestos.service.ts
- Ejecuta getResumenIva, getIvaMensual y readResumenIvaFromCache con SET LOCAL jit = off
- Evita compilación JIT de ~17s en queries con costo estimado alto

feat(contribuyentes): auto-asignar a cartera del supervisor

- Al crear contribuyente con supervisorUserId, se agrega automáticamente
  a todas las carteras top-level del supervisor

feat(permisos): restricciones de UI por rol en contribuyentes

- Oculta botón Add-ons para roles distintos de owner/cfo
- Oculta botón Eliminar contribuyente para no-owner
- Oculta botón Agregar RFC para auxiliar/visor/cliente/contador

feat(cfdi): ver CFDI desde conceptos y forma de pago en Excel

- Agrega botón Ver CFDI en cada fila de la tabla de Conceptos
- Agrega columna Forma de Pago en export Excel de CFDIs
- Agrega columna Forma de Pago en export individual de CFDI

chore(migraciones): índices GIN para relaciones de activos

- 048: índices btree parciales para activos
- 049: índices GIN para cfdis_relacionados y uuid_relacionado
2026-05-28 02:38:30 +00:00
Horux Dev
138e223361 fix(subnav): ocultar pestaña Contribuyentes en Despacho para no-owner/cfo
- La página /despachos/contribuyentes solo permite owner/cfo/platform_staff.
- La pestaña en el subnav ahora solo se muestra a esos roles, evitando que
  supervisor, contador, visor y auxiliar vean un link que lleva a mensaje
  de 'solo disponible para owner'.
2026-05-26 00:20:51 +00:00
Horux Dev
441ec20059 fix(despacho-stats): contar obligaciones y tareas pendientes correctamente
- Obligaciones: las obligaciones activas sin registro en obligacion_periodos
  para el periodo actual ahora se cuentan como pendientes (antes daban 0)
- Tareas: se materializan los periodos antes de contar para que las tareas
  sin registro previo aparezcan como pendientes
- Usa CTEs separadas para obligaciones y tareas evitando producto cartesiano
2026-05-25 23:40:17 +00:00
Horux Dev
929aeec641 feat(declaraciones): supervisor puede crear declaraciones y extras
- Backend: agrega 'supervisor' a ROLES_UPLOAD en documentos.controller.ts
- Frontend: agrega 'supervisor' a ROLES_UPLOAD y ROLES_UPLOAD_EXTRA en
  documentos/page.tsx para habilitar botones de subir declaración,
  comprobante de pago, eliminar y subir PDFs extra
2026-05-25 21:49:01 +00:00
Horux Dev
4a885de520 feat(configuracion): supervisor puede ver regimenes y domicilio fiscal
- Extiende la condición de visibilidad de Regímenes Fiscales, Domicilio
  Fiscal y Bancos para incluir al rol supervisor
2026-05-25 19:25:07 +00:00
Horux Dev
c84ad6c4db feat(sidebar): Configuracion visible para auxiliar y cliente
- Sidebars/topnav: agrega 'auxiliar' y 'cliente' a la opción Configuracion
- /configuracion/page.tsx: auxiliar y cliente solo ven Información de Usuario,
  Información de Empresa y Seguridad (cambio de contraseña). Todo lo demás
  (FIEL, Obligaciones, Notificaciones, Facturación, CSD) queda restringido
  a owner/cfo/supervisor
2026-05-25 16:57:10 +00:00
Horux Dev
acd7de76d9 feat(sidebar): mostrar Configuracion a supervisor
- Extiende roles de la opción Configuracion en sidebar, sidebar-compact,
  sidebar-floating y topnav
2026-05-25 16:46:09 +00:00
Horux Dev
9c4a2343f5 feat(auth): supervisor puede configurar FIEL, CSD y Obligaciones
- Backend: agrega 'supervisor' a authorize() de rutas:
  - POST/DELETE /contribuyentes/:id/fiel
  - POST /contribuyentes/:id/facturapi/csd
  - POST/DELETE /contribuyentes/:id/obligaciones/*
- Frontend: muestra tarjeta 'Obligaciones Fiscales' en /configuracion
  para rol supervisor
2026-05-25 16:39:31 +00:00
Horux Dev
1d828adc27 feat(contribuyentes): mostrar contador de RFCs disponibles del plan
- Agrega contador 'X de Y RFCs' debajo del título de la página
- Usa DESPACHO_PLANS desde @horux/shared para obtener maxRfcs del plan actual
- Durante trial muestra 'X de 5 RFCs'
- Planes ilimitados muestran solo 'X RFCs'
2026-05-25 16:20:37 +00:00
Horux Dev
4c7ab4fd35 feat(sidebar): mostrar Contribuyentes a supervisor, contador y auxiliar
- Extiende roles de la opción Contribuyentes en sidebar, sidebar-compact,
  sidebar-floating y topnav
2026-05-25 15:52:46 +00:00
Horux Dev
0fa2c3c90f feat(contribuyentes): permitir a supervisor crear contribuyentes
- Agrega 'supervisor' al authorize() de POST /contribuyentes
2026-05-25 15:22:23 +00:00
Horux Dev
cbefaa2bf7 refactor(declaraciones): renombrar SUELDOS→ISN, agregar ISH
- Cambia la opción 'SUELDOS' por 'ISN' (Impuesto Sobre Nómina)
- Agrega nueva opción 'ISH' (Impuesto Sobre Hospedaje)
- ISH no cierra alertas ni obligaciones (aún no hay flujo definido)
- ISN mantiene keywords de sueldos/salarios/nómina + agrega 'isn'
- Migración 047: actualiza declaraciones históricas SUELDOS→ISN en BD
2026-05-25 02:35:16 +00:00
Horux Dev
e35eae2a72 refactor(cfdi): descarga masiva de XMLs por filtros en lugar de checkboxes
- Backend: POST /cfdi/download-xmls acepta CfdiFilters, usa getXmlsByFilters con LIMIT 1000
- Frontend: eliminados checkboxes y estado selectedIds; botón Descargar XMLs usa filtros activos
- Si >1000 resultados, muestra confirm() de advertencia pero permite proceder
- Agregada documentación técnica y changelog
2026-05-24 21:40:08 +00:00
Horux Dev
5c940847af feat(cfdi): descarga masiva de XMLs como ZIP, limite 1,000 2026-05-24 21:19:56 +00:00
59 changed files with 2260 additions and 513 deletions

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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); }
}

View File

@@ -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'));

View File

@@ -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],

View File

@@ -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),

View File

@@ -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); }

View File

@@ -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);
}
}
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View 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;

View 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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 };

View File

@@ -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'

View File

@@ -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({

View File

@@ -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;
}
/**

View File

@@ -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);
}

View File

@@ -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';

View File

@@ -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í');
}
/**

View File

@@ -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,
};
}

View File

@@ -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: [],
};
/**

View File

@@ -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],

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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',

View File

@@ -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[]) {

View File

@@ -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`;
}

View File

@@ -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);

View File

@@ -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,
};
/**

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 && (
<>

View File

@@ -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>

View File

@@ -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>
))}

View File

@@ -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);

View File

@@ -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>
) : (

View File

@@ -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;

View File

@@ -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'] },
];

View File

@@ -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>
);
}

View File

@@ -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[] = [

View File

@@ -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[] = [

View File

@@ -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[] = [

View File

@@ -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[] = [

View File

@@ -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);

View File

@@ -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());

View File

@@ -6,6 +6,7 @@ export interface Contribuyente {
nombre: string;
identificador: string;
supervisorUserId: string | null;
supervisorNombre: string | null;
active: boolean;
createdAt: string;
rfc: string;

View File

@@ -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 {

View File

@@ -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
View 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

View 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 2030 MB.
- El endpoint requiere autenticación y tenant middleware (como todo el módulo CFDI).

View File

@@ -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 {