Compare commits

...

6 Commits

Author SHA1 Message Date
Consultoria AS
3466ec740e fix: resolve TypeScript compilation errors in API
- Add explicit IRouter type to all route files
- Add explicit Express type to app.ts
- Fix env.ts by moving getCorsOrigins after parsing
- Fix token.ts SignOptions type for expiresIn
- Cast req.params.id to String() in controllers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 06:48:26 +00:00
Consultoria AS
3098a40356 fix: add auth protection to onboarding and remove demo text
- Add authentication check using useAuthStore
- Redirect unauthenticated users to /login
- Show loading state while auth store hydrates
- Remove "Demo UI sin backend" text from production

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 03:33:22 +00:00
Consultoria AS
34864742d8 Merge branch 'DevMarlene' into main
feat: add onboarding screen and redirect new users after login
2026-01-31 03:09:42 +00:00
Consultoria AS
1fe462764f fix: use transaction in refreshTokens to prevent race conditions
- Wrap token refresh logic in Prisma transaction
- Use deleteMany instead of delete to handle race conditions gracefully

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 03:09:32 +00:00
Consultoria AS
ba012254db docs: add current state and next steps for SAT sync
- Document current implementation status
- Add pending items to verify after SAT rate limit resets
- Include test tenant info and verification commands
- List known issues and workarounds

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 04:38:43 +00:00
Consultoria AS
dcc33af523 feat: SAT sync improvements and documentation
- Add custom date range support for SAT synchronization
- Fix UUID cast in SQL queries for sat_sync_job_id
- Fix processInitialSync to respect custom dateFrom/dateTo parameters
- Add date picker UI for custom period sync
- Add comprehensive documentation for SAT sync implementation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 03:01:27 +00:00
24 changed files with 505 additions and 108 deletions

View File

@@ -1,4 +1,4 @@
import express from 'express'; import express, { type Express } from 'express';
import cors from 'cors'; import cors from 'cors';
import helmet from 'helmet'; import helmet from 'helmet';
import { env, getCorsOrigins } from './config/env.js'; import { env, getCorsOrigins } from './config/env.js';
@@ -16,7 +16,7 @@ import { tenantsRoutes } from './routes/tenants.routes.js';
import fielRoutes from './routes/fiel.routes.js'; import fielRoutes from './routes/fiel.routes.js';
import satRoutes from './routes/sat.routes.js'; import satRoutes from './routes/sat.routes.js';
const app = express(); const app: Express = express();
// Security // Security
app.use(helmet()); app.use(helmet());

View File

@@ -15,11 +15,6 @@ const envSchema = z.object({
CORS_ORIGIN: z.string().default('http://localhost:3000'), CORS_ORIGIN: z.string().default('http://localhost:3000'),
}); });
// Parse CORS origins (comma-separated) into array
export function getCorsOrigins(): string[] {
return parsed.data.CORS_ORIGIN.split(',').map(origin => origin.trim());
}
const parsed = envSchema.safeParse(process.env); const parsed = envSchema.safeParse(process.env);
if (!parsed.success) { if (!parsed.success) {
@@ -28,3 +23,8 @@ if (!parsed.success) {
} }
export const env = parsed.data; export const env = parsed.data;
// Parse CORS origins (comma-separated) into array
export function getCorsOrigins(): string[] {
return env.CORS_ORIGIN.split(',').map(origin => origin.trim());
}

View File

@@ -17,7 +17,7 @@ export async function getAlertas(req: Request, res: Response, next: NextFunction
export async function getAlerta(req: Request, res: Response, next: NextFunction) { export async function getAlerta(req: Request, res: Response, next: NextFunction) {
try { try {
const alerta = await alertasService.getAlertaById(req.tenantSchema!, parseInt(req.params.id)); const alerta = await alertasService.getAlertaById(req.tenantSchema!, parseInt(String(req.params.id)));
if (!alerta) { if (!alerta) {
return res.status(404).json({ message: 'Alerta no encontrada' }); return res.status(404).json({ message: 'Alerta no encontrada' });
} }
@@ -38,7 +38,7 @@ export async function createAlerta(req: Request, res: Response, next: NextFuncti
export async function updateAlerta(req: Request, res: Response, next: NextFunction) { export async function updateAlerta(req: Request, res: Response, next: NextFunction) {
try { try {
const alerta = await alertasService.updateAlerta(req.tenantSchema!, parseInt(req.params.id), req.body); const alerta = await alertasService.updateAlerta(req.tenantSchema!, parseInt(String(req.params.id)), req.body);
res.json(alerta); res.json(alerta);
} catch (error) { } catch (error) {
next(error); next(error);
@@ -47,7 +47,7 @@ export async function updateAlerta(req: Request, res: Response, next: NextFuncti
export async function deleteAlerta(req: Request, res: Response, next: NextFunction) { export async function deleteAlerta(req: Request, res: Response, next: NextFunction) {
try { try {
await alertasService.deleteAlerta(req.tenantSchema!, parseInt(req.params.id)); await alertasService.deleteAlerta(req.tenantSchema!, parseInt(String(req.params.id)));
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
next(error); next(error);

View File

@@ -35,7 +35,7 @@ export async function createEvento(req: Request, res: Response, next: NextFuncti
export async function updateEvento(req: Request, res: Response, next: NextFunction) { export async function updateEvento(req: Request, res: Response, next: NextFunction) {
try { try {
const evento = await calendarioService.updateEvento(req.tenantSchema!, parseInt(req.params.id), req.body); const evento = await calendarioService.updateEvento(req.tenantSchema!, parseInt(String(req.params.id)), req.body);
res.json(evento); res.json(evento);
} catch (error) { } catch (error) {
next(error); next(error);
@@ -44,7 +44,7 @@ export async function updateEvento(req: Request, res: Response, next: NextFuncti
export async function deleteEvento(req: Request, res: Response, next: NextFunction) { export async function deleteEvento(req: Request, res: Response, next: NextFunction) {
try { try {
await calendarioService.deleteEvento(req.tenantSchema!, parseInt(req.params.id)); await calendarioService.deleteEvento(req.tenantSchema!, parseInt(String(req.params.id)));
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
next(error); next(error);

View File

@@ -33,7 +33,7 @@ export async function getCfdiById(req: Request, res: Response, next: NextFunctio
return next(new AppError(400, 'Schema no configurado')); return next(new AppError(400, 'Schema no configurado'));
} }
const cfdi = await cfdiService.getCfdiById(req.tenantSchema, req.params.id); const cfdi = await cfdiService.getCfdiById(req.tenantSchema, String(req.params.id));
if (!cfdi) { if (!cfdi) {
return next(new AppError(404, 'CFDI no encontrado')); return next(new AppError(404, 'CFDI no encontrado'));
@@ -131,7 +131,7 @@ export async function deleteCfdi(req: Request, res: Response, next: NextFunction
return next(new AppError(403, 'No tienes permisos para eliminar CFDIs')); return next(new AppError(403, 'No tienes permisos para eliminar CFDIs'));
} }
await cfdiService.deleteCfdi(req.tenantSchema, req.params.id); await cfdiService.deleteCfdi(req.tenantSchema, String(req.params.id));
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
next(error); next(error);

View File

@@ -22,7 +22,7 @@ export async function getTenant(req: Request, res: Response, next: NextFunction)
throw new AppError(403, 'Solo administradores pueden ver detalles de clientes'); throw new AppError(403, 'Solo administradores pueden ver detalles de clientes');
} }
const tenant = await tenantsService.getTenantById(req.params.id); const tenant = await tenantsService.getTenantById(String(req.params.id));
if (!tenant) { if (!tenant) {
throw new AppError(404, 'Cliente no encontrado'); throw new AppError(404, 'Cliente no encontrado');
} }
@@ -65,7 +65,7 @@ export async function updateTenant(req: Request, res: Response, next: NextFuncti
throw new AppError(403, 'Solo administradores pueden editar clientes'); throw new AppError(403, 'Solo administradores pueden editar clientes');
} }
const { id } = req.params; const id = String(req.params.id);
const { nombre, rfc, plan, cfdiLimit, usersLimit, active } = req.body; const { nombre, rfc, plan, cfdiLimit, usersLimit, active } = req.body;
const tenant = await tenantsService.updateTenant(id, { const tenant = await tenantsService.updateTenant(id, {
@@ -89,7 +89,7 @@ export async function deleteTenant(req: Request, res: Response, next: NextFuncti
throw new AppError(403, 'Solo administradores pueden eliminar clientes'); throw new AppError(403, 'Solo administradores pueden eliminar clientes');
} }
await tenantsService.deleteTenant(req.params.id); await tenantsService.deleteTenant(String(req.params.id));
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
next(error); next(error);

View File

@@ -1,9 +1,9 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as alertasController from '../controllers/alertas.controller.js'; import * as alertasController from '../controllers/alertas.controller.js';
const router = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);
router.use(tenantMiddleware); router.use(tenantMiddleware);

View File

@@ -1,8 +1,8 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import * as authController from '../controllers/auth.controller.js'; import * as authController from '../controllers/auth.controller.js';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
const router = Router(); const router: IRouter = Router();
router.post('/register', authController.register); router.post('/register', authController.register);
router.post('/login', authController.login); router.post('/login', authController.login);

View File

@@ -1,9 +1,9 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as calendarioController from '../controllers/calendario.controller.js'; import * as calendarioController from '../controllers/calendario.controller.js';
const router = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);
router.use(tenantMiddleware); router.use(tenantMiddleware);

View File

@@ -1,9 +1,9 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as cfdiController from '../controllers/cfdi.controller.js'; import * as cfdiController from '../controllers/cfdi.controller.js';
const router = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);
router.use(tenantMiddleware); router.use(tenantMiddleware);

View File

@@ -1,9 +1,9 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as dashboardController from '../controllers/dashboard.controller.js'; import * as dashboardController from '../controllers/dashboard.controller.js';
const router = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);
router.use(tenantMiddleware); router.use(tenantMiddleware);

View File

@@ -1,9 +1,9 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as exportController from '../controllers/export.controller.js'; import * as exportController from '../controllers/export.controller.js';
const router = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);
router.use(tenantMiddleware); router.use(tenantMiddleware);

View File

@@ -1,8 +1,8 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import * as fielController from '../controllers/fiel.controller.js'; import * as fielController from '../controllers/fiel.controller.js';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
const router = Router(); const router: IRouter = Router();
// Todas las rutas requieren autenticación // Todas las rutas requieren autenticación
router.use(authenticate); router.use(authenticate);

View File

@@ -1,9 +1,9 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as impuestosController from '../controllers/impuestos.controller.js'; import * as impuestosController from '../controllers/impuestos.controller.js';
const router = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);
router.use(tenantMiddleware); router.use(tenantMiddleware);

View File

@@ -1,9 +1,9 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as reportesController from '../controllers/reportes.controller.js'; import * as reportesController from '../controllers/reportes.controller.js';
const router = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);
router.use(tenantMiddleware); router.use(tenantMiddleware);

View File

@@ -1,8 +1,8 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import * as satController from '../controllers/sat.controller.js'; import * as satController from '../controllers/sat.controller.js';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
const router = Router(); const router: IRouter = Router();
// Todas las rutas requieren autenticación // Todas las rutas requieren autenticación
router.use(authenticate); router.use(authenticate);

View File

@@ -1,8 +1,8 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
import * as tenantsController from '../controllers/tenants.controller.js'; import * as tenantsController from '../controllers/tenants.controller.js';
const router = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);

View File

@@ -1,8 +1,8 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
import * as usuariosController from '../controllers/usuarios.controller.js'; import * as usuariosController from '../controllers/usuarios.controller.js';
const router = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);

View File

@@ -147,7 +147,9 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
} }
export async function refreshTokens(token: string): Promise<{ accessToken: string; refreshToken: string }> { export async function refreshTokens(token: string): Promise<{ accessToken: string; refreshToken: string }> {
const storedToken = await prisma.refreshToken.findUnique({ // Use a transaction to prevent race conditions
return await prisma.$transaction(async (tx) => {
const storedToken = await tx.refreshToken.findUnique({
where: { token }, where: { token },
}); });
@@ -156,13 +158,13 @@ export async function refreshTokens(token: string): Promise<{ accessToken: strin
} }
if (storedToken.expiresAt < new Date()) { if (storedToken.expiresAt < new Date()) {
await prisma.refreshToken.delete({ where: { id: storedToken.id } }); await tx.refreshToken.deleteMany({ where: { id: storedToken.id } });
throw new AppError(401, 'Token expirado'); throw new AppError(401, 'Token expirado');
} }
const payload = verifyToken(token); const payload = verifyToken(token);
const user = await prisma.user.findUnique({ const user = await tx.user.findUnique({
where: { id: payload.userId }, where: { id: payload.userId },
include: { tenant: true }, include: { tenant: true },
}); });
@@ -171,7 +173,8 @@ export async function refreshTokens(token: string): Promise<{ accessToken: strin
throw new AppError(401, 'Usuario no encontrado o desactivado'); throw new AppError(401, 'Usuario no encontrado o desactivado');
} }
await prisma.refreshToken.delete({ where: { id: storedToken.id } }); // Use deleteMany to avoid error if already deleted (race condition)
await tx.refreshToken.deleteMany({ where: { id: storedToken.id } });
const newTokenPayload = { const newTokenPayload = {
userId: user.id, userId: user.id,
@@ -184,7 +187,7 @@ export async function refreshTokens(token: string): Promise<{ accessToken: strin
const accessToken = generateAccessToken(newTokenPayload); const accessToken = generateAccessToken(newTokenPayload);
const refreshToken = generateRefreshToken(newTokenPayload); const refreshToken = generateRefreshToken(newTokenPayload);
await prisma.refreshToken.create({ await tx.refreshToken.create({
data: { data: {
userId: user.id, userId: user.id,
token: refreshToken, token: refreshToken,
@@ -193,6 +196,7 @@ export async function refreshTokens(token: string): Promise<{ accessToken: strin
}); });
return { accessToken, refreshToken }; return { accessToken, refreshToken };
});
} }
export async function logout(token: string): Promise<void> { export async function logout(token: string): Promise<void> {

View File

@@ -96,7 +96,7 @@ async function saveCfdis(
estado = $22, estado = $22,
xml_original = $23, xml_original = $23,
last_sat_sync = NOW(), last_sat_sync = NOW(),
sat_sync_job_id = $24, sat_sync_job_id = $24::uuid,
updated_at = NOW() updated_at = NOW()
WHERE uuid_fiscal = $1`, WHERE uuid_fiscal = $1`,
cfdi.uuidFiscal, cfdi.uuidFiscal,
@@ -137,7 +137,7 @@ async function saveCfdis(
) VALUES ( ) VALUES (
gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 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, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22,
$23, 'sat', $24, NOW(), NOW() $23, 'sat', $24::uuid, NOW(), NOW()
)`, )`,
cfdi.uuidFiscal, cfdi.uuidFiscal,
cfdi.tipo, cfdi.tipo,
@@ -278,11 +278,18 @@ async function processDateRange(
} }
/** /**
* Ejecuta sincronización inicial (últimos 10 años) * Ejecuta sincronización inicial o por rango personalizado
*/ */
async function processInitialSync(ctx: SyncContext, jobId: string): Promise<void> { async function processInitialSync(
ctx: SyncContext,
jobId: string,
customDateFrom?: Date,
customDateTo?: Date
): Promise<void> {
const ahora = new Date(); const ahora = new Date();
const inicioHistorico = new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1); // Usar fechas personalizadas si se proporcionan, sino calcular desde YEARS_TO_SYNC
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
const fechaFin = customDateTo || ahora;
let totalFound = 0; let totalFound = 0;
let totalDownloaded = 0; let totalDownloaded = 0;
@@ -292,9 +299,9 @@ async function processInitialSync(ctx: SyncContext, jobId: string): Promise<void
// Procesar por meses para evitar límites del SAT // Procesar por meses para evitar límites del SAT
let currentDate = new Date(inicioHistorico); let currentDate = new Date(inicioHistorico);
while (currentDate < ahora) { while (currentDate < fechaFin) {
const monthEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0, 23, 59, 59); const monthEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0, 23, 59, 59);
const rangeEnd = monthEnd > ahora ? ahora : monthEnd; const rangeEnd = monthEnd > fechaFin ? fechaFin : monthEnd;
// Procesar emitidos // Procesar emitidos
try { try {
@@ -446,7 +453,7 @@ export async function startSync(
(async () => { (async () => {
try { try {
if (type === 'initial') { if (type === 'initial') {
await processInitialSync(ctx, job.id); await processInitialSync(ctx, job.id, dateFrom, dateTo);
} else { } else {
await processDailySync(ctx, job.id); await processDailySync(ctx, job.id);
} }

View File

@@ -1,17 +1,19 @@
import jwt from 'jsonwebtoken'; import jwt, { type SignOptions } from 'jsonwebtoken';
import type { JWTPayload } from '@horux/shared'; import type { JWTPayload } from '@horux/shared';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string { export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
return jwt.sign(payload, env.JWT_SECRET, { const options: SignOptions = {
expiresIn: env.JWT_EXPIRES_IN, expiresIn: env.JWT_EXPIRES_IN as SignOptions['expiresIn'],
}); };
return jwt.sign(payload, env.JWT_SECRET, options);
} }
export function generateRefreshToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string { export function generateRefreshToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
return jwt.sign(payload, env.JWT_SECRET, { const options: SignOptions = {
expiresIn: env.JWT_REFRESH_EXPIRES_IN, expiresIn: env.JWT_REFRESH_EXPIRES_IN as SignOptions['expiresIn'],
}); };
return jwt.sign(payload, env.JWT_SECRET, options);
} }
export function verifyToken(token: string): JWTPayload { export function verifyToken(token: string): JWTPayload {

View File

@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth-store';
/** /**
* Onboarding persistence key. * Onboarding persistence key.
@@ -11,6 +12,7 @@ const STORAGE_KEY = 'horux360:onboarding_seen_v1';
export default function OnboardingScreen() { export default function OnboardingScreen() {
const router = useRouter(); const router = useRouter();
const { isAuthenticated, _hasHydrated } = useAuthStore();
const [isNewUser, setIsNewUser] = useState(true); const [isNewUser, setIsNewUser] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -21,6 +23,13 @@ export default function OnboardingScreen() {
router.push(path); router.push(path);
}; };
// Redirect to login if not authenticated
useEffect(() => {
if (_hasHydrated && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, _hasHydrated, router]);
useEffect(() => { useEffect(() => {
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1'; const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
@@ -46,6 +55,20 @@ export default function OnboardingScreen() {
const headerStatus = useMemo(() => (isNewUser ? 'Onboarding' : 'Redirección'), [isNewUser]); const headerStatus = useMemo(() => (isNewUser ? 'Onboarding' : 'Redirección'), [isNewUser]);
// Show loading while store hydrates
if (!_hasHydrated) {
return (
<div className="min-h-screen flex items-center justify-center bg-white">
<div className="animate-pulse text-slate-500">Cargando...</div>
</div>
);
}
// Don't render if not authenticated
if (!isAuthenticated) {
return null;
}
return ( return (
<main className="min-h-screen relative overflow-hidden bg-white"> <main className="min-h-screen relative overflow-hidden bg-white">
{/* Grid tech claro */} {/* Grid tech claro */}
@@ -160,9 +183,6 @@ export default function OnboardingScreen() {
</div> </div>
</div> </div>
<p className="mt-4 text-center text-xs text-slate-400">
Demo UI sin backend Persistencia local: localStorage
</p>
</div> </div>
</div> </div>
</main> </main>

View File

@@ -3,6 +3,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { getSyncStatus, startSync } from '@/lib/api/sat'; import { getSyncStatus, startSync } from '@/lib/api/sat';
import type { SatSyncStatusResponse } from '@horux/shared'; import type { SatSyncStatusResponse } from '@horux/shared';
@@ -30,6 +32,9 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [startingSync, setStartingSync] = useState(false); const [startingSync, setStartingSync] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [showCustomDate, setShowCustomDate] = useState(false);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const fetchStatus = async () => { const fetchStatus = async () => {
try { try {
@@ -53,12 +58,21 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
} }
}, [fielConfigured]); }, [fielConfigured]);
const handleStartSync = async (type: 'initial' | 'daily') => { const handleStartSync = async (type: 'initial' | 'daily', customDates?: boolean) => {
setStartingSync(true); setStartingSync(true);
setError(''); setError('');
try { try {
await startSync({ type }); const params: { type: 'initial' | 'daily'; dateFrom?: string; dateTo?: string } = { type };
if (customDates && dateFrom && dateTo) {
// Convertir a formato completo con hora
params.dateFrom = `${dateFrom}T00:00:00`;
params.dateTo = `${dateTo}T23:59:59`;
}
await startSync(params);
await fetchStatus(); await fetchStatus();
setShowCustomDate(false);
onSyncStarted?.(); onSyncStarted?.();
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.error || 'Error al iniciar sincronizacion'); setError(err.response?.data?.error || 'Error al iniciar sincronizacion');
@@ -162,6 +176,49 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
<p className="text-sm text-red-500">{error}</p> <p className="text-sm text-red-500">{error}</p>
)} )}
{/* Formulario de fechas personalizadas */}
{showCustomDate && (
<div className="p-4 bg-gray-50 rounded-lg space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="dateFrom">Fecha inicio</Label>
<Input
id="dateFrom"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
max={dateTo || undefined}
/>
</div>
<div>
<Label htmlFor="dateTo">Fecha fin</Label>
<Input
id="dateTo"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
min={dateFrom || undefined}
/>
</div>
</div>
<div className="flex gap-2">
<Button
disabled={startingSync || status?.hasActiveSync || !dateFrom || !dateTo}
onClick={() => handleStartSync('initial', true)}
className="flex-1"
>
{startingSync ? 'Iniciando...' : 'Sincronizar periodo'}
</Button>
<Button
variant="outline"
onClick={() => setShowCustomDate(false)}
>
Cancelar
</Button>
</div>
</div>
)}
<div className="flex gap-3"> <div className="flex gap-3">
<Button <Button
variant="outline" variant="outline"
@@ -169,18 +226,27 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
onClick={() => handleStartSync('daily')} onClick={() => handleStartSync('daily')}
className="flex-1" className="flex-1"
> >
{startingSync ? 'Iniciando...' : 'Sincronizar ahora'} {startingSync ? 'Iniciando...' : 'Sincronizar mes actual'}
</Button> </Button>
<Button
variant="outline"
disabled={startingSync || status?.hasActiveSync}
onClick={() => setShowCustomDate(!showCustomDate)}
className="flex-1"
>
Periodo personalizado
</Button>
</div>
{!status?.lastCompletedJob && ( {!status?.lastCompletedJob && (
<Button <Button
disabled={startingSync || status?.hasActiveSync} disabled={startingSync || status?.hasActiveSync}
onClick={() => handleStartSync('initial')} onClick={() => handleStartSync('initial')}
className="flex-1" className="w-full"
> >
{startingSync ? 'Iniciando...' : 'Sincronizacion inicial (10 anos)'} {startingSync ? 'Iniciando...' : 'Sincronizacion inicial (6 anos)'}
</Button> </Button>
)} )}
</div>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -0,0 +1,298 @@
# Implementación de Sincronización SAT
## Resumen
Sistema de sincronización automática de CFDIs con el SAT (Servicio de Administración Tributaria de México) para Horux360.
## Componentes Implementados
### 1. Backend (API)
#### Servicios
| Archivo | Descripción |
|---------|-------------|
| `src/services/fiel.service.ts` | Gestión de credenciales FIEL (e.firma) |
| `src/services/sat/sat-client.service.ts` | Cliente para el servicio web del SAT |
| `src/services/sat/sat.service.ts` | Lógica principal de sincronización |
| `src/services/sat/sat-crypto.service.ts` | Encriptación AES-256-GCM para credenciales |
| `src/services/sat/sat-parser.service.ts` | Parser de XMLs de CFDI |
#### Controladores
| Archivo | Descripción |
|---------|-------------|
| `src/controllers/fiel.controller.ts` | Endpoints para gestión de FIEL |
| `src/controllers/sat.controller.ts` | Endpoints para sincronización SAT |
#### Job Programado
| Archivo | Descripción |
|---------|-------------|
| `src/jobs/sat-sync.job.ts` | Cron job para sincronización diaria (3:00 AM) |
### 2. Frontend (Web)
#### Componentes
| Archivo | Descripción |
|---------|-------------|
| `components/sat/FielUploadModal.tsx` | Modal para subir certificado y llave FIEL |
| `components/sat/SyncStatus.tsx` | Estado de sincronización con selector de fechas |
| `components/sat/SyncHistory.tsx` | Historial de sincronizaciones |
#### Página
| Archivo | Descripción |
|---------|-------------|
| `app/(dashboard)/configuracion/sat/page.tsx` | Página de configuración SAT |
### 3. Base de Datos
#### Tabla Principal (schema public)
```sql
-- sat_sync_jobs: Almacena los trabajos de sincronización
CREATE TABLE sat_sync_jobs (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
type VARCHAR(20) NOT NULL, -- 'initial' | 'daily'
status VARCHAR(20) NOT NULL, -- 'pending' | 'running' | 'completed' | 'failed'
date_from TIMESTAMP NOT NULL,
date_to TIMESTAMP NOT NULL,
cfdi_type VARCHAR(20),
sat_request_id VARCHAR(100),
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
);
-- fiel_credentials: Almacena las credenciales FIEL encriptadas
CREATE TABLE fiel_credentials (
id UUID PRIMARY KEY,
tenant_id UUID UNIQUE NOT NULL,
rfc VARCHAR(13) NOT NULL,
cer_data BYTEA NOT NULL,
key_data BYTEA NOT NULL,
key_password_encrypted BYTEA NOT NULL,
encryption_iv BYTEA NOT NULL,
encryption_tag BYTEA NOT NULL,
serial_number VARCHAR(100),
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()
);
```
#### Columnas agregadas a tabla cfdis (por tenant)
```sql
ALTER TABLE tenant_xxx.cfdis ADD COLUMN xml_original TEXT;
ALTER TABLE tenant_xxx.cfdis ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE tenant_xxx.cfdis ADD COLUMN last_sat_sync TIMESTAMP;
ALTER TABLE tenant_xxx.cfdis ADD COLUMN sat_sync_job_id UUID;
ALTER TABLE tenant_xxx.cfdis ADD COLUMN source VARCHAR(20) DEFAULT 'manual';
```
## Dependencias
```json
{
"@nodecfdi/sat-ws-descarga-masiva": "^2.0.0",
"@nodecfdi/credentials": "^2.0.0",
"@nodecfdi/cfdi-core": "^1.0.1"
}
```
## Flujo de Sincronización
```
1. Usuario configura FIEL (certificado .cer + llave .key + contraseña)
2. Sistema valida y encripta credenciales (AES-256-GCM)
3. Usuario inicia sincronización (manual o automática 3:00 AM)
4. Sistema desencripta FIEL y crea cliente SAT
5. Por cada mes en el rango:
a. Solicitar CFDIs emitidos al SAT
b. Esperar respuesta (polling cada 30s)
c. Descargar paquetes ZIP
d. Extraer y parsear XMLs
e. Guardar en BD del tenant
f. Repetir para CFDIs recibidos
6. Marcar job como completado
```
## API Endpoints
### FIEL
| Método | Ruta | Descripción |
|--------|------|-------------|
| GET | `/api/fiel/status` | Estado de la FIEL configurada |
| POST | `/api/fiel/upload` | Subir nueva FIEL |
| DELETE | `/api/fiel` | Eliminar FIEL |
### Sincronización SAT
| Método | Ruta | Descripción |
|--------|------|-------------|
| POST | `/api/sat/sync` | Iniciar sincronización |
| GET | `/api/sat/sync/status` | Estado actual |
| GET | `/api/sat/sync/history` | Historial de syncs |
| GET | `/api/sat/sync/:id` | Detalle de un job |
| POST | `/api/sat/sync/:id/retry` | Reintentar job fallido |
### Parámetros de sincronización
```typescript
interface StartSyncRequest {
type?: 'initial' | 'daily'; // default: 'daily'
dateFrom?: string; // ISO date, ej: "2025-01-01T00:00:00"
dateTo?: string; // ISO date, ej: "2025-12-31T23:59:59"
}
```
## Configuración
### Variables de entorno
```env
# Clave para encriptar credenciales FIEL (32 bytes hex)
FIEL_ENCRYPTION_KEY=tu_clave_de_32_bytes_en_hexadecimal
# Zona horaria para el cron
TZ=America/Mexico_City
```
### Límites del SAT
- **Antigüedad máxima**: 6 años
- **Solicitudes por día**: Limitadas (se reinicia cada 24h)
- **Tamaño de paquete**: Variable
## Errores Comunes del SAT
| Código | Mensaje | Solución |
|--------|---------|----------|
| 5000 | Solicitud Aceptada | OK - esperar verificación |
| 5002 | Límite de solicitudes agotado | Esperar 24 horas |
| 5004 | No se encontraron CFDIs | Normal si no hay facturas en el rango |
| 5005 | Solicitud duplicada | Ya existe una solicitud pendiente |
| - | Información mayor a 6 años | Ajustar rango de fechas |
| - | No se permite descarga de cancelados | Facturas canceladas no disponibles |
## Seguridad
1. **Encriptación de credenciales**: AES-256-GCM con IV único
2. **Almacenamiento seguro**: Certificado, llave y contraseña encriptados
3. **Autenticación**: JWT con tenantId embebido
4. **Aislamiento**: Cada tenant tiene su propio schema en PostgreSQL
## Servicios Systemd
```bash
# API Backend
systemctl status horux-api
# Web Frontend
systemctl status horux-web
```
## Comandos Útiles
```bash
# Ver logs de sincronización SAT
journalctl -u horux-api -f | grep "\[SAT\]"
# Estado de jobs
psql -U postgres -d horux360 -c "SELECT * FROM sat_sync_jobs ORDER BY created_at DESC LIMIT 5;"
# CFDIs sincronizados por tenant
psql -U postgres -d horux360 -c "SELECT COUNT(*) FROM tenant_xxx.cfdis WHERE source = 'sat';"
```
## Changelog
### 2026-01-25
- Implementación inicial de sincronización SAT
- Integración con librería @nodecfdi/sat-ws-descarga-masiva
- Soporte para fechas personalizadas en sincronización
- Corrección de cast UUID en queries SQL
- Agregadas columnas faltantes a tabla cfdis
- UI para selección de periodo personalizado
- Cambio de servicio web a modo producción (next start)
## Estado Actual (2026-01-25)
### Completado
- [x] Servicio de encriptación de credenciales FIEL
- [x] Integración con @nodecfdi/sat-ws-descarga-masiva
- [x] Parser de XMLs de CFDI
- [x] UI para subir FIEL
- [x] UI para ver estado de sincronización
- [x] UI para seleccionar periodo personalizado
- [x] Cron job para sincronización diaria (3:00 AM)
- [x] Soporte para fechas personalizadas
- [x] Corrección de cast UUID en queries
- [x] Columnas adicionales en tabla cfdis de todos los tenants
### Pendiente por probar
El SAT bloqueó las solicitudes por exceso de pruebas. **Esperar 24 horas** y luego:
1. Ir a **Configuración > SAT**
2. Clic en **"Periodo personalizado"**
3. Seleccionar: **2025-01-01** a **2025-12-31**
4. Clic en **"Sincronizar periodo"**
### Tenant de prueba
- **RFC**: CAS2408138W2
- **Schema**: `tenant_cas2408138w2`
- **Nota**: Los CFDIs "recibidos" de este tenant están cancelados (SAT no permite descargarlos)
### Comandos para verificar después de 24h
```bash
# Ver estado del sync
PGPASSWORD=postgres psql -h localhost -U postgres -d horux360 -c \
"SELECT status, cfdis_found, cfdis_downloaded, cfdis_inserted FROM sat_sync_jobs ORDER BY created_at DESC LIMIT 1;"
# Ver logs en tiempo real
journalctl -u horux-api -f | grep "\[SAT\]"
# Contar CFDIs sincronizados
PGPASSWORD=postgres psql -h localhost -U postgres -d horux360 -c \
"SELECT COUNT(*) as total FROM tenant_cas2408138w2.cfdis WHERE source = 'sat';"
```
### Problemas conocidos
1. **"Se han agotado las solicitudes de por vida"**: Límite de SAT alcanzado, esperar 24h
2. **"No se permite la descarga de xml que se encuentren cancelados"**: Normal para facturas canceladas
3. **"Información mayor a 6 años"**: SAT solo permite descargar últimos 6 años
## Próximos Pasos
- [ ] Probar sincronización completa después de 24h
- [ ] Verificar que los CFDIs se guarden correctamente
- [ ] Implementar reintentos automáticos para errores temporales
- [ ] Notificaciones por email al completar sincronización
- [ ] Dashboard con estadísticas de CFDIs por periodo
- [ ] Soporte para filtros adicionales (RFC emisor/receptor, tipo de comprobante)