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": {
|
"dependencies": {
|
||||||
"@horux/shared": "workspace:*",
|
"@horux/shared": "workspace:*",
|
||||||
|
"@nodecfdi/cfdi-core": "^1.0.1",
|
||||||
|
"@nodecfdi/credentials": "^3.2.0",
|
||||||
|
"@nodecfdi/sat-ws-descarga-masiva": "^2.0.0",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"adm-zip": "^0.5.16",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
|
"fast-xml-parser": "^5.3.3",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
|
"node-forge": "^1.3.3",
|
||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/node-forge": "^1.3.14",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.3.0"
|
"typescript": "^5.3.0"
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ model Tenant {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
expiresAt DateTime? @map("expires_at")
|
expiresAt DateTime? @map("expires_at")
|
||||||
|
|
||||||
users User[]
|
users User[]
|
||||||
|
fielCredential FielCredential?
|
||||||
|
satSyncJobs SatSyncJob[]
|
||||||
|
|
||||||
@@map("tenants")
|
@@map("tenants")
|
||||||
}
|
}
|
||||||
@@ -62,3 +64,75 @@ enum Role {
|
|||||||
contador
|
contador
|
||||||
visor
|
visor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SAT Sync Models
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model FielCredential {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String @unique @map("tenant_id")
|
||||||
|
rfc String @db.VarChar(13)
|
||||||
|
cerData Bytes @map("cer_data")
|
||||||
|
keyData Bytes @map("key_data")
|
||||||
|
keyPasswordEncrypted Bytes @map("key_password_encrypted")
|
||||||
|
encryptionIv Bytes @map("encryption_iv")
|
||||||
|
encryptionTag Bytes @map("encryption_tag")
|
||||||
|
serialNumber String? @map("serial_number") @db.VarChar(50)
|
||||||
|
validFrom DateTime @map("valid_from")
|
||||||
|
validUntil DateTime @map("valid_until")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("fiel_credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
model SatSyncJob {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String @map("tenant_id")
|
||||||
|
type SatSyncType
|
||||||
|
status SatSyncStatus @default(pending)
|
||||||
|
dateFrom DateTime @map("date_from") @db.Date
|
||||||
|
dateTo DateTime @map("date_to") @db.Date
|
||||||
|
cfdiType CfdiSyncType? @map("cfdi_type")
|
||||||
|
satRequestId String? @map("sat_request_id") @db.VarChar(50)
|
||||||
|
satPackageIds String[] @map("sat_package_ids")
|
||||||
|
cfdisFound Int @default(0) @map("cfdis_found")
|
||||||
|
cfdisDownloaded Int @default(0) @map("cfdis_downloaded")
|
||||||
|
cfdisInserted Int @default(0) @map("cfdis_inserted")
|
||||||
|
cfdisUpdated Int @default(0) @map("cfdis_updated")
|
||||||
|
progressPercent Int @default(0) @map("progress_percent")
|
||||||
|
errorMessage String? @map("error_message")
|
||||||
|
startedAt DateTime? @map("started_at")
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
retryCount Int @default(0) @map("retry_count")
|
||||||
|
nextRetryAt DateTime? @map("next_retry_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([status, nextRetryAt])
|
||||||
|
@@map("sat_sync_jobs")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SatSyncType {
|
||||||
|
initial
|
||||||
|
daily
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SatSyncStatus {
|
||||||
|
pending
|
||||||
|
running
|
||||||
|
completed
|
||||||
|
failed
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CfdiSyncType {
|
||||||
|
emitidos
|
||||||
|
recibidos
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { calendarioRoutes } from './routes/calendario.routes.js';
|
|||||||
import { reportesRoutes } from './routes/reportes.routes.js';
|
import { reportesRoutes } from './routes/reportes.routes.js';
|
||||||
import { usuariosRoutes } from './routes/usuarios.routes.js';
|
import { usuariosRoutes } from './routes/usuarios.routes.js';
|
||||||
import { tenantsRoutes } from './routes/tenants.routes.js';
|
import { tenantsRoutes } from './routes/tenants.routes.js';
|
||||||
|
import fielRoutes from './routes/fiel.routes.js';
|
||||||
|
import satRoutes from './routes/sat.routes.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -43,6 +45,8 @@ app.use('/api/calendario', calendarioRoutes);
|
|||||||
app.use('/api/reportes', reportesRoutes);
|
app.use('/api/reportes', reportesRoutes);
|
||||||
app.use('/api/usuarios', usuariosRoutes);
|
app.use('/api/usuarios', usuariosRoutes);
|
||||||
app.use('/api/tenants', tenantsRoutes);
|
app.use('/api/tenants', tenantsRoutes);
|
||||||
|
app.use('/api/fiel', fielRoutes);
|
||||||
|
app.use('/api/sat', satRoutes);
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
app.use(errorMiddleware);
|
app.use(errorMiddleware);
|
||||||
|
|||||||
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 { Request, Response, NextFunction } from 'express';
|
||||||
import * as usuariosService from '../services/usuarios.service.js';
|
import * as usuariosService from '../services/usuarios.service.js';
|
||||||
import { AppError } from '../utils/errors.js';
|
import { AppError } from '../utils/errors.js';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
|
||||||
|
// RFC del tenant administrador global
|
||||||
|
const ADMIN_TENANT_RFC = 'CAS2408138W2';
|
||||||
|
|
||||||
|
async function isGlobalAdmin(req: Request): Promise<boolean> {
|
||||||
|
if (req.user!.role !== 'admin') return false;
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.findUnique({
|
||||||
|
where: { id: req.user!.tenantId },
|
||||||
|
select: { rfc: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return tenant?.rfc === ADMIN_TENANT_RFC;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {
|
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
@@ -11,6 +26,21 @@ export async function getUsuarios(req: Request, res: Response, next: NextFunctio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todos los usuarios de todas las empresas (solo admin global)
|
||||||
|
*/
|
||||||
|
export async function getAllUsuarios(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!(await isGlobalAdmin(req))) {
|
||||||
|
throw new AppError(403, 'Solo el administrador global puede ver todos los usuarios');
|
||||||
|
}
|
||||||
|
const usuarios = await usuariosService.getAllUsuarios();
|
||||||
|
res.json(usuarios);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
|
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (req.user!.role !== 'admin') {
|
if (req.user!.role !== 'admin') {
|
||||||
@@ -28,7 +58,8 @@ export async function updateUsuario(req: Request, res: Response, next: NextFunct
|
|||||||
if (req.user!.role !== 'admin') {
|
if (req.user!.role !== 'admin') {
|
||||||
throw new AppError(403, 'Solo administradores pueden modificar usuarios');
|
throw new AppError(403, 'Solo administradores pueden modificar usuarios');
|
||||||
}
|
}
|
||||||
const usuario = await usuariosService.updateUsuario(req.user!.tenantId, req.params.id, req.body);
|
const userId = req.params.id as string;
|
||||||
|
const usuario = await usuariosService.updateUsuario(req.user!.tenantId, userId, req.body);
|
||||||
res.json(usuario);
|
res.json(usuario);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -40,10 +71,49 @@ export async function deleteUsuario(req: Request, res: Response, next: NextFunct
|
|||||||
if (req.user!.role !== 'admin') {
|
if (req.user!.role !== 'admin') {
|
||||||
throw new AppError(403, 'Solo administradores pueden eliminar usuarios');
|
throw new AppError(403, 'Solo administradores pueden eliminar usuarios');
|
||||||
}
|
}
|
||||||
if (req.params.id === req.user!.id) {
|
const userId = req.params.id as string;
|
||||||
|
if (userId === req.user!.userId) {
|
||||||
throw new AppError(400, 'No puedes eliminar tu propia cuenta');
|
throw new AppError(400, 'No puedes eliminar tu propia cuenta');
|
||||||
}
|
}
|
||||||
await usuariosService.deleteUsuario(req.user!.tenantId, req.params.id);
|
await usuariosService.deleteUsuario(req.user!.tenantId, userId);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza un usuario globalmente (puede cambiar de empresa)
|
||||||
|
*/
|
||||||
|
export async function updateUsuarioGlobal(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!(await isGlobalAdmin(req))) {
|
||||||
|
throw new AppError(403, 'Solo el administrador global puede modificar usuarios globalmente');
|
||||||
|
}
|
||||||
|
const userId = req.params.id as string;
|
||||||
|
if (userId === req.user!.userId && req.body.tenantId) {
|
||||||
|
throw new AppError(400, 'No puedes cambiar tu propia empresa');
|
||||||
|
}
|
||||||
|
const usuario = await usuariosService.updateUsuarioGlobal(userId, req.body);
|
||||||
|
res.json(usuario);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina un usuario globalmente
|
||||||
|
*/
|
||||||
|
export async function deleteUsuarioGlobal(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!(await isGlobalAdmin(req))) {
|
||||||
|
throw new AppError(403, 'Solo el administrador global puede eliminar usuarios globalmente');
|
||||||
|
}
|
||||||
|
const userId = req.params.id as string;
|
||||||
|
if (userId === req.user!.userId) {
|
||||||
|
throw new AppError(400, 'No puedes eliminar tu propia cuenta');
|
||||||
|
}
|
||||||
|
await usuariosService.deleteUsuarioGlobal(userId);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { app } from './app.js';
|
import { app } from './app.js';
|
||||||
import { env } from './config/env.js';
|
import { env } from './config/env.js';
|
||||||
|
import { startSatSyncJob } from './jobs/sat-sync.job.js';
|
||||||
|
|
||||||
const PORT = parseInt(env.PORT, 10);
|
const PORT = parseInt(env.PORT, 10);
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`🚀 API Server running on http://0.0.0.0:${PORT}`);
|
console.log(`API Server running on http://0.0.0.0:${PORT}`);
|
||||||
console.log(`📊 Environment: ${env.NODE_ENV}`);
|
console.log(`Environment: ${env.NODE_ENV}`);
|
||||||
|
|
||||||
|
// Iniciar job de sincronización SAT
|
||||||
|
if (env.NODE_ENV === 'production') {
|
||||||
|
startSatSyncJob();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
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);
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// Rutas por tenant
|
||||||
router.get('/', usuariosController.getUsuarios);
|
router.get('/', usuariosController.getUsuarios);
|
||||||
router.post('/invite', usuariosController.inviteUsuario);
|
router.post('/invite', usuariosController.inviteUsuario);
|
||||||
router.patch('/:id', usuariosController.updateUsuario);
|
router.patch('/:id', usuariosController.updateUsuario);
|
||||||
router.delete('/:id', usuariosController.deleteUsuario);
|
router.delete('/:id', usuariosController.deleteUsuario);
|
||||||
|
|
||||||
|
// Rutas globales (solo admin global)
|
||||||
|
router.get('/global/all', usuariosController.getAllUsuarios);
|
||||||
|
router.patch('/global/:id', usuariosController.updateUsuarioGlobal);
|
||||||
|
router.delete('/global/:id', usuariosController.deleteUsuarioGlobal);
|
||||||
|
|
||||||
export { router as usuariosRoutes };
|
export { router as usuariosRoutes };
|
||||||
|
|||||||
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 },
|
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 });
|
const response = await login({ email, password });
|
||||||
setTokens(response.accessToken, response.refreshToken);
|
setTokens(response.accessToken, response.refreshToken);
|
||||||
setUser(response.user);
|
setUser(response.user);
|
||||||
router.push('/dashboard');
|
|
||||||
|
const STORAGE_KEY = 'horux360:onboarding_seen_v1';
|
||||||
|
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
|
||||||
|
|
||||||
|
router.push(seen ? '/dashboard' : '/onboarding');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.message || 'Error al iniciar sesión');
|
setError(err.response?.data?.message || 'Error al iniciar sesión');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
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 { useThemeStore } from '@/stores/theme-store';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { themes, type ThemeName } from '@/themes';
|
import { themes, type ThemeName } from '@/themes';
|
||||||
import { Check, Palette, User, Building, Sidebar, PanelTop, Minimize2, Sparkles } from 'lucide-react';
|
import { Check, Palette, User, Building, Sidebar, PanelTop, Minimize2, Sparkles, RefreshCw } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
const themeOptions: { name: ThemeName; label: string; description: string; layoutDesc: string; layoutIcon: typeof Sidebar }[] = [
|
const themeOptions: { name: ThemeName; label: string; description: string; layoutDesc: string; layoutIcon: typeof Sidebar }[] = [
|
||||||
{
|
{
|
||||||
@@ -90,6 +91,26 @@ export default function ConfiguracionPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* SAT Configuration */}
|
||||||
|
<Link href="/configuracion/sat">
|
||||||
|
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Sincronizacion SAT
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configura tu FIEL y la sincronizacion automatica de CFDIs con el SAT
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Descarga automaticamente tus facturas emitidas y recibidas directamente del portal del SAT.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Theme Selection */}
|
{/* Theme Selection */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
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,
|
Bell,
|
||||||
Users,
|
Users,
|
||||||
Building2,
|
Building2,
|
||||||
|
UserCog,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { logout } from '@/lib/api/auth';
|
import { logout } from '@/lib/api/auth';
|
||||||
@@ -33,6 +34,7 @@ const navigation = [
|
|||||||
|
|
||||||
const adminNavigation = [
|
const adminNavigation = [
|
||||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||||
|
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
|
|||||||
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> {
|
export async function deleteUsuario(id: string): Promise<void> {
|
||||||
await apiClient.delete(`/usuarios/${id}`);
|
await apiClient.delete(`/usuarios/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Funciones globales (admin global)
|
||||||
|
export async function getAllUsuarios(): Promise<UserListItem[]> {
|
||||||
|
const response = await apiClient.get<UserListItem[]>('/usuarios/global/all');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUsuarioGlobal(id: string, data: UserUpdate): Promise<UserListItem> {
|
||||||
|
const response = await apiClient.patch<UserListItem>(`/usuarios/global/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUsuarioGlobal(id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/usuarios/global/${id}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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/alertas';
|
||||||
export * from './types/reportes';
|
export * from './types/reportes';
|
||||||
export * from './types/calendario';
|
export * from './types/calendario';
|
||||||
|
export * from './types/sat';
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
export * from './constants/plans';
|
export * from './constants/plans';
|
||||||
|
|||||||
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;
|
active: boolean;
|
||||||
lastLogin: string | null;
|
lastLogin: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
tenantId?: string;
|
||||||
|
tenantName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserUpdate {
|
export interface UserUpdate {
|
||||||
nombre?: string;
|
nombre?: string;
|
||||||
role?: 'admin' | 'contador' | 'visor';
|
role?: 'admin' | 'contador' | 'visor';
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
tenantId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditLog {
|
export interface AuditLog {
|
||||||
|
|||||||
165
pnpm-lock.yaml
generated
165
pnpm-lock.yaml
generated
@@ -20,9 +20,21 @@ importers:
|
|||||||
'@horux/shared':
|
'@horux/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
|
'@nodecfdi/cfdi-core':
|
||||||
|
specifier: ^1.0.1
|
||||||
|
version: 1.0.1
|
||||||
|
'@nodecfdi/credentials':
|
||||||
|
specifier: ^3.2.0
|
||||||
|
version: 3.2.0(luxon@3.7.2)
|
||||||
|
'@nodecfdi/sat-ws-descarga-masiva':
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.0.0(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^5.22.0
|
specifier: ^5.22.0
|
||||||
version: 5.22.0(prisma@5.22.0)
|
version: 5.22.0(prisma@5.22.0)
|
||||||
|
adm-zip:
|
||||||
|
specifier: ^0.5.16
|
||||||
|
version: 0.5.16
|
||||||
bcryptjs:
|
bcryptjs:
|
||||||
specifier: ^2.4.3
|
specifier: ^2.4.3
|
||||||
version: 2.4.3
|
version: 2.4.3
|
||||||
@@ -38,16 +50,28 @@ importers:
|
|||||||
express:
|
express:
|
||||||
specifier: ^4.21.0
|
specifier: ^4.21.0
|
||||||
version: 4.22.1
|
version: 4.22.1
|
||||||
|
fast-xml-parser:
|
||||||
|
specifier: ^5.3.3
|
||||||
|
version: 5.3.3
|
||||||
helmet:
|
helmet:
|
||||||
specifier: ^8.0.0
|
specifier: ^8.0.0
|
||||||
version: 8.1.0
|
version: 8.1.0
|
||||||
jsonwebtoken:
|
jsonwebtoken:
|
||||||
specifier: ^9.0.2
|
specifier: ^9.0.2
|
||||||
version: 9.0.3
|
version: 9.0.3
|
||||||
|
node-cron:
|
||||||
|
specifier: ^4.2.1
|
||||||
|
version: 4.2.1
|
||||||
|
node-forge:
|
||||||
|
specifier: ^1.3.3
|
||||||
|
version: 1.3.3
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.23.0
|
specifier: ^3.23.0
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/adm-zip':
|
||||||
|
specifier: ^0.5.7
|
||||||
|
version: 0.5.7
|
||||||
'@types/bcryptjs':
|
'@types/bcryptjs':
|
||||||
specifier: ^2.4.6
|
specifier: ^2.4.6
|
||||||
version: 2.4.6
|
version: 2.4.6
|
||||||
@@ -63,6 +87,12 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
version: 22.19.7
|
version: 22.19.7
|
||||||
|
'@types/node-cron':
|
||||||
|
specifier: ^3.0.11
|
||||||
|
version: 3.0.11
|
||||||
|
'@types/node-forge':
|
||||||
|
specifier: ^1.3.14
|
||||||
|
version: 1.3.14
|
||||||
prisma:
|
prisma:
|
||||||
specifier: ^5.22.0
|
specifier: ^5.22.0
|
||||||
version: 5.22.0
|
version: 5.22.0
|
||||||
@@ -439,6 +469,41 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@nodecfdi/base-converter@1.0.7':
|
||||||
|
resolution: {integrity: sha512-YoWtdhCPB86W+2TpXrZ1yXzehNC2sEFCB0vw4XtnHKdtw6pKxKyDT2qQf4TqICROp0IZNNKunFDw3EhcoR41Tw==}
|
||||||
|
engines: {node: '>=18 <=22 || ^16'}
|
||||||
|
|
||||||
|
'@nodecfdi/cfdi-core@1.0.1':
|
||||||
|
resolution: {integrity: sha512-OGm8BUxehpofu53j0weJ8SyF8v6RNJsGdziBu/Y+Xfd6PnrbpMWdPd40LSiP5tctLzm9ubDQIwKJX63Zp0I5BA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@nodecfdi/credentials@3.2.0':
|
||||||
|
resolution: {integrity: sha512-knZE8kIrIib27M/tcUQRgvnObMd7oR9EKZTSdBSHXW/5Pw6UB23v0ruUAJSFY0789J3OLfKaIVRXBG2I+q9ZTA==}
|
||||||
|
engines: {node: '>=18 <=22 || ^16'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/luxon': 3.4.2
|
||||||
|
luxon: ^3.5.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/luxon':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@nodecfdi/rfc@2.0.6':
|
||||||
|
resolution: {integrity: sha512-DiNC6j/mubbci8D9Qj9tdCm4/T/Q3ST92qpQ+AuHKJFVZ+/98F6ap8QFKeYK2ECu71wQGqAgkbmgQmVONAI5gg==}
|
||||||
|
engines: {node: '>=18 <=22 || ^16'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/luxon': 3.4.2
|
||||||
|
luxon: ^3.4.4
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/luxon':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@nodecfdi/sat-ws-descarga-masiva@2.0.0':
|
||||||
|
resolution: {integrity: sha512-FAmypqJfilOd29bf2bgMdysUkQKsu6ZirgljRfH4VFClXXtDHKmjOKahX0AbegUFc1GhtLjxhQgM+PJX3zhOdA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
'@nodecfdi/cfdi-core': ^1.0.0
|
||||||
|
luxon: ^3.6.1
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -935,6 +1000,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
'@types/adm-zip@0.5.7':
|
||||||
|
resolution: {integrity: sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==}
|
||||||
|
|
||||||
'@types/bcryptjs@2.4.6':
|
'@types/bcryptjs@2.4.6':
|
||||||
resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==}
|
resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==}
|
||||||
|
|
||||||
@@ -989,6 +1057,12 @@ packages:
|
|||||||
'@types/ms@2.1.0':
|
'@types/ms@2.1.0':
|
||||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||||
|
|
||||||
|
'@types/node-cron@3.0.11':
|
||||||
|
resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
|
||||||
|
|
||||||
|
'@types/node-forge@1.3.14':
|
||||||
|
resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==}
|
||||||
|
|
||||||
'@types/node@14.18.63':
|
'@types/node@14.18.63':
|
||||||
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
|
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
|
||||||
|
|
||||||
@@ -1018,10 +1092,22 @@ packages:
|
|||||||
'@types/serve-static@2.2.0':
|
'@types/serve-static@2.2.0':
|
||||||
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
|
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
|
||||||
|
|
||||||
|
'@vilic/node-forge@1.3.2-5':
|
||||||
|
resolution: {integrity: sha512-8GVr3S/nmLKL7QI7RYhVIcz3PuT/fxfkQLuh/F1CaT+/3QgI14RqiJkcKIni7h9u4ySbQGiGvm4XbNxRBJin4g==}
|
||||||
|
engines: {node: '>= 6.13.0'}
|
||||||
|
|
||||||
|
'@xmldom/xmldom@0.9.8':
|
||||||
|
resolution: {integrity: sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==}
|
||||||
|
engines: {node: '>=14.6'}
|
||||||
|
|
||||||
accepts@1.3.8:
|
accepts@1.3.8:
|
||||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
adm-zip@0.5.16:
|
||||||
|
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
|
||||||
|
engines: {node: '>=12.0'}
|
||||||
|
|
||||||
any-promise@1.3.0:
|
any-promise@1.3.0:
|
||||||
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
||||||
|
|
||||||
@@ -1394,6 +1480,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||||
engines: {node: '>=8.6.0'}
|
engines: {node: '>=8.6.0'}
|
||||||
|
|
||||||
|
fast-xml-parser@5.3.3:
|
||||||
|
resolution: {integrity: sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
fastq@1.20.1:
|
fastq@1.20.1:
|
||||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||||
|
|
||||||
@@ -1663,6 +1753,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc
|
||||||
|
|
||||||
|
luxon@3.7.2:
|
||||||
|
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
math-intrinsics@1.1.0:
|
math-intrinsics@1.1.0:
|
||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1749,6 +1843,14 @@ packages:
|
|||||||
sass:
|
sass:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
node-cron@4.2.1:
|
||||||
|
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
node-forge@1.3.3:
|
||||||
|
resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==}
|
||||||
|
engines: {node: '>= 6.13.0'}
|
||||||
|
|
||||||
node-releases@2.0.27:
|
node-releases@2.0.27:
|
||||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||||
|
|
||||||
@@ -2074,6 +2176,9 @@ packages:
|
|||||||
string_decoder@1.3.0:
|
string_decoder@1.3.0:
|
||||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||||
|
|
||||||
|
strnum@2.1.2:
|
||||||
|
resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==}
|
||||||
|
|
||||||
styled-jsx@5.1.1:
|
styled-jsx@5.1.1:
|
||||||
resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
|
resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -2140,6 +2245,9 @@ packages:
|
|||||||
ts-interface-checker@0.1.13:
|
ts-interface-checker@0.1.13:
|
||||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||||
|
|
||||||
|
ts-mixer@6.0.4:
|
||||||
|
resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@@ -2444,6 +2552,33 @@ snapshots:
|
|||||||
'@next/swc-win32-x64-msvc@14.2.33':
|
'@next/swc-win32-x64-msvc@14.2.33':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@nodecfdi/base-converter@1.0.7': {}
|
||||||
|
|
||||||
|
'@nodecfdi/cfdi-core@1.0.1':
|
||||||
|
dependencies:
|
||||||
|
'@xmldom/xmldom': 0.9.8
|
||||||
|
|
||||||
|
'@nodecfdi/credentials@3.2.0(luxon@3.7.2)':
|
||||||
|
dependencies:
|
||||||
|
'@nodecfdi/base-converter': 1.0.7
|
||||||
|
'@vilic/node-forge': 1.3.2-5
|
||||||
|
luxon: 3.7.2
|
||||||
|
ts-mixer: 6.0.4
|
||||||
|
|
||||||
|
'@nodecfdi/rfc@2.0.6(luxon@3.7.2)':
|
||||||
|
dependencies:
|
||||||
|
luxon: 3.7.2
|
||||||
|
|
||||||
|
'@nodecfdi/sat-ws-descarga-masiva@2.0.0(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)':
|
||||||
|
dependencies:
|
||||||
|
'@nodecfdi/cfdi-core': 1.0.1
|
||||||
|
'@nodecfdi/credentials': 3.2.0(luxon@3.7.2)
|
||||||
|
'@nodecfdi/rfc': 2.0.6(luxon@3.7.2)
|
||||||
|
jszip: 3.10.1
|
||||||
|
luxon: 3.7.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/luxon'
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodelib/fs.stat': 2.0.5
|
'@nodelib/fs.stat': 2.0.5
|
||||||
@@ -2927,6 +3062,10 @@ snapshots:
|
|||||||
|
|
||||||
'@tanstack/table-core@8.21.3': {}
|
'@tanstack/table-core@8.21.3': {}
|
||||||
|
|
||||||
|
'@types/adm-zip@0.5.7':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.19.7
|
||||||
|
|
||||||
'@types/bcryptjs@2.4.6': {}
|
'@types/bcryptjs@2.4.6': {}
|
||||||
|
|
||||||
'@types/body-parser@1.19.6':
|
'@types/body-parser@1.19.6':
|
||||||
@@ -2988,6 +3127,12 @@ snapshots:
|
|||||||
|
|
||||||
'@types/ms@2.1.0': {}
|
'@types/ms@2.1.0': {}
|
||||||
|
|
||||||
|
'@types/node-cron@3.0.11': {}
|
||||||
|
|
||||||
|
'@types/node-forge@1.3.14':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.19.7
|
||||||
|
|
||||||
'@types/node@14.18.63': {}
|
'@types/node@14.18.63': {}
|
||||||
|
|
||||||
'@types/node@22.19.7':
|
'@types/node@22.19.7':
|
||||||
@@ -3018,11 +3163,17 @@ snapshots:
|
|||||||
'@types/http-errors': 2.0.5
|
'@types/http-errors': 2.0.5
|
||||||
'@types/node': 22.19.7
|
'@types/node': 22.19.7
|
||||||
|
|
||||||
|
'@vilic/node-forge@1.3.2-5': {}
|
||||||
|
|
||||||
|
'@xmldom/xmldom@0.9.8': {}
|
||||||
|
|
||||||
accepts@1.3.8:
|
accepts@1.3.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
negotiator: 0.6.3
|
negotiator: 0.6.3
|
||||||
|
|
||||||
|
adm-zip@0.5.16: {}
|
||||||
|
|
||||||
any-promise@1.3.0: {}
|
any-promise@1.3.0: {}
|
||||||
|
|
||||||
anymatch@3.1.3:
|
anymatch@3.1.3:
|
||||||
@@ -3464,6 +3615,10 @@ snapshots:
|
|||||||
merge2: 1.4.1
|
merge2: 1.4.1
|
||||||
micromatch: 4.0.8
|
micromatch: 4.0.8
|
||||||
|
|
||||||
|
fast-xml-parser@5.3.3:
|
||||||
|
dependencies:
|
||||||
|
strnum: 2.1.2
|
||||||
|
|
||||||
fastq@1.20.1:
|
fastq@1.20.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
reusify: 1.1.0
|
reusify: 1.1.0
|
||||||
@@ -3717,6 +3872,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
|
luxon@3.7.2: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
media-typer@0.3.0: {}
|
media-typer@0.3.0: {}
|
||||||
@@ -3793,6 +3950,10 @@ snapshots:
|
|||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
|
|
||||||
|
node-cron@4.2.1: {}
|
||||||
|
|
||||||
|
node-forge@1.3.3: {}
|
||||||
|
|
||||||
node-releases@2.0.27: {}
|
node-releases@2.0.27: {}
|
||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
@@ -4125,6 +4286,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
strnum@2.1.2: {}
|
||||||
|
|
||||||
styled-jsx@5.1.1(react@18.3.1):
|
styled-jsx@5.1.1(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
client-only: 0.0.1
|
client-only: 0.0.1
|
||||||
@@ -4207,6 +4370,8 @@ snapshots:
|
|||||||
|
|
||||||
ts-interface-checker@0.1.13: {}
|
ts-interface-checker@0.1.13: {}
|
||||||
|
|
||||||
|
ts-mixer@6.0.4: {}
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
tsx@4.21.0:
|
tsx@4.21.0:
|
||||||
|
|||||||
Reference in New Issue
Block a user