Compare commits

..

18 Commits

Author SHA1 Message Date
Marlene-Angel
07fc9a8fe3 feat: add onboarding screen and redirect new users after login 2026-01-24 20:02:21 -08:00
Consultoria AS
492cd62772 debug: add logging to verify SAT status 2026-01-25 02:20:29 +00:00
Consultoria AS
008f586b54 fix: reduce sync years to 6 (SAT maximum allowed)
SAT only allows downloading CFDIs from the last 6 years.
Reduced from 10 to avoid wasted requests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 02:17:33 +00:00
Consultoria AS
38466a2b23 fix: use isTypeOf for SAT status request checking
The StatusRequest class has an isTypeOf method that properly checks
the status. Using getValue() and comparing numbers was unreliable.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 02:17:17 +00:00
Consultoria AS
98d704a549 feat: use @nodecfdi/sat-ws-descarga-masiva for SAT sync
Replace manual SOAP authentication with the official nodecfdi library
which properly handles WS-Security signatures for SAT web services.

- Add sat-client.service.ts using Fiel.create() for authentication
- Update sat.service.ts to use new client
- Update fiel.service.ts to return raw certificate data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 02:07:55 +00:00
Consultoria AS
c52548a2bb fix: remove double base64 encoding of certificate in SAT auth
The PEM certificate content is already base64 encoded after removing
headers and newlines. We should not re-encode it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:51:57 +00:00
Consultoria AS
121fe731d0 fix: use combined encryption for FIEL credentials
Each piece of data was being encrypted with a different IV, but only
the first IV was saved. Now using encryptFielCredentials/decryptFielCredentials
helper functions that encrypt all data together with a single IV/tag.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:50:15 +00:00
Consultoria AS
02ccfb41a0 fix: convert certificate dates to Date objects in fiel.service
The @nodecfdi/credentials library returns date values that aren't
JavaScript Date objects, causing getTime() to fail.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:46:54 +00:00
Consultoria AS
75a9819c1e fix: use JWT tenantId instead of header in FIEL and SAT controllers
The controllers were looking for x-tenant-id header which the frontend
doesn't send. Now using req.user!.tenantId from the JWT token instead.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:45:42 +00:00
Consultoria AS
2dd22ec152 fix: correct global admin tenant RFC 2026-01-25 01:38:38 +00:00
Consultoria AS
69efb585d3 feat: add global user administration for admin users
Backend:
- Add getAllUsuarios() to get users from all tenants
- Add updateUsuarioGlobal() to edit users and change their tenant
- Add deleteUsuarioGlobal() for global user deletion
- Add global admin check based on tenant RFC
- Add new API routes: /usuarios/global/*

Frontend:
- Add UserListItem.tenantId and tenantName fields
- Add /admin/usuarios page with full user management
- Support filtering by tenant and search
- Inline editing for name, role, and tenant assignment
- Group users by company for better organization
- Add "Admin Usuarios" menu item for admin navigation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:22:34 +00:00
Consultoria AS
2655a51a99 feat(web): add SAT configuration link to settings page
Add a card linking to /configuracion/sat from the main settings page,
making the SAT sync feature discoverable from the navigation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:03:55 +00:00
Consultoria AS
31c66f2823 feat(sat): add frontend components for SAT configuration (Phase 8)
- Add FielUploadModal component for FIEL credential upload
- Add SyncStatus component showing current sync progress
- Add SyncHistory component with pagination and retry
- Add SAT configuration page at /configuracion/sat
- Add API client functions for FIEL and SAT endpoints

Features:
- File upload with Base64 encoding
- Real-time sync progress tracking
- Manual sync trigger (initial/daily)
- Sync history with retry capability
- FIEL status display with expiration warning

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:00:08 +00:00
Consultoria AS
e50e7100f1 feat(sat): add API endpoints for FIEL and SAT sync (Phase 7)
- Add FIEL controller with upload, status, and delete endpoints
- Add SAT controller with sync start, status, history, and retry
- Add admin endpoints for cron job info and manual execution
- Register new routes in app.ts
- All endpoints protected with authentication middleware

Endpoints added:
- POST /api/fiel/upload
- GET /api/fiel/status
- DELETE /api/fiel
- POST /api/sat/sync
- GET /api/sat/sync/status
- GET /api/sat/sync/history
- GET /api/sat/sync/:id
- POST /api/sat/sync/:id/retry
- GET /api/sat/cron
- POST /api/sat/cron/run

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:56:47 +00:00
Consultoria AS
0a65c60570 feat(sat): add scheduled cron job for daily sync (Phase 6)
- Add sat-sync.job.ts with scheduled daily sync at 3:00 AM
- Automatic detection of tenants with active FIEL
- Initial sync (10 years) for new tenants, daily for existing
- Concurrent processing with configurable batch size
- Integration with app startup for production environment
- Install node-cron dependency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:53:54 +00:00
Consultoria AS
473912bfd7 feat(sat): add main sync orchestrator service (Phase 5)
- Add sat.service.ts as the main orchestrator that coordinates:
  - FIEL credential retrieval and token management
  - SAT download request workflow
  - Package processing and CFDI storage
  - Progress tracking and job management
- Support for initial sync (10 years history) and daily sync
- Automatic token refresh during long-running syncs
- Month-by-month processing to avoid SAT limits
- Raw SQL queries for multi-tenant schema isolation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:52:18 +00:00
Consultoria AS
09684f77b9 feat(sat): add CFDI XML parser service (Phase 4)
- Add sat-parser.service.ts for processing SAT packages:
  - Extract XML files from ZIP packages
  - Parse CFDI 4.0 XML structure with proper namespace handling
  - Extract fiscal data: UUID, amounts, taxes, dates, RFC info
  - Map SAT types (I/E/T/P/N) to application types
  - Handle IVA and ISR retention calculations
- Install @nodecfdi/cfdi-core dependency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:50:11 +00:00
Consultoria AS
56e6e27ab3 feat(sat): add SAT authentication and download services (Phase 3)
- Add sat-auth.service.ts for SAML token authentication with SAT
  using FIEL credentials and SOAP protocol
- Add sat-download.service.ts with full download workflow:
  - Request CFDI download (emitted/received)
  - Verify request status with polling support
  - Download ZIP packages when ready
  - Helper functions for status checking
- Install fast-xml-parser and adm-zip dependencies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:49:02 +00:00
33 changed files with 3650 additions and 38 deletions

View File

@@ -15,24 +15,31 @@
},
"dependencies": {
"@horux/shared": "workspace:*",
"@nodecfdi/cfdi-core": "^1.0.1",
"@nodecfdi/credentials": "^3.2.0",
"@nodecfdi/sat-ws-descarga-masiva": "^2.0.0",
"@prisma/client": "^5.22.0",
"adm-zip": "^0.5.16",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"exceljs": "^4.4.0",
"express": "^4.21.0",
"fast-xml-parser": "^5.3.3",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"node-cron": "^4.2.1",
"node-forge": "^1.3.3",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11",
"@types/node-forge": "^1.3.14",
"prisma": "^5.22.0",
"tsx": "^4.19.0",

View File

@@ -13,6 +13,8 @@ import { calendarioRoutes } from './routes/calendario.routes.js';
import { reportesRoutes } from './routes/reportes.routes.js';
import { usuariosRoutes } from './routes/usuarios.routes.js';
import { tenantsRoutes } from './routes/tenants.routes.js';
import fielRoutes from './routes/fiel.routes.js';
import satRoutes from './routes/sat.routes.js';
const app = express();
@@ -43,6 +45,8 @@ app.use('/api/calendario', calendarioRoutes);
app.use('/api/reportes', reportesRoutes);
app.use('/api/usuarios', usuariosRoutes);
app.use('/api/tenants', tenantsRoutes);
app.use('/api/fiel', fielRoutes);
app.use('/api/sat', satRoutes);
// Error handling
app.use(errorMiddleware);

View File

@@ -0,0 +1,68 @@
import type { Request, Response } from 'express';
import { uploadFiel, getFielStatus, deleteFiel } from '../services/fiel.service.js';
import type { FielUploadRequest } from '@horux/shared';
/**
* Sube y configura las credenciales FIEL
*/
export async function upload(req: Request, res: Response): Promise<void> {
try {
const tenantId = req.user!.tenantId;
const { cerFile, keyFile, password } = req.body as FielUploadRequest;
if (!cerFile || !keyFile || !password) {
res.status(400).json({ error: 'cerFile, keyFile y password son requeridos' });
return;
}
const result = await uploadFiel(tenantId, cerFile, keyFile, password);
if (!result.success) {
res.status(400).json({ error: result.message });
return;
}
res.json({
message: result.message,
status: result.status,
});
} catch (error: any) {
console.error('[FIEL Controller] Error en upload:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Obtiene el estado de la FIEL configurada
*/
export async function status(req: Request, res: Response): Promise<void> {
try {
const tenantId = req.user!.tenantId;
const fielStatus = await getFielStatus(tenantId);
res.json(fielStatus);
} catch (error: any) {
console.error('[FIEL Controller] Error en status:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Elimina las credenciales FIEL
*/
export async function remove(req: Request, res: Response): Promise<void> {
try {
const tenantId = req.user!.tenantId;
const deleted = await deleteFiel(tenantId);
if (!deleted) {
res.status(404).json({ error: 'No hay FIEL configurada' });
return;
}
res.json({ message: 'FIEL eliminada correctamente' });
} catch (error: any) {
console.error('[FIEL Controller] Error en remove:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}

View File

@@ -0,0 +1,151 @@
import type { Request, Response } from 'express';
import {
startSync,
getSyncStatus,
getSyncHistory,
retryJob,
} from '../services/sat/sat.service.js';
import { getJobInfo, runSatSyncJobManually } from '../jobs/sat-sync.job.js';
import type { StartSyncRequest } from '@horux/shared';
/**
* Inicia una sincronización manual
*/
export async function start(req: Request, res: Response): Promise<void> {
try {
const tenantId = req.user!.tenantId;
const { type, dateFrom, dateTo } = req.body as StartSyncRequest;
const jobId = await startSync(
tenantId,
type || 'daily',
dateFrom ? new Date(dateFrom) : undefined,
dateTo ? new Date(dateTo) : undefined
);
res.json({
jobId,
message: 'Sincronización iniciada',
});
} catch (error: any) {
console.error('[SAT Controller] Error en start:', error);
if (error.message.includes('FIEL') || error.message.includes('sincronización en curso')) {
res.status(400).json({ error: error.message });
return;
}
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Obtiene el estado actual de sincronización
*/
export async function status(req: Request, res: Response): Promise<void> {
try {
const tenantId = req.user!.tenantId;
const syncStatus = await getSyncStatus(tenantId);
res.json(syncStatus);
} catch (error: any) {
console.error('[SAT Controller] Error en status:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Obtiene el historial de sincronizaciones
*/
export async function history(req: Request, res: Response): Promise<void> {
try {
const tenantId = req.user!.tenantId;
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const result = await getSyncHistory(tenantId, page, limit);
res.json({
...result,
page,
limit,
});
} catch (error: any) {
console.error('[SAT Controller] Error en history:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Obtiene detalle de un job específico
*/
export async function jobDetail(req: Request, res: Response): Promise<void> {
try {
const tenantId = req.user!.tenantId;
const { id } = req.params;
const { jobs } = await getSyncHistory(tenantId, 1, 100);
const job = jobs.find(j => j.id === id);
if (!job) {
res.status(404).json({ error: 'Job no encontrado' });
return;
}
res.json(job);
} catch (error: any) {
console.error('[SAT Controller] Error en jobDetail:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Reintenta un job fallido
*/
export async function retry(req: Request, res: Response): Promise<void> {
try {
const id = req.params.id as string;
const newJobId = await retryJob(id);
res.json({
jobId: newJobId,
message: 'Job reintentado',
});
} catch (error: any) {
console.error('[SAT Controller] Error en retry:', error);
if (error.message.includes('no encontrado') || error.message.includes('Solo se pueden')) {
res.status(400).json({ error: error.message });
return;
}
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Obtiene información del job programado (solo admin)
*/
export async function cronInfo(req: Request, res: Response): Promise<void> {
try {
const info = getJobInfo();
res.json(info);
} catch (error: any) {
console.error('[SAT Controller] Error en cronInfo:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Ejecuta el job de sincronización manualmente (solo admin)
*/
export async function runCron(req: Request, res: Response): Promise<void> {
try {
// Ejecutar en background
runSatSyncJobManually().catch(err =>
console.error('[SAT Controller] Error ejecutando cron manual:', err)
);
res.json({ message: 'Job de sincronización iniciado' });
} catch (error: any) {
console.error('[SAT Controller] Error en runCron:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}

View File

@@ -1,6 +1,21 @@
import { Request, Response, NextFunction } from 'express';
import * as usuariosService from '../services/usuarios.service.js';
import { AppError } from '../utils/errors.js';
import { prisma } from '../config/database.js';
// RFC del tenant administrador global
const ADMIN_TENANT_RFC = 'CAS2408138W2';
async function isGlobalAdmin(req: Request): Promise<boolean> {
if (req.user!.role !== 'admin') return false;
const tenant = await prisma.tenant.findUnique({
where: { id: req.user!.tenantId },
select: { rfc: true },
});
return tenant?.rfc === ADMIN_TENANT_RFC;
}
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {
try {
@@ -11,6 +26,21 @@ export async function getUsuarios(req: Request, res: Response, next: NextFunctio
}
}
/**
* Obtiene todos los usuarios de todas las empresas (solo admin global)
*/
export async function getAllUsuarios(req: Request, res: Response, next: NextFunction) {
try {
if (!(await isGlobalAdmin(req))) {
throw new AppError(403, 'Solo el administrador global puede ver todos los usuarios');
}
const usuarios = await usuariosService.getAllUsuarios();
res.json(usuarios);
} catch (error) {
next(error);
}
}
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'admin') {
@@ -28,7 +58,8 @@ export async function updateUsuario(req: Request, res: Response, next: NextFunct
if (req.user!.role !== 'admin') {
throw new AppError(403, 'Solo administradores pueden modificar usuarios');
}
const usuario = await usuariosService.updateUsuario(req.user!.tenantId, req.params.id, req.body);
const userId = req.params.id as string;
const usuario = await usuariosService.updateUsuario(req.user!.tenantId, userId, req.body);
res.json(usuario);
} catch (error) {
next(error);
@@ -40,10 +71,49 @@ export async function deleteUsuario(req: Request, res: Response, next: NextFunct
if (req.user!.role !== 'admin') {
throw new AppError(403, 'Solo administradores pueden eliminar usuarios');
}
if (req.params.id === req.user!.id) {
const userId = req.params.id as string;
if (userId === req.user!.userId) {
throw new AppError(400, 'No puedes eliminar tu propia cuenta');
}
await usuariosService.deleteUsuario(req.user!.tenantId, req.params.id);
await usuariosService.deleteUsuario(req.user!.tenantId, userId);
res.status(204).send();
} catch (error) {
next(error);
}
}
/**
* Actualiza un usuario globalmente (puede cambiar de empresa)
*/
export async function updateUsuarioGlobal(req: Request, res: Response, next: NextFunction) {
try {
if (!(await isGlobalAdmin(req))) {
throw new AppError(403, 'Solo el administrador global puede modificar usuarios globalmente');
}
const userId = req.params.id as string;
if (userId === req.user!.userId && req.body.tenantId) {
throw new AppError(400, 'No puedes cambiar tu propia empresa');
}
const usuario = await usuariosService.updateUsuarioGlobal(userId, req.body);
res.json(usuario);
} catch (error) {
next(error);
}
}
/**
* Elimina un usuario globalmente
*/
export async function deleteUsuarioGlobal(req: Request, res: Response, next: NextFunction) {
try {
if (!(await isGlobalAdmin(req))) {
throw new AppError(403, 'Solo el administrador global puede eliminar usuarios globalmente');
}
const userId = req.params.id as string;
if (userId === req.user!.userId) {
throw new AppError(400, 'No puedes eliminar tu propia cuenta');
}
await usuariosService.deleteUsuarioGlobal(userId);
res.status(204).send();
} catch (error) {
next(error);

View File

@@ -1,9 +1,15 @@
import { app } from './app.js';
import { env } from './config/env.js';
import { startSatSyncJob } from './jobs/sat-sync.job.js';
const PORT = parseInt(env.PORT, 10);
app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 API Server running on http://0.0.0.0:${PORT}`);
console.log(`📊 Environment: ${env.NODE_ENV}`);
console.log(`API Server running on http://0.0.0.0:${PORT}`);
console.log(`Environment: ${env.NODE_ENV}`);
// Iniciar job de sincronización SAT
if (env.NODE_ENV === 'production') {
startSatSyncJob();
}
});

View File

@@ -0,0 +1,162 @@
import cron from 'node-cron';
import { prisma } from '../config/database.js';
import { startSync, getSyncStatus } from '../services/sat/sat.service.js';
import { hasFielConfigured } from '../services/fiel.service.js';
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
let isRunning = false;
/**
* Obtiene los tenants que tienen FIEL configurada y activa
*/
async function getTenantsWithFiel(): Promise<string[]> {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true },
});
const tenantsWithFiel: string[] = [];
for (const tenant of tenants) {
const hasFiel = await hasFielConfigured(tenant.id);
if (hasFiel) {
tenantsWithFiel.push(tenant.id);
}
}
return tenantsWithFiel;
}
/**
* Verifica si un tenant necesita sincronización inicial
*/
async function needsInitialSync(tenantId: string): Promise<boolean> {
const completedSync = await prisma.satSyncJob.findFirst({
where: {
tenantId,
type: 'initial',
status: 'completed',
},
});
return !completedSync;
}
/**
* Ejecuta sincronización para un tenant
*/
async function syncTenant(tenantId: string): Promise<void> {
try {
// Verificar si hay sync activo
const status = await getSyncStatus(tenantId);
if (status.hasActiveSync) {
console.log(`[SAT Cron] Tenant ${tenantId} ya tiene sync activo, omitiendo`);
return;
}
// Determinar tipo de sync
const needsInitial = await needsInitialSync(tenantId);
const syncType = needsInitial ? 'initial' : 'daily';
console.log(`[SAT Cron] Iniciando sync ${syncType} para tenant ${tenantId}`);
const jobId = await startSync(tenantId, syncType);
console.log(`[SAT Cron] Job ${jobId} iniciado para tenant ${tenantId}`);
} catch (error: any) {
console.error(`[SAT Cron] Error sincronizando tenant ${tenantId}:`, error.message);
}
}
/**
* Ejecuta el job de sincronización para todos los tenants
*/
async function runSyncJob(): Promise<void> {
if (isRunning) {
console.log('[SAT Cron] Job ya en ejecución, omitiendo');
return;
}
isRunning = true;
console.log('[SAT Cron] Iniciando job de sincronización diaria');
try {
const tenantIds = await getTenantsWithFiel();
console.log(`[SAT Cron] ${tenantIds.length} tenants con FIEL configurada`);
if (tenantIds.length === 0) {
console.log('[SAT Cron] No hay tenants para sincronizar');
return;
}
// Procesar en lotes para no saturar
for (let i = 0; i < tenantIds.length; i += CONCURRENT_SYNCS) {
const batch = tenantIds.slice(i, i + CONCURRENT_SYNCS);
await Promise.all(batch.map(syncTenant));
// Pequeña pausa entre lotes
if (i + CONCURRENT_SYNCS < tenantIds.length) {
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
console.log('[SAT Cron] Job de sincronización completado');
} catch (error: any) {
console.error('[SAT Cron] Error en job:', error.message);
} finally {
isRunning = false;
}
}
let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
/**
* Inicia el job programado
*/
export function startSatSyncJob(): void {
if (scheduledTask) {
console.log('[SAT Cron] Job ya está programado');
return;
}
// Validar expresión cron
if (!cron.validate(SYNC_CRON_SCHEDULE)) {
console.error('[SAT Cron] Expresión cron inválida:', SYNC_CRON_SCHEDULE);
return;
}
scheduledTask = cron.schedule(SYNC_CRON_SCHEDULE, runSyncJob, {
timezone: 'America/Mexico_City',
});
console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
}
/**
* Detiene el job programado
*/
export function stopSatSyncJob(): void {
if (scheduledTask) {
scheduledTask.stop();
scheduledTask = null;
console.log('[SAT Cron] Job detenido');
}
}
/**
* Ejecuta el job manualmente (para testing o ejecución forzada)
*/
export async function runSatSyncJobManually(): Promise<void> {
await runSyncJob();
}
/**
* Obtiene información del próximo job programado
*/
export function getJobInfo(): { scheduled: boolean; expression: string; timezone: string } {
return {
scheduled: scheduledTask !== null,
expression: SYNC_CRON_SCHEDULE,
timezone: 'America/Mexico_City',
};
}

View File

@@ -0,0 +1,19 @@
import { Router } from 'express';
import * as fielController from '../controllers/fiel.controller.js';
import { authenticate } from '../middlewares/auth.middleware.js';
const router = Router();
// Todas las rutas requieren autenticación
router.use(authenticate);
// POST /api/fiel/upload - Subir credenciales FIEL
router.post('/upload', fielController.upload);
// GET /api/fiel/status - Obtener estado de la FIEL
router.get('/status', fielController.status);
// DELETE /api/fiel - Eliminar credenciales FIEL
router.delete('/', fielController.remove);
export default router;

View File

@@ -0,0 +1,31 @@
import { Router } from 'express';
import * as satController from '../controllers/sat.controller.js';
import { authenticate } from '../middlewares/auth.middleware.js';
const router = Router();
// Todas las rutas requieren autenticación
router.use(authenticate);
// POST /api/sat/sync - Iniciar sincronización manual
router.post('/sync', satController.start);
// GET /api/sat/sync/status - Estado actual de sincronización
router.get('/sync/status', satController.status);
// GET /api/sat/sync/history - Historial de sincronizaciones
router.get('/sync/history', satController.history);
// GET /api/sat/sync/:id - Detalle de un job
router.get('/sync/:id', satController.jobDetail);
// POST /api/sat/sync/:id/retry - Reintentar job fallido
router.post('/sync/:id/retry', satController.retry);
// GET /api/sat/cron - Información del job programado (admin)
router.get('/cron', satController.cronInfo);
// POST /api/sat/cron/run - Ejecutar job manualmente (admin)
router.post('/cron/run', satController.runCron);
export default router;

View File

@@ -6,9 +6,15 @@ const router = Router();
router.use(authenticate);
// Rutas por tenant
router.get('/', usuariosController.getUsuarios);
router.post('/invite', usuariosController.inviteUsuario);
router.patch('/:id', usuariosController.updateUsuario);
router.delete('/:id', usuariosController.deleteUsuario);
// Rutas globales (solo admin global)
router.get('/global/all', usuariosController.getAllUsuarios);
router.patch('/global/:id', usuariosController.updateUsuarioGlobal);
router.delete('/global/:id', usuariosController.deleteUsuarioGlobal);
export { router as usuariosRoutes };

View File

@@ -1,6 +1,6 @@
import { Credential } from '@nodecfdi/credentials/node';
import { prisma } from '../config/database.js';
import { encrypt, decrypt } from './sat/sat-crypto.service.js';
import { encryptFielCredentials, decryptFielCredentials } from './sat/sat-crypto.service.js';
import type { FielStatus } from '@horux/shared';
/**
@@ -44,9 +44,12 @@ export async function uploadFiel(
const certificate = credential.certificate();
const rfc = certificate.rfc();
const serialNumber = certificate.serialNumber().bytes();
const validFrom = certificate.validFromDateTime();
const validUntil = certificate.validToDateTime();
// validFromDateTime() y validToDateTime() retornan strings ISO o objetos DateTime
const validFromRaw = certificate.validFromDateTime();
const validUntilRaw = certificate.validToDateTime();
const validFrom = new Date(String(validFromRaw));
const validUntil = new Date(String(validUntilRaw));
// Verificar que no esté vencida
if (new Date() > validUntil) {
return {
@@ -55,10 +58,14 @@ export async function uploadFiel(
};
}
// Encriptar credenciales
const { encrypted: encryptedCer, iv, tag } = encrypt(cerData);
const { encrypted: encryptedKey } = encrypt(keyData);
const { encrypted: encryptedPassword } = encrypt(Buffer.from(password, 'utf-8'));
// Encriptar credenciales (todas juntas con el mismo IV/tag)
const {
encryptedCer,
encryptedKey,
encryptedPassword,
iv,
tag,
} = encryptFielCredentials(cerData, keyData, password);
// Guardar o actualizar en BD
await prisma.fielCredential.upsert({
@@ -172,49 +179,38 @@ export async function deleteFiel(tenantId: string): Promise<boolean> {
* Solo debe usarse internamente por el servicio de SAT
*/
export async function getDecryptedFiel(tenantId: string): Promise<{
credential: Credential;
cerContent: string;
keyContent: string;
password: string;
rfc: string;
} | null> {
const fiel = await prisma.fielCredential.findUnique({
where: { tenantId },
});
if (!fiel || !fiel.isActive) {
return null;
}
// Verificar que no esté vencida
if (new Date() > fiel.validUntil) {
return null;
}
try {
// Desencriptar
const cerData = decrypt(
// Desencriptar todas las credenciales juntas
const { cerData, keyData, password } = decryptFielCredentials(
Buffer.from(fiel.cerData),
Buffer.from(fiel.encryptionIv),
Buffer.from(fiel.encryptionTag)
);
const keyData = decrypt(
Buffer.from(fiel.keyData),
Buffer.from(fiel.encryptionIv),
Buffer.from(fiel.encryptionTag)
);
const password = decrypt(
Buffer.from(fiel.keyPasswordEncrypted),
Buffer.from(fiel.encryptionIv),
Buffer.from(fiel.encryptionTag)
).toString('utf-8');
// Crear credencial
const credential = Credential.create(
cerData.toString('binary'),
keyData.toString('binary'),
password
);
return {
credential,
cerContent: cerData.toString('binary'),
keyContent: keyData.toString('binary'),
password,
rfc: fiel.rfc,
};
} catch (error) {

View File

@@ -0,0 +1,160 @@
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
import { createHash, randomUUID } from 'crypto';
import type { Credential } from '@nodecfdi/credentials/node';
const SAT_AUTH_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc';
interface SatToken {
token: string;
expiresAt: Date;
}
/**
* Genera el timestamp para la solicitud SOAP
*/
function createTimestamp(): { created: string; expires: string } {
const now = new Date();
const created = now.toISOString();
const expires = new Date(now.getTime() + 5 * 60 * 1000).toISOString(); // 5 minutos
return { created, expires };
}
/**
* Construye el XML de solicitud de autenticación
*/
function buildAuthRequest(credential: Credential): string {
const timestamp = createTimestamp();
const uuid = randomUUID();
const certificate = credential.certificate();
// El PEM ya contiene el certificado en base64, solo quitamos headers y newlines
const cerB64 = certificate.pem().replace(/-----.*-----/g, '').replace(/\s/g, '');
// Canonicalizar y firmar
const toDigestXml = `<u:Timestamp xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" u:Id="_0">` +
`<u:Created>${timestamp.created}</u:Created>` +
`<u:Expires>${timestamp.expires}</u:Expires>` +
`</u:Timestamp>`;
const digestValue = createHash('sha1').update(toDigestXml).digest('base64');
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
`<Reference URI="#_0">` +
`<Transforms>` +
`<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
`</Transforms>` +
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
`<DigestValue>${digestValue}</DigestValue>` +
`</Reference>` +
`</SignedInfo>`;
// Firmar con la llave privada (sign retorna binary string, convertir a base64)
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
const soapEnvelope = `<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
<s:Header>
<o:Security xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" s:mustUnderstand="1">
<u:Timestamp u:Id="_0">
<u:Created>${timestamp.created}</u:Created>
<u:Expires>${timestamp.expires}</u:Expires>
</u:Timestamp>
<o:BinarySecurityToken u:Id="uuid-${uuid}-1" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">${cerB64}</o:BinarySecurityToken>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
${signedInfoXml}
<SignatureValue>${signatureValue}</SignatureValue>
<KeyInfo>
<o:SecurityTokenReference>
<o:Reference URI="#uuid-${uuid}-1" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3"/>
</o:SecurityTokenReference>
</KeyInfo>
</Signature>
</o:Security>
</s:Header>
<s:Body>
<Autentica xmlns="http://DescargaMasivaTerceros.gob.mx"/>
</s:Body>
</s:Envelope>`;
return soapEnvelope;
}
/**
* Extrae el token de la respuesta SOAP
*/
function parseAuthResponse(responseXml: string): SatToken {
const parser = new XMLParser({
ignoreAttributes: false,
removeNSPrefix: true,
});
const result = parser.parse(responseXml);
// Navegar la estructura de respuesta SOAP
const envelope = result.Envelope || result['s:Envelope'];
if (!envelope) {
throw new Error('Respuesta SOAP inválida');
}
const body = envelope.Body || envelope['s:Body'];
if (!body) {
throw new Error('No se encontró el cuerpo de la respuesta');
}
const autenticaResponse = body.AutenticaResponse;
if (!autenticaResponse) {
throw new Error('No se encontró AutenticaResponse');
}
const autenticaResult = autenticaResponse.AutenticaResult;
if (!autenticaResult) {
throw new Error('No se obtuvo token de autenticación');
}
// El token es un SAML assertion en base64
const token = autenticaResult;
// El token expira en 5 minutos según documentación SAT
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
return { token, expiresAt };
}
/**
* Autentica con el SAT usando la FIEL y obtiene un token
*/
export async function authenticate(credential: Credential): Promise<SatToken> {
const soapRequest = buildAuthRequest(credential);
try {
const response = await fetch(SAT_AUTH_URL, {
method: 'POST',
headers: {
'Content-Type': 'text/xml;charset=UTF-8',
'SOAPAction': 'http://DescargaMasivaTerceros.gob.mx/IAutenticacion/Autentica',
},
body: soapRequest,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
}
const responseXml = await response.text();
return parseAuthResponse(responseXml);
} catch (error: any) {
console.error('[SAT Auth Error]', error);
throw new Error(`Error al autenticar con el SAT: ${error.message}`);
}
}
/**
* Verifica si un token está vigente
*/
export function isTokenValid(token: SatToken): boolean {
return new Date() < token.expiresAt;
}

View File

@@ -0,0 +1,210 @@
import {
Fiel,
HttpsWebClient,
FielRequestBuilder,
Service,
QueryParameters,
DateTimePeriod,
DownloadType,
RequestType,
ServiceEndpoints,
} from '@nodecfdi/sat-ws-descarga-masiva';
export interface FielData {
cerContent: string;
keyContent: string;
password: string;
}
/**
* Crea el servicio de descarga masiva del SAT usando los datos de la FIEL
*/
export function createSatService(fielData: FielData): Service {
// Crear FIEL usando el método estático create
const fiel = Fiel.create(fielData.cerContent, fielData.keyContent, fielData.password);
// Verificar que la FIEL sea válida
if (!fiel.isValid()) {
throw new Error('La FIEL no es válida o está vencida');
}
// Crear cliente HTTP
const webClient = new HttpsWebClient();
// Crear request builder con la FIEL
const requestBuilder = new FielRequestBuilder(fiel);
// Crear y retornar el servicio
return new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi());
}
export interface QueryResult {
success: boolean;
requestId?: string;
message: string;
statusCode?: string;
}
export interface VerifyResult {
success: boolean;
status: 'pending' | 'processing' | 'ready' | 'failed' | 'rejected';
packageIds: string[];
totalCfdis: number;
message: string;
statusCode?: string;
}
export interface DownloadResult {
success: boolean;
packageContent: string; // Base64 encoded ZIP
message: string;
}
/**
* Realiza una consulta al SAT para solicitar CFDIs
*/
export async function querySat(
service: Service,
fechaInicio: Date,
fechaFin: Date,
tipo: 'emitidos' | 'recibidos',
requestType: 'metadata' | 'cfdi' = 'cfdi'
): Promise<QueryResult> {
try {
const period = DateTimePeriod.createFromValues(
formatDateForSat(fechaInicio),
formatDateForSat(fechaFin)
);
const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received');
const reqType = new RequestType(requestType === 'cfdi' ? 'xml' : 'metadata');
const parameters = QueryParameters.create(period, downloadType, reqType);
const result = await service.query(parameters);
if (!result.getStatus().isAccepted()) {
return {
success: false,
message: result.getStatus().getMessage(),
statusCode: result.getStatus().getCode().toString(),
};
}
return {
success: true,
requestId: result.getRequestId(),
message: 'Solicitud aceptada',
statusCode: result.getStatus().getCode().toString(),
};
} catch (error: any) {
console.error('[SAT Query Error]', error);
return {
success: false,
message: error.message || 'Error al realizar consulta',
};
}
}
/**
* Verifica el estado de una solicitud
*/
export async function verifySatRequest(
service: Service,
requestId: string
): Promise<VerifyResult> {
try {
const result = await service.verify(requestId);
const statusRequest = result.getStatusRequest();
// Debug logging
console.log('[SAT Verify Debug]', {
statusRequestValue: statusRequest.getValue(),
statusRequestEntryId: statusRequest.getEntryId(),
cfdis: result.getNumberCfdis(),
packages: result.getPackageIds(),
statusCode: result.getStatus().getCode(),
statusMsg: result.getStatus().getMessage(),
});
// Usar isTypeOf para determinar el estado
let status: VerifyResult['status'];
if (statusRequest.isTypeOf('Finished')) {
status = 'ready';
} else if (statusRequest.isTypeOf('InProgress')) {
status = 'processing';
} else if (statusRequest.isTypeOf('Accepted')) {
status = 'pending';
} else if (statusRequest.isTypeOf('Failure')) {
status = 'failed';
} else if (statusRequest.isTypeOf('Rejected')) {
status = 'rejected';
} else {
// Default: check by entryId
const entryId = statusRequest.getEntryId();
if (entryId === 'Finished') status = 'ready';
else if (entryId === 'InProgress') status = 'processing';
else if (entryId === 'Accepted') status = 'pending';
else status = 'pending';
}
return {
success: result.getStatus().isAccepted(),
status,
packageIds: result.getPackageIds(),
totalCfdis: result.getNumberCfdis(),
message: result.getStatus().getMessage(),
statusCode: result.getStatus().getCode().toString(),
};
} catch (error: any) {
console.error('[SAT Verify Error]', error);
return {
success: false,
status: 'failed',
packageIds: [],
totalCfdis: 0,
message: error.message || 'Error al verificar solicitud',
};
}
}
/**
* Descarga un paquete de CFDIs
*/
export async function downloadSatPackage(
service: Service,
packageId: string
): Promise<DownloadResult> {
try {
const result = await service.download(packageId);
if (!result.getStatus().isAccepted()) {
return {
success: false,
packageContent: '',
message: result.getStatus().getMessage(),
};
}
return {
success: true,
packageContent: result.getPackageContent(),
message: 'Paquete descargado',
};
} catch (error: any) {
console.error('[SAT Download Error]', error);
return {
success: false,
packageContent: '',
message: error.message || 'Error al descargar paquete',
};
}
}
/**
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss)
*/
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())}`;
}

View File

@@ -0,0 +1,408 @@
import { XMLParser } from 'fast-xml-parser';
import { createHash, randomUUID } from 'crypto';
import type { Credential } from '@nodecfdi/credentials/node';
import type {
SatDownloadRequestResponse,
SatVerifyResponse,
SatPackageResponse,
CfdiSyncType
} from '@horux/shared';
const SAT_SOLICITUD_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitaDescargaService.svc';
const SAT_VERIFICA_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc';
const SAT_DESCARGA_URL = 'https://cfdidescargamasaborrar.clouda.sat.gob.mx/DescargaMasivaService.svc';
type TipoSolicitud = 'CFDI' | 'Metadata';
interface RequestDownloadParams {
credential: Credential;
token: string;
rfc: string;
fechaInicio: Date;
fechaFin: Date;
tipoSolicitud: TipoSolicitud;
tipoCfdi: CfdiSyncType;
}
/**
* Formatea fecha a formato SAT (YYYY-MM-DDTHH:MM:SS)
*/
function formatSatDate(date: Date): string {
return date.toISOString().slice(0, 19);
}
/**
* Construye el XML de solicitud de descarga
*/
function buildDownloadRequest(params: RequestDownloadParams): string {
const { credential, token, rfc, fechaInicio, fechaFin, tipoSolicitud, tipoCfdi } = params;
const uuid = randomUUID();
const certificate = credential.certificate();
const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64');
// Construir el elemento de solicitud
const rfcEmisor = tipoCfdi === 'emitidos' ? rfc : undefined;
const rfcReceptor = tipoCfdi === 'recibidos' ? rfc : undefined;
const solicitudContent = `<des:RfcSolicitante>${rfc}</des:RfcSolicitante>` +
`<des:FechaInicial>${formatSatDate(fechaInicio)}</des:FechaInicial>` +
`<des:FechaFinal>${formatSatDate(fechaFin)}</des:FechaFinal>` +
`<des:TipoSolicitud>${tipoSolicitud}</des:TipoSolicitud>` +
(rfcEmisor ? `<des:RfcEmisor>${rfcEmisor}</des:RfcEmisor>` : '') +
(rfcReceptor ? `<des:RfcReceptor>${rfcReceptor}</des:RfcReceptor>` : '');
const solicitudToSign = `<des:SolicitaDescarga xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx">${solicitudContent}</des:SolicitaDescarga>`;
const digestValue = createHash('sha1').update(solicitudToSign).digest('base64');
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
`<Reference URI="">` +
`<Transforms>` +
`<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
`</Transforms>` +
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
`<DigestValue>${digestValue}</DigestValue>` +
`</Reference>` +
`</SignedInfo>`;
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
return `<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx" xmlns:xd="http://www.w3.org/2000/09/xmldsig#">
<s:Header/>
<s:Body>
<des:SolicitaDescarga>
<des:solicitud RfcSolicitante="${rfc}" FechaInicial="${formatSatDate(fechaInicio)}" FechaFinal="${formatSatDate(fechaFin)}" TipoSolicitud="${tipoSolicitud}"${rfcEmisor ? ` RfcEmisor="${rfcEmisor}"` : ''}${rfcReceptor ? ` RfcReceptor="${rfcReceptor}"` : ''}>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
${signedInfoXml}
<SignatureValue>${signatureValue}</SignatureValue>
<KeyInfo>
<X509Data>
<X509IssuerSerial>
<X509IssuerName>${certificate.issuerAsRfc4514()}</X509IssuerName>
<X509SerialNumber>${certificate.serialNumber().bytes()}</X509SerialNumber>
</X509IssuerSerial>
<X509Certificate>${cerB64}</X509Certificate>
</X509Data>
</KeyInfo>
</Signature>
</des:solicitud>
</des:SolicitaDescarga>
</s:Body>
</s:Envelope>`;
}
/**
* Solicita la descarga de CFDIs al SAT
*/
export async function requestDownload(params: RequestDownloadParams): Promise<SatDownloadRequestResponse> {
const soapRequest = buildDownloadRequest(params);
try {
const response = await fetch(SAT_SOLICITUD_URL, {
method: 'POST',
headers: {
'Content-Type': 'text/xml;charset=UTF-8',
'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/ISolicitaDescargaService/SolicitaDescarga',
'Authorization': `WRAP access_token="${params.token}"`,
},
body: soapRequest,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
}
const responseXml = await response.text();
return parseDownloadRequestResponse(responseXml);
} catch (error: any) {
console.error('[SAT Download Request Error]', error);
throw new Error(`Error al solicitar descarga: ${error.message}`);
}
}
/**
* Parsea la respuesta de solicitud de descarga
*/
function parseDownloadRequestResponse(responseXml: string): SatDownloadRequestResponse {
const parser = new XMLParser({
ignoreAttributes: false,
removeNSPrefix: true,
attributeNamePrefix: '@_',
});
const result = parser.parse(responseXml);
const envelope = result.Envelope || result['s:Envelope'];
const body = envelope?.Body || envelope?.['s:Body'];
const respuesta = body?.SolicitaDescargaResponse?.SolicitaDescargaResult;
if (!respuesta) {
throw new Error('Respuesta inválida del SAT');
}
return {
idSolicitud: respuesta['@_IdSolicitud'] || '',
codEstatus: respuesta['@_CodEstatus'] || '',
mensaje: respuesta['@_Mensaje'] || '',
};
}
/**
* Verifica el estado de una solicitud de descarga
*/
export async function verifyRequest(
credential: Credential,
token: string,
rfc: string,
idSolicitud: string
): Promise<SatVerifyResponse> {
const certificate = credential.certificate();
const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64');
const verificaContent = `<des:VerificaSolicitudDescarga xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx"><des:solicitud IdSolicitud="${idSolicitud}" RfcSolicitante="${rfc}"/></des:VerificaSolicitudDescarga>`;
const digestValue = createHash('sha1').update(verificaContent).digest('base64');
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
`<Reference URI="">` +
`<Transforms><Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></Transforms>` +
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
`<DigestValue>${digestValue}</DigestValue>` +
`</Reference>` +
`</SignedInfo>`;
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
const soapRequest = `<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx">
<s:Header/>
<s:Body>
<des:VerificaSolicitudDescarga>
<des:solicitud IdSolicitud="${idSolicitud}" RfcSolicitante="${rfc}">
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
${signedInfoXml}
<SignatureValue>${signatureValue}</SignatureValue>
<KeyInfo>
<X509Data>
<X509IssuerSerial>
<X509IssuerName>${certificate.issuerAsRfc4514()}</X509IssuerName>
<X509SerialNumber>${certificate.serialNumber().bytes()}</X509SerialNumber>
</X509IssuerSerial>
<X509Certificate>${cerB64}</X509Certificate>
</X509Data>
</KeyInfo>
</Signature>
</des:solicitud>
</des:VerificaSolicitudDescarga>
</s:Body>
</s:Envelope>`;
try {
const response = await fetch(SAT_VERIFICA_URL, {
method: 'POST',
headers: {
'Content-Type': 'text/xml;charset=UTF-8',
'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/IVerificaSolicitudDescargaService/VerificaSolicitudDescarga',
'Authorization': `WRAP access_token="${token}"`,
},
body: soapRequest,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
}
const responseXml = await response.text();
return parseVerifyResponse(responseXml);
} catch (error: any) {
console.error('[SAT Verify Error]', error);
throw new Error(`Error al verificar solicitud: ${error.message}`);
}
}
/**
* Parsea la respuesta de verificación
*/
function parseVerifyResponse(responseXml: string): SatVerifyResponse {
const parser = new XMLParser({
ignoreAttributes: false,
removeNSPrefix: true,
attributeNamePrefix: '@_',
});
const result = parser.parse(responseXml);
const envelope = result.Envelope || result['s:Envelope'];
const body = envelope?.Body || envelope?.['s:Body'];
const respuesta = body?.VerificaSolicitudDescargaResponse?.VerificaSolicitudDescargaResult;
if (!respuesta) {
throw new Error('Respuesta de verificación inválida');
}
// Extraer paquetes
let paquetes: string[] = [];
const paquetesNode = respuesta.IdsPaquetes;
if (paquetesNode) {
if (Array.isArray(paquetesNode)) {
paquetes = paquetesNode;
} else if (typeof paquetesNode === 'string') {
paquetes = [paquetesNode];
}
}
return {
codEstatus: respuesta['@_CodEstatus'] || '',
estadoSolicitud: parseInt(respuesta['@_EstadoSolicitud'] || '0', 10),
codigoEstadoSolicitud: respuesta['@_CodigoEstadoSolicitud'] || '',
numeroCfdis: parseInt(respuesta['@_NumeroCFDIs'] || '0', 10),
mensaje: respuesta['@_Mensaje'] || '',
paquetes,
};
}
/**
* Descarga un paquete de CFDIs
*/
export async function downloadPackage(
credential: Credential,
token: string,
rfc: string,
idPaquete: string
): Promise<SatPackageResponse> {
const certificate = credential.certificate();
const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64');
const descargaContent = `<des:PeticionDescargaMasivaTercerosEntrada xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx"><des:peticionDescarga IdPaquete="${idPaquete}" RfcSolicitante="${rfc}"/></des:PeticionDescargaMasivaTercerosEntrada>`;
const digestValue = createHash('sha1').update(descargaContent).digest('base64');
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
`<Reference URI="">` +
`<Transforms><Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></Transforms>` +
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
`<DigestValue>${digestValue}</DigestValue>` +
`</Reference>` +
`</SignedInfo>`;
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
const soapRequest = `<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx">
<s:Header/>
<s:Body>
<des:PeticionDescargaMasivaTercerosEntrada>
<des:peticionDescarga IdPaquete="${idPaquete}" RfcSolicitante="${rfc}">
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
${signedInfoXml}
<SignatureValue>${signatureValue}</SignatureValue>
<KeyInfo>
<X509Data>
<X509IssuerSerial>
<X509IssuerName>${certificate.issuerAsRfc4514()}</X509IssuerName>
<X509SerialNumber>${certificate.serialNumber().bytes()}</X509SerialNumber>
</X509IssuerSerial>
<X509Certificate>${cerB64}</X509Certificate>
</X509Data>
</KeyInfo>
</Signature>
</des:peticionDescarga>
</des:PeticionDescargaMasivaTercerosEntrada>
</s:Body>
</s:Envelope>`;
try {
const response = await fetch(SAT_DESCARGA_URL, {
method: 'POST',
headers: {
'Content-Type': 'text/xml;charset=UTF-8',
'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/IDescargaMasivaService/Descargar',
'Authorization': `WRAP access_token="${token}"`,
},
body: soapRequest,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
}
const responseXml = await response.text();
return parseDownloadResponse(responseXml);
} catch (error: any) {
console.error('[SAT Download Package Error]', error);
throw new Error(`Error al descargar paquete: ${error.message}`);
}
}
/**
* Parsea la respuesta de descarga de paquete
*/
function parseDownloadResponse(responseXml: string): SatPackageResponse {
const parser = new XMLParser({
ignoreAttributes: false,
removeNSPrefix: true,
attributeNamePrefix: '@_',
});
const result = parser.parse(responseXml);
const envelope = result.Envelope || result['s:Envelope'];
const body = envelope?.Body || envelope?.['s:Body'];
const respuesta = body?.RespuestaDescargaMasivaTercerosSalida?.Paquete;
if (!respuesta) {
throw new Error('No se pudo obtener el paquete');
}
return {
paquete: respuesta,
};
}
/**
* Estados de solicitud del SAT
*/
export const SAT_REQUEST_STATES = {
ACCEPTED: 1,
IN_PROGRESS: 2,
COMPLETED: 3,
ERROR: 4,
REJECTED: 5,
EXPIRED: 6,
} as const;
/**
* Verifica si la solicitud está completa
*/
export function isRequestComplete(estadoSolicitud: number): boolean {
return estadoSolicitud === SAT_REQUEST_STATES.COMPLETED;
}
/**
* Verifica si la solicitud falló
*/
export function isRequestFailed(estadoSolicitud: number): boolean {
return (
estadoSolicitud === SAT_REQUEST_STATES.ERROR ||
estadoSolicitud === SAT_REQUEST_STATES.REJECTED ||
estadoSolicitud === SAT_REQUEST_STATES.EXPIRED
);
}
/**
* Verifica si la solicitud está en progreso
*/
export function isRequestInProgress(estadoSolicitud: number): boolean {
return (
estadoSolicitud === SAT_REQUEST_STATES.ACCEPTED ||
estadoSolicitud === SAT_REQUEST_STATES.IN_PROGRESS
);
}

View File

@@ -0,0 +1,244 @@
import AdmZip from 'adm-zip';
import { XMLParser } from 'fast-xml-parser';
import type { TipoCfdi, EstadoCfdi } from '@horux/shared';
interface CfdiParsed {
uuidFiscal: string;
tipo: TipoCfdi;
serie: string | null;
folio: string | null;
fechaEmision: Date;
fechaTimbrado: Date;
rfcEmisor: string;
nombreEmisor: string;
rfcReceptor: string;
nombreReceptor: string;
subtotal: number;
descuento: number;
iva: number;
isrRetenido: number;
ivaRetenido: number;
total: number;
moneda: string;
tipoCambio: number;
metodoPago: string | null;
formaPago: string | null;
usoCfdi: string | null;
estado: EstadoCfdi;
xmlOriginal: string;
}
interface ExtractedXml {
filename: string;
content: string;
}
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
removeNSPrefix: true,
});
/**
* Extrae archivos XML de un paquete ZIP en base64
*/
export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] {
const zipBuffer = Buffer.from(zipBase64, 'base64');
const zip = new AdmZip(zipBuffer);
const entries = zip.getEntries();
const xmlFiles: ExtractedXml[] = [];
for (const entry of entries) {
if (entry.entryName.toLowerCase().endsWith('.xml')) {
const content = entry.getData().toString('utf-8');
xmlFiles.push({
filename: entry.entryName,
content,
});
}
}
return xmlFiles;
}
/**
* Mapea el tipo de comprobante SAT a nuestro tipo
*/
function mapTipoCfdi(tipoComprobante: string): TipoCfdi {
const mapping: Record<string, TipoCfdi> = {
'I': 'ingreso',
'E': 'egreso',
'T': 'traslado',
'P': 'pago',
'N': 'nomina',
};
return mapping[tipoComprobante] || 'ingreso';
}
/**
* Extrae el UUID del TimbreFiscalDigital
*/
function extractUuid(comprobante: any): string {
const complemento = comprobante.Complemento;
if (!complemento) return '';
const timbre = complemento.TimbreFiscalDigital;
if (!timbre) return '';
return timbre['@_UUID'] || '';
}
/**
* Extrae la fecha de timbrado
*/
function extractFechaTimbrado(comprobante: any): Date {
const complemento = comprobante.Complemento;
if (!complemento) return new Date();
const timbre = complemento.TimbreFiscalDigital;
if (!timbre) return new Date();
return new Date(timbre['@_FechaTimbrado']);
}
/**
* Extrae los impuestos trasladados (IVA)
*/
function extractIva(comprobante: any): number {
const impuestos = comprobante.Impuestos;
if (!impuestos) return 0;
const traslados = impuestos.Traslados?.Traslado;
if (!traslados) return 0;
const trasladoArray = Array.isArray(traslados) ? traslados : [traslados];
let totalIva = 0;
for (const traslado of trasladoArray) {
if (traslado['@_Impuesto'] === '002') { // 002 = IVA
totalIva += parseFloat(traslado['@_Importe'] || '0');
}
}
return totalIva;
}
/**
* Extrae los impuestos retenidos
*/
function extractRetenciones(comprobante: any): { isr: number; iva: number } {
const impuestos = comprobante.Impuestos;
if (!impuestos) return { isr: 0, iva: 0 };
const retenciones = impuestos.Retenciones?.Retencion;
if (!retenciones) return { isr: 0, iva: 0 };
const retencionArray = Array.isArray(retenciones) ? retenciones : [retenciones];
let isr = 0;
let iva = 0;
for (const retencion of retencionArray) {
const importe = parseFloat(retencion['@_Importe'] || '0');
if (retencion['@_Impuesto'] === '001') { // 001 = ISR
isr += importe;
} else if (retencion['@_Impuesto'] === '002') { // 002 = IVA
iva += importe;
}
}
return { isr, iva };
}
/**
* Parsea un XML de CFDI y extrae los datos relevantes
*/
export function parseXml(xmlContent: string): CfdiParsed | null {
try {
const result = xmlParser.parse(xmlContent);
const comprobante = result.Comprobante;
if (!comprobante) {
console.error('[Parser] No se encontró el nodo Comprobante');
return null;
}
const emisor = comprobante.Emisor || {};
const receptor = comprobante.Receptor || {};
const retenciones = extractRetenciones(comprobante);
const cfdi: CfdiParsed = {
uuidFiscal: extractUuid(comprobante),
tipo: mapTipoCfdi(comprobante['@_TipoDeComprobante']),
serie: comprobante['@_Serie'] || null,
folio: comprobante['@_Folio'] || null,
fechaEmision: new Date(comprobante['@_Fecha']),
fechaTimbrado: extractFechaTimbrado(comprobante),
rfcEmisor: emisor['@_Rfc'] || '',
nombreEmisor: emisor['@_Nombre'] || '',
rfcReceptor: receptor['@_Rfc'] || '',
nombreReceptor: receptor['@_Nombre'] || '',
subtotal: parseFloat(comprobante['@_SubTotal'] || '0'),
descuento: parseFloat(comprobante['@_Descuento'] || '0'),
iva: extractIva(comprobante),
isrRetenido: retenciones.isr,
ivaRetenido: retenciones.iva,
total: parseFloat(comprobante['@_Total'] || '0'),
moneda: comprobante['@_Moneda'] || 'MXN',
tipoCambio: parseFloat(comprobante['@_TipoCambio'] || '1'),
metodoPago: comprobante['@_MetodoPago'] || null,
formaPago: comprobante['@_FormaPago'] || null,
usoCfdi: receptor['@_UsoCFDI'] || null,
estado: 'vigente',
xmlOriginal: xmlContent,
};
if (!cfdi.uuidFiscal) {
console.error('[Parser] CFDI sin UUID');
return null;
}
return cfdi;
} catch (error) {
console.error('[Parser Error]', error);
return null;
}
}
/**
* Procesa un paquete ZIP completo y retorna los CFDIs parseados
*/
export function processPackage(zipBase64: string): CfdiParsed[] {
const xmlFiles = extractXmlsFromZip(zipBase64);
const cfdis: CfdiParsed[] = [];
for (const { content } of xmlFiles) {
const cfdi = parseXml(content);
if (cfdi) {
cfdis.push(cfdi);
}
}
return cfdis;
}
/**
* Valida que un XML sea un CFDI válido
*/
export function isValidCfdi(xmlContent: string): boolean {
try {
const result = xmlParser.parse(xmlContent);
const comprobante = result.Comprobante;
if (!comprobante) return false;
if (!comprobante.Complemento?.TimbreFiscalDigital) return false;
if (!extractUuid(comprobante)) return false;
return true;
} catch {
return false;
}
}
export type { CfdiParsed, ExtractedXml };

View File

@@ -0,0 +1,600 @@
import { prisma } from '../../config/database.js';
import { getDecryptedFiel } from '../fiel.service.js';
import {
createSatService,
querySat,
verifySatRequest,
downloadSatPackage,
type FielData,
} from './sat-client.service.js';
import { processPackage, type CfdiParsed } from './sat-parser.service.js';
import type { SatSyncJob, CfdiSyncType, SatSyncType } from '@horux/shared';
import type { Service } from '@nodecfdi/sat-ws-descarga-masiva';
const POLL_INTERVAL_MS = 30000; // 30 segundos
const MAX_POLL_ATTEMPTS = 60; // 30 minutos máximo
const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años
interface SyncContext {
fielData: FielData;
service: Service;
rfc: string;
tenantId: string;
schemaName: string;
}
/**
* Actualiza el progreso de un job
*/
async function updateJobProgress(
jobId: string,
updates: Partial<{
status: 'pending' | 'running' | 'completed' | 'failed';
satRequestId: string;
satPackageIds: string[];
cfdisFound: number;
cfdisDownloaded: number;
cfdisInserted: number;
cfdisUpdated: number;
progressPercent: number;
errorMessage: string;
startedAt: Date;
completedAt: Date;
retryCount: number;
nextRetryAt: Date;
}>
): Promise<void> {
await prisma.satSyncJob.update({
where: { id: jobId },
data: updates,
});
}
/**
* Guarda los CFDIs en la base de datos del tenant
*/
async function saveCfdis(
schemaName: string,
cfdis: CfdiParsed[],
jobId: string
): Promise<{ inserted: number; updated: number }> {
let inserted = 0;
let updated = 0;
for (const cfdi of cfdis) {
try {
// Usar raw query para el esquema del tenant
const existing = await prisma.$queryRawUnsafe<{ id: string }[]>(
`SELECT id FROM "${schemaName}".cfdis WHERE uuid_fiscal = $1`,
cfdi.uuidFiscal
);
if (existing.length > 0) {
// Actualizar CFDI existente
await prisma.$executeRawUnsafe(
`UPDATE "${schemaName}".cfdis SET
tipo = $2,
serie = $3,
folio = $4,
fecha_emision = $5,
fecha_timbrado = $6,
rfc_emisor = $7,
nombre_emisor = $8,
rfc_receptor = $9,
nombre_receptor = $10,
subtotal = $11,
descuento = $12,
iva = $13,
isr_retenido = $14,
iva_retenido = $15,
total = $16,
moneda = $17,
tipo_cambio = $18,
metodo_pago = $19,
forma_pago = $20,
uso_cfdi = $21,
estado = $22,
xml_original = $23,
last_sat_sync = NOW(),
sat_sync_job_id = $24,
updated_at = NOW()
WHERE uuid_fiscal = $1`,
cfdi.uuidFiscal,
cfdi.tipo,
cfdi.serie,
cfdi.folio,
cfdi.fechaEmision,
cfdi.fechaTimbrado,
cfdi.rfcEmisor,
cfdi.nombreEmisor,
cfdi.rfcReceptor,
cfdi.nombreReceptor,
cfdi.subtotal,
cfdi.descuento,
cfdi.iva,
cfdi.isrRetenido,
cfdi.ivaRetenido,
cfdi.total,
cfdi.moneda,
cfdi.tipoCambio,
cfdi.metodoPago,
cfdi.formaPago,
cfdi.usoCfdi,
cfdi.estado,
cfdi.xmlOriginal,
jobId
);
updated++;
} else {
// Insertar nuevo CFDI
await prisma.$executeRawUnsafe(
`INSERT INTO "${schemaName}".cfdis (
id, uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
moneda, tipo_cambio, metodo_pago, forma_pago, uso_cfdi, estado,
xml_original, source, sat_sync_job_id, last_sat_sync, created_at
) VALUES (
gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22,
$23, 'sat', $24, NOW(), NOW()
)`,
cfdi.uuidFiscal,
cfdi.tipo,
cfdi.serie,
cfdi.folio,
cfdi.fechaEmision,
cfdi.fechaTimbrado,
cfdi.rfcEmisor,
cfdi.nombreEmisor,
cfdi.rfcReceptor,
cfdi.nombreReceptor,
cfdi.subtotal,
cfdi.descuento,
cfdi.iva,
cfdi.isrRetenido,
cfdi.ivaRetenido,
cfdi.total,
cfdi.moneda,
cfdi.tipoCambio,
cfdi.metodoPago,
cfdi.formaPago,
cfdi.usoCfdi,
cfdi.estado,
cfdi.xmlOriginal,
jobId
);
inserted++;
}
} catch (error) {
console.error(`[SAT] Error guardando CFDI ${cfdi.uuidFiscal}:`, error);
}
}
return { inserted, updated };
}
/**
* Procesa una solicitud de descarga para un rango de fechas
*/
async function processDateRange(
ctx: SyncContext,
jobId: string,
fechaInicio: Date,
fechaFin: Date,
tipoCfdi: CfdiSyncType
): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> {
console.log(`[SAT] Procesando ${tipoCfdi} desde ${fechaInicio.toISOString()} hasta ${fechaFin.toISOString()}`);
// 1. Solicitar descarga
const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi);
if (!queryResult.success) {
// Código 5004 = No hay CFDIs en el rango
if (queryResult.statusCode === '5004') {
console.log('[SAT] No se encontraron CFDIs en el rango');
return { found: 0, downloaded: 0, inserted: 0, updated: 0 };
}
throw new Error(`Error SAT: ${queryResult.message}`);
}
const requestId = queryResult.requestId!;
console.log(`[SAT] Solicitud creada: ${requestId}`);
await updateJobProgress(jobId, { satRequestId: requestId });
// 2. Esperar y verificar solicitud
let verifyResult;
let attempts = 0;
while (attempts < MAX_POLL_ATTEMPTS) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
attempts++;
verifyResult = await verifySatRequest(ctx.service, requestId);
console.log(`[SAT] Estado solicitud: ${verifyResult.status} (intento ${attempts})`);
if (verifyResult.status === 'ready') {
break;
}
if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') {
throw new Error(`Solicitud fallida: ${verifyResult.message}`);
}
}
if (!verifyResult || verifyResult.status !== 'ready') {
throw new Error('Timeout esperando respuesta del SAT');
}
// 3. Descargar paquetes
const packageIds = verifyResult.packageIds;
await updateJobProgress(jobId, {
satPackageIds: packageIds,
cfdisFound: verifyResult.totalCfdis,
});
let totalInserted = 0;
let totalUpdated = 0;
let totalDownloaded = 0;
for (let i = 0; i < packageIds.length; i++) {
const packageId = packageIds[i];
console.log(`[SAT] Descargando paquete ${i + 1}/${packageIds.length}: ${packageId}`);
const downloadResult = await downloadSatPackage(ctx.service, packageId);
if (!downloadResult.success) {
console.error(`[SAT] Error descargando paquete ${packageId}: ${downloadResult.message}`);
continue;
}
// 4. Procesar paquete (el contenido viene en base64)
const cfdis = processPackage(downloadResult.packageContent);
totalDownloaded += cfdis.length;
console.log(`[SAT] Procesando ${cfdis.length} CFDIs del paquete`);
const { inserted, updated } = await saveCfdis(ctx.schemaName, cfdis, jobId);
totalInserted += inserted;
totalUpdated += updated;
// Actualizar progreso
const progress = Math.round(((i + 1) / packageIds.length) * 100);
await updateJobProgress(jobId, {
cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
progressPercent: progress,
});
}
return {
found: verifyResult.totalCfdis,
downloaded: totalDownloaded,
inserted: totalInserted,
updated: totalUpdated,
};
}
/**
* Ejecuta sincronización inicial (últimos 10 años)
*/
async function processInitialSync(ctx: SyncContext, jobId: string): Promise<void> {
const ahora = new Date();
const inicioHistorico = new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
let totalFound = 0;
let totalDownloaded = 0;
let totalInserted = 0;
let totalUpdated = 0;
// Procesar por meses para evitar límites del SAT
let currentDate = new Date(inicioHistorico);
while (currentDate < ahora) {
const monthEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0, 23, 59, 59);
const rangeEnd = monthEnd > ahora ? ahora : monthEnd;
// Procesar emitidos
try {
const emitidos = await processDateRange(ctx, jobId, currentDate, rangeEnd, 'emitidos');
totalFound += emitidos.found;
totalDownloaded += emitidos.downloaded;
totalInserted += emitidos.inserted;
totalUpdated += emitidos.updated;
} catch (error: any) {
console.error(`[SAT] Error procesando emitidos ${currentDate.toISOString()}:`, error.message);
}
// Procesar recibidos
try {
const recibidos = await processDateRange(ctx, jobId, currentDate, rangeEnd, 'recibidos');
totalFound += recibidos.found;
totalDownloaded += recibidos.downloaded;
totalInserted += recibidos.inserted;
totalUpdated += recibidos.updated;
} catch (error: any) {
console.error(`[SAT] Error procesando recibidos ${currentDate.toISOString()}:`, error.message);
}
// Siguiente mes
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1);
// Pequeña pausa entre meses para no saturar el SAT
await new Promise(resolve => setTimeout(resolve, 5000));
}
await updateJobProgress(jobId, {
cfdisFound: totalFound,
cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
});
}
/**
* Ejecuta sincronización diaria (mes actual)
*/
async function processDailySync(ctx: SyncContext, jobId: string): Promise<void> {
const ahora = new Date();
const inicioMes = new Date(ahora.getFullYear(), ahora.getMonth(), 1);
let totalFound = 0;
let totalDownloaded = 0;
let totalInserted = 0;
let totalUpdated = 0;
// Procesar emitidos del mes
try {
const emitidos = await processDateRange(ctx, jobId, inicioMes, ahora, 'emitidos');
totalFound += emitidos.found;
totalDownloaded += emitidos.downloaded;
totalInserted += emitidos.inserted;
totalUpdated += emitidos.updated;
} catch (error: any) {
console.error('[SAT] Error procesando emitidos:', error.message);
}
// Procesar recibidos del mes
try {
const recibidos = await processDateRange(ctx, jobId, inicioMes, ahora, 'recibidos');
totalFound += recibidos.found;
totalDownloaded += recibidos.downloaded;
totalInserted += recibidos.inserted;
totalUpdated += recibidos.updated;
} catch (error: any) {
console.error('[SAT] Error procesando recibidos:', error.message);
}
await updateJobProgress(jobId, {
cfdisFound: totalFound,
cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
});
}
/**
* Inicia la sincronización con el SAT
*/
export async function startSync(
tenantId: string,
type: SatSyncType = 'daily',
dateFrom?: Date,
dateTo?: Date
): Promise<string> {
// Obtener credenciales FIEL
const decryptedFiel = await getDecryptedFiel(tenantId);
if (!decryptedFiel) {
throw new Error('No hay FIEL configurada o está vencida');
}
const fielData: FielData = {
cerContent: decryptedFiel.cerContent,
keyContent: decryptedFiel.keyContent,
password: decryptedFiel.password,
};
// Crear servicio SAT
const service = createSatService(fielData);
// Obtener datos del tenant
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { schemaName: true },
});
if (!tenant) {
throw new Error('Tenant no encontrado');
}
// Verificar que no haya sync activo
const activeSync = await prisma.satSyncJob.findFirst({
where: {
tenantId,
status: { in: ['pending', 'running'] },
},
});
if (activeSync) {
throw new Error('Ya hay una sincronización en curso');
}
// Crear job
const now = new Date();
const job = await prisma.satSyncJob.create({
data: {
tenantId,
type,
status: 'running',
dateFrom: dateFrom || new Date(now.getFullYear() - YEARS_TO_SYNC, 0, 1),
dateTo: dateTo || now,
startedAt: now,
},
});
const ctx: SyncContext = {
fielData,
service,
rfc: decryptedFiel.rfc,
tenantId,
schemaName: tenant.schemaName,
};
// Ejecutar sincronización en background
(async () => {
try {
if (type === 'initial') {
await processInitialSync(ctx, job.id);
} else {
await processDailySync(ctx, job.id);
}
await updateJobProgress(job.id, {
status: 'completed',
completedAt: new Date(),
progressPercent: 100,
});
console.log(`[SAT] Sincronización ${job.id} completada`);
} catch (error: any) {
console.error(`[SAT] Error en sincronización ${job.id}:`, error);
await updateJobProgress(job.id, {
status: 'failed',
errorMessage: error.message,
completedAt: new Date(),
});
}
})();
return job.id;
}
/**
* Obtiene el estado actual de sincronización de un tenant
*/
export async function getSyncStatus(tenantId: string): Promise<{
hasActiveSync: boolean;
currentJob?: SatSyncJob;
lastCompletedJob?: SatSyncJob;
totalCfdisSynced: number;
}> {
const activeJob = await prisma.satSyncJob.findFirst({
where: {
tenantId,
status: { in: ['pending', 'running'] },
},
orderBy: { createdAt: 'desc' },
});
const lastCompleted = await prisma.satSyncJob.findFirst({
where: {
tenantId,
status: 'completed',
},
orderBy: { completedAt: 'desc' },
});
const totals = await prisma.satSyncJob.aggregate({
where: {
tenantId,
status: 'completed',
},
_sum: {
cfdisInserted: true,
},
});
const mapJob = (job: any): SatSyncJob => ({
id: job.id,
tenantId: job.tenantId,
type: job.type,
status: job.status,
dateFrom: job.dateFrom.toISOString(),
dateTo: job.dateTo.toISOString(),
cfdiType: job.cfdiType ?? undefined,
satRequestId: job.satRequestId ?? undefined,
satPackageIds: job.satPackageIds,
cfdisFound: job.cfdisFound,
cfdisDownloaded: job.cfdisDownloaded,
cfdisInserted: job.cfdisInserted,
cfdisUpdated: job.cfdisUpdated,
progressPercent: job.progressPercent,
errorMessage: job.errorMessage ?? undefined,
startedAt: job.startedAt?.toISOString(),
completedAt: job.completedAt?.toISOString(),
createdAt: job.createdAt.toISOString(),
retryCount: job.retryCount,
});
return {
hasActiveSync: !!activeJob,
currentJob: activeJob ? mapJob(activeJob) : undefined,
lastCompletedJob: lastCompleted ? mapJob(lastCompleted) : undefined,
totalCfdisSynced: totals._sum.cfdisInserted || 0,
};
}
/**
* Obtiene el historial de sincronizaciones
*/
export async function getSyncHistory(
tenantId: string,
page: number = 1,
limit: number = 10
): Promise<{ jobs: SatSyncJob[]; total: number }> {
const [jobs, total] = await Promise.all([
prisma.satSyncJob.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.satSyncJob.count({ where: { tenantId } }),
]);
return {
jobs: jobs.map(job => ({
id: job.id,
tenantId: job.tenantId,
type: job.type,
status: job.status,
dateFrom: job.dateFrom.toISOString(),
dateTo: job.dateTo.toISOString(),
cfdiType: job.cfdiType ?? undefined,
satRequestId: job.satRequestId ?? undefined,
satPackageIds: job.satPackageIds,
cfdisFound: job.cfdisFound,
cfdisDownloaded: job.cfdisDownloaded,
cfdisInserted: job.cfdisInserted,
cfdisUpdated: job.cfdisUpdated,
progressPercent: job.progressPercent,
errorMessage: job.errorMessage ?? undefined,
startedAt: job.startedAt?.toISOString(),
completedAt: job.completedAt?.toISOString(),
createdAt: job.createdAt.toISOString(),
retryCount: job.retryCount,
})),
total,
};
}
/**
* Reintenta un job fallido
*/
export async function retryJob(jobId: string): Promise<string> {
const job = await prisma.satSyncJob.findUnique({
where: { id: jobId },
});
if (!job) {
throw new Error('Job no encontrado');
}
if (job.status !== 'failed') {
throw new Error('Solo se pueden reintentar jobs fallidos');
}
return startSync(job.tenantId, job.type, job.dateFrom, job.dateTo);
}

View File

@@ -105,3 +105,93 @@ export async function deleteUsuario(tenantId: string, userId: string): Promise<v
where: { id: userId, tenantId },
});
}
/**
* Obtiene todos los usuarios de todas las empresas (solo admin global)
*/
export async function getAllUsuarios(): Promise<UserListItem[]> {
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
nombre: true,
role: true,
active: true,
lastLogin: true,
createdAt: true,
tenantId: true,
tenant: {
select: {
nombre: true,
},
},
},
orderBy: [{ tenant: { nombre: 'asc' } }, { createdAt: 'desc' }],
});
return users.map(u => ({
id: u.id,
email: u.email,
nombre: u.nombre,
role: u.role,
active: u.active,
lastLogin: u.lastLogin?.toISOString() || null,
createdAt: u.createdAt.toISOString(),
tenantId: u.tenantId,
tenantName: u.tenant.nombre,
}));
}
/**
* Actualiza un usuario globalmente (puede cambiar de tenant)
*/
export async function updateUsuarioGlobal(
userId: string,
data: UserUpdate & { tenantId?: string }
): Promise<UserListItem> {
const user = await prisma.user.update({
where: { id: userId },
data: {
...(data.nombre && { nombre: data.nombre }),
...(data.role && { role: data.role }),
...(data.active !== undefined && { active: data.active }),
...(data.tenantId && { tenantId: data.tenantId }),
},
select: {
id: true,
email: true,
nombre: true,
role: true,
active: true,
lastLogin: true,
createdAt: true,
tenantId: true,
tenant: {
select: {
nombre: true,
},
},
},
});
return {
id: user.id,
email: user.email,
nombre: user.nombre,
role: user.role,
active: user.active,
lastLogin: user.lastLogin?.toISOString() || null,
createdAt: user.createdAt.toISOString(),
tenantId: user.tenantId,
tenantName: user.tenant.nombre,
};
}
/**
* Elimina un usuario globalmente
*/
export async function deleteUsuarioGlobal(userId: string): Promise<void> {
await prisma.user.delete({
where: { id: userId },
});
}

View File

@@ -30,7 +30,11 @@ export default function LoginPage() {
const response = await login({ email, password });
setTokens(response.accessToken, response.refreshToken);
setUser(response.user);
router.push('/dashboard');
const STORAGE_KEY = 'horux360:onboarding_seen_v1';
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
router.push(seen ? '/dashboard' : '/onboarding');
} catch (err: any) {
setError(err.response?.data?.message || 'Error al iniciar sesión');
} finally {

View File

@@ -0,0 +1,305 @@
'use client';
import { useState, useEffect } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useAllUsuarios, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios';
import { getTenants, type Tenant } from '@/lib/api/tenants';
import { useAuthStore } from '@/stores/auth-store';
import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
const roleLabels = {
admin: { label: 'Administrador', icon: Shield, color: 'text-primary' },
contador: { label: 'Contador', icon: Calculator, color: 'text-green-600' },
visor: { label: 'Visor', icon: Eye, color: 'text-muted-foreground' },
};
interface EditingUser {
id: string;
nombre: string;
role: 'admin' | 'contador' | 'visor';
tenantId: string;
}
export default function AdminUsuariosPage() {
const { user: currentUser } = useAuthStore();
const { data: usuarios, isLoading, error } = useAllUsuarios();
const updateUsuario = useUpdateUsuarioGlobal();
const deleteUsuario = useDeleteUsuarioGlobal();
const [tenants, setTenants] = useState<Tenant[]>([]);
const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
const [filterTenant, setFilterTenant] = useState<string>('all');
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
getTenants().then(setTenants).catch(console.error);
}, []);
const handleEdit = (usuario: any) => {
setEditingUser({
id: usuario.id,
nombre: usuario.nombre,
role: usuario.role,
tenantId: usuario.tenantId,
});
};
const handleSave = async () => {
if (!editingUser) return;
try {
await updateUsuario.mutateAsync({
id: editingUser.id,
data: {
nombre: editingUser.nombre,
role: editingUser.role,
tenantId: editingUser.tenantId,
},
});
setEditingUser(null);
} catch (err: any) {
alert(err.response?.data?.error || 'Error al actualizar usuario');
}
};
const handleDelete = async (id: string) => {
if (!confirm('Estas seguro de eliminar este usuario?')) return;
try {
await deleteUsuario.mutateAsync(id);
} catch (err: any) {
alert(err.response?.data?.error || 'Error al eliminar usuario');
}
};
const filteredUsuarios = usuarios?.filter(u => {
const matchesTenant = filterTenant === 'all' || u.tenantId === filterTenant;
const matchesSearch = !searchTerm ||
u.nombre.toLowerCase().includes(searchTerm.toLowerCase()) ||
u.email.toLowerCase().includes(searchTerm.toLowerCase());
return matchesTenant && matchesSearch;
});
// Agrupar por empresa
const groupedByTenant = filteredUsuarios?.reduce((acc, u) => {
const key = u.tenantId || 'sin-empresa';
if (!acc[key]) {
acc[key] = {
tenantName: u.tenantName || 'Sin empresa',
users: [],
};
}
acc[key].users.push(u);
return acc;
}, {} as Record<string, { tenantName: string; users: typeof filteredUsuarios }>);
if (error) {
return (
<DashboardShell title="Administracion de Usuarios">
<Card>
<CardContent className="py-8 text-center">
<p className="text-destructive">
No tienes permisos para ver esta pagina o ocurrio un error.
</p>
</CardContent>
</Card>
</DashboardShell>
);
}
return (
<DashboardShell title="Administracion de Usuarios">
<div className="space-y-4">
{/* Filtros */}
<Card>
<CardContent className="py-4">
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<Input
placeholder="Buscar por nombre o email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="w-[250px]">
<Select value={filterTenant} onValueChange={setFilterTenant}>
<SelectTrigger>
<SelectValue placeholder="Filtrar por empresa" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas las empresas</SelectItem>
{tenants.map(t => (
<SelectItem key={t.id} value={t.id}>{t.nombre}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Stats */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Users className="h-5 w-5" />
<span className="font-medium">{filteredUsuarios?.length || 0} usuarios</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Building2 className="h-4 w-4" />
<span className="text-sm">{Object.keys(groupedByTenant || {}).length} empresas</span>
</div>
</div>
{/* Users by tenant */}
{isLoading ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Cargando usuarios...
</CardContent>
</Card>
) : (
Object.entries(groupedByTenant || {}).map(([tenantId, { tenantName, users }]) => (
<Card key={tenantId}>
<CardHeader className="py-3">
<CardTitle className="text-base flex items-center gap-2">
<Building2 className="h-4 w-4" />
{tenantName}
<span className="text-muted-foreground font-normal text-sm">
({users?.length} usuarios)
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y">
{users?.map(usuario => {
const roleInfo = roleLabels[usuario.role];
const RoleIcon = roleInfo.icon;
const isCurrentUser = usuario.id === currentUser?.id;
const isEditing = editingUser?.id === usuario.id;
return (
<div key={usuario.id} className="p-4 flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<div className={cn(
'w-10 h-10 rounded-full flex items-center justify-center',
'bg-primary/10 text-primary font-medium'
)}>
{usuario.nombre.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
{isEditing ? (
<div className="space-y-2">
<Input
value={editingUser.nombre}
onChange={(e) => setEditingUser({ ...editingUser, nombre: e.target.value })}
className="h-8"
/>
<div className="flex gap-2">
<Select
value={editingUser.role}
onValueChange={(v) => setEditingUser({ ...editingUser, role: v as any })}
>
<SelectTrigger className="h-8 w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Administrador</SelectItem>
<SelectItem value="contador">Contador</SelectItem>
<SelectItem value="visor">Visor</SelectItem>
</SelectContent>
</Select>
<Select
value={editingUser.tenantId}
onValueChange={(v) => setEditingUser({ ...editingUser, tenantId: v })}
>
<SelectTrigger className="h-8 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{tenants.map(t => (
<SelectItem key={t.id} value={t.id}>{t.nombre}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span className="font-medium">{usuario.nombre}</span>
{isCurrentUser && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">Tu</span>
)}
{!usuario.active && (
<span className="text-xs bg-destructive/10 text-destructive px-2 py-0.5 rounded">Inactivo</span>
)}
</div>
<div className="text-sm text-muted-foreground">{usuario.email}</div>
</>
)}
</div>
</div>
<div className="flex items-center gap-4">
{!isEditing && (
<div className={cn('flex items-center gap-1', roleInfo.color)}>
<RoleIcon className="h-4 w-4" />
<span className="text-sm">{roleInfo.label}</span>
</div>
)}
{!isCurrentUser && (
<div className="flex gap-1">
{isEditing ? (
<>
<Button
variant="ghost"
size="icon"
onClick={handleSave}
disabled={updateUsuario.isPending}
>
<Check className="h-4 w-4 text-green-600" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setEditingUser(null)}
>
<X className="h-4 w-4" />
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(usuario)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(usuario.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
))
)}
</div>
</DashboardShell>
);
}

View File

@@ -5,7 +5,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { useThemeStore } from '@/stores/theme-store';
import { useAuthStore } from '@/stores/auth-store';
import { themes, type ThemeName } from '@/themes';
import { Check, Palette, User, Building, Sidebar, PanelTop, Minimize2, Sparkles } from 'lucide-react';
import { Check, Palette, User, Building, Sidebar, PanelTop, Minimize2, Sparkles, RefreshCw } from 'lucide-react';
import Link from 'next/link';
const themeOptions: { name: ThemeName; label: string; description: string; layoutDesc: string; layoutIcon: typeof Sidebar }[] = [
{
@@ -90,6 +91,26 @@ export default function ConfiguracionPage() {
</CardContent>
</Card>
{/* 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>
{/* Theme Selection */}
<Card>
<CardHeader>

View File

@@ -0,0 +1,156 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { FielUploadModal } from '@/components/sat/FielUploadModal';
import { SyncStatus } from '@/components/sat/SyncStatus';
import { SyncHistory } from '@/components/sat/SyncHistory';
import { getFielStatus, deleteFiel } from '@/lib/api/fiel';
import type { FielStatus } from '@horux/shared';
export default function SatConfigPage() {
const [fielStatus, setFielStatus] = useState<FielStatus | null>(null);
const [loading, setLoading] = useState(true);
const [showUploadModal, setShowUploadModal] = useState(false);
const [deleting, setDeleting] = useState(false);
const fetchFielStatus = async () => {
try {
const status = await getFielStatus();
setFielStatus(status);
} catch (err) {
console.error('Error fetching FIEL status:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchFielStatus();
}, []);
const handleUploadSuccess = (status: FielStatus) => {
setFielStatus(status);
setShowUploadModal(false);
};
const handleDelete = async () => {
if (!confirm('Estas seguro de eliminar la FIEL? Se detendran las sincronizaciones automaticas.')) {
return;
}
setDeleting(true);
try {
await deleteFiel();
setFielStatus({ configured: false });
} catch (err) {
console.error('Error deleting FIEL:', err);
} finally {
setDeleting(false);
}
};
if (loading) {
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6">Configuracion SAT</h1>
<p>Cargando...</p>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Configuracion SAT</h1>
<p className="text-muted-foreground">
Gestiona tu FIEL y la sincronizacion automatica de CFDIs
</p>
</div>
</div>
{/* Estado de la FIEL */}
<Card>
<CardHeader>
<CardTitle>FIEL (e.firma)</CardTitle>
<CardDescription>
Tu firma electronica para autenticarte con el SAT
</CardDescription>
</CardHeader>
<CardContent>
{fielStatus?.configured ? (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-muted-foreground">RFC</p>
<p className="font-medium">{fielStatus.rfc}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">No. Serie</p>
<p className="font-medium text-xs">{fielStatus.serialNumber}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Vigente hasta</p>
<p className="font-medium">
{fielStatus.validUntil ? new Date(fielStatus.validUntil).toLocaleDateString('es-MX') : '-'}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Estado</p>
<p className={`font-medium ${fielStatus.isExpired ? 'text-red-500' : 'text-green-500'}`}>
{fielStatus.isExpired ? 'Vencida' : `Valida (${fielStatus.daysUntilExpiration} dias)`}
</p>
</div>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setShowUploadModal(true)}
>
Actualizar FIEL
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? 'Eliminando...' : 'Eliminar FIEL'}
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<p className="text-muted-foreground">
No tienes una FIEL configurada. Sube tu certificado y llave privada para habilitar
la sincronizacion automatica de CFDIs con el SAT.
</p>
<Button onClick={() => setShowUploadModal(true)}>
Configurar FIEL
</Button>
</div>
)}
</CardContent>
</Card>
{/* Estado de Sincronizacion */}
<SyncStatus
fielConfigured={fielStatus?.configured || false}
onSyncStarted={fetchFielStatus}
/>
{/* Historial */}
<SyncHistory fielConfigured={fielStatus?.configured || false} />
{/* Modal de carga */}
{showUploadModal && (
<FielUploadModal
onSuccess={handleUploadSuccess}
onClose={() => setShowUploadModal(false)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import OnboardingScreen from "../../components/onboarding/OnboardingScreen";
export default function Page() {
return <OnboardingScreen />;
}

View File

@@ -15,6 +15,7 @@ import {
Bell,
Users,
Building2,
UserCog,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth';
@@ -33,6 +34,7 @@ const navigation = [
const adminNavigation = [
{ name: 'Clientes', href: '/clientes', icon: Building2 },
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
];
export function Sidebar() {

View File

@@ -0,0 +1,170 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
/**
* Onboarding persistence key.
* If you later want this to come from env/config, move it to apps/web/config/onboarding.ts
*/
const STORAGE_KEY = 'horux360:onboarding_seen_v1';
export default function OnboardingScreen() {
const router = useRouter();
const [isNewUser, setIsNewUser] = useState(true);
const [loading, setLoading] = useState(false);
const safePush = (path: string) => {
// Avoid multiple navigations if user clicks quickly.
if (loading) return;
setLoading(true);
router.push(path);
};
useEffect(() => {
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
// If the user has already seen onboarding, go to dashboard automatically.
if (seen) {
setIsNewUser(false);
setLoading(true);
const t = setTimeout(() => router.push('/dashboard'), 900);
return () => clearTimeout(t);
}
}, [router]);
const handleContinue = () => {
if (typeof window !== 'undefined') localStorage.setItem(STORAGE_KEY, '1');
setLoading(true);
setTimeout(() => router.push('/dashboard'), 700);
};
const handleReset = () => {
if (typeof window !== 'undefined') localStorage.removeItem(STORAGE_KEY);
location.reload();
};
const headerStatus = useMemo(() => (isNewUser ? 'Onboarding' : 'Redirección'), [isNewUser]);
return (
<main className="min-h-screen relative overflow-hidden bg-white">
{/* Grid tech claro */}
<div
className="absolute inset-0 opacity-[0.05]"
style={{
backgroundImage:
'linear-gradient(to right, rgba(15,23,42,.2) 1px, transparent 1px), linear-gradient(to bottom, rgba(15,23,42,.2) 1px, transparent 1px)',
backgroundSize: '48px 48px',
}}
/>
{/* Glow global azul (sutil) */}
<div className="absolute -top-24 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-blue-500/20 blur-3xl" />
<div className="relative z-10 flex min-h-screen items-center justify-center p-6">
<div className="w-full max-w-4xl">
<div className="rounded-2xl border border-slate-200 bg-white shadow-xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-xl bg-blue-600/10 border border-blue-500/30 flex items-center justify-center">
<div className="h-2.5 w-2.5 rounded-full bg-blue-500" />
</div>
<div className="leading-tight">
<p className="text-sm font-semibold text-slate-800">Horux360</p>
<p className="text-xs text-slate-500">Pantalla de inicio</p>
</div>
</div>
<span className="text-xs text-slate-500">{headerStatus}</span>
</div>
{/* Body */}
<div className="p-6 md:p-8">
{isNewUser ? (
<div className="grid gap-8 md:grid-cols-2 md:items-center">
{/* Left */}
<div>
<h1 className="text-2xl md:text-3xl font-semibold text-slate-900">
Bienvenido a Horux360
</h1>
<p className="mt-2 text-sm md:text-base text-slate-600 max-w-md">
Revisa este breve video para conocer el flujo. Después podrás continuar.
</p>
<div className="mt-6 flex items-center gap-3">
<button
onClick={handleContinue}
disabled={loading}
className="bg-blue-600 disabled:opacity-60 disabled:cursor-not-allowed text-white px-6 py-3 rounded-xl font-semibold shadow-md hover:bg-blue-700 hover:shadow-lg transition-all"
>
{loading ? 'Cargando…' : 'Continuar'}
</button>
<button
onClick={() => safePush('/login')}
disabled={loading}
className="px-5 py-3 rounded-xl font-medium text-slate-700 border border-slate-300 hover:bg-slate-100 transition disabled:opacity-60 disabled:cursor-not-allowed"
>
Ver más
</button>
</div>
<div className="mt-6 text-xs text-slate-500">
Usuario nuevo: muestra video Usuario recurrente: redirección automática
</div>
</div>
{/* Right (video) - elegante sin glow */}
<div className="relative">
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<div className="h-1 w-full rounded-t-2xl bg-gradient-to-r from-blue-600/80 via-blue-500/40 to-transparent" />
<div className="p-3">
<div className="rounded-xl border border-slate-200 bg-slate-50 overflow-hidden">
<video src="/video-intro.mp4" controls className="w-full rounded-xl" />
</div>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
<span className="flex items-center gap-2">
<span className="inline-block h-2 w-2 rounded-full bg-blue-500" />
Video introductorio
</span>
<span>v1</span>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="py-12 flex flex-col items-center justify-center text-center">
<div className="h-12 w-12 rounded-2xl bg-blue-600/10 border border-blue-500/30 flex items-center justify-center">
<div className="h-3 w-3 rounded-full bg-blue-500 animate-pulse" />
</div>
<h2 className="mt-5 text-lg font-semibold text-slate-800">
Redirigiendo al dashboard
</h2>
<p className="mt-2 text-sm text-slate-600">Usuario recurrente detectado.</p>
<div className="mt-6 w-full max-w-sm h-2 rounded-full bg-slate-200 overflow-hidden border border-slate-300">
<div className="h-full w-2/3 bg-blue-600/80 animate-pulse" />
</div>
<button
onClick={handleReset}
className="mt-6 text-xs text-slate-500 hover:text-slate-700 underline underline-offset-4"
>
Ver video otra vez (reset demo)
</button>
</div>
)}
</div>
</div>
<p className="mt-4 text-center text-xs text-slate-400">
Demo UI sin backend Persistencia local: localStorage
</p>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,138 @@
'use client';
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { uploadFiel } from '@/lib/api/fiel';
import type { FielStatus } from '@horux/shared';
interface FielUploadModalProps {
onSuccess: (status: FielStatus) => void;
onClose: () => void;
}
export function FielUploadModal({ onSuccess, onClose }: FielUploadModalProps) {
const [cerFile, setCerFile] = useState<File | null>(null);
const [keyFile, setKeyFile] = useState<File | null>(null);
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
const result = reader.result as string;
// Remove data URL prefix (e.g., "data:application/x-x509-ca-cert;base64,")
const base64 = result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
});
};
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!cerFile || !keyFile || !password) {
setError('Todos los campos son requeridos');
return;
}
setLoading(true);
try {
const cerBase64 = await fileToBase64(cerFile);
const keyBase64 = await fileToBase64(keyFile);
const result = await uploadFiel({
cerFile: cerBase64,
keyFile: keyBase64,
password,
});
if (result.status) {
onSuccess(result.status);
}
} catch (err: any) {
setError(err.response?.data?.error || 'Error al subir la FIEL');
} finally {
setLoading(false);
}
}, [cerFile, keyFile, password, onSuccess]);
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="w-full max-w-md mx-4">
<CardHeader>
<CardTitle>Configurar FIEL (e.firma)</CardTitle>
<CardDescription>
Sube tu certificado y llave privada para sincronizar CFDIs con el SAT
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cer">Certificado (.cer)</Label>
<Input
id="cer"
type="file"
accept=".cer"
onChange={(e) => setCerFile(e.target.files?.[0] || null)}
className="cursor-pointer"
/>
</div>
<div className="space-y-2">
<Label htmlFor="key">Llave Privada (.key)</Label>
<Input
id="key"
type="file"
accept=".key"
onChange={(e) => setKeyFile(e.target.files?.[0] || null)}
className="cursor-pointer"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Contrasena de la llave</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Ingresa la contrasena de tu FIEL"
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onClose}
className="flex-1"
>
Cancelar
</Button>
<Button
type="submit"
disabled={loading}
className="flex-1"
>
{loading ? 'Subiendo...' : 'Configurar FIEL'}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,182 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { getSyncHistory, retrySync } from '@/lib/api/sat';
import type { SatSyncJob } from '@horux/shared';
interface SyncHistoryProps {
fielConfigured: boolean;
}
const statusLabels: Record<string, string> = {
pending: 'Pendiente',
running: 'En progreso',
completed: 'Completado',
failed: 'Fallido',
};
const statusColors: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
running: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
};
const typeLabels: Record<string, string> = {
initial: 'Inicial',
daily: 'Diaria',
};
export function SyncHistory({ fielConfigured }: SyncHistoryProps) {
const [jobs, setJobs] = useState<SatSyncJob[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const limit = 10;
const fetchHistory = async () => {
try {
const data = await getSyncHistory(page, limit);
setJobs(data.jobs);
setTotal(data.total);
} catch (err) {
console.error('Error fetching sync history:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (fielConfigured) {
fetchHistory();
} else {
setLoading(false);
}
}, [fielConfigured, page]);
const handleRetry = async (jobId: string) => {
try {
await retrySync(jobId);
fetchHistory();
} catch (err) {
console.error('Error retrying job:', err);
}
};
if (!fielConfigured) {
return null;
}
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle>Historial de Sincronizaciones</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">Cargando historial...</p>
</CardContent>
</Card>
);
}
if (jobs.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Historial de Sincronizaciones</CardTitle>
<CardDescription>
Registro de todas las sincronizaciones con el SAT
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">No hay sincronizaciones registradas.</p>
</CardContent>
</Card>
);
}
const totalPages = Math.ceil(total / limit);
return (
<Card>
<CardHeader>
<CardTitle>Historial de Sincronizaciones</CardTitle>
<CardDescription>
Registro de todas las sincronizaciones con el SAT
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{jobs.map((job) => (
<div
key={job.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 rounded text-xs ${statusColors[job.status]}`}>
{statusLabels[job.status]}
</span>
<span className="text-xs text-muted-foreground">
{typeLabels[job.type]}
</span>
</div>
<p className="text-sm">
{job.startedAt ? new Date(job.startedAt).toLocaleString('es-MX') : 'No iniciado'}
</p>
<p className="text-xs text-muted-foreground">
{job.cfdisInserted} nuevos, {job.cfdisUpdated} actualizados
</p>
{job.errorMessage && (
<p className="text-xs text-red-500 mt-1">{job.errorMessage}</p>
)}
</div>
{job.status === 'failed' && (
<Button
size="sm"
variant="outline"
onClick={() => handleRetry(job.id)}
>
Reintentar
</Button>
)}
{job.status === 'running' && (
<div className="text-right">
<p className="text-sm font-medium">{job.progressPercent}%</p>
<p className="text-xs text-muted-foreground">{job.cfdisDownloaded} descargados</p>
</div>
)}
</div>
))}
</div>
{totalPages > 1 && (
<div className="flex justify-center gap-2 mt-4">
<Button
size="sm"
variant="outline"
disabled={page === 1}
onClick={() => setPage(p => p - 1)}
>
Anterior
</Button>
<span className="py-2 px-3 text-sm">
Pagina {page} de {totalPages}
</span>
<Button
size="sm"
variant="outline"
disabled={page === totalPages}
onClick={() => setPage(p => p + 1)}
>
Siguiente
</Button>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,187 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { getSyncStatus, startSync } from '@/lib/api/sat';
import type { SatSyncStatusResponse } from '@horux/shared';
interface SyncStatusProps {
fielConfigured: boolean;
onSyncStarted?: () => void;
}
const statusLabels: Record<string, string> = {
pending: 'Pendiente',
running: 'En progreso',
completed: 'Completado',
failed: 'Fallido',
};
const statusColors: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
running: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
};
export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
const [status, setStatus] = useState<SatSyncStatusResponse | null>(null);
const [loading, setLoading] = useState(true);
const [startingSync, setStartingSync] = useState(false);
const [error, setError] = useState('');
const fetchStatus = async () => {
try {
const data = await getSyncStatus();
setStatus(data);
} catch (err) {
console.error('Error fetching sync status:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (fielConfigured) {
fetchStatus();
// Actualizar cada 30 segundos si hay sync activo
const interval = setInterval(fetchStatus, 30000);
return () => clearInterval(interval);
} else {
setLoading(false);
}
}, [fielConfigured]);
const handleStartSync = async (type: 'initial' | 'daily') => {
setStartingSync(true);
setError('');
try {
await startSync({ type });
await fetchStatus();
onSyncStarted?.();
} catch (err: any) {
setError(err.response?.data?.error || 'Error al iniciar sincronizacion');
} finally {
setStartingSync(false);
}
};
if (!fielConfigured) {
return (
<Card>
<CardHeader>
<CardTitle>Sincronizacion SAT</CardTitle>
<CardDescription>
Configura tu FIEL para habilitar la sincronizacion automatica
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
La sincronizacion con el SAT requiere una FIEL valida configurada.
</p>
</CardContent>
</Card>
);
}
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle>Sincronizacion SAT</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">Cargando estado...</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Sincronizacion SAT</CardTitle>
<CardDescription>
Estado de la sincronizacion automatica de CFDIs
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{status?.hasActiveSync && status.currentJob && (
<div className="p-4 bg-blue-50 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 rounded text-sm ${statusColors[status.currentJob.status]}`}>
{statusLabels[status.currentJob.status]}
</span>
<span className="text-sm text-muted-foreground">
{status.currentJob.type === 'initial' ? 'Sincronizacion inicial' : 'Sincronizacion diaria'}
</span>
</div>
{status.currentJob.status === 'running' && (
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${status.currentJob.progressPercent}%` }}
/>
</div>
)}
<p className="text-sm mt-2">
{status.currentJob.cfdisDownloaded} CFDIs descargados
</p>
</div>
)}
{status?.lastCompletedJob && !status.hasActiveSync && (
<div className="p-4 bg-green-50 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 rounded text-sm ${statusColors.completed}`}>
Ultima sincronizacion exitosa
</span>
</div>
<p className="text-sm">
{new Date(status.lastCompletedJob.completedAt!).toLocaleString('es-MX')}
</p>
<p className="text-sm text-muted-foreground">
{status.lastCompletedJob.cfdisInserted} CFDIs nuevos, {status.lastCompletedJob.cfdisUpdated} actualizados
</p>
</div>
)}
<div className="grid grid-cols-2 gap-4 text-center">
<div className="p-4 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold">{status?.totalCfdisSynced || 0}</p>
<p className="text-sm text-muted-foreground">CFDIs sincronizados</p>
</div>
<div className="p-4 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold">3:00 AM</p>
<p className="text-sm text-muted-foreground">Sincronizacion diaria</p>
</div>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<div className="flex gap-3">
<Button
variant="outline"
disabled={startingSync || status?.hasActiveSync}
onClick={() => handleStartSync('daily')}
className="flex-1"
>
{startingSync ? 'Iniciando...' : 'Sincronizar ahora'}
</Button>
{!status?.lastCompletedJob && (
<Button
disabled={startingSync || status?.hasActiveSync}
onClick={() => handleStartSync('initial')}
className="flex-1"
>
{startingSync ? 'Iniciando...' : 'Sincronizacion inicial (10 anos)'}
</Button>
)}
</div>
</CardContent>
</Card>
);
}

16
apps/web/lib/api/fiel.ts Normal file
View File

@@ -0,0 +1,16 @@
import { apiClient } from './client';
import type { FielStatus, FielUploadRequest } from '@horux/shared';
export async function uploadFiel(data: FielUploadRequest): Promise<{ message: string; status: FielStatus }> {
const response = await apiClient.post('/fiel/upload', data);
return response.data;
}
export async function getFielStatus(): Promise<FielStatus> {
const response = await apiClient.get<FielStatus>('/fiel/status');
return response.data;
}
export async function deleteFiel(): Promise<void> {
await apiClient.delete('/fiel');
}

45
apps/web/lib/api/sat.ts Normal file
View File

@@ -0,0 +1,45 @@
import { apiClient } from './client';
import type {
SatSyncJob,
SatSyncStatusResponse,
SatSyncHistoryResponse,
StartSyncRequest,
StartSyncResponse,
} from '@horux/shared';
export async function startSync(data?: StartSyncRequest): Promise<StartSyncResponse> {
const response = await apiClient.post<StartSyncResponse>('/sat/sync', data || {});
return response.data;
}
export async function getSyncStatus(): Promise<SatSyncStatusResponse> {
const response = await apiClient.get<SatSyncStatusResponse>('/sat/sync/status');
return response.data;
}
export async function getSyncHistory(page: number = 1, limit: number = 10): Promise<SatSyncHistoryResponse> {
const response = await apiClient.get<SatSyncHistoryResponse>('/sat/sync/history', {
params: { page, limit },
});
return response.data;
}
export async function getSyncJob(id: string): Promise<SatSyncJob> {
const response = await apiClient.get<SatSyncJob>(`/sat/sync/${id}`);
return response.data;
}
export async function retrySync(id: string): Promise<StartSyncResponse> {
const response = await apiClient.post<StartSyncResponse>(`/sat/sync/${id}/retry`);
return response.data;
}
export async function getCronInfo(): Promise<{ scheduled: boolean; expression: string; timezone: string }> {
const response = await apiClient.get('/sat/cron');
return response.data;
}
export async function runCron(): Promise<{ message: string }> {
const response = await apiClient.post('/sat/cron/run');
return response.data;
}

View File

@@ -19,3 +19,18 @@ export async function updateUsuario(id: string, data: UserUpdate): Promise<UserL
export async function deleteUsuario(id: string): Promise<void> {
await apiClient.delete(`/usuarios/${id}`);
}
// Funciones globales (admin global)
export async function getAllUsuarios(): Promise<UserListItem[]> {
const response = await apiClient.get<UserListItem[]>('/usuarios/global/all');
return response.data;
}
export async function updateUsuarioGlobal(id: string, data: UserUpdate): Promise<UserListItem> {
const response = await apiClient.patch<UserListItem>(`/usuarios/global/${id}`, data);
return response.data;
}
export async function deleteUsuarioGlobal(id: string): Promise<void> {
await apiClient.delete(`/usuarios/global/${id}`);
}

View File

@@ -38,3 +38,31 @@ export function useDeleteUsuario() {
},
});
}
// Hooks globales (admin global)
export function useAllUsuarios() {
return useQuery({
queryKey: ['usuarios', 'global'],
queryFn: usuariosApi.getAllUsuarios,
});
}
export function useUpdateUsuarioGlobal() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UserUpdate }) => usuariosApi.updateUsuarioGlobal(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['usuarios'] });
},
});
}
export function useDeleteUsuarioGlobal() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => usuariosApi.deleteUsuarioGlobal(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['usuarios'] });
},
});
}

View File

@@ -38,12 +38,15 @@ export interface UserListItem {
active: boolean;
lastLogin: string | null;
createdAt: string;
tenantId?: string;
tenantName?: string;
}
export interface UserUpdate {
nombre?: string;
role?: 'admin' | 'contador' | 'visor';
active?: boolean;
tenantId?: string;
}
export interface AuditLog {

103
pnpm-lock.yaml generated
View File

@@ -20,12 +20,21 @@ importers:
'@horux/shared':
specifier: workspace:*
version: link:../../packages/shared
'@nodecfdi/cfdi-core':
specifier: ^1.0.1
version: 1.0.1
'@nodecfdi/credentials':
specifier: ^3.2.0
version: 3.2.0(luxon@3.7.2)
'@nodecfdi/sat-ws-descarga-masiva':
specifier: ^2.0.0
version: 2.0.0(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)
'@prisma/client':
specifier: ^5.22.0
version: 5.22.0(prisma@5.22.0)
adm-zip:
specifier: ^0.5.16
version: 0.5.16
bcryptjs:
specifier: ^2.4.3
version: 2.4.3
@@ -41,12 +50,18 @@ importers:
express:
specifier: ^4.21.0
version: 4.22.1
fast-xml-parser:
specifier: ^5.3.3
version: 5.3.3
helmet:
specifier: ^8.0.0
version: 8.1.0
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.3
node-cron:
specifier: ^4.2.1
version: 4.2.1
node-forge:
specifier: ^1.3.3
version: 1.3.3
@@ -54,6 +69,9 @@ importers:
specifier: ^3.23.0
version: 3.25.76
devDependencies:
'@types/adm-zip':
specifier: ^0.5.7
version: 0.5.7
'@types/bcryptjs':
specifier: ^2.4.6
version: 2.4.6
@@ -69,6 +87,9 @@ importers:
'@types/node':
specifier: ^22.0.0
version: 22.19.7
'@types/node-cron':
specifier: ^3.0.11
version: 3.0.11
'@types/node-forge':
specifier: ^1.3.14
version: 1.3.14
@@ -452,6 +473,10 @@ packages:
resolution: {integrity: sha512-YoWtdhCPB86W+2TpXrZ1yXzehNC2sEFCB0vw4XtnHKdtw6pKxKyDT2qQf4TqICROp0IZNNKunFDw3EhcoR41Tw==}
engines: {node: '>=18 <=22 || ^16'}
'@nodecfdi/cfdi-core@1.0.1':
resolution: {integrity: sha512-OGm8BUxehpofu53j0weJ8SyF8v6RNJsGdziBu/Y+Xfd6PnrbpMWdPd40LSiP5tctLzm9ubDQIwKJX63Zp0I5BA==}
engines: {node: '>=18'}
'@nodecfdi/credentials@3.2.0':
resolution: {integrity: sha512-knZE8kIrIib27M/tcUQRgvnObMd7oR9EKZTSdBSHXW/5Pw6UB23v0ruUAJSFY0789J3OLfKaIVRXBG2I+q9ZTA==}
engines: {node: '>=18 <=22 || ^16'}
@@ -462,6 +487,23 @@ packages:
'@types/luxon':
optional: true
'@nodecfdi/rfc@2.0.6':
resolution: {integrity: sha512-DiNC6j/mubbci8D9Qj9tdCm4/T/Q3ST92qpQ+AuHKJFVZ+/98F6ap8QFKeYK2ECu71wQGqAgkbmgQmVONAI5gg==}
engines: {node: '>=18 <=22 || ^16'}
peerDependencies:
'@types/luxon': 3.4.2
luxon: ^3.4.4
peerDependenciesMeta:
'@types/luxon':
optional: true
'@nodecfdi/sat-ws-descarga-masiva@2.0.0':
resolution: {integrity: sha512-FAmypqJfilOd29bf2bgMdysUkQKsu6ZirgljRfH4VFClXXtDHKmjOKahX0AbegUFc1GhtLjxhQgM+PJX3zhOdA==}
engines: {node: '>=18'}
peerDependencies:
'@nodecfdi/cfdi-core': ^1.0.0
luxon: ^3.6.1
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -958,6 +1000,9 @@ packages:
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@types/adm-zip@0.5.7':
resolution: {integrity: sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==}
'@types/bcryptjs@2.4.6':
resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==}
@@ -1012,6 +1057,9 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node-cron@3.0.11':
resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
'@types/node-forge@1.3.14':
resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==}
@@ -1048,10 +1096,18 @@ packages:
resolution: {integrity: sha512-8GVr3S/nmLKL7QI7RYhVIcz3PuT/fxfkQLuh/F1CaT+/3QgI14RqiJkcKIni7h9u4ySbQGiGvm4XbNxRBJin4g==}
engines: {node: '>= 6.13.0'}
'@xmldom/xmldom@0.9.8':
resolution: {integrity: sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==}
engines: {node: '>=14.6'}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
adm-zip@0.5.16:
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
engines: {node: '>=12.0'}
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
@@ -1424,6 +1480,10 @@ packages:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
fast-xml-parser@5.3.3:
resolution: {integrity: sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==}
hasBin: true
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -1783,6 +1843,10 @@ packages:
sass:
optional: true
node-cron@4.2.1:
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
engines: {node: '>=6.0.0'}
node-forge@1.3.3:
resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==}
engines: {node: '>= 6.13.0'}
@@ -2112,6 +2176,9 @@ packages:
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strnum@2.1.2:
resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==}
styled-jsx@5.1.1:
resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
engines: {node: '>= 12.0.0'}
@@ -2487,6 +2554,10 @@ snapshots:
'@nodecfdi/base-converter@1.0.7': {}
'@nodecfdi/cfdi-core@1.0.1':
dependencies:
'@xmldom/xmldom': 0.9.8
'@nodecfdi/credentials@3.2.0(luxon@3.7.2)':
dependencies:
'@nodecfdi/base-converter': 1.0.7
@@ -2494,6 +2565,20 @@ snapshots:
luxon: 3.7.2
ts-mixer: 6.0.4
'@nodecfdi/rfc@2.0.6(luxon@3.7.2)':
dependencies:
luxon: 3.7.2
'@nodecfdi/sat-ws-descarga-masiva@2.0.0(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)':
dependencies:
'@nodecfdi/cfdi-core': 1.0.1
'@nodecfdi/credentials': 3.2.0(luxon@3.7.2)
'@nodecfdi/rfc': 2.0.6(luxon@3.7.2)
jszip: 3.10.1
luxon: 3.7.2
transitivePeerDependencies:
- '@types/luxon'
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -2977,6 +3062,10 @@ snapshots:
'@tanstack/table-core@8.21.3': {}
'@types/adm-zip@0.5.7':
dependencies:
'@types/node': 22.19.7
'@types/bcryptjs@2.4.6': {}
'@types/body-parser@1.19.6':
@@ -3038,6 +3127,8 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/node-cron@3.0.11': {}
'@types/node-forge@1.3.14':
dependencies:
'@types/node': 22.19.7
@@ -3074,11 +3165,15 @@ snapshots:
'@vilic/node-forge@1.3.2-5': {}
'@xmldom/xmldom@0.9.8': {}
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
negotiator: 0.6.3
adm-zip@0.5.16: {}
any-promise@1.3.0: {}
anymatch@3.1.3:
@@ -3520,6 +3615,10 @@ snapshots:
merge2: 1.4.1
micromatch: 4.0.8
fast-xml-parser@5.3.3:
dependencies:
strnum: 2.1.2
fastq@1.20.1:
dependencies:
reusify: 1.1.0
@@ -3851,6 +3950,8 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
node-cron@4.2.1: {}
node-forge@1.3.3: {}
node-releases@2.0.27: {}
@@ -4185,6 +4286,8 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
strnum@2.1.2: {}
styled-jsx@5.1.1(react@18.3.1):
dependencies:
client-only: 0.0.1