Compare commits

..

61 Commits

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

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

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

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

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

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

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

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

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

- 048: índices btree parciales para activos
- 049: índices GIN para cfdis_relacionados y uuid_relacionado
2026-05-28 02:38:30 +00:00
Horux Dev
138e223361 fix(subnav): ocultar pestaña Contribuyentes en Despacho para no-owner/cfo
- La página /despachos/contribuyentes solo permite owner/cfo/platform_staff.
- La pestaña en el subnav ahora solo se muestra a esos roles, evitando que
  supervisor, contador, visor y auxiliar vean un link que lleva a mensaje
  de 'solo disponible para owner'.
2026-05-26 00:20:51 +00:00
Horux Dev
441ec20059 fix(despacho-stats): contar obligaciones y tareas pendientes correctamente
- Obligaciones: las obligaciones activas sin registro en obligacion_periodos
  para el periodo actual ahora se cuentan como pendientes (antes daban 0)
- Tareas: se materializan los periodos antes de contar para que las tareas
  sin registro previo aparezcan como pendientes
- Usa CTEs separadas para obligaciones y tareas evitando producto cartesiano
2026-05-25 23:40:17 +00:00
Horux Dev
929aeec641 feat(declaraciones): supervisor puede crear declaraciones y extras
- Backend: agrega 'supervisor' a ROLES_UPLOAD en documentos.controller.ts
- Frontend: agrega 'supervisor' a ROLES_UPLOAD y ROLES_UPLOAD_EXTRA en
  documentos/page.tsx para habilitar botones de subir declaración,
  comprobante de pago, eliminar y subir PDFs extra
2026-05-25 21:49:01 +00:00
Horux Dev
4a885de520 feat(configuracion): supervisor puede ver regimenes y domicilio fiscal
- Extiende la condición de visibilidad de Regímenes Fiscales, Domicilio
  Fiscal y Bancos para incluir al rol supervisor
2026-05-25 19:25:07 +00:00
Horux Dev
c84ad6c4db feat(sidebar): Configuracion visible para auxiliar y cliente
- Sidebars/topnav: agrega 'auxiliar' y 'cliente' a la opción Configuracion
- /configuracion/page.tsx: auxiliar y cliente solo ven Información de Usuario,
  Información de Empresa y Seguridad (cambio de contraseña). Todo lo demás
  (FIEL, Obligaciones, Notificaciones, Facturación, CSD) queda restringido
  a owner/cfo/supervisor
2026-05-25 16:57:10 +00:00
Horux Dev
acd7de76d9 feat(sidebar): mostrar Configuracion a supervisor
- Extiende roles de la opción Configuracion en sidebar, sidebar-compact,
  sidebar-floating y topnav
2026-05-25 16:46:09 +00:00
Horux Dev
9c4a2343f5 feat(auth): supervisor puede configurar FIEL, CSD y Obligaciones
- Backend: agrega 'supervisor' a authorize() de rutas:
  - POST/DELETE /contribuyentes/:id/fiel
  - POST /contribuyentes/:id/facturapi/csd
  - POST/DELETE /contribuyentes/:id/obligaciones/*
- Frontend: muestra tarjeta 'Obligaciones Fiscales' en /configuracion
  para rol supervisor
2026-05-25 16:39:31 +00:00
Horux Dev
1d828adc27 feat(contribuyentes): mostrar contador de RFCs disponibles del plan
- Agrega contador 'X de Y RFCs' debajo del título de la página
- Usa DESPACHO_PLANS desde @horux/shared para obtener maxRfcs del plan actual
- Durante trial muestra 'X de 5 RFCs'
- Planes ilimitados muestran solo 'X RFCs'
2026-05-25 16:20:37 +00:00
Horux Dev
4c7ab4fd35 feat(sidebar): mostrar Contribuyentes a supervisor, contador y auxiliar
- Extiende roles de la opción Contribuyentes en sidebar, sidebar-compact,
  sidebar-floating y topnav
2026-05-25 15:52:46 +00:00
Horux Dev
0fa2c3c90f feat(contribuyentes): permitir a supervisor crear contribuyentes
- Agrega 'supervisor' al authorize() de POST /contribuyentes
2026-05-25 15:22:23 +00:00
Horux Dev
cbefaa2bf7 refactor(declaraciones): renombrar SUELDOS→ISN, agregar ISH
- Cambia la opción 'SUELDOS' por 'ISN' (Impuesto Sobre Nómina)
- Agrega nueva opción 'ISH' (Impuesto Sobre Hospedaje)
- ISH no cierra alertas ni obligaciones (aún no hay flujo definido)
- ISN mantiene keywords de sueldos/salarios/nómina + agrega 'isn'
- Migración 047: actualiza declaraciones históricas SUELDOS→ISN en BD
2026-05-25 02:35:16 +00:00
Horux Dev
e35eae2a72 refactor(cfdi): descarga masiva de XMLs por filtros en lugar de checkboxes
- Backend: POST /cfdi/download-xmls acepta CfdiFilters, usa getXmlsByFilters con LIMIT 1000
- Frontend: eliminados checkboxes y estado selectedIds; botón Descargar XMLs usa filtros activos
- Si >1000 resultados, muestra confirm() de advertencia pero permite proceder
- Agregada documentación técnica y changelog
2026-05-24 21:40:08 +00:00
Horux Dev
5c940847af feat(cfdi): descarga masiva de XMLs como ZIP, limite 1,000 2026-05-24 21:19:56 +00:00
Horux Dev
80e2c099d9 style(conciliacion): aumentar tamano de fuente en tabla Conciliadas 2026-05-24 19:54:46 +00:00
Horux Dev
70f94ce0f2 style(conciliacion): aumentar tamano de fuente en tabla Por conciliar 2026-05-24 19:44:55 +00:00
Horux Dev
a24947187a feat(conciliacion): columnas de regimen en tabla Por conciliar segun tab 2026-05-24 19:39:21 +00:00
Horux Dev
c65e3455e6 fix(conciliacion): headers visibles aun sin resultados en filtros 2026-05-24 19:28:45 +00:00
Horux Dev
31be887882 feat(conciliacion): metricas I+P-E para montos conciliado y pendiente 2026-05-24 02:17:06 +00:00
Horux Dev
3eeec3c60e fix(conciliacion): celdas dinamicas RFC/Nombre aplicadas a tabla conciliadas 2026-05-24 01:28:07 +00:00
Horux Dev
face71ef5d feat(conciliacion): columnas RFC/Nombre dinamicas segun tab en conciliadas 2026-05-24 01:11:07 +00:00
Horux Dev
a727c1b069 feat(conciliacion): filtro autocomplete en columna Banco de conciliadas 2026-05-24 01:05:27 +00:00
Horux Dev
918d84f2d2 feat(conciliacion): filtros de columna con sugerencias autocomplete
- Agregar prop suggestions a FilterHeader con dropdown de opciones
- Calcular valores unicos de rfc/nombre emisor/receptor desde los
  CFDIs cargados en memoria
- Filtrar sugerencias segun texto escrito (max 8 resultados)
- Al seleccionar una sugerencia se aplica el filtro y cierra el popover
2026-05-24 00:55:08 +00:00
Horux Dev
a30060050b fix(conciliacion): filtros de columna se atascaban al escribir
- Mover FilterHeader fuera de ConciliacionPage para evitar
  desmonte/remonte en cada render (causaba perdida de foco)
- Agregar debounce de 300ms al input de filtro para reducir
  re-renders mientras el usuario escribe
2026-05-24 00:37:35 +00:00
Horux Dev
8f420711ae docs: sesion 2026-05-23 asignaciones tareas admin ui 2026-05-23 23:42:25 +00:00
Horux Dev
be96ecc324 feat: invitaciones trial como pestaña en admin usuarios + sidebar
- Quitado Invitaciones Trial del sidebar (4 layouts)
- Agregado tab Invitaciones Trial dentro de /admin/usuarios
- Componente reutilizable invitaciones-trial-tab.tsx
- Agregada nueva opcion Tareas en el sidebar principal
2026-05-23 23:41:58 +00:00
Horux Dev
bba000d308 feat: pagina /tareas + quitar completar obligaciones fiscales
- Nueva pagina /tareas para ver y marcar tareas operativas
- Endpoint GET /tareas/mis-tareas con periodo actual
- Quitado boton de marcar completada de obligaciones fiscales en /pendientes
2026-05-23 23:41:28 +00:00
Horux Dev
e8b0733304 feat: seguimiento auxiliares UI con tabs Asignadas/Sin asignar
- Componente seguimiento-auxiliares.tsx con tabs Asignadas/Sin asignar
- Tabs internos Obligaciones/Tareas en cada vista
- API client y hooks para asignaciones
- Fix: invalidar query sin-asignar al asignar/desasignar
2026-05-23 23:40:39 +00:00
Horux Dev
f43cb165c6 feat: asignaciones obligaciones/tareas + fixes backend
- Migracion 046: tablas obligacion_asignaciones y tarea_asignaciones
- Servicio y controller de asignaciones (CRUD + listados)
- Fix: enviar correo welcome al invitar usuario nuevo
- Fix: quitar JOIN users de queries tenant (usar Prisma en BD central)
- Fix: req.params.obligacionId correcto en asignaciones controller
- Fix: orden rutas estaticas antes de dinamicas en cartera.routes
- Fix: owner/cfo ven todas las asignaciones en getAsignacionesPorSupervisor
- Fix: validar que entidad pertenezca a cartera padre en subcartera
- Nuevo endpoint GET /carteras/asignaciones/sin-asignar
- Nuevo endpoint GET /tareas/mis-tareas
2026-05-23 23:40:12 +00:00
Horux Dev
0c7580aa44 docs: sesión 2026-05-22 — personalización CSD, fecha emisión, precio sin IVA, cuenta predial 2026-05-23 18:18:39 +00:00
Horux Dev
a91a2f415d feat(facturacion): cuenta predial para régimen 606 (arrendamiento)
- Frontend: muestra input 'No. Cuenta Predial' en sección 'Datos del Inmueble'
  cuando el régimen del emisor es 606 (Arrendamiento), antes de Conceptos
- Frontend: incluye cuentaPredial en payload; se resetea al cambiar contribuyente
- Backend: pasa property_tax_account a nivel de cada item en Facturapi
  para facturapi.service.ts y contribuyente-facturapi.service.ts
- Build y deploy exitosos
2026-05-22 23:20:36 +00:00
Horux Dev
0c8ae05919 feat(facturacion): precio unitario sin IVA en conceptos
- Cambia label de 'Precio Unitario (IVA incluido)' a 'Precio Unitario (sin IVA)'
- Elimina división interna price/(1+iva) en calcConcepto; ahora price es la base
- Cambia taxIncluded: true → false en payload enviado a backend
- Backend: tax_included default false en facturapi.service.ts y
  contribuyente-facturapi.service.ts
- Build y deploy exitosos
2026-05-22 22:23:38 +00:00
Horux Dev
1bde570035 feat(facturacion): fecha de emisión personalizable para I, E, T
- Frontend: input datetime-local visible solo para tipos I, E, T
  (no P). Default al día actual a las 12:00. Se resetea al cambiar tipo.
- Frontend: validación en handleSubmit: fecha ≤ ahora y ≥ ahora-72h
- Backend controller: validación idéntica antes de consumir timbre
- Backend servicios: pasa campo 'date' al payload de Facturapi
  cuando viene 'fechaEmision' en el body
- Build y deploy exitosos
2026-05-22 20:11:03 +00:00
Horux Dev
5ba31b7291 fix: personalización logo/color por contribuyente en vez de tenant
- Agrega getCustomizationContribuyente, uploadLogoContribuyente,
  updateColorContribuyente en contribuyente-facturapi.service.ts
- Agrega controllers per-contribuyente en facturacion.controller.ts
- Agrega rutas GET/POST/PUT /contribuyentes/:id/facturapi/customization|logo|color
- Modifica CustomizationSection para recibir contribuyenteId, usar endpoints
  per-contribuyente, y corrige useState mal aplicado a useEffect
- Backend y frontend buildeados y deployados
2026-05-22 18:20:09 +00:00
Horux Dev
46846200da feat(sat): factura global + fecha_efectiva, fallback tenant-contribuyente, fix anio_global typo
Factura Global & fecha_efectiva:
- Migracion 045_factura_global.sql: periodicidad, meses_global, año_global, fecha_efectiva
- sat-parser.service.ts: extrae InformacionGlobal del XML
- sat.service.ts: calcFechaEfectiva con soporte bimestral (periodicidad 05)
- metricas-compute, dashboard, impuestos, cfdi, export, conciliacion, alertas:
  reemplaza fecha_emision-1h por COALESCE(fecha_efectiva, fecha_emision-1h)
- Script recalc-metricas.ts para recalculo manual

Fallback datos fiscales tenant → contribuyente:
- contribuyente.service.ts: fetchTenantFiscalData + mergeContribuyenteWithTenant
  rellena regimenFiscal, codigoPostal y domicilio cuando el contribuyente
  tiene el mismo RFC que el tenant y sus campos estan vacios
- contribuyente.controller.ts y contribuyente-config.controller.ts:
  pasan req.user!.tenantId al servicio

Fix critico SAT sync:
- sat.service.ts: anio_global → año_global en INSERT/UPDATE de CFDIs
  (la migracion creo 'año_global' con tilde; el codigo usaba 'anio_global',
   causando fallo en 100% de inserciones de CFDI)
- determineChunkMonths: salta sondeo si existe job previo con requestIds
- MAX_POLL_ATTEMPTS: 45 → 500 (~8h) para syncs iniciales grandes

Docs:
- docs/sessions/2026-05-22-factura-global-contribuyente-fallback.md
2026-05-22 15:52:10 +00:00
Horux Dev
ba6004ebd6 docs: sesión 2026-05-20 — saldo PPD, seguridad cancelación, sync Alcaraz Salazar 2026-05-20 05:19:50 +00:00
Horux Dev
b5e307e142 fix(facturacion): saldo pendiente PPD + seguridad cancelación multi-contribuyente
- Inicializar saldo_pendiente_mxn al emitir facturas I/PPD vía Facturapi
  (antes quedaba NULL y no aparecían en complemento de pago)
- Validar ownership en cancelación: backend rechaza 403 si el caller
  intenta cancelar una factura de otro contribuyente
- Frontend: ocultar botón cancelar si no se es el emisor de la factura
- Frontend: enviar contribuyenteId en la petición de cancelación
2026-05-20 05:18:34 +00:00
106 changed files with 5808 additions and 851 deletions

View File

@@ -71,9 +71,9 @@ class TenantConnectionManager {
user: connectionOverride?.user ?? this.dbConfig.user,
password: connectionOverride?.password ?? this.dbConfig.password,
database: databaseName,
max: 3,
max: 10,
idleTimeoutMillis: 300_000,
connectionTimeoutMillis: 10_000,
connectionTimeoutMillis: 30_000,
};
pool = new Pool(poolConfig);
@@ -187,11 +187,13 @@ class TenantConnectionManager {
}
/**
* Remove idle pools (not accessed in last 5 minutes).
* Remove idle pools (not accessed in last 12 hours).
* SAT syncs (initial/daily) can run for hours in background;
* a 5-minute timeout caused 'pool already ended' errors mid-sync.
*/
private cleanupIdlePools(): void {
const now = Date.now();
const maxIdle = 5 * 60 * 1000;
const maxIdle = 12 * 60 * 60 * 1000;
for (const [tenantId, entry] of this.pools.entries()) {
if (now - entry.lastAccess.getTime() > maxIdle) {

View File

@@ -125,7 +125,9 @@ export async function resolverAlertaManual(req: Request, res: Response, next: Ne
export async function getAlertasAutomaticas(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
console.log(`[AlertasCtrl] GET /automaticas tenant=${req.user!.tenantId} contribuyente=${contribuyenteId || 'null'} user=${req.user!.userId} role=${req.user!.role}`);
const alertas = await generarAlertasAutomaticas(req.tenantPool!, req.user!.tenantId, contribuyenteId || null);
console.log(`[AlertasCtrl] GET /automaticas devuelve ${alertas.length} alertas: ${alertas.map(a => a.id).join(', ') || 'ninguna'}`);
res.json(alertas);
} catch (error) {
next(error);
@@ -333,7 +335,7 @@ export async function getCancelados(req: Request, res: Response, next: NextFunct
total_mxn as "totalMxn", fecha_cancelacion as "fechaCancelacion"
FROM cfdis
WHERE status IN ('Cancelado', '0')
AND fecha_emision >= $1::date
AND (fecha_emision - interval '1 hour') >= $1::date
${cf}
ORDER BY fecha_emision DESC
`, [hace5.toISOString().split('T')[0]]);
@@ -364,7 +366,7 @@ export async function getCancelacionesPeriodoAnterior(req: Request, res: Respons
FROM cfdis
WHERE status IN ('Cancelado', '0')
AND fecha_cancelacion >= $1::date
AND fecha_emision < $1::date
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < $1::date
${cf}
ORDER BY fecha_cancelacion DESC
`, [inicioMes]);

View File

@@ -0,0 +1,197 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as asignacionesService from '../services/asignaciones.service.js';
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
import { AppError } from '../middlewares/error.middleware.js';
/**
* Valida que el auxiliar pertenezca al supervisor (o que el caller sea owner).
* Owner puede asignar a cualquier auxiliar del tenant.
* La relación se infiere desde carteras (directas y subcarteras) con fallback
* a la tabla legacy auxiliar_supervisores.
*/
async function validarAuxiliarDelSupervisor(
pool: import('pg').Pool,
supervisorUserId: string,
auxiliarUserId: string,
callerRole: string,
): Promise<void> {
if (callerRole === 'owner') return;
const { rows } = await pool.query(
`SELECT 1 FROM (
SELECT c.auxiliar_user_id
FROM carteras c
WHERE c.supervisor_user_id = $1
AND c.auxiliar_user_id = $2
UNION
SELECT sub.auxiliar_user_id
FROM carteras sub
JOIN carteras p ON p.id = sub.parent_id
WHERE p.supervisor_user_id = $1
AND sub.auxiliar_user_id = $2
UNION
SELECT auxiliar_user_id FROM auxiliar_supervisores
WHERE supervisor_user_id = $1 AND auxiliar_user_id = $2
) t LIMIT 1`,
[supervisorUserId, auxiliarUserId],
);
if (rows.length === 0) {
throw new AppError(403, 'El auxiliar no pertenece a tu equipo');
}
}
/**
* Valida que el auxiliar tenga al contribuyente en alguna de sus subcarteras.
* Si no hay ningún auxiliar con ese contribuyente en su subcartera, la asignación
* se rechaza (el supervisor debe agregar el contribuyente a una subcartera primero).
*/
async function validarAuxiliarEnSubcartera(
pool: import('pg').Pool,
contribuyenteId: string,
auxiliarUserId: string,
): Promise<void> {
const elegibles = await asignacionesService.getAuxiliaresElegibles(pool, contribuyenteId);
if (elegibles.length === 0) {
throw new AppError(403, 'Ningún auxiliar tiene este contribuyente en su subcartera');
}
if (!elegibles.includes(auxiliarUserId)) {
throw new AppError(403, 'El auxiliar no tiene este contribuyente en ninguna de sus subcarteras');
}
}
// ── Obligaciones ──
export async function asignarObligacion(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const obligacionId = String(req.params.obligacionId);
const schema = z.object({ auxiliarUserId: z.string().uuid() });
const { auxiliarUserId } = schema.parse(req.body);
await validarAuxiliarDelSupervisor(
req.tenantPool!,
req.user!.userId,
auxiliarUserId,
req.user!.role,
);
await validarAuxiliarEnSubcartera(
req.tenantPool!,
contribuyenteId,
auxiliarUserId,
);
await asignacionesService.asignarObligacion(
req.tenantPool!,
obligacionId,
auxiliarUserId,
req.user!.userId,
);
res.json({ message: 'Obligación asignada' });
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
next(err);
}
}
export async function desasignarObligacion(req: Request, res: Response, next: NextFunction) {
try {
const obligacionId = String(req.params.obligacionId);
await asignacionesService.desasignarObligacion(req.tenantPool!, obligacionId);
res.json({ message: 'Asignación de obligación eliminada' });
} catch (err) { next(err); }
}
// ── Tareas ──
export async function asignarTarea(req: Request, res: Response, next: NextFunction) {
try {
const tareaId = String(req.params.id);
const schema = z.object({ auxiliarUserId: z.string().uuid() });
const { auxiliarUserId } = schema.parse(req.body);
await validarAuxiliarDelSupervisor(
req.tenantPool!,
req.user!.userId,
auxiliarUserId,
req.user!.role,
);
// Obtener contribuyenteId de la tarea para validar subcartera
const { rows } = await req.tenantPool!.query<{ contribuyente_id: string }>(
`SELECT contribuyente_id FROM tareas_catalogo WHERE id = $1 LIMIT 1`,
[tareaId],
);
if (rows.length > 0) {
await validarAuxiliarEnSubcartera(
req.tenantPool!,
rows[0].contribuyente_id,
auxiliarUserId,
);
}
await asignacionesService.asignarTarea(
req.tenantPool!,
tareaId,
auxiliarUserId,
req.user!.userId,
);
res.json({ message: 'Tarea asignada' });
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
next(err);
}
}
export async function desasignarTarea(req: Request, res: Response, next: NextFunction) {
try {
const tareaId = String(req.params.id);
await asignacionesService.desasignarTarea(req.tenantPool!, tareaId);
res.json({ message: 'Asignación de tarea eliminada' });
} catch (err) { next(err); }
}
// ── Listados ──
export async function listPorSupervisor(req: Request, res: Response, next: NextFunction) {
try {
const data = await asignacionesService.getAsignacionesPorSupervisor(
req.tenantPool!,
req.user!.userId,
req.user!.role,
);
res.json(data);
} catch (err) { next(err); }
}
export async function listPorAuxiliar(req: Request, res: Response, next: NextFunction) {
try {
const data = await asignacionesService.getAsignacionesPorAuxiliar(
req.tenantPool!,
req.user!.userId,
);
res.json(data);
} catch (err) { next(err); }
}
export async function listSinAsignar(req: Request, res: Response, next: NextFunction) {
try {
const entidadIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
const [obligaciones, tareas] = await Promise.all([
asignacionesService.getObligacionesSinAsignar(req.tenantPool!, entidadIds),
asignacionesService.getTareasSinAsignar(req.tenantPool!, entidadIds),
]);
res.json({ obligaciones, tareas });
} catch (err) { next(err); }
}
export async function listAuxiliaresElegibles(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.contribuyenteId);
const auxIds = await asignacionesService.getAuxiliaresElegibles(req.tenantPool!, contribuyenteId);
res.json({ auxiliares: auxIds });
} catch (err) { next(err); }
}

View File

@@ -1,6 +1,7 @@
import type { Request, Response, NextFunction } from 'express';
import * as cfdiService from '../services/cfdi.service.js';
import { AppError } from '../middlewares/error.middleware.js';
import AdmZip from 'adm-zip';
import { GRUPO_PF_EMPRESARIAL, GRUPO_PM_OTROS } from '../services/dashboard.service.js';
import { getRegimenesIgnoradosClaves } from '../services/regimen.service.js';
import { resolveContribuyenteContext } from '../utils/contribuyente-context.js';
@@ -75,6 +76,50 @@ export async function getXml(req: Request, res: Response, next: NextFunction) {
}
}
export async function downloadXmlsZip(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const filters: CfdiFilters = {
tipo: req.body.tipo as any,
tipoComprobante: req.body.tipoComprobante as any,
estado: req.body.estado as any,
fechaInicio: req.body.fechaInicio as string,
fechaFin: req.body.fechaFin as string,
rfc: req.body.rfc as string,
emisor: req.body.emisor as string,
receptor: req.body.receptor as string,
search: req.body.search as string,
contribuyenteId: req.body.contribuyenteId as string,
};
const cfdis = await cfdiService.getCfdiXmlsForZip(req.tenantPool, filters);
const zip = new AdmZip();
let added = 0;
for (const cfdi of cfdis) {
if (cfdi.xml) {
const filename = `${cfdi.uuid || 'cfdi'}.xml`;
zip.addFile(filename, Buffer.from(cfdi.xml, 'utf8'));
added++;
}
}
if (added === 0) {
return next(new AppError(404, 'No se encontraron XMLs para los filtros aplicados'));
}
const zipBuffer = zip.toBuffer();
res.set('Content-Type', 'application/zip');
res.set('Content-Disposition', `attachment; filename="cfdis-${Date.now()}.zip"`);
res.send(zipBuffer);
} catch (error) {
next(error);
}
}
export async function listConceptos(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) return next(new AppError(400, 'Tenant no configurado'));
@@ -239,13 +284,28 @@ export async function drillDown(req: Request, res: Response, next: NextFunction)
) ${NO_IGNORADO_EMISOR.replace('regimen_fiscal_emisor', `CASE WHEN ${esEmisor} THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END`)}`;
} else if (bucketStr === 'gastos') {
// Las E PUE se exhiben en su propia card "NCs Recibidas" — no entran aquí.
// La nómina emitida (tipo_comprobante = 'N') SÍ entra: el patrón la emite
// (lado emisor) y es un gasto/egreso para sus libros — alineado con
// calcularEgresosPorRegimen en dashboard.service.ts.
where += ` AND (
${esReceptor} AND (
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
OR (tipo_comprobante = 'P')
(
${esReceptor} AND (
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
OR (tipo_comprobante = 'P')
)
AND regimen_fiscal_receptor IN (${TODOS_REGS})
)
AND regimen_fiscal_receptor IN (${TODOS_REGS})
) ${NO_IGNORADO_RECEPTOR}`;
OR (
${esEmisor} AND tipo_comprobante = 'N'
AND regimen_fiscal_emisor IN (${TODOS_REGS})
)
)`;
if (ignorados.length > 0) {
where += ` AND (
(${esReceptor} AND regimen_fiscal_receptor NOT IN (${ignorados.map(r => `'${r}'`).join(',')}))
OR (${esEmisor} AND tipo_comprobante = 'N' AND regimen_fiscal_emisor NOT IN (${ignorados.map(r => `'${r}'`).join(',')}))
)`;
}
} else if (bucketStr === 'causado') {
where += ` AND (
${esEmisor} AND (

View File

@@ -13,7 +13,7 @@ export async function uploadFiel(req: Request, res: Response, next: NextFunction
return next(new AppError(400, 'cerFile, keyFile y password son requeridos'));
}
const contribuyenteId = String(req.params.id);
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId);
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId, req.user!.tenantId);
if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado'));
const result = await fielService.uploadFielContribuyente(req.tenantPool!, contribuyenteId, cerFile, keyFile, password);
@@ -62,7 +62,7 @@ export async function deleteFiel(req: Request, res: Response, next: NextFunction
export async function createOrg(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId);
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId, req.user!.tenantId);
if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado'));
const result = await facturapiService.createOrgContribuyente(req.tenantPool!, contribuyenteId, contrib.nombre);

View File

@@ -1,6 +1,7 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as contribuyenteService from '../services/contribuyente.service.js';
import * as carteraService from '../services/cartera.service.js';
import { AppError } from '../middlewares/error.middleware.js';
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
import { adjustDespachoOverage } from '../services/payment/addon.service.js';
@@ -40,14 +41,31 @@ const updateSchema = createSchema.partial();
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds);
return res.json({ data: rows });
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, req.user!.tenantId);
// 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); }
}
export async function getById(req: Request, res: Response, next: NextFunction) {
try {
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id));
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id), req.user!.tenantId);
if (!row) return next(new AppError(404, 'Contribuyente no encontrado'));
return res.json(row);
} catch (err) { return next(err); }
@@ -77,6 +95,19 @@ export async function create(req: Request, res: Response, next: NextFunction) {
const row = await contribuyenteService.createContribuyente(req.tenantPool!, data);
// Si se asignó un supervisor, agregar el contribuyente a todas las carteras
// top-level de ese supervisor para que aparezca directamente en su vista.
if (data.supervisorUserId) {
try {
const carteras = await carteraService.listCarteras(req.tenantPool!, data.supervisorUserId);
await Promise.all(
carteras.map(c => carteraService.addEntidadToCartera(req.tenantPool!, c.id, row.id))
);
} catch (err: any) {
console.error('[Contribuyente] Auto-assign to cartera failed (non-blocking):', err.message || err);
}
}
// Ajuste de overage despacho: si el tenant pasa de 100 a 101+ RFCs, crea
// el addon y devuelve paymentUrl para que el frontend redirija al usuario.
// Fail-soft: si falla el addon, el contribuyente queda creado y se loguea.
@@ -139,6 +170,15 @@ export async function addClienteAcceso(req: Request, res: Response, next: NextFu
const { userId } = req.body;
if (!userId || typeof userId !== 'string') return next(new AppError(400, 'userId requerido'));
const entidadId = String(req.params.id);
// Seguridad: supervisor solo puede asignar contribuyentes que supervise
if (req.user!.role === 'supervisor') {
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
if (!visibleIds.includes(entidadId)) {
return next(new AppError(403, 'No tienes acceso a este contribuyente'));
}
}
await req.tenantPool!.query(
'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[userId, entidadId],

View File

@@ -71,7 +71,7 @@ export async function consultarManual(req: Request, res: Response, next: NextFun
// Declaraciones provisionales
// ============================================================================
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar'];
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
function canUpload(req: Request): boolean {
return ROLES_UPLOAD.includes(req.user!.role);
@@ -82,7 +82,7 @@ const createDeclaracionSchema = z.object({
mes: z.number().int().min(1).max(12),
tipo: z.enum(['normal', 'complementaria']),
periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'semestral', 'anual']).optional(),
impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'SUELDOS', 'DIOT', 'OTRO'])).min(1, 'Selecciona al menos un impuesto'),
impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'])).min(1, 'Selecciona al menos un impuesto'),
montoPago: z.number().min(0).optional(),
pdfBase64: z.string().min(100),
pdfFilename: z.string().min(1).max(255),

View File

@@ -8,6 +8,9 @@ import {
downloadPdfContribuyente,
downloadXmlContribuyente,
sendInvoiceByEmailContribuyente,
getCustomizationContribuyente,
uploadLogoContribuyente,
updateColorContribuyente,
} from '../services/contribuyente-facturapi.service.js';
import { parseXml } from '../services/sat/sat-parser.service.js';
import * as tenantsService from '../services/tenants.service.js';
@@ -15,6 +18,7 @@ import { prisma } from '../config/database.js';
import { AppError } from '../middlewares/error.middleware.js';
import { hasPlatformRole } from '../utils/platform-admin.js';
import { auditFromReq } from '../utils/audit.js';
import { recomputarSaldoPendiente } from '../utils/saldo.js';
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
@@ -134,6 +138,17 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
}
}
// ── Validar fecha de emisión (solo I, E, T) ──
const tipo = req.body.type || 'I';
if (tipo !== 'P' && req.body.fechaEmision) {
const fecha = new Date(req.body.fechaEmision);
const now = new Date();
const minDate = new Date(now.getTime() - 72 * 60 * 60 * 1000);
if (isNaN(fecha.getTime()) || fecha > now || fecha < minDate) {
throw new AppError(400, 'La fecha de emisión debe estar entre 72 horas en el pasado y el momento actual');
}
}
// Reservar timbre — si falla emisión en Facturapi, revertimos abajo
const consumedTimbre = await facturapiService.consumeTimbre(tenantId);
@@ -272,6 +287,11 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
contribuyenteId ?? null, xmlString,
]);
// Inicializar saldo pendiente para I/PPD (igual que el flujo SAT)
if (parsed.tipoComprobante === 'I' && parsed.metodoPago === 'PPD' && parsed.uuid) {
await recomputarSaldoPendiente(pool, [parsed.uuid]);
}
// Enviar por email si el receptor tiene email — ruteado a la org correcta
const customerEmail = req.body.customer?.email;
if (customerEmail) {
@@ -325,7 +345,7 @@ export async function cancelar(req: Request, res: Response, next: NextFunction)
try {
const tenantId = effectiveTenantId(req);
const { uuid } = req.params;
const { motive, substitution } = req.body;
const { motive, substitution, contribuyenteId: bodyContribuyenteId } = req.body;
const pool = req.tenantPool!;
const { rows } = await pool.query(
@@ -340,6 +360,12 @@ export async function cancelar(req: Request, res: Response, next: NextFunction)
const facturapiId = rows[0].facturapi_id;
const cfdiContribuyenteId = rows[0].contribuyente_id as string | null;
// En modelo multi-contribuyente: si el caller envía un contribuyenteId,
// solo puede cancelar facturas de ESE contribuyente.
if (bodyContribuyenteId && cfdiContribuyenteId && bodyContribuyenteId !== cfdiContribuyenteId) {
return res.status(403).json({ message: 'No tienes permiso para cancelar esta factura' });
}
const result = cfdiContribuyenteId
? await cancelInvoiceContribuyente(pool, cfdiContribuyenteId, facturapiId, motive || '02', substitution)
: await facturapiService.cancelInvoice(tenantId, facturapiId, motive || '02', substitution);
@@ -454,6 +480,38 @@ export async function updateColor(req: Request, res: Response, next: NextFunctio
} catch (error) { next(error); }
}
// ── Personalización per-contribuyente ──
export async function getCustomizationContribuyenteCtrl(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const data = await getCustomizationContribuyente(req.tenantPool!, contribuyenteId);
res.json(data || {});
} catch (error) { next(error); }
}
export async function uploadLogoContribuyenteCtrl(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const { logo } = req.body;
if (!logo) return res.status(400).json({ message: 'Logo es requerido (base64)' });
const result = await uploadLogoContribuyente(req.tenantPool!, contribuyenteId, logo);
if (!result.success) return res.status(400).json({ message: result.message });
res.json(result);
} catch (error) { next(error); }
}
export async function updateColorContribuyenteCtrl(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const { color } = req.body;
if (!color) return res.status(400).json({ message: 'Color es requerido' });
const result = await updateColorContribuyente(req.tenantPool!, contribuyenteId, color);
if (!result.success) return res.status(400).json({ message: result.message });
res.json(result);
} catch (error) { next(error); }
}
// ── Datos fiscales del tenant ──
// Schema Zod para preferencias de auto-facturación
@@ -522,7 +580,13 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
const params: any[] = [];
if (q.length >= 2) {
params.push(`%${q}%`);
whereSearch = `AND (cc.descripcion ILIKE $1 OR cc.clave_prod_serv ILIKE $1)`;
whereSearch = `AND (cc.descripcion ILIKE $${params.length} OR cc.clave_prod_serv ILIKE $${params.length})`;
}
let whereContribuyente = '';
if (contribuyenteId) {
params.push(contribuyenteId);
whereContribuyente = `AND c.contribuyente_id = $${params.length}`;
}
const { rows } = await pool.query(`
@@ -547,6 +611,7 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
WHERE c.status NOT IN ('Cancelado', '0')
${whereType}
${whereSearch}
${whereContribuyente}
ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC
LIMIT 30
`, params);
@@ -650,7 +715,7 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
const q = (req.query.q as string || '').trim();
if (q.length < 3) return res.json([]);
const contribuyenteId = (req.query.contribuyenteId as string || '').trim();
const contribuyenteId = (req.query.contribuyenteId as string || '').replace(/[^a-f0-9-]/gi, '');
const pool = req.tenantPool!;
// RFC del tenant despacho para excluirlo (no se factura a sí mismo)
@@ -661,10 +726,17 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
});
const tenantRfc = tenant?.rfc || '';
// Búsqueda en el catálogo completo de RFCs. El contribuyente activo solo
// filtra CFDIs relacionados / PPD, no el autocompleto de RFCs — de lo
// contrario no se podría facturar a un cliente nuevo que nunca haya
// aparecido en un CFDI previo.
const params: any[] = [tenantRfc, `%${q}%`];
let whereContribuyente = '';
if (contribuyenteId) {
params.push(contribuyenteId);
whereContribuyente = `AND id IN (
SELECT rfc_receptor_id FROM cfdis WHERE contribuyente_id = $${params.length} AND rfc_receptor_id IS NOT NULL
UNION
SELECT rfc_emisor_id FROM cfdis WHERE contribuyente_id = $${params.length} AND rfc_emisor_id IS NOT NULL
)`;
}
const { rows } = await pool.query(`
SELECT id, rfc, razon_social as "razonSocial",
regimen_fiscal as "regimenFiscal",
@@ -672,9 +744,10 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
FROM rfcs
WHERE rfc != $1
AND (rfc ILIKE $2 OR razon_social ILIKE $2)
${whereContribuyente}
ORDER BY razon_social
LIMIT 10
`, [tenantRfc, `%${q}%`]);
`, params);
res.json(rows);
} catch (error) { next(error); }

View File

@@ -4,15 +4,10 @@ import { AppError } from '../middlewares/error.middleware.js';
import * as papeleriaService from '../services/papeleria.service.js';
import { emailService } from '../services/email/email.service.js';
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
import { env } from '../config/env.js';
import { prisma } from '../config/database.js';
function rejectClienteRole(req: Request): void {
if (req.user?.role === 'cliente') {
throw new AppError(403, 'Papelería no disponible para usuarios cliente');
}
}
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
@@ -24,6 +19,7 @@ const uploadSchema = z.object({
anio: z.number().int().min(2000).max(2100),
mes: z.number().int().min(1).max(12),
requiereAprobacion: z.boolean(),
requiereAprobacionCliente: z.boolean(),
archivoBase64: z.string().min(1),
archivoFilename: z.string().min(1).max(255),
archivoMime: z.string().min(1).max(100),
@@ -31,7 +27,9 @@ const uploadSchema = z.object({
export async function upload(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
if (req.user?.role === 'cliente') {
throw new AppError(403, 'Los clientes no pueden subir documentos de papelería');
}
const data = uploadSchema.parse(req.body);
const archivo = Buffer.from(data.archivoBase64, 'base64');
@@ -42,18 +40,23 @@ export async function upload(req: Request, res: Response, next: NextFunction) {
anio: data.anio,
mes: data.mes,
requiereAprobacion: data.requiereAprobacion,
requiereAprobacionCliente: data.requiereAprobacionCliente,
archivo,
archivoFilename: data.archivoFilename,
archivoMime: data.archivoMime,
subidoPor: req.user!.userId,
});
// Notificación a aprobadores si la papelería requiere aprobación.
if (item.requiereAprobacion) {
notifyAprobacionRequerida(req, item).catch(err =>
console.error('[papeleria.upload] notify aprobadores failed:', err?.message || err),
);
}
if (item.requiereAprobacionCliente) {
notifyClienteAprobacionRequerida(req, item).catch(err =>
console.error('[papeleria.upload] notify clientes failed:', err?.message || err),
);
}
res.status(201).json(item);
} catch (error: any) {
@@ -74,13 +77,20 @@ const listSchema = z.object({
export async function list(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const q = listSchema.parse(req.query);
const entidadIds = await getEntidadesVisibles(
req.tenantPool!, req.user!.userId, req.user!.role,
);
if (!entidadIds.includes(q.contribuyenteId)) {
return res.json([]);
}
const items = await papeleriaService.listPapeleria(req.tenantPool!, {
contribuyenteId: q.contribuyenteId,
anio: q.anio ? parseInt(q.anio, 10) : undefined,
mes: q.mes ? parseInt(q.mes, 10) : undefined,
estado: q.estado,
entidadIds,
userRole: req.user!.role,
});
res.json(items);
} catch (error) {
@@ -91,9 +101,22 @@ export async function list(req: Request, res: Response, next: NextFunction) {
export async function download(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const item = await papeleriaService.getById(req.tenantPool!, id);
if (!item) return next(new AppError(404, 'Documento no encontrado'));
const entidadIds = await getEntidadesVisibles(
req.tenantPool!, req.user!.userId, req.user!.role,
);
if (!entidadIds.includes(item.contribuyenteId)) {
return next(new AppError(403, 'No tienes acceso a este documento'));
}
if (req.user!.role === 'cliente' && !item.requiereAprobacionCliente) {
return next(new AppError(403, 'No tienes acceso a este documento'));
}
const file = await papeleriaService.downloadArchivo(req.tenantPool!, id);
if (!file) return next(new AppError(404, 'Documento no encontrado'));
res.setHeader('Content-Type', file.mime);
@@ -106,7 +129,9 @@ export async function download(req: Request, res: Response, next: NextFunction)
export async function aprobar(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
if (req.user?.role === 'cliente') {
throw new AppError(403, 'Los clientes no pueden usar este endpoint');
}
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const item = await papeleriaService.aprobar(
@@ -127,7 +152,9 @@ const rechazarSchema = z.object({ comentario: z.string().max(2000).nullable().op
export async function rechazar(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
if (req.user?.role === 'cliente') {
throw new AppError(403, 'Los clientes no pueden usar este endpoint');
}
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const { comentario } = rechazarSchema.parse(req.body);
@@ -146,9 +173,63 @@ export async function rechazar(req: Request, res: Response, next: NextFunction)
}
}
export async function aprobarCliente(req: Request, res: Response, next: NextFunction) {
try {
if (req.user?.role !== 'cliente') {
throw new AppError(403, 'Solo clientes pueden usar este endpoint');
}
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const entidadIds = await getEntidadesVisibles(
req.tenantPool!, req.user!.userId, req.user!.role,
);
const itemCheck = await papeleriaService.getById(req.tenantPool!, id);
if (!itemCheck || !entidadIds.includes(itemCheck.contribuyenteId) || !itemCheck.requiereAprobacionCliente) {
return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
}
const item = await papeleriaService.aprobarCliente(req.tenantPool!, id, req.user!.userId);
if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
res.json(item);
} catch (error) {
next(error);
}
}
export async function rechazarCliente(req: Request, res: Response, next: NextFunction) {
try {
if (req.user?.role !== 'cliente') {
throw new AppError(403, 'Solo clientes pueden usar este endpoint');
}
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const { comentario } = rechazarSchema.parse(req.body);
const entidadIds = await getEntidadesVisibles(
req.tenantPool!, req.user!.userId, req.user!.role,
);
const itemCheck = await papeleriaService.getById(req.tenantPool!, id);
if (!itemCheck || !entidadIds.includes(itemCheck.contribuyenteId) || !itemCheck.requiereAprobacionCliente) {
return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
}
const item = await papeleriaService.rechazarCliente(
req.tenantPool!, id, req.user!.userId, comentario ?? null,
);
if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
res.json(item);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function eliminar(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
if (req.user?.role === 'cliente') {
throw new AppError(403, 'Los clientes no pueden eliminar documentos');
}
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const ok = await papeleriaService.eliminar(req.tenantPool!, id);
@@ -161,22 +242,26 @@ export async function eliminar(req: Request, res: Response, next: NextFunction)
// ─── Notificaciones ───
async function getContribuyenteInfo(req: Request, contribuyenteId: string) {
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
`SELECT c.rfc, eg.nombre FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE c.entidad_id = $1`,
[contribuyenteId],
);
return rows[0] ?? null;
}
/**
* Notifica a owners y supervisores cuando una papelería requiere aprobación.
* Owners se obtienen de tenant_memberships (BD central). Supervisores se
* resuelven leyendo carteras del tenant.
*/
async function notifyAprobacionRequerida(
req: Request,
item: papeleriaService.PapeleriaItem,
): Promise<void> {
const tenantId = effectiveTenantId(req);
// Owners del despacho
const recipients = new Set<string>(await getTenantOwnerEmails(tenantId));
// Supervisores: cualquier user con rol 'supervisor' o 'cfo' que pertenezca a este tenant.
// Buscamos vía tenant_memberships + roles.
const supervisores = await prisma.tenantMembership.findMany({
where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } },
include: { user: { select: { email: true, active: true } } },
@@ -185,23 +270,15 @@ async function notifyAprobacionRequerida(
if (m.user.active && m.user.email) recipients.add(m.user.email);
}
// No notificarse a sí mismo
recipients.delete(req.user!.email);
if (recipients.size === 0) return;
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { nombre: true },
});
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
`SELECT c.rfc, eg.nombre FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE c.entidad_id = $1`,
[item.contribuyenteId],
);
if (rows.length === 0) return;
const info = await getContribuyenteInfo(req, item.contribuyenteId);
if (!info) return;
const link = `${env.FRONTEND_URL}/documentos`;
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
@@ -210,8 +287,8 @@ async function notifyAprobacionRequerida(
for (const to of recipients) {
try {
await emailService.sendPapeleriaAprobacionRequerida(to, {
contribuyenteRfc: rows[0].rfc,
contribuyenteNombre: rows[0].nombre,
contribuyenteRfc: info.rfc,
contribuyenteNombre: info.nombre,
despachoNombre: tenant?.nombre,
nombreDocumento: item.nombre,
descripcion: item.descripcion,
@@ -226,9 +303,7 @@ async function notifyAprobacionRequerida(
}
/**
* Notifica al uploader (auxiliar) cuando un documento que él subió fue
* aprobado o rechazado. Solo manda si quien aprobó/rechazó NO es el mismo
* uploader (caso edge: owner sube su propia papelería).
* Notifica al uploader cuando un documento fue aprobado o rechazado por owner/supervisor.
*/
async function notifyDecisionAuxiliar(
req: Request,
@@ -238,21 +313,16 @@ async function notifyDecisionAuxiliar(
const auxiliarEmail = await getUserEmailById(item.subidoPor);
if (!auxiliarEmail) return;
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
`SELECT c.rfc, eg.nombre FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE c.entidad_id = $1`,
[item.contribuyenteId],
);
if (rows.length === 0) return;
const info = await getContribuyenteInfo(req, item.contribuyenteId);
if (!info) return;
const link = `${env.FRONTEND_URL}/documentos`;
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
await emailService.sendPapeleriaDecision(auxiliarEmail, {
contribuyenteRfc: rows[0].rfc,
contribuyenteNombre: rows[0].nombre,
contribuyenteRfc: info.rfc,
contribuyenteNombre: info.nombre,
nombreDocumento: item.nombre,
estado: item.estado as 'aprobado' | 'rechazado',
revisor: req.user!.email,
@@ -261,3 +331,57 @@ async function notifyDecisionAuxiliar(
link,
});
}
/**
* Notifica a los usuarios cliente asociados al contribuyente cuando un documento
* requiere su aprobación.
*/
async function notifyClienteAprobacionRequerida(
req: Request,
item: papeleriaService.PapeleriaItem,
): Promise<void> {
const tenantId = effectiveTenantId(req);
// Obtener user_ids de clientes con acceso a este contribuyente
const { rows } = await req.tenantPool!.query<{ user_id: string }>(
`SELECT user_id FROM cliente_accesos WHERE entidad_id = $1`,
[item.contribuyenteId],
);
if (rows.length === 0) return;
const userIds = rows.map(r => r.user_id);
const users = await prisma.user.findMany({
where: { id: { in: userIds }, active: true },
select: { email: true },
});
const recipients = users.map(u => u.email).filter(Boolean) as string[];
if (recipients.length === 0) return;
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { nombre: true },
});
const info = await getContribuyenteInfo(req, item.contribuyenteId);
if (!info) return;
const link = `${env.FRONTEND_URL}/documentos`;
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
for (const to of recipients) {
try {
await emailService.sendPapeleriaAprobacionClienteRequerida(to, {
contribuyenteRfc: info.rfc,
contribuyenteNombre: info.nombre,
despachoNombre: tenant?.nombre,
nombreDocumento: item.nombre,
descripcion: item.descripcion,
periodo,
subidoPor: req.user!.email,
link,
});
} catch (err: any) {
console.error(`[Email] papeleria-aprobacion-cliente a ${to}:`, err?.message || err);
}
}
}

View File

@@ -2,6 +2,7 @@ import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { AppError } from '../middlewares/error.middleware.js';
import * as tareasService from '../services/tareas.service.js';
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
import { emailService } from '../services/email/email.service.js';
import { getUserEmailById } from '../utils/memberships.js';
import { env } from '../config/env.js';
@@ -164,6 +165,17 @@ export async function descompletarPeriodo(req: Request, res: Response, next: Nex
}
}
export async function listMisTareas(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const entidadIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
const tareas = await tareasService.listTareasConPeriodoPorContribuyentes(req.tenantPool!, entidadIds);
res.json(tareas);
} catch (error) {
next(error);
}
}
export async function seedDefaults(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import * as usuariosService from '../services/usuarios.service.js';
import { AppError } from '../middlewares/error.middleware.js';
import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js';
import { prisma } from '../config/database.js';
const inviteSchema = z.object({
email: z.string().email('email inválido'),
@@ -64,11 +65,16 @@ export async function getAllUsuarios(req: Request, res: Response, next: NextFunc
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'owner') {
throw new AppError(403, 'Solo los dueños pueden invitar usuarios');
if (!['owner', 'cfo', 'supervisor'].includes(req.user!.role)) {
throw new AppError(403, 'No autorizado para invitar usuarios');
}
const data = inviteSchema.parse(req.body);
// Los supervisores solo pueden invitar clientes
if (req.user!.role === 'supervisor' && data.role !== 'cliente') {
throw new AppError(403, 'Los supervisores solo pueden invitar clientes');
}
// Validate: auxiliar requires a supervisor
if (data.role === 'auxiliar' && !data.supervisorUserId) {
throw new AppError(400, 'Debes asignar un supervisor al auxiliar');
@@ -139,7 +145,16 @@ export async function getSupervisor(req: Request, res: Response, next: NextFunct
LIMIT 1`,
[userId],
);
res.json({ supervisorUserId: rows[0]?.supervisor_user_id ?? null });
const supervisorUserId = rows[0]?.supervisor_user_id ?? null;
let supervisorNombre: string | null = null;
if (supervisorUserId) {
const u = await prisma.user.findUnique({
where: { id: supervisorUserId },
select: { nombre: true },
});
supervisorNombre = u?.nombre ?? null;
}
res.json({ supervisorUserId, supervisorNombre });
} catch (error) {
next(error);
}

View File

@@ -10,6 +10,21 @@ import { despachoPlanTieneDualidadDb } from '../services/plan-catalogo.service.j
import { emailService } from '../services/email/email.service.js';
import { getTenantOwnerEmail } from '../utils/memberships.js';
/**
* Calcula la siguiente fecha de fin de período según la frecuencia.
* Usa el mismo algoritmo que Mercado Pago: mismo día del mes siguiente,
* ajustando al último día si el mes destino tiene menos días.
*/
function computeNextPeriodEnd(date: Date, frequency: string): Date {
const d = new Date(date);
if (frequency === 'monthly') {
d.setMonth(d.getMonth() + 1);
} else if (frequency === 'annual' || frequency === 'yearly') {
d.setFullYear(d.getFullYear() + 1);
}
return d;
}
export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) {
try {
const { type, data } = req.body;
@@ -187,9 +202,20 @@ async function handlePaymentNotification(paymentId: string) {
// precio de renewal. Se detecta comparando el monto cobrado contra lo que
// `getPlanPrice(phase='firstYear')` devolvería para este plan.
const esPrimerPago = subscription.status === 'pending';
const updateData: { status: string; currentPeriodEnd?: Date } = { status: 'authorized' };
// Extender currentPeriodEnd para renovaciones recurrentes.
// El primer pago ya tiene currentPeriodEnd establecido al crear la suscripción;
// solo extendemos en pagos subsecuentes para reflejar el nuevo período cobrado.
if (!esPrimerPago && subscription.currentPeriodEnd) {
const nextPeriodEnd = computeNextPeriodEnd(subscription.currentPeriodEnd, subscription.frequency);
updateData.currentPeriodEnd = nextPeriodEnd;
console.log(`[WEBHOOK] Subscription ${subscription.id} extended to ${nextPeriodEnd.toISOString()} (${subscription.frequency})`);
}
await prisma.subscription.update({
where: { id: subscription.id },
data: { status: 'authorized' },
data: updateData,
});
subscriptionService.invalidateSubscriptionCache(tenantId);

View File

@@ -9,8 +9,10 @@ import { resetExpiredMonthlyTimbres } from '../services/facturapi.service.js';
import { purgeDeclaracionesAntiguas } from '../services/declaraciones.service.js';
import { consultarConstancia, purgeConstanciasAntiguas } from '../services/constancia.service.js';
import { tenantDb } from '../config/database.js';
import type { Pool } from 'pg';
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días
const RECOVERY_CRON_SCHEDULE = '0 10 * * *'; // 10:00 AM todos los días
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM
const CSF_CRON_SCHEDULE = '0 4 1 * *'; // Día 1 de cada mes 04:00 AM (CSF mensual)
@@ -20,6 +22,38 @@ const EXPIRY_REMINDERS_CRON = '0 9 * * *'; // 9:00 AM diario — avisos p
let isRunning = false;
let isIncrementalRunning = false;
let isRecoveryRunning = false;
/**
* Verifica si un tenant tiene FIEL a nivel tenant (legacy Horux 360)
* o a nivel contribuyente (modelo despacho).
*/
async function hasAnyFielConfigured(tenantId: string, databaseName?: string | null): Promise<boolean> {
// 1) FIEL legacy a nivel tenant
const hasLegacy = await hasFielConfigured(tenantId);
if (hasLegacy) return true;
// 2) FIEL por contribuyente (modelo despacho)
if (!databaseName) {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { databaseName: true },
});
databaseName = tenant?.databaseName;
}
if (!databaseName) return false;
try {
const pool = await tenantDb.getPool(tenantId, databaseName);
const { rows } = await pool.query(
`SELECT 1 FROM fiel_contribuyente WHERE is_active = true LIMIT 1`
);
return rows.length > 0;
} catch (err: any) {
console.error(`[SAT Cron] Error verificando FIEL contribuyente para tenant ${tenantId}:`, err.message);
return false;
}
}
/**
* Obtiene los tenants que tienen FIEL configurada y activa
@@ -27,13 +61,13 @@ let isIncrementalRunning = false;
async function getTenantsWithFiel(): Promise<string[]> {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true },
select: { id: true, databaseName: true },
});
const tenantsWithFiel: string[] = [];
for (const tenant of tenants) {
const hasFiel = await hasFielConfigured(tenant.id);
const hasFiel = await hasAnyFielConfigured(tenant.id, tenant.databaseName);
if (hasFiel) {
tenantsWithFiel.push(tenant.id);
}
@@ -172,12 +206,12 @@ async function getTenantsConSatIncremental(): Promise<string[]> {
const tenants = await prisma.tenant.findMany({
where: { active: true, plan: { in: planNames as any } },
select: { id: true },
select: { id: true, databaseName: true },
});
const result: string[] = [];
for (const tenant of tenants) {
if (await hasFielConfigured(tenant.id)) {
if (await hasAnyFielConfigured(tenant.id, tenant.databaseName)) {
result.push(tenant.id);
}
}
@@ -351,12 +385,153 @@ async function runCsfJob(): Promise<void> {
console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message);
failed++;
}
// Delay entre tenants para no saturar al SAT y reducir bloqueos por IP
await new Promise(r => setTimeout(r, 30_000));
}
console.log(`[CSF Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`);
}
function getYesterdayEnd(): Date {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59);
}
async function hasIncompleteCfdis(pool: Pool, contribuyenteId: string): Promise<boolean> {
const { rows } = await pool.query<{ count: string }>(`
SELECT COUNT(*)::text as count
FROM cfdis
WHERE contribuyente_id = $1
AND status = 'Vigente'
AND tipo_comprobante IN ('I', 'E')
AND xml_original IS NULL
`, [contribuyenteId]);
return Number(rows[0]?.count || 0) > 0;
}
async function getOldestIncompleteCfdiDate(pool: Pool, contribuyenteId: string): Promise<Date | null> {
const { rows } = await pool.query<{ fecha_emision: Date | null }>(`
SELECT MIN(fecha_emision) as fecha_emision
FROM cfdis
WHERE contribuyente_id = $1
AND status = 'Vigente'
AND tipo_comprobante IN ('I', 'E')
AND xml_original IS NULL
`, [contribuyenteId]);
return rows[0]?.fecha_emision || null;
}
async function waitForRecoveryJob(jobId: string): Promise<void> {
while (true) {
const job = await prisma.satSyncJob.findUnique({ where: { id: jobId } });
if (!job || job.status === 'completed' || job.status === 'failed') {
return;
}
await new Promise(resolve => setTimeout(resolve, 60000));
}
}
async function recoverContribuyente(tenantId: string, databaseName: string, contribuyenteId: string): Promise<void> {
try {
const status = await getSyncStatus(tenantId, contribuyenteId);
if (status.hasActiveSync) {
console.log(`[SAT Recovery] ${contribuyenteId} tiene sync activo, omitiendo`);
return;
}
const pool = await tenantDb.getPool(tenantId, databaseName);
const hasIncomplete = await hasIncompleteCfdis(pool, contribuyenteId);
const lastDaily = await prisma.satSyncJob.findFirst({
where: { tenantId, contribuyenteId, type: 'daily' },
orderBy: { startedAt: 'desc' },
});
if (!hasIncomplete && lastDaily?.status !== 'failed') {
return;
}
const dateTo = getYesterdayEnd();
let dateFrom = new Date(dateTo.getFullYear() - 1, dateTo.getMonth(), dateTo.getDate());
if (hasIncomplete) {
const oldest = await getOldestIncompleteCfdiDate(pool, contribuyenteId);
if (oldest) {
dateFrom = new Date(oldest.getFullYear(), oldest.getMonth(), 1);
dateFrom.setMonth(dateFrom.getMonth() - 1);
}
}
console.log(`[SAT Recovery] Recuperando ${contribuyenteId}: ${dateFrom.toISOString()}${dateTo.toISOString()}`);
const jobId = await startSync(tenantId, 'initial', dateFrom, dateTo, contribuyenteId);
console.log(`[SAT Recovery] Job ${jobId} iniciado`);
await waitForRecoveryJob(jobId);
} catch (error: any) {
console.error(`[SAT Recovery] Error recuperando ${contribuyenteId}:`, error.message);
}
}
async function recoverTenant(tenantId: string): Promise<void> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { databaseName: true },
});
if (!tenant?.databaseName) return;
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
const { rows } = await pool.query<{ entidad_id: string }>('SELECT entidad_id FROM contribuyentes');
const contribuyenteIds = rows.map(r => r.entidad_id);
if (contribuyenteIds.length === 0) {
const status = await getSyncStatus(tenantId);
if (status.hasActiveSync) return;
const lastDaily = await prisma.satSyncJob.findFirst({
where: { tenantId, contribuyenteId: null, type: 'daily' },
orderBy: { startedAt: 'desc' },
});
if (lastDaily?.status === 'failed') {
const dateTo = getYesterdayEnd();
const dateFrom = new Date(dateTo.getFullYear() - 1, dateTo.getMonth(), dateTo.getDate());
console.log(`[SAT Recovery] Recuperando tenant legacy ${tenantId}`);
const jobId = await startSync(tenantId, 'initial', dateFrom, dateTo);
await waitForRecoveryJob(jobId);
}
return;
}
for (const contribuyenteId of contribuyenteIds) {
await recoverContribuyente(tenantId, tenant.databaseName, contribuyenteId);
}
}
async function runRecoverySyncJob(): Promise<void> {
if (isRecoveryRunning) {
console.log('[SAT Recovery] Ya en ejecución, omitiendo');
return;
}
isRecoveryRunning = true;
console.log('[SAT Recovery] Iniciando job de recuperación');
try {
const tenantIds = await getTenantsWithFiel();
console.log(`[SAT Recovery] ${tenantIds.length} tenants con FIEL`);
for (const tenantId of tenantIds) {
await recoverTenant(tenantId);
}
console.log('[SAT Recovery] Job de recuperación completado');
} catch (error: any) {
console.error('[SAT Recovery] Error:', error.message);
} finally {
isRecoveryRunning = false;
}
}
let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
let retryTask: ReturnType<typeof cron.schedule> | null = null;
let recoveryTask: ReturnType<typeof cron.schedule> | null = null;
let opinionTask: ReturnType<typeof cron.schedule> | null = null;
let csfTask: ReturnType<typeof cron.schedule> | null = null;
let incrementalTask: ReturnType<typeof cron.schedule> | null = null;
@@ -397,6 +572,19 @@ export function startSatSyncJob(): void {
timezone: 'America/Mexico_City',
});
// Cron de recuperación: 10:00 AM diario. Revisa si el sync diario falló o si
// hay CFDIs vigentes sin XML, y relanza un sync `initial` con rango extendido
// para completar los XML faltantes.
recoveryTask = cron.schedule(RECOVERY_CRON_SCHEDULE, async () => {
try {
await runRecoverySyncJob();
} catch (error: any) {
console.error('[SAT Recovery Cron] Error:', error.message);
}
}, {
timezone: 'America/Mexico_City',
});
// Cron watchdog: cada 2h marca como `failed` los jobs que quedaron stale
// (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás).
// Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS)
@@ -502,6 +690,7 @@ export function startSatSyncJob(): void {
console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[SAT Cron] Retry programado cada hora`);
console.log(`[SAT Recovery Cron] Programado para: ${RECOVERY_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[CSF Cron] Programado para: ${CSF_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[SAT Cron Inc] Incremental Enterprise programado para: ${INCREMENTAL_CRON_SCHEDULE} (America/Mexico_City)`);
@@ -521,6 +710,10 @@ export function stopSatSyncJob(): void {
retryTask.stop();
retryTask = null;
}
if (recoveryTask) {
recoveryTask.stop();
recoveryTask = null;
}
if (opinionTask) {
opinionTask.stop();
opinionTask = null;

View File

@@ -118,6 +118,10 @@ CREATE TABLE IF NOT EXISTS cfdis (
facturapi_id VARCHAR(50),
regimen_fiscal_emisor VARCHAR(3),
regimen_fiscal_receptor VARCHAR(3),
periodicidad VARCHAR(2),
meses_global VARCHAR(10),
año_global VARCHAR(4),
fecha_efectiva DATE,
creado_en TIMESTAMP DEFAULT NOW(),
actualizado_en TIMESTAMP DEFAULT NOW()
);

View File

@@ -0,0 +1,11 @@
-- Migration: 007_factura_global
-- Description: Agrega campos de InformacionGlobal y fecha_efectiva para facturas globales
ALTER TABLE cfdis
ADD COLUMN IF NOT EXISTS periodicidad VARCHAR(2),
ADD COLUMN IF NOT EXISTS meses_global VARCHAR(10),
ADD COLUMN IF NOT EXISTS año_global VARCHAR(4),
ADD COLUMN IF NOT EXISTS fecha_efectiva DATE;
-- Crear índice para acelerar métricas que filtran por fecha_efectiva
CREATE INDEX IF NOT EXISTS idx_cfdis_fecha_efectiva ON cfdis(fecha_efectiva);

View File

@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS obligacion_asignaciones (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
obligacion_id uuid NOT NULL REFERENCES obligaciones_contribuyente(id) ON DELETE CASCADE,
auxiliar_user_id uuid NOT NULL,
asignado_por uuid NOT NULL,
asignado_at timestamptz DEFAULT now(),
UNIQUE (obligacion_id)
);
CREATE TABLE IF NOT EXISTS tarea_asignaciones (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tarea_id uuid NOT NULL REFERENCES tareas_catalogo(id) ON DELETE CASCADE,
auxiliar_user_id uuid NOT NULL,
asignado_por uuid NOT NULL,
asignado_at timestamptz DEFAULT now(),
UNIQUE (tarea_id)
);
CREATE INDEX IF NOT EXISTS idx_obligacion_asignaciones_auxiliar ON obligacion_asignaciones(auxiliar_user_id);
CREATE INDEX IF NOT EXISTS idx_tarea_asignaciones_auxiliar ON tarea_asignaciones(auxiliar_user_id);

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

@@ -2,6 +2,7 @@ import { Router, type IRouter } from 'express';
import { authenticate, authorize } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as ctrl from '../controllers/cartera.controller.js';
import * as asignacionesCtrl from '../controllers/asignaciones.controller.js';
const router: IRouter = Router();
@@ -11,6 +12,12 @@ router.use(tenantMiddleware);
// Static routes first
router.get('/supervisores', authorize('owner'), ctrl.getSupervisores);
// Asignaciones de obligaciones/tareas a auxiliares (antes de /:id para evitar match dinámico)
router.get('/asignaciones', authorize('owner', 'supervisor'), asignacionesCtrl.listPorSupervisor);
router.get('/asignaciones/mias', authorize('auxiliar'), asignacionesCtrl.listPorAuxiliar);
router.get('/asignaciones/sin-asignar', authorize('owner', 'supervisor'), asignacionesCtrl.listSinAsignar);
router.get('/asignaciones/auxiliares-elegibles/:contribuyenteId', authorize('owner', 'supervisor'), asignacionesCtrl.listAuxiliaresElegibles);
// Read: owner + supervisor + auxiliar
router.get('/', authorize('owner', 'supervisor', 'auxiliar'), ctrl.list);
router.get('/:id', authorize('owner', 'supervisor', 'auxiliar'), ctrl.getById);

View File

@@ -23,6 +23,7 @@ router.get('/conceptos', cfdiController.listConceptos);
router.get('/:id', cfdiController.getCfdiById);
router.get('/:id/conceptos', cfdiController.getConceptos);
router.get('/:id/xml', cfdiController.getXml);
router.post('/download-xmls', cfdiController.downloadXmlsZip);
router.post('/', checkCfdiLimit, cfdiController.createCfdi);
// Bulk upload: 10/hora — procesa hasta 50MB, pesado en parseo + inserts
router.post('/bulk', strictLimit, express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis);

View File

@@ -3,7 +3,9 @@ import { authenticate, authorize } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as ctrl from '../controllers/contribuyente.controller.js';
import * as configCtrl from '../controllers/contribuyente-config.controller.js';
import * as facturacionCtrl from '../controllers/facturacion.controller.js';
import * as obligacionesCtrl from '../controllers/obligaciones.controller.js';
import * as asignacionesCtrl from '../controllers/asignaciones.controller.js';
const router: IRouter = Router();
@@ -12,7 +14,7 @@ router.use(tenantMiddleware);
// === Static routes FIRST (before /:id to avoid route conflict) ===
router.get('/', ctrl.list);
router.post('/', authorize('owner', 'cfo'), ctrl.create);
router.post('/', authorize('owner', 'cfo', 'supervisor'), ctrl.create);
router.post('/backfill', authorize('owner'), ctrl.backfill);
router.get('/catalogo-obligaciones', obligacionesCtrl.getCatalogo);
@@ -23,25 +25,34 @@ router.delete('/:id', authorize('owner'), ctrl.deactivate);
router.post('/:id/cliente-acceso', authorize('owner', 'supervisor'), ctrl.addClienteAcceso);
// FIEL per contribuyente
router.post('/:id/fiel', authorize('owner', 'cfo'), configCtrl.uploadFiel);
router.post('/:id/fiel', authorize('owner', 'cfo', 'supervisor'), configCtrl.uploadFiel);
router.get('/:id/fiel/status', configCtrl.fielStatus);
router.delete('/:id/fiel', authorize('owner', 'cfo'), configCtrl.deleteFiel);
router.delete('/:id/fiel', authorize('owner', 'cfo', 'supervisor'), configCtrl.deleteFiel);
// Facturapi per contribuyente
router.post('/:id/facturapi/org', authorize('owner', 'cfo'), configCtrl.createOrg);
router.get('/:id/facturapi/status', configCtrl.orgStatus);
router.post('/:id/facturapi/csd', authorize('owner', 'cfo'), configCtrl.uploadCsd);
router.post('/:id/facturapi/csd', authorize('owner', 'cfo', 'supervisor'), configCtrl.uploadCsd);
// Personalización per contribuyente
router.get('/:id/facturapi/customization', facturacionCtrl.getCustomizationContribuyenteCtrl);
router.post('/:id/facturapi/logo', authorize('owner', 'cfo'), facturacionCtrl.uploadLogoContribuyenteCtrl);
router.put('/:id/facturapi/color', authorize('owner', 'cfo'), facturacionCtrl.updateColorContribuyenteCtrl);
// Obligaciones fiscales per contribuyente
router.get('/:id/obligaciones/periodo', obligacionesCtrl.getObligacionesPorPeriodo);
router.get('/:id/obligaciones', obligacionesCtrl.getObligaciones);
router.post('/:id/obligaciones/init', authorize('owner', 'cfo'), obligacionesCtrl.initRecomendaciones);
router.post('/:id/obligaciones', authorize('owner', 'cfo'), obligacionesCtrl.addObligacion);
router.delete('/:id/obligaciones/:obligacionId', authorize('owner', 'cfo'), obligacionesCtrl.removeObligacion);
router.post('/:id/obligaciones/:obligacionId/restore', authorize('owner', 'cfo'), obligacionesCtrl.restoreObligacion);
router.post('/:id/obligaciones/init', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.initRecomendaciones);
router.post('/:id/obligaciones', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.addObligacion);
router.delete('/:id/obligaciones/:obligacionId', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.removeObligacion);
router.post('/:id/obligaciones/:obligacionId/restore', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.restoreObligacion);
router.post('/:id/obligaciones/:obligacionId/complete', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completeObligacion);
router.post('/:id/obligaciones/:obligacionId/uncomplete', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompleteObligacion);
router.post('/:id/obligaciones/:obligacionId/complete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completePeriodo);
router.post('/:id/obligaciones/:obligacionId/uncomplete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompletePeriodo);
// Asignación de obligaciones a auxiliares (supervisor/owner)
router.post('/:id/obligaciones/:obligacionId/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.asignarObligacion);
router.delete('/:id/obligaciones/:obligacionId/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.desasignarObligacion);
export default router;

View File

@@ -13,6 +13,8 @@ router.post('/', ctrl.upload);
router.get('/:id/download', ctrl.download);
router.post('/:id/aprobar', ctrl.aprobar);
router.post('/:id/rechazar', ctrl.rechazar);
router.post('/:id/aprobar-cliente', ctrl.aprobarCliente);
router.post('/:id/rechazar-cliente', ctrl.rechazarCliente);
router.delete('/:id', ctrl.eliminar);
export { router as papeleriaRoutes };

View File

@@ -1,13 +1,15 @@
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { authenticate, authorize } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as ctrl from '../controllers/tareas.controller.js';
import * as asignacionesCtrl from '../controllers/asignaciones.controller.js';
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.get('/mis-tareas', ctrl.listMisTareas);
router.get('/', ctrl.listTareas);
router.post('/', ctrl.createTarea);
router.post('/seed', ctrl.seedDefaults);
@@ -17,4 +19,8 @@ router.delete('/:id', ctrl.deleteTarea);
router.post('/periodo/:id/completar', ctrl.completarPeriodo);
router.delete('/periodo/:id/completar', ctrl.descompletarPeriodo);
// Asignación de tareas a auxiliares (supervisor/owner)
router.post('/:id/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.asignarTarea);
router.delete('/:id/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.desasignarTarea);
export { router as tareasRoutes };

View File

@@ -0,0 +1,23 @@
import { tenantDb } from '../config/database.js';
import { computeMetricaMensual } from '../services/metricas-compute.service.js';
async function main() {
const tenantId = 'c52c2f5d-b1ae-45c6-8cc8-b11c9611618a';
const dbName = 'horux_hts240708lja';
const contribuyenteId = '4a1d6014-f705-424b-b185-7740be6a80c6';
const pool = await tenantDb.getPool(tenantId, dbName);
for (const mes of [1, 2, 3]) {
console.log(`Recalculando 2026-${String(mes).padStart(2, '0')}...`);
const r = await computeMetricaMensual(pool, tenantId, contribuyenteId, 2026, mes);
console.log(` Filas escritas: ${r.filasEscritas}`);
}
await pool.end();
process.exit(0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -24,44 +24,62 @@
* el de activos aplica también pero algunos predicados son no-op funcional
* en subqueries que filtran por tipo_comprobante específico (Postgres los
* optimiza away).
*
* OPTIMIZACIÓN: los subqueries de exclusiones de activos se reescribieron
* para usar subqueries NO-correlacionados donde sea posible (casos 1-3).
* Esto permite a PostgreSQL ejecutar el subquery una sola vez por query
* principal, en lugar de una vez por cada fila. Solo el caso 4 (anticipo
* referenciado por I07) requiere un correlated EXISTS.
*/
const ACTIVOS_USOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
/**
* Subquery no-correlacionado que devuelve todos los UUIDs de facturas tipo I
* con uso de activo. Usado para lookups P→I y E→I.
*/
const UUIDS_ACTIVOS = `SELECT LOWER(uuid) AS uuid FROM cfdis WHERE tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS}`;
/**
* Subquery no-correlacionado que devuelve todos los UUIDs de E's que
* referencian un activo (directamente I-activo, o indirectamente P→I-activo).
*
* Usa JOIN + UNION en lugar de EXISTS + OR para que PostgreSQL pueda usar
* índices de forma más efectiva (especialmente el GIN en cfdis_relacionados).
*/
const UUIDS_E_DE_ACTIVOS = `
SELECT e.uuid
FROM cfdis e
JOIN cfdis r_act ON LOWER(r_act.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
WHERE e.tipo_comprobante = 'E'
AND e.cfdis_relacionados IS NOT NULL
AND r_act.tipo_comprobante = 'I'
AND r_act.uso_cfdi IN ${ACTIVOS_USOS}
UNION ALL
SELECT e.uuid
FROM cfdis e
JOIN cfdis r_act ON LOWER(r_act.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
JOIN cfdis pi_act ON LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
WHERE e.tipo_comprobante = 'E'
AND e.cfdis_relacionados IS NOT NULL
AND r_act.tipo_comprobante = 'P'
AND pi_act.tipo_comprobante = 'I'
AND pi_act.uso_cfdi IN ${ACTIVOS_USOS}
`;
/**
* Predicado SQL que detecta si el row actual (sin alias de tabla, asume
* `FROM cfdis`) referencia un activo directamente (I), indirectamente vía
* pago (P→I), o transitivamente vía relación (E→I, E→P→I).
*
* IMPORTANTE — qualifying outer refs: dentro de los subqueries `cfdis i_act`
* y `cfdis r_act`, la tabla interna también tiene columnas `uuid_relacionado`
* y `cfdis_relacionados`. Una referencia no-qualificada las resolvería a las
* columnas internas (NO al row outer), volviendo el predicado a no-op.
* Por eso usamos `cfdis.uuid_relacionado` y `cfdis.cfdis_relacionados`
* explícitamente — fuerza la resolución al outer.
*/
function activosExclusionNoAlias(): string {
return `
AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS})
AND NOT (tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis i_act
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
AND i_act.tipo_comprobante = 'I'
AND i_act.uso_cfdi IN ${ACTIVOS_USOS}
))
AND NOT (tipo_comprobante = 'E' AND cfdis.cfdis_relacionados IS NOT NULL AND EXISTS (
SELECT 1 FROM cfdis r_act
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
AND (
(r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS_USOS})
OR (r_act.tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis pi_act
WHERE LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
AND pi_act.tipo_comprobante = 'I'
AND pi_act.uso_cfdi IN ${ACTIVOS_USOS}
))
)
SELECT 1 FROM (${UUIDS_ACTIVOS}) ua
WHERE ua.uuid = ANY(string_to_array(LOWER(uuid_relacionado), '|'))
))
AND NOT (tipo_comprobante = 'E' AND uuid IN (${UUIDS_E_DE_ACTIVOS}))
AND NOT (tipo_comprobante = 'I' AND EXISTS (
-- Anticipo: CFDI tipo I (puede no tener uso_cfdi de activo) que es
-- referenciado por una I/07 PPD con uso_cfdi de activo. La I/07 PPD
@@ -87,24 +105,10 @@ function activosExclusionAlias(alias: string): string {
return `
AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ${ACTIVOS_USOS})
AND NOT (${alias}.tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis i_act
WHERE LOWER(i_act.uuid) = LOWER(${alias}.uuid_relacionado)
AND i_act.tipo_comprobante = 'I'
AND i_act.uso_cfdi IN ${ACTIVOS_USOS}
))
AND NOT (${alias}.tipo_comprobante = 'E' AND ${alias}.cfdis_relacionados IS NOT NULL AND EXISTS (
SELECT 1 FROM cfdis r_act
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(${alias}.cfdis_relacionados), '|'))
AND (
(r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS_USOS})
OR (r_act.tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis pi_act
WHERE LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
AND pi_act.tipo_comprobante = 'I'
AND pi_act.uso_cfdi IN ${ACTIVOS_USOS}
))
)
SELECT 1 FROM (${UUIDS_ACTIVOS}) ua
WHERE ua.uuid = ANY(string_to_array(LOWER(${alias}.uuid_relacionado), '|'))
))
AND NOT (${alias}.tipo_comprobante = 'E' AND ${alias}.uuid IN (${UUIDS_E_DE_ACTIVOS}))
AND NOT (${alias}.tipo_comprobante = 'I' AND EXISTS (
SELECT 1 FROM cfdis i07_act
WHERE i07_act.tipo_comprobante = 'I'

View File

@@ -66,11 +66,13 @@ export async function getClientesStats(range: ClientesStatsRange): Promise<Clien
paymentsCount: payments._count,
};
// 3) Clientes que NO renovaron: subs cuyo currentPeriodEnd cae en el rango
// y que están en status terminal (cancelled, trial_expired, paused) o sin
// payment posterior aprobado. Nota: un sub `authorized` con periodEnd
// pasado es un "se renovó automáticamente" — para detectar no-renovaciones
// miramos status efectivo + ausencia de payment en los siguientes 7 días.
// 3) Clientes que NO renovaron:
// a) Subs cuyo currentPeriodEnd cae en el rango y están en status terminal
// (cancelled, trial_expired, paused).
// b) Tenants cuyo trialEndsAt ya pasó y NO tienen suscripción authorized
// (incluye trials que nunca convirtieron o cuya sub fue borrada).
// c) Tenants con sub trial vencida (currentPeriodEnd < ahora) que nunca
// fue marcada trial_expired por el cron.
const subsExpiradas = await prisma.subscription.findMany({
where: {
currentPeriodEnd: { gte: range.from, lte: range.to },
@@ -84,14 +86,99 @@ export async function getClientesStats(range: ClientesStatsRange): Promise<Clien
tenant: { select: { id: true, nombre: true, rfc: true } },
},
});
const noRenovaciones = subsExpiradas.map(s => ({
tenantId: s.tenantId,
tenantNombre: s.tenant?.nombre ?? '',
rfc: s.tenant?.rfc ?? '',
plan: String(s.plan),
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '',
statusActual: s.status,
}));
const noRenovacionesMap = new Map<string, ClientesStats['noRenovaciones'][number]>();
for (const s of subsExpiradas) {
noRenovacionesMap.set(s.tenantId, {
tenantId: s.tenantId,
tenantNombre: s.tenant?.nombre ?? '',
rfc: s.tenant?.rfc ?? '',
plan: String(s.plan),
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '',
statusActual: s.status,
});
}
// b + c) Trials vencidos / sin suscripción activa / subs borradas
const now = new Date();
const tenantsConSubAutorizada = new Set(
(await prisma.subscription.findMany({
where: { status: 'authorized' },
select: { tenantId: true },
})).map(s => s.tenantId)
);
const excluded = Array.from(tenantsConSubAutorizada);
// Tenants con trialEndsAt pasado y sin sub authorized
const tenantsTrialsVencidos = await prisma.tenant.findMany({
where: {
trialEndsAt: { lt: now },
id: { notIn: excluded },
},
select: { id: true, nombre: true, rfc: true, plan: true, trialEndsAt: true },
});
for (const t of tenantsTrialsVencidos) {
if (noRenovacionesMap.has(t.id)) continue;
noRenovacionesMap.set(t.id, {
tenantId: t.id,
tenantNombre: t.nombre,
rfc: t.rfc ?? '',
plan: String(t.plan ?? 'trial'),
currentPeriodEnd: t.trialEndsAt?.toISOString() ?? '',
statusActual: 'trial_expired',
});
}
// Tenants con sub trial vencida (currentPeriodEnd < ahora) que nunca fue
// marcada trial_expired por el cron, y no tienen otra sub authorized.
const subsTrialVencidas = await prisma.subscription.findMany({
where: {
status: 'trial',
currentPeriodEnd: { lt: now },
tenantId: { notIn: excluded },
},
select: {
tenantId: true,
plan: true,
currentPeriodEnd: true,
tenant: { select: { id: true, nombre: true, rfc: true } },
},
});
for (const s of subsTrialVencidas) {
if (noRenovacionesMap.has(s.tenantId)) continue;
noRenovacionesMap.set(s.tenantId, {
tenantId: s.tenantId,
tenantNombre: s.tenant?.nombre ?? '',
rfc: s.tenant?.rfc ?? '',
plan: String(s.plan),
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '',
statusActual: 'trial_expired',
});
}
// Tenants con plan de pago asignado manualmente (plan != 'trial') pero
// sin NINGUNA suscripción. Indica que nunca iniciaron el flujo de pago.
const tenantsConPlanPeroSinSub = await prisma.tenant.findMany({
where: {
plan: { not: 'trial' },
id: { notIn: excluded },
subscriptions: { none: {} },
},
select: { id: true, nombre: true, rfc: true, plan: true, createdAt: true },
});
for (const t of tenantsConPlanPeroSinSub) {
if (noRenovacionesMap.has(t.id)) continue;
noRenovacionesMap.set(t.id, {
tenantId: t.id,
tenantNombre: t.nombre,
rfc: t.rfc ?? '',
plan: String(t.plan),
currentPeriodEnd: t.createdAt.toISOString(),
statusActual: 'sin_suscripcion',
});
}
const noRenovaciones = Array.from(noRenovacionesMap.values());
// 4) Usuarios por cliente (memberships activos por tenant)
const memberships = await prisma.tenantMembership.findMany({

View File

@@ -176,7 +176,7 @@ async function alertaRiesgoCancelaciones(pool: Pool, contribuyenteId?: string |
COUNT(*)::int as total,
COUNT(CASE WHEN status IN ('Cancelado', '0') THEN 1 END)::int as cancelados
FROM cfdis
WHERE fecha_emision >= $1::date
WHERE (fecha_emision - interval '1 hour') >= $1::date
${cf}
`, [fechaDesde]);
@@ -359,7 +359,7 @@ async function alertaCancelacionPeriodoAnterior(pool: Pool, contribuyenteId?: st
FROM cfdis
WHERE status IN ('Cancelado', '0')
AND fecha_cancelacion >= $1::date
AND fecha_emision < $1::date
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < $1::date
${cf}
`, [inicioMes]);
@@ -529,7 +529,7 @@ async function alertaResicoPfLimiteIngresos(
FROM cfdis
WHERE type = 'EMITIDO'
AND status NOT IN ('Cancelado', '0')
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(YEAR FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour')) = $1
AND contribuyente_id = $2
`, [año, safeId]);
@@ -609,30 +609,46 @@ async function alertaOpinionCumplimiento(pool: Pool, contribuyenteId?: string |
/**
* Genera todas las alertas automáticas para un tenant.
* Cada alerta se envuelve en try/catch para que un fallo en una no
* bloquee el resto (robustez ante timeouts o errores transitorios).
*/
export async function generarAlertasAutomaticas(
pool: Pool,
tenantId: string,
contribuyenteId?: string | null,
): Promise<AlertaAuto[]> {
const alertas = await Promise.all([
alertaListaNegraPropia(pool, tenantId, contribuyenteId),
alertaClienteListaNegra(pool, contribuyenteId),
alertaProveedorListaNegra(pool, contribuyenteId),
alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId),
alertaConcentracionClientes(pool, contribuyenteId),
alertaConcentracionProveedores(pool, contribuyenteId),
alertaRiesgoCambiario(pool, contribuyenteId),
alertaRiesgoCancelaciones(pool, contribuyenteId),
alertaRiesgoTransaccional(pool, contribuyenteId),
alertaCancelacionPeriodoAnterior(pool, contribuyenteId),
alertaOpinionCumplimiento(pool, contribuyenteId),
alertaTipoRelacionSospechosa(pool, contribuyenteId),
alertaTareasProximasVencer(pool, contribuyenteId),
alertaResicoPfLimiteIngresos(pool, contribuyenteId),
]);
const generadores: { name: string; fn: () => Promise<AlertaAuto | null> }[] = [
{ name: 'lista-negra-propia', fn: () => alertaListaNegraPropia(pool, tenantId, contribuyenteId) },
{ name: 'lista-negra-clientes', fn: () => alertaClienteListaNegra(pool, contribuyenteId) },
{ name: 'lista-negra-proveedores', fn: () => alertaProveedorListaNegra(pool, contribuyenteId) },
{ name: 'discrepancia-regimen', fn: () => alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId) },
{ name: 'concentracion-clientes', fn: () => alertaConcentracionClientes(pool, contribuyenteId) },
{ name: 'concentracion-proveedores', fn: () => alertaConcentracionProveedores(pool, contribuyenteId) },
{ name: 'riesgo-cambiario', fn: () => alertaRiesgoCambiario(pool, contribuyenteId) },
{ name: 'riesgo-cancelaciones', fn: () => alertaRiesgoCancelaciones(pool, contribuyenteId) },
{ name: 'riesgo-transaccional', fn: () => alertaRiesgoTransaccional(pool, contribuyenteId) },
{ name: 'cancelacion-periodo-anterior', fn: () => alertaCancelacionPeriodoAnterior(pool, contribuyenteId) },
{ name: 'opinion-cumplimiento', fn: () => alertaOpinionCumplimiento(pool, contribuyenteId) },
{ name: 'tipo-relacion-sospechosa', fn: () => alertaTipoRelacionSospechosa(pool, contribuyenteId) },
{ name: 'tareas-proximas-vencer', fn: () => alertaTareasProximasVencer(pool, contribuyenteId) },
{ name: 'resico-pf-limite-ingresos', fn: () => alertaResicoPfLimiteIngresos(pool, contribuyenteId) },
];
return alertas.filter((a): a is AlertaAuto => a !== null);
const alertas: AlertaAuto[] = [];
for (const g of generadores) {
try {
const a = await g.fn();
if (a) alertas.push(a);
} catch (err: any) {
console.error(`[AlertasAuto] Fallo ${g.name} (tenant=${tenantId}, contribuyente=${contribuyenteId}):`, err.message || err);
}
}
if (alertas.length > 0) {
console.log(`[AlertasAuto] tenant=${tenantId} contribuyente=${contribuyenteId || 'null'} generadas=${alertas.map(a => a.id).join(', ')}`);
}
return alertas;
}
/**
@@ -659,8 +675,8 @@ export async function getDiscrepanciasPorMes(
const { rows } = await pool.query(`
SELECT
EXTRACT(YEAR FROM fecha_emision)::int as año,
EXTRACT(MONTH FROM fecha_emision)::int as mes,
EXTRACT(YEAR FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'))::int as año,
EXTRACT(MONTH FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'))::int as mes,
COUNT(*)::int as count
FROM cfdis
WHERE type = 'RECIBIDO' AND ${VIGENTE}

View File

@@ -0,0 +1,343 @@
import type { Pool } from 'pg';
import { prisma } from '../config/database.js';
// ── Asignación de obligaciones ──
export async function asignarObligacion(
pool: Pool,
obligacionId: string,
auxiliarUserId: string,
asignadoPor: string,
): Promise<void> {
await pool.query(
`INSERT INTO obligacion_asignaciones (obligacion_id, auxiliar_user_id, asignado_por)
VALUES ($1, $2, $3)
ON CONFLICT (obligacion_id)
DO UPDATE SET auxiliar_user_id = $2, asignado_por = $3, asignado_at = now()`,
[obligacionId, auxiliarUserId, asignadoPor],
);
}
export async function desasignarObligacion(pool: Pool, obligacionId: string): Promise<void> {
await pool.query('DELETE FROM obligacion_asignaciones WHERE obligacion_id = $1', [obligacionId]);
}
// ── Asignación de tareas ──
export async function asignarTarea(
pool: Pool,
tareaId: string,
auxiliarUserId: string,
asignadoPor: string,
): Promise<void> {
await pool.query(
`INSERT INTO tarea_asignaciones (tarea_id, auxiliar_user_id, asignado_por)
VALUES ($1, $2, $3)
ON CONFLICT (tarea_id)
DO UPDATE SET auxiliar_user_id = $2, asignado_por = $3, asignado_at = now()`,
[tareaId, auxiliarUserId, asignadoPor],
);
}
export async function desasignarTarea(pool: Pool, tareaId: string): Promise<void> {
await pool.query('DELETE FROM tarea_asignaciones WHERE tarea_id = $1', [tareaId]);
}
// ── Listados ──
export interface AsignacionObligacion {
id: string;
obligacionId: string;
obligacionNombre: string;
contribuyenteId: string;
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
auxiliarUserId: string;
auxiliarNombre: string | null;
asignadoPor: string;
asignadoAt: string;
}
export interface AsignacionTarea {
id: string;
tareaId: string;
tareaNombre: string;
contribuyenteId: string;
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
auxiliarUserId: string;
auxiliarNombre: string | null;
asignadoPor: string;
asignadoAt: string;
}
async function resolveUserNames(userIds: string[]): Promise<Map<string, string>> {
const map = new Map<string, string>();
if (userIds.length === 0) return map;
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, nombre: true },
});
for (const u of users) {
map.set(u.id, u.nombre);
}
return map;
}
/**
* Devuelve todas las asignaciones de obligaciones y tareas de los auxiliares
* que pertenecen al supervisor indicado (vía auxiliar_supervisores).
* Owner ve todas las asignaciones del tenant.
*/
export async function getAsignacionesPorSupervisor(
pool: Pool,
supervisorUserId: string,
role: string,
): Promise<{ obligaciones: AsignacionObligacion[]; tareas: AsignacionTarea[] }> {
const isOwner = role === 'owner' || role === 'cfo' || role === 'contador';
// Relación supervisor → auxiliar se infiere desde carteras (directas y
// subcarteras) con fallback a la tabla legacy auxiliar_supervisores.
const supervisorFilter = isOwner
? ''
: `AND EXISTS (
SELECT 1 FROM (
SELECT c.auxiliar_user_id
FROM carteras c
WHERE c.supervisor_user_id = $1
AND c.auxiliar_user_id IS NOT NULL
UNION
SELECT sub.auxiliar_user_id
FROM carteras sub
JOIN carteras p ON p.id = sub.parent_id
WHERE p.supervisor_user_id = $1
AND sub.auxiliar_user_id IS NOT NULL
UNION
SELECT auxiliar_user_id FROM auxiliar_supervisores WHERE supervisor_user_id = $1
) sup_aux WHERE sup_aux.auxiliar_user_id = __AUX_COL__
)`;
const whereObl = isOwner
? 'WHERE 1=1'
: `WHERE 1=1 ${supervisorFilter.replace(/__AUX_COL__/g, 'oa.auxiliar_user_id')}`;
const whereTarea = isOwner
? 'WHERE 1=1'
: `WHERE 1=1 ${supervisorFilter.replace(/__AUX_COL__/g, 'ta.auxiliar_user_id')}`;
const params = isOwner ? [] : [supervisorUserId];
const { rows: obligaciones } = await pool.query<AsignacionObligacion>(
`SELECT
oa.id,
oa.obligacion_id AS "obligacionId",
oc.nombre AS "obligacionNombre",
oc.contribuyente_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial",
oa.auxiliar_user_id AS "auxiliarUserId",
oa.asignado_por AS "asignadoPor",
oa.asignado_at AS "asignadoAt"
FROM obligacion_asignaciones oa
JOIN obligaciones_contribuyente oc ON oc.id = oa.obligacion_id
JOIN contribuyentes c ON c.entidad_id = oc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
${whereObl}
ORDER BY oa.asignado_at DESC`,
params,
);
const { rows: tareas } = await pool.query<AsignacionTarea>(
`SELECT
ta.id,
ta.tarea_id AS "tareaId",
tc.nombre AS "tareaNombre",
tc.contribuyente_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial",
ta.auxiliar_user_id AS "auxiliarUserId",
ta.asignado_por AS "asignadoPor",
ta.asignado_at AS "asignadoAt"
FROM tarea_asignaciones ta
JOIN tareas_catalogo tc ON tc.id = ta.tarea_id
JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
${whereTarea}
ORDER BY ta.asignado_at DESC`,
params,
);
const allAuxIds = [...new Set([
...obligaciones.map(o => o.auxiliarUserId),
...tareas.map(t => t.auxiliarUserId),
])];
const names = await resolveUserNames(allAuxIds);
return {
obligaciones: obligaciones.map(o => ({ ...o, auxiliarNombre: names.get(o.auxiliarUserId) ?? null })),
tareas: tareas.map(t => ({ ...t, auxiliarNombre: names.get(t.auxiliarUserId) ?? null })),
};
}
/**
* Devuelve las asignaciones del auxiliar logueado.
*/
export async function getAsignacionesPorAuxiliar(
pool: Pool,
auxiliarUserId: string,
): Promise<{ obligaciones: AsignacionObligacion[]; tareas: AsignacionTarea[] }> {
const { rows: obligaciones } = await pool.query<AsignacionObligacion>(
`SELECT
oa.id,
oa.obligacion_id AS "obligacionId",
oc.nombre AS "obligacionNombre",
oc.contribuyente_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial",
oa.auxiliar_user_id AS "auxiliarUserId",
oa.asignado_por AS "asignadoPor",
oa.asignado_at AS "asignadoAt"
FROM obligacion_asignaciones oa
JOIN obligaciones_contribuyente oc ON oc.id = oa.obligacion_id
JOIN contribuyentes c ON c.entidad_id = oc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
WHERE oa.auxiliar_user_id = $1
ORDER BY oa.asignado_at DESC`,
[auxiliarUserId],
);
const { rows: tareas } = await pool.query<AsignacionTarea>(
`SELECT
ta.id,
ta.tarea_id AS "tareaId",
tc.nombre AS "tareaNombre",
tc.contribuyente_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial",
ta.auxiliar_user_id AS "auxiliarUserId",
ta.asignado_por AS "asignadoPor",
ta.asignado_at AS "asignadoAt"
FROM tarea_asignaciones ta
JOIN tareas_catalogo tc ON tc.id = ta.tarea_id
JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
WHERE ta.auxiliar_user_id = $1
ORDER BY ta.asignado_at DESC`,
[auxiliarUserId],
);
const names = await resolveUserNames([auxiliarUserId]);
const auxName = names.get(auxiliarUserId) ?? null;
return {
obligaciones: obligaciones.map(o => ({ ...o, auxiliarNombre: auxName })),
tareas: tareas.map(t => ({ ...t, auxiliarNombre: auxName })),
};
}
/**
* Devuelve obligaciones activas sin asignar para los contribuyentes indicados.
*/
export async function getObligacionesSinAsignar(
pool: Pool,
entidadIds: string[],
): Promise<Omit<AsignacionObligacion, 'id' | 'auxiliarUserId' | 'auxiliarNombre' | 'asignadoPor' | 'asignadoAt'>[]> {
if (entidadIds.length === 0) return [];
const { rows } = await pool.query(
`SELECT
oc.id AS "obligacionId",
oc.nombre AS "obligacionNombre",
oc.contribuyente_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial"
FROM obligaciones_contribuyente oc
JOIN contribuyentes c ON c.entidad_id = oc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
LEFT JOIN obligacion_asignaciones oa ON oa.obligacion_id = oc.id
WHERE oc.activa = true AND oa.id IS NULL AND oc.contribuyente_id = ANY($1)
ORDER BY c.rfc, oc.nombre`,
[entidadIds],
);
return rows;
}
/**
* Devuelve tareas activas sin asignar para los contribuyentes indicados.
*/
export async function getTareasSinAsignar(
pool: Pool,
entidadIds: string[],
): Promise<Omit<AsignacionTarea, 'id' | 'auxiliarUserId' | 'auxiliarNombre' | 'asignadoPor' | 'asignadoAt'>[]> {
if (entidadIds.length === 0) return [];
const { rows } = await pool.query(
`SELECT
tc.id AS "tareaId",
tc.nombre AS "tareaNombre",
tc.contribuyente_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial"
FROM tareas_catalogo tc
JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
LEFT JOIN tarea_asignaciones ta ON ta.tarea_id = tc.id
WHERE tc.active = true AND ta.id IS NULL AND tc.contribuyente_id = ANY($1)
ORDER BY c.rfc, tc.nombre`,
[entidadIds],
);
return rows;
}
/**
* Resuelve el auxiliar asignado a una obligación (o null).
*/
export async function getAuxiliarAsignadoObligacion(
pool: Pool,
obligacionId: string,
): Promise<{ auxiliarUserId: string; auxiliarNombre: string | null } | null> {
const { rows } = await pool.query<{ auxiliar_user_id: string }>(
`SELECT oa.auxiliar_user_id
FROM obligacion_asignaciones oa
WHERE oa.obligacion_id = $1`,
[obligacionId],
);
if (rows.length === 0) return null;
const auxId = rows[0].auxiliar_user_id;
const names = await resolveUserNames([auxId]);
return { auxiliarUserId: auxId, auxiliarNombre: names.get(auxId) ?? null };
}
/**
* Resuelve el auxiliar asignado a una tarea (o null).
*/
export async function getAuxiliarAsignadoTarea(
pool: Pool,
tareaId: string,
): Promise<{ auxiliarUserId: string; auxiliarNombre: string | null } | null> {
const { rows } = await pool.query<{ auxiliar_user_id: string }>(
`SELECT ta.auxiliar_user_id
FROM tarea_asignaciones ta
WHERE ta.tarea_id = $1`,
[tareaId],
);
if (rows.length === 0) return null;
const auxId = rows[0].auxiliar_user_id;
const names = await resolveUserNames([auxId]);
return { auxiliarUserId: auxId, auxiliarNombre: names.get(auxId) ?? null };
}
/**
* Devuelve los userIds de auxiliares que tienen al contribuyente en alguna
* de sus subcarteras (carteras con auxiliar_user_id no nulo que contienen
* al contribuyente en cartera_entidades).
*/
export async function getAuxiliaresElegibles(
pool: Pool,
contribuyenteId: string,
): Promise<string[]> {
const { rows } = await pool.query<{ auxiliar_user_id: string }>(
`SELECT DISTINCT c.auxiliar_user_id
FROM carteras c
JOIN cartera_entidades ce ON ce.cartera_id = c.id
WHERE ce.entidad_id = $1
AND c.auxiliar_user_id IS NOT NULL`,
[contribuyenteId],
);
return rows.map(r => r.auxiliar_user_id);
}

View File

@@ -110,6 +110,17 @@ export async function deleteCartera(pool: Pool, id: string): Promise<boolean> {
// Entidades in cartera
export async function addEntidadToCartera(pool: Pool, carteraId: string, entidadId: string): Promise<void> {
// Si es subcartera, validar que la entidad pertenezca a la cartera padre
const cartera = await getCarteraById(pool, carteraId);
if (cartera?.parentId) {
const { rows } = await pool.query(
'SELECT 1 FROM cartera_entidades WHERE cartera_id = $1 AND entidad_id = $2',
[cartera.parentId, entidadId],
);
if (rows.length === 0) {
throw new Error('La entidad no pertenece a la cartera padre de esta subcartera');
}
}
await pool.query('INSERT INTO cartera_entidades (cartera_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [carteraId, entidadId]);
}

View File

@@ -102,12 +102,12 @@ export async function getCfdis(pool: Pool, filters: CfdiFilters): Promise<CfdiLi
}
if (filters.fechaInicio) {
whereClause += ` AND fecha_emision >= $${paramIndex++}::date`;
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
whereClause += ` AND fecha_emision <= ($${paramIndex++}::date + interval '1 day')`;
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
params.push(filters.fechaFin);
}
@@ -214,11 +214,11 @@ export async function getConceptosList(
params.push(filters.estado);
}
if (filters.fechaInicio) {
whereClause += ` AND c.fecha_emision >= $${paramIndex++}::date`;
whereClause += ` AND COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
whereClause += ` AND c.fecha_emision <= ($${paramIndex++}::date + interval '1 day')`;
whereClause += ` AND COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
params.push(filters.fechaFin);
}
if (filters.rfc) {
@@ -357,6 +357,81 @@ export async function getXmlById(pool: Pool, id: string): Promise<string | null>
return rows[0]?.xml_original || null;
}
export async function getXmlsByIds(pool: Pool, ids: number[]): Promise<{ id: number; uuid: string; xml: string | null }[]> {
const { rows } = await pool.query(`
SELECT id, uuid, xml_original FROM cfdis WHERE id = ANY($1)
`, [ids]);
return rows.map((r: any) => ({ id: r.id, uuid: r.uuid, xml: r.xml_original || null }));
}
export async function getCfdiXmlsForZip(
pool: Pool,
filters: CfdiFilters
): Promise<{ uuid: string; xml: string | null }[]> {
let whereClause = 'WHERE xml_original IS NOT NULL';
const params: any[] = [];
let paramIndex = 1;
if (filters.tipo && !filters.contribuyenteId) {
whereClause += ` AND type = $${paramIndex++}`;
params.push(filters.tipo);
}
if (filters.tipoComprobante) {
whereClause += ` AND tipo_comprobante = $${paramIndex++}`;
params.push(filters.tipoComprobante);
}
if (filters.estado) {
whereClause += ` AND status = $${paramIndex++}`;
params.push(filters.estado);
}
if (filters.fechaInicio) {
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
params.push(filters.fechaFin);
}
if (filters.rfc) {
whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`;
params.push(`%${filters.rfc}%`);
}
if (filters.emisor) {
whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex++})`;
params.push(`%${filters.emisor}%`);
}
if (filters.receptor) {
whereClause += ` AND (rfc_receptor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`;
params.push(`%${filters.receptor}%`);
}
if (filters.search) {
whereClause += ` AND (uuid ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex} OR rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`;
params.push(`%${filters.search}%`);
}
if (filters.contribuyenteId) {
if (filters.tipo === 'EMITIDO') {
whereClause += ` AND rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`;
params.push(filters.contribuyenteId);
} else if (filters.tipo === 'RECIBIDO') {
whereClause += ` AND rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`;
params.push(filters.contribuyenteId);
} else {
whereClause += ` AND (contribuyente_id = $${paramIndex} OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex}) OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++}))`;
params.push(filters.contribuyenteId);
}
}
params.push(1000);
const { rows } = await pool.query(`
SELECT uuid, xml_original FROM cfdis
${whereClause}
ORDER BY fecha_emision DESC
LIMIT $${paramIndex++}
`, params);
return rows.map((r: any) => ({ uuid: r.uuid, xml: r.xml_original || null }));
}
export interface CreateCfdiData {
uuid: string;
type: 'EMITIDO' | 'RECIBIDO';
@@ -746,7 +821,10 @@ export async function getReceptores(pool: Pool, search: string, limit: number =
}
export async function getResumenCfdis(pool: Pool, año: number, mes: number, contribuyenteId?: string) {
let whereClause = `WHERE status NOT IN ('Cancelado', '0') AND year = $1 AND month = $2`;
const fi = `${año}-${String(mes).padStart(2, '0')}-01`;
const lastDay = new Date(año, mes, 0).getDate();
const ff = `${año}-${String(mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
let whereClause = `WHERE status NOT IN ('Cancelado', '0') AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= $2::date`;
if (contribuyenteId) {
const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, '');
whereClause += ` AND (contribuyente_id = '${safeId}' OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}') OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}'))`;
@@ -761,7 +839,7 @@ export async function getResumenCfdis(pool: Pool, año: number, mes: number, con
COALESCE(SUM(CASE WHEN type = 'RECIBIDO' THEN iva_traslado_mxn ELSE 0 END), 0) as iva_acreditable
FROM cfdis
${whereClause}
`, [String(año), String(mes).padStart(2, '0')]);
`, [fi, ff]);
const r = rows[0];
return {

View File

@@ -13,6 +13,8 @@ export interface ConciliacionCfdi {
nombreEmisor: string;
rfcReceptor: string;
nombreReceptor: string;
regimenFiscalEmisor: string | null;
regimenFiscalReceptor: string | null;
total: number;
totalMxn: number;
subtotal: number;
@@ -68,11 +70,11 @@ export async function getCfdisConConciliacion(
}
if (filters.fechaInicio) {
where += ` AND COALESCE(c.fecha_pago_p, c.fecha_emision) >= $${idx++}::date`;
where += ` AND CASE WHEN c.tipo_comprobante = 'P' THEN c.fecha_pago_p - interval '1 hour' ELSE COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') END >= $${idx++}::date`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
where += ` AND COALESCE(c.fecha_pago_p, c.fecha_emision) <= ($${idx++}::date + interval '1 day')`;
where += ` AND CASE WHEN c.tipo_comprobante = 'P' THEN c.fecha_pago_p - interval '1 hour' ELSE COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') END <= ($${idx++}::date + interval '1 day')`;
params.push(filters.fechaFin);
}
if (filters.regimen) {
@@ -98,6 +100,7 @@ export async function getCfdisConConciliacion(
c.fecha_emision as "fechaEmision",
c.rfc_emisor as "rfcEmisor", c.nombre_emisor as "nombreEmisor",
c.rfc_receptor as "rfcReceptor", c.nombre_receptor as "nombreReceptor",
c.regimen_fiscal_emisor as "regimenFiscalEmisor", c.regimen_fiscal_receptor as "regimenFiscalReceptor",
c.total, c.total_mxn as "totalMxn",
c.subtotal, c.descuento,
c.moneda, c.tipo_cambio as "tipoCambio",
@@ -136,6 +139,8 @@ export async function getCfdisConConciliacion(
nombreEmisor: r.nombreEmisor,
rfcReceptor: r.rfcReceptor,
nombreReceptor: r.nombreReceptor,
regimenFiscalEmisor: r.regimenFiscalEmisor,
regimenFiscalReceptor: r.regimenFiscalReceptor,
total: Number(r.total),
totalMxn: Number(r.totalMxn),
subtotal: Number(r.subtotal || 0),

View File

@@ -44,6 +44,9 @@ function rowToConstancia(r: any): ConstanciaRow {
* sincroniza automáticamente domicilio + regímenes activos con lo que reporta
* el SAT. El auto-fill NO es destructivo para datos custom del usuario:
* solo sobreescribe campos si la CSF tiene un valor no-vacío.
*
* Incluye retry con backoff (3 intentos) para robustez ante timeouts
* transitorios del portal SAT (mantenimiento nocturno, congestión, etc.).
*/
export async function consultarConstancia(tenantId: string): Promise<ConstanciaRow> {
const fiel = await getDecryptedFiel(tenantId);
@@ -55,72 +58,78 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
});
if (!tenant) throw new Error('Tenant no encontrado');
const tempId = randomUUID();
const tempDir = join(tmpdir(), `horux-csf-${tempId}`);
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
const cerPath = join(tempDir, 'cert.cer');
const keyPath = join(tempDir, 'key.key');
const MAX_RETRIES = 3;
const RETRY_DELAYS = [5_000, 15_000, 30_000]; // backoff
try {
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
const tempId = randomUUID();
const tempDir = join(tmpdir(), `horux-csf-${tempId}`);
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
const cerPath = join(tempDir, 'cert.cer');
const keyPath = join(tempDir, 'key.key');
// Headless por default. El fix de dispatchEvent en sat-csf-login cubre el
// caso donde el click sintético no dispara el handler del SAT. Si algún
// ambiente necesita ver el browser (debug), setear SAT_HEADLESS=false.
const headless = process.env.SAT_HEADLESS !== 'false';
const browser = await chromium.launch({
headless,
args: ['--disable-blink-features=AutomationControlled'],
ignoreDefaultArgs: ['--enable-automation'],
});
try {
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT),
);
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
const resultPromise = (async () => {
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc);
const pdfBuffer = await extractCsfPdf(session);
const csf = await parseCsfPdf(pdfBuffer);
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
const { rows } = await pool.query(
`INSERT INTO constancias_situacion_fiscal
(rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision,
datos, fecha_consulta, created_at`,
[
csf.rfc,
csf.idCIF,
csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null,
csf.estatusPadron,
csf.lugarFechaEmision,
JSON.stringify(csf),
pdfBuffer,
],
const headless = process.env.SAT_HEADLESS !== 'false';
const browser = await chromium.launch({
headless,
args: ['--disable-blink-features=AutomationControlled'],
ignoreDefaultArgs: ['--enable-automation'],
});
try {
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 5 minutos')), 300_000),
);
// Auto-fill domicilio del tenant + regímenes activos desde el CSF.
// Se hace después del INSERT para que si algo falla en la sincronización
// la CSF ya quedó guardada y el usuario puede verla.
await sincronizarDatosFiscales(tenantId, csf).catch(err => {
console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err);
});
const resultPromise = (async () => {
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc);
const pdfBuffer = await extractCsfPdf(session);
const csf = await parseCsfPdf(pdfBuffer);
return rowToConstancia(rows[0]);
})();
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
const { rows } = await pool.query(
`INSERT INTO constancias_situacion_fiscal
(rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision,
datos, fecha_consulta, created_at`,
[
csf.rfc,
csf.idCIF,
csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null,
csf.estatusPadron,
csf.lugarFechaEmision,
JSON.stringify(csf),
pdfBuffer,
],
);
return await Promise.race([resultPromise, timeoutPromise]);
await sincronizarDatosFiscales(tenantId, csf).catch(err => {
console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err);
});
return rowToConstancia(rows[0]);
})();
return await Promise.race([resultPromise, timeoutPromise]);
} finally {
await browser.close();
}
} catch (err: any) {
const willRetry = attempt < MAX_RETRIES - 1;
console.error(`[CSF] Intento ${attempt + 1}/${MAX_RETRIES} falló para tenant ${tenantId}: ${err.message}${willRetry ? ` — reintentando en ${RETRY_DELAYS[attempt]}ms...` : ''}`);
if (!willRetry) throw err;
await new Promise(r => setTimeout(r, RETRY_DELAYS[attempt]));
} finally {
await browser.close();
try { unlinkSync(cerPath); } catch { /* ok */ }
try { unlinkSync(keyPath); } catch { /* ok */ }
try { rmdirSync(tempDir); } catch { /* ok */ }
}
} finally {
try { unlinkSync(cerPath); } catch { /* ok */ }
try { unlinkSync(keyPath); } catch { /* ok */ }
try { rmdirSync(tempDir); } catch { /* ok */ }
}
throw new Error('No debería llegar aquí');
}
/**

View File

@@ -435,7 +435,7 @@ export async function createInvoiceContribuyente(
unit_key: item.unitKey || 'E48',
unit_name: item.unitName || 'Servicio',
price: item.price,
tax_included: item.taxIncluded ?? true,
tax_included: item.taxIncluded ?? false,
taxes: item.taxes?.map((t: any) => ({
type: t.type,
rate: t.rate,
@@ -443,6 +443,7 @@ export async function createInvoiceContribuyente(
...(t.withholding ? { withholding: true } : {}),
})) || [{ type: 'IVA', rate: 0.16 }],
},
...(data.cuentaPredial ? { property_tax_account: data.cuentaPredial } : {}),
}));
}
@@ -457,6 +458,7 @@ export async function createInvoiceContribuyente(
if (data.series) invoicePayload.series = data.series;
if (data.folioNumber) invoicePayload.folio_number = data.folioNumber;
if (data.fechaEmision) invoicePayload.date = data.fechaEmision;
if (data.relatedDocuments?.length) {
// Estructura SAT 4.0: agrupa N uuids por tipo de relación. Acepta tanto
@@ -542,3 +544,66 @@ async function ensureOrgLegalForEmit(
const payload = await buildLegalPayload(contrib, chosenTaxSystem, currentLegal);
await putOrgLegal(orgId, payload);
}
// ── Personalización (logo, color) per-contribuyente ──
export async function getCustomizationContribuyente(
pool: Pool,
contribuyenteId: string,
): Promise<{ logoUrl?: string; color?: string } | null> {
const { rows } = await pool.query<{ facturapi_org_id: string }>(
'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true',
[contribuyenteId],
);
if (rows.length === 0) return null;
const userClient = getUserClient();
try {
const org = await userClient.organizations.retrieve(rows[0].facturapi_org_id);
return {
logoUrl: org.customization?.has_logo ? (org.logo_url ?? undefined) : undefined,
color: org.customization?.color || undefined,
};
} catch { return null; }
}
export async function uploadLogoContribuyente(
pool: Pool,
contribuyenteId: string,
logoBase64: string,
): Promise<{ success: boolean; message: string }> {
const { rows } = await pool.query<{ facturapi_org_id: string }>(
'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true',
[contribuyenteId],
);
if (rows.length === 0) throw new Error('Organización no configurada');
const userClient = getUserClient();
try {
const buffer = Buffer.from(logoBase64, 'base64');
await userClient.organizations.uploadLogo(rows[0].facturapi_org_id, buffer);
return { success: true, message: 'Logo subido correctamente' };
} catch (error: any) {
return { success: false, message: error.message || 'Error al subir logo' };
}
}
export async function updateColorContribuyente(
pool: Pool,
contribuyenteId: string,
color: string,
): Promise<{ success: boolean; message: string }> {
const { rows } = await pool.query<{ facturapi_org_id: string }>(
'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true',
[contribuyenteId],
);
if (rows.length === 0) throw new Error('Organización no configurada');
const userClient = getUserClient();
try {
await userClient.organizations.updateCustomization(rows[0].facturapi_org_id, { color });
return { success: true, message: 'Color actualizado correctamente' };
} catch (error: any) {
return { success: false, message: error.message || 'Error al actualizar color' };
}
}

View File

@@ -1,4 +1,5 @@
import type { Pool } from 'pg';
import { prisma } from '../config/database.js';
export interface CreateContribuyenteData {
rfc: string;
@@ -23,7 +24,61 @@ export interface ContribuyenteRow {
domicilio: Record<string, unknown> | null;
}
export async function listContribuyentes(pool: Pool, entidadIds?: string[]): Promise<ContribuyenteRow[]> {
async function fetchTenantFiscalData(tenantId: string) {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: {
rfc: true,
codigoPostal: true,
calle: true,
numExterior: true,
numInterior: true,
colonia: true,
ciudad: true,
municipio: true,
estado: true,
telefono: true,
},
});
if (!tenant) return null;
const regimenes = await prisma.tenantRegimenActivo.findMany({
where: { tenantId },
select: { regimen: { select: { clave: true } } },
});
const regimenFiscal = regimenes.map(r => r.regimen.clave).join(',') || null;
const hasAnyAddress = tenant.calle || tenant.colonia || tenant.ciudad || tenant.municipio || tenant.estado || tenant.codigoPostal;
const domicilio = hasAnyAddress
? {
calle: tenant.calle || '',
numExterior: tenant.numExterior || '',
numInterior: tenant.numInterior || '',
colonia: tenant.colonia || '',
ciudad: tenant.ciudad || '',
municipio: tenant.municipio || '',
estado: tenant.estado || '',
codigoPostal: tenant.codigoPostal || '',
telefono: tenant.telefono || '',
}
: null;
return { tenantRfc: tenant.rfc, regimenFiscal, codigoPostal: tenant.codigoPostal, domicilio };
}
function mergeContribuyenteWithTenant(
row: ContribuyenteRow,
tenantData: NonNullable<Awaited<ReturnType<typeof fetchTenantFiscalData>>>
): ContribuyenteRow {
return {
...row,
regimenFiscal: row.regimenFiscal || tenantData.regimenFiscal,
codigoPostal: row.codigoPostal || tenantData.codigoPostal,
domicilio: row.domicilio || tenantData.domicilio,
};
}
export async function listContribuyentes(pool: Pool, entidadIds?: string[], tenantId?: string): Promise<ContribuyenteRow[]> {
let query = `
SELECT
e.id, e.tipo, e.nombre, e.identificador,
@@ -45,10 +100,20 @@ export async function listContribuyentes(pool: Pool, entidadIds?: string[]): Pro
query += ' ORDER BY e.created_at DESC';
const { rows } = await pool.query(query, params);
return rows;
if (!tenantId) return rows;
const tenantData = await fetchTenantFiscalData(tenantId);
if (!tenantData) return rows;
return rows.map((r: ContribuyenteRow) => {
if (r.rfc !== tenantData.tenantRfc) return r;
if (r.regimenFiscal && r.codigoPostal && r.domicilio) return r;
return mergeContribuyenteWithTenant(r, tenantData);
});
}
export async function getContribuyenteById(pool: Pool, id: string): Promise<ContribuyenteRow | null> {
export async function getContribuyenteById(pool: Pool, id: string, tenantId?: string): Promise<ContribuyenteRow | null> {
const { rows } = await pool.query(`
SELECT
e.id, e.tipo, e.nombre, e.identificador,
@@ -60,7 +125,14 @@ export async function getContribuyenteById(pool: Pool, id: string): Promise<Cont
JOIN contribuyentes c ON c.entidad_id = e.id
WHERE e.id = $1
`, [id]);
return rows[0] ?? null;
const row = rows[0] ?? null;
if (!row || !tenantId) return row;
const tenantData = await fetchTenantFiscalData(tenantId);
if (!tenantData || row.rfc !== tenantData.tenantRfc) return row;
if (row.regimenFiscal && row.codigoPostal && row.domicilio) return row;
return mergeContribuyenteWithTenant(row, tenantData);
}
export async function createContribuyente(pool: Pool, data: CreateContribuyenteData): Promise<ContribuyenteRow> {

View File

@@ -109,13 +109,13 @@ export const GRUPO_PM_OTROS = ['601', '603', '607', '608', '610', '611', '614',
const TODOS_REGIMENES = [...GRUPO_PF_EMPRESARIAL, ...GRUPO_SUELDOS, ...GRUPO_PM_OTROS];
// Filtro de fecha por rango — normal o conciliación
const FECHA_RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`;
const FECHA_RANGO = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
// Para CFDIs tipo P (complementos de pago): el ingreso/gasto se reconoce en la
// fecha_pago_p (cuándo el cliente realmente pagó), no cuando se emitió el
// complemento — el CFDI P puede emitirse hasta el día 5 del mes siguiente al
// pago, o incluso después, y cruzar meses (ej. pago de noviembre 2024 con
// complemento emitido en mayo 2025).
const FECHA_PAGO_RANGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`;
const FECHA_PAGO_RANGO = `(fecha_pago_p - interval '1 hour') >= $1::date AND (fecha_pago_p - interval '1 hour') < ($2::date + interval '1 day')`;
const FECHA_RANGO_CONCILIACION = `id_conciliacion IS NOT NULL AND id_conciliacion IN (
SELECT id FROM conciliaciones WHERE fecha_de_pago >= $1::date AND fecha_de_pago < ($2::date + interval '1 day')
)`;
@@ -989,14 +989,14 @@ export async function calcularIvaBalancePorRegimen(
AND e.status NOT IN ('Cancelado', '0')
AND ${esEmisor.replace(/\brfc_emisor\b/g, 'e.rfc_emisor')}
AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND date_trunc('month', e.fecha_emision) = date_trunc('month', i.fecha_emision)
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour')) = date_trunc('month', COALESCE(i.fecha_efectiva, i.fecha_emision - interval '1 hour'))
)), 0) as monto
FROM cfdis i
WHERE ${esEmisor.replace(/\brfc_emisor\b/g, 'i.rfc_emisor')}
AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD'
AND COALESCE(i.cfdi_tipo_relacion, '') = '07'
AND i.status NOT IN ('Cancelado','0')
AND ${FR.replace(/\bfecha_emision\b/g, 'i.fecha_emision')}
AND ${FR.replace(/\bfecha_efectiva\b/g, 'i.fecha_efectiva').replace(/\bfecha_emision\b/g, 'i.fecha_emision')}
AND i.regimen_fiscal_emisor = ANY($3)
GROUP BY i.regimen_fiscal_emisor
`, [fechaInicio, fechaFin, TODOS_REGIMENES]);
@@ -1012,14 +1012,14 @@ export async function calcularIvaBalancePorRegimen(
AND e.status NOT IN ('Cancelado', '0')
AND ${esReceptor.replace(/\brfc_receptor\b/g, 'e.rfc_receptor')}
AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND date_trunc('month', e.fecha_emision) = date_trunc('month', i.fecha_emision)
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour')) = date_trunc('month', COALESCE(i.fecha_efectiva, i.fecha_emision - interval '1 hour'))
)), 0) as monto
FROM cfdis i
WHERE ${esReceptor.replace(/\brfc_receptor\b/g, 'i.rfc_receptor')}
AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD'
AND COALESCE(i.cfdi_tipo_relacion, '') = '07'
AND i.status NOT IN ('Cancelado','0')
AND ${FR.replace(/\bfecha_emision\b/g, 'i.fecha_emision')}
AND ${FR.replace(/\bfecha_efectiva\b/g, 'i.fecha_efectiva').replace(/\bfecha_emision\b/g, 'i.fecha_emision')}
AND i.regimen_fiscal_receptor = ANY($3)
GROUP BY i.regimen_fiscal_receptor
`, [fechaInicio, fechaFin, TODOS_REGIMENES]);
@@ -1107,10 +1107,21 @@ export async function getKpis(
const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId);
const esEmisor = ctx.esEmisor;
const esReceptor = ctx.esReceptor;
const ingresosData = await calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId);
const egresosData = await calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId);
const adquisicionData = await calcularAdquisicionesMercancias(pool, tenantId, fechaInicio, fechaFin, conciliacion, contribuyenteId);
const ivaData = await calcularIvaBalancePorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId);
const [
ingresosData,
egresosData,
adquisicionData,
ivaData,
ncsEmitidasData,
ncsRecibidasData,
] = await Promise.all([
calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
calcularAdquisicionesMercancias(pool, tenantId, fechaInicio, fechaFin, conciliacion, contribuyenteId),
calcularIvaBalancePorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
calcularNcsEmitidasPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
calcularNcsRecibidasPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
]);
// IVA a favor año actual: desde enero del año en curso
const ivaAFavorAcumulado = await calcularIvaAFavorAcumulado(pool, tenantId, fechaFin, undefined, conciliacion, contribuyenteId);
@@ -1163,6 +1174,10 @@ export async function getKpis(
cfdisEmitidosPorRegimen: emitidosPorRegimen,
cfdisRecibidos: recibidosPorRegimen.reduce((s: number, r: any) => s + r.total, 0),
cfdisRecibidosPorRegimen: recibidosPorRegimen,
ncsEmitidas: ncsEmitidasData.total,
ncsEmitidasPorRegimen: ncsEmitidasData.porRegimen,
ncsRecibidas: ncsRecibidasData.total,
ncsRecibidasPorRegimen: ncsRecibidasData.porRegimen,
};
}

View File

@@ -9,7 +9,8 @@ const IMPUESTO_A_OBLIGACION_KEYWORDS: Record<string, { include: string[]; exclud
IVA: { include: ['iva'], exclude: ['diot', 'proveedores de iva', 'informativa'] },
ISR: { include: ['isr'], exclude: ['retenciones', 'asimilados a salarios'] },
IEPS: { include: ['ieps'], exclude: [] },
SUELDOS: { include: ['sueldos', 'salarios', 'nómina'], exclude: [] },
ISN: { include: ['isn', 'sueldos', 'salarios', 'nómina'], exclude: [] },
ISH: { include: [], exclude: [] },
DIOT: { include: ['diot', 'proveedores de iva'], exclude: [] },
OTRO: { include: [], exclude: [] },
};
@@ -93,7 +94,7 @@ async function completarObligacionesPorDeclaracion(
* adicional, no reemplaza.
*/
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'SUELDOS' | 'DIOT' | 'OTRO';
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'ISN' | 'DIOT' | 'OTRO' | 'ISH';
export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'semestral' | 'anual';
@@ -123,17 +124,19 @@ const IMPUESTO_A_PREFIJO_DECL: Record<string, string[]> = {
IVA: ['decl-iva'],
ISR: ['decl-isr'],
IEPS: ['decl-ieps'],
SUELDOS: ['decl-sueldos'],
ISN: ['decl-isn'],
DIOT: ['diot'],
OTRO: [],
ISH: [],
};
const IMPUESTO_A_PREFIJO_PAGO: Record<string, string[]> = {
IVA: ['pago-iva'],
ISR: ['pago-isr'],
IEPS: ['pago-ieps'],
SUELDOS: [], // sueldos solo es declaración informativa, no tiene pago provisional
ISN: [], // ISN solo es declaración informativa, no tiene pago provisional
DIOT: [],
OTRO: [],
ISH: [],
};
/**

View File

@@ -1,5 +1,6 @@
import type { Pool } from 'pg';
import { prisma } from '../config/database.js';
import { materializarPeriodos } from './tareas.service.js';
export interface ContribuyentesStats {
totalContribuyentes: number;
@@ -210,30 +211,62 @@ export async function getMisAsignados(
const inicioMes = `${_año}-${String(_mes).padStart(2, '0')}-01`;
const finMes = new Date(_año, _mes, 0).toISOString().split('T')[0];
// Materializar periodos de tareas antes de contar (evita que tareas sin
// registro en tarea_periodos aparezcan como 0).
await Promise.all(ids.map(id => materializarPeriodos(pool, id).catch(() => {})));
const { rows: stats } = await pool.query(
`WITH obl AS (
SELECT oc.contribuyente_id,
COUNT(*) FILTER (WHERE op.completada = false AND op.periodo = $1)::int AS pendientes,
COUNT(*) FILTER (WHERE op.completada = false AND op.periodo < $1)::int AS atrasadas,
COUNT(*) FILTER (WHERE op.completada = true AND op.periodo = $1)::int AS completadas
FROM obligaciones_contribuyente oc
LEFT JOIN obligacion_periodos op ON op.obligacion_id = oc.id
WHERE oc.contribuyente_id = ANY($4::uuid[]) AND oc.activa = true
GROUP BY oc.contribuyente_id
`WITH obligaciones_activas AS (
SELECT id, contribuyente_id FROM obligaciones_contribuyente
WHERE contribuyente_id = ANY($4::uuid[]) AND activa = true
),
op_actual AS (
SELECT obligacion_id, completada FROM obligacion_periodos
WHERE obligacion_id IN (SELECT id FROM obligaciones_activas) AND periodo = $1
),
op_atrasadas AS (
SELECT obligacion_id, COUNT(*) as atrasadas FROM obligacion_periodos
WHERE obligacion_id IN (SELECT id FROM obligaciones_activas) AND periodo < $1 AND completada = false
GROUP BY obligacion_id
),
obl AS (
SELECT oa.contribuyente_id,
COUNT(*) FILTER (WHERE op_a.completada IS NULL OR op_a.completada = false)::int AS pendientes,
COALESCE(SUM(op_atr.atrasadas), 0)::int AS atrasadas,
COUNT(*) FILTER (WHERE op_a.completada = true)::int AS completadas
FROM obligaciones_activas oa
LEFT JOIN op_actual op_a ON op_a.obligacion_id = oa.id
LEFT JOIN op_atrasadas op_atr ON op_atr.obligacion_id = oa.id
GROUP BY oa.contribuyente_id
),
tareas_activas AS (
SELECT id, contribuyente_id FROM tareas_catalogo
WHERE contribuyente_id = ANY($4::uuid[]) AND active = true
),
tar_actual AS (
SELECT tarea_id, completada FROM tarea_periodos
WHERE tarea_id IN (SELECT id FROM tareas_activas)
AND fecha_limite BETWEEN $2::date AND $3::date
),
tar_atrasadas AS (
SELECT tarea_id, COUNT(*) as atrasadas FROM tarea_periodos
WHERE tarea_id IN (SELECT id FROM tareas_activas)
AND fecha_limite < $2::date AND completada = false
GROUP BY tarea_id
),
tar AS (
SELECT tc.contribuyente_id,
COUNT(*) FILTER (WHERE tp.completada = false AND tp.fecha_limite BETWEEN $2::date AND $3::date)::int AS pendientes,
COUNT(*) FILTER (WHERE tp.completada = false AND tp.fecha_limite < $2::date)::int AS atrasadas,
COUNT(*) FILTER (WHERE tp.completada = true AND tp.fecha_limite BETWEEN $2::date AND $3::date)::int AS completadas
FROM tareas_catalogo tc
LEFT JOIN tarea_periodos tp ON tp.tarea_id = tc.id
WHERE tc.contribuyente_id = ANY($4::uuid[]) AND tc.active = true
GROUP BY tc.contribuyente_id
SELECT ta.contribuyente_id,
COUNT(*) FILTER (WHERE tar_a.completada IS NULL OR tar_a.completada = false)::int AS pendientes,
COALESCE(SUM(tar_atr.atrasadas), 0)::int AS atrasadas,
COUNT(*) FILTER (WHERE tar_a.completada = true)::int AS completadas
FROM tareas_activas ta
LEFT JOIN tar_actual tar_a ON tar_a.tarea_id = ta.id
LEFT JOIN tar_atrasadas tar_atr ON tar_atr.tarea_id = ta.id
GROUP BY ta.contribuyente_id
)
SELECT
obl.contribuyente_id AS obl_id, obl.pendientes AS obl_pen, obl.atrasadas AS obl_atr, obl.completadas AS obl_com,
tar.contribuyente_id AS tar_id, tar.pendientes AS tar_pen, tar.atrasadas AS tar_atr, tar.completadas AS tar_com
obl.contribuyente_id AS obl_id, obl.pendientes AS obl_pen, obl.atrasadas AS obl_atr, obl.completadas AS obl_com,
tar.contribuyente_id AS tar_id, tar.pendientes AS tar_pen, tar.atrasadas AS tar_atr, tar.completadas AS tar_com
FROM obl
FULL OUTER JOIN tar ON tar.contribuyente_id = obl.contribuyente_id`,
[periodoMes, inicioMes, finMes, ids],

View File

@@ -193,6 +193,19 @@ export const emailService = {
);
},
/** Clientes reciben aviso cuando se sube papelería que requiere su aprobación. */
sendPapeleriaAprobacionClienteRequerida: async (
to: string,
data: import('./templates/papeleria.js').PapeleriaAprobacionClienteRequeridaData,
) => {
const { papeleriaAprobacionClienteRequeridaEmail } = await import('./templates/papeleria.js');
await sendEmail(
to,
`📋 Documento pendiente de tu aprobación — ${data.contribuyenteRfc}`,
papeleriaAprobacionClienteRequeridaEmail(data),
);
},
/**
* Cron 8:30 AM — alertas fiscales nuevas activadas hoy. Envía un solo
* correo por destinatario con el batch completo. Caller debe deduplicar

View File

@@ -55,3 +55,32 @@ export function papeleriaDecisionEmail(d: PapeleriaDecisionData): string {
`;
return baseTemplate(body);
}
export interface PapeleriaAprobacionClienteRequeridaData {
contribuyenteRfc: string;
contribuyenteNombre: string;
despachoNombre?: string;
nombreDocumento: string;
descripcion: string | null;
periodo: string;
subidoPor: string;
link: string;
}
export function papeleriaAprobacionClienteRequeridaEmail(d: PapeleriaAprobacionClienteRequeridaData): string {
const body = `
${heading('Documento pendiente de tu aprobación')}
<p>${d.subidoPor} subió un documento que requiere tu aprobación como cliente:</p>
<ul>
<li><strong>Documento:</strong> ${d.nombreDocumento}</li>
<li><strong>Contribuyente:</strong> ${d.contribuyenteNombre} (${d.contribuyenteRfc})</li>
<li><strong>Periodo:</strong> ${d.periodo}</li>
${d.descripcion ? `<li><strong>Descripción:</strong> ${d.descripcion}</li>` : ''}
</ul>
${infoBox('Revisa el documento y márcalo como aprobado o rechazado desde la sección de Documentos del despacho.')}
<div style="margin-top: 24px;">
${primaryButton('Ver documento', d.link)}
</div>
`;
return baseTemplate(body);
}

View File

@@ -18,11 +18,11 @@ export async function exportCfdisToExcel(
params.push(filters.estado);
}
if (filters.fechaInicio) {
whereClause += ` AND fecha_emision >= $${paramIndex++}`;
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
whereClause += ` AND fecha_emision <= $${paramIndex++}`;
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= $${paramIndex++}`;
params.push(filters.fechaFin);
}
@@ -74,7 +74,7 @@ export async function exportCfdisToExcel(
cfdis.forEach((cfdi: any) => {
sheet.addRow({
...cfdi,
fecha_emision: new Date(cfdi.fecha_emision).toLocaleDateString('es-MX'),
fecha_emision: new Date(new Date(cfdi.fecha_emision).getTime() - 60*60*1000).toLocaleDateString('es-MX'),
subtotal: Number(cfdi.subtotal),
subtotal_mxn: Number(cfdi.subtotal_mxn),
iva_traslado: Number(cfdi.iva_traslado),
@@ -103,7 +103,7 @@ export async function exportReporteToExcel(
COALESCE(SUM(CASE WHEN type = 'EMITIDO' AND tipo_comprobante = 'I' THEN subtotal_mxn ELSE 0 END), 0) as ingresos,
COALESCE(SUM(CASE WHEN type = 'RECIBIDO' AND tipo_comprobante = 'I' THEN subtotal_mxn ELSE 0 END), 0) as egresos
FROM cfdis
WHERE status NOT IN ('Cancelado', '0') AND fecha_emision BETWEEN $1 AND $2
WHERE status NOT IN ('Cancelado', '0') AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') BETWEEN $1 AND $2
`, [fechaInicio, fechaFin]);
sheet.columns = [

View File

@@ -315,7 +315,7 @@ export async function createInvoice(
unit_key: item.unitKey || 'E48',
unit_name: item.unitName || 'Servicio',
price: item.price,
tax_included: item.taxIncluded ?? true,
tax_included: item.taxIncluded ?? false,
taxes: item.taxes?.map(t => ({
type: t.type,
rate: t.rate,
@@ -323,6 +323,7 @@ export async function createInvoice(
...(t.withholding ? { withholding: true } : {}),
})) || [{ type: 'IVA', rate: 0.16 }],
},
...((data as any).cuentaPredial ? { property_tax_account: (data as any).cuentaPredial } : {}),
}));
}
@@ -340,6 +341,7 @@ export async function createInvoice(
if (data.series) invoiceData.series = data.series;
if (data.folioNumber) invoiceData.folio_number = data.folioNumber;
if ((data as any).fechaEmision) invoiceData.date = (data as any).fechaEmision;
// Documentos relacionados (Ingreso / Egreso / Pago / Traslado).
if (data.relatedDocuments?.length) {

View File

@@ -1,4 +1,4 @@
import type { Pool } from 'pg';
import type { Pool, PoolClient } from 'pg';
import type { IvaMensual, IsrMensual, ResumenIva, IvaRegimenDetalle, ResumenIsr } from '@horux/shared';
import { getRegimenesIgnoradosClaves } from './regimen.service.js';
import {
@@ -22,7 +22,7 @@ const VIGENTE = `status NOT IN ('Cancelado', '0')`;
// - otros tipos (I, E, T, N): fecha_emision del comprobante
// El CASE se evalúa por fila, garantizando que un P emitido en mayo por un pago
// real de noviembre quede contabilizado en noviembre.
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN fecha_pago_p ELSE fecha_emision END`;
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN (fecha_pago_p - interval '1 hour') ELSE COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') END`;
const FECHA_RANGO = `${FECHA_EFECTIVA} >= $1::date AND ${FECHA_EFECTIVA} < ($2::date + interval '1 day')`;
const FECHA_RANGO_CONCILIACION = `id_conciliacion IS NOT NULL AND id_conciliacion IN (
SELECT id FROM conciliaciones WHERE fecha_de_pago >= $1::date AND fecha_de_pago < ($2::date + interval '1 day')
@@ -106,32 +106,40 @@ const SUM_E_REFERENCING_TRAS = (
esLadoE: string,
considerarActivos: boolean,
considerarNCs: boolean,
) => `COALESCE((
SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')})
FROM cfdis e
WHERE e.tipo_comprobante = 'E'
AND e.metodo_pago = 'PUE'
AND e.status NOT IN ('Cancelado', '0')
AND ${esLadoE}
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND date_trunc('month', e.fecha_emision)
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
), 0)`;
) => {
if (!considerarNCs) return '0';
return `COALESCE((
SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')})
FROM cfdis e
WHERE e.tipo_comprobante = 'E'
AND e.metodo_pago = 'PUE'
AND e.status NOT IN ('Cancelado', '0')
AND ${esLadoE}
AND e.cfdis_relacionados IS NOT NULL
AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)]
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour'))
= date_trunc('month', COALESCE(cfdis.fecha_efectiva, cfdis.fecha_emision - interval '1 hour'))${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
), 0)`;
};
const SUM_E_REFERENCING_RET = (
esLadoE: string,
considerarActivos: boolean,
considerarNCs: boolean,
) => `COALESCE((
SELECT SUM(${IVA_RET_EXPR_ALIAS('e')})
FROM cfdis e
WHERE e.tipo_comprobante = 'E'
AND e.metodo_pago = 'PUE'
AND e.status NOT IN ('Cancelado', '0')
AND ${esLadoE}
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND date_trunc('month', e.fecha_emision)
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
), 0)`;
) => {
if (!considerarNCs) return '0';
return `COALESCE((
SELECT SUM(${IVA_RET_EXPR_ALIAS('e')})
FROM cfdis e
WHERE e.tipo_comprobante = 'E'
AND e.metodo_pago = 'PUE'
AND e.status NOT IN ('Cancelado', '0')
AND ${esLadoE}
AND e.cfdis_relacionados IS NOT NULL
AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)]
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour'))
= date_trunc('month', COALESCE(cfdis.fecha_efectiva, cfdis.fecha_emision - interval '1 hour'))${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
), 0)`;
};
// Régimen del contribuyente según su lado: emisor/receptor del CFDI.
// Usa el RFC del contribuyente (via `ctx.esEmisor`/`ctx.esReceptor`) para
// determinar el lado, no el `type` de BD.
@@ -152,16 +160,20 @@ const HAS_E_REFERENCING_MISMO_MES = (
esLadoE: string,
considerarActivos: boolean,
considerarNCs: boolean,
) => `EXISTS (
SELECT 1 FROM cfdis e
WHERE e.tipo_comprobante = 'E'
AND e.metodo_pago = 'PUE'
AND e.status NOT IN ('Cancelado', '0')
AND ${esLadoE}
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND date_trunc('month', e.fecha_emision)
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
)`;
) => {
if (!considerarNCs) return 'FALSE';
return `EXISTS (
SELECT 1 FROM cfdis e
WHERE e.tipo_comprobante = 'E'
AND e.metodo_pago = 'PUE'
AND e.status NOT IN ('Cancelado', '0')
AND ${esLadoE}
AND e.cfdis_relacionados IS NOT NULL
AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)]
AND date_trunc('month', e.fecha_emision)
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
)`;
};
// Atribución por lado usando RFC en lugar de `type`. Los buckets son
// factories que reciben el context del contribuyente:
@@ -397,8 +409,8 @@ export async function getIvaMensual(
const añoEnd = `${año}-12-31`;
const extra = buildExtraFilters(considerarActivos, considerarNCs);
const [{ rows: causadoRows }, { rows: acreditableRows }] = await Promise.all([
pool.query<{ mes: number; trasladado: string; retencion: string }>(`
const { rows: causadoRows } = await withJitOff(pool, (client) =>
client.query<{ mes: number; trasladado: string; retencion: string }>(`
SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes,
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
@@ -407,8 +419,10 @@ export async function getIvaMensual(
AND ${VIGENTE} AND ${FR}${extra}
AND (${REGIMEN_TENANT}) = ANY($3)
GROUP BY mes
`, [añoStart, añoEnd, TODOS_REGIMENES]),
pool.query<{ mes: number; trasladado: string; retencion: string }>(`
`, [añoStart, añoEnd, TODOS_REGIMENES])
);
const { rows: acreditableRows } = await withJitOff(pool, (client) =>
client.query<{ mes: number; trasladado: string; retencion: string }>(`
SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes,
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
@@ -417,8 +431,8 @@ export async function getIvaMensual(
AND ${VIGENTE} AND ${FR}${extra}
AND (${REGIMEN_TENANT}) = ANY($3)
GROUP BY mes
`, [añoStart, añoEnd, TODOS_REGIMENES]),
]);
`, [añoStart, añoEnd, TODOS_REGIMENES])
);
perMes = new Map();
for (const row of causadoRows) {
@@ -648,20 +662,22 @@ async function readResumenIvaFromCache(
const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
const REGIMEN_TENANT = regimenTenantExpr(ctx);
const acumRow = (await pool.query(`
SELECT
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
(
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) -
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0)
) as total
FROM cfdis
WHERE ${VIGENTE}
AND (${REGIMEN_TENANT}) = ANY($3)
AND ${acumFR}
AND (${ctx.esEmisor} OR ${ctx.esReceptor})
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])).rows[0];
const acumRow = (await withJitOff(pool, (client) =>
client.query(`
SELECT
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
(
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) -
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0)
) as total
FROM cfdis
WHERE ${VIGENTE}
AND (${REGIMEN_TENANT}) = ANY($3)
AND ${acumFR}
AND (${ctx.esEmisor} OR ${ctx.esReceptor})
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])
)).rows[0];
// Cache hit retorna 0/empty para los surface IVA No Acreditable. El cache
// aún no persiste esos campos — si se hace crítico para BI, agregar columna
@@ -698,6 +714,29 @@ async function readResumenIvaFromCache(
*
* Algebraicamente: T A R == dashboard.balance, céntimo por céntimo.
*/
/**
* Ejecuta un callback con un client de pool con JIT desactivado (SET LOCAL jit = off).
* Usa una transacción implícita para que el SET LOCAL se restaure automáticamente
* al liberar la conexión. Esto evita que PostgreSQL compile JIT para queries con
* muchos subplans (correlacionados), lo cual puede tardar >15s en queries con
* costo estimado muy alto aunque la ejecución real sea rápida.
*/
async function withJitOff<T>(pool: Pool, fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('SET LOCAL jit = off');
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
throw e;
} finally {
client.release();
}
}
export async function getResumenIva(
pool: Pool,
fechaInicio: string,
@@ -725,10 +764,10 @@ export async function getResumenIva(
if (cached) return cached;
}
// Una query por lado (causado / acreditable). Filtro por RFC via
// ctx.esEmisor/esReceptor (embedded en buckets/signed exprs).
const [{ rows: causadoRows }, { rows: acreditableRows }] = await Promise.all([
pool.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
// Queries con JIT off: evitan compilación JIT de >15s en queries con muchos
// subplans correlacionados (activado por costo estimado >100k).
const { rows: causadoRows } = await withJitOff(pool, (client) =>
client.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
SELECT ${REGIMEN_TENANT} as regimen,
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
@@ -737,8 +776,10 @@ export async function getResumenIva(
AND ${VIGENTE} AND ${FR}${extra}
AND (${REGIMEN_TENANT}) = ANY($3)
GROUP BY ${REGIMEN_TENANT}
`, [fechaInicio, fechaFin, TODOS_REGIMENES]),
pool.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
`, [fechaInicio, fechaFin, TODOS_REGIMENES])
);
const { rows: acreditableRows } = await withJitOff(pool, (client) =>
client.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
SELECT ${REGIMEN_TENANT} as regimen,
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
@@ -747,8 +788,8 @@ export async function getResumenIva(
AND ${VIGENTE} AND ${FR}${extra}
AND (${REGIMEN_TENANT}) = ANY($3)
GROUP BY ${REGIMEN_TENANT}
`, [fechaInicio, fechaFin, TODOS_REGIMENES]),
]);
`, [fechaInicio, fechaFin, TODOS_REGIMENES])
);
// Combinar por régimen: el set de régimenes posibles es la unión de ambos lados.
type Acc = { trasCausado: number; retCausado: number; trasAcreditable: number; retAcreditable: number };
@@ -799,20 +840,22 @@ export async function getResumenIva(
// Acumulado anual (misma fórmula T A R, pero rango = enero → fechaFin).
const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
const { rows: [acumRow] } = await pool.query(`
SELECT
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
(
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) -
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0)
) as total
FROM cfdis
WHERE ${VIGENTE}
AND (${REGIMEN_TENANT}) = ANY($3)
AND ${acumFR}${extra}
AND (${ctx.esEmisor} OR ${ctx.esReceptor})
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES]);
const { rows: [acumRow] } = await withJitOff(pool, (client) =>
client.query(`
SELECT
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
(
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) -
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0)
) as total
FROM cfdis
WHERE ${VIGENTE}
AND (${REGIMEN_TENANT}) = ANY($3)
AND ${acumFR}${extra}
AND (${ctx.esEmisor} OR ${ctx.esReceptor})
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])
);
// IVA No Acreditable surface (Art. 5 LIVA fracción I + Art. 27 fracción III LISR).
// No participa en `resultado` — ya excluido del `acreditable` arriba via filtro

View File

@@ -92,8 +92,8 @@ export async function computeMetricaMensual(
COUNT(*) FILTER (WHERE status = 'Vigente') AS vigentes,
COUNT(*) FILTER (WHERE status IN ('Cancelado','0')) AS cancelados
FROM cfdis
WHERE EXTRACT(YEAR FROM (CASE WHEN tipo_comprobante='P' THEN fecha_pago_p ELSE fecha_emision END)) = $1
AND EXTRACT(MONTH FROM (CASE WHEN tipo_comprobante='P' THEN fecha_pago_p ELSE fecha_emision END)) = $2
WHERE EXTRACT(YEAR FROM (CASE WHEN tipo_comprobante='P' THEN (fecha_pago_p - interval '1 hour') ELSE COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') END)) = $1
AND EXTRACT(MONTH FROM (CASE WHEN tipo_comprobante='P' THEN (fecha_pago_p - interval '1 hour') ELSE COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') END)) = $2
AND contribuyente_id = $3
GROUP BY 1, 2
`, [anio, mes, safeContrib]);
@@ -227,7 +227,7 @@ export async function backfillTenant(
for (const c of contribs) {
const { rows: [rango] } = await pool.query<{ min_anio: number | null }>(
`SELECT EXTRACT(YEAR FROM MIN(fecha_emision))::int AS min_anio
`SELECT EXTRACT(YEAR FROM MIN(fecha_emision - interval '1 hour'))::int AS min_anio
FROM cfdis WHERE contribuyente_id = $1`,
[c.entidad_id],
);

View File

@@ -138,6 +138,8 @@ export interface ObligacionContribuyente {
completadaPor: string | null;
periodoCompletado: string | null;
createdAt?: string;
auxiliarAsignadoId?: string | null;
auxiliarAsignadoNombre?: string | null;
}
export function getCatalogo(): ObligacionFiscal[] {
@@ -146,15 +148,18 @@ export function getCatalogo(): ObligacionFiscal[] {
export async function getObligaciones(pool: Pool, contribuyenteId: string): Promise<ObligacionContribuyente[]> {
const { rows } = await pool.query(`
SELECT id, contribuyente_id AS "contribuyenteId", catalogo_id AS "catalogoId",
nombre, fundamento, frecuencia, fecha_limite AS "fechaLimite", categoria,
activa, es_recomendada AS "esRecomendada", es_custom AS "esCustom",
completada, completada_at AS "completadaAt", completada_por AS "completadaPor",
periodo_completado AS "periodoCompletado",
created_at AS "createdAt"
FROM obligaciones_contribuyente
WHERE contribuyente_id = $1
ORDER BY categoria, nombre
SELECT
oc.id, oc.contribuyente_id AS "contribuyenteId", oc.catalogo_id AS "catalogoId",
oc.nombre, oc.fundamento, oc.frecuencia, oc.fecha_limite AS "fechaLimite", oc.categoria,
oc.activa, oc.es_recomendada AS "esRecomendada", oc.es_custom AS "esCustom",
oc.completada, oc.completada_at AS "completadaAt", oc.completada_por AS "completadaPor",
oc.periodo_completado AS "periodoCompletado",
oc.created_at AS "createdAt",
oa.auxiliar_user_id AS "auxiliarAsignadoId"
FROM obligaciones_contribuyente oc
LEFT JOIN obligacion_asignaciones oa ON oa.obligacion_id = oc.id
WHERE oc.contribuyente_id = $1
ORDER BY oc.categoria, oc.nombre
`, [contribuyenteId]);
return rows;
}

View File

@@ -27,6 +27,11 @@ export interface PapeleriaItem {
aprobadoPor: string | null;
aprobadoAt: Date | null;
comentarioRechazo: string | null;
requiereAprobacionCliente: boolean;
estadoCliente: EstadoPapeleria | null;
aprobadoPorCliente: string | null;
aprobadoAtCliente: Date | null;
comentarioRechazoCliente: string | null;
subidoPor: string;
createdAt: Date;
}
@@ -36,6 +41,7 @@ const SELECT = `
archivo_filename, archivo_mime, archivo_size,
anio, mes,
requiere_aprobacion, estado, aprobado_por, aprobado_at, comentario_rechazo,
requiere_aprobacion_cliente, estado_cliente, aprobado_por_cliente, aprobado_at_cliente, comentario_rechazo_cliente,
subido_por, created_at
`;
@@ -54,6 +60,11 @@ const ROW = (r: any): PapeleriaItem => ({
aprobadoPor: r.aprobado_por,
aprobadoAt: r.aprobado_at,
comentarioRechazo: r.comentario_rechazo,
requiereAprobacionCliente: r.requiere_aprobacion_cliente,
estadoCliente: r.estado_cliente,
aprobadoPorCliente: r.aprobado_por_cliente,
aprobadoAtCliente: r.aprobado_at_cliente,
comentarioRechazoCliente: r.comentario_rechazo_cliente,
subidoPor: r.subido_por,
createdAt: r.created_at,
});
@@ -69,6 +80,7 @@ export interface UploadInput {
anio: number;
mes: number;
requiereAprobacion: boolean;
requiereAprobacionCliente: boolean;
archivo: Buffer;
archivoFilename: string;
archivoMime: string;
@@ -87,12 +99,13 @@ export async function uploadPapeleria(
}
const estadoInicial = input.requiereAprobacion ? 'pendiente' : null;
const estadoClienteInicial = input.requiereAprobacionCliente ? 'pendiente' : null;
const { rows: [r] } = await pool.query(
`INSERT INTO papeleria_trabajo
(contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size,
anio, mes, requiere_aprobacion, estado, subido_por)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
anio, mes, requiere_aprobacion, estado, requiere_aprobacion_cliente, estado_cliente, subido_por)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING ${SELECT}`,
[
sanitizeUuid(input.contribuyenteId),
@@ -106,6 +119,8 @@ export async function uploadPapeleria(
input.mes,
input.requiereAprobacion,
estadoInicial,
input.requiereAprobacionCliente,
estadoClienteInicial,
input.subidoPor,
],
);
@@ -117,6 +132,8 @@ export interface ListFilters {
anio?: number;
mes?: number;
estado?: EstadoPapeleria | 'sin_aprobacion';
entidadIds?: string[];
userRole?: string;
}
export async function listPapeleria(pool: Pool, f: ListFilters): Promise<PapeleriaItem[]> {
@@ -126,10 +143,17 @@ export async function listPapeleria(pool: Pool, f: ListFilters): Promise<Papeler
if (f.anio) { conds.push(`anio = $${i++}`); vals.push(f.anio); }
if (f.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); }
if (f.estado === 'sin_aprobacion') {
conds.push('requiere_aprobacion = false');
conds.push('requiere_aprobacion = false AND requiere_aprobacion_cliente = false');
} else if (f.estado) {
conds.push(`estado = $${i++}`); vals.push(f.estado);
}
if (f.entidadIds && f.entidadIds.length > 0) {
conds.push(`contribuyente_id = ANY($${i++})`);
vals.push(f.entidadIds);
}
if (f.userRole === 'cliente') {
conds.push('requiere_aprobacion_cliente = true');
}
const { rows } = await pool.query(
`SELECT ${SELECT} FROM papeleria_trabajo
WHERE ${conds.join(' AND ')}
@@ -202,6 +226,39 @@ export async function rechazar(
return r ? ROW(r) : null;
}
export async function aprobarCliente(
pool: Pool,
id: number,
userId: string,
): Promise<PapeleriaItem | null> {
const { rows: [r] } = await pool.query(
`UPDATE papeleria_trabajo
SET estado_cliente = 'aprobado', aprobado_por_cliente = $2, aprobado_at_cliente = NOW(),
comentario_rechazo_cliente = NULL
WHERE id = $1 AND requiere_aprobacion_cliente = true
RETURNING ${SELECT}`,
[id, userId],
);
return r ? ROW(r) : null;
}
export async function rechazarCliente(
pool: Pool,
id: number,
userId: string,
comentario: string | null,
): Promise<PapeleriaItem | null> {
const { rows: [r] } = await pool.query(
`UPDATE papeleria_trabajo
SET estado_cliente = 'rechazado', aprobado_por_cliente = $2, aprobado_at_cliente = NOW(),
comentario_rechazo_cliente = $3
WHERE id = $1 AND requiere_aprobacion_cliente = true
RETURNING ${SELECT}`,
[id, userId, comentario],
);
return r ? ROW(r) : null;
}
export async function eliminar(pool: Pool, id: number): Promise<boolean> {
const { rowCount } = await pool.query(
`DELETE FROM papeleria_trabajo WHERE id = $1`,
@@ -209,3 +266,30 @@ export async function eliminar(pool: Pool, id: number): Promise<boolean> {
);
return (rowCount ?? 0) > 0;
}
/**
* Calcula el estado visual combinado considerando ambas aprobaciones.
*/
export function estadoGlobal(item: PapeleriaItem): 'pendiente' | 'aprobado' | 'rechazado' | null {
const reqOwner = item.requiereAprobacion;
const reqCliente = item.requiereAprobacionCliente;
const estOwner = item.estado;
const estCliente = item.estadoCliente;
if (!reqOwner && !reqCliente) return null;
// Si cualquiera está rechazado, el documento está rechazado
if (estOwner === 'rechazado' || estCliente === 'rechazado') return 'rechazado';
// Si ambos requieren aprobación
if (reqOwner && reqCliente) {
if (estOwner === 'aprobado' && estCliente === 'aprobado') return 'aprobado';
return 'pendiente';
}
// Solo owner
if (reqOwner) return estOwner;
// Solo cliente
return estCliente;
}

View File

@@ -348,6 +348,17 @@ export async function emitInvoiceIfApplicable(paymentId: string): Promise<void>
data: { facturapiInvoiceId: invoice.id },
});
// Enviar factura por email al cliente cuando se factura con datos reales
// (no público en general). Fail-soft: si el envío falla, no bloquea.
if (customer?.email) {
try {
await facturapiService.sendInvoiceByEmail(emitter.id, invoice.id, customer.email);
console.log(`[Invoicing] Factura ${invoice.id} enviada a ${customer.email}`);
} catch (emailErr: any) {
console.error(`[Invoicing] Error enviando factura ${invoice.id} a ${customer.email}:`, emailErr.message || emailErr);
}
}
auditLog({
tenantId: payment.tenantId,
action: 'invoice.emitted_auto',

View File

@@ -45,7 +45,10 @@ export async function getRegimenesActivosClaves(tenantId: string): Promise<strin
/**
* Resuelve las claves de regímenes activos para la alerta de discrepancia.
* Si hay contribuyenteId, lee de contribuyentes.regimen_fiscal (comma-separated).
* Si no, fallback a TenantRegimenActivo (tabla central).
* Si no, combina TenantRegimenActivo (tabla central) con los regímenes de
* todos los contribuyentes activos del tenant. Esto evita que la alerta
* aparezca en el correo por-contribuyente pero desaparezca en el dashboard
* cuando no hay un contribuyente seleccionado.
*/
export async function getRegimenesActivosClavesEfectivos(
tenantId: string,
@@ -61,9 +64,49 @@ export async function getRegimenesActivosClavesEfectivos(
if (rows.length > 0 && rows[0].regimen_fiscal) {
return rows[0].regimen_fiscal.split(',').map((c: string) => c.trim()).filter(Boolean);
}
return [];
// Fallback: si el contribuyente no tiene regimen_fiscal, usamos los del tenant
// para no perder la alerta si el campo quedó vacío accidentalmente.
const tenantRegimenes = await getRegimenesActivosClaves(tenantId);
if (tenantRegimenes.length > 0) return tenantRegimenes;
const { rows: allRows } = await pool.query(
`SELECT DISTINCT regimen_fiscal FROM contribuyentes WHERE regimen_fiscal IS NOT NULL AND regimen_fiscal <> ''`,
);
const set = new Set<string>();
for (const row of allRows) {
if (row.regimen_fiscal) {
for (const clave of row.regimen_fiscal.split(',')) {
const trimmed = clave.trim();
if (trimmed) set.add(trimmed);
}
}
}
return Array.from(set);
}
return getRegimenesActivosClaves(tenantId);
const tenantRegimenes = await getRegimenesActivosClaves(tenantId);
// Fallback: si no hay regímenes configurados a nivel tenant, usamos los
// regímenes de todos los contribuyentes activos del tenant.
if (tenantRegimenes.length > 0) {
return tenantRegimenes;
}
const { rows } = await pool.query(
`SELECT DISTINCT regimen_fiscal FROM contribuyentes WHERE regimen_fiscal IS NOT NULL AND regimen_fiscal <> ''`,
);
const set = new Set<string>();
for (const row of rows) {
if (row.regimen_fiscal) {
for (const clave of row.regimen_fiscal.split(',')) {
const trimmed = clave.trim();
if (trimmed) set.add(trimmed);
}
}
}
return Array.from(set);
}
export async function setRegimenesActivos(tenantId: string, regimenIds: number[]) {

View File

@@ -94,12 +94,12 @@ export async function getFlujoEfectivo(
contribuyenteId?: string | null,
): Promise<FlujoEfectivo> {
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
const RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`;
const RANGO_PAGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`;
const RANGO = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
const RANGO_PAGO = `(fecha_pago_p - interval '1 hour') >= $1::date AND (fecha_pago_p - interval '1 hour') < ($2::date + interval '1 day')`;
const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId);
const { rows: entradasPUE } = await pool.query(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
FROM cfdis
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
AND ${VIGENTE} AND ${RANGO}
@@ -107,7 +107,7 @@ export async function getFlujoEfectivo(
`, [fechaInicio, fechaFin]);
const { rows: entradasPago } = await pool.query(`
SELECT TO_CHAR(fecha_pago_p, 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
SELECT TO_CHAR(fecha_pago_p - interval '1 hour', 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
FROM cfdis
WHERE ${esEmisor} AND tipo_comprobante = 'P'
AND ${VIGENTE} AND ${RANGO_PAGO}
@@ -115,7 +115,7 @@ export async function getFlujoEfectivo(
`, [fechaInicio, fechaFin]);
const { rows: entradasNC } = await pool.query(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
FROM cfdis
WHERE ${esEmisor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
AND COALESCE(cfdi_tipo_relacion, '') <> '07'
@@ -124,7 +124,7 @@ export async function getFlujoEfectivo(
`, [fechaInicio, fechaFin]);
const { rows: salidasPUE } = await pool.query(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
FROM cfdis
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
AND ${VIGENTE} AND ${RANGO}
@@ -132,7 +132,7 @@ export async function getFlujoEfectivo(
`, [fechaInicio, fechaFin]);
const { rows: salidasPago } = await pool.query(`
SELECT TO_CHAR(fecha_pago_p, 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
SELECT TO_CHAR(fecha_pago_p - interval '1 hour', 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
FROM cfdis
WHERE ${esReceptor} AND tipo_comprobante = 'P'
AND ${VIGENTE} AND ${RANGO_PAGO}
@@ -140,7 +140,7 @@ export async function getFlujoEfectivo(
`, [fechaInicio, fechaFin]);
const { rows: salidasNC } = await pool.query(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
FROM cfdis
WHERE ${esReceptor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
AND COALESCE(cfdi_tipo_relacion, '') <> '07'
@@ -187,8 +187,8 @@ async function calcularFlujoPorMes(pool: Pool, año: number, contribuyenteId?: s
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
const fi = `${año}-01-01`;
const ff = `${año}-12-31`;
const RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`;
const RANGO_PAGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`;
const RANGO = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
const RANGO_PAGO = `(fecha_pago_p - interval '1 hour') >= $1::date AND (fecha_pago_p - interval '1 hour') < ($2::date + interval '1 day')`;
const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId);
const q = async (lado: 'EMITIDO' | 'RECIBIDO', tc: string, campo: string, mp?: string) => {
@@ -198,7 +198,7 @@ async function calcularFlujoPorMes(pool: Pool, año: number, contribuyenteId?: s
const noAnticipo = tc === 'E' ? `AND COALESCE(cfdi_tipo_relacion, '') <> '07'` : '';
const ladoCond = lado === 'EMITIDO' ? esEmisor : esReceptor;
const { rows } = await pool.query(`
SELECT EXTRACT(MONTH FROM ${fechaCol})::int as mes, COALESCE(SUM(${campo}), 0) as total
SELECT EXTRACT(MONTH FROM COALESCE(fecha_efectiva, ${fechaCol} - interval '1 hour'))::int as mes, COALESCE(SUM(${campo}), 0) as total
FROM cfdis
WHERE ${ladoCond} AND tipo_comprobante = '${tc}' ${mpF} ${noAnticipo} AND ${VIGENTE} AND ${rango}
GROUP BY mes
@@ -277,7 +277,7 @@ export async function getConcentradoRfc(
COUNT(*)::int as "cantidadCfdis"
FROM cfdis
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0')
AND fecha_emision BETWEEN $1::date AND $2::date
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') BETWEEN $1::date AND $2::date
GROUP BY rfc_receptor, nombre_receptor
ORDER BY "totalFacturado" DESC
`, [fechaInicio, fechaFin]);
@@ -298,7 +298,7 @@ export async function getConcentradoRfc(
COUNT(*)::int as "cantidadCfdis"
FROM cfdis
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0')
AND fecha_emision BETWEEN $1::date AND $2::date
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') BETWEEN $1::date AND $2::date
GROUP BY rfc_emisor, nombre_emisor
ORDER BY "totalFacturado" DESC
`, [fechaInicio, fechaFin]);
@@ -338,8 +338,8 @@ export async function getCuentasXPagar(
FROM cfdis
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PPD'
AND status NOT IN ('Cancelado', '0')
AND fecha_emision >= $1::date
AND fecha_emision < ($2::date + interval '1 day')
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')
AND COALESCE(saldo_pendiente_mxn, total_mxn) > 0.01
${regimenFilter}
GROUP BY rfc_emisor, nombre_emisor
@@ -365,7 +365,7 @@ export async function getCuentasXPagar(
// ─────────────────────────────────────────────────────────────────────────────
const VIGENTE_ER = `status NOT IN ('Cancelado', '0')`;
const RANGO_FECHA = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`;
const RANGO_FECHA = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
const BASE_MONTO = `COALESCE(subtotal_mxn, 0) - COALESCE(descuento_mxn, 0)`;
function sameDateLastYear(dateStr: string): string {
@@ -842,8 +842,8 @@ export async function getCuentasXCobrar(
FROM cfdis
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PPD'
AND status NOT IN ('Cancelado', '0')
AND fecha_emision >= $1::date
AND fecha_emision < ($2::date + interval '1 day')
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')
AND COALESCE(saldo_pendiente_mxn, total_mxn) > 0.01
${regimenFilter}
GROUP BY rfc_receptor, nombre_receptor

View File

@@ -72,9 +72,17 @@ export async function querySat(
requestType: 'metadata' | 'cfdi' = 'cfdi'
): Promise<QueryResult> {
try {
// El SAT rechaza fechaInicial >= fechaFinal. Como formatDateForSat trunca
// a medianoche, dos fechas dentro del mismo día calendario resultan iguales.
// Ajustamos fechaFin al día siguiente para evitar el error.
let adjustedFechaFin = fechaFin;
if (formatDateForSat(fechaInicio) === formatDateForSat(fechaFin)) {
adjustedFechaFin = new Date(fechaFin.getTime() + 24 * 60 * 60 * 1000);
}
const period = DateTimePeriod.createFromValues(
formatDateForSat(fechaInicio),
formatDateForSat(fechaFin)
formatDateForSat(adjustedFechaFin)
);
const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received');
@@ -239,10 +247,11 @@ export async function downloadSatPackage(
}
/**
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss)
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss).
* El SAT requiere hora 00:00:00; cualquier otra hora causa
* "Fecha final invalida" / "Fecha inicial invalida".
*/
function formatDateForSat(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 00:00:00`;
}

View File

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

View File

@@ -69,6 +69,11 @@ interface CfdiParsed {
cfdisRelacionados: string | null;
conceptos: ConceptoParsed[];
xmlOriginal: string;
// Factura global (InformacionGlobal)
periodicidad: string | null;
mesesGlobal: string | null;
añoGlobal: string | null;
}
interface ConceptoParsed {
@@ -569,6 +574,9 @@ export function parseXml(xmlContent: string, downloadType: 'emitidos' | 'recibid
...nominaData,
conceptos: extractConceptos(comprobante),
xmlOriginal: xmlContent,
periodicidad: comprobante.InformacionGlobal?.['@_Periodicidad'] || null,
mesesGlobal: comprobante.InformacionGlobal?.['@_Meses'] || null,
añoGlobal: comprobante.InformacionGlobal?.['@_Año'] || null,
};
if (!cfdi.uuid) {

View File

@@ -18,7 +18,7 @@ import * as fs from 'fs';
import * as path from 'path';
const POLL_INTERVAL_MS = 60000; // 60 segundos
const MAX_POLL_ATTEMPTS = 45; // 45 minutos máximo (45 × 60s)
const MAX_POLL_ATTEMPTS = 500; // ~8 horas máximo para syncs iniciales grandes
const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años
/**
@@ -121,6 +121,35 @@ async function getOrCreateRfc(pool: Pool, rfc: string, razonSocial: string | nul
return rows[0].id;
}
/**
* Calcula la fecha efectiva de un CFDI para métricas.
* Si tiene InformacionGlobal, usa el año/mes declarado.
* Para bimestral (periodicidad 05), convierte el código 13-18 a mes 2-12.
*/
function calcFechaEfectiva(cfdi: CfdiParsed): Date | null {
if (!cfdi.añoGlobal || !cfdi.mesesGlobal) {
return null;
}
const anio = parseInt(cfdi.añoGlobal, 10);
if (isNaN(anio)) return null;
const mesesStr = cfdi.mesesGlobal;
const mesesParts = mesesStr.split(',').map((s: string) => s.trim());
const ultimoMesStr = mesesParts[mesesParts.length - 1];
let mes = parseInt(ultimoMesStr, 10);
if (isNaN(mes)) return null;
// Bimestral: códigos 13-18 → meses 2,4,6,8,10,12
if (cfdi.periodicidad === '05') {
if (mes >= 13 && mes <= 18) {
mes = (mes - 12) * 2;
}
}
if (mes < 1 || mes > 12) return null;
return new Date(anio, mes - 1, 1);
}
/**
* Guarda los XMLs extraídos del ZIP en disco para respaldo
*/
@@ -212,6 +241,10 @@ async function saveCfdis(
cfdi.subsidioCausado, m(cfdi.subsidioCausado),
cfdi.regimenFiscalEmisor, cfdi.regimenFiscalReceptor,
cfdi.codigoPostalReceptor,
cfdi.periodicidad,
cfdi.mesesGlobal,
cfdi.añoGlobal,
calcFechaEfectiva(cfdi),
cfdi.xmlOriginal,
cfdi.cfdiTipoRelacion, cfdi.cfdisRelacionados,
jobId,
@@ -261,16 +294,17 @@ async function saveCfdis(
subsidio_causado=$78, subsidio_causado_mxn=$79,
regimen_fiscal_emisor=$80, regimen_fiscal_receptor=$81,
codigo_postal_receptor=$82,
xml_original=$83,
cfdi_tipo_relacion=$84, cfdis_relacionados=$85,
last_sat_sync=NOW(), sat_sync_job_id=$86::uuid,
periodicidad=$83, meses_global=$84, año_global=$85, fecha_efectiva=$86,
xml_original=$87,
cfdi_tipo_relacion=$88, cfdis_relacionados=$89,
last_sat_sync=NOW(), sat_sync_job_id=$90::uuid,
actualizado_en=NOW()
WHERE uuid = $1`,
[cfdi.uuid, ...vals]
);
// Re-insert conceptos for updated CFDI
await pool.query(`DELETE FROM cfdi_conceptos WHERE cfdi_id = $1`, [existing[0].id]);
await saveConceptos(pool, existing[0].id, cfdi);
await saveConceptosWithRetry(pool, existing[0].id, cfdi);
updated++;
} else {
// $1-$83 = data fields (year..cfdis_relacionados), $84 = jobId, $85 = contribuyente_id
@@ -310,6 +344,7 @@ async function saveCfdis(
subsidio_causado, subsidio_causado_mxn,
regimen_fiscal_emisor, regimen_fiscal_receptor,
codigo_postal_receptor,
periodicidad, meses_global, año_global, fecha_efectiva,
xml_original,
cfdi_tipo_relacion, cfdis_relacionados,
source, sat_sync_job_id, last_sat_sync, contribuyente_id
@@ -321,7 +356,7 @@ async function saveCfdis(
);
// Get the inserted cfdi id and save conceptos
const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE uuid = $1`, [cfdi.uuid]);
if (newRow) await saveConceptos(pool, newRow.id, cfdi);
if (newRow) await saveConceptosWithRetry(pool, newRow.id, cfdi);
inserted++;
}
// Marcar el mes para recompute de métricas pre-calculadas. Para tipo P
@@ -404,6 +439,26 @@ async function saveConceptos(pool: Pool, cfdiId: number, cfdi: CfdiParsed): Prom
}
}
/** Reintenta saveConceptos con backoff exponencial para tolerar errores transitorios. */
async function saveConceptosWithRetry(pool: Pool, cfdiId: number, cfdi: CfdiParsed, maxRetries = 3): Promise<void> {
let lastError: any;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await saveConceptos(pool, cfdiId, cfdi);
return;
} catch (err: any) {
lastError = err;
if (attempt < maxRetries) {
const delay = 500 * attempt;
console.warn(`[SAT] saveConceptos falló (intento ${attempt}/${maxRetries}) para CFDI ${cfdi.uuid}, reintentando en ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
}
}
}
console.error(`[SAT] saveConceptos falló definitivamente después de ${maxRetries} intentos para CFDI ${cfdi.uuid}:`, lastError?.message || lastError);
throw lastError;
}
/**
* Guarda/actualiza CFDIs desde metadata del SAT.
* - Si el CFDI no existe: inserta con datos básicos de metadata (sin XML).
@@ -770,6 +825,26 @@ async function determineChunkMonths(
fechaInicio: Date,
fechaFin: Date,
): Promise<number> {
// Si el job previo del mismo tenant/contribuyente ya tenía chunks,
// inferimos que el volumen es alto y usamos 6 meses directamente
// para evitar el sondeo lento del SAT.
const previousJob = await prisma.satSyncJob.findFirst({
where: {
tenantId: ctx.tenantId,
contribuyenteId: ctx.contribuyenteId ?? null,
id: { not: jobId },
status: 'completed',
cfdisFound: { gt: 0 },
},
orderBy: { createdAt: 'desc' },
select: { satRequestIds: true, cfdisFound: true },
});
if (previousJob?.satRequestIds && Object.keys(previousJob.satRequestIds as Record<string, string>).length > 0) {
const chunkMonths = (previousJob.cfdisFound || 0) > 15_000 ? 3 : 6;
console.log(`[SAT] Reutilizando estrategia de job previo (${previousJob.cfdisFound} CFDIs) → bloques de ${chunkMonths} meses`);
return chunkMonths;
}
const THRESHOLD = 15_000;
let totalCfdis = 0;

View File

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

View File

@@ -17,6 +17,8 @@ export interface TareaCatalogo {
active: boolean;
orden: number;
createdAt: Date;
auxiliarAsignadoId?: string | null;
auxiliarAsignadoNombre?: string | null;
}
export interface TareaPeriodo {
@@ -47,6 +49,8 @@ const ROW_TO_TAREA = (r: any): TareaCatalogo => ({
active: r.active,
orden: r.orden,
createdAt: r.created_at,
auxiliarAsignadoId: r.auxiliarAsignadoId ?? null,
auxiliarAsignadoNombre: r.auxiliarAsignadoNombre ?? null,
});
const ROW_TO_PERIODO = (r: any): TareaPeriodo => ({
@@ -68,9 +72,13 @@ function sanitizeUuid(id: string): string {
export async function listTareas(pool: Pool, contribuyenteId: string): Promise<TareaCatalogo[]> {
const { rows } = await pool.query(
`SELECT * FROM tareas_catalogo
WHERE contribuyente_id = $1 AND active = true
ORDER BY orden, nombre`,
`SELECT
tc.*,
ta.auxiliar_user_id AS "auxiliarAsignadoId"
FROM tareas_catalogo tc
LEFT JOIN tarea_asignaciones ta ON ta.tarea_id = tc.id
WHERE tc.contribuyente_id = $1 AND tc.active = true
ORDER BY tc.orden, tc.nombre`,
[sanitizeUuid(contribuyenteId)],
);
return rows.map(ROW_TO_TAREA);
@@ -272,6 +280,59 @@ export async function listTareasConPeriodoActual(
return tareas.map(t => ({ ...t, periodoActual: periodos.get(t.id) ?? null }));
}
export interface TareaConContribuyente extends TareaConPeriodo {
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
}
/**
* Lee tareas activas con periodo actual para una lista de contribuyentes.
* Útil para la vista "Mis Tareas".
*/
export async function listTareasConPeriodoPorContribuyentes(
pool: Pool,
contribuyenteIds: string[],
): Promise<TareaConContribuyente[]> {
if (contribuyenteIds.length === 0) return [];
// Materializar periodos para cada contribuyente en paralelo
await Promise.all(contribuyenteIds.map(id => materializarPeriodos(pool, id)));
const { rows: tareasRows } = await pool.query(
`SELECT tc.*, c.entidad_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial"
FROM tareas_catalogo tc
JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
WHERE tc.contribuyente_id = ANY($1::uuid[]) AND tc.active = true
ORDER BY c.rfc, tc.orden, tc.nombre`,
[contribuyenteIds],
);
if (tareasRows.length === 0) return [];
const tareaIds = tareasRows.map((r: any) => r.id);
const today = new Date().toISOString().split('T')[0];
const { rows: periodoRows } = await pool.query(
`SELECT DISTINCT ON (tarea_id) *
FROM tarea_periodos
WHERE tarea_id = ANY($1::uuid[])
AND (completada = false OR fecha_limite >= $2::date)
ORDER BY tarea_id, fecha_limite ASC`,
[tareaIds, today],
);
const periodos = new Map(periodoRows.map((r: any) => [r.tarea_id, ROW_TO_PERIODO(r)]));
return tareasRows.map((r: any) => ({
...ROW_TO_TAREA(r),
contribuyenteId: r.contribuyenteId,
contribuyenteRfc: r.contribuyenteRfc,
contribuyenteRazonSocial: r.contribuyenteRazonSocial,
periodoActual: periodos.get(r.id) ?? null,
}));
}
// ─── Completar / descompletar periodo ───
const ROLES_SUPERVISOR = new Set(['owner', 'cfo', 'supervisor']);

View File

@@ -2,6 +2,7 @@ import { prisma } from '../config/database.js';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto';
import { getDespachoPlanLimits } from './plan-catalogo.service.js';
import { emailService } from './email/email.service.js';
import type { UserListItem, UserInvite, UserUpdate, Role } from '@horux/shared';
/**
@@ -99,6 +100,13 @@ export async function inviteUsuario(tenantId: string, data: UserInvite): Promise
lastTenantId: tenantId,
},
});
// Enviar correo de bienvenida con credenciales (non-blocking)
emailService.sendWelcome(data.email, {
nombre: data.nombre,
email: data.email,
tempPassword,
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
}
const rolId = await getRolId(data.role);
@@ -224,8 +232,9 @@ export async function createUsuarioGlobal(
// Si el email ya existe como user global, agregamos membership en este tenant
let user = await prisma.user.findUnique({ where: { email: data.email } });
let tempPassword: string | null = null;
if (!user) {
const tempPassword = randomBytes(4).toString('hex');
tempPassword = randomBytes(4).toString('hex');
const passwordHash = await bcrypt.hash(tempPassword, 12);
user = await prisma.user.create({
data: {
@@ -235,6 +244,13 @@ export async function createUsuarioGlobal(
lastTenantId: tenantId,
},
});
// Enviar correo de bienvenida con credenciales (non-blocking)
emailService.sendWelcome(data.email, {
nombre: data.nombre,
email: data.email,
tempPassword,
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
}
const rolId = await getRolId(data.role);

View File

@@ -0,0 +1,252 @@
'use client';
import { useEffect, useState } from 'react';
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
import { getAllInvitations, createInvitation, cancelInvitation } from '@/lib/api/trial-invitations';
import { getTenants } from '@/lib/api/tenants';
import { Gift, X, Clock, CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react';
interface TenantOption {
id: string;
nombre: string;
rfc: string;
}
interface Invitation {
id: string;
tenantId: string;
plan: string;
durationDays: number;
status: string;
token: string;
sentAt: string;
expiresAt: string;
acceptedAt: string | null;
tenant: {
nombre: string;
rfc: string;
} | null;
}
export default function InvitacionesTrialTab() {
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [selectedTenantId, setSelectedTenantId] = useState('');
const [durationDays, setDurationDays] = useState('30');
const [plan, setPlan] = useState('business_control');
const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
useEffect(() => {
loadData();
}, []);
async function loadData() {
setLoading(true);
try {
const [tenantsData, invitationsData] = await Promise.all([
getTenants(),
getAllInvitations(),
]);
setTenants(tenantsData);
setInvitations(invitationsData);
} catch (err: any) {
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al cargar datos' });
} finally {
setLoading(false);
}
}
async function handleCreate() {
if (!selectedTenantId || !durationDays) {
setMessage({ kind: 'err', text: 'Selecciona un despacho y duración' });
return;
}
setCreating(true);
setMessage(null);
try {
await createInvitation({
tenantId: selectedTenantId,
plan,
durationDays: parseInt(durationDays, 10),
});
setMessage({ kind: 'ok', text: 'Invitación enviada correctamente' });
setSelectedTenantId('');
setDurationDays('30');
loadData();
} catch (err: any) {
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al crear invitación' });
} finally {
setCreating(false);
}
}
async function handleCancel(id: string) {
if (!confirm('¿Seguro que quieres cancelar esta invitación?')) return;
try {
await cancelInvitation(id);
setMessage({ kind: 'ok', text: 'Invitación cancelada' });
loadData();
} catch (err: any) {
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al cancelar' });
}
}
function statusIcon(status: string) {
switch (status) {
case 'pending': return <Clock className="h-4 w-4 text-amber-500" />;
case 'accepted': return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case 'expired': return <AlertTriangle className="h-4 w-4 text-red-500" />;
case 'cancelled': return <X className="h-4 w-4 text-gray-500" />;
default: return null;
}
}
function statusLabel(status: string) {
switch (status) {
case 'pending': return 'Pendiente';
case 'accepted': return 'Aceptada';
case 'expired': return 'Expirada';
case 'cancelled': return 'Cancelada';
default: return status;
}
}
return (
<div className="space-y-6">
{/* Toast de resultado */}
{message && (
<div
className={`max-w-3xl rounded-lg px-4 py-3 text-sm ${
message.kind === 'ok'
? 'bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-300'
: 'bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-300'
}`}
>
{message.text}
</div>
)}
{/* Formulario de creación */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Nueva invitación</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Despacho</Label>
<Select value={selectedTenantId} onValueChange={setSelectedTenantId}>
<SelectTrigger>
<SelectValue placeholder="Selecciona un despacho" />
</SelectTrigger>
<SelectContent>
{tenants.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.nombre} ({t.rfc})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Plan</Label>
<Select value={plan} onValueChange={setPlan}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="business_control">Business Control</SelectItem>
<SelectItem value="business_cloud">Enterprise</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Duración (días)</Label>
<Input
type="number"
min={1}
max={365}
value={durationDays}
onChange={(e) => setDurationDays(e.target.value)}
/>
</div>
</div>
<Button onClick={handleCreate} disabled={creating}>
{creating ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Gift className="h-4 w-4 mr-2" />}
Enviar invitación
</Button>
</CardContent>
</Card>
{/* Tabla de invitaciones */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Historial de invitaciones</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : invitations.length === 0 ? (
<p className="text-muted-foreground text-center py-8">No hay invitaciones enviadas</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-3">Despacho</th>
<th className="text-left py-2 px-3">Plan</th>
<th className="text-left py-2 px-3">Días</th>
<th className="text-left py-2 px-3">Estado</th>
<th className="text-left py-2 px-3">Enviado</th>
<th className="text-left py-2 px-3">Expira</th>
<th className="text-left py-2 px-3"></th>
</tr>
</thead>
<tbody>
{invitations.map((inv) => (
<tr key={inv.id} className="border-b hover:bg-muted/50">
<td className="py-2 px-3">
<div className="font-medium">{inv.tenant?.nombre || '—'}</div>
<div className="text-xs text-muted-foreground">{inv.tenant?.rfc || '—'}</div>
</td>
<td className="py-2 px-3">
{inv.plan === 'business_control' ? 'Business Control' : inv.plan === 'business_cloud' ? 'Enterprise' : inv.plan}
</td>
<td className="py-2 px-3">{inv.durationDays}</td>
<td className="py-2 px-3">
<span className="flex items-center gap-1">
{statusIcon(inv.status)}
{statusLabel(inv.status)}
</span>
</td>
<td className="py-2 px-3">
{new Date(inv.sentAt).toLocaleDateString('es-MX')}
</td>
<td className="py-2 px-3">
{new Date(inv.expiresAt).toLocaleDateString('es-MX')}
</td>
<td className="py-2 px-3">
{inv.status === 'pending' && (
<button
onClick={() => handleCancel(inv.id)}
className="text-destructive hover:underline text-xs"
>
Cancelar
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -2,13 +2,14 @@
import { useState, useEffect } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Tabs, TabsList, TabsTrigger, TabsContent } from '@horux/shared-ui';
import { useAllUsuarios, useCreateUsuarioGlobal, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios';
import { getTenants, type Tenant } from '@/lib/api/tenants';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check, UserCog, UserCheck, User, Briefcase, Plus } from 'lucide-react';
import { cn } from '@horux/shared-ui';
import InvitacionesTrialTab from '../_components/invitaciones-trial-tab';
// Mapa de roles + fallback defensivo. El fork despacho introduce roles
// adicionales (cfo, supervisor, auxiliar, cliente) que no estaban en
@@ -43,6 +44,7 @@ export default function AdminUsuariosPage() {
const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
const [filterTenant, setFilterTenant] = useState<string>('all');
const [searchTerm, setSearchTerm] = useState('');
const [activeTab, setActiveTab] = useState('usuarios');
const [showCreateForm, setShowCreateForm] = useState(false);
const [createFormData, setCreateFormData] = useState({
email: '',
@@ -152,6 +154,13 @@ export default function AdminUsuariosPage() {
return (
<DashboardShell title="Administracion de Usuarios">
<Tabs value={activeTab} onValueChange={setActiveTab} defaultValue="usuarios" className="space-y-4">
<TabsList>
<TabsTrigger value="usuarios">Usuarios</TabsTrigger>
<TabsTrigger value="invitaciones-trial">Invitaciones Trial</TabsTrigger>
</TabsList>
<TabsContent value="usuarios">
<div className="space-y-4">
{/* Filtros */}
<Card>
@@ -425,6 +434,12 @@ export default function AdminUsuariosPage() {
))
)}
</div>
</TabsContent>
<TabsContent value="invitaciones-trial">
<InvitacionesTrialTab />
</TabsContent>
</Tabs>
</DashboardShell>
);
}

View File

@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { formatCurrency, toCfdiDate } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
@@ -27,8 +27,8 @@ const EXCEL_COLUMNS = [
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fechaEmision: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fechaCancelacion: c.fechaCancelacion ? new Date(c.fechaCancelacion).toLocaleDateString('es-MX') : '',
_fechaEmision: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
_fechaCancelacion: c.fechaCancelacion ? toCfdiDate(c.fechaCancelacion).toLocaleDateString('es-MX') : '',
_totalMxn: Number(c.totalMxn || 0),
}));
}
@@ -50,8 +50,8 @@ export default function CancelacionesPeriodoAnteriorPage() {
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'cancelacion' | 'total'>(
data,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
cancelacion: (c: any) => c.fechaCancelacion ? new Date(c.fechaCancelacion).getTime() : 0,
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
cancelacion: (c: any) => c.fechaCancelacion ? toCfdiDate(c.fechaCancelacion).getTime() : 0,
total: (c) => Number(c.totalMxn || 0),
},
'cancelacion',
@@ -97,8 +97,8 @@ export default function CancelacionesPeriodoAnteriorPage() {
{(sortedData || []).map((cfdi: any) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3">{cfdi.fechaCancelacion ? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}</td>
<td className="py-3">{toCfdiDate(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3">{cfdi.fechaCancelacion ? toCfdiDate(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>

View File

@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { formatCurrency, toCfdiDate } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
@@ -26,8 +26,8 @@ const EXCEL_COLUMNS = [
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fechaEmision: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fechaCancelacion: c.fechaCancelacion ? new Date(c.fechaCancelacion).toLocaleDateString('es-MX') : '',
_fechaEmision: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
_fechaCancelacion: c.fechaCancelacion ? toCfdiDate(c.fechaCancelacion).toLocaleDateString('es-MX') : '',
_totalMxn: Number(c.totalMxn || 0),
}));
}
@@ -46,8 +46,8 @@ export default function CancelacionesPage() {
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'cancelacion' | 'total'>(
data,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
cancelacion: (c: any) => c.fechaCancelacion ? new Date(c.fechaCancelacion).getTime() : 0,
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
cancelacion: (c: any) => c.fechaCancelacion ? toCfdiDate(c.fechaCancelacion).getTime() : 0,
total: (c) => Number(c.totalMxn || 0),
},
'cancelacion',
@@ -91,8 +91,8 @@ export default function CancelacionesPage() {
{(sortedData || []).map((cfdi: any) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3">{cfdi.fechaCancelacion ? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}</td>
<td className="py-3">{toCfdiDate(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3">{cfdi.fechaCancelacion ? toCfdiDate(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>

View File

@@ -5,7 +5,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, SortableHeader, Input } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { formatCurrency, toCfdiDate } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
@@ -29,7 +29,7 @@ const EXCEL_COLUMNS = [
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fecha: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: Number(c.totalMxn || 0),
regimenReceptor: c.regimenReceptor || c.regimenFiscalReceptor || '',
}));
@@ -91,10 +91,10 @@ export default function DiscrepanciaRegimenPage() {
let filtered = data;
if (fechaDesde) {
filtered = filtered.filter(c => c.fechaEmision >= fechaDesde);
filtered = filtered.filter(c => toCfdiDate(c.fechaEmision).toISOString() >= fechaDesde);
}
if (fechaHasta) {
filtered = filtered.filter(c => c.fechaEmision <= fechaHasta + 'T23:59:59');
filtered = filtered.filter(c => toCfdiDate(c.fechaEmision).toISOString() <= fechaHasta + 'T23:59:59');
}
if (regimenFilter) {
filtered = filtered.filter((c: any) => (c.regimenReceptor || c.regimenFiscalReceptor) === regimenFilter);
@@ -106,7 +106,7 @@ export default function DiscrepanciaRegimenPage() {
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total'>(
visibleData,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
total: (c) => Number(c.totalMxn || 0),
},
'fecha',
@@ -311,7 +311,7 @@ export default function DiscrepanciaRegimenPage() {
</button>
</td>
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3">{toCfdiDate(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-3 truncate max-w-[200px]">{cfdi.nombreEmisor}</td>
<td className="py-3 font-mono font-bold text-destructive">{cfdi.regimenReceptor}</td>

View File

@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { formatCurrency, toCfdiDate } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
@@ -26,7 +26,7 @@ const EXCEL_COLUMNS = [
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fecha: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: Number(c.totalMxn || 0),
}));
}
@@ -45,7 +45,7 @@ export default function EfectivoPage() {
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total'>(
data,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
total: (c) => Number(c.totalMxn || 0),
},
'fecha',
@@ -89,7 +89,7 @@ export default function EfectivoPage() {
{(sortedData || []).map((cfdi: any) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3">{toCfdiDate(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-3 truncate max-w-[200px]">{cfdi.nombreEmisor}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcReceptor}</td>

View File

@@ -5,7 +5,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, SortableHeader, Input } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { formatCurrency, toCfdiDate } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
@@ -30,7 +30,7 @@ const EXCEL_COLUMNS = [
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fecha: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: Number(c.totalMxn || 0),
}));
}
@@ -83,8 +83,8 @@ export default function TipoRelacionSospechosaPage() {
const visibleData = useMemo(() => {
if (!data) return [];
let filtered = data;
if (fechaDesde) filtered = filtered.filter(c => c.fechaEmision >= fechaDesde);
if (fechaHasta) filtered = filtered.filter(c => c.fechaEmision <= fechaHasta + 'T23:59:59');
if (fechaDesde) filtered = filtered.filter(c => toCfdiDate(c.fechaEmision).toISOString() >= fechaDesde);
if (fechaHasta) filtered = filtered.filter(c => toCfdiDate(c.fechaEmision).toISOString() <= fechaHasta + 'T23:59:59');
if (tipoRelFilter) filtered = filtered.filter((c: any) => c.cfdiTipoRelacion === tipoRelFilter);
return filtered;
}, [data, fechaDesde, fechaHasta, tipoRelFilter]);
@@ -92,7 +92,7 @@ export default function TipoRelacionSospechosaPage() {
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total'>(
visibleData,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
total: (c) => Number(c.totalMxn || 0),
},
'fecha',
@@ -296,7 +296,7 @@ export default function TipoRelacionSospechosaPage() {
</button>
</td>
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3">{toCfdiDate(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3 truncate max-w-[180px]">
<div className="font-mono text-xs">{cfdi.rfcEmisor}</div>
<div className="text-xs text-muted-foreground truncate">{cfdi.nombreEmisor}</div>

View File

@@ -5,12 +5,13 @@ import {
Button, Card, CardContent, CardHeader, CardTitle, Input, Label,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
Tabs, TabsList, TabsTrigger, TabsContent,
cn,
} from '@horux/shared-ui';
import { useQueryClient } from '@tanstack/react-query';
import {
FolderOpen, Plus, Trash2, ChevronDown, ChevronUp, X,
Users, Building2, FolderPlus, UserCog,
Users, Building2, FolderPlus, UserCog, ClipboardList,
} from 'lucide-react';
import {
useCarteras, useCreateCartera, useDeleteCartera,
@@ -25,14 +26,16 @@ import { useUsuarios } from '@/lib/hooks/use-usuarios';
import { useAuthStore } from '@/stores/auth-store';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import type { Cartera } from '@/lib/api/carteras';
import SeguimientoAuxiliares from './seguimiento-auxiliares';
/* ------------------------------------------------------------------ */
/* SubcarteraCard */
/* ------------------------------------------------------------------ */
function SubcarteraCard({ sub, usuarios, contribuyentes, onDelete }: {
function SubcarteraCard({ sub, usuarios, contribuyentes, parentEntidadIds, onDelete }: {
sub: Cartera;
usuarios: any[];
contribuyentes: any[];
parentEntidadIds: string[];
onDelete: () => void;
}) {
const [expanded, setExpanded] = useState(false);
@@ -47,7 +50,7 @@ function SubcarteraCard({ sub, usuarios, contribuyentes, onDelete }: {
);
const available = (contribuyentes ?? []).filter(
(c: any) => !(entidadIds ?? []).includes(c.id)
(c: any) => (parentEntidadIds ?? []).includes(c.id) && !(entidadIds ?? []).includes(c.id)
);
const auxiliarUser = usuarios?.find((u: any) => u.id === sub.auxiliarUserId);
@@ -319,6 +322,7 @@ function CarteraDetail({ cartera, canEdit = true, canManageSubcarteras = true }:
sub={sub}
usuarios={usuarios ?? []}
contribuyentes={contribuyentes ?? []}
parentEntidadIds={entidadIds ?? []}
onDelete={() => handleDeleteSubcartera(sub.id)}
/>
))}
@@ -396,6 +400,10 @@ export default function CarterasPage() {
const canEditCartera = userRole === 'owner'; // Edit/delete top-level carteras + add/remove RFCs
const canManageSubcarteras = userRole === 'owner' || userRole === 'supervisor'; // Create subcarteras
const isAuxiliar = userRole === 'auxiliar';
const isSupervisor = userRole === 'supervisor';
const isOwner = userRole === 'owner';
const puedeVerSeguimiento = isOwner || isSupervisor;
const [activeTab, setActiveTab] = useState('carteras');
const { data: carteras, isLoading } = useCarteras();
const { data: supervisores } = useSupervisores();
const { data: usuarios } = useUsuarios();
@@ -440,9 +448,43 @@ export default function CarterasPage() {
}
};
const CarterasList = () => (
<>
{isLoading ? (
<p className="text-muted-foreground">Cargando...</p>
) : !carteras || carteras.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FolderOpen className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Sin carteras</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4">
Crea la primera cartera para organizar tus contribuyentes.
</p>
<Button onClick={() => setShowCreate(true)}>Crear primera cartera</Button>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{carteras.map(cartera => (
<CarteraCard
key={cartera.id}
cartera={cartera}
expanded={expandedId === cartera.id}
onToggle={() => setExpandedId(expandedId === cartera.id ? null : cartera.id)}
onDelete={() => handleDelete(cartera)}
usuarios={usuarios ?? []}
canEdit={canEditCartera}
canManageSubcarteras={canManageSubcarteras}
/>
))}
</div>
)}
</>
);
return (
<DashboardShell title="Carteras">
<div className="max-w-3xl mx-auto space-y-6">
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
@@ -450,42 +492,34 @@ export default function CarterasPage() {
{isAuxiliar ? 'Carteras asignadas a ti' : 'Organiza contribuyentes en carteras y asigna subcarteras a cada auxiliar'}
</p>
</div>
{canCreate && (
{canCreate && activeTab === 'carteras' && (
<Button onClick={() => setShowCreate(true)} className="flex items-center gap-2">
<Plus className="h-4 w-4" /> Nueva cartera
</Button>
)}
</div>
{/* List */}
{isLoading ? (
<p className="text-muted-foreground">Cargando...</p>
) : !carteras || carteras.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FolderOpen className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Sin carteras</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4">
Crea la primera cartera para organizar tus contribuyentes.
</p>
<Button onClick={() => setShowCreate(true)}>Crear primera cartera</Button>
</CardContent>
</Card>
{puedeVerSeguimiento ? (
<Tabs value={activeTab} onValueChange={setActiveTab} defaultValue="carteras" className="space-y-4">
<TabsList>
<TabsTrigger value="carteras">
<FolderOpen className="h-4 w-4 mr-1.5" /> Carteras
</TabsTrigger>
<TabsTrigger value="seguimiento">
<ClipboardList className="h-4 w-4 mr-1.5" /> Seguimiento de Auxiliares
</TabsTrigger>
</TabsList>
<TabsContent value="carteras">
<CarterasList />
</TabsContent>
<TabsContent value="seguimiento">
<SeguimientoAuxiliares />
</TabsContent>
</Tabs>
) : (
<div className="space-y-3">
{carteras.map(cartera => (
<CarteraCard
key={cartera.id}
cartera={cartera}
expanded={expandedId === cartera.id}
onToggle={() => setExpandedId(expandedId === cartera.id ? null : cartera.id)}
onDelete={() => handleDelete(cartera)}
usuarios={usuarios ?? []}
canEdit={canEditCartera}
canManageSubcarteras={canManageSubcarteras}
/>
))}
</div>
<CarterasList />
)}
{/* Create dialog */}

View File

@@ -0,0 +1,346 @@
'use client';
import { useState } from 'react';
import {
Button, Card, CardContent, CardHeader, CardTitle,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
Tabs, TabsList, TabsTrigger, TabsContent,
} from '@horux/shared-ui';
import {
useAsignacionesSupervisor,
useSinAsignar,
useAsignarObligacion,
useDesasignarObligacion,
useAsignarTarea,
useDesasignarTarea,
useAuxiliaresElegibles,
} from '@/lib/hooks/use-asignaciones';
import { useUsuarios } from '@/lib/hooks/use-usuarios';
import { useAuthStore } from '@/stores/auth-store';
import { UserCheck, UserX, UserCog, Plus } from 'lucide-react';
export default function SeguimientoAuxiliares() {
const { user } = useAuthStore();
const { data: asignaciones, isLoading: loadingAsignadas } = useAsignacionesSupervisor();
const { data: sinAsignar, isLoading: loadingSinAsignar } = useSinAsignar();
const { data: usuarios } = useUsuarios();
const asignarObligacionMut = useAsignarObligacion();
const desasignarObligacionMut = useDesasignarObligacion();
const asignarTareaMut = useAsignarTarea();
const desasignarTareaMut = useDesasignarTarea();
const [modalOpen, setModalOpen] = useState(false);
const [modalType, setModalType] = useState<'obligacion' | 'tarea'>('obligacion');
const [modalItem, setModalItem] = useState<any>(null);
const [selectedAuxiliar, setSelectedAuxiliar] = useState('');
const auxiliares = (usuarios ?? []).filter((u: any) => u.role === 'auxiliar');
const { data: elegiblesData, isLoading: loadingElegibles } = useAuxiliaresElegibles(modalItem?.contribuyenteId);
const auxiliaresIdsElegibles = elegiblesData?.auxiliares ?? [];
const auxiliaresFiltrados = auxiliares.filter((a: any) => auxiliaresIdsElegibles.includes(a.id));
const puedeAsignar = !loadingElegibles && auxiliaresFiltrados.length > 0;
const openAssignModal = (type: 'obligacion' | 'tarea', item: any) => {
setModalType(type);
setModalItem(item);
setSelectedAuxiliar(item.auxiliarUserId || '');
setModalOpen(true);
};
const handleAssign = async () => {
if (!selectedAuxiliar || !modalItem) return;
try {
if (modalType === 'obligacion') {
await asignarObligacionMut.mutateAsync({
contribuyenteId: modalItem.contribuyenteId,
obligacionId: modalItem.obligacionId,
auxiliarUserId: selectedAuxiliar,
});
} else {
await asignarTareaMut.mutateAsync({
tareaId: modalItem.tareaId,
auxiliarUserId: selectedAuxiliar,
});
}
setModalOpen(false);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al asignar');
}
};
const handleUnassign = async (type: 'obligacion' | 'tarea', item: any) => {
if (!confirm('¿Eliminar la asignación?')) return;
try {
if (type === 'obligacion') {
await desasignarObligacionMut.mutateAsync({
contribuyenteId: item.contribuyenteId,
obligacionId: item.obligacionId,
});
} else {
await desasignarTareaMut.mutateAsync({ tareaId: item.tareaId });
}
} catch (err: any) {
alert(err.response?.data?.message || 'Error al desasignar');
}
};
if (loadingAsignadas || loadingSinAsignar) {
return <p className="text-muted-foreground">Cargando asignaciones...</p>;
}
const obligacionesAsignadas = asignaciones?.obligaciones ?? [];
const tareasAsignadas = asignaciones?.tareas ?? [];
const obligacionesSinAsignar = sinAsignar?.obligaciones ?? [];
const tareasSinAsignar = sinAsignar?.tareas ?? [];
return (
<div className="space-y-4">
<Tabs defaultValue="asignadas" className="space-y-4">
<TabsList>
<TabsTrigger value="asignadas">Asignadas</TabsTrigger>
<TabsTrigger value="sin-asignar">Sin asignar</TabsTrigger>
</TabsList>
<TabsContent value="asignadas">
<Tabs defaultValue="obligaciones" className="space-y-4">
<TabsList>
<TabsTrigger value="obligaciones">Obligaciones ({obligacionesAsignadas.length})</TabsTrigger>
<TabsTrigger value="tareas">Tareas ({tareasAsignadas.length})</TabsTrigger>
</TabsList>
<TabsContent value="obligaciones">
<AsignacionesTable
items={obligacionesAsignadas}
tipo="obligacion"
modo="asignadas"
auxiliares={auxiliares}
onAssign={(item) => openAssignModal('obligacion', item)}
onUnassign={(item) => handleUnassign('obligacion', item)}
/>
</TabsContent>
<TabsContent value="tareas">
<AsignacionesTable
items={tareasAsignadas}
tipo="tarea"
modo="asignadas"
auxiliares={auxiliares}
onAssign={(item) => openAssignModal('tarea', item)}
onUnassign={(item) => handleUnassign('tarea', item)}
/>
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="sin-asignar">
<Tabs defaultValue="obligaciones" className="space-y-4">
<TabsList>
<TabsTrigger value="obligaciones">Obligaciones ({obligacionesSinAsignar.length})</TabsTrigger>
<TabsTrigger value="tareas">Tareas ({tareasSinAsignar.length})</TabsTrigger>
</TabsList>
<TabsContent value="obligaciones">
<SinAsignarTable
items={obligacionesSinAsignar}
tipo="obligacion"
auxiliares={auxiliares}
onAssign={(item) => openAssignModal('obligacion', item)}
/>
</TabsContent>
<TabsContent value="tareas">
<SinAsignarTable
items={tareasSinAsignar}
tipo="tarea"
auxiliares={auxiliares}
onAssign={(item) => openAssignModal('tarea', item)}
/>
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
{/* Modal de asignación */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{modalItem?.auxiliarUserId ? 'Reasignar' : 'Asignar'} {modalType === 'obligacion' ? 'obligación' : 'tarea'}</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground mb-2">
{modalType === 'obligacion' ? modalItem?.obligacionNombre : modalItem?.tareaNombre}
</p>
<p className="text-xs text-muted-foreground mb-4">
Contribuyente: {modalItem?.contribuyenteRazonSocial} ({modalItem?.contribuyenteRfc})
</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}>
<SelectTrigger>
<SelectValue placeholder="Selecciona un auxiliar" />
</SelectTrigger>
<SelectContent>
{auxiliaresFiltrados.map((a: any) => (
<SelectItem key={a.id} value={a.id}>{a.nombre} ({a.email})</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button onClick={handleAssign} disabled={!selectedAuxiliar || !puedeAsignar}>
{modalItem?.auxiliarUserId ? 'Reasignar' : 'Asignar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function AsignacionesTable({
items,
tipo,
modo,
auxiliares,
onAssign,
onUnassign,
}: {
items: any[];
tipo: 'obligacion' | 'tarea';
modo: 'asignadas';
auxiliares: any[];
onAssign: (item: any) => void;
onUnassign: (item: any) => void;
}) {
if (items.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay {tipo === 'obligacion' ? 'obligaciones' : 'tareas'} asignadas.
</CardContent>
</Card>
);
}
return (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium">Auxiliar</th>
<th className="text-left px-4 py-3 font-medium">Contribuyente</th>
<th className="text-left px-4 py-3 font-medium">{tipo === 'obligacion' ? 'Obligación' : 'Tarea'}</th>
<th className="text-left px-4 py-3 font-medium">Asignado</th>
<th className="text-right px-4 py-3 font-medium">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item) => (
<tr key={item.id} className="hover:bg-muted/30">
<td className="px-4 py-3">
{item.auxiliarNombre ? (
<span className="inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 flex items-center gap-1 w-fit">
<UserCheck className="h-3 w-3" /> {item.auxiliarNombre}
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-border text-muted-foreground hover:bg-secondary/80 flex items-center gap-1 w-fit">
<UserX className="h-3 w-3" /> Sin asignar
</span>
)}
</td>
<td className="px-4 py-3">
<div className="font-medium">{item.contribuyenteRazonSocial}</div>
<div className="text-xs text-muted-foreground">{item.contribuyenteRfc}</div>
</td>
<td className="px-4 py-3">{tipo === 'obligacion' ? item.obligacionNombre : item.tareaNombre}</td>
<td className="px-4 py-3 text-muted-foreground">
{item.asignadoAt ? new Date(item.asignadoAt).toLocaleDateString('es-MX') : '-'}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => onAssign(item)}>
<UserCog className="h-4 w-4" />
</Button>
{item.auxiliarUserId && (
<Button variant="ghost" size="sm" className="text-red-600" onClick={() => onUnassign(item)}>
<UserX className="h-4 w-4" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function SinAsignarTable({
items,
tipo,
auxiliares,
onAssign,
}: {
items: any[];
tipo: 'obligacion' | 'tarea';
auxiliares: any[];
onAssign: (item: any) => void;
}) {
if (items.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay {tipo === 'obligacion' ? 'obligaciones' : 'tareas'} sin asignar.
</CardContent>
</Card>
);
}
return (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium">Contribuyente</th>
<th className="text-left px-4 py-3 font-medium">{tipo === 'obligacion' ? 'Obligación' : 'Tarea'}</th>
<th className="text-right px-4 py-3 font-medium">Acción</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item, idx) => (
<tr key={`${item.obligacionId || item.tareaId}-${idx}`} className="hover:bg-muted/30">
<td className="px-4 py-3">
<div className="font-medium">{item.contribuyenteRazonSocial}</div>
<div className="text-xs text-muted-foreground">{item.contribuyenteRfc}</div>
</td>
<td className="px-4 py-3">{tipo === 'obligacion' ? item.obligacionNombre : item.tareaNombre}</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="sm" onClick={() => onAssign(item)}>
<Plus className="h-4 w-4 mr-1" /> Asignar
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}

View File

@@ -5,7 +5,7 @@ import { useDebounce } from '@horux/shared-ui';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Popover, PopoverTrigger, PopoverContent } from '@horux/shared-ui';
import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
import { createManyCfdis, searchEmisores, searchReceptores, getCfdis, getConceptosList, type EmisorReceptor } from '@/lib/api/cfdi';
import { createManyCfdis, searchEmisores, searchReceptores, getCfdis, getConceptosList, downloadXmlsZip, type EmisorReceptor } from '@/lib/api/cfdi';
import { cancelarFactura, downloadPdf } from '@/lib/api/facturacion';
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
import type { CreateCfdiData } from '@/lib/api/cfdi';
@@ -261,6 +261,7 @@ export default function CfdiPage() {
const [loadingEmisor, setLoadingEmisor] = useState(false);
const [loadingReceptor, setLoadingReceptor] = useState(false);
const [showForm, setShowForm] = useState(false);
const [downloadingXmls, setDownloadingXmls] = useState(false);
// Debounced values for autocomplete
const debouncedEmisor = useDebounce(columnFilters.emisor, 300);
@@ -421,9 +422,10 @@ export default function CfdiPage() {
}
const exportData = allRows.map(cfdi => ({
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
'Fecha Emisión': formatCfdiDate(cfdi.fechaEmision),
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
'Uso CFDI': (cfdi as any).usoCfdi || '',
'Forma de Pago': cfdi.formaPago || '',
'Serie': cfdi.serie || '',
'Folio': cfdi.folio || '',
'RFC Emisor': cfdi.rfcEmisor,
@@ -442,9 +444,7 @@ export default function CfdiPage() {
// vacío en Excel para no confundir "0 = pagado" con "no aplica".
'Saldo Pendiente': cfdi.saldoPendienteMxn ?? '',
'Estatus': cfdi.status === 'Vigente' || cfdi.status === '1' ? 'Vigente' : 'Cancelado',
'Fecha Cancelación': cfdi.fechaCancelacion
? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX')
: '',
'Fecha Cancelación': formatCfdiDate(cfdi.fechaCancelacion),
'UUID': cfdi.uuid,
}));
@@ -509,7 +509,7 @@ export default function CfdiPage() {
if (key.endsWith('_mxn') || key === 'id' || key === 'cfdi_id') continue;
// Formatear fecha si aplica
if (key === 'fechaEmision' && typeof val === 'string') {
out['Fecha Emisión'] = new Date(val).toLocaleDateString('es-MX');
out['Fecha Emisión'] = formatCfdiDate(val);
} else {
out[key] = val;
}
@@ -539,9 +539,10 @@ export default function CfdiPage() {
const exportSingleCfdiToExcel = (cfdi: Cfdi) => {
const row = {
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
'Fecha Emisión': formatCfdiDate(cfdi.fechaEmision),
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
'Uso CFDI': (cfdi as any).usoCfdi || '',
'Forma de Pago': cfdi.formaPago || '',
'Serie': cfdi.serie || '',
'Folio': cfdi.folio || '',
'RFC Emisor': cfdi.rfcEmisor,
@@ -560,9 +561,7 @@ export default function CfdiPage() {
// vacío en Excel para no confundir "0 = pagado" con "no aplica".
'Saldo Pendiente': cfdi.saldoPendienteMxn ?? '',
'Estatus': cfdi.status === 'Vigente' || cfdi.status === '1' ? 'Vigente' : 'Cancelado',
'Fecha Cancelación': cfdi.fechaCancelacion
? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX')
: '',
'Fecha Cancelación': formatCfdiDate(cfdi.fechaCancelacion),
'UUID': cfdi.uuid,
};
@@ -909,7 +908,7 @@ export default function CfdiPage() {
}
setCancelling(true);
try {
await cancelarFactura(cancelTarget.uuid, cancelMotive, cancelMotive === '01' ? cancelSubstitution.trim() : undefined);
await cancelarFactura(cancelTarget.uuid, cancelMotive, cancelMotive === '01' ? cancelSubstitution.trim() : undefined, selectedContribuyenteId || undefined);
await queryClient.invalidateQueries({ queryKey: ['cfdis'] });
setCancelTarget(null);
alert('Factura cancelada. El estatus final depende del SAT (puede quedar en "pendiente" si requiere aceptación del receptor).');
@@ -935,12 +934,22 @@ export default function CfdiPage() {
currency: 'MXN',
}).format(value);
const formatDate = (dateString: string) =>
new Date(dateString).toLocaleDateString('es-MX', {
const formatDate = (dateString: string) => {
const d = new Date(dateString);
d.setHours(d.getHours() - 1);
return d.toLocaleDateString('es-MX', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
};
const formatCfdiDate = (dateString: string | null | undefined) => {
if (!dateString) return '-';
const d = new Date(dateString);
d.setHours(d.getHours() - 1);
return d.toLocaleDateString('es-MX');
};
const generateUUID = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
@@ -1692,12 +1701,13 @@ export default function CfdiPage() {
)}
</button>
</th>
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody className="text-sm text-center">
{conceptosQuery.data.data.map((row, idx) => (
<tr key={`${row.cfdi_id}-${row.id}-${idx}`} className="border-b hover:bg-muted/50">
<td className="py-2">{new Date(row.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-2">{formatCfdiDate(row.fechaEmision)}</td>
<td className="py-2 font-mono text-xs" title={row.uuid}>{row.uuid?.substring(0, 8) || '-'}</td>
<td className="py-2 font-mono text-xs">{row.clave_prod_serv || '-'}</td>
<td className="py-2 text-left max-w-[280px] truncate" title={row.descripcion}>{row.descripcion}</td>
@@ -1708,6 +1718,21 @@ export default function CfdiPage() {
<td className="py-2 text-xs" title={row.unidad || ''}>{row.clave_unidad || '-'}</td>
<td className="py-2 text-right">${Number(row.valor_unitario ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
<td className="py-2 text-right font-medium">${Number(row.importe ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
<td className="py-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleViewCfdi(row.cfdi_id)}
disabled={loadingCfdi === row.cfdi_id}
title="Ver CFDI"
>
{loadingCfdi === row.cfdi_id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</td>
</tr>
))}
</tbody>
@@ -1754,6 +1779,48 @@ export default function CfdiPage() {
<FileText className="h-4 w-4" />
CFDIs ({data?.total || 0})
</CardTitle>
<Button
variant="outline"
size="sm"
onClick={async () => {
if ((data?.total || 0) > 1000) {
if (!confirm('Solo se descargarán los primeros 1,000 XMLs. ¿Continuar?')) return;
}
try {
setDownloadingXmls(true);
const blob = await downloadXmlsZip({
tipo: filters.tipo,
tipoComprobante: filters.tipoComprobante,
estado: filters.estado,
fechaInicio: filters.fechaInicio,
fechaFin: filters.fechaFin,
rfc: filters.rfc,
emisor: filters.emisor,
receptor: filters.receptor,
search: filters.search,
contribuyenteId: filters.contribuyenteId,
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `cfdis-xml-${Date.now()}.zip`;
a.click();
URL.revokeObjectURL(url);
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al descargar XMLs');
} finally {
setDownloadingXmls(false);
}
}}
disabled={downloadingXmls || !data?.total}
>
{downloadingXmls ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Download className="h-4 w-4 mr-1" />
)}
Descargar XMLs
</Button>
{hasActiveColumnFilters && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Filtros activos:</span>
@@ -2087,6 +2154,9 @@ export default function CfdiPage() {
<td className="py-3">
<div className="flex items-center gap-0">
{(cfdi as any).source === 'facturapi' && (cfdi.status === 'Vigente' || cfdi.status === '1') && (
// Solo el contribuyente emisor puede cancelar su propia factura
((!selectedContribuyenteId && !cfdi.contribuyenteId) ||
(selectedContribuyenteId && cfdi.contribuyenteId === selectedContribuyenteId)) && (
<Button
variant="ghost"
size="icon"
@@ -2096,7 +2166,7 @@ export default function CfdiPage() {
>
<XCircle className="h-4 w-4" />
</Button>
)}
))}
<Button
variant="ghost"
size="icon"

View File

@@ -4,12 +4,12 @@ import { useState, useEffect } from 'react';
import { useCfdisConConciliacion, useConciliar, useDesconciliar } from '@/lib/hooks/use-conciliacion';
import { useBancos } from '@/lib/hooks/use-bancos';
import { useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
import { PeriodSelector, RegimenSelector } from '@horux/shared-ui';
import { PeriodSelector, RegimenSelector, useDebounce } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Input } from '@horux/shared-ui';
import { Card, CardContent, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Input, Label, Popover, PopoverTrigger, PopoverContent } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { formatCurrency } from '@/lib/utils';
import { formatCurrency, toCfdiDate } from '@/lib/utils';
function formatCurrencyConciliacion(value: number): string {
return new Intl.NumberFormat('es-MX', {
@@ -20,7 +20,7 @@ function formatCurrencyConciliacion(value: number): string {
}).format(value);
}
import { exportToExcel } from '@/lib/export-excel';
import { Eye, Download, X, CheckCircle } from 'lucide-react';
import { Eye, Download, X, CheckCircle, Search, ArrowUpDown, Filter } from 'lucide-react';
function getMonthRange(year: number, month: number) {
const start = `${year}-${String(month).padStart(2, '0')}-01`;
@@ -29,6 +29,98 @@ function getMonthRange(year: number, month: number) {
return { start, end };
}
function FilterHeader({
label,
filterKey,
filters,
setFilters,
openFilter,
setOpenFilter,
suggestions,
}: {
label: string;
filterKey: string;
filters: Record<string, string>;
setFilters: any;
openFilter: string | null;
setOpenFilter: (v: string | null) => void;
suggestions: string[];
}) {
const rawValue = (filters as any)[filterKey] || '';
const [localValue, setLocalValue] = useState(rawValue);
const debouncedValue = useDebounce(localValue, 300);
// Sync local state when popover opens or external filter changes
useEffect(() => {
setLocalValue(rawValue);
}, [rawValue, openFilter === filterKey]);
// Update parent filter only when debounced value changes
useEffect(() => {
if (debouncedValue !== rawValue) {
setFilters((prev: any) => ({ ...prev, [filterKey]: debouncedValue }));
}
}, [debouncedValue]);
const hasFilter = !!rawValue;
const filteredSuggestions = localValue.length >= 1
? suggestions.filter((s) => s.toLowerCase().includes(localValue.toLowerCase())).slice(0, 8)
: suggestions.slice(0, 8);
return (
<div className="flex items-center justify-center gap-1">
{label}
<Popover open={openFilter === filterKey} onOpenChange={(open) => setOpenFilter(open ? filterKey : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${hasFilter ? 'text-primary' : ''}`}>
<Filter className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-72" align="start">
<div className="space-y-3">
<h4 className="font-medium text-sm">Filtrar por {label}</h4>
<div className="relative">
<Label className="text-xs">Contiene</Label>
<Input
placeholder={`Buscar ${label.toLowerCase()}...`}
className="h-8 text-sm"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
autoFocus
/>
{filteredSuggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-background border rounded-md shadow-lg max-h-40 overflow-y-auto z-50">
{filteredSuggestions.map((s, idx) => (
<button
key={idx}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-muted truncate"
onClick={() => {
setLocalValue(s);
setFilters((prev: any) => ({ ...prev, [filterKey]: s }));
setOpenFilter(null);
}}
>
{s}
</button>
))}
</div>
)}
</div>
<div className="flex gap-2">
<Button size="sm" onClick={() => setOpenFilter(null)}>Aplicar</Button>
{hasFilter && (
<Button size="sm" variant="outline" onClick={() => { setFilters((prev: any) => ({ ...prev, [filterKey]: '' })); setOpenFilter(null); }}>
Limpiar
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
}
export default function ConciliacionPage() {
const now = new Date();
const defaultRange = getMonthRange(now.getFullYear(), now.getMonth() + 1);
@@ -42,6 +134,20 @@ export default function ConciliacionPage() {
const [bancoId, setBancoId] = useState<string>('');
const [selectedCfdi, setSelectedCfdi] = useState<any>(null);
// Ordenación — Por conciliar
const [sortPendientes, setSortPendientes] = useState<{ field: 'fecha' | 'total'; dir: 'asc' | 'desc' } | null>(null);
// Ordenación — Conciliadas
const [sortConciliadas, setSortConciliadas] = useState<{ field: 'fecha' | 'total'; dir: 'asc' | 'desc' } | null>(null);
// Filtros por columna — Por conciliar
const [filtersPendientes, setFiltersPendientes] = useState({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
const [openFilterPendientes, setOpenFilterPendientes] = useState<string | null>(null);
// Filtros por columna — Conciliadas
const [filtersConciliadas, setFiltersConciliadas] = useState({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '', banco: '' });
const [openFilterConciliadas, setOpenFilterConciliadas] = useState<string | null>(null);
const { user } = useAuthStore();
const isVisor = user?.role === 'visor';
@@ -61,14 +167,34 @@ export default function ConciliacionPage() {
const pendientes = cfdis?.filter((c) => c.conciliado !== 'true') || [];
const conciliadas = cfdis?.filter((c) => c.conciliado === 'true') || [];
// Sugerencias únicas para filtros de columna (de todos los CFDIs cargados)
const allCfdis = cfdis || [];
const uniqueSuggestions = {
rfcEmisor: [...new Set(allCfdis.map((c: any) => c.rfcEmisor).filter(Boolean))].sort(),
nombreEmisor: [...new Set(allCfdis.map((c: any) => c.nombreEmisor).filter(Boolean))].sort(),
rfcReceptor: [...new Set(allCfdis.map((c: any) => c.rfcReceptor).filter(Boolean))].sort(),
nombreReceptor: [...new Set(allCfdis.map((c: any) => c.nombreReceptor).filter(Boolean))].sort(),
banco: [...new Set(conciliadas.map((c: any) => c.conciliacion?.banco).filter(Boolean))].sort(),
};
// Score cards — tipo P usa monto_pago_mxn, otros usan total_mxn
const getMonto = (c: any) => Number(c.montoMxn || c.totalMxn || 0);
// I+P-E: Ingresos y Pagos suman, Egresos restan
const getMonto = (c: any) => {
const monto = Number(c.montoMxn || c.totalMxn || 0);
return c.tipoComprobante === 'E' ? -monto : monto;
};
const montoConciliado = conciliadas.reduce((s, c) => s + getMonto(c), 0);
const montoPendiente = pendientes.reduce((s, c) => s + getMonto(c), 0);
// Reset selection on tab/filter change
// Reset selection + ordenación + filtros on tab/filter change
useEffect(() => {
setSelected(new Set());
setSortPendientes(null);
setSortConciliadas(null);
setFiltersPendientes({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
setOpenFilterPendientes(null);
setFiltersConciliadas({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '', banco: '' });
setOpenFilterConciliadas(null);
}, [activeTab, fechaInicio, fechaFin, regimenSeleccionado]);
// Handlers
@@ -85,10 +211,10 @@ export default function ConciliacionPage() {
};
const toggleSelectAll = () => {
if (selected.size === pendientes.length && pendientes.length > 0) {
if (selected.size === pendientesOrdenados.length && pendientesOrdenados.length > 0) {
setSelected(new Set());
} else {
setSelected(new Set(pendientes.map((c) => c.id)));
setSelected(new Set(pendientesOrdenados.map((c) => c.id)));
}
};
@@ -117,12 +243,49 @@ export default function ConciliacionPage() {
}
};
function matchesColumnFilters(c: any, filters: Record<string, string>) {
const rfcEmisorMatch = !filters.rfcEmisor || (c.rfcEmisor || '').toLowerCase().includes(filters.rfcEmisor.toLowerCase());
const nombreEmisorMatch = !filters.nombreEmisor || (c.nombreEmisor || '').toLowerCase().includes(filters.nombreEmisor.toLowerCase());
const rfcReceptorMatch = !filters.rfcReceptor || (c.rfcReceptor || '').toLowerCase().includes(filters.rfcReceptor.toLowerCase());
const nombreReceptorMatch = !filters.nombreReceptor || (c.nombreReceptor || '').toLowerCase().includes(filters.nombreReceptor.toLowerCase());
const bancoMatch = !filters.banco || ((c.conciliacion?.banco || '') + ' ****' + (c.conciliacion?.terminacionCuenta || '')).toLowerCase().includes(filters.banco.toLowerCase());
return rfcEmisorMatch && nombreEmisorMatch && rfcReceptorMatch && nombreReceptorMatch && bancoMatch;
}
function sortCfdis(list: any[], sort: { field: 'fecha' | 'total'; dir: 'asc' | 'desc' } | null) {
if (!sort) return list;
const sorted = [...list].sort((a, b) => {
if (sort.field === 'fecha') {
const da = toCfdiDate(a.fechaPagoP || a.fechaEmision).getTime();
const db = toCfdiDate(b.fechaPagoP || b.fechaEmision).getTime();
return sort.dir === 'asc' ? da - db : db - da;
}
if (sort.field === 'total') {
const ta = getMonto(a);
const tb = getMonto(b);
return sort.dir === 'asc' ? ta - tb : tb - ta;
}
return 0;
});
return sorted;
}
const pendientesOrdenados = sortCfdis(
pendientes.filter((c) => matchesColumnFilters(c, filtersPendientes)),
sortPendientes
);
const conciliadasOrdenadas = sortCfdis(
conciliadas.filter((c) => matchesColumnFilters(c, filtersConciliadas)),
sortConciliadas
);
const handleExport = () => {
if (!cfdis?.length) return;
const allVisible = [...pendientesOrdenados, ...conciliadasOrdenadas];
if (!allVisible.length) return;
exportToExcel(
cfdis.map((c) => ({
allVisible.map((c) => ({
...c,
_fecha: new Date(c.fechaPagoP || c.fechaEmision).toLocaleDateString('es-MX'),
_fecha: toCfdiDate(c.fechaPagoP || c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: getMonto(c),
_estado: c.conciliado === 'true' ? 'Conciliado' : 'Pendiente',
_fechaPago: c.conciliacion?.fechaDePago || '',
@@ -212,84 +375,108 @@ export default function ConciliacionPage() {
{/* Por conciliar */}
<Card>
<CardContent className="pt-6">
<h3 className="font-medium mb-4">Por conciliar ({pendientes.length})</h3>
{pendientes.length === 0 ? (
<p className="text-sm text-muted-foreground">
No hay CFDIs pendientes de conciliar
</p>
) : (
<h3 className="font-medium mb-4">Por conciliar ({pendientesOrdenados.length})</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-base">
<thead>
<tr className="border-b text-left text-muted-foreground">
<tr className="border-b text-center text-muted-foreground">
{!isVisor && (
<th className="pb-3 w-8">
<input
type="checkbox"
checked={
selected.size === pendientes.length && pendientes.length > 0
selected.size === pendientesOrdenados.length && pendientesOrdenados.length > 0
}
onChange={toggleSelectAll}
/>
</th>
)}
<th className="pb-3 font-medium">UUID</th>
<th className="pb-3 font-medium">Fecha</th>
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">Nombre Emisor</th>
<th className="pb-3 font-medium">RFC Receptor</th>
<th className="pb-3 font-medium">Nombre Receptor</th>
<th className="pb-3 font-medium text-right">Total MXN</th>
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortPendientes(prev => prev?.field === 'fecha' ? { field: 'fecha', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'fecha', dir: 'asc' })}>
<span className="flex items-center justify-center gap-1">Fecha <ArrowUpDown className="h-3 w-3" /></span>
</th>
{activeTab === 'EMITIDO' ? (
<>
<th className="pb-3 font-medium"><FilterHeader label="RFC Receptor" filterKey="rfcReceptor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} suggestions={uniqueSuggestions.rfcReceptor} /></th>
<th className="pb-3 font-medium"><FilterHeader label="Nombre Receptor" filterKey="nombreReceptor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} suggestions={uniqueSuggestions.nombreReceptor} /></th>
<th className="pb-3 font-medium">Régimen Emisor</th>
</>
) : (
<>
<th className="pb-3 font-medium"><FilterHeader label="RFC Emisor" filterKey="rfcEmisor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} suggestions={uniqueSuggestions.rfcEmisor} /></th>
<th className="pb-3 font-medium"><FilterHeader label="Nombre Emisor" filterKey="nombreEmisor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} suggestions={uniqueSuggestions.nombreEmisor} /></th>
<th className="pb-3 font-medium">Régimen Receptor</th>
</>
)}
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortPendientes(prev => prev?.field === 'total' ? { field: 'total', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'total', dir: 'asc' })}>
<span className="flex items-center justify-center gap-1">Total MXN <ArrowUpDown className="h-3 w-3" /></span>
</th>
<th className="pb-3 font-medium">M. Pago</th>
<th className="pb-3"></th>
</tr>
</thead>
<tbody>
{pendientes.map((cfdi) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
{!isVisor && (
<td className="py-2">
<input
type="checkbox"
checked={selected.has(cfdi.id)}
onChange={() => toggleSelect(cfdi.id)}
/>
</td>
)}
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>
{cfdi.uuid?.substring(0, 8)}
</td>
<td className="py-2 text-xs">
{new Date(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-2 text-xs truncate max-w-[120px]">
{cfdi.nombreEmisor}
</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-2 text-xs truncate max-w-[120px]">
{cfdi.nombreReceptor}
</td>
<td className="py-2 text-right text-xs font-medium">
{formatCurrencyConciliacion(getMonto(cfdi))}
</td>
<td className="py-2 text-xs">{cfdi.metodoPago || '-'}</td>
<td className="py-2">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedCfdi(cfdi)}
title="Ver factura"
>
<Eye className="h-4 w-4" />
</Button>
{pendientesOrdenados.length === 0 ? (
<tr>
<td colSpan={isVisor ? 9 : 10} className="py-4 text-base text-muted-foreground text-center">
No hay CFDIs pendientes de conciliar
</td>
</tr>
))}
) : (
pendientesOrdenados.map((cfdi) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
{!isVisor && (
<td className="py-2">
<input
type="checkbox"
checked={selected.has(cfdi.id)}
onChange={() => toggleSelect(cfdi.id)}
/>
</td>
)}
<td className="py-2 font-mono text-sm text-center" title={cfdi.uuid}>
{cfdi.uuid?.substring(0, 8)}
</td>
<td className="py-2 text-sm text-center">
{toCfdiDate(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
</td>
{activeTab === 'EMITIDO' ? (
<>
<td className="py-2 font-mono text-sm text-center">{cfdi.rfcReceptor}</td>
<td className="py-2 text-sm truncate max-w-[120px] text-center">
{cfdi.nombreReceptor}
</td>
<td className="py-2 text-sm text-center">{cfdi.regimenFiscalEmisor || '-'}</td>
</>
) : (
<>
<td className="py-2 font-mono text-sm text-center">{cfdi.rfcEmisor}</td>
<td className="py-2 text-sm truncate max-w-[120px] text-center">
{cfdi.nombreEmisor}
</td>
<td className="py-2 text-sm text-center">{cfdi.regimenFiscalReceptor || '-'}</td>
</>
)}
<td className="py-2 text-sm font-medium text-center">
{formatCurrencyConciliacion(getMonto(cfdi))}
</td>
<td className="py-2 text-sm text-center">{cfdi.metodoPago || '-'}</td>
<td className="py-2 text-center">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedCfdi(cfdi)}
title="Ver factura"
>
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
@@ -340,79 +527,87 @@ export default function ConciliacionPage() {
{/* Conciliadas */}
<Card>
<CardContent className="pt-6">
<h3 className="font-medium mb-4">Conciliadas ({conciliadas.length})</h3>
{conciliadas.length === 0 ? (
<p className="text-sm text-muted-foreground">No hay CFDIs conciliados</p>
) : (
<h3 className="font-medium mb-4">Conciliadas ({conciliadasOrdenadas.length})</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-base">
<thead>
<tr className="border-b text-left text-muted-foreground">
<tr className="border-b text-center text-muted-foreground">
<th className="pb-3 font-medium">UUID</th>
<th className="pb-3 font-medium">Fecha Emisión</th>
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">Nombre Emisor</th>
<th className="pb-3 font-medium text-right">Total MXN</th>
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortConciliadas(prev => prev?.field === 'fecha' ? { field: 'fecha', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'fecha', dir: 'asc' })}>
<span className="flex items-center justify-center gap-1">Fecha Emisión <ArrowUpDown className="h-3 w-3" /></span>
</th>
<th className="pb-3 font-medium"><FilterHeader label={activeTab === 'EMITIDO' ? 'RFC Receptor' : 'RFC Emisor'} filterKey={activeTab === 'EMITIDO' ? 'rfcReceptor' : 'rfcEmisor'} filters={filtersConciliadas} setFilters={setFiltersConciliadas} openFilter={openFilterConciliadas} setOpenFilter={setOpenFilterConciliadas} suggestions={activeTab === 'EMITIDO' ? uniqueSuggestions.rfcReceptor : uniqueSuggestions.rfcEmisor} /></th>
<th className="pb-3 font-medium"><FilterHeader label={activeTab === 'EMITIDO' ? 'Nombre Receptor' : 'Nombre Emisor'} filterKey={activeTab === 'EMITIDO' ? 'nombreReceptor' : 'nombreEmisor'} filters={filtersConciliadas} setFilters={setFiltersConciliadas} openFilter={openFilterConciliadas} setOpenFilter={setOpenFilterConciliadas} suggestions={activeTab === 'EMITIDO' ? uniqueSuggestions.nombreReceptor : uniqueSuggestions.nombreEmisor} /></th>
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortConciliadas(prev => prev?.field === 'total' ? { field: 'total', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'total', dir: 'asc' })}>
<span className="flex items-center justify-center gap-1">Total MXN <ArrowUpDown className="h-3 w-3" /></span>
</th>
<th className="pb-3 font-medium">Fecha Pago</th>
<th className="pb-3 font-medium">Banco</th>
<th className="pb-3 font-medium"><FilterHeader label="Banco" filterKey="banco" filters={filtersConciliadas} setFilters={setFiltersConciliadas} openFilter={openFilterConciliadas} setOpenFilter={setOpenFilterConciliadas} suggestions={uniqueSuggestions.banco} /></th>
<th className="pb-3"></th>
</tr>
</thead>
<tbody>
{conciliadas.map((cfdi) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>
{cfdi.uuid?.substring(0, 8)}
{conciliadasOrdenadas.length === 0 ? (
<tr>
<td colSpan={8} className="py-4 text-base text-muted-foreground text-center">
No hay CFDIs conciliados
</td>
<td className="py-2 text-xs">
{new Date(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-2 text-xs truncate max-w-[120px]">
{cfdi.nombreEmisor}
</td>
<td className="py-2 text-right text-xs font-medium">
{formatCurrencyConciliacion(getMonto(cfdi))}
</td>
<td className="py-2 text-xs">
{cfdi.conciliacion?.fechaDePago
? new Date(
cfdi.conciliacion.fechaDePago + 'T12:00:00',
).toLocaleDateString('es-MX')
: '-'}
</td>
<td className="py-2 text-xs">
{cfdi.conciliacion
? `${cfdi.conciliacion.banco} ****${cfdi.conciliacion.terminacionCuenta}`
: '-'}
</td>
<td className="py-2 flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedCfdi(cfdi)}
title="Ver factura"
>
<Eye className="h-4 w-4" />
</Button>
{!isVisor && cfdi.conciliacion && (
</tr>
) : (
conciliadasOrdenadas.map((cfdi) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-2 font-mono text-sm text-center" title={cfdi.uuid}>
{cfdi.uuid?.substring(0, 8)}
</td>
<td className="py-2 text-sm text-center">
{toCfdiDate(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
</td>
<td className="py-2 font-mono text-sm text-center">{activeTab === 'EMITIDO' ? cfdi.rfcReceptor : cfdi.rfcEmisor}</td>
<td className="py-2 text-sm truncate max-w-[120px] text-center">
{activeTab === 'EMITIDO' ? cfdi.nombreReceptor : cfdi.nombreEmisor}
</td>
<td className="py-2 text-sm font-medium text-center">
{formatCurrencyConciliacion(getMonto(cfdi))}
</td>
<td className="py-2 text-sm text-center">
{cfdi.conciliacion?.fechaDePago
? new Date(
(cfdi.conciliacion.fechaDePago.split('T')[0]) + 'T12:00:00',
).toLocaleDateString('es-MX')
: '-'}
</td>
<td className="py-2 text-sm text-center">
{cfdi.conciliacion
? `${cfdi.conciliacion.banco} ****${cfdi.conciliacion.terminacionCuenta}`
: '-'}
</td>
<td className="py-2 flex gap-1 justify-center">
<Button
variant="ghost"
size="sm"
onClick={() => handleDesconciliar(cfdi.conciliacion!.id)}
title="Desconciliar"
className="text-destructive hover:text-destructive"
onClick={() => setSelectedCfdi(cfdi)}
title="Ver factura"
>
<X className="h-4 w-4" />
<Eye className="h-4 w-4" />
</Button>
)}
</td>
</tr>
))}
{!isVisor && cfdi.conciliacion && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDesconciliar(cfdi.conciliacion!.id)}
title="Desconciliar"
className="text-destructive hover:text-destructive"
>
<X className="h-4 w-4" />
</Button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</>

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui';
import { useTimbres } from '@/lib/hooks/use-facturacion';
@@ -9,21 +9,24 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { Shield, Upload, Check, AlertCircle, Receipt, Palette, Image, Building2, FileText } from 'lucide-react';
function CustomizationSection() {
function CustomizationSection({ contribuyenteId }: { contribuyenteId: string }) {
const queryClient = useQueryClient();
const [logoUploading, setLogoUploading] = useState(false);
const [colorSaving, setColorSaving] = useState(false);
const [color, setColor] = useState('#75A4FF');
const [msg, setMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const queryKey = ['facturapi-customization', contribuyenteId];
const { data: customization } = useQuery({
queryKey: ['facturapi-customization'],
queryFn: () => apiClient.get('/facturacion/customization').then(r => r.data),
queryKey,
queryFn: () => apiClient.get(`/contribuyentes/${contribuyenteId}/facturapi/customization`).then(r => r.data),
enabled: !!contribuyenteId,
});
useState(() => {
useEffect(() => {
if (customization?.color) setColor(`#${customization.color}`);
});
}, [customization?.color]);
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -45,8 +48,8 @@ function CustomizationSection() {
reader.onload = async () => {
const base64 = (reader.result as string).split(',')[1];
try {
await apiClient.post('/facturacion/logo', { logo: base64 });
queryClient.invalidateQueries({ queryKey: ['facturapi-customization'] });
await apiClient.post(`/contribuyentes/${contribuyenteId}/facturapi/logo`, { logo: base64 });
queryClient.invalidateQueries({ queryKey });
setMsg({ type: 'success', text: 'Logo subido correctamente' });
} catch {
setMsg({ type: 'error', text: 'Error al subir logo' });
@@ -61,8 +64,8 @@ function CustomizationSection() {
setColorSaving(true);
setMsg(null);
try {
await apiClient.put('/facturacion/color', { color: color.replace('#', '') });
queryClient.invalidateQueries({ queryKey: ['facturapi-customization'] });
await apiClient.put(`/contribuyentes/${contribuyenteId}/facturapi/color`, { color: color.replace('#', '') });
queryClient.invalidateQueries({ queryKey });
setMsg({ type: 'success', text: 'Color actualizado' });
} catch {
setMsg({ type: 'error', text: 'Error al actualizar color' });
@@ -357,7 +360,7 @@ export default function CsdConfigPage() {
)}
{/* Personalización de factura */}
{selectedContribuyenteId && orgStatus?.configured && <CustomizationSection />}
{selectedContribuyenteId && orgStatus?.configured && <CustomizationSection contribuyenteId={selectedContribuyenteId} />}
{/* Timbres */}
{selectedContribuyenteId && <Card>

View File

@@ -379,6 +379,7 @@ export default function ConfiguracionPage() {
const empresaNombre = viewingTenantName || user?.tenantName;
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const isDespacho = isDespachoTenant(user?.tenantRfc);
const showFullConfig = ['owner', 'cfo', 'supervisor'].includes(user?.role || '');
return (
<>
@@ -440,7 +441,7 @@ export default function ConfiguracionPage() {
)}
{/* Regímenes Fiscales, Domicilio Fiscal, Bancos */}
{(user?.role === 'owner' || user?.role === 'cfo') && (
{(user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'supervisor') && (
isDespacho && !selectedContribuyenteId ? (
<Card>
<CardContent className="py-6 text-center text-muted-foreground">
@@ -456,88 +457,112 @@ export default function ConfiguracionPage() {
)
)}
{/* SAT Configuration */}
<Link href="/configuracion/sat">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<RefreshCw className="h-4 w-4" />
Sincronizacion SAT
</CardTitle>
<CardDescription>
Configura tu FIEL y la sincronizacion automatica de CFDIs con el SAT
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Descarga automaticamente tus facturas emitidas y recibidas directamente del portal del SAT.
</p>
</CardContent>
</Card>
</Link>
{showFullConfig && (
<>
{/* SAT Configuration */}
<Link href="/configuracion/sat">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<RefreshCw className="h-4 w-4" />
Sincronizacion SAT
</CardTitle>
<CardDescription>
Configura tu FIEL y la sincronizacion automatica de CFDIs con el SAT
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Descarga automaticamente tus facturas emitidas y recibidas directamente del portal del SAT.
</p>
</CardContent>
</Card>
</Link>
{/* Obligaciones Fiscales */}
{(user?.role === 'owner' || user?.role === 'cfo') && (
<Link href="/configuracion/obligaciones">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Tags className="h-4 w-4" />
Obligaciones Fiscales
</CardTitle>
<CardDescription>
Gestiona las obligaciones fiscales de tus contribuyentes
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Recibe recomendaciones basadas en el régimen fiscal, agrega o elimina obligaciones según las necesidades de cada RFC.
</p>
</CardContent>
</Card>
</Link>
{/* Obligaciones Fiscales */}
{(user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'supervisor') && (
<Link href="/configuracion/obligaciones">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Tags className="h-4 w-4" />
Obligaciones Fiscales
</CardTitle>
<CardDescription>
Gestiona las obligaciones fiscales de tus contribuyentes
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Recibe recomendaciones basadas en el régimen fiscal, agrega o elimina obligaciones según las necesidades de cada RFC.
</p>
</CardContent>
</Card>
</Link>
)}
{/* Notificaciones */}
<Link href="/configuracion/notificaciones">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Bell className="h-4 w-4" />
Notificaciones
</CardTitle>
<CardDescription>
Activa o desactiva los correos informativos por contribuyente
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Controla qué correos quieres recibir por cada cliente: documentos subidos, reporte semanal, recordatorios fiscales, vencimiento de suscripción.
</p>
</CardContent>
</Card>
</Link>
{/* Preferencias de Facturación */}
<Link href="/configuracion/facturacion">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Receipt className="h-4 w-4" />
Preferencias de Facturación
</CardTitle>
<CardDescription>
Define cómo facturamos los pagos de tu suscripción a Horux 360
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Elige si tus facturas salen con tus datos fiscales o como Público en General. Configura el uso CFDI (G03 Gastos en general / S01 Sin obligaciones) y el régimen a usar si tienes varios activos.
</p>
</CardContent>
</Card>
</Link>
{/* CSD / Facturapi */}
<Link href="/configuracion/csd">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Building className="h-4 w-4" />
Certificado de Sello Digital (CSD)
</CardTitle>
<CardDescription>
Configura tu CSD para emitir facturas electrónicas desde Horux360
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Sube tu certificado y llave privada para timbrar CFDIs directamente desde la plataforma.
</p>
</CardContent>
</Card>
</Link>
</>
)}
{/* Notificaciones */}
<Link href="/configuracion/notificaciones">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Bell className="h-4 w-4" />
Notificaciones
</CardTitle>
<CardDescription>
Activa o desactiva los correos informativos por contribuyente
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Controla qué correos quieres recibir por cada cliente: documentos subidos, reporte semanal, recordatorios fiscales, vencimiento de suscripción.
</p>
</CardContent>
</Card>
</Link>
{/* Preferencias de Facturación (auto-emisión de pagos de suscripción) */}
<Link href="/configuracion/facturacion">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Receipt className="h-4 w-4" />
Preferencias de Facturación
</CardTitle>
<CardDescription>
Define cómo facturamos los pagos de tu suscripción a Horux 360
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Elige si tus facturas salen con tus datos fiscales o como Público en General. Configura el uso CFDI (G03 Gastos en general / S01 Sin obligaciones) y el régimen a usar si tienes varios activos.
</p>
</CardContent>
</Card>
</Link>
{/* Seguridad */}
<Link href="/configuracion/seguridad">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
@@ -558,26 +583,6 @@ export default function ConfiguracionPage() {
</Card>
</Link>
{/* CSD / Facturapi */}
<Link href="/configuracion/csd">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Building className="h-4 w-4" />
Certificado de Sello Digital (CSD)
</CardTitle>
<CardDescription>
Configura tu CSD para emitir facturas electrónicas desde Horux360
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Sube tu certificado y llave privada para timbrar CFDIs directamente desde la plataforma.
</p>
</CardContent>
</Card>
</Link>
{/* Admin global: edición de precios */}
{isGlobalAdmin && (
<>

View File

@@ -9,6 +9,7 @@ import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/lib/api/client';
import { Plus, Pencil, Trash2, Building2, Sparkles } from 'lucide-react';
import { AddonsDialog } from './addons-dialog';
import { DESPACHO_PLANS } from '@horux/shared';
const TRIAL_LIMIT_TOOLTIP = 'Límite de contribuyentes para la prueba gratuita, para continuar agregando contribuyentes, selecciona un plan.';
@@ -30,11 +31,21 @@ export default function ContribuyentesPage() {
// deshabilita el botón con tooltip explicativo.
const { data: planInfo } = useQuery({
queryKey: ['my-plan-info'],
queryFn: () => apiClient.get<{ isTrialActive: boolean }>('/despachos/me/plan').then(r => r.data),
queryFn: () => apiClient.get<{ plan: string; isTrialActive: boolean }>('/despachos/me/plan').then(r => r.data),
});
const activeCount = (contribuyentes ?? []).filter((c: any) => c.active !== false).length;
const trialAtLimit = (planInfo?.isTrialActive ?? false) && activeCount >= 5;
// Contador de RFCs disponibles en el plan
const planKey = planInfo?.plan as keyof typeof DESPACHO_PLANS | undefined;
const planMaxRfcs = planKey ? DESPACHO_PLANS[planKey]?.maxRfcs ?? undefined : undefined;
const rfcCounterText = (() => {
if (planInfo?.isTrialActive) return `${activeCount} de 5 RFCs`;
if (planMaxRfcs != null && planMaxRfcs < 0) return `${activeCount} RFCs`;
if (planMaxRfcs !== undefined) return `${activeCount} de ${planMaxRfcs} RFCs`;
return `${activeCount} RFCs`;
})();
const resetForm = () => { setForm({ rfc: '', razonSocial: '' }); setAssignSelf(true); setShowDialog(false); setEditingId(null); };
const handleSave = async () => {
@@ -77,18 +88,25 @@ export default function ContribuyentesPage() {
setShowDialog(true);
};
const canCreate = user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'supervisor';
return (
<div className="p-6 space-y-6 max-w-7xl mx-auto">
<div className="flex items-center justify-between">
<div><h1 className="text-2xl font-bold">Contribuyentes</h1><p className="text-sm text-muted-foreground">RFCs que gestiona tu despacho</p></div>
<Button
onClick={() => { resetForm(); setShowDialog(true); }}
disabled={trialAtLimit}
title={trialAtLimit ? TRIAL_LIMIT_TOOLTIP : undefined}
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" /> Agregar RFC
</Button>
<div>
<h1 className="text-2xl font-bold">Contribuyentes</h1>
<p className="text-sm text-muted-foreground">RFCs que gestiona tu despacho · {rfcCounterText}</p>
</div>
{canCreate && (
<Button
onClick={() => { resetForm(); setShowDialog(true); }}
disabled={trialAtLimit}
title={trialAtLimit ? TRIAL_LIMIT_TOOLTIP : undefined}
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" /> Agregar RFC
</Button>
)}
</div>
{isLoading ? <p className="text-muted-foreground">Cargando...</p> : !contribuyentes || contribuyentes.length === 0 ? (
@@ -96,13 +114,15 @@ export default function ContribuyentesPage() {
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Sin contribuyentes</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4">Agrega el primer RFC para empezar.</p>
<Button
onClick={() => { resetForm(); setShowDialog(true); }}
disabled={trialAtLimit}
title={trialAtLimit ? TRIAL_LIMIT_TOOLTIP : undefined}
>
Agregar primer RFC
</Button>
{canCreate && (
<Button
onClick={() => { resetForm(); setShowDialog(true); }}
disabled={trialAtLimit}
title={trialAtLimit ? TRIAL_LIMIT_TOOLTIP : undefined}
>
Agregar primer RFC
</Button>
)}
</CardContent></Card>
) : (
<div className="grid gap-3 lg:grid-cols-2 3xl:grid-cols-3 4xl:grid-cols-4">{contribuyentes.map((c) => (
@@ -111,11 +131,16 @@ export default function ContribuyentesPage() {
<p className="font-semibold">{c.nombre}</p>
<p className="text-sm text-muted-foreground font-mono">{c.rfc}</p>
{c.regimenFiscal && <p className="text-xs text-muted-foreground mt-1">Régimen: {c.regimenFiscal}</p>}
{c.supervisorNombre && <p className="text-xs text-muted-foreground mt-1">Supervisor: {c.supervisorNombre}</p>}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setAddonsTarget({ id: c.id, nombre: c.nombre })} title="Add-ons"><Sparkles className="h-4 w-4" /></Button>
{(user?.role === 'owner' || user?.role === 'cfo') && (
<Button variant="ghost" size="sm" onClick={() => setAddonsTarget({ id: c.id, nombre: c.nombre })} title="Add-ons"><Sparkles className="h-4 w-4" /></Button>
)}
<Button variant="ghost" size="sm" onClick={() => openEdit(c)}><Pencil className="h-4 w-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => handleDeactivate(c.id, c.rfc)} className="text-destructive hover:text-destructive"><Trash2 className="h-4 w-4" /></Button>
{user?.role === 'owner' && (
<Button variant="ghost" size="sm" onClick={() => handleDeactivate(c.id, c.rfc)} className="text-destructive hover:text-destructive"><Trash2 className="h-4 w-4" /></Button>
)}
</div>
</CardContent></Card>
))}</div>

View File

@@ -19,6 +19,8 @@ import {
AlertTriangle,
ShoppingCart,
CheckSquare,
FileMinus,
FilePlus,
} from 'lucide-react';
import { cn } from '@horux/shared-ui';
import { FiscalDisclaimer } from '@/components/fiscal-disclaimer';
@@ -118,6 +120,15 @@ export default function DashboardPage() {
? kpis?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpis?.ivaBalance || 0;
// Notas de crédito
const ncsEmitidasDisplay = regimenSeleccionado
? kpis?.ncsEmitidasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpis?.ncsEmitidas || 0;
const ncsRecibidasDisplay = regimenSeleccionado
? kpis?.ncsRecibidasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpis?.ncsRecibidas || 0;
const ivaAnterior = regimenSeleccionado
? kpisAnterior?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpisAnterior?.ivaBalance || 0;
@@ -126,9 +137,15 @@ export default function DashboardPage() {
? Math.round(((ivaDisplay - ivaAnterior) / Math.abs(ivaAnterior)) * 10000) / 100
: null;
const utilidadDisplay = ingresosDisplay - egresosDisplay;
const margenDisplay = ingresosDisplay > 0
? Math.round((utilidadDisplay / ingresosDisplay) * 10000) / 100
// Utilidad ajustada por notas de crédito:
// Ingresos netos = Ingresos NCs emitidas
// Egresos netos = Gastos NCs recibidas
// Utilidad neta = Ingresos netos Egresos netos
const ingresosNetosDisplay = ingresosDisplay - ncsEmitidasDisplay;
const egresosNetosDisplay = egresosDisplay - ncsRecibidasDisplay;
const utilidadDisplay = ingresosNetosDisplay - egresosNetosDisplay;
const margenDisplay = ingresosNetosDisplay > 0
? Math.round((utilidadDisplay / ingresosNetosDisplay) * 10000) / 100
: 0;
const formatCurrency = (value: number) =>
@@ -203,7 +220,7 @@ export default function DashboardPage() {
</div>
{/* KPIs */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<KpiCard
title={regimenSeleccionado ? `Ingresos del Mes (${regimenSeleccionado})` : 'Ingresos del Mes'}
value={ingresosDisplay}
@@ -216,6 +233,13 @@ export default function DashboardPage() {
}
href={drillUrl('Ingresos del Mes - CFDIs', { bucket: 'ingresos' })}
/>
<KpiCard
title={regimenSeleccionado ? `NCs Emitidas (${regimenSeleccionado})` : 'NCs Emitidas'}
value={ncsEmitidasDisplay}
icon={<FileMinus className="h-4 w-4" />}
trend="neutral"
trendValue="Notas de crédito emitidas"
/>
<KpiCard
title={regimenSeleccionado ? `Gastos del Mes (${regimenSeleccionado})` : 'Gastos del Mes'}
value={egresosDisplay}
@@ -229,11 +253,18 @@ export default function DashboardPage() {
href={drillUrl('Gastos del Mes - CFDIs', { bucket: 'gastos' })}
/>
<KpiCard
title="Utilidad"
title={regimenSeleccionado ? `NCs Recibidas (${regimenSeleccionado})` : 'NCs Recibidas'}
value={ncsRecibidasDisplay}
icon={<FilePlus className="h-4 w-4" />}
trend="neutral"
trendValue="Notas de crédito recibidas"
/>
<KpiCard
title={regimenSeleccionado ? `Utilidad Neta (${regimenSeleccionado})` : 'Utilidad Neta'}
value={utilidadDisplay}
icon={<Wallet className="h-4 w-4" />}
trend={utilidadDisplay > 0 ? 'up' : 'down'}
trendValue={`${margenDisplay}% margen`}
trendValue={`${margenDisplay}% margen · incluye NCs`}
/>
<KpiCard
title={regimenSeleccionado ? `Balance IVA (${regimenSeleccionado})` : 'Balance IVA'}
@@ -252,7 +283,7 @@ export default function DashboardPage() {
{/* Desglose por régimen */}
{!regimenSeleccionado && kpis && (
(kpis.ingresosPorRegimen.length > 1 || kpis.egresosPorRegimen.length > 1 || kpis.ivaBalancePorRegimen.length > 1) && (
(kpis.ingresosPorRegimen.length > 1 || kpis.egresosPorRegimen.length > 1 || kpis.ivaBalancePorRegimen.length > 1 || kpis.ncsEmitidasPorRegimen.length > 1 || kpis.ncsRecibidasPorRegimen.length > 1) && (
<div className="grid gap-4 md:grid-cols-2 3xl:grid-cols-3">
{kpis.ingresosPorRegimen.length > 1 && (
<Card>
@@ -316,6 +347,46 @@ export default function DashboardPage() {
</CardContent>
</Card>
)}
{kpis.ncsEmitidasPorRegimen.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base font-medium">NCs Emitidas por Regimen</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{kpis.ncsEmitidasPorRegimen.map((r) => (
<div key={r.regimenClave} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-3">
<span className="text-xs font-mono font-bold bg-muted px-2 py-1 rounded">{r.regimenClave}</span>
<span className="text-sm">{r.regimenDescripcion}</span>
</div>
<span className="text-sm font-semibold">{formatCurrency(r.monto)}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{kpis.ncsRecibidasPorRegimen.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base font-medium">NCs Recibidas por Regimen</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{kpis.ncsRecibidasPorRegimen.map((r) => (
<div key={r.regimenClave} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-3">
<span className="text-xs font-mono font-bold bg-muted px-2 py-1 rounded">{r.regimenClave}</span>
<span className="text-sm">{r.regimenDescripcion}</span>
</div>
<span className="text-sm font-semibold">{formatCurrency(r.monto)}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
))}

View File

@@ -25,7 +25,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as docsApi from '@/lib/api/documentos';
const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
const IMPUESTOS: Impuesto[] = ['IVA', 'ISR', 'IEPS', 'SUELDOS', 'DIOT', 'OTRO'];
const IMPUESTOS: Impuesto[] = ['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'];
const PERIODICIDADES: { value: Periodicidad; label: string }[] = [
{ value: 'mensual', label: 'Mensual' },
{ value: 'bimestral', label: 'Bimestral' },
@@ -76,7 +76,7 @@ function getPeriodLabel(periodicidad: string, mes: number): string {
const options = getPeriodOptions(periodicidad as Periodicidad);
return options.find(o => o.value === mes)?.label || MESES[mes - 1] || String(mes);
}
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar'];
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
function EstatusBadge({ estatus }: { estatus: string }) {
if (estatus === 'Positiva') return <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"><CheckCircle2 className="h-3 w-3" /> {estatus}</span>;
@@ -87,7 +87,7 @@ function EstatusBadge({ estatus }: { estatus: string }) {
export default function DocumentosPage() {
const user = useAuthStore((s) => s.user);
const canConsultarOpinion = user?.role === 'owner' || user?.role === 'cfo';
const canSeePapeleria = user?.role !== 'cliente';
const canSeePapeleria = true; // Todos los roles pueden ver papelería (cliente con restricciones)
return (
<>
@@ -700,7 +700,7 @@ function ComprobantePagoDialog({ declaracion, onClose }: { declaracion: Declarac
// Extras — PDFs libres (acuses, contratos, poderes, estados de cuenta, etc.)
// ============================================================================
const ROLES_UPLOAD_EXTRA = ['owner', 'cfo', 'contador', 'auxiliar'];
const ROLES_UPLOAD_EXTRA = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
function ExtrasTab() {
const user = useAuthStore((s) => s.user);

View File

@@ -7,7 +7,7 @@ import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, Button, SortableHeader, cn } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { formatCurrency } from '@/lib/utils';
import { formatCurrency, toCfdiDate } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
@@ -33,7 +33,7 @@ const EXCEL_COLUMNS = [
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fecha: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: Number(c.totalMxn || 0),
_montoPagoMxn: Number(c.montoPagoMxn || 0),
_ivaMxn: Number(c.ivaTrasladoMxn || 0),
@@ -69,7 +69,7 @@ export default function DrillDownPage() {
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total' | 'pago' | 'iva'>(
data,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
total: (c) => Number(c.totalMxn || 0),
pago: (c) => Number(c.montoPagoMxn || 0),
iva: (c) => Number(c.ivaTrasladoMxn || 0),
@@ -142,7 +142,7 @@ export default function DrillDownPage() {
<tr key={cfdi.id} className={cn('border-b hover:bg-muted/50', isNC && 'bg-red-50/50 dark:bg-red-950/20')}>
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>{cfdi.uuid?.substring(0, 8)}</td>
<td className={cn('py-2 text-xs font-mono', isNC && 'text-red-600 dark:text-red-400 font-semibold')} title={isNC ? 'Nota de crédito — resta del total' : undefined}>{cfdi.tipoComprobante}</td>
<td className="py-2 text-xs">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-2 text-xs">{toCfdiDate(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-2 text-xs truncate max-w-[120px]" title={cfdi.nombreEmisor}>{cfdi.nombreEmisor}</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcReceptor}</td>

View File

@@ -17,6 +17,7 @@ import type { InvoiceData, InvoiceLineItem, RfcSearchResult, CfdiPpdPendiente, C
import { searchRfcs, getCfdisPpd, searchConceptos, getCfdisRelacionables, downloadPdf, downloadXml } from '@/lib/api/facturacion';
import { Plus, Trash2, Send, Receipt, Search, Check, X, FileSearch, AlertTriangle } from 'lucide-react';
import { cn } from '@horux/shared-ui';
import { toCfdiDate } from '@/lib/utils';
interface TaxLine {
category: 'traslado' | 'retencion';
@@ -302,6 +303,12 @@ export default function FacturacionPage() {
const [serie, setSerie] = useState('');
const [folio, setFolio] = useState('');
const [condiciones, setCondiciones] = useState('');
const [cuentaPredial, setCuentaPredial] = useState('');
const [fechaEmision, setFechaEmision] = useState(() => {
const d = new Date();
d.setHours(12, 0, 0, 0);
return d.toISOString().slice(0, 16);
});
// Conceptos
const [conceptos, setConceptos] = useState<ConceptoForm[]>([{ ...emptyConcepto }]);
@@ -343,6 +350,7 @@ export default function FacturacionPage() {
setSerie('');
setFolio('');
setCondiciones('');
setCuentaPredial('');
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit }]);
setRelatedUuid('');
setRelatedRelationship('01');
@@ -534,6 +542,10 @@ export default function FacturacionPage() {
// Resetear conceptos con unidad default según tipo
const defaultUnit = tipo === 'T' ? 'H87' : 'E48';
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit }]);
// Resetear fecha de emisión al día actual (12:00)
const d = new Date();
d.setHours(12, 0, 0, 0);
setFechaEmision(d.toISOString().slice(0, 16));
};
// Unidades de servicio que no aplican para Traslado
@@ -601,8 +613,7 @@ export default function FacturacionPage() {
// Cálculos
function calcConcepto(c: ConceptoForm) {
const trasladoRates = c.taxes.filter(t => t.category === 'traslado' && t.factor === 'Tasa').reduce((s, t) => s + t.rate, 0);
const unitPrice = trasladoRates > 0 ? c.price / (1 + trasladoRates) : c.price;
const base = unitPrice * c.quantity - c.discount;
const base = c.price * c.quantity - c.discount;
const traslados = c.taxes.filter(t => t.category === 'traslado' && t.factor === 'Tasa').reduce((s, t) => s + base * t.rate, 0);
const retenciones = c.taxes.filter(t => t.category === 'retencion').reduce((s, t) => s + base * t.rate, 0);
return { base, traslados, retenciones };
@@ -649,6 +660,21 @@ export default function FacturacionPage() {
if (serie) data.series = serie;
if (folio) data.folioNumber = parseInt(folio) || undefined;
if (condiciones) data.conditions = condiciones;
if (cuentaPredial) data.cuentaPredial = cuentaPredial;
// Validar fecha de emisión para I, E, T
if (tipoComprobante !== 'P' && fechaEmision) {
const now = new Date();
const selected = new Date(fechaEmision);
const minDate = new Date(now.getTime() - 72 * 60 * 60 * 1000);
if (selected > now) {
alert('La fecha de emisión no puede ser a futuro'); return;
}
if (selected < minDate) {
alert('La fecha de emisión no puede ser mayor a 72 horas en el pasado'); return;
}
data.fechaEmision = selected.toISOString();
}
if (config.needsConceptos) {
if (conceptos.some(c => !c.description || !c.productKey)) {
@@ -664,7 +690,7 @@ export default function FacturacionPage() {
quantity: c.quantity,
price: tipoComprobante === 'T' ? 0 : c.price,
discount: c.discount || 0,
taxIncluded: true,
taxIncluded: false,
objetoImp: c.objetoImp,
taxes: tipoComprobante === 'T' || c.objetoImp === '01' ? [] : c.taxes.map(t => ({
type: t.type,
@@ -843,7 +869,7 @@ export default function FacturacionPage() {
<p className="text-xs text-muted-foreground mt-0.5">
{cp.tipoCfdi === 'EMITIDO' ? cp.nombreReceptor : cp.nombreEmisor}
{' · '}
{new Date(cp.fechaEmision).toLocaleDateString('es-MX')}
{toCfdiDate(cp.fechaEmision).toLocaleDateString('es-MX')}
</p>
</div>
<div className="text-right flex-shrink-0 ml-3">
@@ -1076,6 +1102,17 @@ export default function FacturacionPage() {
</p>
</div>
)}
{tipoComprobante !== 'P' && (
<div className="space-y-2">
<Label>Fecha de Emisión</Label>
<Input
type="datetime-local"
value={fechaEmision}
onChange={e => setFechaEmision(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Máximo 72 horas en el pasado. No se permiten fechas a futuro.</p>
</div>
)}
<div className="space-y-2">
<Label>Serie (opcional)</Label>
<Input value={serie} onChange={e => setSerie(e.target.value.toUpperCase())} placeholder="P" maxLength={10} />
@@ -1237,7 +1274,7 @@ export default function FacturacionPage() {
<span className="font-bold">${Number(c.saldoPendiente).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{new Date(c.fechaEmision).toLocaleDateString('es-MX')} · Total: ${Number(c.totalMxn).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
{toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX')} · Total: ${Number(c.totalMxn).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</div>
</button>
))}
@@ -1311,6 +1348,30 @@ export default function FacturacionPage() {
</Card>
)}
{/* Cuenta Predial — solo régimen 606 (Arrendamiento) */}
{emisorRegimen === '606' && config.needsConceptos && (
<Card>
<CardHeader>
<CardTitle className="text-base">Datos del Inmueble</CardTitle>
<CardDescription>Obligatorio para arrendamiento (SAT)</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label>No. Cuenta Predial</Label>
<Input
value={cuentaPredial}
onChange={e => setCuentaPredial(e.target.value.replace(/[^0-9a-zA-Z]/g, '').toUpperCase())}
placeholder="Ej. 15956011002"
maxLength={150}
/>
<p className="text-xs text-muted-foreground">
Número de cuenta predial del inmueble arrendado. Si contiene letras o guiones, sustitúyalos por "0".
</p>
</div>
</CardContent>
</Card>
)}
{/* Conceptos (Ingreso, Egreso, Traslado) */}
{config.needsConceptos && (
<Card>
@@ -1402,7 +1463,7 @@ export default function FacturacionPage() {
{tipoComprobante !== 'T' && (
<>
<div className="space-y-2">
<Label>Precio Unitario (IVA incluido)</Label>
<Label>Precio Unitario (sin IVA)</Label>
<Input type="number" min="0" step="0.01" value={c.price || ''} onChange={e => updateConcepto(idx, 'price', parseFloat(e.target.value) || 0)} placeholder="0.00" required />
</div>
<div className="space-y-2">
@@ -1540,7 +1601,7 @@ export default function FacturacionPage() {
<span className="px-1.5 py-0.5 rounded bg-muted">{c.tipoComprobante}</span>
{c.serie || c.folio ? <span>{c.serie || ''}{c.folio ? `-${c.folio}` : ''}</span> : null}
<span>${Number(c.totalMxn).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
<span>{new Date(c.fechaEmision).toLocaleDateString('es-MX')}</span>
<span>{toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX')}</span>
</div>
</button>
))}

View File

@@ -11,7 +11,7 @@ import { Header } from '@/components/layouts/header';
import {
ClipboardList,
CheckCircle2,
Circle,
Clock,
AlertTriangle,
Building2,
@@ -75,7 +75,7 @@ export default function PendientesPage() {
const [loading, setLoading] = useState(true);
const [singleObligaciones, setSingleObligaciones] = useState<ObligacionPeriodo[]>([]);
const [filter, setFilter] = useState<'todos' | 'mis'>('todos');
const [toggling, setToggling] = useState<string | null>(null);
// Single contribuyente view — fetch period-aware data
useEffect(() => {
@@ -132,31 +132,7 @@ export default function PendientesPage() {
const pendientesCount = singleObligaciones.filter((o) => o.periodStatus === 'pendiente').length;
const categorias = [...new Set(singleObligaciones.map((o) => o.categoria || 'Sin categoría'))];
const toggleComplete = async (obligacionId: string, currentStatus: string, periodoAplica: string) => {
if (!selectedContribuyenteId) return;
const key = `${obligacionId}:${periodoAplica}`;
setToggling(key);
try {
if (currentStatus === 'completada') {
await apiClient.post(
`/contribuyentes/${selectedContribuyenteId}/obligaciones/${obligacionId}/uncomplete-periodo`,
{ periodo: periodoAplica }
);
} else {
await apiClient.post(
`/contribuyentes/${selectedContribuyenteId}/obligaciones/${obligacionId}/complete-periodo`,
{ periodo: periodoAplica }
);
}
// Refetch
const { data } = await apiClient.get(`/contribuyentes/${selectedContribuyenteId}/obligaciones/periodo?periodo=${periodo}&atrasados=true`);
setSingleObligaciones(data.data || []);
} catch {
// silent — state stays as-is
} finally {
setToggling(null);
}
};
// Status badge
const statusBadge = (status: string) => {
@@ -311,18 +287,15 @@ export default function PendientesPage() {
)}
>
<div className="flex items-center gap-3">
<button
onClick={() => toggleComplete(ob.id, ob.periodStatus, ob.periodoAplica)}
disabled={toggling === toggleKey}
className="shrink-0 focus:outline-none"
title={ob.periodStatus === 'completada' ? 'Marcar como pendiente' : 'Marcar como completada'}
>
<span className="shrink-0">
{ob.periodStatus === 'completada' ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : ob.periodStatus === 'atrasada' ? (
<AlertTriangle className="h-4 w-4 text-red-400" />
) : (
<Circle className={cn('h-4 w-4', ob.periodStatus === 'atrasada' ? 'text-red-400' : 'text-muted-foreground')} />
<Clock className="h-4 w-4 text-muted-foreground" />
)}
</button>
</span>
<div>
<p className={cn('text-sm font-medium', ob.periodStatus === 'completada' && 'line-through')}>{ob.nombre}</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">

View File

@@ -9,7 +9,7 @@ import {
Button,
} from '@horux/shared-ui';
import { useEstadoResultadosDrillDown } from '@/lib/hooks/use-reportes';
import { formatCurrency } from '@/lib/utils';
import { formatCurrency, toCfdiDate } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { ArrowLeft, Download, Eye } from 'lucide-react';
import type { DrillDownResumenItem, DrillDownCfdiItem } from '@/lib/api/reportes';
@@ -92,7 +92,7 @@ export function EstadoResultadosDrillDownModal({
} else if (cfdis.length > 0) {
const rows = cfdis.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fecha: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
_monto: c.monto,
}));
exportToExcel(rows, CFDI_COLUMNS, `drill-down-${categoria}-cfdis`);
@@ -197,7 +197,7 @@ export function EstadoResultadosDrillDownModal({
</td>
<td className="py-3 text-sm font-mono">{item.tipoComprobante}</td>
<td className="py-3 text-sm">
{new Date(item.fechaEmision).toLocaleDateString('es-MX')}
{toCfdiDate(item.fechaEmision).toLocaleDateString('es-MX')}
</td>
<td className="py-3 font-mono text-sm">{item.rfcEmisor}</td>
<td className="py-3 text-sm truncate max-w-[180px]">

View File

@@ -0,0 +1,223 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, Button, cn } from '@horux/shared-ui';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { useMisTareas, useCompletarTareaPeriodo, useDescompletarTareaPeriodo } from '@/lib/hooks/use-tareas-mis';
import { CheckCircle2, Circle, AlertTriangle, Clock, Building2 } from 'lucide-react';
const RECURRENCIAS: Record<string, string> = {
semanal: 'Semanal',
quincenal: 'Quincenal',
mensual: 'Mensual',
bimestral: 'Bimestral',
trimestral: 'Trimestral',
semestral: 'Semestral',
anual: 'Anual',
};
const DIAS_SEMANA = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado'];
interface TareaItem {
id: string;
contribuyenteId: string;
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
nombre: string;
descripcion: string | null;
recurrencia: string;
diaSemana: number | null;
diaMes: number | null;
soloSupervisorCompleta: boolean;
periodoActual: {
id: string;
fechaLimite: string;
completada: boolean;
completadaAt: string | null;
completadaPor: string | null;
notas: string | null;
} | null;
}
export default function TareasPage() {
const { data: tareas, isLoading } = useMisTareas();
const completarMut = useCompletarTareaPeriodo();
const descompletarMut = useDescompletarTareaPeriodo();
const [filter, setFilter] = useState<'todas' | 'pendientes' | 'completadas'>('todas');
if (isLoading) {
return (
<DashboardShell title="Mis Tareas">
<p className="text-muted-foreground">Cargando tareas...</p>
</DashboardShell>
);
}
const all = tareas ?? [];
const filtered = all.filter((t: TareaItem) => {
if (filter === 'pendientes') return !t.periodoActual?.completada;
if (filter === 'completadas') return t.periodoActual?.completada;
return true;
});
// Agrupar por contribuyente
const grouped = filtered.reduce((acc: Record<string, TareaItem[]>, t: TareaItem) => {
const key = t.contribuyenteId;
if (!acc[key]) acc[key] = [];
acc[key].push(t);
return acc;
}, {});
const contribuyenteMap = all.reduce((acc: Record<string, { rfc: string; razonSocial: string }>, t: TareaItem) => {
if (!acc[t.contribuyenteId]) {
acc[t.contribuyenteId] = { rfc: t.contribuyenteRfc, razonSocial: t.contribuyenteRazonSocial };
}
return acc;
}, {});
const pendingCount = all.filter((t: TareaItem) => !t.periodoActual?.completada).length;
const completedCount = all.filter((t: TareaItem) => t.periodoActual?.completada).length;
return (
<DashboardShell title="Mis Tareas">
<div className="space-y-4">
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="bg-amber-100 dark:bg-amber-900 rounded-full p-2">
<Clock className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-2xl font-bold">{pendingCount}</p>
<p className="text-xs text-muted-foreground">Pendientes</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="bg-green-100 dark:bg-green-900 rounded-full p-2">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-2xl font-bold">{completedCount}</p>
<p className="text-xs text-muted-foreground">Completadas</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex gap-2">
{(['todas', 'pendientes', 'completadas'] as const).map((f) => (
<Button
key={f}
variant={filter === f ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter(f)}
>
{f === 'todas' ? 'Todas' : f === 'pendientes' ? 'Pendientes' : 'Completadas'}
</Button>
))}
</div>
{/* Tareas por contribuyente */}
{Object.keys(grouped).length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay tareas {filter === 'pendientes' ? 'pendientes' : filter === 'completadas' ? 'completadas' : ''}.
</CardContent>
</Card>
) : (
Object.keys(grouped).map((contribuyenteId) => {
const info = contribuyenteMap[contribuyenteId];
const items = grouped[contribuyenteId];
return (
<Card key={contribuyenteId}>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span>{info.razonSocial}</span>
<span className="text-muted-foreground font-normal">({info.rfc})</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{items.map((t: TareaItem) => {
const p = t.periodoActual;
const fl = p ? new Date(p.fechaLimite) : null;
const today = new Date(); today.setHours(0, 0, 0, 0);
const atrasada = !!fl && !p?.completada && fl < today;
const recLabel = RECURRENCIAS[t.recurrencia] || t.recurrencia;
const cuando = (t.recurrencia === 'semanal' || t.recurrencia === 'quincenal')
? DIAS_SEMANA[t.diaSemana ?? 1]
: `día ${t.diaMes}`;
return (
<div
key={t.id}
className={cn(
'flex items-center gap-3 py-2 border-b last:border-0',
p?.completada && 'opacity-60'
)}
>
<button
onClick={() => {
if (!p) return;
if (p.completada) {
descompletarMut.mutate(p.id);
} else {
completarMut.mutate(p.id);
}
}}
disabled={!p || completarMut.isPending || descompletarMut.isPending}
title={p?.completada ? 'Marcar pendiente' : 'Marcar completada'}
className="flex-shrink-0 focus:outline-none"
>
{p?.completada ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : atrasada ? (
<AlertTriangle className="h-5 w-5 text-red-400" />
) : (
<Circle className="h-5 w-5 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={cn('text-sm font-medium', p?.completada && 'line-through text-muted-foreground')}>
{t.nombre}
</span>
{t.soloSupervisorCompleta && (
<span className="text-[10px] uppercase bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200 rounded px-1.5 py-0.5">
Supervisor
</span>
)}
{atrasada && (
<span className="text-[10px] uppercase bg-red-100 text-red-700 rounded px-1.5 py-0.5">
Atrasada
</span>
)}
</div>
{t.descripcion && (
<p className="text-xs text-muted-foreground truncate">{t.descripcion}</p>
)}
<p className="text-xs text-muted-foreground mt-0.5">
{recLabel} · {cuando}
{fl && ` · vence ${fl.toLocaleDateString('es-MX', { day: 'numeric', month: 'short' })}`}
</p>
</div>
</div>
);
})}
</CardContent>
</Card>
);
})
)}
</div>
</DashboardShell>
);
}

View File

@@ -77,10 +77,14 @@ export default function UsuariosPage() {
const deleteUsuario = useDeleteUsuario();
const isDespacho = isDespachoTenant(currentUser?.tenantRfc);
const inviteRoles = isDespacho ? despachoInviteRoles : legacyInviteRoles;
const inviteRoles = isDespacho
? (currentUser?.role === 'supervisor'
? despachoInviteRoles.filter(r => r.value === 'cliente')
: despachoInviteRoles)
: legacyInviteRoles;
const defaultInviteRole = isDespacho ? 'auxiliar' : 'visor';
const isAdmin = currentUser?.role === 'owner' || currentUser?.role === 'cfo';
const isAdmin = currentUser?.role === 'owner' || currentUser?.role === 'cfo' || currentUser?.role === 'supervisor';
const [showInvite, setShowInvite] = useState(false);
const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: UserInvite['role']; supervisorUserId?: string }>({
@@ -96,15 +100,18 @@ export default function UsuariosPage() {
const [savingAccesos, setSavingAccesos] = useState(false);
// Edit supervisor modal (para auxiliares)
const [editingSupervisorUser, setEditingSupervisorUser] = useState<{ id: string; nombre: string } | null>(null);
const [editingSupervisorUser, setEditingSupervisorUser] = useState<{ id: string; nombre: string; supervisorNombre?: string | null } | null>(null);
const [selectedSupervisorId, setSelectedSupervisorId] = useState<string>('');
const [savingSupervisor, setSavingSupervisor] = useState(false);
const [currentSupervisorNombre, setCurrentSupervisorNombre] = useState<string>('');
const openEditSupervisor = async (userId: string, nombre: string) => {
try {
const res = await apiClient.get<{ supervisorUserId: string | null }>(`/usuarios/${userId}/supervisor`);
const res = await apiClient.get<{ supervisorUserId: string | null; supervisorNombre: string | null }>(`/usuarios/${userId}/supervisor`);
setSelectedSupervisorId(res.data.supervisorUserId ?? '');
setEditingSupervisorUser({ id: userId, nombre });
setCurrentSupervisorNombre(res.data.supervisorNombre ?? '');
setEditingSupervisorUser({ id: userId, nombre, supervisorNombre: res.data.supervisorNombre });
} catch {
alert('Error al cargar supervisor');
}
@@ -483,7 +490,14 @@ export default function UsuariosPage() {
<div className="space-y-2 py-2">
{supervisores && supervisores.length > 0 ? (
<Select value={selectedSupervisorId || 'none'} onValueChange={(v) => setSelectedSupervisorId(v === 'none' ? '' : v)}>
<SelectTrigger><SelectValue placeholder="Selecciona un supervisor..." /></SelectTrigger>
<SelectTrigger className="w-full">
{(() => {
if (!selectedSupervisorId || selectedSupervisorId === 'none') return <span className="text-muted-foreground">Sin supervisor asignado</span>;
const s = supervisores?.find(x => x.userId === selectedSupervisorId);
if (s) return <span>{s.nombre} {s.email}</span>;
return <span>{currentSupervisorNombre || editingSupervisorUser?.supervisorNombre || selectedSupervisorId}</span>;
})()}
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Sin supervisor asignado</SelectItem>
{supervisores.map(s => (
@@ -491,6 +505,12 @@ export default function UsuariosPage() {
{s.nombre} {s.email}
</SelectItem>
))}
{/* Si el supervisor actual no está en la lista de carteras, mostrarlo igual */}
{selectedSupervisorId && !supervisores.some(s => s.userId === selectedSupervisorId) && (
<SelectItem value={selectedSupervisorId}>
{currentSupervisorNombre || editingSupervisorUser?.supervisorNombre || selectedSupervisorId}
</SelectItem>
)}
</SelectContent>
</Select>
) : (

View File

@@ -24,21 +24,27 @@ const formatCurrency = (value: number) =>
currency: 'MXN',
}).format(value);
const formatDate = (dateString: string) =>
new Date(dateString).toLocaleDateString('es-MX', {
const formatDate = (dateString: string) => {
const d = new Date(dateString);
d.setHours(d.getHours() - 1);
return d.toLocaleDateString('es-MX', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
};
const formatDateTime = (dateString: string) =>
new Date(dateString).toLocaleString('es-MX', {
const formatDateTime = (dateString: string) => {
const d = new Date(dateString);
d.setHours(d.getHours() - 1);
return d.toLocaleString('es-MX', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const typeLabels: Record<string, string> = {
EMITIDO: 'Emitido',

View File

@@ -33,6 +33,16 @@ export function ContribuyenteSelector() {
}
}, [contribuyentes, selectedContribuyenteId, setSelectedContribuyente]);
// Clear invalid selection (e.g. stale localStorage from another tenant/session)
useEffect(() => {
if (contribuyentes && contribuyentes.length > 0 && selectedContribuyenteId) {
const exists = contribuyentes.some(c => c.id === selectedContribuyenteId);
if (!exists) {
clearSelectedContribuyente();
}
}
}, [contribuyentes, selectedContribuyenteId, clearSelectedContribuyente]);
if (isLoading || !contribuyentes || contribuyentes.length === 0) return null;
if (pathname && HIDDEN_PATHS.some(p => pathname === p || pathname.startsWith(`${p}/`))) return null;

View File

@@ -16,7 +16,7 @@ interface NavItem {
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
const ITEMS: NavItem[] = [
{ href: '/despachos/contribuyentes', label: 'Contribuyentes', icon: Building2, roles: ['owner', 'cfo', 'contador', 'visor', 'supervisor', 'auxiliar'] },
{ href: '/despachos/contribuyentes', label: 'Contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
{ href: '/despachos/mis-asignados', label: 'Mis asignados', icon: UserCheck, roles: ['owner', 'cfo', 'supervisor', 'auxiliar', 'contador', 'visor'] },
{ href: '/despachos/equipo', label: 'Equipo', icon: Users, roles: ['owner', 'cfo', 'supervisor'] },
];

View File

@@ -10,7 +10,7 @@ import {
import { apiClient } from '@/lib/api/client';
import { useAuthStore } from '@/stores/auth-store';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { Upload, Download, Trash2, CheckCircle2, XCircle, Clock, AlertTriangle, MessageSquare } from 'lucide-react';
import { Upload, Download, Trash2, CheckCircle2, XCircle, Clock, AlertTriangle, MessageSquare, UserCheck } from 'lucide-react';
const MESES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
const ALLOWED_MIMES = [
@@ -37,6 +37,9 @@ interface Papeleria {
requiereAprobacion: boolean;
estado: 'pendiente' | 'aprobado' | 'rechazado' | null;
comentarioRechazo: string | null;
requiereAprobacionCliente: boolean;
estadoCliente: 'pendiente' | 'aprobado' | 'rechazado' | null;
comentarioRechazoCliente: string | null;
subidoPor: string;
createdAt: string;
}
@@ -54,28 +57,59 @@ function fileToBase64(file: File): Promise<string> {
});
}
function EstadoBadge({ estado, requiereAprobacion }: { estado: string | null; requiereAprobacion: boolean }) {
if (!requiereAprobacion) {
function estadoGlobal(item: Papeleria): 'sin_aprobacion' | 'pendiente' | 'aprobado' | 'rechazado' {
const reqOwner = item.requiereAprobacion;
const reqCliente = item.requiereAprobacionCliente;
const estOwner = item.estado;
const estCliente = item.estadoCliente;
if (!reqOwner && !reqCliente) return 'sin_aprobacion';
if (estOwner === 'rechazado' || estCliente === 'rechazado') return 'rechazado';
if (reqOwner && reqCliente) {
if (estOwner === 'aprobado' && estCliente === 'aprobado') return 'aprobado';
return 'pendiente';
}
if (reqOwner) return estOwner ?? 'pendiente';
return estCliente ?? 'pendiente';
}
function EstadoBadge({ item }: { item: Papeleria }) {
const global = estadoGlobal(item);
if (global === 'sin_aprobacion') {
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-muted-foreground">Sin aprobación</span>;
}
if (estado === 'aprobado') {
if (global === 'aprobado') {
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"><CheckCircle2 className="h-3 w-3" /> Aprobado</span>;
}
if (estado === 'rechazado') {
if (global === 'rechazado') {
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"><XCircle className="h-3 w-3" /> Rechazado</span>;
}
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"><Clock className="h-3 w-3" /> Pendiente</span>;
// Pendiente — mostrar quién falta
const faltaOwner = item.requiereAprobacion && item.estado !== 'aprobado';
const faltaCliente = item.requiereAprobacionCliente && item.estadoCliente !== 'aprobado';
let label = 'Pendiente';
if (faltaOwner && faltaCliente) label = 'Pendiente (ambos)';
else if (faltaOwner) label = 'Pendiente (owner)';
else if (faltaCliente) label = 'Pendiente (cliente)';
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"><Clock className="h-3 w-3" /> {label}</span>;
}
export function PapeleriaTab() {
const user = useAuthStore(s => s.user);
const { selectedContribuyenteId } = useContribuyenteStore();
const queryClient = useQueryClient();
const canApprove = user?.role ? ROLES_APROBADOR.has(user.role) : false;
const isCliente = user?.role === 'cliente';
const canApproveOwner = user?.role ? ROLES_APROBADOR.has(user.role) : false;
const canUpload = !isCliente;
const [showUpload, setShowUpload] = useState(false);
const [rechazoFor, setRechazoFor] = useState<Papeleria | null>(null);
const [comentarioRechazo, setComentarioRechazo] = useState('');
const [rechazoClienteFor, setRechazoClienteFor] = useState<Papeleria | null>(null);
const [comentarioRechazoCliente, setComentarioRechazoCliente] = useState('');
// Filtros
const currentYear = new Date().getFullYear();
@@ -105,6 +139,7 @@ export function PapeleriaTab() {
const [anio, setAnio] = useState(currentYear);
const [mes, setMes] = useState(new Date().getMonth() + 1);
const [requiereAprobacion, setRequiereAprobacion] = useState(false);
const [requiereAprobacionCliente, setRequiereAprobacionCliente] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const resetUpload = () => {
@@ -114,6 +149,7 @@ export function PapeleriaTab() {
setAnio(currentYear);
setMes(new Date().getMonth() + 1);
setRequiereAprobacion(false);
setRequiereAprobacionCliente(false);
setUploadError(null);
};
@@ -130,6 +166,7 @@ export function PapeleriaTab() {
anio,
mes,
requiereAprobacion,
requiereAprobacionCliente,
archivoBase64: base64,
archivoFilename: file.name,
archivoMime: file.type,
@@ -172,11 +209,33 @@ export function PapeleriaTab() {
},
});
const aprobarClienteMutation = useMutation({
mutationFn: async (id: number) => apiClient.post(`/papeleria/${id}/aprobar-cliente`),
onSuccess: invalidate,
});
const rechazarClienteMutation = useMutation({
mutationFn: async ({ id, comentario }: { id: number; comentario: string | null }) =>
apiClient.post(`/papeleria/${id}/rechazar-cliente`, { comentario }),
onSuccess: () => {
setRechazoClienteFor(null);
setComentarioRechazoCliente('');
invalidate();
},
});
const eliminarMutation = useMutation({
mutationFn: async (id: number) => apiClient.delete(`/papeleria/${id}`),
onSuccess: invalidate,
});
const items = query.data ?? [];
const años = useMemo(() => {
const set = new Set<number>([currentYear]);
items.forEach(i => set.add(i.anio));
return [...set].sort((a, b) => b - a);
}, [items, currentYear]);
if (!selectedContribuyenteId) {
return (
<Card>
@@ -187,13 +246,6 @@ export function PapeleriaTab() {
);
}
const items = query.data ?? [];
const años = useMemo(() => {
const set = new Set<number>([currentYear]);
items.forEach(i => set.add(i.anio));
return [...set].sort((a, b) => b - a);
}, [items, currentYear]);
return (
<div className="space-y-4">
{/* Filtros + upload */}
@@ -236,9 +288,11 @@ export function PapeleriaTab() {
</select>
</div>
</div>
<Button onClick={() => setShowUpload(true)}>
<Upload className="h-4 w-4 mr-2" /> Subir documento
</Button>
{canUpload && (
<Button onClick={() => setShowUpload(true)}>
<Upload className="h-4 w-4 mr-2" /> Subir documento
</Button>
)}
</div>
{/* Listado */}
@@ -258,7 +312,7 @@ export function PapeleriaTab() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{it.nombre}</span>
<EstadoBadge estado={it.estado} requiereAprobacion={it.requiereAprobacion} />
<EstadoBadge item={it} />
<span className="text-xs text-muted-foreground">
{MESES[it.mes - 1]} {it.anio}
</span>
@@ -270,10 +324,31 @@ export function PapeleriaTab() {
{it.archivoFilename} · {(it.archivoSize / 1024).toFixed(0)} KB
· subido {new Date(it.createdAt).toLocaleDateString('es-MX')}
</p>
{/* Mostrar estado detallado para no-clientes */}
{!isCliente && (it.requiereAprobacion || it.requiereAprobacionCliente) && (
<div className="flex items-center gap-2 mt-1">
{it.requiereAprobacion && (
<span className={`text-xs inline-flex items-center gap-1 ${it.estado === 'aprobado' ? 'text-green-700 dark:text-green-400' : it.estado === 'rechazado' ? 'text-red-700 dark:text-red-400' : 'text-yellow-700 dark:text-yellow-400'}`}>
<UserCheck className="h-3 w-3" /> Owner: {it.estado ?? '—'}
</span>
)}
{it.requiereAprobacionCliente && (
<span className={`text-xs inline-flex items-center gap-1 ${it.estadoCliente === 'aprobado' ? 'text-green-700 dark:text-green-400' : it.estadoCliente === 'rechazado' ? 'text-red-700 dark:text-red-400' : 'text-yellow-700 dark:text-yellow-400'}`}>
<UserCheck className="h-3 w-3" /> Cliente: {it.estadoCliente ?? '—'}
</span>
)}
</div>
)}
{it.estado === 'rechazado' && it.comentarioRechazo && (
<p className="text-xs mt-1 flex items-start gap-1 text-red-700 dark:text-red-400">
<MessageSquare className="h-3 w-3 mt-0.5 flex-shrink-0" />
<span>{it.comentarioRechazo}</span>
<span><strong>Owner:</strong> {it.comentarioRechazo}</span>
</p>
)}
{it.estadoCliente === 'rechazado' && it.comentarioRechazoCliente && (
<p className="text-xs mt-1 flex items-start gap-1 text-red-700 dark:text-red-400">
<MessageSquare className="h-3 w-3 mt-0.5 flex-shrink-0" />
<span><strong>Cliente:</strong> {it.comentarioRechazoCliente}</span>
</p>
)}
</div>
@@ -281,7 +356,8 @@ export function PapeleriaTab() {
<Button variant="ghost" size="icon" onClick={() => downloadMutation.mutate(it)} title="Descargar">
<Download className="h-4 w-4" />
</Button>
{canApprove && it.requiereAprobacion && it.estado === 'pendiente' && (
{/* Botones owner/supervisor */}
{canApproveOwner && it.requiereAprobacion && it.estado === 'pendiente' && (
<>
<Button
variant="ghost" size="icon"
@@ -299,13 +375,34 @@ export function PapeleriaTab() {
</Button>
</>
)}
<Button
variant="ghost" size="icon"
onClick={() => confirm(`¿Eliminar "${it.nombre}"?`) && eliminarMutation.mutate(it.id)}
title="Eliminar"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
{/* Botones cliente */}
{isCliente && it.requiereAprobacionCliente && it.estadoCliente === 'pendiente' && (
<>
<Button
variant="ghost" size="icon"
onClick={() => aprobarClienteMutation.mutate(it.id)}
title="Aprobar"
>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</Button>
<Button
variant="ghost" size="icon"
onClick={() => setRechazoClienteFor(it)}
title="Rechazar"
>
<XCircle className="h-4 w-4 text-red-600" />
</Button>
</>
)}
{canUpload && (
<Button
variant="ghost" size="icon"
onClick={() => confirm(`¿Eliminar "${it.nombre}"?`) && eliminarMutation.mutate(it.id)}
title="Eliminar"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</CardContent>
</Card>
@@ -375,6 +472,14 @@ export function PapeleriaTab() {
/>
Este documento requiere aprobación de owner/supervisor
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={requiereAprobacionCliente}
onChange={e => setRequiereAprobacionCliente(e.target.checked)}
/>
Este documento requiere aprobación del cliente
</label>
{uploadError && (
<p className="text-xs text-destructive flex items-start gap-1">
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
@@ -394,7 +499,7 @@ export function PapeleriaTab() {
</DialogContent>
</Dialog>
{/* Modal Rechazo */}
{/* Modal Rechazo Owner */}
<Dialog open={!!rechazoFor} onOpenChange={(o) => { if (!o) { setRechazoFor(null); setComentarioRechazo(''); } }}>
<DialogContent>
<DialogHeader>
@@ -426,6 +531,39 @@ export function PapeleriaTab() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal Rechazo Cliente */}
<Dialog open={!!rechazoClienteFor} onOpenChange={(o) => { if (!o) { setRechazoClienteFor(null); setComentarioRechazoCliente(''); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rechazar documento</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<p className="text-sm">
Vas a rechazar <strong>{rechazoClienteFor?.nombre}</strong>. El comentario es opcional.
</p>
<div>
<Label>Comentario (opcional)</Label>
<Input
value={comentarioRechazoCliente}
onChange={e => setComentarioRechazoCliente(e.target.value)}
placeholder="Motivo del rechazo..."
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setRechazoClienteFor(null); setComentarioRechazoCliente(''); }}>
Cancelar
</Button>
<Button
onClick={() => rechazoClienteFor && rechazarClienteMutation.mutate({ id: rechazoClienteFor.id, comentario: comentarioRechazoCliente || null })}
className={cn('bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
>
Rechazar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -14,7 +14,7 @@ import {
Wallet, Calendar, AlertTriangle, CheckCircle2, Trash2, RotateCcw,
Building2, TrendingUp, Clock, CircleSlash, Filter,
} from 'lucide-react';
import { formatCurrency } from '@/lib/utils';
import { formatCurrency, toCfdiDate } from '@/lib/utils';
interface ActivoFijoItem {
cfdiId: number;
@@ -238,7 +238,7 @@ export function ActivosFijosTab({ año, mes }: { año: number; mes: number }) {
return (
<tr key={a.cfdiId} className="border-b hover:bg-muted/30">
<td className="px-3 py-2 whitespace-nowrap">
{new Date(a.fechaEmision).toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' })}
{toCfdiDate(a.fechaEmision).toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' })}
</td>
<td className="px-3 py-2">
<div className="font-mono text-xs">{a.rfcEmisor}</div>

View File

@@ -22,6 +22,7 @@ import {
ClipboardList,
CreditCard,
Gift,
CheckSquare2,
UserCog,
Shield,
FileWarning,
@@ -54,17 +55,17 @@ const navigation: NavItem[] = [
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo', 'supervisor', 'contador', 'auxiliar'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo', 'supervisor', 'auxiliar', 'cliente'] },
];
const adminNavigation: NavItem[] = [
{ name: 'Clientes', href: '/clientes', icon: Building2 },
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
{ name: 'Staff', href: '/admin/staff', icon: Shield },
{ name: 'Invitaciones Trial', href: '/admin/invitaciones-trial', icon: Gift },
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
];

View File

@@ -22,6 +22,7 @@ import {
ClipboardList,
CreditCard,
Gift,
CheckSquare2,
UserCog,
Shield,
FileWarning,
@@ -53,17 +54,17 @@ const navigation: NavItem[] = [
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo', 'supervisor', 'contador', 'auxiliar'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo', 'supervisor', 'auxiliar', 'cliente'] },
];
const adminNavigation: NavItem[] = [
{ name: 'Clientes', href: '/clientes', icon: Building2 },
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
{ name: 'Staff', href: '/admin/staff', icon: Shield },
{ name: 'Invitaciones Trial', href: '/admin/invitaciones-trial', icon: Gift },
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
];

View File

@@ -27,6 +27,7 @@ import {
ClipboardList,
ListChecks,
Gift,
CheckSquare2,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth';
@@ -57,17 +58,17 @@ const navigation: NavItem[] = [
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo', 'supervisor', 'contador', 'auxiliar'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo', 'supervisor', 'auxiliar'] },
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo', 'supervisor', 'auxiliar', 'cliente'] },
];
const adminNavigation: NavItem[] = [
{ name: 'Clientes', href: '/clientes', icon: Building2 },
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
{ name: 'Staff', href: '/admin/staff', icon: Shield },
{ name: 'Invitaciones Trial', href: '/admin/invitaciones-trial', icon: Gift },
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
];

View File

@@ -22,6 +22,7 @@ import {
ClipboardList,
CreditCard,
Gift,
CheckSquare2,
UserCog,
Shield,
FileWarning,
@@ -54,17 +55,17 @@ const navigation: NavItem[] = [
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo', 'supervisor', 'contador', 'auxiliar'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo', 'supervisor', 'auxiliar', 'cliente'] },
];
const adminNavigation: NavItem[] = [
{ name: 'Clientes', href: '/clientes', icon: Building2 },
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
{ name: 'Staff', href: '/admin/staff', icon: Shield },
{ name: 'Invitaciones Trial', href: '/admin/invitaciones-trial', icon: Gift },
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
];

View File

@@ -0,0 +1,61 @@
import { apiClient } from './client';
export interface AsignacionObligacion {
id: string;
obligacionId: string;
obligacionNombre: string;
contribuyenteId: string;
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
auxiliarUserId: string;
auxiliarNombre: string | null;
asignadoPor: string;
asignadoAt: string;
}
export interface AsignacionTarea {
id: string;
tareaId: string;
tareaNombre: string;
contribuyenteId: string;
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
auxiliarUserId: string;
auxiliarNombre: string | null;
asignadoPor: string;
asignadoAt: string;
}
export interface AsignacionesResponse {
obligaciones: AsignacionObligacion[];
tareas: AsignacionTarea[];
}
export const getAsignacionesPorSupervisor = () =>
apiClient.get<AsignacionesResponse>('/carteras/asignaciones').then(r => r.data);
export const getAsignacionesPorAuxiliar = () =>
apiClient.get<AsignacionesResponse>('/carteras/asignaciones/mias').then(r => r.data);
export interface SinAsignarResponse {
obligaciones: Omit<AsignacionObligacion, 'id' | 'auxiliarUserId' | 'auxiliarNombre' | 'asignadoPor' | 'asignadoAt'>[];
tareas: Omit<AsignacionTarea, 'id' | 'auxiliarUserId' | 'auxiliarNombre' | 'asignadoPor' | 'asignadoAt'>[];
}
export const getSinAsignar = () =>
apiClient.get<SinAsignarResponse>('/carteras/asignaciones/sin-asignar').then(r => r.data);
export const asignarObligacion = (contribuyenteId: string, obligacionId: string, auxiliarUserId: string) =>
apiClient.post(`/contribuyentes/${contribuyenteId}/obligaciones/${obligacionId}/asignar`, { auxiliarUserId }).then(r => r.data);
export const desasignarObligacion = (contribuyenteId: string, obligacionId: string) =>
apiClient.delete(`/contribuyentes/${contribuyenteId}/obligaciones/${obligacionId}/asignar`).then(r => r.data);
export const asignarTarea = (tareaId: string, auxiliarUserId: string) =>
apiClient.post(`/tareas/${tareaId}/asignar`, { auxiliarUserId }).then(r => r.data);
export const desasignarTarea = (tareaId: string) =>
apiClient.delete(`/tareas/${tareaId}/asignar`).then(r => r.data);
export const getAuxiliaresElegibles = (contribuyenteId: string) =>
apiClient.get<{ auxiliares: string[] }>(`/carteras/asignaciones/auxiliares-elegibles/${contribuyenteId}`).then(r => r.data);

View File

@@ -91,6 +91,11 @@ export async function getCfdiById(id: string): Promise<Cfdi> {
return response.data;
}
export async function downloadXmlsZip(filters: CfdiFilters): Promise<Blob> {
const response = await apiClient.post('/cfdi/download-xmls', filters, { responseType: 'blob' });
return response.data;
}
export async function getResumenCfdi(año?: number, mes?: number, contribuyenteId?: string) {
const params = new URLSearchParams();
if (año) params.set('año', año.toString());

View File

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

View File

@@ -1,6 +1,6 @@
import { apiClient } from './client';
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'SUELDOS' | 'DIOT' | 'OTRO';
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'ISN' | 'DIOT' | 'OTRO' | 'ISH';
export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'semestral' | 'anual';
export interface Declaracion {

View File

@@ -69,6 +69,8 @@ export interface InvoiceData {
series?: string;
folioNumber?: number;
conditions?: string;
fechaEmision?: string;
cuentaPredial?: string;
}
export interface InvoiceResult {
@@ -112,8 +114,8 @@ export const updatePaqueteCatalogo = (id: number, data: { precio?: number; activ
apiClient.put<PaqueteCatalogoAdmin>(`/facturacion/timbres/paquetes-catalogo/${id}`, data).then(r => r.data);
export const emitirFactura = (data: InvoiceData) =>
apiClient.post<InvoiceResult>('/facturacion/emitir', data).then(r => r.data);
export const cancelarFactura = (uuid: string, motive?: string, substitution?: string) =>
apiClient.post(`/facturacion/cancelar/${uuid}`, { motive, substitution }).then(r => r.data);
export const cancelarFactura = (uuid: string, motive?: string, substitution?: string, contribuyenteId?: string) =>
apiClient.post(`/facturacion/cancelar/${uuid}`, { motive, substitution, contribuyenteId }).then(r => r.data);
export const downloadPdf = (id: string) =>
apiClient.get(`/facturacion/pdf/${id}`, { responseType: 'blob' }).then(r => r.data);
export const downloadXml = (id: string) =>

View File

@@ -0,0 +1,31 @@
import { apiClient } from './client';
export interface TareaConContribuyente {
id: string;
contribuyenteId: string;
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
nombre: string;
descripcion: string | null;
recurrencia: string;
diaSemana: number | null;
diaMes: number | null;
soloSupervisorCompleta: boolean;
esDefault: boolean;
active: boolean;
orden: number;
createdAt: string;
auxiliarAsignadoId?: string | null;
periodoActual: {
id: string;
periodo: string;
fechaLimite: string;
completada: boolean;
completadaAt: string | null;
completadaPor: string | null;
notas: string | null;
} | null;
}
export const getMisTareas = () =>
apiClient.get<TareaConContribuyente[]>('/tareas/mis-tareas').then(r => r.data);

View File

@@ -0,0 +1,98 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getAsignacionesPorSupervisor,
getAsignacionesPorAuxiliar,
getSinAsignar,
asignarObligacion,
desasignarObligacion,
asignarTarea,
desasignarTarea,
getAuxiliaresElegibles,
} from '../api/asignaciones';
export function useAsignacionesSupervisor() {
return useQuery({
queryKey: ['asignaciones-supervisor'],
queryFn: getAsignacionesPorSupervisor,
});
}
export function useAsignacionesAuxiliar() {
return useQuery({
queryKey: ['asignaciones-auxiliar'],
queryFn: getAsignacionesPorAuxiliar,
});
}
export function useSinAsignar() {
return useQuery({
queryKey: ['asignaciones-sin-asignar'],
queryFn: getSinAsignar,
});
}
export function useAsignarObligacion() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ contribuyenteId, obligacionId, auxiliarUserId }: {
contribuyenteId: string;
obligacionId: string;
auxiliarUserId: string;
}) => asignarObligacion(contribuyenteId, obligacionId, auxiliarUserId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['asignaciones-supervisor'] });
qc.invalidateQueries({ queryKey: ['asignaciones-sin-asignar'] });
qc.invalidateQueries({ queryKey: ['obligaciones'] });
},
});
}
export function useDesasignarObligacion() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ contribuyenteId, obligacionId }: {
contribuyenteId: string;
obligacionId: string;
}) => desasignarObligacion(contribuyenteId, obligacionId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['asignaciones-supervisor'] });
qc.invalidateQueries({ queryKey: ['asignaciones-sin-asignar'] });
qc.invalidateQueries({ queryKey: ['obligaciones'] });
},
});
}
export function useAsignarTarea() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ tareaId, auxiliarUserId }: {
tareaId: string;
auxiliarUserId: string;
}) => asignarTarea(tareaId, auxiliarUserId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['asignaciones-supervisor'] });
qc.invalidateQueries({ queryKey: ['asignaciones-sin-asignar'] });
qc.invalidateQueries({ queryKey: ['tareas'] });
},
});
}
export function useDesasignarTarea() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ tareaId }: { tareaId: string }) => desasignarTarea(tareaId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['asignaciones-supervisor'] });
qc.invalidateQueries({ queryKey: ['asignaciones-sin-asignar'] });
qc.invalidateQueries({ queryKey: ['tareas'] });
},
});
}
export function useAuxiliaresElegibles(contribuyenteId: string | undefined) {
return useQuery({
queryKey: ['auxiliares-elegibles', contribuyenteId],
queryFn: () => getAuxiliaresElegibles(contribuyenteId!),
enabled: !!contribuyenteId,
});
}

View File

@@ -0,0 +1,37 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getMisTareas } from '../api/tareas-mis';
import { apiClient } from '../api/client';
export function useMisTareas() {
return useQuery({
queryKey: ['tareas-mis-tareas'],
queryFn: getMisTareas,
});
}
export function useCompletarTareaPeriodo() {
const qc = useQueryClient();
return useMutation({
mutationFn: (periodoId: string) =>
apiClient.post(`/tareas/periodo/${periodoId}/completar`).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tareas-mis-tareas'] });
qc.invalidateQueries({ queryKey: ['tareas'] });
},
onError: (err: any) => {
alert(err.response?.data?.message || 'No se pudo marcar como completada');
},
});
}
export function useDescompletarTareaPeriodo() {
const qc = useQueryClient();
return useMutation({
mutationFn: (periodoId: string) =>
apiClient.delete(`/tareas/periodo/${periodoId}/completar`).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tareas-mis-tareas'] });
qc.invalidateQueries({ queryKey: ['tareas'] });
},
});
}

View File

@@ -6,3 +6,11 @@ export function formatCurrency(value: number): string {
maximumFractionDigits: 0,
}).format(value);
}
/** Ajusta una fecha CFDI restando 1 hora para corregir el offset del SAT/Facturapi. */
export function toCfdiDate(dateString: string | null | undefined): Date {
if (!dateString) return new Date(0);
const d = new Date(dateString);
d.setHours(d.getHours() - 1);
return d;
}

View File

@@ -429,3 +429,61 @@ Se agregaron 5 campos adicionales al visualizador de CFDI (`CfdiInvoice`) para m
- **`regimenFiscalLabels`**: 20 regímenes fiscales (601626)
- **`tipoRelacionLabels`**: 7 tipos de relación SAT (`01` Nota de crédito … `07` Aplicación de anticipo)
- **`usoCfdiLabels`**: ya existía, se reutiliza para el receptor
---
## 9. Fix: Facturas Facturapi no aparecen en complemento de pago
**Fecha:** 2026-05-20
**Problema:** Las facturas emitidas por Facturapi con método de pago PPD no aparecían en el dropdown de "complemento de pago" (tipo P). Solo aparecían las descargadas del SAT.
**Causa raíz:** Al emitir vía Facturapi, el campo `saldo_pendiente_mxn` quedaba `NULL`. El endpoint `GET /facturacion/cfdis-ppd` filtra con `COALESCE(saldo_pendiente_mxn, 0) > 0`, excluyendo las facturas de Facturapi.
**Fix:**
- Después del `INSERT` en `emitir()`, se llama `recomputarSaldoPendiente(pool, [uuid])` para facturas tipo I + método PPD.
- Backfill: se recalcularon 352 filas en la BD del tenant `horux_hts240708lja`.
**Archivos:**
- `apps/api/src/controllers/facturacion.controller.ts` — Agregado `recomputarSaldoPendiente` post-emisión
---
## 10. Seguridad: cancelación de facturas cruzada entre contribuyentes
**Fecha:** 2026-05-20
**Problema:** Un usuario viendo como contribuyente **Horux 360** podía cancelar facturas emitidas por **Consultoria Alcaraz Salazar**.
**Causa raíz:** El endpoint `POST /facturacion/cancelar/:uuid` no validaba ownership del contribuyente. Solo buscaba por UUID y cancelaba.
**Fix (backend):**
- El endpoint ahora recibe `contribuyenteId` del body.
- Si el caller envía un `contribuyenteId` y el CFDI pertenece a otro contribuyente → **403 Forbidden**.
**Fix (frontend):**
- `cancelarFactura` ahora pasa `selectedContribuyenteId` al backend.
- El botón de cancelar en la tabla de CFDIs solo se muestra si:
- Modo legacy: la factura no tiene `contribuyenteId`
- Modo multi-RFC: `cfdi.contribuyenteId === selectedContribuyenteId`
**Archivos:**
- `apps/api/src/controllers/facturacion.controller.ts` — Validación 403 + recepción de contribuyenteId
- `apps/web/lib/api/facturacion.ts``cancelarFactura` acepta `contribuyenteId`
- `apps/web/app/(dashboard)/cfdi/page.tsx` — Condicional de visibilidad del botón cancelar
---
## 11. Sync inicial SAT — Consultoria Alcaraz Salazar
**Fecha:** 2026-05-20
**Contexto:** La FIEL de Alcaraz Salazar se subió el 2026-05-19, pero la extracción de CSF falló por timeout del SAT. La sincronización inicial nunca se ejecutó (no había job `initial` en `sat_sync_jobs`).
**Acciones:**
- Creado job `initial` manualmente para contribuyente `bd9ba71c-55f9-40d5-a0d7-18909419298b`.
- El sync descubrió ~616 CFDIs en bloques 20242026.
- La tabla `rfcs` se pobló, habilitando el autocompletado del receptor en facturación.
**Estado:** ✅ Sync completado exitosamente

152
docs/CAMBIOS-2026-05-24.md Normal file
View File

@@ -0,0 +1,152 @@
# Resumen de cambios - 24 de mayo de 2026
---
## 1. Refactor: Descarga masiva de XMLs por filtros
**Fecha:** 2026-05-24
### Problema
El mecanismo anterior requería que el usuario seleccionara individualmente cada CFDI mediante checkboxes. Era lento, propenso a errores y no permitía descargar rangos grandes eficientemente.
### Solución
Reemplazo completo por descarga basada en filtros: un botón "Descargar XMLs" descarga todos los CFDIs que coincidan con los filtros activos en la tabla (tipo, estado, fechas, RFC, emisor, receptor, búsqueda, contribuyente).
### Cambios
- **Backend:** `POST /cfdi/download-xmls` acepta `{ filters: CfdiFilters }` en lugar de `{ ids: number[] }`. Usa `getXmlsByFilters()` con `LIMIT 1000`.
- **Frontend:** Eliminados checkboxes de tabla y estado `selectedIds`. Botón de descarga permanente que usa filtros actuales.
- **Warning >1,000:** Si los filtros devuelven más de 1,000 CFDIs, se muestra `confirm()` con "Solo se descargarán los primeros 1,000 XMLs. ¿Continuar?" y se procede.
### Archivos
| Archivo | Cambio |
|---------|--------|
| `apps/api/src/services/cfdi.service.ts` | `getXmlsByFilters()` reusa `whereClause` de `getCfdis` |
| `apps/api/src/controllers/cfdi.controller.ts` | `downloadXmlsZip()` acepta filtros, genera ZIP |
| `apps/web/lib/api/cfdi.ts` | `downloadXmlsZip(filters: CfdiFilters)` |
| `apps/web/app/(dashboard)/cfdi/page.tsx` | Sin checkboxes; botón usa filtros actuales |
---
## 2. Conciliación: filtros con autocompletado
**Fecha:** ~2026-05-12
Filtros de columna en tablas de conciliación (RFC Emisor, Nombre Emisor, RFC Receptor, Nombre Receptor, Banco) con:
- Debounce de 300ms
- Dropdown de sugerencias clickeables (máx 8 items)
- Botones "Aplicar" y "Limpiar" dentro del Popover
### Archivos
| Archivo | Cambio |
|---------|--------|
| `apps/web/app/(dashboard)/conciliacion/page.tsx` | `FilterHeader` component con `useDebounce` |
---
## 3. Conciliación: columnas dinámicas según tab
**Fecha:** ~2026-05-12
Las tablas "Conciliadas" y "Por conciliar" muestran columnas diferentes según el tab activo (EMITIDO / RECIBIDO):
- **EMITIDO:** RFC Receptor, Nombre Receptor
- **RECIBIDO:** RFC Emisor, Nombre Emisor
En Pendientes se agregó también la columna de régimen fiscal correspondiente:
- **EMITIDO:** Régimen Emisor
- **RECIBIDO:** Régimen Receptor
### Archivos
| Archivo | Cambio |
|---------|--------|
| `apps/web/app/(dashboard)/conciliacion/page.tsx` | Renderizado condicional de columnas |
| `apps/api/src/services/conciliacion.service.ts` | SELECT incluye `regimen_fiscal_emisor` y `regimen_fiscal_receptor` |
---
## 4. Conciliación: métricas I+P-E
**Fecha:** ~2026-05-12
El cálculo de "Monto Conciliado" y "Pendiente" ahora aplica la fórmula contable:
- **Ingresos (I)** y **Pagos (P)** suman
- **Egresos (E)** restan
Implementado en `getMonto()` del frontend.
---
## 5. Conciliación: headers visibles sin datos
**Fecha:** ~2026-05-12
Las tablas de conciliación mantienen encabezados y filtros visibles incluso cuando no hay resultados (antes desaparecían completamente). Se muestra mensaje "No hay datos" en el `tbody`.
---
## 6. Conciliación: aumento de tamaño de fuente
**Fecha:** ~2026-05-12
- Tablas: `text-xs``text-base`
- Celdas: `text-xs``text-sm`
---
## 7. Backfill masivo de datos CFDI faltantes
**Fecha:** ~2026-05-10
**Problema:** ~63,618 CFDIs tenían campos vacíos (`serie`, `folio`, `metodo_pago`, `forma_pago`, `uso_cfdi`, `regimen_fiscal`) porque los INSERTs fallaron durante sincronización SAT al no existir la columna `año_global` en ese momento.
**Fix:**
- Se parsearon los XMLs originales desde disco
- Se actualizaron masivamente las filas faltantes vía script Node.js
---
## 8. Fix: Visor de CFDI en conciliación — campos faltantes
**Fecha:** 2026-05-09 (continuación)
El visor de CFDI desde conciliación ahora muestra correctamente:
- Status (Vigente/Cancelado)
- Forma de pago
- Serie/Folio
- Uso CFDI
- Subtotal, descuento, impuestos desglosados
- Moneda y tipo de cambio
Ver `docs/CAMBIOS-2026-05-09.md` sección 7 para detalles completos.
---
## Archivos modificados (consolidado)
### Backend (`apps/api/`)
| Archivo | Cambio |
|---------|--------|
| `src/services/cfdi.service.ts` | `getXmlsByFilters()` para descarga masiva por filtros |
| `src/controllers/cfdi.controller.ts` | `downloadXmlsZip()` refactorizado |
| `src/services/conciliacion.service.ts` | SELECT régimen fiscal + campos visor + fecha_pago_p |
### Frontend (`apps/web/`)
| Archivo | Cambio |
|---------|--------|
| `app/(dashboard)/cfdi/page.tsx` | Descarga XMLs por filtros, sin checkboxes |
| `lib/api/cfdi.ts` | `downloadXmlsZip()` recibe filtros |
| `app/(dashboard)/conciliacion/page.tsx` | Filtros debounce, columnas dinámicas, métricas I+P-E, font size, headers siempre visibles |
---
## Deploy
```bash
cd /root/HoruxDespachosNuevo
npm run build --filter=@horux/api
npm run build --filter=@horux/web
pm2 reload horux-api
pm2 reload horux-web
```
**Estado:** ✅ Exitoso

View File

@@ -0,0 +1,236 @@
# Sesión: Trial Business Control Prueba (Invitación desde Admin Global)
**Fecha:** 2026-05-04
**Feature:** Sistema de invitaciones de trial configurable para plan Business Control
---
## 1. Requerimiento
Crear un trial específico para el plan **Business Control**, llamado "Business Control Prueba", con las siguientes características:
1. **Registro simplificado**: Los nuevos usuarios se registran sin escoger plan. Todos empiezan con el trial genérico actual (30 días, 3 RFCs, 1 usuario, MANAGED).
2. **Invitación desde admin global**: El administrador global puede enviar una invitación a un tenant para activar "Business Control Prueba".
3. **Periodo gratuito configurable**: El admin define cuántos días dura la prueba (1-365 días).
4. **Todas las features de Business Control**: Durante el trial, el tenant tiene 100 RFCs, usuarios ilimitados, API, SAT incremental, etc.
5. **Al vencer**: El tenant debe pagar la suscripción de Business Control para continuar.
---
## 2. Decisiones Arquitectónicas
### 2.1 No nuevo enum Plan
No se agregó `business_control_trial` al enum `Plan`. En su lugar:
- `tenant.plan` se actualiza a `business_control` al aceptar la invitación.
- `subscription.plan` = `business_control`, `subscription.status` = `'trial'`.
- `tenant.trialEndsAt` se recalcula según los días configurados.
**Ventaja**: El feature-gate (`requireFeature`) y los límites (`DESPACHO_PLANS['business_control']`) funcionan automáticamente.
### 2.2 dbMode siempre MANAGED (Opción 1)
**Decisión arquitectónica clave**: Todos los tenants, sin importar el plan, siempre tienen `dbMode: 'MANAGED'`. El conector/BYO es una **feature de respaldo** que se activa por separado sin cambiar el modo de la base de datos.
**Razón**: El BYO es "como respaldo" para cuando fallen los servicios en la nube, pero muchos clientes no tendrán servidor físico desde el inicio. Business Control y Enterprise operan 100% en la nube; el conector local es un respaldo opcional.
**Cambios aplicados**:
- `signupDespacho()`: siempre crea tenant con `dbMode: 'MANAGED'`
- `provisionConnector()`: ya NO cambia `dbMode` a `'BYO'`; solo guarda `connectorTokenEnc` y `connectorTunnelHostname`
- `DESPACHO_PLANS`: `business_control` y `business_cloud` ahora tienen `dbMode: 'MANAGED'`
- Seed de `despacho_plan_prices`: actualizado a `MANAGED` para esos planes
- BD: `UPDATE despacho_plan_prices SET db_mode = 'MANAGED' WHERE plan IN ('business_control', 'business_cloud')`
### 2.3 Registro simplificado
Se eliminó la selección de plan y frecuencia del registro de despacho (`/register-despacho`). Todos los nuevos usuarios empiezan con trial genérico de 30 días.
---
## 3. Cambios Implementados
### 3.1 Backend
#### Migración Prisma — `TrialInvitation`
Nueva tabla en `apps/api/prisma/schema.prisma`:
```prisma
model TrialInvitation {
id String @id @default(uuid())
tenantId String @map("tenant_id")
invitedBy String @map("invited_by")
plan String @default("business_control")
durationDays Int @map("duration_days")
status String @default("pending")
token String @unique
emailSentTo String? @map("email_sent_to")
sentAt DateTime @default(now()) @map("sent_at")
expiresAt DateTime @map("expires_at")
acceptedAt DateTime? @map("accepted_at")
createdAt DateTime @default(now()) @map("created_at")
}
```
**Migración aplicada:** `20260507201624_add_trial_invitations`
#### Nuevo Service: `apps/api/src/services/trial-invitations.service.ts`
- `createInvitation()` — Crea invitación, genera token, envía email al owner
- `acceptInvitation()` — Valida token, actualiza tenant.plan, crea subscription trial
- `getInvitations()` — Lista invitaciones con enrich de tenant data
- `getPendingInvitationForTenant()` — Obtiene invitación pendiente para un tenant
- `cancelInvitation()` — Cancela invitación pendiente
#### Nuevo Controller: `apps/api/src/controllers/trial-invitations.controller.ts`
Endpoints:
- `POST /api/invitations/trial` — Solo global admin. Body: `{ tenantId, plan?, durationDays }`
- `GET /api/invitations/trial` — Solo global admin. Lista todas.
- `GET /api/invitations/trial/pending` — Autenticado. Devuelve invitación pendiente para el tenant.
- `POST /api/invitations/trial/:token/accept` — Autenticado. Owner del tenant.
- `POST /api/invitations/trial/:id/cancel` — Solo global admin.
- `GET /api/invitations/trial/token/:token` — Público (autenticado). Detalles de invitación.
#### Nuevas Routes: `apps/api/src/routes/trial-invitations.routes.ts`
Registradas en `app.ts` bajo `/api/invitations/trial`.
#### Modificación: `apps/api/src/controllers/despacho.controller.ts`
`getMyPlan()` ahora respeta `subscription.plan` cuando `status === 'trial'`:
```ts
if (subscription?.status === 'trial' && subscription.plan && subscription.plan !== 'trial') {
currentPlan = subscription.plan; // 'business_control' si es Business Control Prueba
} else if (isTrialActive) {
currentPlan = 'trial';
} else {
currentPlan = String(tenant.plan);
}
```
Schema Zod de signup: `plan` y `frequency` ahora son puramente opcionales (sin defaults forzados).
#### Modificación: `apps/api/src/services/despacho.service.ts`
- `signupDespacho()` ya no crea suscripción pagada durante el registro.
- Todos los nuevos tenants empiezan con `plan: 'trial'`, `dbMode: 'MANAGED'`.
- No se devuelve `paymentUrl` en el response.
#### Nuevo template de email: `apps/api/src/services/email/templates/trial-invitation.ts`
Email de invitación con link de activación y detalles del plan.
#### Modificación: `apps/api/src/services/email/email.service.ts`
Agregado método `sendTrialInvitation()`.
### 3.2 Frontend
#### Registro simplificado: `apps/web/app/(auth)/register-despacho/page.tsx`
- Eliminado step 3 (selección de plan y frecuencia).
- Solo 2 pasos: datos del despacho/owner → selección de vertical.
- No se envía `plan` ni `frequency` en el POST.
- Redirige a `/onboarding` tras registro exitoso.
#### Nueva página: `apps/web/app/invitacion/trial/[token]/page.tsx`
- Muestra detalles de la invitación (plan, días, despacho).
- Botón "Aceptar invitación" (requiere autenticación como owner).
- Si no está logueado, redirige a login con redirect.
- Estados: loading, inválida, expirada, aceptada, éxito.
#### Modificación: `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx`
- Detecta invitación pendiente vía `GET /api/invitations/trial/pending`.
- Muestra banner destacado: "Invitación especial — Business Control Prueba" con botón "Activar ahora".
- Al aceptar, recarga el plan info y muestra mensaje de éxito.
#### Nueva página de admin: `apps/web/app/(dashboard)/admin/invitaciones-trial/page.tsx`
- Formulario para crear invitación: selector de despacho, plan, duración en días.
- Tabla de historial con estados (Pendiente, Aceptada, Expirada, Cancelada).
- Acción cancelar para invitaciones pendientes.
#### Modificación: `apps/web/components/layouts/sidebar.tsx`
Agregado link "Invitaciones Trial" a la navegación de admin global.
#### Nuevo API client: `apps/web/lib/api/trial-invitations.ts`
Helpers para consumir todos los endpoints de invitaciones.
---
## 4. Flujo de Uso
### 4.1 Admin envía invitación
1. Admin global navega a **Invitaciones Trial** en el sidebar.
2. Selecciona un despacho, elige plan (Business Control o Enterprise), define duración (ej. 60 días).
3. Clic en "Enviar invitación".
4. El backend crea el token y envía email al owner del despacho.
### 4.2 Owner recibe y acepta
1. Owner recibe email con link `/invitacion/trial/{token}`.
2. Abre el link (si no está logueado, va a login primero).
3. Ve los detalles de la invitación y clic en "Aceptar invitación".
4. El backend:
- Actualiza `tenant.plan = 'business_control'`
- Actualiza `tenant.trialEndsAt = now + 60 días`
- Marca subscriptions trial anteriores como `trial_converted`
- Crea nueva subscription con `plan: 'business_control', status: 'trial'`
- Marca invitación como `accepted`
5. Owner es redirigido a `/configuracion/planes-despacho`.
### 4.3 Durante el trial
- El tenant tiene todas las features de Business Control (100 RFCs, usuarios ilimitados, API, etc.).
- `getMyPlan()` retorna `plan: 'business_control'` en lugar de `'trial'`.
- Feature-gate permite acceso a todas las funciones del plan.
### 4.4 Al vencer
- `plan-limits.middleware` detecta `needsRenewal = true`.
- Bloquea escrituras (POST/PUT/DELETE) con código `SUBSCRIPTION_INACTIVE`.
- Muestra banner: "Tu prueba gratuita terminó. Renueva tu plan para continuar."
- Owner puede contratar Business Control desde `/configuracion/planes-despacho`.
---
## 5. Archivos Modificados/Creados
### Backend (nuevos)
- `apps/api/src/services/trial-invitations.service.ts`
- `apps/api/src/controllers/trial-invitations.controller.ts`
- `apps/api/src/routes/trial-invitations.routes.ts`
- `apps/api/src/services/email/templates/trial-invitation.ts`
- `apps/api/prisma/migrations/20260507201624_add_trial_invitations/migration.sql`
### Backend (modificados)
- `apps/api/prisma/schema.prisma`
- `apps/api/prisma/seed.ts`
- `apps/api/src/app.ts`
- `apps/api/src/services/despacho.service.ts`
- `apps/api/src/controllers/despacho.controller.ts`
- `apps/api/src/services/email/email.service.ts`
- `apps/api/src/services/connector.service.ts`
- `apps/api/src/services/admin-dashboard.service.ts`
- `packages/shared/src/constants/despacho-plans.ts`
### Frontend (nuevos)
- `apps/web/app/invitacion/trial/[token]/page.tsx`
- `apps/web/app/(dashboard)/admin/invitaciones-trial/page.tsx`
- `apps/web/lib/api/trial-invitations.ts`
### Frontend (modificados)
- `apps/web/app/(auth)/register-despacho/page.tsx`
- `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx`
- `apps/web/components/layouts/sidebar.tsx`
---
## 6. Deploy
```bash
cd /root/HoruxDespachosNuevo
# Backend: migración ya aplicada + build implícito en reload
# Frontend:
pnpm build --filter=@horux/web
# PM2 reload:
pm2 reload horux-api
pm2 reload horux-web
```
**Estado:** ✅ Exitoso. Build sin errores. Procesos reiniciados.
---
## 7. Notas para Futuras Sesiones
- Si se quiere agregar más planes a las invitaciones (ej. `mi_empresa_plus`), solo hay que agregarlos al Select del frontend de admin.
- El email de invitación usa `FRONTEND_URL` del environment. Si no está seteado, fallback a `https://app.horux360.com`.
- La invitación expira en 7 días desde el envío (configurable en `createInvitation()`).
- Si un tenant en trial genérico acepta Business Control Prueba, su trial anterior se pierde (se sobreescribe `trialEndsAt`). Esto es intencional.
- Considerar agregar un cron que marque invitaciones expiradas automáticamente (hoy solo se marcan al intentar aceptar).
- **dbMode siempre MANAGED**: El conector/BYO es una feature de respaldo independiente. Nunca cambiar `dbMode` a `'BYO'` en ningún flujo. Si en el futuro se quiere usar la conexión BYO como fallback cuando la nube falla, implementarlo en el `tenant.middleware.ts` como lógica de retry/fallback, no como modo principal.

Some files were not shown because too many files have changed in this diff Show More