Compare commits

...

23 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
Consultoria AS
a64aa11548 feat(sat): add FIEL management and encryption services (Phase 2)
- Add sat-crypto.service.ts with AES-256-GCM encryption for secure
  credential storage using JWT_SECRET as key derivation source
- Add fiel.service.ts with complete FIEL lifecycle management:
  - Upload and validate FIEL credentials (.cer/.key files)
  - Verify certificate is FIEL (not CSD) and not expired
  - Store encrypted credentials in database
  - Retrieve and decrypt credentials for SAT sync operations
- Install @nodecfdi/credentials for FIEL/CSD handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:44:04 +00:00
Consultoria AS
787aac9a4c feat(sat): add database models for SAT sync
Phase 1 - Database models:
- Add FielCredential model for encrypted FIEL storage
- Add SatSyncJob model for sync job tracking
- Add SAT-related enums (SatSyncType, SatSyncStatus, CfdiSyncType)
- Add TypeScript types in shared package
- Relations: Tenant -> FielCredential (1:1), Tenant -> SatSyncJobs (1:N)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:38:51 +00:00
Consultoria AS
3763014eca docs: add SAT sync implementation plan
Detailed implementation plan with 9 phases:
1. Database models and migrations
2. Cryptography and FIEL services
3. SAT communication services
4. CFDI XML parser
5. Main orchestrator service
6. Scheduled cron job
7. API endpoints
8. Frontend components
9. Testing and validation

Includes:
- 16 new files to create
- 5 files to modify
- Dependencies list
- Implementation order

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:36:53 +00:00
Consultoria AS
b49902bcff docs: add SAT sync feature design
Design document for automatic CFDI synchronization with SAT:
- FIEL (e.firma) authentication
- Download emitted and received CFDIs
- Daily automated sync at 3:00 AM
- Initial extraction of last 10 years
- Encrypted credential storage (AES-256-GCM)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:35:12 +00:00
Consultoria AS
519de61c6f chore: add systemd service files for auto-start
Add systemd unit files for automatic service startup:
- horux-api.service: API server on port 4000
- horux-web.service: Web frontend on port 3000

Services are configured to:
- Start automatically on boot
- Restart on failure
- Depend on PostgreSQL

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:17:47 +00:00
41 changed files with 4835 additions and 8 deletions

View File

@@ -15,22 +15,32 @@
}, },
"dependencies": { "dependencies": {
"@horux/shared": "workspace:*", "@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", "@prisma/client": "^5.22.0",
"adm-zip": "^0.5.16",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"express": "^4.21.0", "express": "^4.21.0",
"fast-xml-parser": "^5.3.3",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"node-cron": "^4.2.1",
"node-forge": "^1.3.3",
"zod": "^3.23.0" "zod": "^3.23.0"
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11",
"@types/node-forge": "^1.3.14",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.3.0" "typescript": "^5.3.0"

View File

@@ -19,7 +19,9 @@ model Tenant {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime? @map("expires_at") expiresAt DateTime? @map("expires_at")
users User[] users User[]
fielCredential FielCredential?
satSyncJobs SatSyncJob[]
@@map("tenants") @@map("tenants")
} }
@@ -62,3 +64,75 @@ enum Role {
contador contador
visor visor
} }
// ============================================
// SAT Sync Models
// ============================================
model FielCredential {
id String @id @default(uuid())
tenantId String @unique @map("tenant_id")
rfc String @db.VarChar(13)
cerData Bytes @map("cer_data")
keyData Bytes @map("key_data")
keyPasswordEncrypted Bytes @map("key_password_encrypted")
encryptionIv Bytes @map("encryption_iv")
encryptionTag Bytes @map("encryption_tag")
serialNumber String? @map("serial_number") @db.VarChar(50)
validFrom DateTime @map("valid_from")
validUntil DateTime @map("valid_until")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("fiel_credentials")
}
model SatSyncJob {
id String @id @default(uuid())
tenantId String @map("tenant_id")
type SatSyncType
status SatSyncStatus @default(pending)
dateFrom DateTime @map("date_from") @db.Date
dateTo DateTime @map("date_to") @db.Date
cfdiType CfdiSyncType? @map("cfdi_type")
satRequestId String? @map("sat_request_id") @db.VarChar(50)
satPackageIds String[] @map("sat_package_ids")
cfdisFound Int @default(0) @map("cfdis_found")
cfdisDownloaded Int @default(0) @map("cfdis_downloaded")
cfdisInserted Int @default(0) @map("cfdis_inserted")
cfdisUpdated Int @default(0) @map("cfdis_updated")
progressPercent Int @default(0) @map("progress_percent")
errorMessage String? @map("error_message")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
retryCount Int @default(0) @map("retry_count")
nextRetryAt DateTime? @map("next_retry_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([status])
@@index([status, nextRetryAt])
@@map("sat_sync_jobs")
}
enum SatSyncType {
initial
daily
}
enum SatSyncStatus {
pending
running
completed
failed
}
enum CfdiSyncType {
emitidos
recibidos
}

View File

@@ -13,6 +13,8 @@ import { calendarioRoutes } from './routes/calendario.routes.js';
import { reportesRoutes } from './routes/reportes.routes.js'; import { reportesRoutes } from './routes/reportes.routes.js';
import { usuariosRoutes } from './routes/usuarios.routes.js'; import { usuariosRoutes } from './routes/usuarios.routes.js';
import { tenantsRoutes } from './routes/tenants.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(); const app = express();
@@ -43,6 +45,8 @@ app.use('/api/calendario', calendarioRoutes);
app.use('/api/reportes', reportesRoutes); app.use('/api/reportes', reportesRoutes);
app.use('/api/usuarios', usuariosRoutes); app.use('/api/usuarios', usuariosRoutes);
app.use('/api/tenants', tenantsRoutes); app.use('/api/tenants', tenantsRoutes);
app.use('/api/fiel', fielRoutes);
app.use('/api/sat', satRoutes);
// Error handling // Error handling
app.use(errorMiddleware); 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 { Request, Response, NextFunction } from 'express';
import * as usuariosService from '../services/usuarios.service.js'; import * as usuariosService from '../services/usuarios.service.js';
import { AppError } from '../utils/errors.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) { export async function getUsuarios(req: Request, res: Response, next: NextFunction) {
try { 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) { export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
try { try {
if (req.user!.role !== 'admin') { if (req.user!.role !== 'admin') {
@@ -28,7 +58,8 @@ export async function updateUsuario(req: Request, res: Response, next: NextFunct
if (req.user!.role !== 'admin') { if (req.user!.role !== 'admin') {
throw new AppError(403, 'Solo administradores pueden modificar usuarios'); 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); res.json(usuario);
} catch (error) { } catch (error) {
next(error); next(error);
@@ -40,10 +71,49 @@ export async function deleteUsuario(req: Request, res: Response, next: NextFunct
if (req.user!.role !== 'admin') { if (req.user!.role !== 'admin') {
throw new AppError(403, 'Solo administradores pueden eliminar usuarios'); 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'); 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(); res.status(204).send();
} catch (error) { } catch (error) {
next(error); next(error);

View File

@@ -1,9 +1,15 @@
import { app } from './app.js'; import { app } from './app.js';
import { env } from './config/env.js'; import { env } from './config/env.js';
import { startSatSyncJob } from './jobs/sat-sync.job.js';
const PORT = parseInt(env.PORT, 10); const PORT = parseInt(env.PORT, 10);
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 API Server running on http://0.0.0.0:${PORT}`); console.log(`API Server running on http://0.0.0.0:${PORT}`);
console.log(`📊 Environment: ${env.NODE_ENV}`); 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); router.use(authenticate);
// Rutas por tenant
router.get('/', usuariosController.getUsuarios); router.get('/', usuariosController.getUsuarios);
router.post('/invite', usuariosController.inviteUsuario); router.post('/invite', usuariosController.inviteUsuario);
router.patch('/:id', usuariosController.updateUsuario); router.patch('/:id', usuariosController.updateUsuario);
router.delete('/:id', usuariosController.deleteUsuario); 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 }; export { router as usuariosRoutes };

View File

@@ -0,0 +1,228 @@
import { Credential } from '@nodecfdi/credentials/node';
import { prisma } from '../config/database.js';
import { encryptFielCredentials, decryptFielCredentials } from './sat/sat-crypto.service.js';
import type { FielStatus } from '@horux/shared';
/**
* Sube y valida credenciales FIEL
*/
export async function uploadFiel(
tenantId: string,
cerBase64: string,
keyBase64: string,
password: string
): Promise<{ success: boolean; message: string; status?: FielStatus }> {
try {
// Decodificar archivos de Base64
const cerData = Buffer.from(cerBase64, 'base64');
const keyData = Buffer.from(keyBase64, 'base64');
// Validar que los archivos sean válidos y coincidan
let credential: Credential;
try {
credential = Credential.create(
cerData.toString('binary'),
keyData.toString('binary'),
password
);
} catch (error: any) {
return {
success: false,
message: 'Los archivos de la FIEL no son válidos o la contraseña es incorrecta',
};
}
// Verificar que sea una FIEL (no CSD)
if (!credential.isFiel()) {
return {
success: false,
message: 'El certificado proporcionado no es una FIEL (e.firma). Parece ser un CSD.',
};
}
// Obtener información del certificado
const certificate = credential.certificate();
const rfc = certificate.rfc();
const serialNumber = certificate.serialNumber().bytes();
// 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 {
success: false,
message: 'La FIEL está vencida desde ' + validUntil.toLocaleDateString(),
};
}
// 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({
where: { tenantId },
create: {
tenantId,
rfc,
cerData: encryptedCer,
keyData: encryptedKey,
keyPasswordEncrypted: encryptedPassword,
encryptionIv: iv,
encryptionTag: tag,
serialNumber,
validFrom,
validUntil,
isActive: true,
},
update: {
rfc,
cerData: encryptedCer,
keyData: encryptedKey,
keyPasswordEncrypted: encryptedPassword,
encryptionIv: iv,
encryptionTag: tag,
serialNumber,
validFrom,
validUntil,
isActive: true,
updatedAt: new Date(),
},
});
const daysUntilExpiration = Math.ceil(
(validUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
return {
success: true,
message: 'FIEL configurada correctamente',
status: {
configured: true,
rfc,
serialNumber,
validFrom: validFrom.toISOString(),
validUntil: validUntil.toISOString(),
isExpired: false,
daysUntilExpiration,
},
};
} catch (error: any) {
console.error('[FIEL Upload Error]', error);
return {
success: false,
message: error.message || 'Error al procesar la FIEL',
};
}
}
/**
* Obtiene el estado de la FIEL de un tenant
*/
export async function getFielStatus(tenantId: string): Promise<FielStatus> {
const fiel = await prisma.fielCredential.findUnique({
where: { tenantId },
select: {
rfc: true,
serialNumber: true,
validFrom: true,
validUntil: true,
isActive: true,
},
});
if (!fiel || !fiel.isActive) {
return { configured: false };
}
const now = new Date();
const isExpired = now > fiel.validUntil;
const daysUntilExpiration = Math.ceil(
(fiel.validUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
);
return {
configured: true,
rfc: fiel.rfc,
serialNumber: fiel.serialNumber || undefined,
validFrom: fiel.validFrom.toISOString(),
validUntil: fiel.validUntil.toISOString(),
isExpired,
daysUntilExpiration: isExpired ? 0 : daysUntilExpiration,
};
}
/**
* Elimina la FIEL de un tenant
*/
export async function deleteFiel(tenantId: string): Promise<boolean> {
try {
await prisma.fielCredential.delete({
where: { tenantId },
});
return true;
} catch {
return false;
}
}
/**
* Obtiene las credenciales desencriptadas para usar en sincronización
* Solo debe usarse internamente por el servicio de SAT
*/
export async function getDecryptedFiel(tenantId: string): Promise<{
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 todas las credenciales juntas
const { cerData, keyData, password } = decryptFielCredentials(
Buffer.from(fiel.cerData),
Buffer.from(fiel.keyData),
Buffer.from(fiel.keyPasswordEncrypted),
Buffer.from(fiel.encryptionIv),
Buffer.from(fiel.encryptionTag)
);
return {
cerContent: cerData.toString('binary'),
keyContent: keyData.toString('binary'),
password,
rfc: fiel.rfc,
};
} catch (error) {
console.error('[FIEL Decrypt Error]', error);
return null;
}
}
/**
* Verifica si un tenant tiene FIEL configurada y válida
*/
export async function hasFielConfigured(tenantId: string): Promise<boolean> {
const status = await getFielStatus(tenantId);
return status.configured && !status.isExpired;
}

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,122 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'crypto';
import { env } from '../../config/env.js';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const TAG_LENGTH = 16;
/**
* Deriva una clave de 256 bits del JWT_SECRET
*/
function deriveKey(): Buffer {
return createHash('sha256').update(env.JWT_SECRET).digest();
}
/**
* Encripta datos usando AES-256-GCM
*/
export function encrypt(data: Buffer): { encrypted: Buffer; iv: Buffer; tag: Buffer } {
const iv = randomBytes(IV_LENGTH);
const key = deriveKey();
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
const tag = cipher.getAuthTag();
return { encrypted, iv, tag };
}
/**
* Desencripta datos usando AES-256-GCM
*/
export function decrypt(encrypted: Buffer, iv: Buffer, tag: Buffer): Buffer {
const key = deriveKey();
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
}
/**
* Encripta un string y retorna los componentes
*/
export function encryptString(text: string): { encrypted: Buffer; iv: Buffer; tag: Buffer } {
return encrypt(Buffer.from(text, 'utf-8'));
}
/**
* Desencripta a string
*/
export function decryptToString(encrypted: Buffer, iv: Buffer, tag: Buffer): string {
return decrypt(encrypted, iv, tag).toString('utf-8');
}
/**
* Encripta credenciales FIEL (cer, key, password)
*/
export function encryptFielCredentials(
cerData: Buffer,
keyData: Buffer,
password: string
): {
encryptedCer: Buffer;
encryptedKey: Buffer;
encryptedPassword: Buffer;
iv: Buffer;
tag: Buffer;
} {
// Usamos el mismo IV y tag para simplificar, concatenando los datos
const combined = Buffer.concat([
Buffer.from(cerData.length.toString().padStart(10, '0')),
cerData,
Buffer.from(keyData.length.toString().padStart(10, '0')),
keyData,
Buffer.from(password, 'utf-8'),
]);
const { encrypted, iv, tag } = encrypt(combined);
// Extraemos las partes encriptadas
const cerLength = cerData.length;
const keyLength = keyData.length;
const passwordLength = Buffer.from(password, 'utf-8').length;
return {
encryptedCer: encrypted.subarray(0, 10 + cerLength),
encryptedKey: encrypted.subarray(10 + cerLength, 20 + cerLength + keyLength),
encryptedPassword: encrypted.subarray(20 + cerLength + keyLength),
iv,
tag,
};
}
/**
* Desencripta credenciales FIEL
*/
export function decryptFielCredentials(
encryptedCer: Buffer,
encryptedKey: Buffer,
encryptedPassword: Buffer,
iv: Buffer,
tag: Buffer
): {
cerData: Buffer;
keyData: Buffer;
password: string;
} {
const combined = Buffer.concat([encryptedCer, encryptedKey, encryptedPassword]);
const decrypted = decrypt(combined, iv, tag);
// Parseamos las partes
const cerLengthStr = decrypted.subarray(0, 10).toString();
const cerLength = parseInt(cerLengthStr, 10);
const cerData = decrypted.subarray(10, 10 + cerLength);
const keyLengthStr = decrypted.subarray(10 + cerLength, 20 + cerLength).toString();
const keyLength = parseInt(keyLengthStr, 10);
const keyData = decrypted.subarray(20 + cerLength, 20 + cerLength + keyLength);
const password = decrypted.subarray(20 + cerLength + keyLength).toString('utf-8');
return { cerData, keyData, password };
}

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 }, 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 }); const response = await login({ email, password });
setTokens(response.accessToken, response.refreshToken); setTokens(response.accessToken, response.refreshToken);
setUser(response.user); 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) { } catch (err: any) {
setError(err.response?.data?.message || 'Error al iniciar sesión'); setError(err.response?.data?.message || 'Error al iniciar sesión');
} finally { } 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 { useThemeStore } from '@/stores/theme-store';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { themes, type ThemeName } from '@/themes'; 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 }[] = [ const themeOptions: { name: ThemeName; label: string; description: string; layoutDesc: string; layoutIcon: typeof Sidebar }[] = [
{ {
@@ -90,6 +91,26 @@ export default function ConfiguracionPage() {
</CardContent> </CardContent>
</Card> </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 */} {/* Theme Selection */}
<Card> <Card>
<CardHeader> <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, Bell,
Users, Users,
Building2, Building2,
UserCog,
} from 'lucide-react'; } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth'; import { logout } from '@/lib/api/auth';
@@ -33,6 +34,7 @@ const navigation = [
const adminNavigation = [ const adminNavigation = [
{ name: 'Clientes', href: '/clientes', icon: Building2 }, { name: 'Clientes', href: '/clientes', icon: Building2 },
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
]; ];
export function Sidebar() { 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> { export async function deleteUsuario(id: string): Promise<void> {
await apiClient.delete(`/usuarios/${id}`); 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

@@ -0,0 +1,17 @@
[Unit]
Description=Horux360 API Server
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=simple
User=root
WorkingDirectory=/root/Horux/apps/api
Environment=NODE_ENV=production
Environment=PATH=/root/.local/share/pnpm:/usr/local/bin:/usr/bin:/bin
ExecStart=/root/.local/share/pnpm/pnpm dev
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,17 @@
[Unit]
Description=Horux360 Web Frontend
After=network.target horux-api.service
Wants=horux-api.service
[Service]
Type=simple
User=root
WorkingDirectory=/root/Horux/apps/web
Environment=NODE_ENV=production
Environment=PATH=/root/.local/share/pnpm:/usr/local/bin:/usr/bin:/bin
ExecStart=/root/.local/share/pnpm/pnpm dev
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,327 @@
# Diseño: Sincronización con SAT
## Resumen
Implementar sincronización automática de CFDIs desde el portal del SAT usando la e.firma (FIEL).
## Requisitos
| Aspecto | Decisión |
|---------|----------|
| Autenticación | FIEL (archivos .cer y .key + contraseña) |
| Tipos de CFDI | Emitidos y recibidos |
| Ejecución | Programada diaria a las 3:00 AM |
| Almacenamiento credenciales | Encriptadas en PostgreSQL (AES-256-GCM) |
| Primera extracción | Últimos 10 años |
| Extracciones posteriores | Solo mes actual |
| Duplicados | Actualizar con versión del SAT |
---
## Arquitectura General
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Frontend │────▶│ API Horux │────▶│ SAT WSDL │
│ (Configuración)│ │ (sat.service) │ │ Web Service│
└─────────────────┘ └──────────────────┘ └─────────────┘
┌──────────────┐
│ PostgreSQL │
│ - fiel_credentials
│ - sat_sync_jobs
│ - cfdis
└──────────────┘
```
---
## Integración con Web Services del SAT
### Flujo de Descarga
```
1. AUTENTICACIÓN (Token válido por 5 minutos)
- Crear timestamp (Created + Expires)
- Generar digest SHA-1 del timestamp
- Firmar digest con llave privada (.key) usando RSA-SHA1
- Enviar SOAP con certificado (.cer) + firma
- Recibir token SAML para usar en siguientes llamadas
2. SOLICITUD DE DESCARGA
Parámetros:
- RfcSolicitante: RFC de la empresa
- FechaInicio: YYYY-MM-DDTHH:MM:SS
- FechaFin: YYYY-MM-DDTHH:MM:SS
- TipoSolicitud: "CFDI" o "Metadata"
- TipoComprobante: "I"(ingreso), "E"(egreso), "T", "N", "P"
- RfcEmisor / RfcReceptor: Filtrar por contraparte (opcional)
Respuesta:
- IdSolicitud: UUID para tracking
- CodEstatus: 5000 = Aceptada
3. VERIFICACIÓN (Polling cada 30-60 segundos)
Estados posibles:
- 1: Aceptada (en proceso)
- 2: En proceso
- 3: Terminada (lista para descargar)
- 4: Error
- 5: Rechazada
- 6: Vencida
Respuesta exitosa incluye:
- IdsPaquetes: Array de IDs de paquetes ZIP a descargar
- NumeroCFDIs: Total de comprobantes encontrados
4. DESCARGA DE PAQUETES
- Por cada IdPaquete, solicitar descarga
- Respuesta: Paquete en Base64 (archivo ZIP)
- Decodificar y extraer XMLs
- Cada ZIP puede contener hasta 200,000 CFDIs
5. PROCESAMIENTO DE XMLs
Por cada XML:
- Parsear con @nodecfdi/cfdi-core
- Extraer: UUID, emisor, receptor, total, impuestos, fecha
- Buscar en BD por UUID
- Si existe → UPDATE
- Si no existe → INSERT
- Guardar XML original
```
### Endpoints del SAT
| Servicio | URL |
|----------|-----|
| Autenticación | `https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc` |
| Solicitud | `https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitaDescargaService.svc` |
| Verificación | `https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc` |
| Descarga | `https://cfdidescargamasiva.clouda.sat.gob.mx/DescargaMasivaService.svc` |
### Estructura SOAP para Autenticación
```xml
<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">
<u:Timestamp u:Id="_0">
<u:Created>2026-01-25T00:00:00.000Z</u:Created>
<u:Expires>2026-01-25T00:05:00.000Z</u:Expires>
</u:Timestamp>
<o:BinarySecurityToken
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"
u:Id="uuid-cert">
<!-- Certificado .cer en Base64 -->
</o:BinarySecurityToken>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<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><!-- SHA1 del Timestamp --></DigestValue>
</Reference>
</SignedInfo>
<SignatureValue><!-- Firma RSA-SHA1 --></SignatureValue>
<KeyInfo>
<o:SecurityTokenReference>
<o:Reference URI="#uuid-cert"/>
</o:SecurityTokenReference>
</KeyInfo>
</Signature>
</o:Security>
</s:Header>
<s:Body/>
</s:Envelope>
```
### Dependencias Node.js
```json
{
"@nodecfdi/credentials": "^2.0",
"@nodecfdi/cfdi-core": "^0.5",
"node-forge": "^1.3",
"fast-xml-parser": "^4.0",
"adm-zip": "^0.5",
"node-cron": "^3.0"
}
```
### Códigos de Error del SAT
| Código | Significado | Acción |
|--------|-------------|--------|
| 5000 | Solicitud recibida | Continuar con verificación |
| 5002 | Se agotó límite de solicitudes | Esperar 24 horas |
| 5004 | No se encontraron CFDIs | Registrar, no es error |
| 5005 | Solicitud duplicada | Usar IdSolicitud existente |
| 404 | Paquete no encontrado | Reintentar en 1 minuto |
| 500 | Error interno SAT | Reintentar con backoff |
### Estrategia de Extracción Inicial (10 años)
- Dividir en solicitudes mensuales (~121 solicitudes)
- Procesar 3-4 meses por día para no saturar
- Guardar progreso en sat_sync_jobs
- Si falla, continuar desde último mes exitoso
### Tiempos Estimados
| Operación | Tiempo |
|-----------|--------|
| Autenticación | 1-2 segundos |
| Solicitud aceptada | 1-2 segundos |
| Verificación (paquete listo) | 1-30 minutos |
| Descarga 10,000 CFDIs | 30-60 segundos |
| Procesamiento 10,000 XMLs | 2-5 minutos |
---
## Modelo de Datos
### Nuevas Tablas (schema public)
```sql
-- Credenciales FIEL por tenant (encriptadas)
CREATE TABLE fiel_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
rfc VARCHAR(13) NOT NULL,
cer_data BYTEA NOT NULL,
key_data BYTEA NOT NULL,
key_password_encrypted BYTEA NOT NULL,
serial_number VARCHAR(50),
valid_from TIMESTAMP NOT NULL,
valid_until TIMESTAMP NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(tenant_id)
);
-- Jobs de sincronización
CREATE TABLE sat_sync_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
date_from DATE NOT NULL,
date_to DATE NOT NULL,
cfdi_type VARCHAR(10),
sat_request_id VARCHAR(50),
sat_package_ids TEXT[],
cfdis_found INTEGER DEFAULT 0,
cfdis_downloaded INTEGER DEFAULT 0,
cfdis_inserted INTEGER DEFAULT 0,
cfdis_updated INTEGER DEFAULT 0,
progress_percent INTEGER DEFAULT 0,
error_message TEXT,
started_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
retry_count INTEGER DEFAULT 0,
next_retry_at TIMESTAMP
);
CREATE INDEX idx_sat_sync_jobs_tenant ON sat_sync_jobs(tenant_id);
CREATE INDEX idx_sat_sync_jobs_status ON sat_sync_jobs(status);
```
### Modificaciones a tabla cfdis
```sql
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS source VARCHAR(20) DEFAULT 'manual';
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS sat_sync_job_id UUID;
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS xml_original TEXT;
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS last_sat_sync TIMESTAMP;
```
---
## Estructura de Archivos
```
apps/api/src/
├── services/
│ ├── sat/
│ │ ├── sat.service.ts
│ │ ├── sat-auth.service.ts
│ │ ├── sat-download.service.ts
│ │ ├── sat-parser.service.ts
│ │ └── sat-crypto.service.ts
│ └── fiel.service.ts
├── controllers/
│ ├── sat.controller.ts
│ └── fiel.controller.ts
├── routes/
│ ├── sat.routes.ts
│ └── fiel.routes.ts
└── jobs/
└── sat-sync.job.ts
```
---
## API Endpoints
```
POST /api/fiel/upload # Subir .cer, .key y contraseña
GET /api/fiel/status # Estado de FIEL configurada
DELETE /api/fiel # Eliminar credenciales
POST /api/sat/sync # Sincronización manual
GET /api/sat/sync/status # Estado actual
GET /api/sat/sync/history # Historial
GET /api/sat/sync/:id # Detalle de job
POST /api/sat/sync/:id/retry # Reintentar job fallido
```
---
## Interfaz de Usuario
### Sección en Configuración
- Estado de FIEL (configurada/no configurada, vigencia)
- Botones: Actualizar FIEL, Eliminar
- Sincronización automática (frecuencia, última sync, total CFDIs)
- Botón: Sincronizar Ahora
- Historial de sincronizaciones (tabla)
### Modal de Carga FIEL
- Input para archivo .cer
- Input para archivo .key
- Input para contraseña
- Mensaje de seguridad
- Botones: Cancelar, Guardar y Validar
---
## Notificaciones
| Evento | Mensaje |
|--------|---------|
| Sync completada | "Se descargaron X CFDIs del SAT" |
| Sync fallida | "Error al sincronizar: [mensaje]" |
| FIEL por vencer (30 días) | "Tu e.firma vence el DD/MMM/YYYY" |
| FIEL vencida | "Tu e.firma ha vencido" |
---
## Seguridad
- Solo rol `admin` puede gestionar FIEL
- Credenciales nunca se devuelven en API
- Logs de auditoría para accesos
- Rate limiting en endpoints de sincronización
- Encriptación AES-256-GCM para credenciales

View File

@@ -0,0 +1,228 @@
# Plan de Implementación: Sincronización SAT
## Fase 1: Base de Datos y Modelos
### 1.1 Migraciones Prisma
- [ ] Agregar modelo `FielCredential` en schema.prisma
- [ ] Agregar modelo `SatSyncJob` en schema.prisma
- [ ] Agregar campos a modelo `Cfdi`: source, sat_sync_job_id, xml_original, last_sat_sync
- [ ] Ejecutar migración
### 1.2 Tipos TypeScript
- [ ] Crear `packages/shared/src/types/sat.ts` con interfaces
- [ ] Exportar tipos en index.ts
## Fase 2: Servicios de Criptografía y FIEL
### 2.1 Servicio de Criptografía
- [ ] Crear `apps/api/src/services/sat/sat-crypto.service.ts`
- [ ] Implementar encrypt() con AES-256-GCM
- [ ] Implementar decrypt()
- [ ] Tests unitarios
### 2.2 Servicio de FIEL
- [ ] Crear `apps/api/src/services/fiel.service.ts`
- [ ] uploadFiel() - validar y guardar credenciales encriptadas
- [ ] getFielStatus() - obtener estado sin exponer datos sensibles
- [ ] deleteFiel() - eliminar credenciales
- [ ] validateFiel() - verificar que .cer y .key coincidan
- [ ] isExpired() - verificar vigencia
### 2.3 Dependencias
- [ ] Instalar @nodecfdi/credentials
- [ ] Instalar node-forge
## Fase 3: Servicios de Comunicación SAT
### 3.1 Servicio de Autenticación SAT
- [ ] Crear `apps/api/src/services/sat/sat-auth.service.ts`
- [ ] buildAuthSoapEnvelope() - construir XML de autenticación
- [ ] signWithFiel() - firmar con llave privada
- [ ] getToken() - obtener token SAML del SAT
- [ ] Manejo de errores y reintentos
### 3.2 Servicio de Descarga SAT
- [ ] Crear `apps/api/src/services/sat/sat-download.service.ts`
- [ ] requestDownload() - solicitar descarga de CFDIs
- [ ] verifyRequest() - verificar estado de solicitud
- [ ] downloadPackage() - descargar paquete ZIP
- [ ] Polling con backoff exponencial
### 3.3 Dependencias
- [ ] Instalar fast-xml-parser
- [ ] Instalar adm-zip
## Fase 4: Procesamiento de CFDIs
### 4.1 Servicio de Parser
- [ ] Crear `apps/api/src/services/sat/sat-parser.service.ts`
- [ ] extractZip() - extraer XMLs del ZIP
- [ ] parseXml() - parsear XML a objeto
- [ ] mapToDbModel() - mapear a modelo de BD
### 4.2 Dependencias
- [ ] Instalar @nodecfdi/cfdi-core
## Fase 5: Orquestador Principal
### 5.1 Servicio Principal SAT
- [ ] Crear `apps/api/src/services/sat/sat.service.ts`
- [ ] startSync() - iniciar sincronización
- [ ] processInitialSync() - extracción de 10 años
- [ ] processDailySync() - extracción mensual
- [ ] saveProgress() - guardar progreso en sat_sync_jobs
- [ ] handleError() - manejo de errores y reintentos
## Fase 6: Job Programado
### 6.1 Cron Job
- [ ] Crear `apps/api/src/jobs/sat-sync.job.ts`
- [ ] Configurar ejecución a las 3:00 AM
- [ ] Obtener tenants con FIEL activa
- [ ] Ejecutar sync para cada tenant
- [ ] Logging y monitoreo
### 6.2 Dependencias
- [ ] Instalar node-cron
## Fase 7: API Endpoints
### 7.1 Controlador FIEL
- [ ] Crear `apps/api/src/controllers/fiel.controller.ts`
- [ ] POST /upload - subir credenciales
- [ ] GET /status - obtener estado
- [ ] DELETE / - eliminar credenciales
### 7.2 Controlador SAT
- [ ] Crear `apps/api/src/controllers/sat.controller.ts`
- [ ] POST /sync - iniciar sincronización manual
- [ ] GET /sync/status - estado actual
- [ ] GET /sync/history - historial
- [ ] GET /sync/:id - detalle de job
- [ ] POST /sync/:id/retry - reintentar
### 7.3 Rutas
- [ ] Crear `apps/api/src/routes/fiel.routes.ts`
- [ ] Crear `apps/api/src/routes/sat.routes.ts`
- [ ] Registrar en app.ts
## Fase 8: Frontend
### 8.1 Componentes
- [ ] Crear `apps/web/components/sat/FielUploadModal.tsx`
- [ ] Crear `apps/web/components/sat/SyncStatus.tsx`
- [ ] Crear `apps/web/components/sat/SyncHistory.tsx`
### 8.2 Página de Configuración
- [ ] Crear `apps/web/app/(dashboard)/configuracion/sat/page.tsx`
- [ ] Integrar componentes
- [ ] Conectar con API
### 8.3 API Client
- [ ] Agregar métodos en `apps/web/lib/api.ts`
- [ ] uploadFiel()
- [ ] getFielStatus()
- [ ] deleteFiel()
- [ ] startSync()
- [ ] getSyncStatus()
- [ ] getSyncHistory()
## Fase 9: Testing y Validación
### 9.1 Tests
- [ ] Tests unitarios para servicios de criptografía
- [ ] Tests unitarios para parser de XML
- [ ] Tests de integración para flujo completo
- [ ] Test con FIEL de prueba del SAT
### 9.2 Validación
- [ ] Probar carga de FIEL
- [ ] Probar sincronización manual
- [ ] Probar job programado
- [ ] Verificar CFDIs descargados
## Orden de Implementación
```
Fase 1 (BD)
Fase 2 (Crypto + FIEL)
Fase 3 (Auth + Download SAT)
Fase 4 (Parser)
Fase 5 (Orquestador)
Fase 6 (Cron Job)
Fase 7 (API)
Fase 8 (Frontend)
Fase 9 (Testing)
```
## Archivos a Crear/Modificar
### Nuevos Archivos (16)
```
apps/api/src/services/sat/sat-crypto.service.ts
apps/api/src/services/sat/sat-auth.service.ts
apps/api/src/services/sat/sat-download.service.ts
apps/api/src/services/sat/sat-parser.service.ts
apps/api/src/services/sat/sat.service.ts
apps/api/src/services/fiel.service.ts
apps/api/src/controllers/fiel.controller.ts
apps/api/src/controllers/sat.controller.ts
apps/api/src/routes/fiel.routes.ts
apps/api/src/routes/sat.routes.ts
apps/api/src/jobs/sat-sync.job.ts
packages/shared/src/types/sat.ts
apps/web/components/sat/FielUploadModal.tsx
apps/web/components/sat/SyncStatus.tsx
apps/web/components/sat/SyncHistory.tsx
apps/web/app/(dashboard)/configuracion/sat/page.tsx
```
### Archivos a Modificar (5)
```
apps/api/prisma/schema.prisma
apps/api/src/app.ts
apps/api/src/index.ts
packages/shared/src/index.ts
apps/web/lib/api.ts
```
## Dependencias a Instalar
```bash
# En apps/api
pnpm add @nodecfdi/credentials @nodecfdi/cfdi-core node-forge fast-xml-parser adm-zip node-cron
# Tipos
pnpm add -D @types/node-forge @types/node-cron
```
## Estimación por Fase
| Fase | Descripción | Complejidad |
|------|-------------|-------------|
| 1 | Base de datos | Baja |
| 2 | Crypto + FIEL | Media |
| 3 | Comunicación SAT | Alta |
| 4 | Parser | Media |
| 5 | Orquestador | Alta |
| 6 | Cron Job | Baja |
| 7 | API | Media |
| 8 | Frontend | Media |
| 9 | Testing | Media |

View File

@@ -8,6 +8,7 @@ export * from './types/impuestos';
export * from './types/alertas'; export * from './types/alertas';
export * from './types/reportes'; export * from './types/reportes';
export * from './types/calendario'; export * from './types/calendario';
export * from './types/sat';
// Constants // Constants
export * from './constants/plans'; export * from './constants/plans';

View File

@@ -0,0 +1,132 @@
// ============================================
// FIEL (e.firma) Types
// ============================================
export interface FielUploadRequest {
cerFile: string; // Base64
keyFile: string; // Base64
password: string;
}
export interface FielStatus {
configured: boolean;
rfc?: string;
serialNumber?: string;
validFrom?: string;
validUntil?: string;
isExpired?: boolean;
daysUntilExpiration?: number;
}
// ============================================
// SAT Sync Types
// ============================================
export type SatSyncType = 'initial' | 'daily';
export type SatSyncStatus = 'pending' | 'running' | 'completed' | 'failed';
export type CfdiSyncType = 'emitidos' | 'recibidos';
export interface SatSyncJob {
id: string;
tenantId: string;
type: SatSyncType;
status: SatSyncStatus;
dateFrom: string;
dateTo: string;
cfdiType?: CfdiSyncType;
satRequestId?: string;
satPackageIds: string[];
cfdisFound: number;
cfdisDownloaded: number;
cfdisInserted: number;
cfdisUpdated: number;
progressPercent: number;
errorMessage?: string;
startedAt?: string;
completedAt?: string;
createdAt: string;
retryCount: number;
}
export interface SatSyncStatusResponse {
hasActiveSync: boolean;
currentJob?: SatSyncJob;
lastCompletedJob?: SatSyncJob;
totalCfdisSynced: number;
}
export interface SatSyncHistoryResponse {
jobs: SatSyncJob[];
total: number;
page: number;
limit: number;
}
export interface StartSyncRequest {
type?: SatSyncType;
dateFrom?: string;
dateTo?: string;
}
export interface StartSyncResponse {
jobId: string;
message: string;
}
// ============================================
// SAT Web Service Types
// ============================================
export interface SatAuthResponse {
token: string;
expiresAt: Date;
}
export interface SatDownloadRequest {
rfcSolicitante: string;
fechaInicio: Date;
fechaFin: Date;
tipoSolicitud: 'CFDI' | 'Metadata';
tipoComprobante?: 'I' | 'E' | 'T' | 'N' | 'P';
rfcEmisor?: string;
rfcReceptor?: string;
}
export interface SatDownloadRequestResponse {
idSolicitud: string;
codEstatus: string;
mensaje: string;
}
export interface SatVerifyResponse {
codEstatus: string;
estadoSolicitud: number; // 1=Aceptada, 2=EnProceso, 3=Terminada, 4=Error, 5=Rechazada, 6=Vencida
codigoEstadoSolicitud: string;
numeroCfdis: number;
mensaje: string;
paquetes: string[];
}
export interface SatPackageResponse {
paquete: string; // Base64 ZIP
}
// ============================================
// SAT Error Codes
// ============================================
export const SAT_STATUS_CODES: Record<string, string> = {
'5000': 'Solicitud recibida con éxito',
'5002': 'Se agotó el límite de solicitudes',
'5004': 'No se encontraron CFDIs',
'5005': 'Solicitud duplicada',
};
export const SAT_REQUEST_STATUS: Record<number, string> = {
1: 'Aceptada',
2: 'En proceso',
3: 'Terminada',
4: 'Error',
5: 'Rechazada',
6: 'Vencida',
};

View File

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

165
pnpm-lock.yaml generated
View File

@@ -20,9 +20,21 @@ importers:
'@horux/shared': '@horux/shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared 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': '@prisma/client':
specifier: ^5.22.0 specifier: ^5.22.0
version: 5.22.0(prisma@5.22.0) version: 5.22.0(prisma@5.22.0)
adm-zip:
specifier: ^0.5.16
version: 0.5.16
bcryptjs: bcryptjs:
specifier: ^2.4.3 specifier: ^2.4.3
version: 2.4.3 version: 2.4.3
@@ -38,16 +50,28 @@ importers:
express: express:
specifier: ^4.21.0 specifier: ^4.21.0
version: 4.22.1 version: 4.22.1
fast-xml-parser:
specifier: ^5.3.3
version: 5.3.3
helmet: helmet:
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.1.0 version: 8.1.0
jsonwebtoken: jsonwebtoken:
specifier: ^9.0.2 specifier: ^9.0.2
version: 9.0.3 version: 9.0.3
node-cron:
specifier: ^4.2.1
version: 4.2.1
node-forge:
specifier: ^1.3.3
version: 1.3.3
zod: zod:
specifier: ^3.23.0 specifier: ^3.23.0
version: 3.25.76 version: 3.25.76
devDependencies: devDependencies:
'@types/adm-zip':
specifier: ^0.5.7
version: 0.5.7
'@types/bcryptjs': '@types/bcryptjs':
specifier: ^2.4.6 specifier: ^2.4.6
version: 2.4.6 version: 2.4.6
@@ -63,6 +87,12 @@ importers:
'@types/node': '@types/node':
specifier: ^22.0.0 specifier: ^22.0.0
version: 22.19.7 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
prisma: prisma:
specifier: ^5.22.0 specifier: ^5.22.0
version: 5.22.0 version: 5.22.0
@@ -439,6 +469,41 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@nodecfdi/base-converter@1.0.7':
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'}
peerDependencies:
'@types/luxon': 3.4.2
luxon: ^3.5.0
peerDependenciesMeta:
'@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': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -935,6 +1000,9 @@ packages:
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'} engines: {node: '>=12'}
'@types/adm-zip@0.5.7':
resolution: {integrity: sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==}
'@types/bcryptjs@2.4.6': '@types/bcryptjs@2.4.6':
resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==} resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==}
@@ -989,6 +1057,12 @@ packages:
'@types/ms@2.1.0': '@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} 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==}
'@types/node@14.18.63': '@types/node@14.18.63':
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
@@ -1018,10 +1092,22 @@ packages:
'@types/serve-static@2.2.0': '@types/serve-static@2.2.0':
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
'@vilic/node-forge@1.3.2-5':
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: accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
adm-zip@0.5.16:
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
engines: {node: '>=12.0'}
any-promise@1.3.0: any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
@@ -1394,6 +1480,10 @@ packages:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'} 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: fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -1663,6 +1753,10 @@ packages:
peerDependencies: peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
math-intrinsics@1.1.0: math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1749,6 +1843,14 @@ packages:
sass: sass:
optional: true 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'}
node-releases@2.0.27: node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
@@ -2074,6 +2176,9 @@ packages:
string_decoder@1.3.0: string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} 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: styled-jsx@5.1.1:
resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -2140,6 +2245,9 @@ packages:
ts-interface-checker@0.1.13: ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
ts-mixer@6.0.4:
resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -2444,6 +2552,33 @@ snapshots:
'@next/swc-win32-x64-msvc@14.2.33': '@next/swc-win32-x64-msvc@14.2.33':
optional: true optional: true
'@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
'@vilic/node-forge': 1.3.2-5
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': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -2927,6 +3062,10 @@ snapshots:
'@tanstack/table-core@8.21.3': {} '@tanstack/table-core@8.21.3': {}
'@types/adm-zip@0.5.7':
dependencies:
'@types/node': 22.19.7
'@types/bcryptjs@2.4.6': {} '@types/bcryptjs@2.4.6': {}
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
@@ -2988,6 +3127,12 @@ snapshots:
'@types/ms@2.1.0': {} '@types/ms@2.1.0': {}
'@types/node-cron@3.0.11': {}
'@types/node-forge@1.3.14':
dependencies:
'@types/node': 22.19.7
'@types/node@14.18.63': {} '@types/node@14.18.63': {}
'@types/node@22.19.7': '@types/node@22.19.7':
@@ -3018,11 +3163,17 @@ snapshots:
'@types/http-errors': 2.0.5 '@types/http-errors': 2.0.5
'@types/node': 22.19.7 '@types/node': 22.19.7
'@vilic/node-forge@1.3.2-5': {}
'@xmldom/xmldom@0.9.8': {}
accepts@1.3.8: accepts@1.3.8:
dependencies: dependencies:
mime-types: 2.1.35 mime-types: 2.1.35
negotiator: 0.6.3 negotiator: 0.6.3
adm-zip@0.5.16: {}
any-promise@1.3.0: {} any-promise@1.3.0: {}
anymatch@3.1.3: anymatch@3.1.3:
@@ -3464,6 +3615,10 @@ snapshots:
merge2: 1.4.1 merge2: 1.4.1
micromatch: 4.0.8 micromatch: 4.0.8
fast-xml-parser@5.3.3:
dependencies:
strnum: 2.1.2
fastq@1.20.1: fastq@1.20.1:
dependencies: dependencies:
reusify: 1.1.0 reusify: 1.1.0
@@ -3717,6 +3872,8 @@ snapshots:
dependencies: dependencies:
react: 18.3.1 react: 18.3.1
luxon@3.7.2: {}
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
media-typer@0.3.0: {} media-typer@0.3.0: {}
@@ -3793,6 +3950,10 @@ snapshots:
- '@babel/core' - '@babel/core'
- babel-plugin-macros - babel-plugin-macros
node-cron@4.2.1: {}
node-forge@1.3.3: {}
node-releases@2.0.27: {} node-releases@2.0.27: {}
normalize-path@3.0.0: {} normalize-path@3.0.0: {}
@@ -4125,6 +4286,8 @@ snapshots:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
strnum@2.1.2: {}
styled-jsx@5.1.1(react@18.3.1): styled-jsx@5.1.1(react@18.3.1):
dependencies: dependencies:
client-only: 0.0.1 client-only: 0.0.1
@@ -4207,6 +4370,8 @@ snapshots:
ts-interface-checker@0.1.13: {} ts-interface-checker@0.1.13: {}
ts-mixer@6.0.4: {}
tslib@2.8.1: {} tslib@2.8.1: {}
tsx@4.21.0: tsx@4.21.0: