Compare commits

...

36 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
53 changed files with 1801 additions and 512 deletions

View File

@@ -71,9 +71,9 @@ class TenantConnectionManager {
user: connectionOverride?.user ?? this.dbConfig.user, user: connectionOverride?.user ?? this.dbConfig.user,
password: connectionOverride?.password ?? this.dbConfig.password, password: connectionOverride?.password ?? this.dbConfig.password,
database: databaseName, database: databaseName,
max: 3, max: 10,
idleTimeoutMillis: 300_000, idleTimeoutMillis: 300_000,
connectionTimeoutMillis: 10_000, connectionTimeoutMillis: 30_000,
}; };
pool = new Pool(poolConfig); 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 { private cleanupIdlePools(): void {
const now = Date.now(); const now = Date.now();
const maxIdle = 5 * 60 * 1000; const maxIdle = 12 * 60 * 60 * 1000;
for (const [tenantId, entry] of this.pools.entries()) { for (const [tenantId, entry] of this.pools.entries()) {
if (now - entry.lastAccess.getTime() > maxIdle) { 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) { export async function getAlertasAutomaticas(req: Request, res: Response, next: NextFunction) {
try { try {
const contribuyenteId = req.query.contribuyenteId as string | undefined; 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); 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); res.json(alertas);
} catch (error) { } catch (error) {
next(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). * Valida que el auxiliar pertenezca al supervisor (o que el caller sea owner).
* Owner puede asignar a cualquier auxiliar del tenant. * 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( async function validarAuxiliarDelSupervisor(
pool: import('pg').Pool, pool: import('pg').Pool,
@@ -17,10 +19,22 @@ async function validarAuxiliarDelSupervisor(
if (callerRole === 'owner') return; if (callerRole === 'owner') return;
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT 1 FROM auxiliar_supervisores `SELECT 1 FROM (
WHERE auxiliar_user_id = $1 AND supervisor_user_id = $2 SELECT c.auxiliar_user_id
LIMIT 1`, FROM carteras c
[auxiliarUserId, supervisorUserId], 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) { 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 ── // ── Obligaciones ──
export async function asignarObligacion(req: Request, res: Response, next: NextFunction) { export async function asignarObligacion(req: Request, res: Response, next: NextFunction) {
try { try {
const contribuyenteId = String(req.params.id);
const obligacionId = String(req.params.obligacionId); const obligacionId = String(req.params.obligacionId);
const schema = z.object({ auxiliarUserId: z.string().uuid() }); const schema = z.object({ auxiliarUserId: z.string().uuid() });
const { auxiliarUserId } = schema.parse(req.body); const { auxiliarUserId } = schema.parse(req.body);
@@ -42,6 +76,11 @@ export async function asignarObligacion(req: Request, res: Response, next: NextF
auxiliarUserId, auxiliarUserId,
req.user!.role, req.user!.role,
); );
await validarAuxiliarEnSubcartera(
req.tenantPool!,
contribuyenteId,
auxiliarUserId,
);
await asignacionesService.asignarObligacion( await asignacionesService.asignarObligacion(
req.tenantPool!, req.tenantPool!,
@@ -80,6 +119,19 @@ export async function asignarTarea(req: Request, res: Response, next: NextFuncti
req.user!.role, 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( await asignacionesService.asignarTarea(
req.tenantPool!, req.tenantPool!,
tareaId, tareaId,
@@ -135,3 +187,11 @@ export async function listSinAsignar(req: Request, res: Response, next: NextFunc
res.json({ obligaciones, tareas }); res.json({ obligaciones, tareas });
} catch (err) { next(err); } } 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 type { Request, Response, NextFunction } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import * as contribuyenteService from '../services/contribuyente.service.js'; import * as contribuyenteService from '../services/contribuyente.service.js';
import * as carteraService from '../services/cartera.service.js';
import { AppError } from '../middlewares/error.middleware.js'; import { AppError } from '../middlewares/error.middleware.js';
import { getEntidadesVisibles } from '../utils/entidades-visibles.js'; import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
import { adjustDespachoOverage } from '../services/payment/addon.service.js'; import { adjustDespachoOverage } from '../services/payment/addon.service.js';
@@ -41,7 +42,24 @@ export async function list(req: Request, res: Response, next: NextFunction) {
try { try {
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role); const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, req.user!.tenantId); 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); } } 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); 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 // 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. // 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. // 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; const { userId } = req.body;
if (!userId || typeof userId !== 'string') return next(new AppError(400, 'userId requerido')); if (!userId || typeof userId !== 'string') return next(new AppError(400, 'userId requerido'));
const entidadId = String(req.params.id); 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( await req.tenantPool!.query(
'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', 'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[userId, entidadId], [userId, entidadId],

View File

@@ -71,7 +71,7 @@ export async function consultarManual(req: Request, res: Response, next: NextFun
// Declaraciones provisionales // Declaraciones provisionales
// ============================================================================ // ============================================================================
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar']; const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
function canUpload(req: Request): boolean { function canUpload(req: Request): boolean {
return ROLES_UPLOAD.includes(req.user!.role); return ROLES_UPLOAD.includes(req.user!.role);
@@ -82,7 +82,7 @@ const createDeclaracionSchema = z.object({
mes: z.number().int().min(1).max(12), mes: z.number().int().min(1).max(12),
tipo: z.enum(['normal', 'complementaria']), tipo: z.enum(['normal', 'complementaria']),
periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'semestral', 'anual']).optional(), 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(), montoPago: z.number().min(0).optional(),
pdfBase64: z.string().min(100), pdfBase64: z.string().min(100),
pdfFilename: z.string().min(1).max(255), 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[] = []; const params: any[] = [];
if (q.length >= 2) { if (q.length >= 2) {
params.push(`%${q}%`); 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(` 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') WHERE c.status NOT IN ('Cancelado', '0')
${whereType} ${whereType}
${whereSearch} ${whereSearch}
${whereContribuyente}
ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC
LIMIT 30 LIMIT 30
`, params); `, params);
@@ -708,7 +715,7 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
const q = (req.query.q as string || '').trim(); const q = (req.query.q as string || '').trim();
if (q.length < 3) return res.json([]); 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!; const pool = req.tenantPool!;
// RFC del tenant despacho para excluirlo (no se factura a sí mismo) // 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 || ''; const tenantRfc = tenant?.rfc || '';
// Búsqueda en el catálogo completo de RFCs. El contribuyente activo solo const params: any[] = [tenantRfc, `%${q}%`];
// filtra CFDIs relacionados / PPD, no el autocompleto de RFCs — de lo let whereContribuyente = '';
// contrario no se podría facturar a un cliente nuevo que nunca haya if (contribuyenteId) {
// aparecido en un CFDI previo. 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(` const { rows } = await pool.query(`
SELECT id, rfc, razon_social as "razonSocial", SELECT id, rfc, razon_social as "razonSocial",
regimen_fiscal as "regimenFiscal", regimen_fiscal as "regimenFiscal",
@@ -730,9 +744,10 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
FROM rfcs FROM rfcs
WHERE rfc != $1 WHERE rfc != $1
AND (rfc ILIKE $2 OR razon_social ILIKE $2) AND (rfc ILIKE $2 OR razon_social ILIKE $2)
${whereContribuyente}
ORDER BY razon_social ORDER BY razon_social
LIMIT 10 LIMIT 10
`, [tenantRfc, `%${q}%`]); `, params);
res.json(rows); res.json(rows);
} catch (error) { next(error); } } 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 * as papeleriaService from '../services/papeleria.service.js';
import { emailService } from '../services/email/email.service.js'; import { emailService } from '../services/email/email.service.js';
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js'; import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
import { prisma } from '../config/database.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 { function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId; return req.viewingTenantId || req.user!.tenantId;
} }
@@ -24,6 +19,7 @@ const uploadSchema = z.object({
anio: z.number().int().min(2000).max(2100), anio: z.number().int().min(2000).max(2100),
mes: z.number().int().min(1).max(12), mes: z.number().int().min(1).max(12),
requiereAprobacion: z.boolean(), requiereAprobacion: z.boolean(),
requiereAprobacionCliente: z.boolean(),
archivoBase64: z.string().min(1), archivoBase64: z.string().min(1),
archivoFilename: z.string().min(1).max(255), archivoFilename: z.string().min(1).max(255),
archivoMime: z.string().min(1).max(100), 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) { export async function upload(req: Request, res: Response, next: NextFunction) {
try { 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 data = uploadSchema.parse(req.body);
const archivo = Buffer.from(data.archivoBase64, 'base64'); const archivo = Buffer.from(data.archivoBase64, 'base64');
@@ -42,18 +40,23 @@ export async function upload(req: Request, res: Response, next: NextFunction) {
anio: data.anio, anio: data.anio,
mes: data.mes, mes: data.mes,
requiereAprobacion: data.requiereAprobacion, requiereAprobacion: data.requiereAprobacion,
requiereAprobacionCliente: data.requiereAprobacionCliente,
archivo, archivo,
archivoFilename: data.archivoFilename, archivoFilename: data.archivoFilename,
archivoMime: data.archivoMime, archivoMime: data.archivoMime,
subidoPor: req.user!.userId, subidoPor: req.user!.userId,
}); });
// Notificación a aprobadores si la papelería requiere aprobación.
if (item.requiereAprobacion) { if (item.requiereAprobacion) {
notifyAprobacionRequerida(req, item).catch(err => notifyAprobacionRequerida(req, item).catch(err =>
console.error('[papeleria.upload] notify aprobadores failed:', err?.message || 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); res.status(201).json(item);
} catch (error: any) { } catch (error: any) {
@@ -74,13 +77,20 @@ const listSchema = z.object({
export async function list(req: Request, res: Response, next: NextFunction) { export async function list(req: Request, res: Response, next: NextFunction) {
try { try {
rejectClienteRole(req);
const q = listSchema.parse(req.query); 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!, { const items = await papeleriaService.listPapeleria(req.tenantPool!, {
contribuyenteId: q.contribuyenteId, contribuyenteId: q.contribuyenteId,
anio: q.anio ? parseInt(q.anio, 10) : undefined, anio: q.anio ? parseInt(q.anio, 10) : undefined,
mes: q.mes ? parseInt(q.mes, 10) : undefined, mes: q.mes ? parseInt(q.mes, 10) : undefined,
estado: q.estado, estado: q.estado,
entidadIds,
userRole: req.user!.role,
}); });
res.json(items); res.json(items);
} catch (error) { } 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) { export async function download(req: Request, res: Response, next: NextFunction) {
try { try {
rejectClienteRole(req);
const id = parseInt(String(req.params.id), 10); const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido')); 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); const file = await papeleriaService.downloadArchivo(req.tenantPool!, id);
if (!file) return next(new AppError(404, 'Documento no encontrado')); if (!file) return next(new AppError(404, 'Documento no encontrado'));
res.setHeader('Content-Type', file.mime); 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) { export async function aprobar(req: Request, res: Response, next: NextFunction) {
try { 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); const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido')); if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const item = await papeleriaService.aprobar( 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) { export async function rechazar(req: Request, res: Response, next: NextFunction) {
try { 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); const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido')); if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const { comentario } = rechazarSchema.parse(req.body); 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) { export async function eliminar(req: Request, res: Response, next: NextFunction) {
try { 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); const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido')); if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const ok = await papeleriaService.eliminar(req.tenantPool!, id); const ok = await papeleriaService.eliminar(req.tenantPool!, id);
@@ -161,22 +242,26 @@ export async function eliminar(req: Request, res: Response, next: NextFunction)
// ─── Notificaciones ─── // ─── 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. * 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( async function notifyAprobacionRequerida(
req: Request, req: Request,
item: papeleriaService.PapeleriaItem, item: papeleriaService.PapeleriaItem,
): Promise<void> { ): Promise<void> {
const tenantId = effectiveTenantId(req); const tenantId = effectiveTenantId(req);
// Owners del despacho
const recipients = new Set<string>(await getTenantOwnerEmails(tenantId)); 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({ const supervisores = await prisma.tenantMembership.findMany({
where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } }, where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } },
include: { user: { select: { email: true, active: true } } }, 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); if (m.user.active && m.user.email) recipients.add(m.user.email);
} }
// No notificarse a sí mismo
recipients.delete(req.user!.email); recipients.delete(req.user!.email);
if (recipients.size === 0) return; if (recipients.size === 0) return;
const tenant = await prisma.tenant.findUnique({ const tenant = await prisma.tenant.findUnique({
where: { id: tenantId }, where: { id: tenantId },
select: { nombre: true }, select: { nombre: true },
}); });
const info = await getContribuyenteInfo(req, item.contribuyenteId);
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>( if (!info) return;
`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 link = `${env.FRONTEND_URL}/documentos`; const link = `${env.FRONTEND_URL}/documentos`;
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic']; 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) { for (const to of recipients) {
try { try {
await emailService.sendPapeleriaAprobacionRequerida(to, { await emailService.sendPapeleriaAprobacionRequerida(to, {
contribuyenteRfc: rows[0].rfc, contribuyenteRfc: info.rfc,
contribuyenteNombre: rows[0].nombre, contribuyenteNombre: info.nombre,
despachoNombre: tenant?.nombre, despachoNombre: tenant?.nombre,
nombreDocumento: item.nombre, nombreDocumento: item.nombre,
descripcion: item.descripcion, descripcion: item.descripcion,
@@ -226,9 +303,7 @@ async function notifyAprobacionRequerida(
} }
/** /**
* Notifica al uploader (auxiliar) cuando un documento que él subió fue * Notifica al uploader cuando un documento fue aprobado o rechazado por owner/supervisor.
* aprobado o rechazado. Solo manda si quien aprobó/rechazó NO es el mismo
* uploader (caso edge: owner sube su propia papelería).
*/ */
async function notifyDecisionAuxiliar( async function notifyDecisionAuxiliar(
req: Request, req: Request,
@@ -238,21 +313,16 @@ async function notifyDecisionAuxiliar(
const auxiliarEmail = await getUserEmailById(item.subidoPor); const auxiliarEmail = await getUserEmailById(item.subidoPor);
if (!auxiliarEmail) return; if (!auxiliarEmail) return;
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>( const info = await getContribuyenteInfo(req, item.contribuyenteId);
`SELECT c.rfc, eg.nombre FROM contribuyentes c if (!info) return;
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE c.entidad_id = $1`,
[item.contribuyenteId],
);
if (rows.length === 0) return;
const link = `${env.FRONTEND_URL}/documentos`; const link = `${env.FRONTEND_URL}/documentos`;
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic']; const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
const periodo = `${meses[item.mes - 1]} ${item.anio}`; const periodo = `${meses[item.mes - 1]} ${item.anio}`;
await emailService.sendPapeleriaDecision(auxiliarEmail, { await emailService.sendPapeleriaDecision(auxiliarEmail, {
contribuyenteRfc: rows[0].rfc, contribuyenteRfc: info.rfc,
contribuyenteNombre: rows[0].nombre, contribuyenteNombre: info.nombre,
nombreDocumento: item.nombre, nombreDocumento: item.nombre,
estado: item.estado as 'aprobado' | 'rechazado', estado: item.estado as 'aprobado' | 'rechazado',
revisor: req.user!.email, revisor: req.user!.email,
@@ -261,3 +331,57 @@ async function notifyDecisionAuxiliar(
link, 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 * as usuariosService from '../services/usuarios.service.js';
import { AppError } from '../middlewares/error.middleware.js'; import { AppError } from '../middlewares/error.middleware.js';
import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js'; import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js';
import { prisma } from '../config/database.js';
const inviteSchema = z.object({ const inviteSchema = z.object({
email: z.string().email('email inválido'), 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) { export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
try { try {
if (req.user!.role !== 'owner') { if (!['owner', 'cfo', 'supervisor'].includes(req.user!.role)) {
throw new AppError(403, 'Solo los dueños pueden invitar usuarios'); throw new AppError(403, 'No autorizado para invitar usuarios');
} }
const data = inviteSchema.parse(req.body); 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 // Validate: auxiliar requires a supervisor
if (data.role === 'auxiliar' && !data.supervisorUserId) { if (data.role === 'auxiliar' && !data.supervisorUserId) {
throw new AppError(400, 'Debes asignar un supervisor al auxiliar'); 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`, LIMIT 1`,
[userId], [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) { } catch (error) {
next(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 { emailService } from '../services/email/email.service.js';
import { getTenantOwnerEmail } from '../utils/memberships.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) { export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) {
try { try {
const { type, data } = req.body; 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 // precio de renewal. Se detecta comparando el monto cobrado contra lo que
// `getPlanPrice(phase='firstYear')` devolvería para este plan. // `getPlanPrice(phase='firstYear')` devolvería para este plan.
const esPrimerPago = subscription.status === 'pending'; 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({ await prisma.subscription.update({
where: { id: subscription.id }, where: { id: subscription.id },
data: { status: 'authorized' }, data: updateData,
}); });
subscriptionService.invalidateSubscriptionCache(tenantId); subscriptionService.invalidateSubscriptionCache(tenantId);

View File

@@ -9,8 +9,10 @@ import { resetExpiredMonthlyTimbres } from '../services/facturapi.service.js';
import { purgeDeclaracionesAntiguas } from '../services/declaraciones.service.js'; import { purgeDeclaracionesAntiguas } from '../services/declaraciones.service.js';
import { consultarConstancia, purgeConstanciasAntiguas } from '../services/constancia.service.js'; import { consultarConstancia, purgeConstanciasAntiguas } from '../services/constancia.service.js';
import { tenantDb } from '../config/database.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 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 CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM 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) 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 isRunning = false;
let isIncrementalRunning = 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 * Obtiene los tenants que tienen FIEL configurada y activa
@@ -27,13 +61,13 @@ let isIncrementalRunning = false;
async function getTenantsWithFiel(): Promise<string[]> { async function getTenantsWithFiel(): Promise<string[]> {
const tenants = await prisma.tenant.findMany({ const tenants = await prisma.tenant.findMany({
where: { active: true }, where: { active: true },
select: { id: true }, select: { id: true, databaseName: true },
}); });
const tenantsWithFiel: string[] = []; const tenantsWithFiel: string[] = [];
for (const tenant of tenants) { for (const tenant of tenants) {
const hasFiel = await hasFielConfigured(tenant.id); const hasFiel = await hasAnyFielConfigured(tenant.id, tenant.databaseName);
if (hasFiel) { if (hasFiel) {
tenantsWithFiel.push(tenant.id); tenantsWithFiel.push(tenant.id);
} }
@@ -172,12 +206,12 @@ async function getTenantsConSatIncremental(): Promise<string[]> {
const tenants = await prisma.tenant.findMany({ const tenants = await prisma.tenant.findMany({
where: { active: true, plan: { in: planNames as any } }, where: { active: true, plan: { in: planNames as any } },
select: { id: true }, select: { id: true, databaseName: true },
}); });
const result: string[] = []; const result: string[] = [];
for (const tenant of tenants) { for (const tenant of tenants) {
if (await hasFielConfigured(tenant.id)) { if (await hasAnyFielConfigured(tenant.id, tenant.databaseName)) {
result.push(tenant.id); result.push(tenant.id);
} }
} }
@@ -351,12 +385,153 @@ async function runCsfJob(): Promise<void> {
console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message); console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message);
failed++; 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}`); 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 scheduledTask: ReturnType<typeof cron.schedule> | null = null;
let retryTask: 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 opinionTask: ReturnType<typeof cron.schedule> | null = null;
let csfTask: ReturnType<typeof cron.schedule> | null = null; let csfTask: ReturnType<typeof cron.schedule> | null = null;
let incrementalTask: 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', 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 // Cron watchdog: cada 2h marca como `failed` los jobs que quedaron stale
// (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás). // (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás).
// Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS) // 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] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[SAT Cron] Retry programado cada hora`); 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(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[CSF Cron] Programado para: ${CSF_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)`); 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.stop();
retryTask = null; retryTask = null;
} }
if (recoveryTask) {
recoveryTask.stop();
recoveryTask = null;
}
if (opinionTask) { if (opinionTask) {
opinionTask.stop(); opinionTask.stop();
opinionTask = null; 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', authorize('owner', 'supervisor'), asignacionesCtrl.listPorSupervisor);
router.get('/asignaciones/mias', authorize('auxiliar'), asignacionesCtrl.listPorAuxiliar); router.get('/asignaciones/mias', authorize('auxiliar'), asignacionesCtrl.listPorAuxiliar);
router.get('/asignaciones/sin-asignar', authorize('owner', 'supervisor'), asignacionesCtrl.listSinAsignar); 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 // Read: owner + supervisor + auxiliar
router.get('/', authorize('owner', 'supervisor', 'auxiliar'), ctrl.list); router.get('/', authorize('owner', 'supervisor', 'auxiliar'), ctrl.list);

View File

@@ -14,7 +14,7 @@ router.use(tenantMiddleware);
// === Static routes FIRST (before /:id to avoid route conflict) === // === Static routes FIRST (before /:id to avoid route conflict) ===
router.get('/', ctrl.list); 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.post('/backfill', authorize('owner'), ctrl.backfill);
router.get('/catalogo-obligaciones', obligacionesCtrl.getCatalogo); 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); router.post('/:id/cliente-acceso', authorize('owner', 'supervisor'), ctrl.addClienteAcceso);
// FIEL per contribuyente // 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.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 // Facturapi per contribuyente
router.post('/:id/facturapi/org', authorize('owner', 'cfo'), configCtrl.createOrg); router.post('/:id/facturapi/org', authorize('owner', 'cfo'), configCtrl.createOrg);
router.get('/:id/facturapi/status', configCtrl.orgStatus); 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 // Personalización per contribuyente
router.get('/:id/facturapi/customization', facturacionCtrl.getCustomizationContribuyenteCtrl); 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 // Obligaciones fiscales per contribuyente
router.get('/:id/obligaciones/periodo', obligacionesCtrl.getObligacionesPorPeriodo); router.get('/:id/obligaciones/periodo', obligacionesCtrl.getObligacionesPorPeriodo);
router.get('/:id/obligaciones', obligacionesCtrl.getObligaciones); router.get('/:id/obligaciones', obligacionesCtrl.getObligaciones);
router.post('/:id/obligaciones/init', authorize('owner', 'cfo'), obligacionesCtrl.initRecomendaciones); router.post('/:id/obligaciones/init', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.initRecomendaciones);
router.post('/:id/obligaciones', authorize('owner', 'cfo'), obligacionesCtrl.addObligacion); router.post('/:id/obligaciones', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.addObligacion);
router.delete('/:id/obligaciones/:obligacionId', authorize('owner', 'cfo'), obligacionesCtrl.removeObligacion); router.delete('/:id/obligaciones/:obligacionId', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.removeObligacion);
router.post('/:id/obligaciones/:obligacionId/restore', authorize('owner', 'cfo'), obligacionesCtrl.restoreObligacion); 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/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/uncomplete', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompleteObligacion);
router.post('/:id/obligaciones/:obligacionId/complete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completePeriodo); 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.get('/:id/download', ctrl.download);
router.post('/:id/aprobar', ctrl.aprobar); router.post('/:id/aprobar', ctrl.aprobar);
router.post('/:id/rechazar', ctrl.rechazar); 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); router.delete('/:id', ctrl.eliminar);
export { router as papeleriaRoutes }; export { router as papeleriaRoutes };

View File

@@ -24,44 +24,62 @@
* el de activos aplica también pero algunos predicados son no-op funcional * el de activos aplica también pero algunos predicados son no-op funcional
* en subqueries que filtran por tipo_comprobante específico (Postgres los * en subqueries que filtran por tipo_comprobante específico (Postgres los
* optimiza away). * 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')"; 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 * Predicado SQL que detecta si el row actual (sin alias de tabla, asume
* `FROM cfdis`) referencia un activo directamente (I), indirectamente vía * `FROM cfdis`) referencia un activo directamente (I), indirectamente vía
* pago (P→I), o transitivamente vía relación (E→I, E→P→I). * 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 { function activosExclusionNoAlias(): string {
return ` return `
AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS}) AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS})
AND NOT (tipo_comprobante = 'P' AND EXISTS ( AND NOT (tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis i_act SELECT 1 FROM (${UUIDS_ACTIVOS}) ua
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado) WHERE ua.uuid = ANY(string_to_array(LOWER(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}
))
)
)) ))
AND NOT (tipo_comprobante = 'E' AND uuid IN (${UUIDS_E_DE_ACTIVOS}))
AND NOT (tipo_comprobante = 'I' AND EXISTS ( AND NOT (tipo_comprobante = 'I' AND EXISTS (
-- Anticipo: CFDI tipo I (puede no tener uso_cfdi de activo) que es -- 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 -- 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 ` return `
AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ${ACTIVOS_USOS}) AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ${ACTIVOS_USOS})
AND NOT (${alias}.tipo_comprobante = 'P' AND EXISTS ( AND NOT (${alias}.tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis i_act SELECT 1 FROM (${UUIDS_ACTIVOS}) ua
WHERE LOWER(i_act.uuid) = LOWER(${alias}.uuid_relacionado) WHERE ua.uuid = ANY(string_to_array(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}
))
)
)) ))
AND NOT (${alias}.tipo_comprobante = 'E' AND ${alias}.uuid IN (${UUIDS_E_DE_ACTIVOS}))
AND NOT (${alias}.tipo_comprobante = 'I' AND EXISTS ( AND NOT (${alias}.tipo_comprobante = 'I' AND EXISTS (
SELECT 1 FROM cfdis i07_act SELECT 1 FROM cfdis i07_act
WHERE i07_act.tipo_comprobante = 'I' WHERE i07_act.tipo_comprobante = 'I'

View File

@@ -66,11 +66,13 @@ export async function getClientesStats(range: ClientesStatsRange): Promise<Clien
paymentsCount: payments._count, paymentsCount: payments._count,
}; };
// 3) Clientes que NO renovaron: subs cuyo currentPeriodEnd cae en el rango // 3) Clientes que NO renovaron:
// y que están en status terminal (cancelled, trial_expired, paused) o sin // a) Subs cuyo currentPeriodEnd cae en el rango y están en status terminal
// payment posterior aprobado. Nota: un sub `authorized` con periodEnd // (cancelled, trial_expired, paused).
// pasado es un "se renovó automáticamente" — para detectar no-renovaciones // b) Tenants cuyo trialEndsAt ya pasó y NO tienen suscripción authorized
// miramos status efectivo + ausencia de payment en los siguientes 7 días. // (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({ const subsExpiradas = await prisma.subscription.findMany({
where: { where: {
currentPeriodEnd: { gte: range.from, lte: range.to }, 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 } }, tenant: { select: { id: true, nombre: true, rfc: true } },
}, },
}); });
const noRenovaciones = subsExpiradas.map(s => ({
const noRenovacionesMap = new Map<string, ClientesStats['noRenovaciones'][number]>();
for (const s of subsExpiradas) {
noRenovacionesMap.set(s.tenantId, {
tenantId: s.tenantId, tenantId: s.tenantId,
tenantNombre: s.tenant?.nombre ?? '', tenantNombre: s.tenant?.nombre ?? '',
rfc: s.tenant?.rfc ?? '', rfc: s.tenant?.rfc ?? '',
plan: String(s.plan), plan: String(s.plan),
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '', currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '',
statusActual: s.status, 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) // 4) Usuarios por cliente (memberships activos por tenant)
const memberships = await prisma.tenantMembership.findMany({ 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. * 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( export async function generarAlertasAutomaticas(
pool: Pool, pool: Pool,
tenantId: string, tenantId: string,
contribuyenteId?: string | null, contribuyenteId?: string | null,
): Promise<AlertaAuto[]> { ): Promise<AlertaAuto[]> {
const alertas = await Promise.all([ const generadores: { name: string; fn: () => Promise<AlertaAuto | null> }[] = [
alertaListaNegraPropia(pool, tenantId, contribuyenteId), { name: 'lista-negra-propia', fn: () => alertaListaNegraPropia(pool, tenantId, contribuyenteId) },
alertaClienteListaNegra(pool, contribuyenteId), { name: 'lista-negra-clientes', fn: () => alertaClienteListaNegra(pool, contribuyenteId) },
alertaProveedorListaNegra(pool, contribuyenteId), { name: 'lista-negra-proveedores', fn: () => alertaProveedorListaNegra(pool, contribuyenteId) },
alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId), { name: 'discrepancia-regimen', fn: () => alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId) },
alertaConcentracionClientes(pool, contribuyenteId), { name: 'concentracion-clientes', fn: () => alertaConcentracionClientes(pool, contribuyenteId) },
alertaConcentracionProveedores(pool, contribuyenteId), { name: 'concentracion-proveedores', fn: () => alertaConcentracionProveedores(pool, contribuyenteId) },
alertaRiesgoCambiario(pool, contribuyenteId), { name: 'riesgo-cambiario', fn: () => alertaRiesgoCambiario(pool, contribuyenteId) },
alertaRiesgoCancelaciones(pool, contribuyenteId), { name: 'riesgo-cancelaciones', fn: () => alertaRiesgoCancelaciones(pool, contribuyenteId) },
alertaRiesgoTransaccional(pool, contribuyenteId), { name: 'riesgo-transaccional', fn: () => alertaRiesgoTransaccional(pool, contribuyenteId) },
alertaCancelacionPeriodoAnterior(pool, contribuyenteId), { name: 'cancelacion-periodo-anterior', fn: () => alertaCancelacionPeriodoAnterior(pool, contribuyenteId) },
alertaOpinionCumplimiento(pool, contribuyenteId), { name: 'opinion-cumplimiento', fn: () => alertaOpinionCumplimiento(pool, contribuyenteId) },
alertaTipoRelacionSospechosa(pool, contribuyenteId), { name: 'tipo-relacion-sospechosa', fn: () => alertaTipoRelacionSospechosa(pool, contribuyenteId) },
alertaTareasProximasVencer(pool, contribuyenteId), { name: 'tareas-proximas-vencer', fn: () => alertaTareasProximasVencer(pool, contribuyenteId) },
alertaResicoPfLimiteIngresos(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[] }> { ): Promise<{ obligaciones: AsignacionObligacion[]; tareas: AsignacionTarea[] }> {
const isOwner = role === 'owner' || role === 'cfo' || role === 'contador'; 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 const whereObl = isOwner
? 'WHERE 1=1' ? '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 const whereTarea = isOwner
? 'WHERE 1=1' ? '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 params = isOwner ? [] : [supervisorUserId];
const { rows: obligaciones } = await pool.query<AsignacionObligacion>( const { rows: obligaciones } = await pool.query<AsignacionObligacion>(
@@ -301,3 +321,23 @@ export async function getAuxiliarAsignadoTarea(
const names = await resolveUserNames([auxId]); const names = await resolveUserNames([auxId]);
return { auxiliarUserId: auxId, auxiliarNombre: names.get(auxId) ?? null }; 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

@@ -44,6 +44,9 @@ function rowToConstancia(r: any): ConstanciaRow {
* sincroniza automáticamente domicilio + regímenes activos con lo que reporta * sincroniza automáticamente domicilio + regímenes activos con lo que reporta
* el SAT. El auto-fill NO es destructivo para datos custom del usuario: * 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. * 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> { export async function consultarConstancia(tenantId: string): Promise<ConstanciaRow> {
const fiel = await getDecryptedFiel(tenantId); const fiel = await getDecryptedFiel(tenantId);
@@ -55,6 +58,10 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
}); });
if (!tenant) throw new Error('Tenant no encontrado'); if (!tenant) throw new Error('Tenant no encontrado');
const MAX_RETRIES = 3;
const RETRY_DELAYS = [5_000, 15_000, 30_000]; // backoff
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
const tempId = randomUUID(); const tempId = randomUUID();
const tempDir = join(tmpdir(), `horux-csf-${tempId}`); const tempDir = join(tmpdir(), `horux-csf-${tempId}`);
mkdirSync(tempDir, { recursive: true, mode: 0o700 }); mkdirSync(tempDir, { recursive: true, mode: 0o700 });
@@ -65,9 +72,6 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 }); writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 }); writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
// 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 headless = process.env.SAT_HEADLESS !== 'false';
const browser = await chromium.launch({ const browser = await chromium.launch({
headless, headless,
@@ -76,7 +80,7 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
}); });
try { try {
const timeoutPromise = new Promise<never>((_, reject) => const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT), setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 5 minutos')), 300_000),
); );
const resultPromise = (async () => { const resultPromise = (async () => {
@@ -102,9 +106,6 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
], ],
); );
// 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 => { await sincronizarDatosFiscales(tenantId, csf).catch(err => {
console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err); console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err);
}); });
@@ -116,11 +117,19 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
} finally { } finally {
await browser.close(); 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 { } finally {
try { unlinkSync(cerPath); } catch { /* ok */ } try { unlinkSync(cerPath); } catch { /* ok */ }
try { unlinkSync(keyPath); } catch { /* ok */ } try { unlinkSync(keyPath); } catch { /* ok */ }
try { rmdirSync(tempDir); } 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 ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId);
const esEmisor = ctx.esEmisor; const esEmisor = ctx.esEmisor;
const esReceptor = ctx.esReceptor; const esReceptor = ctx.esReceptor;
const ingresosData = await calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId); const [
const egresosData = await calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId); ingresosData,
const adquisicionData = await calcularAdquisicionesMercancias(pool, tenantId, fechaInicio, fechaFin, conciliacion, contribuyenteId); egresosData,
const ivaData = await calcularIvaBalancePorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId); 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 // IVA a favor año actual: desde enero del año en curso
const ivaAFavorAcumulado = await calcularIvaAFavorAcumulado(pool, tenantId, fechaFin, undefined, conciliacion, contribuyenteId); const ivaAFavorAcumulado = await calcularIvaAFavorAcumulado(pool, tenantId, fechaFin, undefined, conciliacion, contribuyenteId);
@@ -1163,6 +1174,10 @@ export async function getKpis(
cfdisEmitidosPorRegimen: emitidosPorRegimen, cfdisEmitidosPorRegimen: emitidosPorRegimen,
cfdisRecibidos: recibidosPorRegimen.reduce((s: number, r: any) => s + r.total, 0), cfdisRecibidos: recibidosPorRegimen.reduce((s: number, r: any) => s + r.total, 0),
cfdisRecibidosPorRegimen: recibidosPorRegimen, 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'] }, IVA: { include: ['iva'], exclude: ['diot', 'proveedores de iva', 'informativa'] },
ISR: { include: ['isr'], exclude: ['retenciones', 'asimilados a salarios'] }, ISR: { include: ['isr'], exclude: ['retenciones', 'asimilados a salarios'] },
IEPS: { include: ['ieps'], exclude: [] }, 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: [] }, DIOT: { include: ['diot', 'proveedores de iva'], exclude: [] },
OTRO: { include: [], exclude: [] }, OTRO: { include: [], exclude: [] },
}; };
@@ -93,7 +94,7 @@ async function completarObligacionesPorDeclaracion(
* adicional, no reemplaza. * 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'; export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'semestral' | 'anual';
@@ -123,17 +124,19 @@ const IMPUESTO_A_PREFIJO_DECL: Record<string, string[]> = {
IVA: ['decl-iva'], IVA: ['decl-iva'],
ISR: ['decl-isr'], ISR: ['decl-isr'],
IEPS: ['decl-ieps'], IEPS: ['decl-ieps'],
SUELDOS: ['decl-sueldos'], ISN: ['decl-isn'],
DIOT: ['diot'], DIOT: ['diot'],
OTRO: [], OTRO: [],
ISH: [],
}; };
const IMPUESTO_A_PREFIJO_PAGO: Record<string, string[]> = { const IMPUESTO_A_PREFIJO_PAGO: Record<string, string[]> = {
IVA: ['pago-iva'], IVA: ['pago-iva'],
ISR: ['pago-isr'], ISR: ['pago-isr'],
IEPS: ['pago-ieps'], 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: [], DIOT: [],
OTRO: [], OTRO: [],
ISH: [],
}; };
/** /**

View File

@@ -1,5 +1,6 @@
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import { prisma } from '../config/database.js'; import { prisma } from '../config/database.js';
import { materializarPeriodos } from './tareas.service.js';
export interface ContribuyentesStats { export interface ContribuyentesStats {
totalContribuyentes: number; totalContribuyentes: number;
@@ -210,26 +211,58 @@ export async function getMisAsignados(
const inicioMes = `${_año}-${String(_mes).padStart(2, '0')}-01`; const inicioMes = `${_año}-${String(_mes).padStart(2, '0')}-01`;
const finMes = new Date(_año, _mes, 0).toISOString().split('T')[0]; 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( const { rows: stats } = await pool.query(
`WITH obl AS ( `WITH obligaciones_activas AS (
SELECT oc.contribuyente_id, SELECT id, contribuyente_id FROM obligaciones_contribuyente
COUNT(*) FILTER (WHERE op.completada = false AND op.periodo = $1)::int AS pendientes, WHERE contribuyente_id = ANY($4::uuid[]) AND activa = true
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 op_actual AS (
FROM obligaciones_contribuyente oc SELECT obligacion_id, completada FROM obligacion_periodos
LEFT JOIN obligacion_periodos op ON op.obligacion_id = oc.id WHERE obligacion_id IN (SELECT id FROM obligaciones_activas) AND periodo = $1
WHERE oc.contribuyente_id = ANY($4::uuid[]) AND oc.activa = true ),
GROUP BY oc.contribuyente_id 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 ( tar AS (
SELECT tc.contribuyente_id, SELECT ta.contribuyente_id,
COUNT(*) FILTER (WHERE tp.completada = false AND tp.fecha_limite BETWEEN $2::date AND $3::date)::int AS pendientes, COUNT(*) FILTER (WHERE tar_a.completada IS NULL OR tar_a.completada = false)::int AS pendientes,
COUNT(*) FILTER (WHERE tp.completada = false AND tp.fecha_limite < $2::date)::int AS atrasadas, COALESCE(SUM(tar_atr.atrasadas), 0)::int AS atrasadas,
COUNT(*) FILTER (WHERE tp.completada = true AND tp.fecha_limite BETWEEN $2::date AND $3::date)::int AS completadas COUNT(*) FILTER (WHERE tar_a.completada = true)::int AS completadas
FROM tareas_catalogo tc FROM tareas_activas ta
LEFT JOIN tarea_periodos tp ON tp.tarea_id = tc.id LEFT JOIN tar_actual tar_a ON tar_a.tarea_id = ta.id
WHERE tc.contribuyente_id = ANY($4::uuid[]) AND tc.active = true LEFT JOIN tar_atrasadas tar_atr ON tar_atr.tarea_id = ta.id
GROUP BY tc.contribuyente_id GROUP BY ta.contribuyente_id
) )
SELECT SELECT
obl.contribuyente_id AS obl_id, obl.pendientes AS obl_pen, obl.atrasadas AS obl_atr, obl.completadas AS obl_com, obl.contribuyente_id AS obl_id, obl.pendientes AS obl_pen, obl.atrasadas AS obl_atr, obl.completadas AS obl_com,

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 * Cron 8:30 AM — alertas fiscales nuevas activadas hoy. Envía un solo
* correo por destinatario con el batch completo. Caller debe deduplicar * 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); 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 type { IvaMensual, IsrMensual, ResumenIva, IvaRegimenDetalle, ResumenIsr } from '@horux/shared';
import { getRegimenesIgnoradosClaves } from './regimen.service.js'; import { getRegimenesIgnoradosClaves } from './regimen.service.js';
import { import {
@@ -106,32 +106,40 @@ const SUM_E_REFERENCING_TRAS = (
esLadoE: string, esLadoE: string,
considerarActivos: boolean, considerarActivos: boolean,
considerarNCs: boolean, considerarNCs: boolean,
) => `COALESCE(( ) => {
if (!considerarNCs) return '0';
return `COALESCE((
SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')}) SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')})
FROM cfdis e FROM cfdis e
WHERE e.tipo_comprobante = 'E' WHERE e.tipo_comprobante = 'E'
AND e.metodo_pago = 'PUE' AND e.metodo_pago = 'PUE'
AND e.status NOT IN ('Cancelado', '0') AND e.status NOT IN ('Cancelado', '0')
AND ${esLadoE} AND ${esLadoE}
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) 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')) 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)} = date_trunc('month', COALESCE(cfdis.fecha_efectiva, cfdis.fecha_emision - interval '1 hour'))${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
), 0)`; ), 0)`;
};
const SUM_E_REFERENCING_RET = ( const SUM_E_REFERENCING_RET = (
esLadoE: string, esLadoE: string,
considerarActivos: boolean, considerarActivos: boolean,
considerarNCs: boolean, considerarNCs: boolean,
) => `COALESCE(( ) => {
if (!considerarNCs) return '0';
return `COALESCE((
SELECT SUM(${IVA_RET_EXPR_ALIAS('e')}) SELECT SUM(${IVA_RET_EXPR_ALIAS('e')})
FROM cfdis e FROM cfdis e
WHERE e.tipo_comprobante = 'E' WHERE e.tipo_comprobante = 'E'
AND e.metodo_pago = 'PUE' AND e.metodo_pago = 'PUE'
AND e.status NOT IN ('Cancelado', '0') AND e.status NOT IN ('Cancelado', '0')
AND ${esLadoE} AND ${esLadoE}
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) 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')) 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)} = date_trunc('month', COALESCE(cfdis.fecha_efectiva, cfdis.fecha_emision - interval '1 hour'))${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
), 0)`; ), 0)`;
};
// Régimen del contribuyente según su lado: emisor/receptor del CFDI. // Régimen del contribuyente según su lado: emisor/receptor del CFDI.
// Usa el RFC del contribuyente (via `ctx.esEmisor`/`ctx.esReceptor`) para // Usa el RFC del contribuyente (via `ctx.esEmisor`/`ctx.esReceptor`) para
// determinar el lado, no el `type` de BD. // determinar el lado, no el `type` de BD.
@@ -152,16 +160,20 @@ const HAS_E_REFERENCING_MISMO_MES = (
esLadoE: string, esLadoE: string,
considerarActivos: boolean, considerarActivos: boolean,
considerarNCs: boolean, considerarNCs: boolean,
) => `EXISTS ( ) => {
if (!considerarNCs) return 'FALSE';
return `EXISTS (
SELECT 1 FROM cfdis e SELECT 1 FROM cfdis e
WHERE e.tipo_comprobante = 'E' WHERE e.tipo_comprobante = 'E'
AND e.metodo_pago = 'PUE' AND e.metodo_pago = 'PUE'
AND e.status NOT IN ('Cancelado', '0') AND e.status NOT IN ('Cancelado', '0')
AND ${esLadoE} AND ${esLadoE}
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) 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) AND date_trunc('month', e.fecha_emision)
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)} = date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
)`; )`;
};
// Atribución por lado usando RFC en lugar de `type`. Los buckets son // Atribución por lado usando RFC en lugar de `type`. Los buckets son
// factories que reciben el context del contribuyente: // factories que reciben el context del contribuyente:
@@ -397,8 +409,8 @@ export async function getIvaMensual(
const añoEnd = `${año}-12-31`; const añoEnd = `${año}-12-31`;
const extra = buildExtraFilters(considerarActivos, considerarNCs); const extra = buildExtraFilters(considerarActivos, considerarNCs);
const [{ rows: causadoRows }, { rows: acreditableRows }] = await Promise.all([ const { rows: causadoRows } = await withJitOff(pool, (client) =>
pool.query<{ mes: number; trasladado: string; retencion: string }>(` client.query<{ mes: number; trasladado: string; retencion: string }>(`
SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes, SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes,
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado, COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
@@ -407,8 +419,10 @@ export async function getIvaMensual(
AND ${VIGENTE} AND ${FR}${extra} AND ${VIGENTE} AND ${FR}${extra}
AND (${REGIMEN_TENANT}) = ANY($3) AND (${REGIMEN_TENANT}) = ANY($3)
GROUP BY mes GROUP BY mes
`, [añoStart, añoEnd, TODOS_REGIMENES]), `, [añoStart, añoEnd, TODOS_REGIMENES])
pool.query<{ mes: number; trasladado: string; retencion: string }>(` );
const { rows: acreditableRows } = await withJitOff(pool, (client) =>
client.query<{ mes: number; trasladado: string; retencion: string }>(`
SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes, SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes,
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado, COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
@@ -417,8 +431,8 @@ export async function getIvaMensual(
AND ${VIGENTE} AND ${FR}${extra} AND ${VIGENTE} AND ${FR}${extra}
AND (${REGIMEN_TENANT}) = ANY($3) AND (${REGIMEN_TENANT}) = ANY($3)
GROUP BY mes GROUP BY mes
`, [añoStart, añoEnd, TODOS_REGIMENES]), `, [añoStart, añoEnd, TODOS_REGIMENES])
]); );
perMes = new Map(); perMes = new Map();
for (const row of causadoRows) { for (const row of causadoRows) {
@@ -648,7 +662,8 @@ async function readResumenIvaFromCache(
const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear(); const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO; const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
const REGIMEN_TENANT = regimenTenantExpr(ctx); const REGIMEN_TENANT = regimenTenantExpr(ctx);
const acumRow = (await pool.query(` const acumRow = (await withJitOff(pool, (client) =>
client.query(`
SELECT SELECT
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) - COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) - COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
@@ -661,7 +676,8 @@ async function readResumenIvaFromCache(
AND (${REGIMEN_TENANT}) = ANY($3) AND (${REGIMEN_TENANT}) = ANY($3)
AND ${acumFR} AND ${acumFR}
AND (${ctx.esEmisor} OR ${ctx.esReceptor}) AND (${ctx.esEmisor} OR ${ctx.esReceptor})
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])).rows[0]; `, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])
)).rows[0];
// Cache hit retorna 0/empty para los surface IVA No Acreditable. El cache // 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 // 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. * 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( export async function getResumenIva(
pool: Pool, pool: Pool,
fechaInicio: string, fechaInicio: string,
@@ -725,10 +764,10 @@ export async function getResumenIva(
if (cached) return cached; if (cached) return cached;
} }
// Una query por lado (causado / acreditable). Filtro por RFC via // Queries con JIT off: evitan compilación JIT de >15s en queries con muchos
// ctx.esEmisor/esReceptor (embedded en buckets/signed exprs). // subplans correlacionados (activado por costo estimado >100k).
const [{ rows: causadoRows }, { rows: acreditableRows }] = await Promise.all([ const { rows: causadoRows } = await withJitOff(pool, (client) =>
pool.query<{ regimen: string | null; trasladado: string; retencion: string }>(` client.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
SELECT ${REGIMEN_TENANT} as regimen, SELECT ${REGIMEN_TENANT} as regimen,
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado, COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
@@ -737,8 +776,10 @@ export async function getResumenIva(
AND ${VIGENTE} AND ${FR}${extra} AND ${VIGENTE} AND ${FR}${extra}
AND (${REGIMEN_TENANT}) = ANY($3) AND (${REGIMEN_TENANT}) = ANY($3)
GROUP BY ${REGIMEN_TENANT} GROUP BY ${REGIMEN_TENANT}
`, [fechaInicio, fechaFin, TODOS_REGIMENES]), `, [fechaInicio, fechaFin, TODOS_REGIMENES])
pool.query<{ regimen: string | null; trasladado: string; retencion: string }>(` );
const { rows: acreditableRows } = await withJitOff(pool, (client) =>
client.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
SELECT ${REGIMEN_TENANT} as regimen, SELECT ${REGIMEN_TENANT} as regimen,
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado, COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
@@ -747,8 +788,8 @@ export async function getResumenIva(
AND ${VIGENTE} AND ${FR}${extra} AND ${VIGENTE} AND ${FR}${extra}
AND (${REGIMEN_TENANT}) = ANY($3) AND (${REGIMEN_TENANT}) = ANY($3)
GROUP BY ${REGIMEN_TENANT} 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. // 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 }; type Acc = { trasCausado: number; retCausado: number; trasAcreditable: number; retAcreditable: number };
@@ -799,7 +840,8 @@ export async function getResumenIva(
// Acumulado anual (misma fórmula T A R, pero rango = enero → fechaFin). // Acumulado anual (misma fórmula T A R, pero rango = enero → fechaFin).
const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear(); const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO; const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
const { rows: [acumRow] } = await pool.query(` const { rows: [acumRow] } = await withJitOff(pool, (client) =>
client.query(`
SELECT SELECT
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) - COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) - COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
@@ -812,7 +854,8 @@ export async function getResumenIva(
AND (${REGIMEN_TENANT}) = ANY($3) AND (${REGIMEN_TENANT}) = ANY($3)
AND ${acumFR}${extra} AND ${acumFR}${extra}
AND (${ctx.esEmisor} OR ${ctx.esReceptor}) AND (${ctx.esEmisor} OR ${ctx.esReceptor})
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES]); `, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])
);
// IVA No Acreditable surface (Art. 5 LIVA fracción I + Art. 27 fracción III LISR). // 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 // No participa en `resultado` — ya excluido del `acreditable` arriba via filtro

View File

@@ -27,6 +27,11 @@ export interface PapeleriaItem {
aprobadoPor: string | null; aprobadoPor: string | null;
aprobadoAt: Date | null; aprobadoAt: Date | null;
comentarioRechazo: string | null; comentarioRechazo: string | null;
requiereAprobacionCliente: boolean;
estadoCliente: EstadoPapeleria | null;
aprobadoPorCliente: string | null;
aprobadoAtCliente: Date | null;
comentarioRechazoCliente: string | null;
subidoPor: string; subidoPor: string;
createdAt: Date; createdAt: Date;
} }
@@ -36,6 +41,7 @@ const SELECT = `
archivo_filename, archivo_mime, archivo_size, archivo_filename, archivo_mime, archivo_size,
anio, mes, anio, mes,
requiere_aprobacion, estado, aprobado_por, aprobado_at, comentario_rechazo, 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 subido_por, created_at
`; `;
@@ -54,6 +60,11 @@ const ROW = (r: any): PapeleriaItem => ({
aprobadoPor: r.aprobado_por, aprobadoPor: r.aprobado_por,
aprobadoAt: r.aprobado_at, aprobadoAt: r.aprobado_at,
comentarioRechazo: r.comentario_rechazo, 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, subidoPor: r.subido_por,
createdAt: r.created_at, createdAt: r.created_at,
}); });
@@ -69,6 +80,7 @@ export interface UploadInput {
anio: number; anio: number;
mes: number; mes: number;
requiereAprobacion: boolean; requiereAprobacion: boolean;
requiereAprobacionCliente: boolean;
archivo: Buffer; archivo: Buffer;
archivoFilename: string; archivoFilename: string;
archivoMime: string; archivoMime: string;
@@ -87,12 +99,13 @@ export async function uploadPapeleria(
} }
const estadoInicial = input.requiereAprobacion ? 'pendiente' : null; const estadoInicial = input.requiereAprobacion ? 'pendiente' : null;
const estadoClienteInicial = input.requiereAprobacionCliente ? 'pendiente' : null;
const { rows: [r] } = await pool.query( const { rows: [r] } = await pool.query(
`INSERT INTO papeleria_trabajo `INSERT INTO papeleria_trabajo
(contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size, (contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size,
anio, mes, requiere_aprobacion, estado, subido_por) 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING ${SELECT}`, RETURNING ${SELECT}`,
[ [
sanitizeUuid(input.contribuyenteId), sanitizeUuid(input.contribuyenteId),
@@ -106,6 +119,8 @@ export async function uploadPapeleria(
input.mes, input.mes,
input.requiereAprobacion, input.requiereAprobacion,
estadoInicial, estadoInicial,
input.requiereAprobacionCliente,
estadoClienteInicial,
input.subidoPor, input.subidoPor,
], ],
); );
@@ -117,6 +132,8 @@ export interface ListFilters {
anio?: number; anio?: number;
mes?: number; mes?: number;
estado?: EstadoPapeleria | 'sin_aprobacion'; estado?: EstadoPapeleria | 'sin_aprobacion';
entidadIds?: string[];
userRole?: string;
} }
export async function listPapeleria(pool: Pool, f: ListFilters): Promise<PapeleriaItem[]> { 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.anio) { conds.push(`anio = $${i++}`); vals.push(f.anio); }
if (f.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); } if (f.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); }
if (f.estado === 'sin_aprobacion') { if (f.estado === 'sin_aprobacion') {
conds.push('requiere_aprobacion = false'); conds.push('requiere_aprobacion = false AND requiere_aprobacion_cliente = false');
} else if (f.estado) { } else if (f.estado) {
conds.push(`estado = $${i++}`); vals.push(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( const { rows } = await pool.query(
`SELECT ${SELECT} FROM papeleria_trabajo `SELECT ${SELECT} FROM papeleria_trabajo
WHERE ${conds.join(' AND ')} WHERE ${conds.join(' AND ')}
@@ -202,6 +226,39 @@ export async function rechazar(
return r ? ROW(r) : null; 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> { export async function eliminar(pool: Pool, id: number): Promise<boolean> {
const { rowCount } = await pool.query( const { rowCount } = await pool.query(
`DELETE FROM papeleria_trabajo WHERE id = $1`, `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; 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 }, 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({ auditLog({
tenantId: payment.tenantId, tenantId: payment.tenantId,
action: 'invoice.emitted_auto', 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. * Resuelve las claves de regímenes activos para la alerta de discrepancia.
* Si hay contribuyenteId, lee de contribuyentes.regimen_fiscal (comma-separated). * 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( export async function getRegimenesActivosClavesEfectivos(
tenantId: string, tenantId: string,
@@ -61,9 +64,49 @@ export async function getRegimenesActivosClavesEfectivos(
if (rows.length > 0 && rows[0].regimen_fiscal) { if (rows.length > 0 && rows[0].regimen_fiscal) {
return rows[0].regimen_fiscal.split(',').map((c: string) => c.trim()).filter(Boolean); 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 getRegimenesActivosClaves(tenantId); }
}
return Array.from(set);
}
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[]) { export async function setRegimenesActivos(tenantId: string, regimenIds: number[]) {

View File

@@ -72,9 +72,17 @@ export async function querySat(
requestType: 'metadata' | 'cfdi' = 'cfdi' requestType: 'metadata' | 'cfdi' = 'cfdi'
): Promise<QueryResult> { ): Promise<QueryResult> {
try { 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( const period = DateTimePeriod.createFromValues(
formatDateForSat(fechaInicio), formatDateForSat(fechaInicio),
formatDateForSat(fechaFin) formatDateForSat(adjustedFechaFin)
); );
const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received'); 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 { function formatDateForSat(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0'); const pad = (n: number) => n.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 00:00:00`;
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
} }

View File

@@ -30,20 +30,20 @@ export async function loginSatCsf(
const publicPage = await context.newPage(); const publicPage = await context.newPage();
publicPage.setDefaultTimeout(60_000); publicPage.setDefaultTimeout(60_000);
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle' }); await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle', timeout: 120_000 });
await publicPage.waitForTimeout(2000); await publicPage.waitForTimeout(3000);
// Click acordeón "Obtén tu constancia" / "Obtener constancia" // Click acordeón "Obtén tu constancia" / "Obtener constancia"
const obtenerLocator = publicPage.locator( 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', '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(); ).first();
await obtenerLocator.waitFor({ state: 'visible', timeout: 60_000 }); await obtenerLocator.waitFor({ state: 'visible', timeout: 120_000 });
await obtenerLocator.scrollIntoViewIfNeeded(); await obtenerLocator.scrollIntoViewIfNeeded();
await obtenerLocator.click(); await obtenerLocator.click();
await publicPage.waitForTimeout(1500); await publicPage.waitForTimeout(1500);
// Click "SERVICIO" → popup // 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(); await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click();
const loginPage = await popupPromise; const loginPage = await popupPromise;
await loginPage.waitForLoadState('domcontentloaded'); await loginPage.waitForLoadState('domcontentloaded');
@@ -56,7 +56,7 @@ export async function loginSatCsf(
const efirmaBtn = loginPage 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]') .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(); .first();
await efirmaBtn.waitFor({ state: 'visible', timeout: 30_000 }); await efirmaBtn.waitFor({ state: 'visible', timeout: 60_000 });
await efirmaBtn.scrollIntoViewIfNeeded(); await efirmaBtn.scrollIntoViewIfNeeded();
await efirmaBtn.click(); await efirmaBtn.click();
@@ -82,7 +82,7 @@ export async function loginSatCsf(
return rfc !== null && rfc.value.length >= 12; return rfc !== null && rfc.value.length >= 12;
}, },
null, null,
{ timeout: 60_000 }, { timeout: 120_000 },
); );
rfcPopulated = true; rfcPopulated = true;
} catch { } catch {
@@ -121,7 +121,7 @@ export async function loginSatCsf(
// Esperar a que salga del dominio de login y aterrice en el portal SAT // Esperar a que salga del dominio de login y aterrice en el portal SAT
await loginPage.waitForURL( await loginPage.waitForURL(
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'), url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
{ timeout: 60_000 }, { timeout: 120_000 },
); );
await loginPage.waitForLoadState('networkidle').catch(() => undefined); await loginPage.waitForLoadState('networkidle').catch(() => undefined);
await loginPage.waitForTimeout(2000); await loginPage.waitForTimeout(2000);

View File

@@ -14,10 +14,10 @@ export interface SweepResult {
} }
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = { const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
initial: 8, initial: 24,
daily: 4, daily: 4,
incremental: 2, incremental: 2,
custom: 4, custom: 24,
}; };
/** /**

View File

@@ -14,6 +14,7 @@ import {
useDesasignarObligacion, useDesasignarObligacion,
useAsignarTarea, useAsignarTarea,
useDesasignarTarea, useDesasignarTarea,
useAuxiliaresElegibles,
} from '@/lib/hooks/use-asignaciones'; } from '@/lib/hooks/use-asignaciones';
import { useUsuarios } from '@/lib/hooks/use-usuarios'; import { useUsuarios } from '@/lib/hooks/use-usuarios';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
@@ -36,6 +37,11 @@ export default function SeguimientoAuxiliares() {
const auxiliares = (usuarios ?? []).filter((u: any) => u.role === 'auxiliar'); 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) => { const openAssignModal = (type: 'obligacion' | 'tarea', item: any) => {
setModalType(type); setModalType(type);
setModalItem(item); setModalItem(item);
@@ -169,20 +175,28 @@ export default function SeguimientoAuxiliares() {
<p className="text-xs text-muted-foreground mb-4"> <p className="text-xs text-muted-foreground mb-4">
Contribuyente: {modalItem?.contribuyenteRazonSocial} ({modalItem?.contribuyenteRfc}) Contribuyente: {modalItem?.contribuyenteRazonSocial} ({modalItem?.contribuyenteRfc})
</p> </p>
{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}> <Select value={selectedAuxiliar} onValueChange={setSelectedAuxiliar}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Selecciona un auxiliar" /> <SelectValue placeholder="Selecciona un auxiliar" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{auxiliares.map((a: any) => ( {auxiliaresFiltrados.map((a: any) => (
<SelectItem key={a.id} value={a.id}>{a.nombre} ({a.email})</SelectItem> <SelectItem key={a.id} value={a.id}>{a.nombre} ({a.email})</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button> <Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button onClick={handleAssign} disabled={!selectedAuxiliar}> <Button onClick={handleAssign} disabled={!selectedAuxiliar || !puedeAsignar}>
{modalItem?.auxiliarUserId ? 'Reasignar' : 'Asignar'} {modalItem?.auxiliarUserId ? 'Reasignar' : 'Asignar'}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -425,6 +425,7 @@ export default function CfdiPage() {
'Fecha Emisión': formatCfdiDate(cfdi.fechaEmision), 'Fecha Emisión': formatCfdiDate(cfdi.fechaEmision),
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante), 'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
'Uso CFDI': (cfdi as any).usoCfdi || '', 'Uso CFDI': (cfdi as any).usoCfdi || '',
'Forma de Pago': cfdi.formaPago || '',
'Serie': cfdi.serie || '', 'Serie': cfdi.serie || '',
'Folio': cfdi.folio || '', 'Folio': cfdi.folio || '',
'RFC Emisor': cfdi.rfcEmisor, 'RFC Emisor': cfdi.rfcEmisor,
@@ -541,6 +542,7 @@ export default function CfdiPage() {
'Fecha Emisión': formatCfdiDate(cfdi.fechaEmision), 'Fecha Emisión': formatCfdiDate(cfdi.fechaEmision),
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante), 'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
'Uso CFDI': (cfdi as any).usoCfdi || '', 'Uso CFDI': (cfdi as any).usoCfdi || '',
'Forma de Pago': cfdi.formaPago || '',
'Serie': cfdi.serie || '', 'Serie': cfdi.serie || '',
'Folio': cfdi.folio || '', 'Folio': cfdi.folio || '',
'RFC Emisor': cfdi.rfcEmisor, 'RFC Emisor': cfdi.rfcEmisor,
@@ -1699,6 +1701,7 @@ export default function CfdiPage() {
)} )}
</button> </button>
</th> </th>
<th className="pb-3 font-medium"></th>
</tr> </tr>
</thead> </thead>
<tbody className="text-sm text-center"> <tbody className="text-sm text-center">
@@ -1715,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-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">${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 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> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -379,6 +379,7 @@ export default function ConfiguracionPage() {
const empresaNombre = viewingTenantName || user?.tenantName; const empresaNombre = viewingTenantName || user?.tenantName;
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles); const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const isDespacho = isDespachoTenant(user?.tenantRfc); const isDespacho = isDespachoTenant(user?.tenantRfc);
const showFullConfig = ['owner', 'cfo', 'supervisor'].includes(user?.role || '');
return ( return (
<> <>
@@ -440,7 +441,7 @@ export default function ConfiguracionPage() {
)} )}
{/* Regímenes Fiscales, Domicilio Fiscal, Bancos */} {/* Regímenes Fiscales, Domicilio Fiscal, Bancos */}
{(user?.role === 'owner' || user?.role === 'cfo') && ( {(user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'supervisor') && (
isDespacho && !selectedContribuyenteId ? ( isDespacho && !selectedContribuyenteId ? (
<Card> <Card>
<CardContent className="py-6 text-center text-muted-foreground"> <CardContent className="py-6 text-center text-muted-foreground">
@@ -456,6 +457,8 @@ export default function ConfiguracionPage() {
) )
)} )}
{showFullConfig && (
<>
{/* SAT Configuration */} {/* SAT Configuration */}
<Link href="/configuracion/sat"> <Link href="/configuracion/sat">
<Card className="hover:border-primary/50 transition-colors cursor-pointer"> <Card className="hover:border-primary/50 transition-colors cursor-pointer">
@@ -477,7 +480,7 @@ export default function ConfiguracionPage() {
</Link> </Link>
{/* Obligaciones Fiscales */} {/* Obligaciones Fiscales */}
{(user?.role === 'owner' || user?.role === 'cfo') && ( {(user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'supervisor') && (
<Link href="/configuracion/obligaciones"> <Link href="/configuracion/obligaciones">
<Card className="hover:border-primary/50 transition-colors cursor-pointer"> <Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader> <CardHeader>
@@ -518,7 +521,7 @@ export default function ConfiguracionPage() {
</Card> </Card>
</Link> </Link>
{/* Preferencias de Facturación (auto-emisión de pagos de suscripción) */} {/* Preferencias de Facturación */}
<Link href="/configuracion/facturacion"> <Link href="/configuracion/facturacion">
<Card className="hover:border-primary/50 transition-colors cursor-pointer"> <Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader> <CardHeader>
@@ -538,26 +541,6 @@ export default function ConfiguracionPage() {
</Card> </Card>
</Link> </Link>
{/* Seguridad */}
<Link href="/configuracion/seguridad">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<KeyRound className="h-4 w-4" />
Seguridad
</CardTitle>
<CardDescription>
Cambia tu contraseña y gestiona las sesiones activas de tu cuenta
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Actualiza tu contraseña o cierra todas las sesiones activas si sospechas un acceso no autorizado.
</p>
</CardContent>
</Card>
</Link>
{/* CSD / Facturapi */} {/* CSD / Facturapi */}
<Link href="/configuracion/csd"> <Link href="/configuracion/csd">
<Card className="hover:border-primary/50 transition-colors cursor-pointer"> <Card className="hover:border-primary/50 transition-colors cursor-pointer">
@@ -577,6 +560,28 @@ export default function ConfiguracionPage() {
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
</>
)}
{/* Seguridad */}
<Link href="/configuracion/seguridad">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<KeyRound className="h-4 w-4" />
Seguridad
</CardTitle>
<CardDescription>
Cambia tu contraseña y gestiona las sesiones activas de tu cuenta
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Actualiza tu contraseña o cierra todas las sesiones activas si sospechas un acceso no autorizado.
</p>
</CardContent>
</Card>
</Link>
{/* Admin global: edición de precios */} {/* Admin global: edición de precios */}
{isGlobalAdmin && ( {isGlobalAdmin && (

View File

@@ -9,6 +9,7 @@ import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { Plus, Pencil, Trash2, Building2, Sparkles } from 'lucide-react'; import { Plus, Pencil, Trash2, Building2, Sparkles } from 'lucide-react';
import { AddonsDialog } from './addons-dialog'; 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.'; 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. // deshabilita el botón con tooltip explicativo.
const { data: planInfo } = useQuery({ const { data: planInfo } = useQuery({
queryKey: ['my-plan-info'], 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 activeCount = (contribuyentes ?? []).filter((c: any) => c.active !== false).length;
const trialAtLimit = (planInfo?.isTrialActive ?? false) && activeCount >= 5; 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 resetForm = () => { setForm({ rfc: '', razonSocial: '' }); setAssignSelf(true); setShowDialog(false); setEditingId(null); };
const handleSave = async () => { const handleSave = async () => {
@@ -77,10 +88,16 @@ export default function ContribuyentesPage() {
setShowDialog(true); setShowDialog(true);
}; };
const canCreate = user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'supervisor';
return ( return (
<div className="p-6 space-y-6 max-w-7xl mx-auto"> <div className="p-6 space-y-6 max-w-7xl mx-auto">
<div className="flex items-center justify-between"> <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> <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 <Button
onClick={() => { resetForm(); setShowDialog(true); }} onClick={() => { resetForm(); setShowDialog(true); }}
disabled={trialAtLimit} disabled={trialAtLimit}
@@ -89,6 +106,7 @@ export default function ContribuyentesPage() {
> >
<Plus className="h-4 w-4" /> Agregar RFC <Plus className="h-4 w-4" /> Agregar RFC
</Button> </Button>
)}
</div> </div>
{isLoading ? <p className="text-muted-foreground">Cargando...</p> : !contribuyentes || contribuyentes.length === 0 ? ( {isLoading ? <p className="text-muted-foreground">Cargando...</p> : !contribuyentes || contribuyentes.length === 0 ? (
@@ -96,6 +114,7 @@ export default function ContribuyentesPage() {
<Building2 className="h-12 w-12 text-muted-foreground mb-4" /> <Building2 className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Sin contribuyentes</h3> <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> <p className="text-sm text-muted-foreground mt-1 mb-4">Agrega el primer RFC para empezar.</p>
{canCreate && (
<Button <Button
onClick={() => { resetForm(); setShowDialog(true); }} onClick={() => { resetForm(); setShowDialog(true); }}
disabled={trialAtLimit} disabled={trialAtLimit}
@@ -103,6 +122,7 @@ export default function ContribuyentesPage() {
> >
Agregar primer RFC Agregar primer RFC
</Button> </Button>
)}
</CardContent></Card> </CardContent></Card>
) : ( ) : (
<div className="grid gap-3 lg:grid-cols-2 3xl:grid-cols-3 4xl:grid-cols-4">{contribuyentes.map((c) => ( <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="font-semibold">{c.nombre}</p>
<p className="text-sm text-muted-foreground font-mono">{c.rfc}</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.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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{(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={() => 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={() => openEdit(c)}><Pencil 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> <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> </div>
</CardContent></Card> </CardContent></Card>
))}</div> ))}</div>

View File

@@ -19,6 +19,8 @@ import {
AlertTriangle, AlertTriangle,
ShoppingCart, ShoppingCart,
CheckSquare, CheckSquare,
FileMinus,
FilePlus,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@horux/shared-ui'; import { cn } from '@horux/shared-ui';
import { FiscalDisclaimer } from '@/components/fiscal-disclaimer'; import { FiscalDisclaimer } from '@/components/fiscal-disclaimer';
@@ -118,6 +120,15 @@ export default function DashboardPage() {
? kpis?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0 ? kpis?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpis?.ivaBalance || 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 const ivaAnterior = regimenSeleccionado
? kpisAnterior?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0 ? kpisAnterior?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpisAnterior?.ivaBalance || 0; : kpisAnterior?.ivaBalance || 0;
@@ -126,9 +137,15 @@ export default function DashboardPage() {
? Math.round(((ivaDisplay - ivaAnterior) / Math.abs(ivaAnterior)) * 10000) / 100 ? Math.round(((ivaDisplay - ivaAnterior) / Math.abs(ivaAnterior)) * 10000) / 100
: null; : null;
const utilidadDisplay = ingresosDisplay - egresosDisplay; // Utilidad ajustada por notas de crédito:
const margenDisplay = ingresosDisplay > 0 // Ingresos netos = Ingresos NCs emitidas
? Math.round((utilidadDisplay / ingresosDisplay) * 10000) / 100 // 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; : 0;
const formatCurrency = (value: number) => const formatCurrency = (value: number) =>
@@ -203,7 +220,7 @@ export default function DashboardPage() {
</div> </div>
{/* KPIs */} {/* 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 <KpiCard
title={regimenSeleccionado ? `Ingresos del Mes (${regimenSeleccionado})` : 'Ingresos del Mes'} title={regimenSeleccionado ? `Ingresos del Mes (${regimenSeleccionado})` : 'Ingresos del Mes'}
value={ingresosDisplay} value={ingresosDisplay}
@@ -216,6 +233,13 @@ export default function DashboardPage() {
} }
href={drillUrl('Ingresos del Mes - CFDIs', { bucket: 'ingresos' })} 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 <KpiCard
title={regimenSeleccionado ? `Gastos del Mes (${regimenSeleccionado})` : 'Gastos del Mes'} title={regimenSeleccionado ? `Gastos del Mes (${regimenSeleccionado})` : 'Gastos del Mes'}
value={egresosDisplay} value={egresosDisplay}
@@ -229,11 +253,18 @@ export default function DashboardPage() {
href={drillUrl('Gastos del Mes - CFDIs', { bucket: 'gastos' })} href={drillUrl('Gastos del Mes - CFDIs', { bucket: 'gastos' })}
/> />
<KpiCard <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} value={utilidadDisplay}
icon={<Wallet className="h-4 w-4" />} icon={<Wallet className="h-4 w-4" />}
trend={utilidadDisplay > 0 ? 'up' : 'down'} trend={utilidadDisplay > 0 ? 'up' : 'down'}
trendValue={`${margenDisplay}% margen`} trendValue={`${margenDisplay}% margen · incluye NCs`}
/> />
<KpiCard <KpiCard
title={regimenSeleccionado ? `Balance IVA (${regimenSeleccionado})` : 'Balance IVA'} title={regimenSeleccionado ? `Balance IVA (${regimenSeleccionado})` : 'Balance IVA'}
@@ -252,7 +283,7 @@ export default function DashboardPage() {
{/* Desglose por régimen */} {/* Desglose por régimen */}
{!regimenSeleccionado && kpis && ( {!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"> <div className="grid gap-4 md:grid-cols-2 3xl:grid-cols-3">
{kpis.ingresosPorRegimen.length > 1 && ( {kpis.ingresosPorRegimen.length > 1 && (
<Card> <Card>
@@ -316,6 +347,46 @@ export default function DashboardPage() {
</CardContent> </CardContent>
</Card> </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> </div>
))} ))}

View File

@@ -25,7 +25,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as docsApi from '@/lib/api/documentos'; import * as docsApi from '@/lib/api/documentos';
const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']; 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 }[] = [ const PERIODICIDADES: { value: Periodicidad; label: string }[] = [
{ value: 'mensual', label: 'Mensual' }, { value: 'mensual', label: 'Mensual' },
{ value: 'bimestral', label: 'Bimestral' }, { value: 'bimestral', label: 'Bimestral' },
@@ -76,7 +76,7 @@ function getPeriodLabel(periodicidad: string, mes: number): string {
const options = getPeriodOptions(periodicidad as Periodicidad); const options = getPeriodOptions(periodicidad as Periodicidad);
return options.find(o => o.value === mes)?.label || MESES[mes - 1] || String(mes); 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 }) { 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>; 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() { export default function DocumentosPage() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const canConsultarOpinion = user?.role === 'owner' || user?.role === 'cfo'; 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 ( return (
<> <>
@@ -700,7 +700,7 @@ function ComprobantePagoDialog({ declaracion, onClose }: { declaracion: Declarac
// Extras — PDFs libres (acuses, contratos, poderes, estados de cuenta, etc.) // 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() { function ExtrasTab() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);

View File

@@ -77,10 +77,14 @@ export default function UsuariosPage() {
const deleteUsuario = useDeleteUsuario(); const deleteUsuario = useDeleteUsuario();
const isDespacho = isDespachoTenant(currentUser?.tenantRfc); 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 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 [showInvite, setShowInvite] = useState(false);
const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: UserInvite['role']; supervisorUserId?: string }>({ 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); const [savingAccesos, setSavingAccesos] = useState(false);
// Edit supervisor modal (para auxiliares) // 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 [selectedSupervisorId, setSelectedSupervisorId] = useState<string>('');
const [savingSupervisor, setSavingSupervisor] = useState(false); const [savingSupervisor, setSavingSupervisor] = useState(false);
const [currentSupervisorNombre, setCurrentSupervisorNombre] = useState<string>('');
const openEditSupervisor = async (userId: string, nombre: string) => { const openEditSupervisor = async (userId: string, nombre: string) => {
try { 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 ?? ''); setSelectedSupervisorId(res.data.supervisorUserId ?? '');
setEditingSupervisorUser({ id: userId, nombre }); setCurrentSupervisorNombre(res.data.supervisorNombre ?? '');
setEditingSupervisorUser({ id: userId, nombre, supervisorNombre: res.data.supervisorNombre });
} catch { } catch {
alert('Error al cargar supervisor'); alert('Error al cargar supervisor');
} }
@@ -483,7 +490,14 @@ export default function UsuariosPage() {
<div className="space-y-2 py-2"> <div className="space-y-2 py-2">
{supervisores && supervisores.length > 0 ? ( {supervisores && supervisores.length > 0 ? (
<Select value={selectedSupervisorId || 'none'} onValueChange={(v) => setSelectedSupervisorId(v === 'none' ? '' : v)}> <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> <SelectContent>
<SelectItem value="none">Sin supervisor asignado</SelectItem> <SelectItem value="none">Sin supervisor asignado</SelectItem>
{supervisores.map(s => ( {supervisores.map(s => (
@@ -491,6 +505,12 @@ export default function UsuariosPage() {
{s.nombre} {s.email} {s.nombre} {s.email}
</SelectItem> </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> </SelectContent>
</Select> </Select>
) : ( ) : (

View File

@@ -33,6 +33,16 @@ export function ContribuyenteSelector() {
} }
}, [contribuyentes, selectedContribuyenteId, setSelectedContribuyente]); }, [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 (isLoading || !contribuyentes || contribuyentes.length === 0) return null;
if (pathname && HIDDEN_PATHS.some(p => pathname === p || pathname.startsWith(`${p}/`))) 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 PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
const ITEMS: NavItem[] = [ 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/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'] }, { 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 { apiClient } from '@/lib/api/client';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useContribuyenteStore } from '@/stores/contribuyente-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 MESES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
const ALLOWED_MIMES = [ const ALLOWED_MIMES = [
@@ -37,6 +37,9 @@ interface Papeleria {
requiereAprobacion: boolean; requiereAprobacion: boolean;
estado: 'pendiente' | 'aprobado' | 'rechazado' | null; estado: 'pendiente' | 'aprobado' | 'rechazado' | null;
comentarioRechazo: string | null; comentarioRechazo: string | null;
requiereAprobacionCliente: boolean;
estadoCliente: 'pendiente' | 'aprobado' | 'rechazado' | null;
comentarioRechazoCliente: string | null;
subidoPor: string; subidoPor: string;
createdAt: string; createdAt: string;
} }
@@ -54,28 +57,59 @@ function fileToBase64(file: File): Promise<string> {
}); });
} }
function EstadoBadge({ estado, requiereAprobacion }: { estado: string | null; requiereAprobacion: boolean }) { function estadoGlobal(item: Papeleria): 'sin_aprobacion' | 'pendiente' | 'aprobado' | 'rechazado' {
if (!requiereAprobacion) { 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>; 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>; 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-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() { export function PapeleriaTab() {
const user = useAuthStore(s => s.user); const user = useAuthStore(s => s.user);
const { selectedContribuyenteId } = useContribuyenteStore(); const { selectedContribuyenteId } = useContribuyenteStore();
const queryClient = useQueryClient(); 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 [showUpload, setShowUpload] = useState(false);
const [rechazoFor, setRechazoFor] = useState<Papeleria | null>(null); const [rechazoFor, setRechazoFor] = useState<Papeleria | null>(null);
const [comentarioRechazo, setComentarioRechazo] = useState(''); const [comentarioRechazo, setComentarioRechazo] = useState('');
const [rechazoClienteFor, setRechazoClienteFor] = useState<Papeleria | null>(null);
const [comentarioRechazoCliente, setComentarioRechazoCliente] = useState('');
// Filtros // Filtros
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
@@ -105,6 +139,7 @@ export function PapeleriaTab() {
const [anio, setAnio] = useState(currentYear); const [anio, setAnio] = useState(currentYear);
const [mes, setMes] = useState(new Date().getMonth() + 1); const [mes, setMes] = useState(new Date().getMonth() + 1);
const [requiereAprobacion, setRequiereAprobacion] = useState(false); const [requiereAprobacion, setRequiereAprobacion] = useState(false);
const [requiereAprobacionCliente, setRequiereAprobacionCliente] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const resetUpload = () => { const resetUpload = () => {
@@ -114,6 +149,7 @@ export function PapeleriaTab() {
setAnio(currentYear); setAnio(currentYear);
setMes(new Date().getMonth() + 1); setMes(new Date().getMonth() + 1);
setRequiereAprobacion(false); setRequiereAprobacion(false);
setRequiereAprobacionCliente(false);
setUploadError(null); setUploadError(null);
}; };
@@ -130,6 +166,7 @@ export function PapeleriaTab() {
anio, anio,
mes, mes,
requiereAprobacion, requiereAprobacion,
requiereAprobacionCliente,
archivoBase64: base64, archivoBase64: base64,
archivoFilename: file.name, archivoFilename: file.name,
archivoMime: file.type, 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({ const eliminarMutation = useMutation({
mutationFn: async (id: number) => apiClient.delete(`/papeleria/${id}`), mutationFn: async (id: number) => apiClient.delete(`/papeleria/${id}`),
onSuccess: invalidate, 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) { if (!selectedContribuyenteId) {
return ( return (
<Card> <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 ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Filtros + upload */} {/* Filtros + upload */}
@@ -236,9 +288,11 @@ export function PapeleriaTab() {
</select> </select>
</div> </div>
</div> </div>
{canUpload && (
<Button onClick={() => setShowUpload(true)}> <Button onClick={() => setShowUpload(true)}>
<Upload className="h-4 w-4 mr-2" /> Subir documento <Upload className="h-4 w-4 mr-2" /> Subir documento
</Button> </Button>
)}
</div> </div>
{/* Listado */} {/* Listado */}
@@ -258,7 +312,7 @@ export function PapeleriaTab() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{it.nombre}</span> <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"> <span className="text-xs text-muted-foreground">
{MESES[it.mes - 1]} {it.anio} {MESES[it.mes - 1]} {it.anio}
</span> </span>
@@ -270,10 +324,31 @@ export function PapeleriaTab() {
{it.archivoFilename} · {(it.archivoSize / 1024).toFixed(0)} KB {it.archivoFilename} · {(it.archivoSize / 1024).toFixed(0)} KB
· subido {new Date(it.createdAt).toLocaleDateString('es-MX')} · subido {new Date(it.createdAt).toLocaleDateString('es-MX')}
</p> </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 && ( {it.estado === 'rechazado' && it.comentarioRechazo && (
<p className="text-xs mt-1 flex items-start gap-1 text-red-700 dark:text-red-400"> <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" /> <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> </p>
)} )}
</div> </div>
@@ -281,7 +356,8 @@ export function PapeleriaTab() {
<Button variant="ghost" size="icon" onClick={() => downloadMutation.mutate(it)} title="Descargar"> <Button variant="ghost" size="icon" onClick={() => downloadMutation.mutate(it)} title="Descargar">
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
</Button> </Button>
{canApprove && it.requiereAprobacion && it.estado === 'pendiente' && ( {/* Botones owner/supervisor */}
{canApproveOwner && it.requiereAprobacion && it.estado === 'pendiente' && (
<> <>
<Button <Button
variant="ghost" size="icon" variant="ghost" size="icon"
@@ -299,6 +375,26 @@ export function PapeleriaTab() {
</Button> </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 <Button
variant="ghost" size="icon" variant="ghost" size="icon"
onClick={() => confirm(`¿Eliminar "${it.nombre}"?`) && eliminarMutation.mutate(it.id)} onClick={() => confirm(`¿Eliminar "${it.nombre}"?`) && eliminarMutation.mutate(it.id)}
@@ -306,6 +402,7 @@ export function PapeleriaTab() {
> >
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
</Button> </Button>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -375,6 +472,14 @@ export function PapeleriaTab() {
/> />
Este documento requiere aprobación de owner/supervisor Este documento requiere aprobación de owner/supervisor
</label> </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 && ( {uploadError && (
<p className="text-xs text-destructive flex items-start gap-1"> <p className="text-xs text-destructive flex items-start gap-1">
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" /> <AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
@@ -394,7 +499,7 @@ export function PapeleriaTab() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Modal Rechazo */} {/* Modal Rechazo Owner */}
<Dialog open={!!rechazoFor} onOpenChange={(o) => { if (!o) { setRechazoFor(null); setComentarioRechazo(''); } }}> <Dialog open={!!rechazoFor} onOpenChange={(o) => { if (!o) { setRechazoFor(null); setComentarioRechazo(''); } }}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -426,6 +531,39 @@ export function PapeleriaTab() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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> </div>
); );
} }

View File

@@ -55,11 +55,11 @@ const navigation: NavItem[] = [
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] }, { name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' }, { name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] }, { 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: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] }, { name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] }, { 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[] = [ 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: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' }, { name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] }, { 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: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] }, { name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] }, { 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[] = [ 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: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' }, { name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] }, { 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: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo', 'supervisor', 'auxiliar'] },
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] }, { name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] }, { 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[] = [ 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: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' }, { name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] }, { 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: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] }, { name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] }, { 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[] = [ const adminNavigation: NavItem[] = [

View File

@@ -56,3 +56,6 @@ export const asignarTarea = (tareaId: string, auxiliarUserId: string) =>
export const desasignarTarea = (tareaId: string) => export const desasignarTarea = (tareaId: string) =>
apiClient.delete(`/tareas/${tareaId}/asignar`).then(r => r.data); 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

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

View File

@@ -1,6 +1,6 @@
import { apiClient } from './client'; 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 type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'semestral' | 'anual';
export interface Declaracion { export interface Declaracion {

View File

@@ -7,6 +7,7 @@ import {
desasignarObligacion, desasignarObligacion,
asignarTarea, asignarTarea,
desasignarTarea, desasignarTarea,
getAuxiliaresElegibles,
} from '../api/asignaciones'; } from '../api/asignaciones';
export function useAsignacionesSupervisor() { 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,
});
}

View File

@@ -33,6 +33,10 @@ export interface KpiData {
cfdisEmitidosPorRegimen: { regimen: string; total: number }[]; cfdisEmitidosPorRegimen: { regimen: string; total: number }[];
cfdisRecibidos: number; cfdisRecibidos: number;
cfdisRecibidosPorRegimen: { regimen: string; total: number }[]; cfdisRecibidosPorRegimen: { regimen: string; total: number }[];
ncsEmitidas: number;
ncsEmitidasPorRegimen: IngresoRegimen[];
ncsRecibidas: number;
ncsRecibidasPorRegimen: IngresoRegimen[];
} }
export interface IngresosEgresosData { export interface IngresosEgresosData {