Compare commits
31 Commits
DevMarlene
...
22543589c3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22543589c3 | ||
|
|
536a5abd33 | ||
|
|
3c9268ea30 | ||
|
|
c44e7cea34 | ||
|
|
2994de4ce0 | ||
|
|
562e23d8bf | ||
|
|
08a7312761 | ||
|
|
0e49c0922d | ||
|
|
5c6367839f | ||
|
|
8ddb60d6c1 | ||
|
|
e132c2ba14 | ||
|
|
29ac067a82 | ||
|
|
8c3fb76406 | ||
|
|
5ff5629cd8 | ||
|
|
2bbab12627 | ||
|
|
cdb6f0c94e | ||
|
|
3beee1c174 | ||
|
|
837831ccd4 | ||
|
|
f9d2161938 | ||
|
|
427c94fb9d | ||
|
|
266e547eb5 | ||
|
|
ebd099f596 | ||
|
|
8c0bc799d3 | ||
|
|
6109294811 | ||
|
|
67f74538b8 | ||
|
|
3466ec740e | ||
|
|
3098a40356 | ||
|
|
34864742d8 | ||
|
|
1fe462764f | ||
|
|
ba012254db | ||
|
|
dcc33af523 |
@@ -1,4 +1,4 @@
|
||||
import express from 'express';
|
||||
import express, { type Express } from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
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 satRoutes from './routes/sat.routes.js';
|
||||
|
||||
const app = express();
|
||||
const app: Express = express();
|
||||
|
||||
// Security
|
||||
app.use(helmet());
|
||||
|
||||
@@ -15,11 +15,6 @@ const envSchema = z.object({
|
||||
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);
|
||||
|
||||
if (!parsed.success) {
|
||||
@@ -28,3 +23,8 @@ if (!parsed.success) {
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function getAlertas(req: Request, res: Response, next: NextFunction
|
||||
|
||||
export async function getAlerta(req: Request, res: Response, next: NextFunction) {
|
||||
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) {
|
||||
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) {
|
||||
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);
|
||||
} catch (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) {
|
||||
try {
|
||||
await alertasService.deleteAlerta(req.tenantSchema!, parseInt(req.params.id));
|
||||
await alertasService.deleteAlerta(req.tenantSchema!, parseInt(String(req.params.id)));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function createEvento(req: Request, res: Response, next: NextFuncti
|
||||
|
||||
export async function updateEvento(req: Request, res: Response, next: NextFunction) {
|
||||
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);
|
||||
} catch (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) {
|
||||
try {
|
||||
await calendarioService.deleteEvento(req.tenantSchema!, parseInt(req.params.id));
|
||||
await calendarioService.deleteEvento(req.tenantSchema!, parseInt(String(req.params.id)));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -15,6 +15,8 @@ export async function getCfdis(req: Request, res: Response, next: NextFunction)
|
||||
fechaInicio: req.query.fechaInicio as string,
|
||||
fechaFin: req.query.fechaFin as string,
|
||||
rfc: req.query.rfc as string,
|
||||
emisor: req.query.emisor as string,
|
||||
receptor: req.query.receptor as string,
|
||||
search: req.query.search as string,
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
limit: parseInt(req.query.limit as string) || 20,
|
||||
@@ -33,7 +35,7 @@ export async function getCfdiById(req: Request, res: Response, next: NextFunctio
|
||||
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) {
|
||||
return next(new AppError(404, 'CFDI no encontrado'));
|
||||
@@ -45,6 +47,62 @@ export async function getCfdiById(req: Request, res: Response, next: NextFunctio
|
||||
}
|
||||
}
|
||||
|
||||
export async function getXml(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
}
|
||||
|
||||
const xml = await cfdiService.getXmlById(req.tenantSchema, String(req.params.id));
|
||||
|
||||
if (!xml) {
|
||||
return next(new AppError(404, 'XML no encontrado para este CFDI'));
|
||||
}
|
||||
|
||||
res.set('Content-Type', 'application/xml');
|
||||
res.set('Content-Disposition', `attachment; filename="cfdi-${req.params.id}.xml"`);
|
||||
res.send(xml);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEmisores(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
}
|
||||
|
||||
const search = (req.query.search as string) || '';
|
||||
if (search.length < 2) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
const emisores = await cfdiService.getEmisores(req.tenantSchema, search);
|
||||
res.json(emisores);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getReceptores(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
}
|
||||
|
||||
const search = (req.query.search as string) || '';
|
||||
if (search.length < 2) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
const receptores = await cfdiService.getReceptores(req.tenantSchema, search);
|
||||
res.json(receptores);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getResumen(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
@@ -131,7 +189,7 @@ export async function deleteCfdi(req: Request, res: Response, next: NextFunction
|
||||
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();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
const tenant = await tenantsService.getTenantById(req.params.id);
|
||||
const tenant = await tenantsService.getTenantById(String(req.params.id));
|
||||
if (!tenant) {
|
||||
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');
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const id = String(req.params.id);
|
||||
const { nombre, rfc, plan, cfdiLimit, usersLimit, active } = req.body;
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
await tenantsService.deleteTenant(req.params.id);
|
||||
await tenantsService.deleteTenant(String(req.params.id));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import * as alertasController from '../controllers/alertas.controller.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type IRouter } from 'express';
|
||||
import * as authController from '../controllers/auth.controller.js';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.post('/register', authController.register);
|
||||
router.post('/login', authController.login);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import * as calendarioController from '../controllers/calendario.controller.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import * as cfdiController from '../controllers/cfdi.controller.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
|
||||
router.get('/', cfdiController.getCfdis);
|
||||
router.get('/resumen', cfdiController.getResumen);
|
||||
router.get('/emisores', cfdiController.getEmisores);
|
||||
router.get('/receptores', cfdiController.getReceptores);
|
||||
router.get('/:id', cfdiController.getCfdiById);
|
||||
router.get('/:id/xml', cfdiController.getXml);
|
||||
router.post('/', cfdiController.createCfdi);
|
||||
router.post('/bulk', cfdiController.createManyCfdis);
|
||||
router.delete('/:id', cfdiController.deleteCfdi);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import * as dashboardController from '../controllers/dashboard.controller.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import * as exportController from '../controllers/export.controller.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type IRouter } from 'express';
|
||||
import * as fielController from '../controllers/fiel.controller.js';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
// Todas las rutas requieren autenticación
|
||||
router.use(authenticate);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import * as impuestosController from '../controllers/impuestos.controller.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import * as reportesController from '../controllers/reportes.controller.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type IRouter } from 'express';
|
||||
import * as satController from '../controllers/sat.controller.js';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
// Todas las rutas requieren autenticación
|
||||
router.use(authenticate);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import * as tenantsController from '../controllers/tenants.controller.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import * as usuariosController from '../controllers/usuarios.controller.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
|
||||
@@ -147,52 +147,56 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
|
||||
}
|
||||
|
||||
export async function refreshTokens(token: string): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
const storedToken = await prisma.refreshToken.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
// Use a transaction to prevent race conditions
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const storedToken = await tx.refreshToken.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
if (!storedToken) {
|
||||
throw new AppError(401, 'Token inválido');
|
||||
}
|
||||
if (!storedToken) {
|
||||
throw new AppError(401, 'Token inválido');
|
||||
}
|
||||
|
||||
if (storedToken.expiresAt < new Date()) {
|
||||
await prisma.refreshToken.delete({ where: { id: storedToken.id } });
|
||||
throw new AppError(401, 'Token expirado');
|
||||
}
|
||||
if (storedToken.expiresAt < new Date()) {
|
||||
await tx.refreshToken.deleteMany({ where: { id: storedToken.id } });
|
||||
throw new AppError(401, 'Token expirado');
|
||||
}
|
||||
|
||||
const payload = verifyToken(token);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.userId },
|
||||
include: { tenant: true },
|
||||
});
|
||||
const user = await tx.user.findUnique({
|
||||
where: { id: payload.userId },
|
||||
include: { tenant: true },
|
||||
});
|
||||
|
||||
if (!user || !user.active) {
|
||||
throw new AppError(401, 'Usuario no encontrado o desactivado');
|
||||
}
|
||||
if (!user || !user.active) {
|
||||
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 = {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
tenantId: user.tenantId,
|
||||
schemaName: user.tenant.schemaName,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(newTokenPayload);
|
||||
const refreshToken = generateRefreshToken(newTokenPayload);
|
||||
|
||||
await prisma.refreshToken.create({
|
||||
data: {
|
||||
const newTokenPayload = {
|
||||
userId: user.id,
|
||||
token: refreshToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
tenantId: user.tenantId,
|
||||
schemaName: user.tenant.schemaName,
|
||||
};
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
const accessToken = generateAccessToken(newTokenPayload);
|
||||
const refreshToken = generateRefreshToken(newTokenPayload);
|
||||
|
||||
await tx.refreshToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token: refreshToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
});
|
||||
}
|
||||
|
||||
export async function logout(token: string): Promise<void> {
|
||||
|
||||
@@ -21,12 +21,12 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
|
||||
}
|
||||
|
||||
if (filters.fechaInicio) {
|
||||
whereClause += ` AND fecha_emision >= $${paramIndex++}`;
|
||||
whereClause += ` AND fecha_emision >= $${paramIndex++}::date`;
|
||||
params.push(filters.fechaInicio);
|
||||
}
|
||||
|
||||
if (filters.fechaFin) {
|
||||
whereClause += ` AND fecha_emision <= $${paramIndex++}`;
|
||||
whereClause += ` AND fecha_emision <= ($${paramIndex++}::date + interval '1 day')`;
|
||||
params.push(filters.fechaFin);
|
||||
}
|
||||
|
||||
@@ -35,19 +35,24 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
|
||||
params.push(`%${filters.rfc}%`);
|
||||
}
|
||||
|
||||
if (filters.emisor) {
|
||||
whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex++})`;
|
||||
params.push(`%${filters.emisor}%`);
|
||||
}
|
||||
|
||||
if (filters.receptor) {
|
||||
whereClause += ` AND (rfc_receptor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`;
|
||||
params.push(`%${filters.receptor}%`);
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
whereClause += ` AND (uuid_fiscal ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`;
|
||||
params.push(`%${filters.search}%`);
|
||||
}
|
||||
|
||||
const countResult = await prisma.$queryRawUnsafe<[{ count: number }]>(`
|
||||
SELECT COUNT(*) as count FROM "${schema}".cfdis ${whereClause}
|
||||
`, ...params);
|
||||
|
||||
const total = Number(countResult[0]?.count || 0);
|
||||
|
||||
// Combinar COUNT con la query principal usando window function
|
||||
params.push(limit, offset);
|
||||
const data = await prisma.$queryRawUnsafe<Cfdi[]>(`
|
||||
const dataWithCount = await prisma.$queryRawUnsafe<(Cfdi & { total_count: number })[]>(`
|
||||
SELECT
|
||||
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
|
||||
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
|
||||
@@ -58,13 +63,17 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
|
||||
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
|
||||
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
||||
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
||||
created_at as "createdAt"
|
||||
created_at as "createdAt",
|
||||
COUNT(*) OVER() as total_count
|
||||
FROM "${schema}".cfdis
|
||||
${whereClause}
|
||||
ORDER BY fecha_emision DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||
`, ...params);
|
||||
|
||||
const total = Number(dataWithCount[0]?.total_count || 0);
|
||||
const data = dataWithCount.map(({ total_count, ...cfdi }) => cfdi) as Cfdi[];
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
@@ -86,14 +95,23 @@ export async function getCfdiById(schema: string, id: string): Promise<Cfdi | nu
|
||||
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
|
||||
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
||||
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
||||
xml_original as "xmlOriginal",
|
||||
created_at as "createdAt"
|
||||
FROM "${schema}".cfdis
|
||||
WHERE id = $1
|
||||
WHERE id = $1::uuid
|
||||
`, id);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
export async function getXmlById(schema: string, id: string): Promise<string | null> {
|
||||
const result = await prisma.$queryRawUnsafe<[{ xml_original: string | null }]>(`
|
||||
SELECT xml_original FROM "${schema}".cfdis WHERE id = $1::uuid
|
||||
`, id);
|
||||
|
||||
return result[0]?.xml_original || null;
|
||||
}
|
||||
|
||||
export interface CreateCfdiData {
|
||||
uuidFiscal: string;
|
||||
tipo: 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago';
|
||||
@@ -368,6 +386,28 @@ export async function deleteCfdi(schema: string, id: string): Promise<void> {
|
||||
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".cfdis WHERE id = $1`, id);
|
||||
}
|
||||
|
||||
export async function getEmisores(schema: string, search: string, limit: number = 10): Promise<{ rfc: string; nombre: string }[]> {
|
||||
const result = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string }[]>(`
|
||||
SELECT DISTINCT rfc_emisor as rfc, nombre_emisor as nombre
|
||||
FROM "${schema}".cfdis
|
||||
WHERE rfc_emisor ILIKE $1 OR nombre_emisor ILIKE $1
|
||||
ORDER BY nombre_emisor
|
||||
LIMIT $2
|
||||
`, `%${search}%`, limit);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getReceptores(schema: string, search: string, limit: number = 10): Promise<{ rfc: string; nombre: string }[]> {
|
||||
const result = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string }[]>(`
|
||||
SELECT DISTINCT rfc_receptor as rfc, nombre_receptor as nombre
|
||||
FROM "${schema}".cfdis
|
||||
WHERE rfc_receptor ILIKE $1 OR nombre_receptor ILIKE $1
|
||||
ORDER BY nombre_receptor
|
||||
LIMIT $2
|
||||
`, `%${search}%`, limit);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getResumenCfdis(schema: string, año: number, mes: number) {
|
||||
const result = await prisma.$queryRawUnsafe<[{
|
||||
total_ingresos: number;
|
||||
|
||||
@@ -96,7 +96,7 @@ async function saveCfdis(
|
||||
estado = $22,
|
||||
xml_original = $23,
|
||||
last_sat_sync = NOW(),
|
||||
sat_sync_job_id = $24,
|
||||
sat_sync_job_id = $24::uuid,
|
||||
updated_at = NOW()
|
||||
WHERE uuid_fiscal = $1`,
|
||||
cfdi.uuidFiscal,
|
||||
@@ -137,7 +137,7 @@ async function saveCfdis(
|
||||
) 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()
|
||||
$23, 'sat', $24::uuid, NOW(), NOW()
|
||||
)`,
|
||||
cfdi.uuidFiscal,
|
||||
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 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 totalDownloaded = 0;
|
||||
@@ -292,9 +299,9 @@ async function processInitialSync(ctx: SyncContext, jobId: string): Promise<void
|
||||
// Procesar por meses para evitar límites del SAT
|
||||
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 rangeEnd = monthEnd > ahora ? ahora : monthEnd;
|
||||
const rangeEnd = monthEnd > fechaFin ? fechaFin : monthEnd;
|
||||
|
||||
// Procesar emitidos
|
||||
try {
|
||||
@@ -446,7 +453,7 @@ export async function startSync(
|
||||
(async () => {
|
||||
try {
|
||||
if (type === 'initial') {
|
||||
await processInitialSync(ctx, job.id);
|
||||
await processInitialSync(ctx, job.id, dateFrom, dateTo);
|
||||
} else {
|
||||
await processDailySync(ctx, job.id);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import jwt, { type SignOptions } from 'jsonwebtoken';
|
||||
import type { JWTPayload } from '@horux/shared';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
|
||||
return jwt.sign(payload, env.JWT_SECRET, {
|
||||
expiresIn: env.JWT_EXPIRES_IN,
|
||||
});
|
||||
const options: SignOptions = {
|
||||
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 {
|
||||
return jwt.sign(payload, env.JWT_SECRET, {
|
||||
expiresIn: env.JWT_REFRESH_EXPIRES_IN,
|
||||
});
|
||||
const options: SignOptions = {
|
||||
expiresIn: env.JWT_REFRESH_EXPIRES_IN as SignOptions['expiresIn'],
|
||||
};
|
||||
return jwt.sign(payload, env.JWT_SECRET, options);
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): JWTPayload {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useDebounce } from '@/lib/hooks/use-debounce';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -8,10 +9,15 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
|
||||
import { createManyCfdis } from '@/lib/api/cfdi';
|
||||
import type { CfdiFilters, TipoCfdi } from '@horux/shared';
|
||||
import { createManyCfdis, searchEmisores, searchReceptores, type EmisorReceptor } from '@/lib/api/cfdi';
|
||||
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
|
||||
import type { CreateCfdiData } from '@/lib/api/cfdi';
|
||||
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2, Eye, Filter, XCircle, Calendar, User, Building2, Download, Printer } from 'lucide-react';
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
import { getCfdiById } from '@/lib/api/cfdi';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useTenantViewStore } from '@/stores/tenant-view-store';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
@@ -228,7 +234,49 @@ export default function CfdiPage() {
|
||||
limit: 20,
|
||||
});
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [columnFilters, setColumnFilters] = useState({
|
||||
fechaInicio: '',
|
||||
fechaFin: '',
|
||||
emisor: '',
|
||||
receptor: '',
|
||||
});
|
||||
const [openFilter, setOpenFilter] = useState<'fecha' | 'emisor' | 'receptor' | null>(null);
|
||||
const [emisorSuggestions, setEmisorSuggestions] = useState<EmisorReceptor[]>([]);
|
||||
const [receptorSuggestions, setReceptorSuggestions] = useState<EmisorReceptor[]>([]);
|
||||
const [loadingEmisor, setLoadingEmisor] = useState(false);
|
||||
const [loadingReceptor, setLoadingReceptor] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
// Debounced values for autocomplete
|
||||
const debouncedEmisor = useDebounce(columnFilters.emisor, 300);
|
||||
const debouncedReceptor = useDebounce(columnFilters.receptor, 300);
|
||||
|
||||
// Fetch emisor suggestions when debounced value changes
|
||||
useEffect(() => {
|
||||
if (debouncedEmisor.length < 2) {
|
||||
setEmisorSuggestions([]);
|
||||
return;
|
||||
}
|
||||
setLoadingEmisor(true);
|
||||
searchEmisores(debouncedEmisor)
|
||||
.then(setEmisorSuggestions)
|
||||
.catch(() => setEmisorSuggestions([]))
|
||||
.finally(() => setLoadingEmisor(false));
|
||||
}, [debouncedEmisor]);
|
||||
|
||||
// Fetch receptor suggestions when debounced value changes
|
||||
useEffect(() => {
|
||||
if (debouncedReceptor.length < 2) {
|
||||
setReceptorSuggestions([]);
|
||||
return;
|
||||
}
|
||||
setLoadingReceptor(true);
|
||||
searchReceptores(debouncedReceptor)
|
||||
.then(setReceptorSuggestions)
|
||||
.catch(() => setReceptorSuggestions([]))
|
||||
.finally(() => setLoadingReceptor(false));
|
||||
}, [debouncedReceptor]);
|
||||
|
||||
const [showBulkForm, setShowBulkForm] = useState(false);
|
||||
const [formData, setFormData] = useState<CreateCfdiData>(initialFormData);
|
||||
const [bulkData, setBulkData] = useState('');
|
||||
@@ -255,12 +303,138 @@ export default function CfdiPage() {
|
||||
const createCfdi = useCreateCfdi();
|
||||
const deleteCfdi = useDeleteCfdi();
|
||||
|
||||
// CFDI Viewer state
|
||||
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
|
||||
const [loadingCfdi, setLoadingCfdi] = useState<string | null>(null);
|
||||
|
||||
const handleViewCfdi = async (id: string) => {
|
||||
setLoadingCfdi(id);
|
||||
try {
|
||||
const cfdi = await getCfdiById(id);
|
||||
setViewingCfdi(cfdi);
|
||||
} catch (error) {
|
||||
console.error('Error loading CFDI:', error);
|
||||
alert('Error al cargar el CFDI');
|
||||
} finally {
|
||||
setLoadingCfdi(null);
|
||||
}
|
||||
};
|
||||
|
||||
const canEdit = user?.role === 'admin' || user?.role === 'contador';
|
||||
|
||||
const handleSearch = () => {
|
||||
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||
};
|
||||
|
||||
// Export to Excel
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const exportToExcel = async () => {
|
||||
if (!data?.data.length) return;
|
||||
|
||||
setExporting(true);
|
||||
try {
|
||||
const exportData = data.data.map(cfdi => ({
|
||||
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
|
||||
'Tipo': cfdi.tipo === 'ingreso' ? 'Ingreso' : 'Egreso',
|
||||
'Serie': cfdi.serie || '',
|
||||
'Folio': cfdi.folio || '',
|
||||
'RFC Emisor': cfdi.rfcEmisor,
|
||||
'Nombre Emisor': cfdi.nombreEmisor,
|
||||
'RFC Receptor': cfdi.rfcReceptor,
|
||||
'Nombre Receptor': cfdi.nombreReceptor,
|
||||
'Subtotal': cfdi.subtotal,
|
||||
'IVA': cfdi.iva,
|
||||
'Total': cfdi.total,
|
||||
'Moneda': cfdi.moneda,
|
||||
'Estado': cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado',
|
||||
'UUID': cfdi.uuidFiscal,
|
||||
}));
|
||||
|
||||
const ws = XLSX.utils.json_to_sheet(exportData);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'CFDIs');
|
||||
|
||||
// Auto-size columns
|
||||
const colWidths = Object.keys(exportData[0]).map(key => ({
|
||||
wch: Math.max(key.length, ...exportData.map(row => String(row[key as keyof typeof row]).length))
|
||||
}));
|
||||
ws['!cols'] = colWidths;
|
||||
|
||||
const excelBuffer = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
|
||||
const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
|
||||
const fileName = `cfdis_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
saveAs(blob, fileName);
|
||||
} catch (error) {
|
||||
console.error('Error exporting:', error);
|
||||
alert('Error al exportar');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectEmisor = (emisor: EmisorReceptor) => {
|
||||
setColumnFilters(prev => ({ ...prev, emisor: emisor.nombre }));
|
||||
setEmisorSuggestions([]);
|
||||
};
|
||||
|
||||
const selectReceptor = (receptor: EmisorReceptor) => {
|
||||
setColumnFilters(prev => ({ ...prev, receptor: receptor.nombre }));
|
||||
setReceptorSuggestions([]);
|
||||
};
|
||||
|
||||
const applyDateFilter = () => {
|
||||
setFilters({
|
||||
...filters,
|
||||
fechaInicio: columnFilters.fechaInicio || undefined,
|
||||
fechaFin: columnFilters.fechaFin || undefined,
|
||||
page: 1,
|
||||
});
|
||||
setOpenFilter(null);
|
||||
};
|
||||
|
||||
const applyEmisorFilter = () => {
|
||||
setFilters({
|
||||
...filters,
|
||||
emisor: columnFilters.emisor || undefined,
|
||||
page: 1,
|
||||
});
|
||||
setOpenFilter(null);
|
||||
};
|
||||
|
||||
const applyReceptorFilter = () => {
|
||||
setFilters({
|
||||
...filters,
|
||||
receptor: columnFilters.receptor || undefined,
|
||||
page: 1,
|
||||
});
|
||||
setOpenFilter(null);
|
||||
};
|
||||
|
||||
const clearDateFilter = () => {
|
||||
setColumnFilters({ ...columnFilters, fechaInicio: '', fechaFin: '' });
|
||||
setFilters({ ...filters, fechaInicio: undefined, fechaFin: undefined, page: 1 });
|
||||
setOpenFilter(null);
|
||||
};
|
||||
|
||||
const clearEmisorFilter = () => {
|
||||
setColumnFilters({ ...columnFilters, emisor: '' });
|
||||
setFilters({ ...filters, emisor: undefined, page: 1 });
|
||||
setOpenFilter(null);
|
||||
};
|
||||
|
||||
const clearReceptorFilter = () => {
|
||||
setColumnFilters({ ...columnFilters, receptor: '' });
|
||||
setFilters({ ...filters, receptor: undefined, page: 1 });
|
||||
setOpenFilter(null);
|
||||
};
|
||||
|
||||
const hasDateFilter = filters.fechaInicio || filters.fechaFin;
|
||||
const hasEmisorFilter = filters.emisor;
|
||||
const hasReceptorFilter = filters.receptor;
|
||||
const hasActiveColumnFilters = hasDateFilter || hasEmisorFilter || hasReceptorFilter;
|
||||
|
||||
const handleFilterType = (tipo?: TipoCfdi) => {
|
||||
setFilters({ ...filters, tipo, page: 1 });
|
||||
};
|
||||
@@ -471,6 +645,32 @@ export default function CfdiPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcuts - Esc to close popovers and forms
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
// Close open filter popovers
|
||||
if (openFilter !== null) {
|
||||
setOpenFilter(null);
|
||||
return;
|
||||
}
|
||||
// Close forms
|
||||
if (showForm) {
|
||||
setShowForm(false);
|
||||
return;
|
||||
}
|
||||
if (showBulkForm) {
|
||||
setShowBulkForm(false);
|
||||
clearXmlFiles();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [openFilter, showForm, showBulkForm]);
|
||||
|
||||
const cancelUpload = () => {
|
||||
uploadAbortRef.current = true;
|
||||
setUploadProgress(prev => ({ ...prev, status: 'idle' }));
|
||||
@@ -558,18 +758,30 @@ export default function CfdiPage() {
|
||||
Egresos
|
||||
</Button>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => { setShowForm(true); setShowBulkForm(false); }}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Agregar
|
||||
<div className="flex gap-2">
|
||||
{data && data.data.length > 0 && (
|
||||
<Button variant="outline" onClick={exportToExcel} disabled={exporting}>
|
||||
{exporting ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Exportar
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => { setShowBulkForm(true); setShowForm(false); }}>
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
Carga Masiva
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button onClick={() => { setShowForm(true); setShowBulkForm(false); }}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Agregar
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => { setShowBulkForm(true); setShowForm(false); }}>
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
Carga Masiva
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1041,15 +1253,58 @@ export default function CfdiPage() {
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4" />
|
||||
CFDIs ({data?.total || 0})
|
||||
</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4" />
|
||||
CFDIs ({data?.total || 0})
|
||||
</CardTitle>
|
||||
{hasActiveColumnFilters && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Filtros activos:</span>
|
||||
{hasDateFilter && (
|
||||
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-full flex items-center gap-1">
|
||||
Fecha
|
||||
<button onClick={clearDateFilter} className="hover:text-destructive">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{hasEmisorFilter && (
|
||||
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-full flex items-center gap-1">
|
||||
Emisor: {filters.emisor}
|
||||
<button onClick={clearEmisorFilter} className="hover:text-destructive">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{hasReceptorFilter && (
|
||||
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-full flex items-center gap-1">
|
||||
Receptor: {filters.receptor}
|
||||
<button onClick={clearReceptorFilter} className="hover:text-destructive">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Cargando...
|
||||
<div className="space-y-3">
|
||||
{/* Skeleton loader */}
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 animate-pulse">
|
||||
<div className="h-4 bg-muted rounded w-20"></div>
|
||||
<div className="h-5 bg-muted rounded w-16"></div>
|
||||
<div className="h-4 bg-muted rounded w-12"></div>
|
||||
<div className="h-4 bg-muted rounded flex-1 max-w-[180px]"></div>
|
||||
<div className="h-4 bg-muted rounded flex-1 max-w-[180px]"></div>
|
||||
<div className="h-4 bg-muted rounded w-24 ml-auto"></div>
|
||||
<div className="h-5 bg-muted rounded w-16"></div>
|
||||
<div className="h-8 bg-muted rounded w-8"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : data?.data.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
@@ -1060,13 +1315,172 @@ export default function CfdiPage() {
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-sm text-muted-foreground">
|
||||
<th className="pb-3 font-medium">Fecha</th>
|
||||
<th className="pb-3 font-medium">
|
||||
<div className="flex items-center gap-1">
|
||||
Fecha
|
||||
<Popover open={openFilter === 'fecha'} onOpenChange={(open) => setOpenFilter(open ? 'fecha' : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${hasDateFilter ? 'text-primary' : ''}`}>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="start">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Filtrar por fecha</h4>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs">Desde</Label>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8 text-sm"
|
||||
value={columnFilters.fechaInicio}
|
||||
onChange={(e) => setColumnFilters({ ...columnFilters, fechaInicio: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Hasta</Label>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8 text-sm"
|
||||
value={columnFilters.fechaFin}
|
||||
onChange={(e) => setColumnFilters({ ...columnFilters, fechaFin: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1" onClick={applyDateFilter}>
|
||||
Aplicar
|
||||
</Button>
|
||||
{hasDateFilter && (
|
||||
<Button size="sm" variant="outline" onClick={clearDateFilter}>
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">Tipo</th>
|
||||
<th className="pb-3 font-medium">Folio</th>
|
||||
<th className="pb-3 font-medium">Emisor</th>
|
||||
<th className="pb-3 font-medium">Receptor</th>
|
||||
<th className="pb-3 font-medium">
|
||||
<div className="flex items-center gap-1">
|
||||
Emisor
|
||||
<Popover open={openFilter === 'emisor'} onOpenChange={(open) => setOpenFilter(open ? 'emisor' : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${hasEmisorFilter ? 'text-primary' : ''}`}>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="start">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Filtrar por emisor</h4>
|
||||
<div className="relative">
|
||||
<Label className="text-xs">RFC o Nombre</Label>
|
||||
<Input
|
||||
placeholder="Buscar emisor..."
|
||||
className="h-8 text-sm"
|
||||
value={columnFilters.emisor}
|
||||
onChange={(e) => setColumnFilters(prev => ({ ...prev, emisor: e.target.value }))}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyEmisorFilter()}
|
||||
/>
|
||||
{emisorSuggestions.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border rounded-md shadow-lg max-h-48 overflow-y-auto z-50">
|
||||
{emisorSuggestions.map((emisor, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-muted transition-colors border-b last:border-b-0"
|
||||
onClick={() => selectEmisor(emisor)}
|
||||
>
|
||||
<p className="font-medium truncate">{emisor.nombre}</p>
|
||||
<p className="text-xs text-muted-foreground">{emisor.rfc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{loadingEmisor && columnFilters.emisor.length >= 2 && emisorSuggestions.length === 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border rounded-md shadow-lg p-2 text-center text-sm text-muted-foreground">
|
||||
Buscando...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1" onClick={applyEmisorFilter}>
|
||||
Aplicar
|
||||
</Button>
|
||||
{hasEmisorFilter && (
|
||||
<Button size="sm" variant="outline" onClick={clearEmisorFilter}>
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">
|
||||
<div className="flex items-center gap-1">
|
||||
Receptor
|
||||
<Popover open={openFilter === 'receptor'} onOpenChange={(open) => setOpenFilter(open ? 'receptor' : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${hasReceptorFilter ? 'text-primary' : ''}`}>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="start">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Filtrar por receptor</h4>
|
||||
<div className="relative">
|
||||
<Label className="text-xs">RFC o Nombre</Label>
|
||||
<Input
|
||||
placeholder="Buscar receptor..."
|
||||
className="h-8 text-sm"
|
||||
value={columnFilters.receptor}
|
||||
onChange={(e) => setColumnFilters(prev => ({ ...prev, receptor: e.target.value }))}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyReceptorFilter()}
|
||||
/>
|
||||
{receptorSuggestions.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border rounded-md shadow-lg max-h-48 overflow-y-auto z-50">
|
||||
{receptorSuggestions.map((receptor, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-muted transition-colors border-b last:border-b-0"
|
||||
onClick={() => selectReceptor(receptor)}
|
||||
>
|
||||
<p className="font-medium truncate">{receptor.nombre}</p>
|
||||
<p className="text-xs text-muted-foreground">{receptor.rfc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{loadingReceptor && columnFilters.receptor.length >= 2 && receptorSuggestions.length === 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border rounded-md shadow-lg p-2 text-center text-sm text-muted-foreground">
|
||||
Buscando...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1" onClick={applyReceptorFilter}>
|
||||
Aplicar
|
||||
</Button>
|
||||
{hasReceptorFilter && (
|
||||
<Button size="sm" variant="outline" onClick={clearReceptorFilter}>
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</th>
|
||||
<th className="pb-3 font-medium text-right">Total</th>
|
||||
<th className="pb-3 font-medium">Estado</th>
|
||||
<th className="pb-3 font-medium"></th>
|
||||
{canEdit && <th className="pb-3 font-medium"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1122,6 +1536,21 @@ export default function CfdiPage() {
|
||||
{cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleViewCfdi(cfdi.id)}
|
||||
disabled={loadingCfdi === cfdi.id}
|
||||
title="Ver factura"
|
||||
>
|
||||
{loadingCfdi === cfdi.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</td>
|
||||
{canEdit && (
|
||||
<td className="py-3">
|
||||
<Button
|
||||
@@ -1174,6 +1603,12 @@ export default function CfdiPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
|
||||
<CfdiViewerModal
|
||||
cfdi={viewingCfdi}
|
||||
open={viewingCfdi !== null}
|
||||
onClose={() => setViewingCfdi(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
317
apps/web/components/cfdi/cfdi-invoice.tsx
Normal file
317
apps/web/components/cfdi/cfdi-invoice.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef } from 'react';
|
||||
import type { Cfdi } from '@horux/shared';
|
||||
|
||||
interface CfdiConcepto {
|
||||
descripcion: string;
|
||||
cantidad: number;
|
||||
valorUnitario: number;
|
||||
importe: number;
|
||||
claveUnidad?: string;
|
||||
claveProdServ?: string;
|
||||
}
|
||||
|
||||
interface CfdiInvoiceProps {
|
||||
cfdi: Cfdi;
|
||||
conceptos?: CfdiConcepto[];
|
||||
}
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
}).format(value);
|
||||
|
||||
const formatDate = (dateString: string) =>
|
||||
new Date(dateString).toLocaleDateString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const formatDateTime = (dateString: string) =>
|
||||
new Date(dateString).toLocaleString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const tipoLabels: Record<string, string> = {
|
||||
ingreso: 'Ingreso',
|
||||
egreso: 'Egreso',
|
||||
traslado: 'Traslado',
|
||||
pago: 'Pago',
|
||||
nomina: 'Nómina',
|
||||
};
|
||||
|
||||
const formaPagoLabels: Record<string, string> = {
|
||||
'01': 'Efectivo',
|
||||
'02': 'Cheque nominativo',
|
||||
'03': 'Transferencia electrónica',
|
||||
'04': 'Tarjeta de crédito',
|
||||
'28': 'Tarjeta de débito',
|
||||
'99': 'Por definir',
|
||||
};
|
||||
|
||||
const metodoPagoLabels: Record<string, string> = {
|
||||
PUE: 'Pago en una sola exhibición',
|
||||
PPD: 'Pago en parcialidades o diferido',
|
||||
};
|
||||
|
||||
const usoCfdiLabels: Record<string, string> = {
|
||||
G01: 'Adquisición de mercancías',
|
||||
G02: 'Devoluciones, descuentos o bonificaciones',
|
||||
G03: 'Gastos en general',
|
||||
I01: 'Construcciones',
|
||||
I02: 'Mobilario y equipo de oficina',
|
||||
I03: 'Equipo de transporte',
|
||||
I04: 'Equipo de cómputo',
|
||||
I05: 'Dados, troqueles, moldes',
|
||||
I06: 'Comunicaciones telefónicas',
|
||||
I07: 'Comunicaciones satelitales',
|
||||
I08: 'Otra maquinaria y equipo',
|
||||
D01: 'Honorarios médicos',
|
||||
D02: 'Gastos médicos por incapacidad',
|
||||
D03: 'Gastos funerales',
|
||||
D04: 'Donativos',
|
||||
D05: 'Intereses por créditos hipotecarios',
|
||||
D06: 'Aportaciones voluntarias SAR',
|
||||
D07: 'Primas por seguros de gastos médicos',
|
||||
D08: 'Gastos de transportación escolar',
|
||||
D09: 'Depósitos en cuentas para el ahorro',
|
||||
D10: 'Pagos por servicios educativos',
|
||||
P01: 'Por definir',
|
||||
S01: 'Sin efectos fiscales',
|
||||
CP01: 'Pagos',
|
||||
CN01: 'Nómina',
|
||||
};
|
||||
|
||||
export const CfdiInvoice = forwardRef<HTMLDivElement, CfdiInvoiceProps>(
|
||||
({ cfdi, conceptos }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="bg-white text-gray-800 max-w-[850px] mx-auto text-sm shadow-lg"
|
||||
style={{ fontFamily: 'Segoe UI, Roboto, Arial, sans-serif' }}
|
||||
>
|
||||
{/* Header con gradiente */}
|
||||
<div className="bg-gradient-to-r from-blue-700 to-blue-900 text-white p-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold opacity-90">Emisor</h2>
|
||||
<p className="text-xl font-bold mt-1">{cfdi.nombreEmisor}</p>
|
||||
<p className="text-blue-200 text-sm mt-1">RFC: {cfdi.rfcEmisor}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center justify-end gap-3 mb-2">
|
||||
<span
|
||||
className={`px-3 py-1 text-xs font-bold rounded-full ${
|
||||
cfdi.estado === 'vigente'
|
||||
? 'bg-green-400 text-green-900'
|
||||
: 'bg-red-400 text-red-900'
|
||||
}`}
|
||||
>
|
||||
{cfdi.estado === 'vigente' ? 'VIGENTE' : 'CANCELADO'}
|
||||
</span>
|
||||
<span className="px-3 py-1 text-xs font-bold rounded-full bg-white/20">
|
||||
{tipoLabels[cfdi.tipo] || cfdi.tipo}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold tracking-tight">
|
||||
{cfdi.serie && <span className="text-blue-300">{cfdi.serie}-</span>}
|
||||
{cfdi.folio || 'S/N'}
|
||||
</div>
|
||||
<p className="text-blue-200 text-sm mt-1">{formatDate(cfdi.fechaEmision)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Receptor */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-5 border-l-4 border-blue-600">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Receptor</p>
|
||||
<p className="text-lg font-semibold text-gray-800 mt-1">{cfdi.nombreReceptor}</p>
|
||||
<p className="text-gray-600 text-sm">RFC: {cfdi.rfcReceptor}</p>
|
||||
</div>
|
||||
{cfdi.usoCfdi && (
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Uso CFDI</p>
|
||||
<p className="text-sm font-medium text-gray-700 mt-1">
|
||||
{cfdi.usoCfdi} - {usoCfdiLabels[cfdi.usoCfdi] || ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Datos del Comprobante */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-5">
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide">Método Pago</p>
|
||||
<p className="text-sm font-semibold text-gray-800 mt-1">
|
||||
{cfdi.metodoPago || '-'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{cfdi.metodoPago ? metodoPagoLabels[cfdi.metodoPago] || '' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide">Forma Pago</p>
|
||||
<p className="text-sm font-semibold text-gray-800 mt-1">
|
||||
{cfdi.formaPago || '-'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{cfdi.formaPago ? formaPagoLabels[cfdi.formaPago] || '' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide">Moneda</p>
|
||||
<p className="text-sm font-semibold text-gray-800 mt-1">{cfdi.moneda || 'MXN'}</p>
|
||||
{cfdi.tipoCambio && cfdi.tipoCambio !== 1 && (
|
||||
<p className="text-xs text-gray-500">TC: {cfdi.tipoCambio}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide">Versión</p>
|
||||
<p className="text-sm font-semibold text-gray-800 mt-1">CFDI 4.0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conceptos */}
|
||||
{conceptos && conceptos.length > 0 && (
|
||||
<div className="mb-5">
|
||||
<h3 className="text-xs text-gray-500 uppercase tracking-wide font-medium mb-2 flex items-center gap-2">
|
||||
<span className="w-1 h-4 bg-blue-600 rounded-full"></span>
|
||||
Conceptos
|
||||
</h3>
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-700">Descripción</th>
|
||||
<th className="text-center py-3 px-3 font-semibold text-gray-700 w-20">Cant.</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-700 w-32">P. Unitario</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-700 w-32">Importe</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{conceptos.map((concepto, idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
className={`border-t border-gray-100 ${idx % 2 === 1 ? 'bg-gray-50/50' : ''}`}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<p className="text-gray-800">{concepto.descripcion}</p>
|
||||
{concepto.claveProdServ && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Clave: {concepto.claveProdServ}
|
||||
{concepto.claveUnidad && ` | Unidad: ${concepto.claveUnidad}`}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center py-3 px-3 text-gray-700">{concepto.cantidad}</td>
|
||||
<td className="text-right py-3 px-4 text-gray-700">
|
||||
{formatCurrency(concepto.valorUnitario)}
|
||||
</td>
|
||||
<td className="text-right py-3 px-4 font-medium text-gray-800">
|
||||
{formatCurrency(concepto.importe)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Totales */}
|
||||
<div className="flex justify-end mb-5">
|
||||
<div className="w-80 bg-gray-50 rounded-lg overflow-hidden">
|
||||
<div className="divide-y divide-gray-200">
|
||||
<div className="flex justify-between py-2.5 px-4">
|
||||
<span className="text-gray-600">Subtotal</span>
|
||||
<span className="font-medium">{formatCurrency(cfdi.subtotal)}</span>
|
||||
</div>
|
||||
{cfdi.descuento > 0 && (
|
||||
<div className="flex justify-between py-2.5 px-4">
|
||||
<span className="text-gray-600">Descuento</span>
|
||||
<span className="font-medium text-red-600">-{formatCurrency(cfdi.descuento)}</span>
|
||||
</div>
|
||||
)}
|
||||
{cfdi.iva > 0 && (
|
||||
<div className="flex justify-between py-2.5 px-4">
|
||||
<span className="text-gray-600">IVA (16%)</span>
|
||||
<span className="font-medium">{formatCurrency(cfdi.iva)}</span>
|
||||
</div>
|
||||
)}
|
||||
{cfdi.ivaRetenido > 0 && (
|
||||
<div className="flex justify-between py-2.5 px-4">
|
||||
<span className="text-gray-600">IVA Retenido</span>
|
||||
<span className="font-medium text-red-600">-{formatCurrency(cfdi.ivaRetenido)}</span>
|
||||
</div>
|
||||
)}
|
||||
{cfdi.isrRetenido > 0 && (
|
||||
<div className="flex justify-between py-2.5 px-4">
|
||||
<span className="text-gray-600">ISR Retenido</span>
|
||||
<span className="font-medium text-red-600">-{formatCurrency(cfdi.isrRetenido)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-blue-700 text-white py-3 px-4 flex justify-between items-center">
|
||||
<span className="font-semibold">TOTAL</span>
|
||||
<span className="text-xl font-bold">{formatCurrency(cfdi.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timbre Fiscal Digital */}
|
||||
<div className="bg-gradient-to-r from-gray-100 to-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<div className="flex gap-4">
|
||||
{/* QR Placeholder */}
|
||||
<div className="w-24 h-24 bg-white border-2 border-gray-300 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<div className="text-center">
|
||||
<svg className="w-12 h-12 text-gray-400 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h2M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
|
||||
</svg>
|
||||
<span className="text-[10px] text-gray-400 mt-1 block">QR</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info del Timbre */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-xs text-gray-500 uppercase tracking-wide font-semibold mb-2 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
Timbre Fiscal Digital
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
<div>
|
||||
<span className="text-xs text-gray-500">UUID: </span>
|
||||
<span className="text-xs font-mono text-blue-700 font-medium">{cfdi.uuidFiscal}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500">Fecha de Timbrado: </span>
|
||||
<span className="text-xs font-medium text-gray-700">{formatDateTime(cfdi.fechaTimbrado)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leyenda */}
|
||||
<p className="text-[10px] text-gray-400 mt-3 text-center border-t border-gray-200 pt-2">
|
||||
Este documento es una representación impresa de un CFDI • Verificable en: https://verificacfdi.facturaelectronica.sat.gob.mx
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CfdiInvoice.displayName = 'CfdiInvoice';
|
||||
218
apps/web/components/cfdi/cfdi-viewer-modal.tsx
Normal file
218
apps/web/components/cfdi/cfdi-viewer-modal.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import type { Cfdi } from '@horux/shared';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CfdiInvoice } from './cfdi-invoice';
|
||||
import { getCfdiXml } from '@/lib/api/cfdi';
|
||||
import { Download, FileText, Loader2, Printer } from 'lucide-react';
|
||||
|
||||
interface CfdiConcepto {
|
||||
descripcion: string;
|
||||
cantidad: number;
|
||||
valorUnitario: number;
|
||||
importe: number;
|
||||
}
|
||||
|
||||
interface CfdiViewerModalProps {
|
||||
cfdi: Cfdi | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function parseConceptosFromXml(xmlString: string): CfdiConcepto[] {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xmlString, 'text/xml');
|
||||
const conceptos: CfdiConcepto[] = [];
|
||||
|
||||
// Find all Concepto elements
|
||||
const elements = doc.getElementsByTagName('*');
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
if (elements[i].localName === 'Concepto') {
|
||||
const el = elements[i];
|
||||
conceptos.push({
|
||||
descripcion: el.getAttribute('Descripcion') || '',
|
||||
cantidad: parseFloat(el.getAttribute('Cantidad') || '1'),
|
||||
valorUnitario: parseFloat(el.getAttribute('ValorUnitario') || '0'),
|
||||
importe: parseFloat(el.getAttribute('Importe') || '0'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return conceptos;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function CfdiViewerModal({ cfdi, open, onClose }: CfdiViewerModalProps) {
|
||||
const invoiceRef = useRef<HTMLDivElement>(null);
|
||||
const [conceptos, setConceptos] = useState<CfdiConcepto[]>([]);
|
||||
const [downloading, setDownloading] = useState<'pdf' | 'xml' | null>(null);
|
||||
const [xmlContent, setXmlContent] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (cfdi?.xmlOriginal) {
|
||||
setXmlContent(cfdi.xmlOriginal);
|
||||
setConceptos(parseConceptosFromXml(cfdi.xmlOriginal));
|
||||
} else {
|
||||
setXmlContent(null);
|
||||
setConceptos([]);
|
||||
}
|
||||
}, [cfdi]);
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
if (!invoiceRef.current || !cfdi) return;
|
||||
|
||||
setDownloading('pdf');
|
||||
try {
|
||||
const html2pdf = (await import('html2pdf.js')).default;
|
||||
|
||||
const opt = {
|
||||
margin: 10,
|
||||
filename: `factura-${cfdi.uuidFiscal.substring(0, 8)}.pdf`,
|
||||
image: { type: 'jpeg' as const, quality: 0.98 },
|
||||
html2canvas: { scale: 2, useCORS: true },
|
||||
jsPDF: { unit: 'mm' as const, format: 'a4' as const, orientation: 'portrait' as const },
|
||||
};
|
||||
|
||||
await html2pdf().set(opt).from(invoiceRef.current).save();
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
alert('Error al generar el PDF');
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadXml = async () => {
|
||||
if (!cfdi) return;
|
||||
|
||||
setDownloading('xml');
|
||||
try {
|
||||
let xml = xmlContent;
|
||||
|
||||
if (!xml) {
|
||||
xml = await getCfdiXml(cfdi.id);
|
||||
}
|
||||
|
||||
if (!xml) {
|
||||
alert('No hay XML disponible para este CFDI');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([xml], { type: 'application/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `cfdi-${cfdi.uuidFiscal.substring(0, 8)}.xml`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Error downloading XML:', error);
|
||||
alert('Error al descargar el XML');
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
if (!invoiceRef.current) return;
|
||||
|
||||
// Create a print-specific stylesheet
|
||||
const printStyles = document.createElement('style');
|
||||
printStyles.innerHTML = `
|
||||
@media print {
|
||||
body * {
|
||||
visibility: hidden;
|
||||
}
|
||||
#cfdi-print-area, #cfdi-print-area * {
|
||||
visibility: visible;
|
||||
}
|
||||
#cfdi-print-area {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 15mm;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(printStyles);
|
||||
|
||||
// Add ID to the invoice container for print targeting
|
||||
invoiceRef.current.id = 'cfdi-print-area';
|
||||
|
||||
// Trigger print
|
||||
window.print();
|
||||
|
||||
// Clean up
|
||||
document.head.removeChild(printStyles);
|
||||
invoiceRef.current.removeAttribute('id');
|
||||
};
|
||||
|
||||
if (!cfdi) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>Vista de Factura</DialogTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadPdf}
|
||||
disabled={downloading !== null}
|
||||
>
|
||||
{downloading === 'pdf' ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
PDF
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadXml}
|
||||
disabled={downloading !== null || !xmlContent}
|
||||
title={!xmlContent ? 'XML no disponible' : 'Descargar XML'}
|
||||
>
|
||||
{downloading === 'xml' ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
XML
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePrint}
|
||||
disabled={downloading !== null}
|
||||
title="Imprimir factura"
|
||||
>
|
||||
<Printer className="h-4 w-4 mr-1" />
|
||||
Imprimir
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden bg-gray-50 p-4">
|
||||
<CfdiInvoice ref={invoiceRef} cfdi={cfdi} conceptos={conceptos} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Sidebar } from './sidebar';
|
||||
import { Header } from './header';
|
||||
|
||||
interface DashboardShellProps {
|
||||
@@ -8,13 +7,12 @@ interface DashboardShellProps {
|
||||
}
|
||||
|
||||
export function DashboardShell({ children, title, headerContent }: DashboardShellProps) {
|
||||
// Navigation is handled by the parent layout.tsx which respects theme settings
|
||||
// DashboardShell only provides Header and content wrapper
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Sidebar />
|
||||
<div className="pl-64">
|
||||
<Header title={title}>{headerContent}</Header>
|
||||
<main className="p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<Header title={title}>{headerContent}</Header>
|
||||
<main className="p-6">{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
/**
|
||||
* Onboarding persistence key.
|
||||
@@ -11,6 +12,7 @@ const STORAGE_KEY = 'horux360:onboarding_seen_v1';
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, _hasHydrated } = useAuthStore();
|
||||
const [isNewUser, setIsNewUser] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -21,6 +23,13 @@ export default function OnboardingScreen() {
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
if (_hasHydrated && !isAuthenticated) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isAuthenticated, _hasHydrated, router]);
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
// 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 (
|
||||
<main className="min-h-screen relative overflow-hidden bg-white">
|
||||
{/* Grid tech claro */}
|
||||
@@ -160,9 +183,6 @@ export default function OnboardingScreen() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-center text-xs text-slate-400">
|
||||
Demo UI sin backend • Persistencia local: localStorage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, 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 { getSyncStatus, startSync } from '@/lib/api/sat';
|
||||
import type { SatSyncStatusResponse } from '@horux/shared';
|
||||
|
||||
@@ -30,6 +32,9 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [startingSync, setStartingSync] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showCustomDate, setShowCustomDate] = useState(false);
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
@@ -53,12 +58,21 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
|
||||
}
|
||||
}, [fielConfigured]);
|
||||
|
||||
const handleStartSync = async (type: 'initial' | 'daily') => {
|
||||
const handleStartSync = async (type: 'initial' | 'daily', customDates?: boolean) => {
|
||||
setStartingSync(true);
|
||||
setError('');
|
||||
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();
|
||||
setShowCustomDate(false);
|
||||
onSyncStarted?.();
|
||||
} catch (err: any) {
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -169,18 +226,27 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
|
||||
onClick={() => handleStartSync('daily')}
|
||||
className="flex-1"
|
||||
>
|
||||
{startingSync ? 'Iniciando...' : 'Sincronizar ahora'}
|
||||
{startingSync ? 'Iniciando...' : 'Sincronizar mes actual'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={startingSync || status?.hasActiveSync}
|
||||
onClick={() => setShowCustomDate(!showCustomDate)}
|
||||
className="flex-1"
|
||||
>
|
||||
Periodo personalizado
|
||||
</Button>
|
||||
{!status?.lastCompletedJob && (
|
||||
<Button
|
||||
disabled={startingSync || status?.hasActiveSync}
|
||||
onClick={() => handleStartSync('initial')}
|
||||
className="flex-1"
|
||||
>
|
||||
{startingSync ? 'Iniciando...' : 'Sincronizacion inicial (10 anos)'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!status?.lastCompletedJob && (
|
||||
<Button
|
||||
disabled={startingSync || status?.hasActiveSync}
|
||||
onClick={() => handleStartSync('initial')}
|
||||
className="w-full"
|
||||
>
|
||||
{startingSync ? 'Iniciando...' : 'Sincronizacion inicial (6 anos)'}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
122
apps/web/components/ui/dialog.tsx
Normal file
122
apps/web/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-1.5 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
30
apps/web/components/ui/popover.tsx
Normal file
30
apps/web/components/ui/popover.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-[9999] w-72 rounded-md border bg-white dark:bg-gray-900 p-4 text-popover-foreground shadow-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
@@ -9,6 +9,8 @@ export async function getCfdis(filters: CfdiFilters): Promise<CfdiListResponse>
|
||||
if (filters.fechaInicio) params.set('fechaInicio', filters.fechaInicio);
|
||||
if (filters.fechaFin) params.set('fechaFin', filters.fechaFin);
|
||||
if (filters.rfc) params.set('rfc', filters.rfc);
|
||||
if (filters.emisor) params.set('emisor', filters.emisor);
|
||||
if (filters.receptor) params.set('receptor', filters.receptor);
|
||||
if (filters.search) params.set('search', filters.search);
|
||||
if (filters.page) params.set('page', filters.page.toString());
|
||||
if (filters.limit) params.set('limit', filters.limit.toString());
|
||||
@@ -89,3 +91,27 @@ export async function createManyCfdis(
|
||||
export async function deleteCfdi(id: string): Promise<void> {
|
||||
await apiClient.delete(`/cfdi/${id}`);
|
||||
}
|
||||
|
||||
export async function getCfdiXml(id: string): Promise<string> {
|
||||
const response = await apiClient.get<string>(`/cfdi/${id}/xml`, {
|
||||
responseType: 'text'
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export interface EmisorReceptor {
|
||||
rfc: string;
|
||||
nombre: string;
|
||||
}
|
||||
|
||||
export async function searchEmisores(search: string): Promise<EmisorReceptor[]> {
|
||||
if (search.length < 2) return [];
|
||||
const response = await apiClient.get<EmisorReceptor[]>(`/cfdi/emisores?search=${encodeURIComponent(search)}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function searchReceptores(search: string): Promise<EmisorReceptor[]> {
|
||||
if (search.length < 2) return [];
|
||||
const response = await apiClient.get<EmisorReceptor[]>(`/cfdi/receptores?search=${encodeURIComponent(search)}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export function useCfdis(filters: CfdiFilters) {
|
||||
return useQuery({
|
||||
queryKey: ['cfdis', filters],
|
||||
queryFn: () => cfdiApi.getCfdis(filters),
|
||||
staleTime: 30 * 1000, // 30 segundos
|
||||
gcTime: 5 * 60 * 1000, // 5 minutos en cache
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
17
apps/web/lib/hooks/use-debounce.ts
Normal file
17
apps/web/lib/hooks/use-debounce.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@@ -11,9 +11,10 @@
|
||||
"dependencies": {
|
||||
"@horux/shared": "workspace:*",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.1.0",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
@@ -26,17 +27,21 @@
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"html2pdf.js": "^0.14.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"recharts": "^2.12.0",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"recharts": "^2.12.0",
|
||||
"tailwind-merge": "^2.5.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.23.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
|
||||
298
docs/SAT-SYNC-IMPLEMENTATION.md
Normal file
298
docs/SAT-SYNC-IMPLEMENTATION.md
Normal 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)
|
||||
126
docs/plans/2026-02-17-cfdi-viewer-design.md
Normal file
126
docs/plans/2026-02-17-cfdi-viewer-design.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Diseño: Visor de CFDI
|
||||
|
||||
**Fecha:** 2026-02-17
|
||||
**Estado:** Aprobado
|
||||
|
||||
## Resumen
|
||||
|
||||
Agregar funcionalidad para visualizar facturas CFDI en formato PDF-like, recreando la representación visual desde el XML almacenado. Incluye descarga de PDF y XML.
|
||||
|
||||
## Decisiones de Diseño
|
||||
|
||||
- **Tipo de vista:** PDF-like (representación visual similar a factura impresa)
|
||||
- **Acceso:** Botón "Ver" (icono ojo) en cada fila de la tabla
|
||||
- **Acciones:** Descargar PDF, Descargar XML
|
||||
- **Enfoque técnico:** Componente React + html2pdf.js para generación de PDF en cliente
|
||||
|
||||
## Arquitectura de Componentes
|
||||
|
||||
```
|
||||
CfdiPage (existente)
|
||||
├── Tabla de CFDIs
|
||||
│ └── Botón "Ver" (Eye icon) → abre modal
|
||||
│
|
||||
└── CfdiViewerModal (NUEVO)
|
||||
├── Header: Título + Botones (PDF, XML, Cerrar)
|
||||
└── CfdiInvoice (NUEVO)
|
||||
├── Encabezado (Emisor + Receptor)
|
||||
├── Datos del comprobante
|
||||
├── Tabla de conceptos (parseados del XML)
|
||||
├── Totales e impuestos
|
||||
└── Timbre fiscal (UUID, fechas)
|
||||
```
|
||||
|
||||
## Componentes Nuevos
|
||||
|
||||
| Componente | Ubicación | Responsabilidad |
|
||||
|------------|-----------|-----------------|
|
||||
| `CfdiViewerModal` | `components/cfdi/cfdi-viewer-modal.tsx` | Modal con visor y botones de acción |
|
||||
| `CfdiInvoice` | `components/cfdi/cfdi-invoice.tsx` | Renderiza la factura estilo PDF |
|
||||
|
||||
## Diseño Visual
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ ┌─────────────────┐ FACTURA │
|
||||
│ │ [LOGO] │ Serie: A Folio: 001 │
|
||||
│ │ placeholder │ Fecha: 15/Ene/2025 │
|
||||
│ └─────────────────┘ │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ EMISOR │ RECEPTOR │
|
||||
│ Empresa Emisora SA de CV │ Cliente SA de CV │
|
||||
│ RFC: XAXX010101000 │ RFC: XAXX010101001 │
|
||||
│ │ Uso CFDI: G03 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ DATOS DEL COMPROBANTE │
|
||||
│ Tipo: Ingreso Método: PUE Forma: 03 - Transferencia │
|
||||
│ Moneda: MXN Tipo Cambio: 1.00 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ CONCEPTOS │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ Descripción │ Cant │ P. Unit │ Importe │ │
|
||||
│ ├──────────────────────────────────────────────────────┤ │
|
||||
│ │ Servicio consultoría │ 1 │ 10,000 │ 10,000.00 │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Subtotal: $10,000.00 │
|
||||
│ IVA 16%: $1,600.00 │
|
||||
│ TOTAL: $11,600.00 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ TIMBRE FISCAL DIGITAL │
|
||||
│ UUID: 12345678-1234-1234-1234-123456789012 │
|
||||
│ Fecha Timbrado: 2025-01-15T12:30:45 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Flujo de Datos
|
||||
|
||||
1. Usuario hace clic en "Ver" (Eye icon)
|
||||
2. Se abre CfdiViewerModal con el CFDI seleccionado
|
||||
3. Si existe xmlOriginal:
|
||||
- Parsear XML para extraer conceptos
|
||||
- Mostrar factura completa
|
||||
4. Si no existe XML:
|
||||
- Mostrar factura con datos de BD (sin conceptos)
|
||||
5. Acciones disponibles:
|
||||
- Descargar PDF (html2pdf genera PDF)
|
||||
- Descargar XML (si existe)
|
||||
|
||||
## Cambios en Backend
|
||||
|
||||
### Nuevo Endpoint
|
||||
|
||||
```
|
||||
GET /api/cfdi/:id/xml
|
||||
```
|
||||
|
||||
Retorna el XML original del CFDI.
|
||||
|
||||
### Modificar Endpoint Existente
|
||||
|
||||
```
|
||||
GET /api/cfdi/:id
|
||||
```
|
||||
|
||||
Agregar campo `xmlOriginal` a la respuesta.
|
||||
|
||||
## Dependencias
|
||||
|
||||
```json
|
||||
{
|
||||
"html2pdf.js": "^0.10.1"
|
||||
}
|
||||
```
|
||||
|
||||
## Archivos a Crear/Modificar
|
||||
|
||||
### Nuevos
|
||||
- `apps/web/components/cfdi/cfdi-viewer-modal.tsx`
|
||||
- `apps/web/components/cfdi/cfdi-invoice.tsx`
|
||||
- `apps/api/src/controllers/cfdi.controller.ts` (nuevo método getXml)
|
||||
|
||||
### Modificar
|
||||
- `apps/web/app/(dashboard)/cfdi/page.tsx` (agregar botón Ver y modal)
|
||||
- `apps/api/src/routes/cfdi.routes.ts` (agregar ruta /xml)
|
||||
- `apps/api/src/services/cfdi.service.ts` (agregar método getXmlById)
|
||||
- `packages/shared/src/types/cfdi.ts` (agregar xmlOriginal a Cfdi)
|
||||
816
docs/plans/2026-02-17-cfdi-viewer-implementation.md
Normal file
816
docs/plans/2026-02-17-cfdi-viewer-implementation.md
Normal file
@@ -0,0 +1,816 @@
|
||||
# CFDI Viewer Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add PDF-like invoice visualization for CFDIs with PDF and XML download capabilities.
|
||||
|
||||
**Architecture:** React modal component with invoice renderer. Backend returns XML via new endpoint. html2pdf.js generates PDF client-side from rendered HTML.
|
||||
|
||||
**Tech Stack:** React, TypeScript, html2pdf.js, Tailwind CSS
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Install html2pdf.js Dependency
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/package.json`
|
||||
|
||||
**Step 1: Install the dependency**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /root/Horux/apps/web && pnpm add html2pdf.js
|
||||
```
|
||||
|
||||
**Step 2: Verify installation**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep html2pdf apps/web/package.json
|
||||
```
|
||||
|
||||
Expected: `"html2pdf.js": "^0.10.x"`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/package.json apps/web/pnpm-lock.yaml
|
||||
git commit -m "chore: add html2pdf.js for PDF generation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add xmlOriginal to Cfdi Type
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/shared/src/types/cfdi.ts:4-31`
|
||||
|
||||
**Step 1: Add xmlOriginal field to Cfdi interface**
|
||||
|
||||
In `packages/shared/src/types/cfdi.ts`, add after line 29 (`pdfUrl: string | null;`):
|
||||
|
||||
```typescript
|
||||
xmlOriginal: string | null;
|
||||
```
|
||||
|
||||
**Step 2: Verify types compile**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /root/Horux && pnpm build --filter=@horux/shared
|
||||
```
|
||||
|
||||
Expected: Build succeeds
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/shared/src/types/cfdi.ts
|
||||
git commit -m "feat(types): add xmlOriginal field to Cfdi interface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update Backend Service to Return XML
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/services/cfdi.service.ts:77-95`
|
||||
|
||||
**Step 1: Update getCfdiById to include xml_original**
|
||||
|
||||
Replace the `getCfdiById` function:
|
||||
|
||||
```typescript
|
||||
export async function getCfdiById(schema: string, id: string): Promise<Cfdi | null> {
|
||||
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
|
||||
SELECT
|
||||
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
|
||||
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
|
||||
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
|
||||
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
|
||||
subtotal, descuento, iva, isr_retenido as "isrRetenido",
|
||||
iva_retenido as "ivaRetenido", total, moneda,
|
||||
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
|
||||
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
||||
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
||||
xml_original as "xmlOriginal",
|
||||
created_at as "createdAt"
|
||||
FROM "${schema}".cfdis
|
||||
WHERE id = $1
|
||||
`, id);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add getXmlById function**
|
||||
|
||||
Add after `getCfdiById`:
|
||||
|
||||
```typescript
|
||||
export async function getXmlById(schema: string, id: string): Promise<string | null> {
|
||||
const result = await prisma.$queryRawUnsafe<[{ xml_original: string | null }]>(`
|
||||
SELECT xml_original FROM "${schema}".cfdis WHERE id = $1
|
||||
`, id);
|
||||
|
||||
return result[0]?.xml_original || null;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Verify API compiles**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /root/Horux/apps/api && pnpm build
|
||||
```
|
||||
|
||||
Expected: Build succeeds
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/src/services/cfdi.service.ts
|
||||
git commit -m "feat(api): add xmlOriginal to getCfdiById and add getXmlById"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add XML Download Endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/controllers/cfdi.controller.ts`
|
||||
- Modify: `apps/api/src/routes/cfdi.routes.ts`
|
||||
|
||||
**Step 1: Add getXml controller function**
|
||||
|
||||
Add to `apps/api/src/controllers/cfdi.controller.ts` after `getCfdiById`:
|
||||
|
||||
```typescript
|
||||
export async function getXml(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
}
|
||||
|
||||
const xml = await cfdiService.getXmlById(req.tenantSchema, String(req.params.id));
|
||||
|
||||
if (!xml) {
|
||||
return next(new AppError(404, 'XML no encontrado para este CFDI'));
|
||||
}
|
||||
|
||||
res.set('Content-Type', 'application/xml');
|
||||
res.set('Content-Disposition', `attachment; filename="cfdi-${req.params.id}.xml"`);
|
||||
res.send(xml);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add route for XML download**
|
||||
|
||||
In `apps/api/src/routes/cfdi.routes.ts`, add after line 13 (`router.get('/:id', ...)`):
|
||||
|
||||
```typescript
|
||||
router.get('/:id/xml', cfdiController.getXml);
|
||||
```
|
||||
|
||||
**Step 3: Verify API compiles**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /root/Horux/apps/api && pnpm build
|
||||
```
|
||||
|
||||
Expected: Build succeeds
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/src/controllers/cfdi.controller.ts apps/api/src/routes/cfdi.routes.ts
|
||||
git commit -m "feat(api): add GET /cfdi/:id/xml endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add API Client Function for XML Download
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/lib/api/cfdi.ts`
|
||||
|
||||
**Step 1: Add getCfdiXml function**
|
||||
|
||||
Add at the end of `apps/web/lib/api/cfdi.ts`:
|
||||
|
||||
```typescript
|
||||
export async function getCfdiXml(id: string): Promise<string> {
|
||||
const response = await apiClient.get<string>(`/cfdi/${id}/xml`, {
|
||||
responseType: 'text'
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/lib/api/cfdi.ts
|
||||
git commit -m "feat(web): add getCfdiXml API function"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Create CfdiInvoice Component
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/components/cfdi/cfdi-invoice.tsx`
|
||||
|
||||
**Step 1: Create the component**
|
||||
|
||||
Create `apps/web/components/cfdi/cfdi-invoice.tsx`:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { forwardRef } from 'react';
|
||||
import type { Cfdi } from '@horux/shared';
|
||||
|
||||
interface CfdiConcepto {
|
||||
descripcion: string;
|
||||
cantidad: number;
|
||||
valorUnitario: number;
|
||||
importe: number;
|
||||
claveUnidad?: string;
|
||||
claveProdServ?: string;
|
||||
}
|
||||
|
||||
interface CfdiInvoiceProps {
|
||||
cfdi: Cfdi;
|
||||
conceptos?: CfdiConcepto[];
|
||||
}
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
}).format(value);
|
||||
|
||||
const formatDate = (dateString: string) =>
|
||||
new Date(dateString).toLocaleDateString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const tipoLabels: Record<string, string> = {
|
||||
ingreso: 'Ingreso',
|
||||
egreso: 'Egreso',
|
||||
traslado: 'Traslado',
|
||||
pago: 'Pago',
|
||||
nomina: 'Nomina',
|
||||
};
|
||||
|
||||
const formaPagoLabels: Record<string, string> = {
|
||||
'01': 'Efectivo',
|
||||
'02': 'Cheque nominativo',
|
||||
'03': 'Transferencia electrónica',
|
||||
'04': 'Tarjeta de crédito',
|
||||
'28': 'Tarjeta de débito',
|
||||
'99': 'Por definir',
|
||||
};
|
||||
|
||||
const metodoPagoLabels: Record<string, string> = {
|
||||
PUE: 'Pago en una sola exhibición',
|
||||
PPD: 'Pago en parcialidades o diferido',
|
||||
};
|
||||
|
||||
export const CfdiInvoice = forwardRef<HTMLDivElement, CfdiInvoiceProps>(
|
||||
({ cfdi, conceptos }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="bg-white text-black p-8 max-w-[800px] mx-auto text-sm"
|
||||
style={{ fontFamily: 'Arial, sans-serif' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start border-b-2 border-gray-800 pb-4 mb-4">
|
||||
<div className="w-32 h-20 bg-gray-200 flex items-center justify-center text-gray-500 text-xs">
|
||||
[LOGO]
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<h1 className="text-2xl font-bold text-gray-800">FACTURA</h1>
|
||||
<p className="text-gray-600">
|
||||
{cfdi.serie && `Serie: ${cfdi.serie} `}
|
||||
{cfdi.folio && `Folio: ${cfdi.folio}`}
|
||||
</p>
|
||||
<p className="text-gray-600">Fecha: {formatDate(cfdi.fechaEmision)}</p>
|
||||
<span
|
||||
className={`inline-block px-2 py-1 text-xs font-semibold rounded mt-1 ${
|
||||
cfdi.estado === 'vigente'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{cfdi.estado === 'vigente' ? 'VIGENTE' : 'CANCELADO'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emisor / Receptor */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
<div className="border border-gray-300 p-4 rounded">
|
||||
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
|
||||
EMISOR
|
||||
</h3>
|
||||
<p className="font-semibold">{cfdi.nombreEmisor}</p>
|
||||
<p className="text-gray-600">RFC: {cfdi.rfcEmisor}</p>
|
||||
</div>
|
||||
<div className="border border-gray-300 p-4 rounded">
|
||||
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
|
||||
RECEPTOR
|
||||
</h3>
|
||||
<p className="font-semibold">{cfdi.nombreReceptor}</p>
|
||||
<p className="text-gray-600">RFC: {cfdi.rfcReceptor}</p>
|
||||
{cfdi.usoCfdi && (
|
||||
<p className="text-gray-600">Uso CFDI: {cfdi.usoCfdi}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Datos del Comprobante */}
|
||||
<div className="border border-gray-300 p-4 rounded mb-6">
|
||||
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
|
||||
DATOS DEL COMPROBANTE
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Tipo:</span>
|
||||
<p className="font-medium">{tipoLabels[cfdi.tipo] || cfdi.tipo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Método de Pago:</span>
|
||||
<p className="font-medium">
|
||||
{cfdi.metodoPago ? metodoPagoLabels[cfdi.metodoPago] || cfdi.metodoPago : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Forma de Pago:</span>
|
||||
<p className="font-medium">
|
||||
{cfdi.formaPago ? formaPagoLabels[cfdi.formaPago] || cfdi.formaPago : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Moneda:</span>
|
||||
<p className="font-medium">
|
||||
{cfdi.moneda}
|
||||
{cfdi.tipoCambio !== 1 && ` (TC: ${cfdi.tipoCambio})`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conceptos */}
|
||||
{conceptos && conceptos.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
|
||||
CONCEPTOS
|
||||
</h3>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="text-left p-2 border">Descripción</th>
|
||||
<th className="text-center p-2 border w-20">Cant.</th>
|
||||
<th className="text-right p-2 border w-28">P. Unit.</th>
|
||||
<th className="text-right p-2 border w-28">Importe</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{conceptos.map((concepto, idx) => (
|
||||
<tr key={idx} className="border-b">
|
||||
<td className="p-2 border">{concepto.descripcion}</td>
|
||||
<td className="text-center p-2 border">{concepto.cantidad}</td>
|
||||
<td className="text-right p-2 border">
|
||||
{formatCurrency(concepto.valorUnitario)}
|
||||
</td>
|
||||
<td className="text-right p-2 border">
|
||||
{formatCurrency(concepto.importe)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Totales */}
|
||||
<div className="flex justify-end mb-6">
|
||||
<div className="w-64">
|
||||
<div className="flex justify-between py-1 border-b">
|
||||
<span className="text-gray-600">Subtotal:</span>
|
||||
<span>{formatCurrency(cfdi.subtotal)}</span>
|
||||
</div>
|
||||
{cfdi.descuento > 0 && (
|
||||
<div className="flex justify-between py-1 border-b">
|
||||
<span className="text-gray-600">Descuento:</span>
|
||||
<span className="text-red-600">-{formatCurrency(cfdi.descuento)}</span>
|
||||
</div>
|
||||
)}
|
||||
{cfdi.iva > 0 && (
|
||||
<div className="flex justify-between py-1 border-b">
|
||||
<span className="text-gray-600">IVA (16%):</span>
|
||||
<span>{formatCurrency(cfdi.iva)}</span>
|
||||
</div>
|
||||
)}
|
||||
{cfdi.ivaRetenido > 0 && (
|
||||
<div className="flex justify-between py-1 border-b">
|
||||
<span className="text-gray-600">IVA Retenido:</span>
|
||||
<span className="text-red-600">-{formatCurrency(cfdi.ivaRetenido)}</span>
|
||||
</div>
|
||||
)}
|
||||
{cfdi.isrRetenido > 0 && (
|
||||
<div className="flex justify-between py-1 border-b">
|
||||
<span className="text-gray-600">ISR Retenido:</span>
|
||||
<span className="text-red-600">-{formatCurrency(cfdi.isrRetenido)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between py-2 font-bold text-lg border-t-2 border-gray-800 mt-1">
|
||||
<span>TOTAL:</span>
|
||||
<span>{formatCurrency(cfdi.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timbre Fiscal */}
|
||||
<div className="border-t-2 border-gray-800 pt-4">
|
||||
<h3 className="font-bold text-gray-700 mb-2">TIMBRE FISCAL DIGITAL</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<p className="text-gray-500">UUID:</p>
|
||||
<p className="font-mono break-all">{cfdi.uuidFiscal}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Fecha de Timbrado:</p>
|
||||
<p>{cfdi.fechaTimbrado}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CfdiInvoice.displayName = 'CfdiInvoice';
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/components/cfdi/cfdi-invoice.tsx
|
||||
git commit -m "feat(web): add CfdiInvoice component for PDF-like rendering"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Create CfdiViewerModal Component
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/components/cfdi/cfdi-viewer-modal.tsx`
|
||||
|
||||
**Step 1: Create the modal component**
|
||||
|
||||
Create `apps/web/components/cfdi/cfdi-viewer-modal.tsx`:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import type { Cfdi } from '@horux/shared';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CfdiInvoice } from './cfdi-invoice';
|
||||
import { getCfdiXml } from '@/lib/api/cfdi';
|
||||
import { Download, FileText, X, Loader2 } from 'lucide-react';
|
||||
|
||||
interface CfdiConcepto {
|
||||
descripcion: string;
|
||||
cantidad: number;
|
||||
valorUnitario: number;
|
||||
importe: number;
|
||||
}
|
||||
|
||||
interface CfdiViewerModalProps {
|
||||
cfdi: Cfdi | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function parseConceptosFromXml(xmlString: string): CfdiConcepto[] {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xmlString, 'text/xml');
|
||||
const conceptos: CfdiConcepto[] = [];
|
||||
|
||||
// Find all Concepto elements
|
||||
const elements = doc.getElementsByTagName('*');
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
if (elements[i].localName === 'Concepto') {
|
||||
const el = elements[i];
|
||||
conceptos.push({
|
||||
descripcion: el.getAttribute('Descripcion') || '',
|
||||
cantidad: parseFloat(el.getAttribute('Cantidad') || '1'),
|
||||
valorUnitario: parseFloat(el.getAttribute('ValorUnitario') || '0'),
|
||||
importe: parseFloat(el.getAttribute('Importe') || '0'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return conceptos;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function CfdiViewerModal({ cfdi, open, onClose }: CfdiViewerModalProps) {
|
||||
const invoiceRef = useRef<HTMLDivElement>(null);
|
||||
const [conceptos, setConceptos] = useState<CfdiConcepto[]>([]);
|
||||
const [downloading, setDownloading] = useState<'pdf' | 'xml' | null>(null);
|
||||
const [xmlContent, setXmlContent] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (cfdi?.xmlOriginal) {
|
||||
setXmlContent(cfdi.xmlOriginal);
|
||||
setConceptos(parseConceptosFromXml(cfdi.xmlOriginal));
|
||||
} else {
|
||||
setXmlContent(null);
|
||||
setConceptos([]);
|
||||
}
|
||||
}, [cfdi]);
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
if (!invoiceRef.current || !cfdi) return;
|
||||
|
||||
setDownloading('pdf');
|
||||
try {
|
||||
const html2pdf = (await import('html2pdf.js')).default;
|
||||
|
||||
const opt = {
|
||||
margin: 10,
|
||||
filename: `factura-${cfdi.uuidFiscal.substring(0, 8)}.pdf`,
|
||||
image: { type: 'jpeg', quality: 0.98 },
|
||||
html2canvas: { scale: 2, useCORS: true },
|
||||
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
|
||||
};
|
||||
|
||||
await html2pdf().set(opt).from(invoiceRef.current).save();
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
alert('Error al generar el PDF');
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadXml = async () => {
|
||||
if (!cfdi) return;
|
||||
|
||||
setDownloading('xml');
|
||||
try {
|
||||
let xml = xmlContent;
|
||||
|
||||
if (!xml) {
|
||||
xml = await getCfdiXml(cfdi.id);
|
||||
}
|
||||
|
||||
if (!xml) {
|
||||
alert('No hay XML disponible para este CFDI');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([xml], { type: 'application/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `cfdi-${cfdi.uuidFiscal.substring(0, 8)}.xml`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Error downloading XML:', error);
|
||||
alert('Error al descargar el XML');
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!cfdi) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>Vista de Factura</DialogTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadPdf}
|
||||
disabled={downloading !== null}
|
||||
>
|
||||
{downloading === 'pdf' ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
PDF
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadXml}
|
||||
disabled={downloading !== null || !xmlContent}
|
||||
title={!xmlContent ? 'XML no disponible' : 'Descargar XML'}
|
||||
>
|
||||
{downloading === 'xml' ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
XML
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden bg-gray-50 p-4">
|
||||
<CfdiInvoice ref={invoiceRef} cfdi={cfdi} conceptos={conceptos} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/components/cfdi/cfdi-viewer-modal.tsx
|
||||
git commit -m "feat(web): add CfdiViewerModal with PDF and XML download"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Integrate Viewer into CFDI Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/(dashboard)/cfdi/page.tsx`
|
||||
|
||||
**Step 1: Add imports at top of file**
|
||||
|
||||
Add after the existing imports (around line 14):
|
||||
|
||||
```typescript
|
||||
import { Eye } from 'lucide-react';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
import { getCfdiById } from '@/lib/api/cfdi';
|
||||
```
|
||||
|
||||
**Step 2: Add state for viewer modal**
|
||||
|
||||
Inside `CfdiPage` component, after line 255 (`const deleteCfdi = useDeleteCfdi();`), add:
|
||||
|
||||
```typescript
|
||||
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
|
||||
const [loadingCfdi, setLoadingCfdi] = useState<string | null>(null);
|
||||
|
||||
const handleViewCfdi = async (id: string) => {
|
||||
setLoadingCfdi(id);
|
||||
try {
|
||||
const cfdi = await getCfdiById(id);
|
||||
setViewingCfdi(cfdi);
|
||||
} catch (error) {
|
||||
console.error('Error loading CFDI:', error);
|
||||
alert('Error al cargar el CFDI');
|
||||
} finally {
|
||||
setLoadingCfdi(null);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Step 3: Add import for Cfdi type**
|
||||
|
||||
Update the import from `@horux/shared` at line 12 to include `Cfdi`:
|
||||
|
||||
```typescript
|
||||
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
|
||||
```
|
||||
|
||||
**Step 4: Add View button in table**
|
||||
|
||||
In the table header (around line 1070), add a new column header before the delete column:
|
||||
|
||||
```typescript
|
||||
<th className="pb-3 font-medium"></th>
|
||||
```
|
||||
|
||||
In the table body (inside the map, around line 1125), add before the delete button:
|
||||
|
||||
```typescript
|
||||
<td className="py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleViewCfdi(cfdi.id)}
|
||||
disabled={loadingCfdi === cfdi.id}
|
||||
title="Ver factura"
|
||||
>
|
||||
{loadingCfdi === cfdi.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</td>
|
||||
```
|
||||
|
||||
**Step 5: Add modal component**
|
||||
|
||||
At the end of the return statement, just before the closing `</>`, add:
|
||||
|
||||
```typescript
|
||||
<CfdiViewerModal
|
||||
cfdi={viewingCfdi}
|
||||
open={viewingCfdi !== null}
|
||||
onClose={() => setViewingCfdi(null)}
|
||||
/>
|
||||
```
|
||||
|
||||
**Step 6: Verify build**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /root/Horux/apps/web && pnpm build
|
||||
```
|
||||
|
||||
Expected: Build succeeds
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/app/\(dashboard\)/cfdi/page.tsx
|
||||
git commit -m "feat(web): integrate CFDI viewer modal into CFDI page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Build and Test
|
||||
|
||||
**Step 1: Build all packages**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /root/Horux && pnpm build
|
||||
```
|
||||
|
||||
Expected: All packages build successfully
|
||||
|
||||
**Step 2: Restart services**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
systemctl restart horux-api horux-web
|
||||
```
|
||||
|
||||
**Step 3: Manual verification**
|
||||
|
||||
1. Open browser to CFDI page
|
||||
2. Click Eye icon on any CFDI row
|
||||
3. Verify modal opens with invoice preview
|
||||
4. Click PDF button - verify PDF downloads
|
||||
5. Click XML button (if XML exists) - verify XML downloads
|
||||
|
||||
**Step 4: Final commit with all changes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git status
|
||||
# If any uncommitted changes:
|
||||
git commit -m "feat: complete CFDI viewer implementation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `apps/web/package.json` | Added html2pdf.js dependency |
|
||||
| `packages/shared/src/types/cfdi.ts` | Added xmlOriginal field |
|
||||
| `apps/api/src/services/cfdi.service.ts` | Updated getCfdiById, added getXmlById |
|
||||
| `apps/api/src/controllers/cfdi.controller.ts` | Added getXml controller |
|
||||
| `apps/api/src/routes/cfdi.routes.ts` | Added GET /:id/xml route |
|
||||
| `apps/web/lib/api/cfdi.ts` | Added getCfdiXml function |
|
||||
| `apps/web/components/cfdi/cfdi-invoice.tsx` | NEW - Invoice renderer |
|
||||
| `apps/web/components/cfdi/cfdi-viewer-modal.tsx` | NEW - Modal wrapper |
|
||||
| `apps/web/app/(dashboard)/cfdi/page.tsx` | Integrated viewer |
|
||||
2252
docs/superpowers/plans/2026-03-15-saas-transformation.md
Normal file
2252
docs/superpowers/plans/2026-03-15-saas-transformation.md
Normal file
File diff suppressed because it is too large
Load Diff
797
docs/superpowers/specs/2026-03-15-saas-transformation-design.md
Normal file
797
docs/superpowers/specs/2026-03-15-saas-transformation-design.md
Normal file
@@ -0,0 +1,797 @@
|
||||
# Horux360 SaaS Transformation — Design Spec
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Status:** Approved
|
||||
**Author:** Carlos Horux + Claude
|
||||
|
||||
## Overview
|
||||
|
||||
Transform Horux360 from an internal multi-tenant accounting tool into a production-ready SaaS platform. Client registration remains manual (sales-led). Each client gets a fully isolated PostgreSQL database. Payments via MercadoPago. Transactional emails via Gmail SMTP (@horuxfin.com). Production deployment on existing server (192.168.10.212).
|
||||
|
||||
**Target scale:** 10-50 clients within 6 months.
|
||||
|
||||
**Starting from scratch:** No data migration. Existing schemas/data will be archived. Fresh setup.
|
||||
|
||||
---
|
||||
|
||||
## Section 1: Database-Per-Tenant Architecture
|
||||
|
||||
### Rationale
|
||||
|
||||
Clients sign NDAs requiring complete data isolation. Schema-per-tenant (current approach) shares a single database. Database-per-tenant provides:
|
||||
- Independent backup/restore per client
|
||||
- No risk of cross-tenant data leakage
|
||||
- Each DB can be moved to a different server if needed
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
PostgreSQL Server (max_connections: 300)
|
||||
├── horux360 ← Central DB (Prisma-managed)
|
||||
├── horux_cas2408138w2 ← Client DB (raw SQL)
|
||||
├── horux_roem691011ez4 ← Client DB
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Central DB (`horux360`) — Prisma-managed tables
|
||||
|
||||
Existing tables (modified):
|
||||
- `tenants` — add `database_name` column, remove `schema_name`
|
||||
- `users` — no changes
|
||||
- `refresh_tokens` — flush all existing tokens at migration cutover (invalidate all sessions)
|
||||
- `fiel_credentials` — no changes
|
||||
|
||||
New tables:
|
||||
- `subscriptions` — MercadoPago subscription tracking
|
||||
- `payments` — payment history
|
||||
|
||||
### Prisma schema migration
|
||||
|
||||
The Prisma schema (`apps/api/prisma/schema.prisma`) must be updated:
|
||||
- Replace `schema_name String @unique @map("schema_name")` with `database_name String @unique @map("database_name")` on the `Tenant` model
|
||||
- Add `Subscription` and `Payment` models
|
||||
- Run `prisma migrate dev` to generate and apply migration
|
||||
- Update `Tenant` type in `packages/shared/src/types/tenant.ts`: replace `schemaName` with `databaseName`
|
||||
|
||||
### JWT payload migration
|
||||
|
||||
The current JWT payload embeds `schemaName`. This must change:
|
||||
- Update `JWTPayload` in `packages/shared/src/types/auth.ts`: replace `schemaName` with `databaseName`
|
||||
- Update token generation in `auth.service.ts`: read `tenant.databaseName` instead of `tenant.schemaName`
|
||||
- Update `refreshTokens` function to embed `databaseName`
|
||||
- At migration cutover: flush `refresh_tokens` table to invalidate all existing sessions (forces re-login)
|
||||
|
||||
### Client DB naming
|
||||
|
||||
Formula: `horux_<rfc_normalized>`
|
||||
```
|
||||
RFC "CAS2408138W2" → horux_cas2408138w2
|
||||
RFC "TPR840604D98" → horux_tpr840604d98
|
||||
```
|
||||
|
||||
### Client DB tables (created via raw SQL)
|
||||
|
||||
Each client database contains these tables (no schema prefix, direct `public` schema):
|
||||
|
||||
- `cfdis` — with indexes: fecha_emision DESC, tipo, rfc_emisor, rfc_receptor, pg_trgm on nombre_emisor/nombre_receptor, uuid_fiscal unique
|
||||
- `iva_mensual`
|
||||
- `isr_mensual`
|
||||
- `alertas`
|
||||
- `calendario_fiscal`
|
||||
|
||||
### TenantConnectionManager
|
||||
|
||||
```typescript
|
||||
class TenantConnectionManager {
|
||||
private pools: Map<string, { pool: pg.Pool; lastAccess: Date }>;
|
||||
private cleanupInterval: NodeJS.Timer;
|
||||
|
||||
// Get or create a pool for a tenant
|
||||
getPool(tenantId: string, databaseName: string): pg.Pool;
|
||||
|
||||
// Create a new tenant database with all tables and indexes
|
||||
provisionDatabase(rfc: string): Promise<string>;
|
||||
|
||||
// Drop a tenant database (soft-delete: rename to horux_deleted_<rfc>_<timestamp>)
|
||||
deprovisionDatabase(databaseName: string): Promise<void>;
|
||||
|
||||
// Cleanup idle pools (called every 60s, removes pools idle > 5min)
|
||||
private cleanupIdlePools(): void;
|
||||
}
|
||||
```
|
||||
|
||||
Pool configuration per tenant:
|
||||
- `max`: 3 connections (with 2 PM2 cluster instances, this means 6 connections/tenant max; at 50 tenants = 300, matching `max_connections`)
|
||||
- `idleTimeoutMillis`: 300000 (5 min)
|
||||
- `connectionTimeoutMillis`: 10000 (10 sec)
|
||||
|
||||
**Note on PM2 cluster mode:** Each PM2 worker is a separate Node.js process with its own `TenantConnectionManager` instance. With `instances: 2` and `max: 3` per pool, worst case is 50 tenants × 3 connections × 2 workers = 300 connections, which matches `max_connections = 300`. If scaling beyond 50 tenants, either increase `max_connections` or reduce pool `max` to 2.
|
||||
|
||||
### Tenant middleware change
|
||||
|
||||
Current: Sets `search_path` on a shared connection.
|
||||
New: Returns a dedicated pool connected to the tenant's own database.
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
req.tenantSchema = schema;
|
||||
await pool.query(`SET search_path TO "${schema}", public`);
|
||||
|
||||
// After
|
||||
req.tenantPool = tenantConnectionManager.getPool(tenant.id, tenant.databaseName);
|
||||
```
|
||||
|
||||
All tenant service functions change from using a shared pool with schema prefix to using `req.tenantPool` with direct table names.
|
||||
|
||||
### Admin impersonation (X-View-Tenant)
|
||||
|
||||
The current `X-View-Tenant` header support for admin "view-as" functionality is preserved. The new middleware resolves the `databaseName` for the viewed tenant:
|
||||
|
||||
```typescript
|
||||
// If admin is viewing another tenant
|
||||
if (req.headers['x-view-tenant'] && req.user.role === 'admin') {
|
||||
const viewedTenant = await getTenantByRfc(req.headers['x-view-tenant']);
|
||||
req.tenantPool = tenantConnectionManager.getPool(viewedTenant.id, viewedTenant.databaseName);
|
||||
} else {
|
||||
req.tenantPool = tenantConnectionManager.getPool(tenant.id, tenant.databaseName);
|
||||
}
|
||||
```
|
||||
|
||||
### Provisioning flow (new client)
|
||||
|
||||
1. Admin creates tenant via UI → POST `/api/tenants/`
|
||||
2. Insert record in `horux360.tenants` with `database_name`
|
||||
3. Execute `CREATE DATABASE horux_<rfc>`
|
||||
4. Connect to new DB, create all tables + indexes
|
||||
5. Create admin user in `horux360.users` linked to tenant
|
||||
6. Send welcome email with temporary credentials
|
||||
7. Generate MercadoPago subscription link
|
||||
|
||||
**Rollback on partial failure:** If any step 3-7 fails:
|
||||
- Drop the created database if it exists (`DROP DATABASE IF EXISTS horux_<rfc>`)
|
||||
- Delete the `tenants` row
|
||||
- Delete the `users` row if created
|
||||
- Return error to admin with the specific step that failed
|
||||
- The entire provisioning is wrapped in a try/catch with explicit cleanup
|
||||
|
||||
### PostgreSQL tuning
|
||||
|
||||
```
|
||||
max_connections = 300
|
||||
shared_buffers = 4GB
|
||||
work_mem = 16MB
|
||||
effective_cache_size = 16GB
|
||||
maintenance_work_mem = 512MB
|
||||
```
|
||||
|
||||
### Server disk
|
||||
|
||||
Expand from 29 GB to 100 GB to accommodate:
|
||||
- 25-50 client databases (~2-3 GB total)
|
||||
- Daily backups with 7-day retention (~15 GB)
|
||||
- FIEL encrypted files (<100 MB)
|
||||
- Logs, builds, OS (~10 GB)
|
||||
|
||||
---
|
||||
|
||||
## Section 2: SAT Credential Storage (FIEL)
|
||||
|
||||
### Dual storage strategy
|
||||
|
||||
When a client uploads their FIEL (.cer + .key + password):
|
||||
|
||||
**A. Filesystem (for manual linking):**
|
||||
```
|
||||
/var/horux/fiel/
|
||||
├── CAS2408138W2/
|
||||
│ ├── certificate.cer.enc ← AES-256-GCM encrypted
|
||||
│ ├── private_key.key.enc ← AES-256-GCM encrypted
|
||||
│ └── metadata.json.enc ← serial, validity dates, upload date (also encrypted)
|
||||
└── ROEM691011EZ4/
|
||||
├── certificate.cer.enc
|
||||
├── private_key.key.enc
|
||||
└── metadata.json.enc
|
||||
```
|
||||
|
||||
**B. Central DB (`fiel_credentials` table):**
|
||||
- Existing structure: `cer_data`, `key_data`, `key_password_encrypted`, `encryption_iv`, `encryption_tag`
|
||||
- **Schema change required:** Add per-component IV/tag columns (`cer_iv`, `cer_tag`, `key_iv`, `key_tag`, `password_iv`, `password_tag`) to support independent encryption per component. Alternatively, use a single JSON column for all encryption metadata. The existing `encryption_iv` and `encryption_tag` columns can be dropped after migration.
|
||||
|
||||
### Encryption
|
||||
|
||||
- Algorithm: AES-256-GCM
|
||||
- Key: `FIEL_ENCRYPTION_KEY` environment variable (separate from other secrets)
|
||||
- **Code change required:** `sat-crypto.service.ts` currently derives the key from `JWT_SECRET` via `createHash('sha256').update(env.JWT_SECRET).digest()`. This must be changed to read `FIEL_ENCRYPTION_KEY` from the env schema. The `env.ts` Zod schema must be updated to declare `FIEL_ENCRYPTION_KEY` as required.
|
||||
- Each component (certificate, private key, password) is encrypted separately with its own IV and auth tag. The `fiel_credentials` table stores separate `encryption_iv` and `encryption_tag` per row. The filesystem also stores each file independently encrypted.
|
||||
- **Code change required:** The current `sat-crypto.service.ts` shares a single IV/tag across all three components. Refactor to encrypt each component independently with its own IV/tag. Store per-component IV/tags in the DB (add columns: `cer_iv`, `cer_tag`, `key_iv`, `key_tag`, `password_iv`, `password_tag` — or use a JSON column).
|
||||
- Password is encrypted, never stored in plaintext
|
||||
|
||||
### Manual decryption CLI
|
||||
|
||||
```bash
|
||||
node scripts/decrypt-fiel.js --rfc CAS2408138W2
|
||||
```
|
||||
|
||||
- Decrypts files to `/tmp/horux-fiel-<rfc>/`
|
||||
- Files auto-delete after 30 minutes (via setTimeout or tmpwatch)
|
||||
- Requires SSH access to server
|
||||
|
||||
### Security
|
||||
|
||||
- `/var/horux/fiel/` permissions: `700` (root only)
|
||||
- Encrypted files are useless without `FIEL_ENCRYPTION_KEY`
|
||||
- `metadata.json` is also encrypted (contains serial number + RFC which could be used to query SAT's certificate validation service, violating NDA confidentiality requirements)
|
||||
|
||||
### Upload flow
|
||||
|
||||
1. Client navigates to `/configuracion/sat`
|
||||
2. Uploads `.cer` + `.key` files + enters password
|
||||
3. API validates the certificate (checks it's a valid FIEL, not expired)
|
||||
4. Encrypts and stores in both filesystem and database
|
||||
5. Sends notification email to admin team: "Cliente X subió su FIEL"
|
||||
|
||||
---
|
||||
|
||||
## Section 3: Payment System (MercadoPago)
|
||||
|
||||
### Integration approach
|
||||
|
||||
Using MercadoPago's **Preapproval (Subscription)** API for recurring payments.
|
||||
|
||||
### New tables in central DB
|
||||
|
||||
```sql
|
||||
CREATE TABLE subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
plan VARCHAR(20) NOT NULL,
|
||||
mp_preapproval_id VARCHAR(100),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
-- status: pending | authorized | paused | cancelled
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
frequency VARCHAR(10) NOT NULL DEFAULT 'monthly',
|
||||
-- frequency: monthly | yearly
|
||||
current_period_start TIMESTAMP,
|
||||
current_period_end TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_subscriptions_tenant_id ON subscriptions(tenant_id);
|
||||
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
|
||||
|
||||
CREATE TABLE payments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
subscription_id UUID REFERENCES subscriptions(id),
|
||||
mp_payment_id VARCHAR(100),
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
-- status: approved | pending | rejected | refunded
|
||||
payment_method VARCHAR(50),
|
||||
paid_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_payments_tenant_id ON payments(tenant_id);
|
||||
CREATE INDEX idx_payments_subscription_id ON payments(subscription_id);
|
||||
```
|
||||
|
||||
### Plans and pricing
|
||||
|
||||
Defined in `packages/shared/src/constants/plans.ts` (update existing):
|
||||
|
||||
| Plan | Monthly price (MXN) | CFDIs | Users | Features |
|
||||
|------|---------------------|-------|-------|----------|
|
||||
| starter | Configurable | 100 | 1 | dashboard, cfdi_basic, iva_isr |
|
||||
| business | Configurable | 500 | 3 | + reportes, alertas, calendario |
|
||||
| professional | Configurable | 2,000 | 10 | + xml_sat, conciliacion, forecasting |
|
||||
| enterprise | Configurable | Unlimited | Unlimited | + api, multi_empresa |
|
||||
|
||||
Prices are configured from admin panel, not hardcoded.
|
||||
|
||||
### Subscription flow
|
||||
|
||||
1. Admin creates tenant and assigns plan
|
||||
2. Admin clicks "Generate payment link" → API creates MercadoPago Preapproval
|
||||
3. Link is sent to client via email
|
||||
4. Client pays → MercadoPago sends webhook
|
||||
5. System activates subscription, records payment
|
||||
|
||||
### Webhook endpoint
|
||||
|
||||
`POST /api/webhooks/mercadopago` (public, no auth)
|
||||
|
||||
Validates webhook signature using `x-signature` header and `x-request-id`.
|
||||
|
||||
Events handled:
|
||||
- `payment` → query MercadoPago API for payment details → insert into `payments`, update subscription period
|
||||
- `subscription_preapproval` → update subscription status (authorized, paused, cancelled)
|
||||
|
||||
On payment failure or subscription cancellation:
|
||||
- Mark tenant `active = false`
|
||||
- Client gets read-only access (can view data but not upload CFDIs, generate reports, etc.)
|
||||
|
||||
### Admin panel additions
|
||||
|
||||
- View subscription status per client (active, amount, next billing date)
|
||||
- Generate payment link button
|
||||
- "Mark as paid manually" button (for bank transfer payments)
|
||||
- Payment history per client
|
||||
|
||||
### Client panel additions
|
||||
|
||||
- New section in `/configuracion`: "Mi suscripción"
|
||||
- Shows: current plan, next billing date, payment history
|
||||
- Client cannot change plan themselves (admin does it)
|
||||
|
||||
### Environment variables
|
||||
|
||||
```
|
||||
MP_ACCESS_TOKEN=<mercadopago_access_token>
|
||||
MP_WEBHOOK_SECRET=<webhook_signature_secret>
|
||||
MP_NOTIFICATION_URL=https://horux360.consultoria-as.com/api/webhooks/mercadopago
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Section 4: Transactional Emails
|
||||
|
||||
### Transport
|
||||
|
||||
Nodemailer with Gmail SMTP (Google Workspace).
|
||||
|
||||
```
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=<user>@horuxfin.com
|
||||
SMTP_PASS=<google_app_password>
|
||||
SMTP_FROM=Horux360 <noreply@horuxfin.com>
|
||||
```
|
||||
|
||||
Requires generating an App Password in Google Workspace admin.
|
||||
|
||||
### Email types
|
||||
|
||||
| Event | Recipient | Subject |
|
||||
|-------|-----------|---------|
|
||||
| Client registered | Client | Bienvenido a Horux360 |
|
||||
| FIEL uploaded | Admin team | [Cliente] subió su FIEL |
|
||||
| Payment received | Client | Confirmación de pago - Horux360 |
|
||||
| Payment failed | Client + Admin | Problema con tu pago - Horux360 |
|
||||
| Subscription expiring | Client | Tu suscripción vence en 5 días |
|
||||
| Subscription cancelled | Client + Admin | Suscripción cancelada - Horux360 |
|
||||
|
||||
### Template approach
|
||||
|
||||
HTML templates as TypeScript template literal functions. No external template engine.
|
||||
|
||||
```typescript
|
||||
// services/email/templates/welcome.ts
|
||||
export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string; loginUrl: string }): string {
|
||||
return `<!DOCTYPE html>...`;
|
||||
}
|
||||
```
|
||||
|
||||
Each template:
|
||||
- Responsive HTML email (inline CSS)
|
||||
- Horux360 branding (logo, colors)
|
||||
- Plain text fallback
|
||||
|
||||
### Email service
|
||||
|
||||
```typescript
|
||||
class EmailService {
|
||||
sendWelcome(to: string, data: WelcomeData): Promise<void>;
|
||||
sendFielNotification(data: FielNotificationData): Promise<void>;
|
||||
sendPaymentConfirmation(to: string, data: PaymentData): Promise<void>;
|
||||
sendPaymentFailed(to: string, data: PaymentData): Promise<void>;
|
||||
sendSubscriptionExpiring(to: string, data: SubscriptionData): Promise<void>;
|
||||
sendSubscriptionCancelled(to: string, data: SubscriptionData): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### Limits
|
||||
|
||||
Gmail Workspace: 500 emails/day. Expected volume for 25 clients: ~50-100 emails/month. Well within limits.
|
||||
|
||||
---
|
||||
|
||||
## Section 5: Production Deployment
|
||||
|
||||
### Build pipeline
|
||||
|
||||
**API:**
|
||||
```bash
|
||||
cd apps/api && pnpm build # tsc → dist/
|
||||
pnpm start # node dist/index.js
|
||||
```
|
||||
|
||||
**Web:**
|
||||
```bash
|
||||
cd apps/web && pnpm build # next build → .next/
|
||||
pnpm start # next start (optimized server)
|
||||
```
|
||||
|
||||
### PM2 configuration
|
||||
|
||||
```javascript
|
||||
// ecosystem.config.js
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'horux-api',
|
||||
script: 'dist/index.js',
|
||||
cwd: '/root/Horux/apps/api',
|
||||
instances: 2,
|
||||
exec_mode: 'cluster',
|
||||
env: { NODE_ENV: 'production' }
|
||||
},
|
||||
{
|
||||
name: 'horux-web',
|
||||
script: 'node_modules/.bin/next',
|
||||
args: 'start',
|
||||
cwd: '/root/Horux/apps/web',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
env: { NODE_ENV: 'production' }
|
||||
}
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
Auto-restart on crash. Log rotation via `pm2-logrotate`.
|
||||
|
||||
### Nginx reverse proxy
|
||||
|
||||
```nginx
|
||||
# Rate limiting zone definitions (in http block of nginx.conf)
|
||||
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
|
||||
limit_req_zone $binary_remote_addr zone=webhooks:10m rate=30r/m;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name horux360.consultoria-as.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name horux360.consultoria-as.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/horux360.consultoria-as.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/horux360.consultoria-as.com/privkey.pem;
|
||||
|
||||
# Security headers
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_types text/plain application/json application/javascript text/css;
|
||||
|
||||
# Health check (for monitoring)
|
||||
location /api/health {
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
}
|
||||
|
||||
# Rate limiting for public endpoints
|
||||
location /api/auth/ {
|
||||
limit_req zone=auth burst=5 nodelay;
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
}
|
||||
|
||||
location /api/webhooks/ {
|
||||
limit_req zone=webhooks burst=10 nodelay;
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
}
|
||||
|
||||
# API
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 200M; # Bulk XML uploads (200MB is enough for ~50k XML files)
|
||||
}
|
||||
|
||||
# Next.js
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Health check endpoint
|
||||
|
||||
The existing `GET /health` endpoint returns `{ status: 'ok', timestamp }`. PM2 uses this for liveness checks. Nginx can optionally use it for upstream health monitoring.
|
||||
|
||||
### SSL
|
||||
|
||||
Let's Encrypt with certbot. Auto-renewal via cron.
|
||||
|
||||
```bash
|
||||
certbot --nginx -d horux360.consultoria-as.com
|
||||
```
|
||||
|
||||
### Firewall
|
||||
|
||||
```bash
|
||||
ufw allow 22/tcp # SSH
|
||||
ufw allow 80/tcp # HTTP (redirect to HTTPS)
|
||||
ufw allow 443/tcp # HTTPS
|
||||
ufw enable
|
||||
```
|
||||
|
||||
PostgreSQL only on localhost (no external access).
|
||||
|
||||
### Backups
|
||||
|
||||
Cron job at **1:00 AM** daily (runs before SAT cron at 3:00 AM, with enough gap to complete):
|
||||
|
||||
**Authentication:** Create a `.pgpass` file at `/root/.pgpass` with `localhost:5432:*:postgres:<password>` and `chmod 600`. This allows `pg_dump` to authenticate without inline passwords.
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# /var/horux/scripts/backup.sh
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR=/var/horux/backups
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
DOW=$(date +%u) # Day of week: 1=Monday, 7=Sunday
|
||||
DAILY_DIR=$BACKUP_DIR/daily
|
||||
WEEKLY_DIR=$BACKUP_DIR/weekly
|
||||
|
||||
mkdir -p $DAILY_DIR $WEEKLY_DIR
|
||||
|
||||
# Backup central DB
|
||||
pg_dump -h localhost -U postgres horux360 | gzip > $DAILY_DIR/horux360_$DATE.sql.gz
|
||||
|
||||
# Backup each tenant DB
|
||||
for db in $(psql -h localhost -U postgres -t -c "SELECT database_name FROM tenants WHERE database_name IS NOT NULL" horux360); do
|
||||
db_trimmed=$(echo $db | xargs) # trim whitespace
|
||||
pg_dump -h localhost -U postgres "$db_trimmed" | gzip > $DAILY_DIR/${db_trimmed}_${DATE}.sql.gz
|
||||
done
|
||||
|
||||
# On Sundays, copy to weekly directory
|
||||
if [ "$DOW" -eq 7 ]; then
|
||||
cp $DAILY_DIR/*_${DATE}.sql.gz $WEEKLY_DIR/
|
||||
fi
|
||||
|
||||
# Remove daily backups older than 7 days
|
||||
find $DAILY_DIR -name "*.sql.gz" -mtime +7 -delete
|
||||
|
||||
# Remove weekly backups older than 28 days
|
||||
find $WEEKLY_DIR -name "*.sql.gz" -mtime +28 -delete
|
||||
|
||||
# Verify backup files are not empty (catch silent pg_dump failures)
|
||||
for f in $DAILY_DIR/*_${DATE}.sql.gz; do
|
||||
if [ ! -s "$f" ]; then
|
||||
echo "WARNING: Empty backup file: $f" >&2
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
**Schedule separation:** Backups run at 1:00 AM, SAT cron runs at 3:00 AM. With 50 clients, backup should complete in ~15-30 minutes, leaving ample gap before SAT sync starts.
|
||||
|
||||
### Environment variables (production)
|
||||
|
||||
```
|
||||
NODE_ENV=production
|
||||
PORT=4000
|
||||
DATABASE_URL=postgresql://postgres:<strong_password>@localhost:5432/horux360?schema=public
|
||||
JWT_SECRET=<cryptographically_secure_random_64_chars>
|
||||
JWT_EXPIRES_IN=24h
|
||||
JWT_REFRESH_EXPIRES_IN=30d
|
||||
CORS_ORIGIN=https://horux360.consultoria-as.com
|
||||
FIEL_ENCRYPTION_KEY=<separate_32_byte_hex_key>
|
||||
MP_ACCESS_TOKEN=<mercadopago_production_token>
|
||||
MP_WEBHOOK_SECRET=<webhook_secret>
|
||||
MP_NOTIFICATION_URL=https://horux360.consultoria-as.com/api/webhooks/mercadopago
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=<user>@horuxfin.com
|
||||
SMTP_PASS=<google_app_password>
|
||||
SMTP_FROM=Horux360 <noreply@horuxfin.com>
|
||||
ADMIN_EMAIL=admin@horuxfin.com
|
||||
```
|
||||
|
||||
### SAT cron
|
||||
|
||||
Already implemented. Runs at 3:00 AM when `NODE_ENV=production`. Will activate automatically with the environment change.
|
||||
|
||||
---
|
||||
|
||||
## Section 6: Plan Enforcement & Feature Gating
|
||||
|
||||
### Enforcement middleware
|
||||
|
||||
```typescript
|
||||
// middleware: checkPlanLimits
|
||||
async function checkPlanLimits(req, res, next) {
|
||||
const tenant = await getTenantWithCache(req.user.tenantId); // cached 5 min
|
||||
const subscription = await getActiveSubscription(tenant.id);
|
||||
|
||||
// Admin-impersonated requests bypass subscription check
|
||||
// (admin needs to complete client setup regardless of payment status)
|
||||
if (req.headers['x-view-tenant'] && req.user.role === 'admin') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Allowed statuses: 'authorized' (paid) or 'pending' (grace period for new clients)
|
||||
const allowedStatuses = ['authorized', 'pending'];
|
||||
|
||||
// Check subscription status
|
||||
if (!subscription || !allowedStatuses.includes(subscription.status)) {
|
||||
// Allow read-only access for cancelled/paused subscriptions
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(403).json({
|
||||
message: 'Suscripción inactiva. Contacta soporte para reactivar.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
```
|
||||
|
||||
**Grace period:** New clients start with `status: 'pending'` and have full write access (can upload FIEL, upload CFDIs, etc.). Once the subscription moves to `'cancelled'` or `'paused'` (e.g., failed payment), write access is revoked. Admin can also manually set status to `'authorized'` for clients who pay by bank transfer.
|
||||
|
||||
### CFDI limit check
|
||||
|
||||
Applied on `POST /api/cfdi/` and `POST /api/cfdi/bulk`:
|
||||
|
||||
```typescript
|
||||
async function checkCfdiLimit(req, res, next) {
|
||||
const tenant = await getTenantWithCache(req.user.tenantId);
|
||||
if (tenant.cfdiLimit === -1) return next(); // unlimited
|
||||
|
||||
const currentCount = await getCfdiCountWithCache(req.tenantPool); // cached 5 min
|
||||
const newCount = Array.isArray(req.body) ? req.body.length : 1;
|
||||
|
||||
if (currentCount + newCount > tenant.cfdiLimit) {
|
||||
return res.status(403).json({
|
||||
message: `Límite de CFDIs alcanzado (${currentCount}/${tenant.cfdiLimit}). Contacta soporte para upgrade.`
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
```
|
||||
|
||||
### User limit check
|
||||
|
||||
Applied on `POST /api/usuarios/invite` (already partially exists):
|
||||
|
||||
```typescript
|
||||
const userCount = await getUserCountForTenant(tenantId);
|
||||
if (userCount >= tenant.usersLimit && tenant.usersLimit !== -1) {
|
||||
return res.status(403).json({
|
||||
message: `Límite de usuarios alcanzado (${userCount}/${tenant.usersLimit}).`
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Feature gating
|
||||
|
||||
Applied per route using the existing `hasFeature()` function from shared:
|
||||
|
||||
```typescript
|
||||
function requireFeature(feature: string) {
|
||||
return async (req, res, next) => {
|
||||
const tenant = await getTenantWithCache(req.user.tenantId);
|
||||
if (!hasFeature(tenant.plan, feature)) {
|
||||
return res.status(403).json({
|
||||
message: 'Tu plan no incluye esta función. Contacta soporte para upgrade.'
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// Usage in routes:
|
||||
router.get('/reportes', authenticate, requireFeature('reportes'), reportesController);
|
||||
router.get('/alertas', authenticate, requireFeature('alertas'), alertasController);
|
||||
```
|
||||
|
||||
### Feature matrix
|
||||
|
||||
| Feature key | Starter | Business | Professional | Enterprise |
|
||||
|-------------|---------|----------|-------------|------------|
|
||||
| dashboard | Yes | Yes | Yes | Yes |
|
||||
| cfdi_basic | Yes | Yes | Yes | Yes |
|
||||
| iva_isr | Yes | Yes | Yes | Yes |
|
||||
| reportes | No | Yes | Yes | Yes |
|
||||
| alertas | No | Yes | Yes | Yes |
|
||||
| calendario | No | Yes | Yes | Yes |
|
||||
| xml_sat | No | No | Yes | Yes |
|
||||
| conciliacion | No | No | Yes | Yes |
|
||||
| forecasting | No | No | Yes | Yes |
|
||||
| multi_empresa | No | No | No | Yes |
|
||||
| api_externa | No | No | No | Yes |
|
||||
|
||||
### Frontend feature gating
|
||||
|
||||
The sidebar/navigation hides menu items based on plan:
|
||||
|
||||
```typescript
|
||||
const tenant = useTenantInfo(); // new hook
|
||||
const menuItems = allMenuItems.filter(item =>
|
||||
!item.requiredFeature || hasFeature(tenant.plan, item.requiredFeature)
|
||||
);
|
||||
```
|
||||
|
||||
Pages also show an "upgrade" message if accessed directly via URL without the required plan.
|
||||
|
||||
### Caching
|
||||
|
||||
Plan checks and CFDI counts are cached in-memory with 5-minute TTL to avoid database queries on every request.
|
||||
|
||||
**Cache invalidation across PM2 workers:** Since each PM2 cluster worker has its own in-memory cache, subscription status changes (via webhook) must invalidate the cache in all workers. The webhook handler writes the status to the DB, then sends a `process.send()` message to the PM2 master which broadcasts to all workers to invalidate the specific tenant's cache entry. This ensures all workers reflect subscription changes within seconds, not minutes.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Nginx (443/80) │
|
||||
│ SSL + Rate Limit │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
│ │ │
|
||||
┌─────▼─────┐ ┌────▼────┐ ┌──────▼──────┐
|
||||
│ Next.js │ │ Express │ │ Webhook │
|
||||
│ :3000 │ │ API x2 │ │ Handler │
|
||||
│ (fork) │ │ :4000 │ │ (no auth) │
|
||||
└───────────┘ │ (cluster)│ └──────┬──────┘
|
||||
└────┬────┘ │
|
||||
│ │
|
||||
┌─────────▼──────────┐ │
|
||||
│ TenantConnection │ │
|
||||
│ Manager │ │
|
||||
│ (pool per tenant) │ │
|
||||
└─────────┬──────────┘ │
|
||||
│ │
|
||||
┌──────────────────┼──────┐ │
|
||||
│ │ │ │
|
||||
┌─────▼─────┐ ┌───────▼┐ ┌──▼──┐ │
|
||||
│ horux360 │ │horux_ │ │horux│ │
|
||||
│ (central) │ │client1 │ │_... │ │
|
||||
│ │ └────────┘ └─────┘ │
|
||||
│ tenants │ │
|
||||
│ users │◄────────────────────────┘
|
||||
│ subs │ (webhook updates)
|
||||
│ payments │
|
||||
└───────────┘
|
||||
|
||||
┌───────────────┐ ┌─────────────┐
|
||||
│ /var/horux/ │ │ Gmail SMTP │
|
||||
│ fiel/<rfc>/ │ │ @horuxfin │
|
||||
│ backups/ │ └─────────────┘
|
||||
└───────────────┘
|
||||
|
||||
┌───────────────┐
|
||||
│ MercadoPago │
|
||||
│ Preapproval │
|
||||
│ API │
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Landing page (already exists separately)
|
||||
- Self-service registration (clients are registered manually by admin)
|
||||
- Automatic SAT connector (manual FIEL linking for now)
|
||||
- Plan change by client (admin handles upgrades/downgrades)
|
||||
- Mobile app
|
||||
- Multi-region deployment
|
||||
36
ecosystem.config.js
Normal file
36
ecosystem.config.js
Normal file
@@ -0,0 +1,36 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'horux-api',
|
||||
cwd: '/root/Horux/apps/api',
|
||||
script: 'pnpm',
|
||||
args: 'dev',
|
||||
interpreter: 'none',
|
||||
watch: false,
|
||||
autorestart: true,
|
||||
restart_delay: 10000,
|
||||
max_restarts: 3,
|
||||
kill_timeout: 5000,
|
||||
env: {
|
||||
NODE_ENV: 'development',
|
||||
PORT: 4000
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'horux-web',
|
||||
cwd: '/root/Horux/apps/web',
|
||||
script: 'pnpm',
|
||||
args: 'dev',
|
||||
interpreter: 'none',
|
||||
watch: false,
|
||||
autorestart: true,
|
||||
restart_delay: 10000,
|
||||
max_restarts: 3,
|
||||
kill_timeout: 5000,
|
||||
env: {
|
||||
NODE_ENV: 'development',
|
||||
PORT: 3000
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -12,6 +12,7 @@
|
||||
"db:seed": "turbo run db:seed"
|
||||
},
|
||||
"devDependencies": {
|
||||
"pg": "^8.18.0",
|
||||
"turbo": "^2.3.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface Cfdi {
|
||||
estado: EstadoCfdi;
|
||||
xmlUrl: string | null;
|
||||
pdfUrl: string | null;
|
||||
xmlOriginal: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -36,6 +37,8 @@ export interface CfdiFilters {
|
||||
fechaInicio?: string;
|
||||
fechaFin?: string;
|
||||
rfc?: string;
|
||||
emisor?: string;
|
||||
receptor?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
|
||||
405
pnpm-lock.yaml
generated
405
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
pg:
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.0
|
||||
turbo:
|
||||
specifier: ^2.3.0
|
||||
version: 2.7.5
|
||||
@@ -112,7 +115,7 @@ importers:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.0
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-dropdown-menu':
|
||||
specifier: ^2.1.0
|
||||
@@ -120,6 +123,9 @@ importers:
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-popover':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.1.0
|
||||
version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -156,6 +162,12 @@ importers:
|
||||
date-fns:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
file-saver:
|
||||
specifier: ^2.0.5
|
||||
version: 2.0.5
|
||||
html2pdf.js:
|
||||
specifier: ^0.14.0
|
||||
version: 0.14.0
|
||||
lucide-react:
|
||||
specifier: ^0.460.0
|
||||
version: 0.460.0(react@18.3.1)
|
||||
@@ -177,6 +189,9 @@ importers:
|
||||
tailwind-merge:
|
||||
specifier: ^2.5.0
|
||||
version: 2.6.0
|
||||
xlsx:
|
||||
specifier: ^0.18.5
|
||||
version: 0.18.5
|
||||
zod:
|
||||
specifier: ^3.23.0
|
||||
version: 3.25.76
|
||||
@@ -184,6 +199,9 @@ importers:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.10(@types/react@18.3.27)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
|
||||
devDependencies:
|
||||
'@types/file-saver':
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.7
|
||||
@@ -717,6 +735,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popover@1.1.15':
|
||||
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popper@1.2.8':
|
||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||
peerDependencies:
|
||||
@@ -1048,6 +1079,9 @@ packages:
|
||||
'@types/express@5.0.6':
|
||||
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
|
||||
|
||||
'@types/file-saver@2.0.7':
|
||||
resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==}
|
||||
|
||||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
@@ -1069,12 +1103,18 @@ packages:
|
||||
'@types/node@22.19.7':
|
||||
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
|
||||
|
||||
'@types/pako@2.0.4':
|
||||
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
|
||||
|
||||
'@types/prop-types@15.7.15':
|
||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||
|
||||
'@types/qs@6.14.0':
|
||||
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
|
||||
|
||||
'@types/raf@3.4.3':
|
||||
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
|
||||
|
||||
'@types/range-parser@1.2.7':
|
||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
||||
|
||||
@@ -1092,6 +1132,9 @@ packages:
|
||||
'@types/serve-static@2.2.0':
|
||||
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@vilic/node-forge@1.3.2-5':
|
||||
resolution: {integrity: sha512-8GVr3S/nmLKL7QI7RYhVIcz3PuT/fxfkQLuh/F1CaT+/3QgI14RqiJkcKIni7h9u4ySbQGiGvm4XbNxRBJin4g==}
|
||||
engines: {node: '>= 6.13.0'}
|
||||
@@ -1104,6 +1147,10 @@ packages:
|
||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
adler-32@1.3.1:
|
||||
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
adm-zip@0.5.16:
|
||||
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
|
||||
engines: {node: '>=12.0'}
|
||||
@@ -1156,6 +1203,10 @@ packages:
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
base64-arraybuffer@1.0.2:
|
||||
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
@@ -1242,6 +1293,14 @@ packages:
|
||||
caniuse-lite@1.0.30001765:
|
||||
resolution: {integrity: sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==}
|
||||
|
||||
canvg@3.0.11:
|
||||
resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
cfb@1.2.2:
|
||||
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
chainsaw@0.1.0:
|
||||
resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==}
|
||||
|
||||
@@ -1259,6 +1318,10 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
codepage@1.15.0:
|
||||
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -1289,6 +1352,9 @@ packages:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
core-js@3.48.0:
|
||||
resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
@@ -1305,6 +1371,9 @@ packages:
|
||||
resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
css-line-break@2.1.0:
|
||||
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
|
||||
|
||||
cssesc@3.0.0:
|
||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -1398,6 +1467,9 @@ packages:
|
||||
dom-helpers@5.2.1:
|
||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||
|
||||
dompurify@3.3.1:
|
||||
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
|
||||
|
||||
dotenv@17.2.3:
|
||||
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1480,6 +1552,9 @@ packages:
|
||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
|
||||
fast-png@6.4.0:
|
||||
resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==}
|
||||
|
||||
fast-xml-parser@5.3.3:
|
||||
resolution: {integrity: sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==}
|
||||
hasBin: true
|
||||
@@ -1496,6 +1571,12 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fflate@0.8.2:
|
||||
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
||||
|
||||
file-saver@2.0.5:
|
||||
resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==}
|
||||
|
||||
fill-range@7.1.1:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1521,6 +1602,10 @@ packages:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
frac@1.1.2:
|
||||
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
fraction.js@5.3.4:
|
||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||
|
||||
@@ -1597,6 +1682,13 @@ packages:
|
||||
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
html2canvas@1.4.1:
|
||||
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
html2pdf.js@0.14.0:
|
||||
resolution: {integrity: sha512-yvNJgE/8yru2UeGflkPdjW8YEY+nDH5X7/2WG4uiuSCwYiCp8PZ8EKNiTAa6HxJ1NjC51fZSIEq6xld5CADKBQ==}
|
||||
|
||||
http-errors@2.0.1:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -1622,6 +1714,9 @@ packages:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
iobuffer@5.4.0:
|
||||
resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -1660,6 +1755,9 @@ packages:
|
||||
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
||||
jspdf@4.1.0:
|
||||
resolution: {integrity: sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==}
|
||||
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
@@ -1880,6 +1978,9 @@ packages:
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
pako@2.1.0:
|
||||
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||
|
||||
parseurl@1.3.3:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -1894,6 +1995,43 @@ packages:
|
||||
path-to-regexp@0.1.12:
|
||||
resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==}
|
||||
|
||||
performance-now@2.1.0:
|
||||
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
|
||||
|
||||
pg-cloudflare@1.3.0:
|
||||
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
|
||||
|
||||
pg-connection-string@2.11.0:
|
||||
resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==}
|
||||
|
||||
pg-int8@1.0.1:
|
||||
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
pg-pool@3.11.0:
|
||||
resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==}
|
||||
peerDependencies:
|
||||
pg: '>=8.0'
|
||||
|
||||
pg-protocol@1.11.0:
|
||||
resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==}
|
||||
|
||||
pg-types@2.2.0:
|
||||
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
pg@8.18.0:
|
||||
resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==}
|
||||
engines: {node: '>= 16.0.0'}
|
||||
peerDependencies:
|
||||
pg-native: '>=3.0.1'
|
||||
peerDependenciesMeta:
|
||||
pg-native:
|
||||
optional: true
|
||||
|
||||
pgpass@1.0.5:
|
||||
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -1964,6 +2102,22 @@ packages:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postgres-array@2.0.0:
|
||||
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postgres-bytea@1.0.1:
|
||||
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
postgres-date@1.0.7:
|
||||
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
postgres-interval@1.2.0:
|
||||
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
prisma@5.22.0:
|
||||
resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==}
|
||||
engines: {node: '>=16.13'}
|
||||
@@ -1989,6 +2143,9 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
raf@3.4.1:
|
||||
resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
|
||||
|
||||
range-parser@1.2.1:
|
||||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -2087,6 +2244,9 @@ packages:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
regenerator-runtime@0.13.11:
|
||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
@@ -2099,6 +2259,10 @@ packages:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
||||
rgbcolor@1.0.1:
|
||||
resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
|
||||
engines: {node: '>= 0.8.15'}
|
||||
|
||||
rimraf@2.7.1:
|
||||
resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
@@ -2162,6 +2326,18 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
split2@4.2.0:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
ssf@0.11.2:
|
||||
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
stackblur-canvas@2.7.0:
|
||||
resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
|
||||
engines: {node: '>=0.1.14'}
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2201,6 +2377,10 @@ packages:
|
||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
svg-pathdata@6.0.3:
|
||||
resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tailwind-merge@2.6.0:
|
||||
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
|
||||
|
||||
@@ -2213,6 +2393,9 @@ packages:
|
||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
text-segmentation@1.0.3:
|
||||
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
|
||||
|
||||
thenify-all@1.6.0:
|
||||
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
||||
engines: {node: '>=0.8'}
|
||||
@@ -2347,6 +2530,9 @@ packages:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
utrie@1.0.2:
|
||||
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
hasBin: true
|
||||
@@ -2358,12 +2544,29 @@ packages:
|
||||
victory-vendor@36.9.2:
|
||||
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
|
||||
|
||||
wmf@1.0.2:
|
||||
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
word@0.3.0:
|
||||
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
xlsx@0.18.5:
|
||||
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
xmlchars@2.2.0:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
zip-stream@4.1.1:
|
||||
resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -2787,6 +2990,29 @@ snapshots:
|
||||
'@types/react': 18.3.27
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.27)
|
||||
|
||||
'@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1)
|
||||
aria-hidden: 1.2.6
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
react-remove-scroll: 2.7.2(@types/react@18.3.27)(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.27
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.27)
|
||||
|
||||
'@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -3118,6 +3344,8 @@ snapshots:
|
||||
'@types/express-serve-static-core': 5.1.1
|
||||
'@types/serve-static': 2.2.0
|
||||
|
||||
'@types/file-saver@2.0.7': {}
|
||||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
@@ -3139,10 +3367,15 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/pako@2.0.4': {}
|
||||
|
||||
'@types/prop-types@15.7.15': {}
|
||||
|
||||
'@types/qs@6.14.0': {}
|
||||
|
||||
'@types/raf@3.4.3':
|
||||
optional: true
|
||||
|
||||
'@types/range-parser@1.2.7': {}
|
||||
|
||||
'@types/react-dom@18.3.7(@types/react@18.3.27)':
|
||||
@@ -3163,6 +3396,9 @@ snapshots:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 22.19.7
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@vilic/node-forge@1.3.2-5': {}
|
||||
|
||||
'@xmldom/xmldom@0.9.8': {}
|
||||
@@ -3172,6 +3408,8 @@ snapshots:
|
||||
mime-types: 2.1.35
|
||||
negotiator: 0.6.3
|
||||
|
||||
adler-32@1.3.1: {}
|
||||
|
||||
adm-zip@0.5.16: {}
|
||||
|
||||
any-promise@1.3.0: {}
|
||||
@@ -3248,6 +3486,8 @@ snapshots:
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
base64-arraybuffer@1.0.2: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.9.17: {}
|
||||
@@ -3342,6 +3582,23 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001765: {}
|
||||
|
||||
canvg@3.0.11:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
'@types/raf': 3.4.3
|
||||
core-js: 3.48.0
|
||||
raf: 3.4.1
|
||||
regenerator-runtime: 0.13.11
|
||||
rgbcolor: 1.0.1
|
||||
stackblur-canvas: 2.7.0
|
||||
svg-pathdata: 6.0.3
|
||||
optional: true
|
||||
|
||||
cfb@1.2.2:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
crc-32: 1.2.2
|
||||
|
||||
chainsaw@0.1.0:
|
||||
dependencies:
|
||||
traverse: 0.3.9
|
||||
@@ -3366,6 +3623,8 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
codepage@1.15.0: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
@@ -3391,6 +3650,9 @@ snapshots:
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
core-js@3.48.0:
|
||||
optional: true
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cors@2.8.5:
|
||||
@@ -3405,6 +3667,10 @@ snapshots:
|
||||
crc-32: 1.2.2
|
||||
readable-stream: 3.6.2
|
||||
|
||||
css-line-break@2.1.0:
|
||||
dependencies:
|
||||
utrie: 1.0.2
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
@@ -3474,6 +3740,10 @@ snapshots:
|
||||
'@babel/runtime': 7.28.6
|
||||
csstype: 3.2.3
|
||||
|
||||
dompurify@3.3.1:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
dotenv@17.2.3: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
@@ -3615,6 +3885,12 @@ snapshots:
|
||||
merge2: 1.4.1
|
||||
micromatch: 4.0.8
|
||||
|
||||
fast-png@6.4.0:
|
||||
dependencies:
|
||||
'@types/pako': 2.0.4
|
||||
iobuffer: 5.4.0
|
||||
pako: 2.1.0
|
||||
|
||||
fast-xml-parser@5.3.3:
|
||||
dependencies:
|
||||
strnum: 2.1.2
|
||||
@@ -3627,6 +3903,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
fflate@0.8.2: {}
|
||||
|
||||
file-saver@2.0.5: {}
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
@@ -3655,6 +3935,8 @@ snapshots:
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
frac@1.1.2: {}
|
||||
|
||||
fraction.js@5.3.4: {}
|
||||
|
||||
fresh@0.5.2: {}
|
||||
@@ -3732,6 +4014,17 @@ snapshots:
|
||||
|
||||
helmet@8.1.0: {}
|
||||
|
||||
html2canvas@1.4.1:
|
||||
dependencies:
|
||||
css-line-break: 2.1.0
|
||||
text-segmentation: 1.0.3
|
||||
|
||||
html2pdf.js@0.14.0:
|
||||
dependencies:
|
||||
dompurify: 3.3.1
|
||||
html2canvas: 1.4.1
|
||||
jspdf: 4.1.0
|
||||
|
||||
http-errors@2.0.1:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
@@ -3757,6 +4050,8 @@ snapshots:
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
iobuffer@5.4.0: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
@@ -3794,6 +4089,17 @@ snapshots:
|
||||
ms: 2.1.3
|
||||
semver: 7.7.3
|
||||
|
||||
jspdf@4.1.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
fast-png: 6.4.0
|
||||
fflate: 0.8.2
|
||||
optionalDependencies:
|
||||
canvg: 3.0.11
|
||||
core-js: 3.48.0
|
||||
dompurify: 3.3.1
|
||||
html2canvas: 1.4.1
|
||||
|
||||
jszip@3.10.1:
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
@@ -3974,6 +4280,8 @@ snapshots:
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
pako@2.1.0: {}
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
path-is-absolute@1.0.1: {}
|
||||
@@ -3982,6 +4290,44 @@ snapshots:
|
||||
|
||||
path-to-regexp@0.1.12: {}
|
||||
|
||||
performance-now@2.1.0:
|
||||
optional: true
|
||||
|
||||
pg-cloudflare@1.3.0:
|
||||
optional: true
|
||||
|
||||
pg-connection-string@2.11.0: {}
|
||||
|
||||
pg-int8@1.0.1: {}
|
||||
|
||||
pg-pool@3.11.0(pg@8.18.0):
|
||||
dependencies:
|
||||
pg: 8.18.0
|
||||
|
||||
pg-protocol@1.11.0: {}
|
||||
|
||||
pg-types@2.2.0:
|
||||
dependencies:
|
||||
pg-int8: 1.0.1
|
||||
postgres-array: 2.0.0
|
||||
postgres-bytea: 1.0.1
|
||||
postgres-date: 1.0.7
|
||||
postgres-interval: 1.2.0
|
||||
|
||||
pg@8.18.0:
|
||||
dependencies:
|
||||
pg-connection-string: 2.11.0
|
||||
pg-pool: 3.11.0(pg@8.18.0)
|
||||
pg-protocol: 1.11.0
|
||||
pg-types: 2.2.0
|
||||
pgpass: 1.0.5
|
||||
optionalDependencies:
|
||||
pg-cloudflare: 1.3.0
|
||||
|
||||
pgpass@1.0.5:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
@@ -4036,6 +4382,16 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postgres-array@2.0.0: {}
|
||||
|
||||
postgres-bytea@1.0.1: {}
|
||||
|
||||
postgres-date@1.0.7: {}
|
||||
|
||||
postgres-interval@1.2.0:
|
||||
dependencies:
|
||||
xtend: 4.0.2
|
||||
|
||||
prisma@5.22.0:
|
||||
dependencies:
|
||||
'@prisma/engines': 5.22.0
|
||||
@@ -4063,6 +4419,11 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
raf@3.4.1:
|
||||
dependencies:
|
||||
performance-now: 2.1.0
|
||||
optional: true
|
||||
|
||||
range-parser@1.2.1: {}
|
||||
|
||||
raw-body@2.5.3:
|
||||
@@ -4179,6 +4540,9 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
victory-vendor: 36.9.2
|
||||
|
||||
regenerator-runtime@0.13.11:
|
||||
optional: true
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
resolve@1.22.11:
|
||||
@@ -4189,6 +4553,9 @@ snapshots:
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rgbcolor@1.0.1:
|
||||
optional: true
|
||||
|
||||
rimraf@2.7.1:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
@@ -4274,6 +4641,15 @@ snapshots:
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
ssf@0.11.2:
|
||||
dependencies:
|
||||
frac: 1.1.2
|
||||
|
||||
stackblur-canvas@2.7.0:
|
||||
optional: true
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
streamsearch@1.1.0: {}
|
||||
@@ -4305,6 +4681,9 @@ snapshots:
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
svg-pathdata@6.0.3:
|
||||
optional: true
|
||||
|
||||
tailwind-merge@2.6.0: {}
|
||||
|
||||
tailwindcss@3.4.19(tsx@4.21.0):
|
||||
@@ -4343,6 +4722,10 @@ snapshots:
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
text-segmentation@1.0.3:
|
||||
dependencies:
|
||||
utrie: 1.0.2
|
||||
|
||||
thenify-all@1.6.0:
|
||||
dependencies:
|
||||
thenify: 3.3.1
|
||||
@@ -4461,6 +4844,10 @@ snapshots:
|
||||
|
||||
utils-merge@1.0.1: {}
|
||||
|
||||
utrie@1.0.2:
|
||||
dependencies:
|
||||
base64-arraybuffer: 1.0.2
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
vary@1.1.2: {}
|
||||
@@ -4482,10 +4869,26 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
wmf@1.0.2: {}
|
||||
|
||||
word@0.3.0: {}
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
xlsx@0.18.5:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
cfb: 1.2.2
|
||||
codepage: 1.15.0
|
||||
crc-32: 1.2.2
|
||||
ssf: 0.11.2
|
||||
wmf: 1.0.2
|
||||
word: 0.3.0
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
zip-stream@4.1.1:
|
||||
dependencies:
|
||||
archiver-utils: 3.0.4
|
||||
|
||||
135
scripts/update-cfdi-xml.js
Normal file
135
scripts/update-cfdi-xml.js
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Script para actualizar CFDIs existentes con su XML original
|
||||
* Uso: node scripts/update-cfdi-xml.js <directorio> <schema>
|
||||
* Ejemplo: node scripts/update-cfdi-xml.js /root/xmls tenant_roem691011ez4
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
// Configuración de la base de datos
|
||||
const pool = new Pool({
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
database: 'horux360',
|
||||
user: 'postgres',
|
||||
password: 'postgres',
|
||||
});
|
||||
|
||||
// Extraer UUID del XML
|
||||
function extractUuidFromXml(xmlContent) {
|
||||
// Buscar UUID en TimbreFiscalDigital
|
||||
const uuidMatch = xmlContent.match(/UUID=["']([A-Fa-f0-9-]{36})["']/i);
|
||||
return uuidMatch ? uuidMatch[1].toUpperCase() : null;
|
||||
}
|
||||
|
||||
// Procesar archivos en lotes
|
||||
async function processFiles(directory, schema, batchSize = 500) {
|
||||
const files = fs.readdirSync(directory).filter(f => f.toLowerCase().endsWith('.xml'));
|
||||
|
||||
console.log(`\nEncontrados ${files.length} archivos XML en ${directory}`);
|
||||
console.log(`Schema: ${schema}`);
|
||||
console.log(`Tamaño de lote: ${batchSize}\n`);
|
||||
|
||||
let updated = 0;
|
||||
let notFound = 0;
|
||||
let errors = 0;
|
||||
let processed = 0;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Procesar en lotes
|
||||
for (let i = 0; i < files.length; i += batchSize) {
|
||||
const batch = files.slice(i, i + batchSize);
|
||||
const updates = [];
|
||||
|
||||
// Leer y parsear XMLs del lote
|
||||
for (const file of batch) {
|
||||
try {
|
||||
const filePath = path.join(directory, file);
|
||||
const xmlContent = fs.readFileSync(filePath, 'utf8');
|
||||
const uuid = extractUuidFromXml(xmlContent);
|
||||
|
||||
if (uuid) {
|
||||
updates.push({ uuid, xmlContent });
|
||||
} else {
|
||||
errors++;
|
||||
if (errors <= 5) {
|
||||
console.log(` ⚠ No se encontró UUID en: ${file}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
errors++;
|
||||
if (errors <= 5) {
|
||||
console.log(` ✗ Error leyendo ${file}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Actualizar base de datos en una transacción
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
for (const { uuid, xmlContent } of updates) {
|
||||
const result = await client.query(
|
||||
`UPDATE "${schema}".cfdis SET xml_original = $1 WHERE UPPER(uuid_fiscal) = $2 AND xml_original IS NULL`,
|
||||
[xmlContent, uuid]
|
||||
);
|
||||
|
||||
if (result.rowCount > 0) {
|
||||
updated++;
|
||||
} else {
|
||||
notFound++;
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(`Error en lote: ${err.message}`);
|
||||
errors += batch.length;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
processed += batch.length;
|
||||
|
||||
// Progreso
|
||||
const percent = ((processed / files.length) * 100).toFixed(1);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
|
||||
const rate = (processed / elapsed).toFixed(0);
|
||||
process.stdout.write(`\r Procesando: ${processed}/${files.length} (${percent}%) - ${updated} actualizados - ${rate} archivos/seg `);
|
||||
}
|
||||
|
||||
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
|
||||
console.log(`\n\n✓ Completado en ${totalTime} segundos`);
|
||||
console.log(` - Actualizados: ${updated}`);
|
||||
console.log(` - No encontrados (UUID no existe en BD): ${notFound}`);
|
||||
console.log(` - Errores: ${errors}`);
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
// Main
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 2) {
|
||||
console.log('Uso: node scripts/update-cfdi-xml.js <directorio> <schema>');
|
||||
console.log('Ejemplo: node scripts/update-cfdi-xml.js /root/xmls tenant_roem691011ez4');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [directory, schema] = args;
|
||||
|
||||
if (!fs.existsSync(directory)) {
|
||||
console.error(`Error: El directorio ${directory} no existe`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
processFiles(directory, schema).catch(err => {
|
||||
console.error('Error fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user