Compare commits
23 Commits
4fd6f01303
...
DevMarlene
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07fc9a8fe3 | ||
|
|
492cd62772 | ||
|
|
008f586b54 | ||
|
|
38466a2b23 | ||
|
|
98d704a549 | ||
|
|
c52548a2bb | ||
|
|
121fe731d0 | ||
|
|
02ccfb41a0 | ||
|
|
75a9819c1e | ||
|
|
2dd22ec152 | ||
|
|
69efb585d3 | ||
|
|
2655a51a99 | ||
|
|
31c66f2823 | ||
|
|
e50e7100f1 | ||
|
|
0a65c60570 | ||
|
|
473912bfd7 | ||
|
|
09684f77b9 | ||
|
|
56e6e27ab3 | ||
|
|
a64aa11548 | ||
|
|
787aac9a4c | ||
|
|
3763014eca | ||
|
|
b49902bcff | ||
|
|
519de61c6f |
@@ -15,22 +15,32 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@horux/shared": "workspace:*",
|
||||
"@nodecfdi/cfdi-core": "^1.0.1",
|
||||
"@nodecfdi/credentials": "^3.2.0",
|
||||
"@nodecfdi/sat-ws-descarga-masiva": "^2.0.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.21.0",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"helmet": "^8.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-forge": "^1.3.3",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"prisma": "^5.22.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.3.0"
|
||||
|
||||
@@ -20,6 +20,8 @@ model Tenant {
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
|
||||
users User[]
|
||||
fielCredential FielCredential?
|
||||
satSyncJobs SatSyncJob[]
|
||||
|
||||
@@map("tenants")
|
||||
}
|
||||
@@ -62,3 +64,75 @@ enum Role {
|
||||
contador
|
||||
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
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import { calendarioRoutes } from './routes/calendario.routes.js';
|
||||
import { reportesRoutes } from './routes/reportes.routes.js';
|
||||
import { usuariosRoutes } from './routes/usuarios.routes.js';
|
||||
import { tenantsRoutes } from './routes/tenants.routes.js';
|
||||
import fielRoutes from './routes/fiel.routes.js';
|
||||
import satRoutes from './routes/sat.routes.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -43,6 +45,8 @@ app.use('/api/calendario', calendarioRoutes);
|
||||
app.use('/api/reportes', reportesRoutes);
|
||||
app.use('/api/usuarios', usuariosRoutes);
|
||||
app.use('/api/tenants', tenantsRoutes);
|
||||
app.use('/api/fiel', fielRoutes);
|
||||
app.use('/api/sat', satRoutes);
|
||||
|
||||
// Error handling
|
||||
app.use(errorMiddleware);
|
||||
|
||||
68
apps/api/src/controllers/fiel.controller.ts
Normal file
68
apps/api/src/controllers/fiel.controller.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
151
apps/api/src/controllers/sat.controller.ts
Normal file
151
apps/api/src/controllers/sat.controller.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import * as usuariosService from '../services/usuarios.service.js';
|
||||
import { AppError } from '../utils/errors.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
// RFC del tenant administrador global
|
||||
const ADMIN_TENANT_RFC = 'CAS2408138W2';
|
||||
|
||||
async function isGlobalAdmin(req: Request): Promise<boolean> {
|
||||
if (req.user!.role !== 'admin') return false;
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: req.user!.tenantId },
|
||||
select: { rfc: true },
|
||||
});
|
||||
|
||||
return tenant?.rfc === ADMIN_TENANT_RFC;
|
||||
}
|
||||
|
||||
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
@@ -11,6 +26,21 @@ export async function getUsuarios(req: Request, res: Response, next: NextFunctio
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene todos los usuarios de todas las empresas (solo admin global)
|
||||
*/
|
||||
export async function getAllUsuarios(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await isGlobalAdmin(req))) {
|
||||
throw new AppError(403, 'Solo el administrador global puede ver todos los usuarios');
|
||||
}
|
||||
const usuarios = await usuariosService.getAllUsuarios();
|
||||
res.json(usuarios);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'admin') {
|
||||
@@ -28,7 +58,8 @@ export async function updateUsuario(req: Request, res: Response, next: NextFunct
|
||||
if (req.user!.role !== 'admin') {
|
||||
throw new AppError(403, 'Solo administradores pueden modificar usuarios');
|
||||
}
|
||||
const usuario = await usuariosService.updateUsuario(req.user!.tenantId, req.params.id, req.body);
|
||||
const userId = req.params.id as string;
|
||||
const usuario = await usuariosService.updateUsuario(req.user!.tenantId, userId, req.body);
|
||||
res.json(usuario);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -40,10 +71,49 @@ export async function deleteUsuario(req: Request, res: Response, next: NextFunct
|
||||
if (req.user!.role !== 'admin') {
|
||||
throw new AppError(403, 'Solo administradores pueden eliminar usuarios');
|
||||
}
|
||||
if (req.params.id === req.user!.id) {
|
||||
const userId = req.params.id as string;
|
||||
if (userId === req.user!.userId) {
|
||||
throw new AppError(400, 'No puedes eliminar tu propia cuenta');
|
||||
}
|
||||
await usuariosService.deleteUsuario(req.user!.tenantId, req.params.id);
|
||||
await usuariosService.deleteUsuario(req.user!.tenantId, userId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza un usuario globalmente (puede cambiar de empresa)
|
||||
*/
|
||||
export async function updateUsuarioGlobal(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await isGlobalAdmin(req))) {
|
||||
throw new AppError(403, 'Solo el administrador global puede modificar usuarios globalmente');
|
||||
}
|
||||
const userId = req.params.id as string;
|
||||
if (userId === req.user!.userId && req.body.tenantId) {
|
||||
throw new AppError(400, 'No puedes cambiar tu propia empresa');
|
||||
}
|
||||
const usuario = await usuariosService.updateUsuarioGlobal(userId, req.body);
|
||||
res.json(usuario);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina un usuario globalmente
|
||||
*/
|
||||
export async function deleteUsuarioGlobal(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await isGlobalAdmin(req))) {
|
||||
throw new AppError(403, 'Solo el administrador global puede eliminar usuarios globalmente');
|
||||
}
|
||||
const userId = req.params.id as string;
|
||||
if (userId === req.user!.userId) {
|
||||
throw new AppError(400, 'No puedes eliminar tu propia cuenta');
|
||||
}
|
||||
await usuariosService.deleteUsuarioGlobal(userId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { app } from './app.js';
|
||||
import { env } from './config/env.js';
|
||||
import { startSatSyncJob } from './jobs/sat-sync.job.js';
|
||||
|
||||
const PORT = parseInt(env.PORT, 10);
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🚀 API Server running on http://0.0.0.0:${PORT}`);
|
||||
console.log(`📊 Environment: ${env.NODE_ENV}`);
|
||||
console.log(`API Server running on http://0.0.0.0:${PORT}`);
|
||||
console.log(`Environment: ${env.NODE_ENV}`);
|
||||
|
||||
// Iniciar job de sincronización SAT
|
||||
if (env.NODE_ENV === 'production') {
|
||||
startSatSyncJob();
|
||||
}
|
||||
});
|
||||
|
||||
162
apps/api/src/jobs/sat-sync.job.ts
Normal file
162
apps/api/src/jobs/sat-sync.job.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
19
apps/api/src/routes/fiel.routes.ts
Normal file
19
apps/api/src/routes/fiel.routes.ts
Normal 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;
|
||||
31
apps/api/src/routes/sat.routes.ts
Normal file
31
apps/api/src/routes/sat.routes.ts
Normal 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;
|
||||
@@ -6,9 +6,15 @@ const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
// Rutas por tenant
|
||||
router.get('/', usuariosController.getUsuarios);
|
||||
router.post('/invite', usuariosController.inviteUsuario);
|
||||
router.patch('/:id', usuariosController.updateUsuario);
|
||||
router.delete('/:id', usuariosController.deleteUsuario);
|
||||
|
||||
// Rutas globales (solo admin global)
|
||||
router.get('/global/all', usuariosController.getAllUsuarios);
|
||||
router.patch('/global/:id', usuariosController.updateUsuarioGlobal);
|
||||
router.delete('/global/:id', usuariosController.deleteUsuarioGlobal);
|
||||
|
||||
export { router as usuariosRoutes };
|
||||
|
||||
228
apps/api/src/services/fiel.service.ts
Normal file
228
apps/api/src/services/fiel.service.ts
Normal 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;
|
||||
}
|
||||
160
apps/api/src/services/sat/sat-auth.service.ts
Normal file
160
apps/api/src/services/sat/sat-auth.service.ts
Normal 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;
|
||||
}
|
||||
210
apps/api/src/services/sat/sat-client.service.ts
Normal file
210
apps/api/src/services/sat/sat-client.service.ts
Normal 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())}`;
|
||||
}
|
||||
122
apps/api/src/services/sat/sat-crypto.service.ts
Normal file
122
apps/api/src/services/sat/sat-crypto.service.ts
Normal 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 };
|
||||
}
|
||||
408
apps/api/src/services/sat/sat-download.service.ts
Normal file
408
apps/api/src/services/sat/sat-download.service.ts
Normal 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
|
||||
);
|
||||
}
|
||||
244
apps/api/src/services/sat/sat-parser.service.ts
Normal file
244
apps/api/src/services/sat/sat-parser.service.ts
Normal 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 };
|
||||
600
apps/api/src/services/sat/sat.service.ts
Normal file
600
apps/api/src/services/sat/sat.service.ts
Normal 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);
|
||||
}
|
||||
@@ -105,3 +105,93 @@ export async function deleteUsuario(tenantId: string, userId: string): Promise<v
|
||||
where: { id: userId, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene todos los usuarios de todas las empresas (solo admin global)
|
||||
*/
|
||||
export async function getAllUsuarios(): Promise<UserListItem[]> {
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
nombre: true,
|
||||
role: true,
|
||||
active: true,
|
||||
lastLogin: true,
|
||||
createdAt: true,
|
||||
tenantId: true,
|
||||
tenant: {
|
||||
select: {
|
||||
nombre: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ tenant: { nombre: 'asc' } }, { createdAt: 'desc' }],
|
||||
});
|
||||
|
||||
return users.map(u => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
nombre: u.nombre,
|
||||
role: u.role,
|
||||
active: u.active,
|
||||
lastLogin: u.lastLogin?.toISOString() || null,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
tenantId: u.tenantId,
|
||||
tenantName: u.tenant.nombre,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza un usuario globalmente (puede cambiar de tenant)
|
||||
*/
|
||||
export async function updateUsuarioGlobal(
|
||||
userId: string,
|
||||
data: UserUpdate & { tenantId?: string }
|
||||
): Promise<UserListItem> {
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...(data.nombre && { nombre: data.nombre }),
|
||||
...(data.role && { role: data.role }),
|
||||
...(data.active !== undefined && { active: data.active }),
|
||||
...(data.tenantId && { tenantId: data.tenantId }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
nombre: true,
|
||||
role: true,
|
||||
active: true,
|
||||
lastLogin: true,
|
||||
createdAt: true,
|
||||
tenantId: true,
|
||||
tenant: {
|
||||
select: {
|
||||
nombre: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nombre: user.nombre,
|
||||
role: user.role,
|
||||
active: user.active,
|
||||
lastLogin: user.lastLogin?.toISOString() || null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
tenantId: user.tenantId,
|
||||
tenantName: user.tenant.nombre,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina un usuario globalmente
|
||||
*/
|
||||
export async function deleteUsuarioGlobal(userId: string): Promise<void> {
|
||||
await prisma.user.delete({
|
||||
where: { id: userId },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,7 +30,11 @@ export default function LoginPage() {
|
||||
const response = await login({ email, password });
|
||||
setTokens(response.accessToken, response.refreshToken);
|
||||
setUser(response.user);
|
||||
router.push('/dashboard');
|
||||
|
||||
const STORAGE_KEY = 'horux360:onboarding_seen_v1';
|
||||
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
|
||||
|
||||
router.push(seen ? '/dashboard' : '/onboarding');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Error al iniciar sesión');
|
||||
} finally {
|
||||
|
||||
305
apps/web/app/(dashboard)/admin/usuarios/page.tsx
Normal file
305
apps/web/app/(dashboard)/admin/usuarios/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { themes, type ThemeName } from '@/themes';
|
||||
import { Check, Palette, User, Building, Sidebar, PanelTop, Minimize2, Sparkles } from 'lucide-react';
|
||||
import { Check, Palette, User, Building, Sidebar, PanelTop, Minimize2, Sparkles, RefreshCw } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
const themeOptions: { name: ThemeName; label: string; description: string; layoutDesc: string; layoutIcon: typeof Sidebar }[] = [
|
||||
{
|
||||
@@ -90,6 +91,26 @@ export default function ConfiguracionPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SAT Configuration */}
|
||||
<Link href="/configuracion/sat">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Sincronizacion SAT
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configura tu FIEL y la sincronizacion automatica de CFDIs con el SAT
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Descarga automaticamente tus facturas emitidas y recibidas directamente del portal del SAT.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Theme Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
156
apps/web/app/(dashboard)/configuracion/sat/page.tsx
Normal file
156
apps/web/app/(dashboard)/configuracion/sat/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/web/app/onboarding/page.tsx
Normal file
5
apps/web/app/onboarding/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import OnboardingScreen from "../../components/onboarding/OnboardingScreen";
|
||||
|
||||
export default function Page() {
|
||||
return <OnboardingScreen />;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Bell,
|
||||
Users,
|
||||
Building2,
|
||||
UserCog,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { logout } from '@/lib/api/auth';
|
||||
@@ -33,6 +34,7 @@ const navigation = [
|
||||
|
||||
const adminNavigation = [
|
||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
|
||||
170
apps/web/components/onboarding/OnboardingScreen.tsx
Normal file
170
apps/web/components/onboarding/OnboardingScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
apps/web/components/sat/FielUploadModal.tsx
Normal file
138
apps/web/components/sat/FielUploadModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
apps/web/components/sat/SyncHistory.tsx
Normal file
182
apps/web/components/sat/SyncHistory.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
apps/web/components/sat/SyncStatus.tsx
Normal file
187
apps/web/components/sat/SyncStatus.tsx
Normal 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
16
apps/web/lib/api/fiel.ts
Normal 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
45
apps/web/lib/api/sat.ts
Normal 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;
|
||||
}
|
||||
@@ -19,3 +19,18 @@ export async function updateUsuario(id: string, data: UserUpdate): Promise<UserL
|
||||
export async function deleteUsuario(id: string): Promise<void> {
|
||||
await apiClient.delete(`/usuarios/${id}`);
|
||||
}
|
||||
|
||||
// Funciones globales (admin global)
|
||||
export async function getAllUsuarios(): Promise<UserListItem[]> {
|
||||
const response = await apiClient.get<UserListItem[]>('/usuarios/global/all');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updateUsuarioGlobal(id: string, data: UserUpdate): Promise<UserListItem> {
|
||||
const response = await apiClient.patch<UserListItem>(`/usuarios/global/${id}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteUsuarioGlobal(id: string): Promise<void> {
|
||||
await apiClient.delete(`/usuarios/global/${id}`);
|
||||
}
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
17
deploy/systemd/horux-api.service
Normal file
17
deploy/systemd/horux-api.service
Normal 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
|
||||
17
deploy/systemd/horux-web.service
Normal file
17
deploy/systemd/horux-web.service
Normal 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
|
||||
327
docs/plans/2026-01-25-sat-sync-design.md
Normal file
327
docs/plans/2026-01-25-sat-sync-design.md
Normal 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
|
||||
|
||||
228
docs/plans/2026-01-25-sat-sync-implementation.md
Normal file
228
docs/plans/2026-01-25-sat-sync-implementation.md
Normal 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 |
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from './types/impuestos';
|
||||
export * from './types/alertas';
|
||||
export * from './types/reportes';
|
||||
export * from './types/calendario';
|
||||
export * from './types/sat';
|
||||
|
||||
// Constants
|
||||
export * from './constants/plans';
|
||||
|
||||
132
packages/shared/src/types/sat.ts
Normal file
132
packages/shared/src/types/sat.ts
Normal 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',
|
||||
};
|
||||
@@ -38,12 +38,15 @@ export interface UserListItem {
|
||||
active: boolean;
|
||||
lastLogin: string | null;
|
||||
createdAt: string;
|
||||
tenantId?: string;
|
||||
tenantName?: string;
|
||||
}
|
||||
|
||||
export interface UserUpdate {
|
||||
nombre?: string;
|
||||
role?: 'admin' | 'contador' | 'visor';
|
||||
active?: boolean;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
|
||||
165
pnpm-lock.yaml
generated
165
pnpm-lock.yaml
generated
@@ -20,9 +20,21 @@ importers:
|
||||
'@horux/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
'@nodecfdi/cfdi-core':
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1
|
||||
'@nodecfdi/credentials':
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0(luxon@3.7.2)
|
||||
'@nodecfdi/sat-ws-descarga-masiva':
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)
|
||||
'@prisma/client':
|
||||
specifier: ^5.22.0
|
||||
version: 5.22.0(prisma@5.22.0)
|
||||
adm-zip:
|
||||
specifier: ^0.5.16
|
||||
version: 0.5.16
|
||||
bcryptjs:
|
||||
specifier: ^2.4.3
|
||||
version: 2.4.3
|
||||
@@ -38,16 +50,28 @@ importers:
|
||||
express:
|
||||
specifier: ^4.21.0
|
||||
version: 4.22.1
|
||||
fast-xml-parser:
|
||||
specifier: ^5.3.3
|
||||
version: 5.3.3
|
||||
helmet:
|
||||
specifier: ^8.0.0
|
||||
version: 8.1.0
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.3
|
||||
node-cron:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
node-forge:
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.3
|
||||
zod:
|
||||
specifier: ^3.23.0
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/adm-zip':
|
||||
specifier: ^0.5.7
|
||||
version: 0.5.7
|
||||
'@types/bcryptjs':
|
||||
specifier: ^2.4.6
|
||||
version: 2.4.6
|
||||
@@ -63,6 +87,12 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.7
|
||||
'@types/node-cron':
|
||||
specifier: ^3.0.11
|
||||
version: 3.0.11
|
||||
'@types/node-forge':
|
||||
specifier: ^1.3.14
|
||||
version: 1.3.14
|
||||
prisma:
|
||||
specifier: ^5.22.0
|
||||
version: 5.22.0
|
||||
@@ -439,6 +469,41 @@ packages:
|
||||
cpu: [x64]
|
||||
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':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -935,6 +1000,9 @@ packages:
|
||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@types/adm-zip@0.5.7':
|
||||
resolution: {integrity: sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==}
|
||||
|
||||
'@types/bcryptjs@2.4.6':
|
||||
resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==}
|
||||
|
||||
@@ -989,6 +1057,12 @@ packages:
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/node-cron@3.0.11':
|
||||
resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
|
||||
|
||||
'@types/node-forge@1.3.14':
|
||||
resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==}
|
||||
|
||||
'@types/node@14.18.63':
|
||||
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
|
||||
|
||||
@@ -1018,10 +1092,22 @@ packages:
|
||||
'@types/serve-static@2.2.0':
|
||||
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:
|
||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
adm-zip@0.5.16:
|
||||
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
|
||||
engines: {node: '>=12.0'}
|
||||
|
||||
any-promise@1.3.0:
|
||||
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
||||
|
||||
@@ -1394,6 +1480,10 @@ packages:
|
||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
|
||||
fast-xml-parser@5.3.3:
|
||||
resolution: {integrity: sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==}
|
||||
hasBin: true
|
||||
|
||||
fastq@1.20.1:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
|
||||
@@ -1663,6 +1753,10 @@ packages:
|
||||
peerDependencies:
|
||||
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:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1749,6 +1843,14 @@ packages:
|
||||
sass:
|
||||
optional: true
|
||||
|
||||
node-cron@4.2.1:
|
||||
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
node-forge@1.3.3:
|
||||
resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==}
|
||||
engines: {node: '>= 6.13.0'}
|
||||
|
||||
node-releases@2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
|
||||
@@ -2074,6 +2176,9 @@ packages:
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
strnum@2.1.2:
|
||||
resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==}
|
||||
|
||||
styled-jsx@5.1.1:
|
||||
resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -2140,6 +2245,9 @@ packages:
|
||||
ts-interface-checker@0.1.13:
|
||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||
|
||||
ts-mixer@6.0.4:
|
||||
resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -2444,6 +2552,33 @@ snapshots:
|
||||
'@next/swc-win32-x64-msvc@14.2.33':
|
||||
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':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -2927,6 +3062,10 @@ snapshots:
|
||||
|
||||
'@tanstack/table-core@8.21.3': {}
|
||||
|
||||
'@types/adm-zip@0.5.7':
|
||||
dependencies:
|
||||
'@types/node': 22.19.7
|
||||
|
||||
'@types/bcryptjs@2.4.6': {}
|
||||
|
||||
'@types/body-parser@1.19.6':
|
||||
@@ -2988,6 +3127,12 @@ snapshots:
|
||||
|
||||
'@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@22.19.7':
|
||||
@@ -3018,11 +3163,17 @@ snapshots:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 22.19.7
|
||||
|
||||
'@vilic/node-forge@1.3.2-5': {}
|
||||
|
||||
'@xmldom/xmldom@0.9.8': {}
|
||||
|
||||
accepts@1.3.8:
|
||||
dependencies:
|
||||
mime-types: 2.1.35
|
||||
negotiator: 0.6.3
|
||||
|
||||
adm-zip@0.5.16: {}
|
||||
|
||||
any-promise@1.3.0: {}
|
||||
|
||||
anymatch@3.1.3:
|
||||
@@ -3464,6 +3615,10 @@ snapshots:
|
||||
merge2: 1.4.1
|
||||
micromatch: 4.0.8
|
||||
|
||||
fast-xml-parser@5.3.3:
|
||||
dependencies:
|
||||
strnum: 2.1.2
|
||||
|
||||
fastq@1.20.1:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
@@ -3717,6 +3872,8 @@ snapshots:
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
luxon@3.7.2: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
@@ -3793,6 +3950,10 @@ snapshots:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
node-cron@4.2.1: {}
|
||||
|
||||
node-forge@1.3.3: {}
|
||||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
@@ -4125,6 +4286,8 @@ snapshots:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
strnum@2.1.2: {}
|
||||
|
||||
styled-jsx@5.1.1(react@18.3.1):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
@@ -4207,6 +4370,8 @@ snapshots:
|
||||
|
||||
ts-interface-checker@0.1.13: {}
|
||||
|
||||
ts-mixer@6.0.4: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.21.0:
|
||||
|
||||
Reference in New Issue
Block a user