feat: Implement Phase 1 & 2 - Full monorepo architecture

## Backend API (apps/api)
- Express.js server with TypeScript
- JWT authentication with access/refresh tokens
- Multi-tenant middleware (schema per tenant)
- Complete CRUD routes: auth, cfdis, transactions, contacts, categories, metrics, alerts
- SAT integration: CFDI 4.0 XML parser, FIEL authentication
- Metrics engine: 50+ financial metrics (Core, Startup, Enterprise)
- Rate limiting, CORS, Helmet security

## Frontend Web (apps/web)
- Next.js 14 with App Router
- Authentication pages: login, register, forgot-password
- Dashboard layout with Sidebar and Header
- Dashboard pages: overview, cash-flow, revenue, expenses, metrics
- Zustand stores for auth and UI state
- Theme support with flash prevention

## Database Package (packages/database)
- PostgreSQL migrations with multi-tenant architecture
- Public schema: plans, tenants, users, sessions, subscriptions
- Tenant schema: sat_credentials, cfdis, transactions, contacts, accounts, alerts
- Tenant management functions
- Seed data for plans and super admin

## Shared Package (packages/shared)
- TypeScript types: auth, tenant, financial, metrics, reports
- Zod validation schemas for all entities
- Utility functions for formatting

## UI Package (packages/ui)
- Chart components: LineChart, BarChart, AreaChart, PieChart
- Data components: DataTable, MetricCard, KPICard, AlertBadge
- PeriodSelector and Skeleton components

## Infrastructure
- Docker Compose: PostgreSQL 15, Redis 7, MinIO, Mailhog
- Makefile with 25+ development commands
- Development scripts: dev-setup.sh, dev-down.sh
- Complete .env.example template

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 11:05:24 +00:00
parent c1321c3f0c
commit a9b1994c48
110 changed files with 40788 additions and 0 deletions

58
apps/api/.env.example Normal file
View File

@@ -0,0 +1,58 @@
# ============================================================================
# Horux Strategy API - Environment Variables
# ============================================================================
# Copy this file to .env and fill in the values
# Server Configuration
NODE_ENV=development
PORT=4000
HOST=0.0.0.0
API_VERSION=v1
# Database (PostgreSQL)
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/horux_strategy
DATABASE_POOL_MIN=2
DATABASE_POOL_MAX=10
# Redis
REDIS_URL=redis://localhost:6379
# JWT Secrets (minimum 32 characters each)
# IMPORTANT: Generate secure random strings for production!
JWT_ACCESS_SECRET=your-access-secret-at-least-32-characters
JWT_REFRESH_SECRET=your-refresh-secret-at-least-32-characters
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# Password Reset
PASSWORD_RESET_SECRET=your-reset-secret-at-least-32-characters!
PASSWORD_RESET_EXPIRES_IN=1h
# Security
BCRYPT_ROUNDS=12
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=100
# MinIO / S3 Object Storage
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=horux-strategy
MINIO_USE_SSL=false
# Email (SMTP)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
EMAIL_FROM=noreply@horuxstrategy.com
# Logging
LOG_LEVEL=info
LOG_FORMAT=simple
# Feature Flags
ENABLE_SWAGGER=true
ENABLE_METRICS=true

51
apps/api/package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "@horux/api",
"version": "0.1.0",
"private": true,
"description": "Horux Strategy Backend API",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:watch": "vitest --watch",
"clean": "rm -rf dist node_modules"
},
"dependencies": {
"@horux/database": "workspace:*",
"@horux/shared": "workspace:*",
"bcryptjs": "^2.4.3",
"bullmq": "^5.1.0",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.2",
"minio": "^7.1.3",
"pg": "^8.11.3",
"uuid": "^9.0.1",
"winston": "^3.11.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20.11.0",
"@types/pg": "^8.10.9",
"@types/uuid": "^9.0.7",
"eslint": "^8.56.0",
"tsx": "^4.7.0",
"typescript": "^5.3.3",
"vitest": "^1.2.0"
}
}

View File

@@ -0,0 +1,205 @@
import dotenv from 'dotenv';
import { z } from 'zod';
// Load environment variables
dotenv.config();
// ============================================================================
// Environment Schema
// ============================================================================
const envSchema = z.object({
// Server
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().transform(Number).default('4000'),
HOST: z.string().default('0.0.0.0'),
API_VERSION: z.string().default('v1'),
// Database
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
DATABASE_POOL_MIN: z.string().transform(Number).default('2'),
DATABASE_POOL_MAX: z.string().transform(Number).default('10'),
// Redis
REDIS_URL: z.string().default('redis://localhost:6379'),
// JWT
JWT_ACCESS_SECRET: z.string().min(32, 'JWT_ACCESS_SECRET must be at least 32 characters'),
JWT_REFRESH_SECRET: z.string().min(32, 'JWT_REFRESH_SECRET must be at least 32 characters'),
JWT_ACCESS_EXPIRES_IN: z.string().default('15m'),
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
// Password Reset
PASSWORD_RESET_SECRET: z.string().min(32, 'PASSWORD_RESET_SECRET must be at least 32 characters'),
PASSWORD_RESET_EXPIRES_IN: z.string().default('1h'),
// Security
BCRYPT_ROUNDS: z.string().transform(Number).default('12'),
CORS_ORIGINS: z.string().default('http://localhost:3000'),
RATE_LIMIT_WINDOW_MS: z.string().transform(Number).default('60000'),
RATE_LIMIT_MAX_REQUESTS: z.string().transform(Number).default('100'),
// MinIO / S3
MINIO_ENDPOINT: z.string().default('localhost'),
MINIO_PORT: z.string().transform(Number).default('9000'),
MINIO_ACCESS_KEY: z.string().default('minioadmin'),
MINIO_SECRET_KEY: z.string().default('minioadmin'),
MINIO_BUCKET: z.string().default('horux-strategy'),
MINIO_USE_SSL: z.string().transform((v) => v === 'true').default('false'),
// Email (optional for now)
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().transform(Number).optional(),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
EMAIL_FROM: z.string().default('noreply@horuxstrategy.com'),
// Logging
LOG_LEVEL: z.enum(['error', 'warn', 'info', 'http', 'verbose', 'debug', 'silly']).default('info'),
LOG_FORMAT: z.enum(['json', 'simple']).default('json'),
// Feature Flags
ENABLE_SWAGGER: z.string().transform((v) => v === 'true').default('true'),
ENABLE_METRICS: z.string().transform((v) => v === 'true').default('true'),
});
// ============================================================================
// Parse and Validate Environment
// ============================================================================
const parseEnv = () => {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('Environment validation failed:');
console.error(result.error.format());
// In development, provide default values for required fields
if (process.env.NODE_ENV !== 'production') {
console.warn('Using default development configuration...');
return {
NODE_ENV: 'development' as const,
PORT: 4000,
HOST: '0.0.0.0',
API_VERSION: 'v1',
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/horux_strategy',
DATABASE_POOL_MIN: 2,
DATABASE_POOL_MAX: 10,
REDIS_URL: 'redis://localhost:6379',
JWT_ACCESS_SECRET: 'dev-access-secret-change-in-production-32chars',
JWT_REFRESH_SECRET: 'dev-refresh-secret-change-in-production-32chars',
JWT_ACCESS_EXPIRES_IN: '15m',
JWT_REFRESH_EXPIRES_IN: '7d',
PASSWORD_RESET_SECRET: 'dev-reset-secret-change-in-production-32chars!',
PASSWORD_RESET_EXPIRES_IN: '1h',
BCRYPT_ROUNDS: 12,
CORS_ORIGINS: 'http://localhost:3000',
RATE_LIMIT_WINDOW_MS: 60000,
RATE_LIMIT_MAX_REQUESTS: 100,
MINIO_ENDPOINT: 'localhost',
MINIO_PORT: 9000,
MINIO_ACCESS_KEY: 'minioadmin',
MINIO_SECRET_KEY: 'minioadmin',
MINIO_BUCKET: 'horux-strategy',
MINIO_USE_SSL: false,
SMTP_HOST: undefined,
SMTP_PORT: undefined,
SMTP_USER: undefined,
SMTP_PASS: undefined,
EMAIL_FROM: 'noreply@horuxstrategy.com',
LOG_LEVEL: 'info' as const,
LOG_FORMAT: 'simple' as const,
ENABLE_SWAGGER: true,
ENABLE_METRICS: true,
};
}
process.exit(1);
}
return result.data;
};
const env = parseEnv();
// ============================================================================
// Configuration Object
// ============================================================================
export const config = {
env: env.NODE_ENV,
isDevelopment: env.NODE_ENV === 'development',
isProduction: env.NODE_ENV === 'production',
isTest: env.NODE_ENV === 'test',
server: {
port: env.PORT,
host: env.HOST,
apiVersion: env.API_VERSION,
apiPrefix: `/api/${env.API_VERSION}`,
},
database: {
url: env.DATABASE_URL,
pool: {
min: env.DATABASE_POOL_MIN,
max: env.DATABASE_POOL_MAX,
},
},
redis: {
url: env.REDIS_URL,
},
jwt: {
accessSecret: env.JWT_ACCESS_SECRET,
refreshSecret: env.JWT_REFRESH_SECRET,
accessExpiresIn: env.JWT_ACCESS_EXPIRES_IN,
refreshExpiresIn: env.JWT_REFRESH_EXPIRES_IN,
},
passwordReset: {
secret: env.PASSWORD_RESET_SECRET,
expiresIn: env.PASSWORD_RESET_EXPIRES_IN,
},
security: {
bcryptRounds: env.BCRYPT_ROUNDS,
corsOrigins: env.CORS_ORIGINS.split(',').map((origin) => origin.trim()),
rateLimit: {
windowMs: env.RATE_LIMIT_WINDOW_MS,
maxRequests: env.RATE_LIMIT_MAX_REQUESTS,
},
},
minio: {
endpoint: env.MINIO_ENDPOINT,
port: env.MINIO_PORT,
accessKey: env.MINIO_ACCESS_KEY,
secretKey: env.MINIO_SECRET_KEY,
bucket: env.MINIO_BUCKET,
useSSL: env.MINIO_USE_SSL,
},
email: {
host: env.SMTP_HOST,
port: env.SMTP_PORT,
user: env.SMTP_USER,
pass: env.SMTP_PASS,
from: env.EMAIL_FROM,
},
logging: {
level: env.LOG_LEVEL,
format: env.LOG_FORMAT,
},
features: {
swagger: env.ENABLE_SWAGGER,
metrics: env.ENABLE_METRICS,
},
} as const;
export type Config = typeof config;
export default config;

281
apps/api/src/index.ts Normal file
View File

@@ -0,0 +1,281 @@
import express, { Application, Request, Response, NextFunction } from 'express';
import helmet from 'helmet';
import cors from 'cors';
import compression from 'compression';
import rateLimit from 'express-rate-limit';
import { v4 as uuidv4 } from 'uuid';
import { config } from './config/index.js';
import { logger, httpLogger } from './utils/logger.js';
import { errorHandler, notFoundHandler } from './middleware/errorHandler.js';
import { authenticate } from './middleware/auth.js';
import { tenantContext } from './middleware/tenant.js';
import authRoutes from './routes/auth.routes.js';
import healthRoutes from './routes/health.routes.js';
import metricsRoutes from './routes/metrics.routes.js';
import transactionsRoutes from './routes/transactions.routes.js';
import contactsRoutes from './routes/contacts.routes.js';
import cfdisRoutes from './routes/cfdis.routes.js';
import categoriesRoutes from './routes/categories.routes.js';
import alertsRoutes from './routes/alerts.routes.js';
// ============================================================================
// Application Setup
// ============================================================================
const app: Application = express();
// ============================================================================
// Trust Proxy (for reverse proxies like nginx)
// ============================================================================
app.set('trust proxy', 1);
// ============================================================================
// Request ID Middleware
// ============================================================================
app.use((req: Request, _res: Response, next: NextFunction) => {
req.headers['x-request-id'] = req.headers['x-request-id'] || uuidv4();
next();
});
// ============================================================================
// Security Middleware
// ============================================================================
// Helmet - sets various HTTP headers for security
app.use(
helmet({
contentSecurityPolicy: config.isProduction,
crossOriginEmbedderPolicy: false,
})
);
// CORS configuration
app.use(
cors({
origin: config.security.corsOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID', 'X-Tenant-ID'],
exposedHeaders: ['X-Request-ID', 'X-RateLimit-Limit', 'X-RateLimit-Remaining'],
maxAge: 86400, // 24 hours
})
);
// ============================================================================
// Compression Middleware
// ============================================================================
app.use(
compression({
filter: (req, res) => {
if (req.headers['x-no-compression']) {
return false;
}
return compression.filter(req, res);
},
level: 6,
})
);
// ============================================================================
// Body Parsing Middleware
// ============================================================================
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// ============================================================================
// Request Logging Middleware
// ============================================================================
app.use((req: Request, res: Response, next: NextFunction) => {
const startTime = Date.now();
res.on('finish', () => {
const duration = Date.now() - startTime;
const logMessage = `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`;
if (res.statusCode >= 500) {
logger.error(logMessage, {
requestId: req.headers['x-request-id'],
ip: req.ip,
userAgent: req.headers['user-agent'],
});
} else if (res.statusCode >= 400) {
logger.warn(logMessage, {
requestId: req.headers['x-request-id'],
});
} else {
httpLogger.write(logMessage);
}
});
next();
});
// ============================================================================
// Rate Limiting
// ============================================================================
// General rate limiter
const generalLimiter = rateLimit({
windowMs: config.security.rateLimit.windowMs,
max: config.security.rateLimit.maxRequests,
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Demasiadas solicitudes. Por favor intenta de nuevo mas tarde.',
},
},
skip: (req) => {
// Skip rate limiting for health checks
return req.path.startsWith('/health');
},
});
// Stricter rate limiter for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20, // 20 requests per window
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
error: {
code: 'AUTH_RATE_LIMIT_EXCEEDED',
message: 'Demasiados intentos de autenticacion. Por favor intenta de nuevo en 15 minutos.',
},
},
});
app.use(generalLimiter);
// ============================================================================
// API Routes
// ============================================================================
const apiPrefix = config.server.apiPrefix;
// Health check routes (no prefix, no rate limit)
app.use('/health', healthRoutes);
// Auth routes with stricter rate limiting
app.use(`${apiPrefix}/auth`, authLimiter, authRoutes);
// Protected routes (require authentication and tenant context)
app.use(`${apiPrefix}/metrics`, authenticate, tenantContext, metricsRoutes);
app.use(`${apiPrefix}/transactions`, authenticate, tenantContext, transactionsRoutes);
app.use(`${apiPrefix}/contacts`, authenticate, tenantContext, contactsRoutes);
app.use(`${apiPrefix}/cfdis`, authenticate, tenantContext, cfdisRoutes);
app.use(`${apiPrefix}/categories`, authenticate, tenantContext, categoriesRoutes);
app.use(`${apiPrefix}/alerts`, authenticate, tenantContext, alertsRoutes);
// ============================================================================
// API Info Route
// ============================================================================
app.get(apiPrefix, (_req: Request, res: Response) => {
res.json({
success: true,
data: {
name: 'Horux Strategy API',
version: process.env.npm_package_version || '0.1.0',
environment: config.env,
documentation: config.features.swagger ? `${apiPrefix}/docs` : undefined,
},
meta: {
timestamp: new Date().toISOString(),
},
});
});
// ============================================================================
// 404 Handler
// ============================================================================
app.use(notFoundHandler);
// ============================================================================
// Error Handler
// ============================================================================
app.use(errorHandler);
// ============================================================================
// Server Startup
// ============================================================================
const startServer = async (): Promise<void> => {
try {
// Start listening
const server = app.listen(config.server.port, config.server.host, () => {
logger.info(`Horux Strategy API started`, {
port: config.server.port,
host: config.server.host,
environment: config.env,
apiPrefix: config.server.apiPrefix,
nodeVersion: process.version,
});
if (config.isDevelopment) {
logger.info(`API available at http://${config.server.host}:${config.server.port}${config.server.apiPrefix}`);
logger.info(`Health check at http://${config.server.host}:${config.server.port}/health`);
}
});
// Graceful shutdown handling
const gracefulShutdown = async (signal: string): Promise<void> => {
logger.info(`${signal} received. Starting graceful shutdown...`);
server.close((err) => {
if (err) {
logger.error('Error during server close', { error: err });
process.exit(1);
}
logger.info('Server closed. Cleaning up...');
// Add any cleanup logic here (close database connections, etc.)
logger.info('Graceful shutdown completed');
process.exit(0);
});
// Force shutdown after 30 seconds
setTimeout(() => {
logger.error('Forced shutdown after timeout');
process.exit(1);
}, 30000);
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
// Handle uncaught exceptions
process.on('uncaughtException', (error: Error) => {
logger.error('Uncaught Exception', { error: error.message, stack: error.stack });
process.exit(1);
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason: unknown) => {
logger.error('Unhandled Rejection', { reason });
process.exit(1);
});
} catch (error) {
logger.error('Failed to start server', { error });
process.exit(1);
}
};
// Start the server
startServer();
// Export for testing
export default app;

View File

@@ -0,0 +1,131 @@
/**
* Authentication Middleware
*
* Handles JWT token validation and tenant context setup
*/
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { config } from '../config';
import { AccessTokenPayload, AuthenticationError, AuthorizationError, UserRole } from '../types';
// Extend Express Request to include user and tenant context
declare global {
namespace Express {
interface Request {
user?: AccessTokenPayload;
tenantSchema?: string;
}
}
}
/**
* Verify JWT access token and attach user info to request
*/
export const authenticate = async (
req: Request,
_res: Response,
next: NextFunction
): Promise<void> => {
try {
const authHeader = req.headers.authorization;
if (!authHeader) {
throw new AuthenticationError('Token de autorizacion no proporcionado');
}
const [type, token] = authHeader.split(' ');
if (type !== 'Bearer' || !token) {
throw new AuthenticationError('Formato de token invalido');
}
const payload = jwt.verify(token, config.jwt.accessSecret) as AccessTokenPayload;
if (payload.type !== 'access') {
throw new AuthenticationError('Tipo de token invalido');
}
// Attach user info to request
req.user = payload;
req.tenantSchema = payload.schema_name;
next();
} catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
next(new AuthenticationError('Token invalido'));
} else if (error instanceof jwt.TokenExpiredError) {
next(new AuthenticationError('Token expirado'));
} else {
next(error);
}
}
};
/**
* Check if user has required role
*/
export const requireRole = (...roles: UserRole[]) => {
return (req: Request, _res: Response, next: NextFunction): void => {
if (!req.user) {
next(new AuthenticationError('Usuario no autenticado'));
return;
}
if (!roles.includes(req.user.role)) {
next(new AuthorizationError('No tienes permisos para realizar esta accion'));
return;
}
next();
};
};
/**
* Require owner or admin role
*/
export const requireAdmin = requireRole('owner', 'admin');
/**
* Require owner role only
*/
export const requireOwner = requireRole('owner');
/**
* Optional authentication - doesn't fail if no token provided
*/
export const optionalAuth = async (
req: Request,
_res: Response,
next: NextFunction
): Promise<void> => {
try {
const authHeader = req.headers.authorization;
if (!authHeader) {
next();
return;
}
const [type, token] = authHeader.split(' ');
if (type !== 'Bearer' || !token) {
next();
return;
}
const payload = jwt.verify(token, config.jwt.accessSecret) as AccessTokenPayload;
if (payload.type === 'access') {
req.user = payload;
req.tenantSchema = payload.schema_name;
}
next();
} catch {
// Ignore errors in optional auth
next();
}
};
export default authenticate;

View File

@@ -0,0 +1,178 @@
import { Request, Response, NextFunction } from 'express';
import { jwtService } from '../services/jwt.service.js';
import { AccessTokenPayload, AuthenticationError, AuthorizationError, UserRole } from '../types/index.js';
import { logger } from '../utils/logger.js';
// ============================================================================
// Extend Express Request Type
// ============================================================================
declare global {
namespace Express {
interface Request {
user?: AccessTokenPayload;
tenantSchema?: string;
}
}
}
// ============================================================================
// Auth Middleware
// ============================================================================
/**
* Middleware to authenticate requests using JWT
*/
export const authenticate = async (
req: Request,
_res: Response,
next: NextFunction
): Promise<void> => {
try {
const authHeader = req.headers.authorization;
if (!authHeader) {
throw new AuthenticationError('Token de autenticacion no proporcionado');
}
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
throw new AuthenticationError('Formato de token invalido. Use: Bearer <token>');
}
const token = parts[1];
if (!token) {
throw new AuthenticationError('Token no proporcionado');
}
// Verify token
const payload = jwtService.verifyAccessToken(token);
// Attach user info to request
req.user = payload;
req.tenantSchema = payload.schema_name;
next();
} catch (error) {
next(error);
}
};
/**
* Middleware to optionally authenticate (doesn't fail if no token)
*/
export const optionalAuth = async (
req: Request,
_res: Response,
next: NextFunction
): Promise<void> => {
try {
const authHeader = req.headers.authorization;
if (!authHeader) {
return next();
}
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer' || !parts[1]) {
return next();
}
try {
const payload = jwtService.verifyAccessToken(parts[1]);
req.user = payload;
req.tenantSchema = payload.schema_name;
} catch {
// Ignore token errors in optional auth
logger.debug('Optional auth failed', { error: 'Invalid token' });
}
next();
} catch (error) {
next(error);
}
};
/**
* Factory function to create role-based authorization middleware
*/
export const authorize = (...allowedRoles: UserRole[]) => {
return (req: Request, _res: Response, next: NextFunction): void => {
if (!req.user) {
return next(new AuthenticationError('No autenticado'));
}
if (!allowedRoles.includes(req.user.role)) {
logger.warn('Authorization failed', {
userId: req.user.sub,
userRole: req.user.role,
requiredRoles: allowedRoles,
path: req.path,
});
return next(new AuthorizationError('No tienes permisos para realizar esta accion'));
}
next();
};
};
/**
* Middleware to ensure user has owner role
*/
export const requireOwner = authorize('owner');
/**
* Middleware to ensure user has admin or owner role
*/
export const requireAdmin = authorize('owner', 'admin');
/**
* Middleware to ensure user has at least member role
*/
export const requireMember = authorize('owner', 'admin', 'member');
/**
* Middleware to check if user can access a specific tenant
*/
export const requireTenantAccess = (
req: Request,
_res: Response,
next: NextFunction
): void => {
if (!req.user) {
return next(new AuthenticationError('No autenticado'));
}
const tenantId = req.params.tenantId || req.body?.tenantId;
if (tenantId && tenantId !== req.user.tenant_id) {
logger.warn('Cross-tenant access attempt', {
userId: req.user.sub,
userTenantId: req.user.tenant_id,
requestedTenantId: tenantId,
path: req.path,
});
return next(new AuthorizationError('No tienes acceso a este tenant'));
}
next();
};
/**
* Rate limiting helper - checks if user has exceeded request limits
* This is a placeholder - actual rate limiting is handled by express-rate-limit
*/
export const checkRateLimit = (
req: Request,
_res: Response,
next: NextFunction
): void => {
// This middleware can be extended to implement custom rate limiting logic
// For now, we rely on express-rate-limit configured in the main app
next();
};
export default authenticate;

View File

@@ -0,0 +1,281 @@
import { Request, Response, NextFunction, ErrorRequestHandler } from 'express';
import { ZodError } from 'zod';
import { AppError, ApiResponse, ApiError } from '../types/index.js';
import { logger } from '../utils/logger.js';
import { config } from '../config/index.js';
// ============================================================================
// Error Response Helpers
// ============================================================================
/**
* Format Zod validation errors into a readable format
*/
const formatZodError = (error: ZodError): Record<string, string[]> => {
const errors: Record<string, string[]> = {};
error.errors.forEach((err) => {
const path = err.path.join('.');
if (!errors[path]) {
errors[path] = [];
}
errors[path]?.push(err.message);
});
return errors;
};
/**
* Create a standardized error response
*/
const createErrorResponse = (
code: string,
message: string,
statusCode: number,
details?: Record<string, unknown>,
stack?: string
): { statusCode: number; body: ApiResponse } => {
const error: ApiError = {
code,
message,
};
if (details) {
error.details = details;
}
// Include stack trace in development
if (stack && config.isDevelopment) {
error.stack = stack;
}
return {
statusCode,
body: {
success: false,
error,
meta: {
timestamp: new Date().toISOString(),
},
},
};
};
// ============================================================================
// Error Handler Middleware
// ============================================================================
/**
* Global error handler middleware
* Catches all errors and returns a standardized response
*/
export const errorHandler: ErrorRequestHandler = (
err: Error,
req: Request,
res: Response,
_next: NextFunction
): void => {
// Log the error
const logContext = {
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
userId: req.user?.sub,
tenantId: req.user?.tenant_id,
requestId: req.headers['x-request-id'],
};
// Handle known error types
if (err instanceof AppError) {
// Operational errors are expected and logged as warnings
if (err.isOperational) {
logger.warn('Operational error', logContext);
} else {
logger.error('Non-operational error', logContext);
}
const { statusCode, body } = createErrorResponse(
err.code,
err.message,
err.statusCode,
err.details,
err.stack
);
res.status(statusCode).json(body);
return;
}
// Handle Zod validation errors
if (err instanceof ZodError) {
logger.warn('Validation error', { ...logContext, validationErrors: err.errors });
const { statusCode, body } = createErrorResponse(
'VALIDATION_ERROR',
'Error de validacion',
400,
{ fields: formatZodError(err) }
);
res.status(statusCode).json(body);
return;
}
// Handle JWT errors (in case they're not caught earlier)
if (err.name === 'JsonWebTokenError') {
logger.warn('JWT error', logContext);
const { statusCode, body } = createErrorResponse(
'INVALID_TOKEN',
'Token invalido',
401
);
res.status(statusCode).json(body);
return;
}
if (err.name === 'TokenExpiredError') {
logger.warn('Token expired', logContext);
const { statusCode, body } = createErrorResponse(
'TOKEN_EXPIRED',
'Token expirado',
401
);
res.status(statusCode).json(body);
return;
}
// Handle syntax errors (malformed JSON)
if (err instanceof SyntaxError && 'body' in err) {
logger.warn('Syntax error in request body', logContext);
const { statusCode, body } = createErrorResponse(
'INVALID_JSON',
'JSON invalido en el cuerpo de la solicitud',
400
);
res.status(statusCode).json(body);
return;
}
// Handle PostgreSQL errors
if ('code' in err && typeof (err as { code: unknown }).code === 'string') {
const pgError = err as { code: string; constraint?: string; detail?: string };
// Unique constraint violation
if (pgError.code === '23505') {
logger.warn('Database unique constraint violation', { ...logContext, constraint: pgError.constraint });
const { statusCode, body } = createErrorResponse(
'DUPLICATE_ENTRY',
'El registro ya existe',
409,
{ constraint: pgError.constraint }
);
res.status(statusCode).json(body);
return;
}
// Foreign key violation
if (pgError.code === '23503') {
logger.warn('Database foreign key violation', { ...logContext, constraint: pgError.constraint });
const { statusCode, body } = createErrorResponse(
'REFERENCE_ERROR',
'Referencia invalida',
400,
{ constraint: pgError.constraint }
);
res.status(statusCode).json(body);
return;
}
// Not null violation
if (pgError.code === '23502') {
logger.warn('Database not null violation', logContext);
const { statusCode, body } = createErrorResponse(
'MISSING_REQUIRED_FIELD',
'Campo requerido faltante',
400
);
res.status(statusCode).json(body);
return;
}
// Connection errors
if (pgError.code === 'ECONNREFUSED' || pgError.code === '57P01') {
logger.error('Database connection error', logContext);
const { statusCode, body } = createErrorResponse(
'DATABASE_ERROR',
'Error de conexion a la base de datos',
503
);
res.status(statusCode).json(body);
return;
}
}
// Unknown errors - log as error and return generic message
logger.error('Unhandled error', logContext);
const { statusCode, body } = createErrorResponse(
'INTERNAL_ERROR',
config.isProduction ? 'Error interno del servidor' : err.message,
500,
undefined,
err.stack
);
res.status(statusCode).json(body);
};
// ============================================================================
// Not Found Handler
// ============================================================================
/**
* Handle 404 errors for undefined routes
*/
export const notFoundHandler = (req: Request, res: Response): void => {
logger.warn('Route not found', {
path: req.path,
method: req.method,
ip: req.ip,
});
const { body } = createErrorResponse(
'NOT_FOUND',
`Ruta no encontrada: ${req.method} ${req.path}`,
404
);
res.status(404).json(body);
};
// ============================================================================
// Async Error Wrapper
// ============================================================================
/**
* Wrap async route handlers to catch errors
*/
export const catchAsync = (
fn: (req: Request, res: Response, next: NextFunction) => Promise<void>
) => {
return (req: Request, res: Response, next: NextFunction): void => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
export default errorHandler;

View File

@@ -0,0 +1,16 @@
// Middleware exports
// Authentication
export { authenticate, optionalAuth, authorize, requireOwner, requireAdmin, requireMember, requireTenantAccess } from './auth.js';
// Legacy auth middleware (for backward compatibility)
export { authenticate as authenticateMiddleware, requireRole, requireAdmin as requireAdminLegacy, requireOwner as requireOwnerLegacy, optionalAuth as optionalAuthMiddleware } from './auth.middleware.js';
// Tenant context
export { tenantContext, getTenantClient, queryWithTenant, transactionWithTenant, validateTenantParam, getTenantPool } from './tenant.js';
// Error handling
export { errorHandler, notFoundHandler, catchAsync } from './errorHandler.js';
// Validation
export { validate, validateBody, validateQuery, validateParams, type ValidateOptions } from './validate.middleware.js';

View File

@@ -0,0 +1,203 @@
import { Request, Response, NextFunction } from 'express';
import { Pool } from 'pg';
import { config } from '../config/index.js';
import { AppError, AuthenticationError, NotFoundError } from '../types/index.js';
import { logger } from '../utils/logger.js';
// ============================================================================
// Database Pool for Tenant Operations
// ============================================================================
const pool = new Pool({
connectionString: config.database.url,
min: config.database.pool.min,
max: config.database.pool.max,
});
// ============================================================================
// Extend Express Request Type
// ============================================================================
declare global {
namespace Express {
interface Request {
tenantSchema?: string;
tenantId?: string;
setTenantSchema?: (schema: string) => Promise<void>;
}
}
}
// ============================================================================
// Tenant Context Middleware
// ============================================================================
/**
* Middleware to set the tenant schema for multi-tenant database operations
* This should be used AFTER the auth middleware
*/
export const tenantContext = async (
req: Request,
_res: Response,
next: NextFunction
): Promise<void> => {
try {
// User should be set by auth middleware
if (!req.user) {
throw new AuthenticationError('Contexto de usuario no disponible');
}
const schemaName = req.user.schema_name;
const tenantId = req.user.tenant_id;
if (!schemaName || !tenantId) {
throw new AppError('Informacion de tenant no disponible', 'TENANT_INFO_MISSING', 500);
}
// Verify tenant exists and is active
const tenantResult = await pool.query(
'SELECT id, schema_name, is_active FROM public.tenants WHERE id = $1',
[tenantId]
);
const tenant = tenantResult.rows[0];
if (!tenant) {
throw new NotFoundError('Tenant');
}
if (!tenant.is_active) {
throw new AppError('Cuenta desactivada', 'TENANT_INACTIVE', 403);
}
// Verify schema exists
const schemaExists = await pool.query(
"SELECT schema_name FROM information_schema.schemata WHERE schema_name = $1",
[schemaName]
);
if (schemaExists.rows.length === 0) {
logger.error('Tenant schema not found', { tenantId, schemaName });
throw new AppError('Error de configuracion del tenant', 'SCHEMA_NOT_FOUND', 500);
}
// Set tenant info on request
req.tenantSchema = schemaName;
req.tenantId = tenantId;
// Helper function to execute queries in tenant context
req.setTenantSchema = async (schema: string) => {
await pool.query(`SET search_path TO "${schema}", public`);
};
logger.debug('Tenant context set', { tenantId, schemaName });
next();
} catch (error) {
next(error);
}
};
/**
* Get a database client with tenant schema set
*/
export const getTenantClient = async (schemaName: string) => {
const client = await pool.connect();
try {
// Set search path to tenant schema
await client.query(`SET search_path TO "${schemaName}", public`);
return client;
} catch (error) {
client.release();
throw error;
}
};
/**
* Execute a query within tenant context
*/
export const queryWithTenant = async <T>(
schemaName: string,
queryText: string,
values?: unknown[]
): Promise<T[]> => {
const client = await getTenantClient(schemaName);
try {
const result = await client.query(queryText, values);
return result.rows as T[];
} finally {
client.release();
}
};
/**
* Execute a transaction within tenant context
*/
export const transactionWithTenant = async <T>(
schemaName: string,
callback: (client: ReturnType<typeof pool.connect> extends Promise<infer C> ? C : never) => Promise<T>
): Promise<T> => {
const client = await getTenantClient(schemaName);
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
/**
* Middleware to validate tenant from URL parameter
* Useful for admin routes that need to access specific tenants
*/
export const validateTenantParam = async (
req: Request,
_res: Response,
next: NextFunction
): Promise<void> => {
try {
const tenantId = req.params.tenantId;
if (!tenantId) {
throw new AppError('Tenant ID es requerido', 'TENANT_ID_REQUIRED', 400);
}
const tenantResult = await pool.query(
'SELECT id, schema_name, name, is_active FROM public.tenants WHERE id = $1',
[tenantId]
);
const tenant = tenantResult.rows[0];
if (!tenant) {
throw new NotFoundError('Tenant');
}
if (!tenant.is_active) {
throw new AppError('Tenant inactivo', 'TENANT_INACTIVE', 403);
}
// Set tenant info from param
req.tenantSchema = tenant.schema_name;
req.tenantId = tenant.id;
next();
} catch (error) {
next(error);
}
};
/**
* Helper to get the current tenant pool
*/
export const getTenantPool = () => pool;
export default tenantContext;

View File

@@ -0,0 +1,70 @@
/**
* Validation Middleware
*
* Validates request body, query, and params using Zod schemas
*/
import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } from 'zod';
import { ValidationError } from '../types';
export interface ValidateOptions {
body?: ZodSchema;
query?: ZodSchema;
params?: ZodSchema;
}
/**
* Validate request against Zod schemas
*/
export const validate = (schemas: ValidateOptions) => {
return async (req: Request, _res: Response, next: NextFunction): Promise<void> => {
try {
if (schemas.body) {
req.body = schemas.body.parse(req.body);
}
if (schemas.query) {
req.query = schemas.query.parse(req.query);
}
if (schemas.params) {
req.params = schemas.params.parse(req.params);
}
next();
} catch (error) {
if (error instanceof ZodError) {
const details = error.errors.reduce(
(acc, err) => {
const path = err.path.join('.');
acc[path] = err.message;
return acc;
},
{} as Record<string, string>
);
next(new ValidationError('Datos de entrada invalidos', details));
} else {
next(error);
}
}
};
};
/**
* Validate only request body
*/
export const validateBody = (schema: ZodSchema) => validate({ body: schema });
/**
* Validate only query parameters
*/
export const validateQuery = (schema: ZodSchema) => validate({ query: schema });
/**
* Validate only path parameters
*/
export const validateParams = (schema: ZodSchema) => validate({ params: schema });
export default validate;

View File

@@ -0,0 +1,453 @@
/**
* Alerts Routes
*
* Handles system alerts and notifications
*/
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { authenticate } from '../middleware/auth.middleware';
import { validate } from '../middleware/validate.middleware';
import { getDatabase, TenantContext } from '@horux/database';
import {
ApiResponse,
AppError,
NotFoundError,
} from '../types';
const router = Router();
// ============================================================================
// Validation Schemas
// ============================================================================
const AlertTypeEnum = z.enum([
'info',
'warning',
'error',
'success',
'payment_due',
'sync_error',
'budget_exceeded',
'low_balance',
'anomaly_detected',
'tax_reminder',
'cfdi_cancelled',
'recurring_expected',
]);
const AlertPriorityEnum = z.enum(['low', 'medium', 'high', 'critical']);
const AlertStatusEnum = z.enum(['unread', 'read', 'dismissed', 'actioned']);
const AlertFiltersSchema = z.object({
page: z.string().optional().transform((v) => (v ? parseInt(v, 10) : 1)),
limit: z.string().optional().transform((v) => (v ? Math.min(parseInt(v, 10), 100) : 20)),
type: AlertTypeEnum.optional(),
priority: AlertPriorityEnum.optional(),
status: AlertStatusEnum.optional(),
unreadOnly: z.string().optional().transform((v) => v === 'true'),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
});
const AlertIdSchema = z.object({
id: z.string().uuid('ID de alerta invalido'),
});
// ============================================================================
// Helper Functions
// ============================================================================
const getTenantContext = (req: Request): TenantContext => {
if (!req.user || !req.tenantSchema) {
throw new AppError('Contexto de tenant no disponible', 'TENANT_CONTEXT_ERROR', 500);
}
return {
tenantId: req.user.tenant_id,
schemaName: req.tenantSchema,
userId: req.user.sub,
};
};
// ============================================================================
// Routes
// ============================================================================
/**
* GET /api/alerts
* List active alerts with filters
*/
router.get(
'/',
authenticate,
validate({ query: AlertFiltersSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const filters = req.query as z.infer<typeof AlertFiltersSchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Build filter conditions
const conditions: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
// Filter by user or global alerts
conditions.push(`(a.user_id = $${paramIndex} OR a.user_id IS NULL)`);
params.push(req.user!.sub);
paramIndex++;
if (filters.unreadOnly) {
conditions.push(`a.status = 'unread'`);
} else if (filters.status) {
conditions.push(`a.status = $${paramIndex++}`);
params.push(filters.status);
} else {
conditions.push(`a.status != 'dismissed'`);
}
if (filters.type) {
conditions.push(`a.type = $${paramIndex++}`);
params.push(filters.type);
}
if (filters.priority) {
conditions.push(`a.priority = $${paramIndex++}`);
params.push(filters.priority);
}
if (filters.startDate) {
conditions.push(`a.created_at >= $${paramIndex++}`);
params.push(filters.startDate);
}
if (filters.endDate) {
conditions.push(`a.created_at <= $${paramIndex++}`);
params.push(filters.endDate);
}
// Exclude expired alerts
conditions.push(`(a.expires_at IS NULL OR a.expires_at > NOW())`);
const whereClause = `WHERE ${conditions.join(' AND ')}`;
const offset = (filters.page - 1) * filters.limit;
// Get total count
const countQuery = `SELECT COUNT(*) as total FROM alerts a ${whereClause}`;
const countResult = await db.queryTenant<{ total: string }>(tenant, countQuery, params);
const total = parseInt(countResult.rows[0]?.total || '0', 10);
// Get unread count
const unreadQuery = `
SELECT COUNT(*) as unread
FROM alerts a
WHERE (a.user_id = $1 OR a.user_id IS NULL)
AND a.status = 'unread'
AND (a.expires_at IS NULL OR a.expires_at > NOW())
`;
const unreadResult = await db.queryTenant<{ unread: string }>(tenant, unreadQuery, [req.user!.sub]);
const unreadCount = parseInt(unreadResult.rows[0]?.unread || '0', 10);
// Get alerts
const dataQuery = `
SELECT
a.id,
a.type,
a.priority,
a.title,
a.message,
a.status,
a.action_url,
a.action_label,
a.metadata,
a.created_at,
a.read_at,
a.expires_at
FROM alerts a
${whereClause}
ORDER BY
CASE a.priority
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
ELSE 4
END,
a.created_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const dataResult = await db.queryTenant(tenant, dataQuery, [...params, filters.limit, offset]);
const response: ApiResponse = {
success: true,
data: dataResult.rows,
meta: {
page: filters.page,
limit: filters.limit,
total,
unreadCount,
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* GET /api/alerts/summary
* Get alert summary counts by type and priority
*/
router.get(
'/summary',
authenticate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const tenant = getTenantContext(req);
const db = getDatabase();
const query = `
SELECT
COUNT(*) FILTER (WHERE status = 'unread') as total_unread,
COUNT(*) FILTER (WHERE priority = 'critical' AND status = 'unread') as critical_unread,
COUNT(*) FILTER (WHERE priority = 'high' AND status = 'unread') as high_unread,
COUNT(*) FILTER (WHERE priority = 'medium' AND status = 'unread') as medium_unread,
COUNT(*) FILTER (WHERE priority = 'low' AND status = 'unread') as low_unread,
json_object_agg(
type,
type_count
) FILTER (WHERE type IS NOT NULL) as by_type
FROM (
SELECT
priority,
status,
type,
COUNT(*) OVER (PARTITION BY type) as type_count
FROM alerts
WHERE (user_id = $1 OR user_id IS NULL)
AND status != 'dismissed'
AND (expires_at IS NULL OR expires_at > NOW())
) sub
`;
const result = await db.queryTenant(tenant, query, [req.user!.sub]);
const summary = result.rows[0] || {
total_unread: 0,
critical_unread: 0,
high_unread: 0,
medium_unread: 0,
low_unread: 0,
by_type: {},
};
const response: ApiResponse = {
success: true,
data: {
unread: {
total: parseInt(summary.total_unread || '0', 10),
critical: parseInt(summary.critical_unread || '0', 10),
high: parseInt(summary.high_unread || '0', 10),
medium: parseInt(summary.medium_unread || '0', 10),
low: parseInt(summary.low_unread || '0', 10),
},
byType: summary.by_type || {},
},
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* GET /api/alerts/:id
* Get a single alert
*/
router.get(
'/:id',
authenticate,
validate({ params: AlertIdSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
const query = `
SELECT *
FROM alerts
WHERE id = $1 AND (user_id = $2 OR user_id IS NULL)
`;
const result = await db.queryTenant(tenant, query, [id, req.user!.sub]);
if (result.rows.length === 0) {
throw new NotFoundError('Alerta');
}
const response: ApiResponse = {
success: true,
data: result.rows[0],
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* PUT /api/alerts/:id/read
* Mark alert as read
*/
router.put(
'/:id/read',
authenticate,
validate({ params: AlertIdSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
// Check if alert exists and belongs to user
const existingCheck = await db.queryTenant(
tenant,
'SELECT id, status FROM alerts WHERE id = $1 AND (user_id = $2 OR user_id IS NULL)',
[id, req.user!.sub]
);
if (existingCheck.rows.length === 0) {
throw new NotFoundError('Alerta');
}
// Update to read
const updateQuery = `
UPDATE alerts
SET status = 'read', read_at = NOW(), updated_at = NOW()
WHERE id = $1
RETURNING *
`;
const result = await db.queryTenant(tenant, updateQuery, [id]);
const response: ApiResponse = {
success: true,
data: result.rows[0],
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* PUT /api/alerts/:id/dismiss
* Dismiss an alert
*/
router.put(
'/:id/dismiss',
authenticate,
validate({ params: AlertIdSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
// Check if alert exists and belongs to user
const existingCheck = await db.queryTenant(
tenant,
'SELECT id, status FROM alerts WHERE id = $1 AND (user_id = $2 OR user_id IS NULL)',
[id, req.user!.sub]
);
if (existingCheck.rows.length === 0) {
throw new NotFoundError('Alerta');
}
// Update to dismissed
const updateQuery = `
UPDATE alerts
SET status = 'dismissed', dismissed_at = NOW(), dismissed_by = $2, updated_at = NOW()
WHERE id = $1
RETURNING *
`;
const result = await db.queryTenant(tenant, updateQuery, [id, req.user!.sub]);
const response: ApiResponse = {
success: true,
data: result.rows[0],
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* PUT /api/alerts/read-all
* Mark all alerts as read
*/
router.put(
'/read-all',
authenticate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const tenant = getTenantContext(req);
const db = getDatabase();
const updateQuery = `
UPDATE alerts
SET status = 'read', read_at = NOW(), updated_at = NOW()
WHERE (user_id = $1 OR user_id IS NULL)
AND status = 'unread'
AND (expires_at IS NULL OR expires_at > NOW())
RETURNING id
`;
const result = await db.queryTenant(tenant, updateQuery, [req.user!.sub]);
const response: ApiResponse = {
success: true,
data: {
updatedCount: result.rowCount || 0,
updatedIds: result.rows.map(r => r.id),
},
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
export default router;

View File

@@ -0,0 +1,324 @@
import { Router, Request, Response } from 'express';
import { authService } from '../services/auth.service.js';
import { authenticate, requireMember } from '../middleware/auth.js';
import { tenantContext } from '../middleware/tenant.js';
import { asyncHandler } from '../utils/asyncHandler.js';
import { logger, auditLog } from '../utils/logger.js';
import {
RegisterSchema,
LoginSchema,
RefreshTokenSchema,
ResetPasswordRequestSchema,
ResetPasswordSchema,
ChangePasswordSchema,
ValidationError,
ApiResponse,
} from '../types/index.js';
// ============================================================================
// Router Setup
// ============================================================================
const router = Router();
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Extract client info from request
*/
const getClientInfo = (req: Request) => ({
userAgent: req.headers['user-agent'],
ipAddress: req.ip || req.socket.remoteAddress,
});
/**
* Create success response
*/
const successResponse = <T>(data: T, meta?: Record<string, unknown>): ApiResponse<T> => ({
success: true,
data,
meta: {
timestamp: new Date().toISOString(),
...meta,
},
});
// ============================================================================
// Public Routes
// ============================================================================
/**
* POST /api/v1/auth/register
* Register a new user and create their organization
*/
router.post(
'/register',
asyncHandler(async (req: Request, res: Response) => {
// Validate input
const parseResult = RegisterSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de registro invalidos', {
errors: parseResult.error.errors,
});
}
const { user, tokens } = await authService.register(parseResult.data);
logger.info('User registered via API', { userId: user.id, email: user.email });
res.status(201).json(
successResponse({
user,
tokens: {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresIn: tokens.expiresIn,
tokenType: 'Bearer',
},
})
);
})
);
/**
* POST /api/v1/auth/login
* Authenticate user and return tokens
*/
router.post(
'/login',
asyncHandler(async (req: Request, res: Response) => {
// Validate input
const parseResult = LoginSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Credenciales invalidas', {
errors: parseResult.error.errors,
});
}
const { userAgent, ipAddress } = getClientInfo(req);
const { user, tenant, tokens } = await authService.login(
parseResult.data,
userAgent,
ipAddress
);
res.json(
successResponse({
user,
tenant,
tokens: {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresIn: tokens.expiresIn,
tokenType: 'Bearer',
},
})
);
})
);
/**
* POST /api/v1/auth/refresh
* Refresh access token using refresh token
*/
router.post(
'/refresh',
asyncHandler(async (req: Request, res: Response) => {
// Validate input
const parseResult = RefreshTokenSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Refresh token invalido', {
errors: parseResult.error.errors,
});
}
const tokens = await authService.refreshToken(parseResult.data.refreshToken);
res.json(
successResponse({
tokens: {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresIn: tokens.expiresIn,
tokenType: 'Bearer',
},
})
);
})
);
/**
* POST /api/v1/auth/logout
* Invalidate the current session
*/
router.post(
'/logout',
asyncHandler(async (req: Request, res: Response) => {
const refreshToken = req.body?.refreshToken;
if (refreshToken) {
await authService.logout(refreshToken);
}
res.json(successResponse({ message: 'Sesion cerrada exitosamente' }));
})
);
/**
* POST /api/v1/auth/forgot-password
* Request password reset email
*/
router.post(
'/forgot-password',
asyncHandler(async (req: Request, res: Response) => {
// Validate input
const parseResult = ResetPasswordRequestSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Email invalido', {
errors: parseResult.error.errors,
});
}
await authService.requestPasswordReset(parseResult.data.email);
// Always return success to prevent email enumeration
res.json(
successResponse({
message: 'Si el email existe, recibiras instrucciones para restablecer tu contrasena',
})
);
})
);
/**
* POST /api/v1/auth/reset-password
* Reset password using token
*/
router.post(
'/reset-password',
asyncHandler(async (req: Request, res: Response) => {
// Validate input
const parseResult = ResetPasswordSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos invalidos', {
errors: parseResult.error.errors,
});
}
await authService.resetPassword(parseResult.data.token, parseResult.data.password);
res.json(
successResponse({
message: 'Contrasena restablecida exitosamente. Por favor inicia sesion con tu nueva contrasena.',
})
);
})
);
// ============================================================================
// Protected Routes (require authentication)
// ============================================================================
/**
* GET /api/v1/auth/me
* Get current user profile
*/
router.get(
'/me',
authenticate,
tenantContext,
asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.sub;
const profile = await authService.getProfile(userId);
res.json(successResponse(profile));
})
);
/**
* POST /api/v1/auth/change-password
* Change password for authenticated user
*/
router.post(
'/change-password',
authenticate,
tenantContext,
asyncHandler(async (req: Request, res: Response) => {
// Validate input
const parseResult = ChangePasswordSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos invalidos', {
errors: parseResult.error.errors,
});
}
const userId = req.user!.sub;
await authService.changePassword(
userId,
parseResult.data.currentPassword,
parseResult.data.newPassword
);
res.json(
successResponse({
message: 'Contrasena actualizada exitosamente',
})
);
})
);
/**
* POST /api/v1/auth/logout-all
* Logout from all sessions
*/
router.post(
'/logout-all',
authenticate,
tenantContext,
asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.sub;
const tenantId = req.user!.tenant_id;
const sessionsDeleted = await authService.logoutAll(userId, tenantId);
res.json(
successResponse({
message: 'Todas las sesiones han sido cerradas',
sessionsDeleted,
})
);
})
);
/**
* GET /api/v1/auth/verify
* Verify if current token is valid
*/
router.get(
'/verify',
authenticate,
asyncHandler(async (req: Request, res: Response) => {
res.json(
successResponse({
valid: true,
user: {
id: req.user!.sub,
email: req.user!.email,
role: req.user!.role,
tenantId: req.user!.tenant_id,
},
})
);
})
);
export default router;

View File

@@ -0,0 +1,596 @@
/**
* Categories Routes
*
* CRUD operations for transaction categories
*/
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { authenticate } from '../middleware/auth.middleware';
import { validate } from '../middleware/validate.middleware';
import { getDatabase, TenantContext } from '@horux/database';
import {
ApiResponse,
AppError,
NotFoundError,
ValidationError,
ConflictError,
} from '../types';
const router = Router();
// ============================================================================
// Validation Schemas
// ============================================================================
const CategoryTypeEnum = z.enum(['income', 'expense', 'both']);
const CategoryFiltersSchema = z.object({
type: CategoryTypeEnum.optional(),
search: z.string().optional(),
parentId: z.string().uuid().optional().nullable(),
includeInactive: z.string().optional().transform((v) => v === 'true'),
});
const CategoryIdSchema = z.object({
id: z.string().uuid('ID de categoria invalido'),
});
const CreateCategorySchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
type: CategoryTypeEnum,
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Color debe ser un codigo hexadecimal valido').optional(),
icon: z.string().max(50).optional(),
parentId: z.string().uuid().optional().nullable(),
budget: z.number().positive().optional().nullable(),
isSystem: z.boolean().optional().default(false),
sortOrder: z.number().int().optional(),
metadata: z.record(z.unknown()).optional(),
});
const UpdateCategorySchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional().nullable(),
type: CategoryTypeEnum.optional(),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Color debe ser un codigo hexadecimal valido').optional(),
icon: z.string().max(50).optional().nullable(),
parentId: z.string().uuid().optional().nullable(),
budget: z.number().positive().optional().nullable(),
isActive: z.boolean().optional(),
sortOrder: z.number().int().optional(),
metadata: z.record(z.unknown()).optional(),
});
// ============================================================================
// Helper Functions
// ============================================================================
const getTenantContext = (req: Request): TenantContext => {
if (!req.user || !req.tenantSchema) {
throw new AppError('Contexto de tenant no disponible', 'TENANT_CONTEXT_ERROR', 500);
}
return {
tenantId: req.user.tenant_id,
schemaName: req.tenantSchema,
userId: req.user.sub,
};
};
// ============================================================================
// Routes
// ============================================================================
/**
* GET /api/categories
* List all categories with optional filters
*/
router.get(
'/',
authenticate,
validate({ query: CategoryFiltersSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const filters = req.query as z.infer<typeof CategoryFiltersSchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Build filter conditions
const conditions: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (!filters.includeInactive) {
conditions.push('c.is_active = true');
}
if (filters.type) {
conditions.push(`(c.type = $${paramIndex} OR c.type = 'both')`);
params.push(filters.type);
paramIndex++;
}
if (filters.search) {
conditions.push(`(c.name ILIKE $${paramIndex} OR c.description ILIKE $${paramIndex})`);
params.push(`%${filters.search}%`);
paramIndex++;
}
if (filters.parentId !== undefined) {
if (filters.parentId === null) {
conditions.push('c.parent_id IS NULL');
} else {
conditions.push(`c.parent_id = $${paramIndex++}`);
params.push(filters.parentId);
}
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Get categories with transaction counts and subcategory info
const query = `
WITH RECURSIVE category_tree AS (
SELECT
c.id,
c.name,
c.description,
c.type,
c.color,
c.icon,
c.parent_id,
c.budget,
c.is_system,
c.is_active,
c.sort_order,
c.created_at,
c.updated_at,
0 as level,
ARRAY[c.id] as path
FROM categories c
WHERE c.parent_id IS NULL
${conditions.length > 0 ? 'AND ' + conditions.filter(c => !c.includes('parent_id')).join(' AND ') : ''}
UNION ALL
SELECT
c.id,
c.name,
c.description,
c.type,
c.color,
c.icon,
c.parent_id,
c.budget,
c.is_system,
c.is_active,
c.sort_order,
c.created_at,
c.updated_at,
ct.level + 1,
ct.path || c.id
FROM categories c
JOIN category_tree ct ON c.parent_id = ct.id
WHERE c.is_active = true OR $${paramIndex} = true
)
SELECT
ct.*,
COALESCE(stats.transaction_count, 0) as transaction_count,
COALESCE(stats.total_amount, 0) as total_amount,
COALESCE(stats.last_30_days_amount, 0) as last_30_days_amount,
(
SELECT json_agg(
json_build_object('id', sub.id, 'name', sub.name, 'color', sub.color, 'icon', sub.icon)
)
FROM categories sub
WHERE sub.parent_id = ct.id AND sub.is_active = true
) as subcategories
FROM category_tree ct
LEFT JOIN LATERAL (
SELECT
COUNT(*) as transaction_count,
SUM(amount) as total_amount,
SUM(CASE WHEN date >= NOW() - INTERVAL '30 days' THEN amount ELSE 0 END) as last_30_days_amount
FROM transactions
WHERE category_id = ct.id
) stats ON true
ORDER BY ct.sort_order, ct.name
`;
params.push(filters.includeInactive || false);
const result = await db.queryTenant(tenant, query, params);
// Build hierarchical structure
const categoriesMap = new Map<string, unknown>();
const rootCategories: unknown[] = [];
for (const row of result.rows) {
categoriesMap.set(row.id, { ...row, children: [] });
}
for (const row of result.rows) {
const category = categoriesMap.get(row.id);
if (row.parent_id && categoriesMap.has(row.parent_id)) {
const parent = categoriesMap.get(row.parent_id) as { children: unknown[] };
parent.children.push(category);
} else if (!row.parent_id) {
rootCategories.push(category);
}
}
const response: ApiResponse = {
success: true,
data: {
categories: result.rows,
tree: rootCategories,
},
meta: {
total: result.rows.length,
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* GET /api/categories/:id
* Get a single category by ID
*/
router.get(
'/:id',
authenticate,
validate({ params: CategoryIdSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
const query = `
SELECT
c.*,
json_build_object('id', p.id, 'name', p.name, 'color', p.color) as parent,
(
SELECT json_agg(
json_build_object('id', sub.id, 'name', sub.name, 'color', sub.color, 'icon', sub.icon)
)
FROM categories sub
WHERE sub.parent_id = c.id AND sub.is_active = true
) as subcategories,
(
SELECT json_build_object(
'transaction_count', COUNT(*),
'total_amount', COALESCE(SUM(amount), 0),
'income_amount', COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0),
'expense_amount', COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0),
'last_30_days', COALESCE(SUM(CASE WHEN date >= NOW() - INTERVAL '30 days' THEN amount ELSE 0 END), 0),
'first_transaction', MIN(date),
'last_transaction', MAX(date)
)
FROM transactions
WHERE category_id = c.id
) as statistics
FROM categories c
LEFT JOIN categories p ON c.parent_id = p.id
WHERE c.id = $1
`;
const result = await db.queryTenant(tenant, query, [id]);
if (result.rows.length === 0) {
throw new NotFoundError('Categoria');
}
const response: ApiResponse = {
success: true,
data: result.rows[0],
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* POST /api/categories
* Create a new category
*/
router.post(
'/',
authenticate,
validate({ body: CreateCategorySchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = req.body as z.infer<typeof CreateCategorySchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Check for duplicate name at same level
const duplicateQuery = data.parentId
? 'SELECT id FROM categories WHERE name = $1 AND parent_id = $2 AND is_active = true'
: 'SELECT id FROM categories WHERE name = $1 AND parent_id IS NULL AND is_active = true';
const duplicateParams = data.parentId ? [data.name, data.parentId] : [data.name];
const duplicateCheck = await db.queryTenant(tenant, duplicateQuery, duplicateParams);
if (duplicateCheck.rows.length > 0) {
throw new ConflictError('Ya existe una categoria con este nombre');
}
// Verify parent exists if provided
if (data.parentId) {
const parentCheck = await db.queryTenant(
tenant,
'SELECT id FROM categories WHERE id = $1 AND is_active = true',
[data.parentId]
);
if (parentCheck.rows.length === 0) {
throw new ValidationError('Categoria padre no encontrada', { parentId: 'No existe' });
}
}
// Get next sort order if not provided
let sortOrder = data.sortOrder;
if (sortOrder === undefined) {
const sortQuery = data.parentId
? 'SELECT COALESCE(MAX(sort_order), 0) + 1 as next_order FROM categories WHERE parent_id = $1'
: 'SELECT COALESCE(MAX(sort_order), 0) + 1 as next_order FROM categories WHERE parent_id IS NULL';
const sortParams = data.parentId ? [data.parentId] : [];
const sortResult = await db.queryTenant<{ next_order: number }>(tenant, sortQuery, sortParams);
sortOrder = sortResult.rows[0]?.next_order || 1;
}
const insertQuery = `
INSERT INTO categories (
name, description, type, color, icon, parent_id,
budget, is_system, sort_order, metadata, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *
`;
const result = await db.queryTenant(tenant, insertQuery, [
data.name,
data.description || null,
data.type,
data.color || '#6B7280',
data.icon || null,
data.parentId || null,
data.budget || null,
data.isSystem || false,
sortOrder,
data.metadata || {},
req.user!.sub,
]);
const response: ApiResponse = {
success: true,
data: result.rows[0],
meta: {
timestamp: new Date().toISOString(),
},
};
res.status(201).json(response);
} catch (error) {
next(error);
}
}
);
/**
* PUT /api/categories/:id
* Update a category
*/
router.put(
'/:id',
authenticate,
validate({ params: CategoryIdSchema, body: UpdateCategorySchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const data = req.body as z.infer<typeof UpdateCategorySchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Check if category exists
const existingCheck = await db.queryTenant(
tenant,
'SELECT id, is_system, name, parent_id FROM categories WHERE id = $1',
[id]
);
if (existingCheck.rows.length === 0) {
throw new NotFoundError('Categoria');
}
const existing = existingCheck.rows[0];
// Prevent modification of system categories (except budget)
if (existing.is_system) {
const allowedFields = ['budget', 'sortOrder'];
const attemptedFields = Object.keys(data);
const disallowedFields = attemptedFields.filter(f => !allowedFields.includes(f));
if (disallowedFields.length > 0) {
throw new ValidationError('No se pueden modificar categorias del sistema', {
fields: disallowedFields,
});
}
}
// Check for duplicate name if changing name
if (data.name && data.name !== existing.name) {
const parentId = data.parentId !== undefined ? data.parentId : existing.parent_id;
const duplicateQuery = parentId
? 'SELECT id FROM categories WHERE name = $1 AND parent_id = $2 AND id != $3 AND is_active = true'
: 'SELECT id FROM categories WHERE name = $1 AND parent_id IS NULL AND id != $2 AND is_active = true';
const duplicateParams = parentId ? [data.name, parentId, id] : [data.name, id];
const duplicateCheck = await db.queryTenant(tenant, duplicateQuery, duplicateParams);
if (duplicateCheck.rows.length > 0) {
throw new ConflictError('Ya existe una categoria con este nombre');
}
}
// Prevent circular parent reference
if (data.parentId === id) {
throw new ValidationError('Una categoria no puede ser su propio padre');
}
// Verify parent exists if changing
if (data.parentId) {
const parentCheck = await db.queryTenant(
tenant,
'SELECT id FROM categories WHERE id = $1 AND is_active = true',
[data.parentId]
);
if (parentCheck.rows.length === 0) {
throw new ValidationError('Categoria padre no encontrada', { parentId: 'No existe' });
}
}
// Build update query dynamically
const updates: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
const fieldMappings: Record<string, string> = {
name: 'name',
description: 'description',
type: 'type',
color: 'color',
icon: 'icon',
parentId: 'parent_id',
budget: 'budget',
isActive: 'is_active',
sortOrder: 'sort_order',
metadata: 'metadata',
};
for (const [key, column] of Object.entries(fieldMappings)) {
if (data[key as keyof typeof data] !== undefined) {
updates.push(`${column} = $${paramIndex++}`);
params.push(data[key as keyof typeof data]);
}
}
if (updates.length === 0) {
throw new ValidationError('No hay campos para actualizar');
}
updates.push(`updated_at = NOW()`);
params.push(id);
const updateQuery = `
UPDATE categories
SET ${updates.join(', ')}
WHERE id = $${paramIndex}
RETURNING *
`;
const result = await db.queryTenant(tenant, updateQuery, params);
const response: ApiResponse = {
success: true,
data: result.rows[0],
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* DELETE /api/categories/:id
* Soft delete a category
*/
router.delete(
'/:id',
authenticate,
validate({ params: CategoryIdSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
// Check if category exists
const existingCheck = await db.queryTenant(
tenant,
'SELECT id, is_system FROM categories WHERE id = $1 AND is_active = true',
[id]
);
if (existingCheck.rows.length === 0) {
throw new NotFoundError('Categoria');
}
// Prevent deletion of system categories
if (existingCheck.rows[0].is_system) {
throw new ValidationError('No se pueden eliminar categorias del sistema');
}
// Check for subcategories
const subcategoryCheck = await db.queryTenant(
tenant,
'SELECT id FROM categories WHERE parent_id = $1 AND is_active = true LIMIT 1',
[id]
);
if (subcategoryCheck.rows.length > 0) {
throw new ValidationError('No se puede eliminar una categoria con subcategorias activas');
}
// Check for linked transactions
const transactionCheck = await db.queryTenant(
tenant,
'SELECT id FROM transactions WHERE category_id = $1 LIMIT 1',
[id]
);
if (transactionCheck.rows.length > 0) {
// Soft delete - just mark as inactive
await db.queryTenant(
tenant,
'UPDATE categories SET is_active = false, updated_at = NOW() WHERE id = $1',
[id]
);
} else {
// Hard delete if no transactions
await db.queryTenant(tenant, 'DELETE FROM categories WHERE id = $1', [id]);
}
const response: ApiResponse = {
success: true,
data: { deleted: true, id },
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
export default router;

View File

@@ -0,0 +1,514 @@
/**
* CFDIs Routes
*
* Handles CFDI (Comprobante Fiscal Digital por Internet) operations
*/
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { authenticate } from '../middleware/auth.middleware';
import { validate } from '../middleware/validate.middleware';
import { getDatabase, TenantContext } from '@horux/database';
import {
ApiResponse,
AppError,
NotFoundError,
} from '../types';
const router = Router();
// ============================================================================
// Validation Schemas
// ============================================================================
const CfdiTypeEnum = z.enum(['ingreso', 'egreso', 'traslado', 'nomina', 'pago']);
const CfdiStatusEnum = z.enum(['vigente', 'cancelado', 'pendiente']);
const PaginationSchema = z.object({
page: z.string().optional().transform((v) => (v ? parseInt(v, 10) : 1)),
limit: z.string().optional().transform((v) => (v ? Math.min(parseInt(v, 10), 100) : 20)),
});
const CfdiFiltersSchema = PaginationSchema.extend({
tipo: CfdiTypeEnum.optional(),
estado: CfdiStatusEnum.optional(),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
rfcEmisor: z.string().optional(),
rfcReceptor: z.string().optional(),
minTotal: z.string().optional().transform((v) => (v ? parseFloat(v) : undefined)),
maxTotal: z.string().optional().transform((v) => (v ? parseFloat(v) : undefined)),
search: z.string().optional(),
sortBy: z.enum(['fecha', 'total', 'created_at']).optional().default('fecha'),
sortOrder: z.enum(['asc', 'desc']).optional().default('desc'),
});
const CfdiIdSchema = z.object({
id: z.string().uuid('ID de CFDI invalido'),
});
const SummaryQuerySchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
groupBy: z.enum(['day', 'week', 'month']).optional().default('month'),
});
const SyncBodySchema = z.object({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
tipoComprobante: CfdiTypeEnum.optional(),
force: z.boolean().optional().default(false),
});
// ============================================================================
// Helper Functions
// ============================================================================
const getTenantContext = (req: Request): TenantContext => {
if (!req.user || !req.tenantSchema) {
throw new AppError('Contexto de tenant no disponible', 'TENANT_CONTEXT_ERROR', 500);
}
return {
tenantId: req.user.tenant_id,
schemaName: req.tenantSchema,
userId: req.user.sub,
};
};
// ============================================================================
// Routes
// ============================================================================
/**
* GET /api/cfdis
* List CFDIs with filters and pagination
*/
router.get(
'/',
authenticate,
validate({ query: CfdiFiltersSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const filters = req.query as z.infer<typeof CfdiFiltersSchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Build filter conditions
const conditions: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (filters.tipo) {
conditions.push(`c.tipo_comprobante = $${paramIndex++}`);
params.push(filters.tipo);
}
if (filters.estado) {
conditions.push(`c.estado = $${paramIndex++}`);
params.push(filters.estado);
}
if (filters.startDate) {
conditions.push(`c.fecha >= $${paramIndex++}`);
params.push(filters.startDate);
}
if (filters.endDate) {
conditions.push(`c.fecha <= $${paramIndex++}`);
params.push(filters.endDate);
}
if (filters.rfcEmisor) {
conditions.push(`c.rfc_emisor ILIKE $${paramIndex++}`);
params.push(`%${filters.rfcEmisor}%`);
}
if (filters.rfcReceptor) {
conditions.push(`c.rfc_receptor ILIKE $${paramIndex++}`);
params.push(`%${filters.rfcReceptor}%`);
}
if (filters.minTotal !== undefined) {
conditions.push(`c.total >= $${paramIndex++}`);
params.push(filters.minTotal);
}
if (filters.maxTotal !== undefined) {
conditions.push(`c.total <= $${paramIndex++}`);
params.push(filters.maxTotal);
}
if (filters.search) {
conditions.push(`(
c.uuid ILIKE $${paramIndex} OR
c.nombre_emisor ILIKE $${paramIndex} OR
c.nombre_receptor ILIKE $${paramIndex} OR
c.folio ILIKE $${paramIndex}
)`);
params.push(`%${filters.search}%`);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const offset = (filters.page - 1) * filters.limit;
const sortColumn = filters.sortBy === 'fecha' ? 'c.fecha' : filters.sortBy === 'total' ? 'c.total' : 'c.created_at';
// Get total count
const countQuery = `SELECT COUNT(*) as total FROM cfdis c ${whereClause}`;
const countResult = await db.queryTenant<{ total: string }>(tenant, countQuery, params);
const total = parseInt(countResult.rows[0]?.total || '0', 10);
// Get CFDIs
const dataQuery = `
SELECT
c.id,
c.uuid,
c.version,
c.serie,
c.folio,
c.fecha,
c.tipo_comprobante,
c.forma_pago,
c.metodo_pago,
c.moneda,
c.tipo_cambio,
c.subtotal,
c.descuento,
c.total,
c.rfc_emisor,
c.nombre_emisor,
c.rfc_receptor,
c.nombre_receptor,
c.uso_cfdi,
c.estado,
c.fecha_cancelacion,
c.created_at,
c.updated_at
FROM cfdis c
${whereClause}
ORDER BY ${sortColumn} ${filters.sortOrder.toUpperCase()}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const dataResult = await db.queryTenant(tenant, dataQuery, [...params, filters.limit, offset]);
const response: ApiResponse = {
success: true,
data: dataResult.rows,
meta: {
page: filters.page,
limit: filters.limit,
total,
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* GET /api/cfdis/summary
* Get CFDI summary for a period
*/
router.get(
'/summary',
authenticate,
validate({ query: SummaryQuerySchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const query = req.query as z.infer<typeof SummaryQuerySchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Get summary by type
const summaryQuery = `
SELECT
tipo_comprobante,
estado,
COUNT(*) as count,
SUM(total) as total_amount,
SUM(subtotal) as subtotal_amount,
SUM(COALESCE(descuento, 0)) as discount_amount,
AVG(total) as avg_amount
FROM cfdis
WHERE fecha >= $1 AND fecha <= $2
GROUP BY tipo_comprobante, estado
ORDER BY tipo_comprobante, estado
`;
const summaryResult = await db.queryTenant(tenant, summaryQuery, [query.startDate, query.endDate]);
// Get totals by period
let dateFormat: string;
switch (query.groupBy) {
case 'day':
dateFormat = 'YYYY-MM-DD';
break;
case 'week':
dateFormat = 'IYYY-IW';
break;
case 'month':
default:
dateFormat = 'YYYY-MM';
}
const periodQuery = `
SELECT
TO_CHAR(fecha, '${dateFormat}') as period,
tipo_comprobante,
COUNT(*) as count,
SUM(total) as total_amount
FROM cfdis
WHERE fecha >= $1 AND fecha <= $2 AND estado = 'vigente'
GROUP BY period, tipo_comprobante
ORDER BY period
`;
const periodResult = await db.queryTenant(tenant, periodQuery, [query.startDate, query.endDate]);
// Get overall totals
const totalsQuery = `
SELECT
COUNT(*) as total_count,
COUNT(*) FILTER (WHERE tipo_comprobante = 'ingreso') as ingresos_count,
COUNT(*) FILTER (WHERE tipo_comprobante = 'egreso') as egresos_count,
COALESCE(SUM(total) FILTER (WHERE tipo_comprobante = 'ingreso' AND estado = 'vigente'), 0) as total_ingresos,
COALESCE(SUM(total) FILTER (WHERE tipo_comprobante = 'egreso' AND estado = 'vigente'), 0) as total_egresos,
COUNT(*) FILTER (WHERE estado = 'cancelado') as cancelados_count
FROM cfdis
WHERE fecha >= $1 AND fecha <= $2
`;
const totalsResult = await db.queryTenant(tenant, totalsQuery, [query.startDate, query.endDate]);
const response: ApiResponse = {
success: true,
data: {
totals: totalsResult.rows[0],
byType: summaryResult.rows,
byPeriod: periodResult.rows,
},
meta: {
startDate: query.startDate,
endDate: query.endDate,
groupBy: query.groupBy,
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* GET /api/cfdis/:id
* Get CFDI detail by ID
*/
router.get(
'/:id',
authenticate,
validate({ params: CfdiIdSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
const query = `
SELECT
c.*,
json_agg(
json_build_object(
'id', cc.id,
'clave_prod_serv', cc.clave_prod_serv,
'descripcion', cc.descripcion,
'cantidad', cc.cantidad,
'unidad', cc.unidad,
'valor_unitario', cc.valor_unitario,
'importe', cc.importe,
'descuento', cc.descuento
)
) FILTER (WHERE cc.id IS NOT NULL) as conceptos,
(
SELECT json_agg(
json_build_object(
'id', t.id,
'type', t.type,
'amount', t.amount,
'date', t.date,
'status', t.status
)
)
FROM transactions t
WHERE t.cfdi_id = c.id
) as transactions
FROM cfdis c
LEFT JOIN cfdi_conceptos cc ON cc.cfdi_id = c.id
WHERE c.id = $1
GROUP BY c.id
`;
const result = await db.queryTenant(tenant, query, [id]);
if (result.rows.length === 0) {
throw new NotFoundError('CFDI');
}
const response: ApiResponse = {
success: true,
data: result.rows[0],
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* GET /api/cfdis/:id/xml
* Get original XML for a CFDI
*/
router.get(
'/:id/xml',
authenticate,
validate({ params: CfdiIdSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
const query = `
SELECT uuid, xml_content, serie, folio
FROM cfdis
WHERE id = $1
`;
const result = await db.queryTenant(tenant, query, [id]);
if (result.rows.length === 0) {
throw new NotFoundError('CFDI');
}
const cfdi = result.rows[0];
if (!cfdi.xml_content) {
throw new NotFoundError('XML del CFDI');
}
const filename = `${cfdi.serie || ''}${cfdi.folio || cfdi.uuid}.xml`;
res.setHeader('Content-Type', 'application/xml; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(cfdi.xml_content);
} catch (error) {
next(error);
}
}
);
/**
* POST /api/cfdis/sync
* Trigger SAT synchronization
*/
router.post(
'/sync',
authenticate,
validate({ body: SyncBodySchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = req.body as z.infer<typeof SyncBodySchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Check for existing active sync job
const activeJobQuery = `
SELECT id, status, started_at
FROM sync_jobs
WHERE status IN ('pending', 'running')
AND job_type = 'sat_cfdis'
ORDER BY created_at DESC
LIMIT 1
`;
const activeJob = await db.queryTenant(tenant, activeJobQuery, []);
if (activeJob.rows.length > 0 && !data.force) {
const response: ApiResponse = {
success: false,
error: {
code: 'SYNC_IN_PROGRESS',
message: 'Ya hay una sincronizacion en progreso',
details: {
jobId: activeJob.rows[0].id,
status: activeJob.rows[0].status,
startedAt: activeJob.rows[0].started_at,
},
},
meta: {
timestamp: new Date().toISOString(),
},
};
res.status(409).json(response);
return;
}
// Create new sync job
const createJobQuery = `
INSERT INTO sync_jobs (
job_type,
status,
parameters,
created_by
)
VALUES ('sat_cfdis', 'pending', $1, $2)
RETURNING id, job_type, status, created_at
`;
const parameters = {
startDate: data.startDate,
endDate: data.endDate,
tipoComprobante: data.tipoComprobante,
force: data.force,
};
const result = await db.queryTenant(tenant, createJobQuery, [
JSON.stringify(parameters),
req.user!.sub,
]);
// In a real implementation, you would trigger a background job here
// using a queue system like Bull or similar
const response: ApiResponse = {
success: true,
data: {
job: result.rows[0],
message: 'Sincronizacion iniciada. Recibiras una notificacion cuando termine.',
},
meta: {
timestamp: new Date().toISOString(),
},
};
res.status(202).json(response);
} catch (error) {
next(error);
}
}
);
export default router;

View File

@@ -0,0 +1,587 @@
/**
* Contacts Routes
*
* CRUD operations for contacts (clients, providers, etc.)
*/
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { authenticate } from '../middleware/auth.middleware';
import { validate } from '../middleware/validate.middleware';
import { getDatabase, TenantContext } from '@horux/database';
import {
ApiResponse,
AppError,
NotFoundError,
ValidationError,
ConflictError,
} from '../types';
const router = Router();
// ============================================================================
// Validation Schemas
// ============================================================================
const ContactTypeEnum = z.enum(['cliente', 'proveedor', 'empleado', 'otro']);
const PaginationSchema = z.object({
page: z.string().optional().transform((v) => (v ? parseInt(v, 10) : 1)),
limit: z.string().optional().transform((v) => (v ? Math.min(parseInt(v, 10), 100) : 20)),
});
const ContactFiltersSchema = PaginationSchema.extend({
type: ContactTypeEnum.optional(),
search: z.string().optional(),
isRecurring: z.string().optional().transform((v) => v === 'true' ? true : v === 'false' ? false : undefined),
hasDebt: z.string().optional().transform((v) => v === 'true'),
sortBy: z.enum(['name', 'created_at', 'total_transactions']).optional().default('name'),
sortOrder: z.enum(['asc', 'desc']).optional().default('asc'),
});
const ContactIdSchema = z.object({
id: z.string().uuid('ID de contacto invalido'),
});
const RfcSchema = z.string().regex(
/^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/,
'RFC invalido. Formato esperado: XAXX010101XXX'
);
const CreateContactSchema = z.object({
name: z.string().min(2).max(200),
rfc: RfcSchema.optional(),
type: ContactTypeEnum,
email: z.string().email().optional().nullable(),
phone: z.string().max(20).optional().nullable(),
address: z.object({
street: z.string().optional(),
exterior: z.string().optional(),
interior: z.string().optional(),
neighborhood: z.string().optional(),
city: z.string().optional(),
state: z.string().optional(),
postalCode: z.string().optional(),
country: z.string().default('MX'),
}).optional(),
taxRegime: z.string().optional(),
usoCfdi: z.string().optional(),
notes: z.string().max(2000).optional(),
isRecurring: z.boolean().optional().default(false),
tags: z.array(z.string()).optional(),
metadata: z.record(z.unknown()).optional(),
});
const UpdateContactSchema = CreateContactSchema.partial().extend({
isActive: z.boolean().optional(),
});
const RecurringSchema = z.object({
isRecurring: z.boolean(),
recurringPattern: z.object({
frequency: z.enum(['weekly', 'biweekly', 'monthly', 'quarterly', 'yearly']),
dayOfMonth: z.number().min(1).max(31).optional(),
dayOfWeek: z.number().min(0).max(6).optional(),
expectedAmount: z.number().positive().optional(),
}).optional(),
});
// ============================================================================
// Helper Functions
// ============================================================================
const getTenantContext = (req: Request): TenantContext => {
if (!req.user || !req.tenantSchema) {
throw new AppError('Contexto de tenant no disponible', 'TENANT_CONTEXT_ERROR', 500);
}
return {
tenantId: req.user.tenant_id,
schemaName: req.tenantSchema,
userId: req.user.sub,
};
};
// ============================================================================
// Routes
// ============================================================================
/**
* GET /api/contacts
* List contacts with filters and pagination
*/
router.get(
'/',
authenticate,
validate({ query: ContactFiltersSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const filters = req.query as z.infer<typeof ContactFiltersSchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Build filter conditions
const conditions: string[] = ['c.is_active = true'];
const params: unknown[] = [];
let paramIndex = 1;
if (filters.type) {
conditions.push(`c.type = $${paramIndex++}`);
params.push(filters.type);
}
if (filters.search) {
conditions.push(`(
c.name ILIKE $${paramIndex} OR
c.rfc ILIKE $${paramIndex} OR
c.email ILIKE $${paramIndex}
)`);
params.push(`%${filters.search}%`);
paramIndex++;
}
if (filters.isRecurring !== undefined) {
conditions.push(`c.is_recurring = $${paramIndex++}`);
params.push(filters.isRecurring);
}
const whereClause = `WHERE ${conditions.join(' AND ')}`;
const offset = (filters.page - 1) * filters.limit;
let sortColumn: string;
switch (filters.sortBy) {
case 'total_transactions':
sortColumn = 'transaction_count';
break;
case 'created_at':
sortColumn = 'c.created_at';
break;
default:
sortColumn = 'c.name';
}
// Get total count
const countQuery = `SELECT COUNT(*) as total FROM contacts c ${whereClause}`;
const countResult = await db.queryTenant<{ total: string }>(tenant, countQuery, params);
const total = parseInt(countResult.rows[0]?.total || '0', 10);
// Get contacts with transaction stats
const dataQuery = `
SELECT
c.id,
c.name,
c.rfc,
c.type,
c.email,
c.phone,
c.address,
c.tax_regime,
c.uso_cfdi,
c.is_recurring,
c.recurring_pattern,
c.tags,
c.created_at,
c.updated_at,
COALESCE(stats.transaction_count, 0) as transaction_count,
COALESCE(stats.total_income, 0) as total_income,
COALESCE(stats.total_expense, 0) as total_expense,
stats.last_transaction_date
FROM contacts c
LEFT JOIN LATERAL (
SELECT
COUNT(*) as transaction_count,
SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) as total_income,
SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) as total_expense,
MAX(date) as last_transaction_date
FROM transactions
WHERE contact_id = c.id
) stats ON true
${whereClause}
ORDER BY ${sortColumn} ${filters.sortOrder.toUpperCase()}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const dataResult = await db.queryTenant(tenant, dataQuery, [...params, filters.limit, offset]);
const response: ApiResponse = {
success: true,
data: dataResult.rows,
meta: {
page: filters.page,
limit: filters.limit,
total,
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* GET /api/contacts/:id
* Get contact with statistics
*/
router.get(
'/:id',
authenticate,
validate({ params: ContactIdSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
const query = `
SELECT
c.*,
(
SELECT json_build_object(
'transaction_count', COUNT(*),
'total_income', COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0),
'total_expense', COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0),
'first_transaction', MIN(date),
'last_transaction', MAX(date),
'average_transaction', AVG(amount)
)
FROM transactions
WHERE contact_id = c.id
) as statistics,
(
SELECT json_agg(
json_build_object(
'id', t.id,
'type', t.type,
'amount', t.amount,
'description', t.description,
'date', t.date,
'status', t.status
)
ORDER BY t.date DESC
)
FROM (
SELECT * FROM transactions
WHERE contact_id = c.id
ORDER BY date DESC
LIMIT 10
) t
) as recent_transactions,
(
SELECT json_agg(
json_build_object(
'id', cf.id,
'uuid', cf.uuid,
'tipo_comprobante', cf.tipo_comprobante,
'total', cf.total,
'fecha', cf.fecha,
'estado', cf.estado
)
ORDER BY cf.fecha DESC
)
FROM (
SELECT * FROM cfdis
WHERE rfc_emisor = c.rfc OR rfc_receptor = c.rfc
ORDER BY fecha DESC
LIMIT 10
) cf
) as recent_cfdis
FROM contacts c
WHERE c.id = $1
`;
const result = await db.queryTenant(tenant, query, [id]);
if (result.rows.length === 0) {
throw new NotFoundError('Contacto');
}
const response: ApiResponse = {
success: true,
data: result.rows[0],
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* POST /api/contacts
* Create a new contact
*/
router.post(
'/',
authenticate,
validate({ body: CreateContactSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = req.body as z.infer<typeof CreateContactSchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Check for duplicate RFC
if (data.rfc) {
const duplicateCheck = await db.queryTenant(
tenant,
'SELECT id FROM contacts WHERE rfc = $1 AND is_active = true',
[data.rfc]
);
if (duplicateCheck.rows.length > 0) {
throw new ConflictError('Ya existe un contacto con este RFC');
}
}
const insertQuery = `
INSERT INTO contacts (
name, rfc, type, email, phone, address,
tax_regime, uso_cfdi, notes, is_recurring, tags, metadata,
created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *
`;
const result = await db.queryTenant(tenant, insertQuery, [
data.name,
data.rfc || null,
data.type,
data.email || null,
data.phone || null,
data.address ? JSON.stringify(data.address) : null,
data.taxRegime || null,
data.usoCfdi || null,
data.notes || null,
data.isRecurring || false,
data.tags || [],
data.metadata || {},
req.user!.sub,
]);
const response: ApiResponse = {
success: true,
data: result.rows[0],
meta: {
timestamp: new Date().toISOString(),
},
};
res.status(201).json(response);
} catch (error) {
next(error);
}
}
);
/**
* PUT /api/contacts/:id
* Update a contact
*/
router.put(
'/:id',
authenticate,
validate({ params: ContactIdSchema, body: UpdateContactSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const data = req.body as z.infer<typeof UpdateContactSchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Check if contact exists
const existingCheck = await db.queryTenant(
tenant,
'SELECT id, rfc FROM contacts WHERE id = $1',
[id]
);
if (existingCheck.rows.length === 0) {
throw new NotFoundError('Contacto');
}
// Check for duplicate RFC if changing
if (data.rfc && data.rfc !== existingCheck.rows[0].rfc) {
const duplicateCheck = await db.queryTenant(
tenant,
'SELECT id FROM contacts WHERE rfc = $1 AND id != $2 AND is_active = true',
[data.rfc, id]
);
if (duplicateCheck.rows.length > 0) {
throw new ConflictError('Ya existe un contacto con este RFC');
}
}
// Build update query dynamically
const updates: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
const fieldMappings: Record<string, string> = {
name: 'name',
rfc: 'rfc',
type: 'type',
email: 'email',
phone: 'phone',
taxRegime: 'tax_regime',
usoCfdi: 'uso_cfdi',
notes: 'notes',
isRecurring: 'is_recurring',
tags: 'tags',
metadata: 'metadata',
isActive: 'is_active',
};
for (const [key, column] of Object.entries(fieldMappings)) {
if (data[key as keyof typeof data] !== undefined) {
updates.push(`${column} = $${paramIndex++}`);
params.push(data[key as keyof typeof data]);
}
}
if (data.address !== undefined) {
updates.push(`address = $${paramIndex++}`);
params.push(data.address ? JSON.stringify(data.address) : null);
}
if (updates.length === 0) {
throw new ValidationError('No hay campos para actualizar');
}
updates.push(`updated_at = NOW()`);
params.push(id);
const updateQuery = `
UPDATE contacts
SET ${updates.join(', ')}
WHERE id = $${paramIndex}
RETURNING *
`;
const result = await db.queryTenant(tenant, updateQuery, params);
const response: ApiResponse = {
success: true,
data: result.rows[0],
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* DELETE /api/contacts/:id
* Soft delete a contact
*/
router.delete(
'/:id',
authenticate,
validate({ params: ContactIdSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
// Check if contact exists
const existingCheck = await db.queryTenant(
tenant,
'SELECT id FROM contacts WHERE id = $1 AND is_active = true',
[id]
);
if (existingCheck.rows.length === 0) {
throw new NotFoundError('Contacto');
}
// Soft delete
await db.queryTenant(
tenant,
'UPDATE contacts SET is_active = false, updated_at = NOW() WHERE id = $1',
[id]
);
const response: ApiResponse = {
success: true,
data: { deleted: true, id },
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* PUT /api/contacts/:id/recurring
* Mark contact as recurring
*/
router.put(
'/:id/recurring',
authenticate,
validate({ params: ContactIdSchema, body: RecurringSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const data = req.body as z.infer<typeof RecurringSchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Check if contact exists
const existingCheck = await db.queryTenant(
tenant,
'SELECT id FROM contacts WHERE id = $1 AND is_active = true',
[id]
);
if (existingCheck.rows.length === 0) {
throw new NotFoundError('Contacto');
}
const updateQuery = `
UPDATE contacts
SET
is_recurring = $1,
recurring_pattern = $2,
updated_at = NOW()
WHERE id = $3
RETURNING *
`;
const result = await db.queryTenant(tenant, updateQuery, [
data.isRecurring,
data.recurringPattern ? JSON.stringify(data.recurringPattern) : null,
id,
]);
const response: ApiResponse = {
success: true,
data: result.rows[0],
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
export default router;

View File

@@ -0,0 +1,284 @@
import { Router, Request, Response } from 'express';
import { Pool } from 'pg';
import Redis from 'ioredis';
import { config } from '../config/index.js';
import { logger } from '../utils/logger.js';
import { asyncHandler } from '../utils/asyncHandler.js';
// ============================================================================
// Router Setup
// ============================================================================
const router = Router();
// ============================================================================
// Health Check Types
// ============================================================================
interface HealthCheckResult {
status: 'healthy' | 'unhealthy' | 'degraded';
timestamp: string;
uptime: number;
version: string;
environment: string;
checks: {
database: ComponentHealth;
redis: ComponentHealth;
memory: ComponentHealth;
};
}
interface ComponentHealth {
status: 'up' | 'down';
latency?: number;
message?: string;
details?: Record<string, unknown>;
}
// ============================================================================
// Health Check Functions
// ============================================================================
/**
* Check database connectivity
*/
const checkDatabase = async (): Promise<ComponentHealth> => {
const pool = new Pool({
connectionString: config.database.url,
max: 1,
connectionTimeoutMillis: 5000,
});
const startTime = Date.now();
try {
const client = await pool.connect();
await client.query('SELECT 1');
client.release();
await pool.end();
return {
status: 'up',
latency: Date.now() - startTime,
};
} catch (error) {
await pool.end().catch(() => {});
return {
status: 'down',
latency: Date.now() - startTime,
message: error instanceof Error ? error.message : 'Database connection failed',
};
}
};
/**
* Check Redis connectivity
*/
const checkRedis = async (): Promise<ComponentHealth> => {
const startTime = Date.now();
try {
const redis = new Redis(config.redis.url, {
connectTimeout: 5000,
maxRetriesPerRequest: 1,
lazyConnect: true,
});
await redis.connect();
await redis.ping();
await redis.quit();
return {
status: 'up',
latency: Date.now() - startTime,
};
} catch (error) {
return {
status: 'down',
latency: Date.now() - startTime,
message: error instanceof Error ? error.message : 'Redis connection failed',
};
}
};
/**
* Check memory usage
*/
const checkMemory = (): ComponentHealth => {
const used = process.memoryUsage();
const heapUsedMB = Math.round(used.heapUsed / 1024 / 1024);
const heapTotalMB = Math.round(used.heapTotal / 1024 / 1024);
const rssMB = Math.round(used.rss / 1024 / 1024);
// Consider unhealthy if heap usage > 90%
const heapUsagePercent = (used.heapUsed / used.heapTotal) * 100;
const isHealthy = heapUsagePercent < 90;
return {
status: isHealthy ? 'up' : 'down',
details: {
heapUsedMB,
heapTotalMB,
rssMB,
heapUsagePercent: Math.round(heapUsagePercent),
externalMB: Math.round(used.external / 1024 / 1024),
},
message: isHealthy ? undefined : 'High memory usage detected',
};
};
// ============================================================================
// Routes
// ============================================================================
/**
* GET /health
* Basic health check - fast response for load balancers
*/
router.get(
'/',
asyncHandler(async (_req: Request, res: Response) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
});
})
);
/**
* GET /health/live
* Liveness probe - is the application running?
*/
router.get(
'/live',
asyncHandler(async (_req: Request, res: Response) => {
res.json({
status: 'alive',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
});
})
);
/**
* GET /health/ready
* Readiness probe - is the application ready to receive traffic?
*/
router.get(
'/ready',
asyncHandler(async (_req: Request, res: Response) => {
const [dbHealth, redisHealth] = await Promise.all([
checkDatabase(),
checkRedis(),
]);
const isReady = dbHealth.status === 'up';
if (!isReady) {
res.status(503).json({
status: 'not_ready',
timestamp: new Date().toISOString(),
checks: {
database: dbHealth,
redis: redisHealth,
},
});
return;
}
res.json({
status: 'ready',
timestamp: new Date().toISOString(),
checks: {
database: dbHealth,
redis: redisHealth,
},
});
})
);
/**
* GET /health/detailed
* Detailed health check with all component statuses
*/
router.get(
'/detailed',
asyncHandler(async (_req: Request, res: Response) => {
const startTime = Date.now();
const [dbHealth, redisHealth] = await Promise.all([
checkDatabase(),
checkRedis(),
]);
const memoryHealth = checkMemory();
// Determine overall status
let overallStatus: 'healthy' | 'unhealthy' | 'degraded' = 'healthy';
if (dbHealth.status === 'down') {
overallStatus = 'unhealthy';
} else if (redisHealth.status === 'down' || memoryHealth.status === 'down') {
overallStatus = 'degraded';
}
const result: HealthCheckResult = {
status: overallStatus,
timestamp: new Date().toISOString(),
uptime: process.uptime(),
version: process.env.npm_package_version || '0.1.0',
environment: config.env,
checks: {
database: dbHealth,
redis: redisHealth,
memory: memoryHealth,
},
};
const statusCode = overallStatus === 'unhealthy' ? 503 : 200;
logger.debug('Health check completed', {
status: overallStatus,
duration: Date.now() - startTime,
});
res.status(statusCode).json(result);
})
);
/**
* GET /health/metrics
* Basic metrics endpoint (can be extended for Prometheus)
*/
router.get(
'/metrics',
asyncHandler(async (_req: Request, res: Response) => {
const memoryUsage = process.memoryUsage();
const cpuUsage = process.cpuUsage();
res.json({
timestamp: new Date().toISOString(),
uptime: process.uptime(),
memory: {
rss: memoryUsage.rss,
heapTotal: memoryUsage.heapTotal,
heapUsed: memoryUsage.heapUsed,
external: memoryUsage.external,
arrayBuffers: memoryUsage.arrayBuffers,
},
cpu: {
user: cpuUsage.user,
system: cpuUsage.system,
},
process: {
pid: process.pid,
version: process.version,
platform: process.platform,
arch: process.arch,
},
});
})
);
export default router;

View File

@@ -0,0 +1,9 @@
// Routes exports
export { default as authRoutes } from './auth.routes.js';
export { default as healthRoutes } from './health.routes.js';
export { default as metricsRoutes } from './metrics.routes.js';
export { default as transactionsRoutes } from './transactions.routes.js';
export { default as contactsRoutes } from './contacts.routes.js';
export { default as cfdisRoutes } from './cfdis.routes.js';
export { default as categoriesRoutes } from './categories.routes.js';
export { default as alertsRoutes } from './alerts.routes.js';

View File

@@ -0,0 +1,819 @@
/**
* Integrations Routes
*
* Manages external integrations (SAT, banks, etc.)
*/
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { authenticate, requireAdmin } from '../middleware/auth.middleware';
import { validate } from '../middleware/validate.middleware';
import { getDatabase, TenantContext } from '@horux/database';
import {
ApiResponse,
AppError,
NotFoundError,
ValidationError,
ConflictError,
} from '../types';
const router = Router();
// ============================================================================
// Validation Schemas
// ============================================================================
const IntegrationTypeEnum = z.enum([
'sat',
'bank_bbva',
'bank_banamex',
'bank_santander',
'bank_banorte',
'bank_hsbc',
'accounting_contpaqi',
'accounting_aspel',
'erp_sap',
'erp_odoo',
'payments_stripe',
'payments_openpay',
'webhook',
]);
const IntegrationStatusEnum = z.enum(['active', 'inactive', 'error', 'pending', 'expired']);
const IntegrationFiltersSchema = z.object({
type: IntegrationTypeEnum.optional(),
status: IntegrationStatusEnum.optional(),
search: z.string().optional(),
});
const IntegrationIdSchema = z.object({
id: z.string().uuid('ID de integracion invalido'),
});
const IntegrationTypeParamSchema = z.object({
type: IntegrationTypeEnum,
});
// SAT-specific configuration
const SatConfigSchema = z.object({
rfc: z.string().regex(
/^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/,
'RFC invalido. Formato esperado: XAXX010101XXX'
),
certificateBase64: z.string().min(1, 'Certificado es requerido'),
privateKeyBase64: z.string().min(1, 'Llave privada es requerida'),
privateKeyPassword: z.string().min(1, 'Contrasena de llave privada es requerida'),
// Optional FIEL credentials
fielCertificateBase64: z.string().optional(),
fielPrivateKeyBase64: z.string().optional(),
fielPrivateKeyPassword: z.string().optional(),
// Sync options
syncIngresos: z.boolean().default(true),
syncEgresos: z.boolean().default(true),
syncNomina: z.boolean().default(false),
syncPagos: z.boolean().default(true),
autoSync: z.boolean().default(true),
syncFrequency: z.enum(['hourly', 'daily', 'weekly']).default('daily'),
});
// Bank configuration
const BankConfigSchema = z.object({
accountNumber: z.string().optional(),
clabe: z.string().regex(/^\d{18}$/, 'CLABE debe tener 18 digitos').optional(),
accessToken: z.string().optional(),
refreshToken: z.string().optional(),
clientId: z.string().optional(),
clientSecret: z.string().optional(),
autoSync: z.boolean().default(true),
syncFrequency: z.enum(['hourly', 'daily', 'weekly']).default('daily'),
});
// Webhook configuration
const WebhookConfigSchema = z.object({
url: z.string().url('URL invalida'),
secret: z.string().min(16, 'Secret debe tener al menos 16 caracteres'),
events: z.array(z.string()).min(1, 'Debe seleccionar al menos un evento'),
isActive: z.boolean().default(true),
retryAttempts: z.number().int().min(0).max(10).default(3),
});
// Generic integration configuration
const GenericIntegrationConfigSchema = z.object({
name: z.string().max(100).optional(),
apiKey: z.string().optional(),
apiSecret: z.string().optional(),
endpoint: z.string().url().optional(),
settings: z.record(z.unknown()).optional(),
autoSync: z.boolean().default(true),
syncFrequency: z.enum(['hourly', 'daily', 'weekly']).default('daily'),
});
const SyncBodySchema = z.object({
force: z.boolean().optional().default(false),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
});
// ============================================================================
// Helper Functions
// ============================================================================
const getTenantContext = (req: Request): TenantContext => {
if (!req.user || !req.tenantSchema) {
throw new AppError('Contexto de tenant no disponible', 'TENANT_CONTEXT_ERROR', 500);
}
return {
tenantId: req.user.tenant_id,
schemaName: req.tenantSchema,
userId: req.user.sub,
};
};
const getConfigSchema = (type: string) => {
if (type === 'sat') return SatConfigSchema;
if (type.startsWith('bank_')) return BankConfigSchema;
if (type === 'webhook') return WebhookConfigSchema;
return GenericIntegrationConfigSchema;
};
// ============================================================================
// Routes
// ============================================================================
/**
* GET /api/integrations
* List all configured integrations
*/
router.get(
'/',
authenticate,
validate({ query: IntegrationFiltersSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const filters = req.query as z.infer<typeof IntegrationFiltersSchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Build filter conditions
const conditions: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (filters.type) {
conditions.push(`i.type = $${paramIndex++}`);
params.push(filters.type);
}
if (filters.status) {
conditions.push(`i.status = $${paramIndex++}`);
params.push(filters.status);
}
if (filters.search) {
conditions.push(`(i.name ILIKE $${paramIndex} OR i.type ILIKE $${paramIndex})`);
params.push(`%${filters.search}%`);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const query = `
SELECT
i.id,
i.type,
i.name,
i.status,
i.is_active,
i.last_sync_at,
i.last_sync_status,
i.next_sync_at,
i.error_message,
i.created_at,
i.updated_at,
(
SELECT json_build_object(
'total_syncs', COUNT(*),
'successful_syncs', COUNT(*) FILTER (WHERE status = 'completed'),
'failed_syncs', COUNT(*) FILTER (WHERE status = 'failed'),
'last_duration_ms', (
SELECT EXTRACT(EPOCH FROM (completed_at - started_at)) * 1000
FROM sync_jobs
WHERE integration_id = i.id
ORDER BY created_at DESC
LIMIT 1
)
)
FROM sync_jobs
WHERE integration_id = i.id
) as sync_stats
FROM integrations i
${whereClause}
ORDER BY i.created_at DESC
`;
const result = await db.queryTenant(tenant, query, params);
// Get available integrations (not yet configured)
const availableQuery = `
SELECT DISTINCT type
FROM integrations
WHERE type IN ('sat', 'bank_bbva', 'bank_banamex', 'bank_santander', 'bank_banorte', 'bank_hsbc')
`;
const configuredTypes = await db.queryTenant<{ type: string }>(tenant, availableQuery, []);
const configuredTypeSet = new Set(configuredTypes.rows.map(r => r.type));
const allTypes = [
{ type: 'sat', name: 'SAT (Servicio de Administracion Tributaria)', category: 'fiscal' },
{ type: 'bank_bbva', name: 'BBVA Mexico', category: 'bank' },
{ type: 'bank_banamex', name: 'Banamex / Citibanamex', category: 'bank' },
{ type: 'bank_santander', name: 'Santander', category: 'bank' },
{ type: 'bank_banorte', name: 'Banorte', category: 'bank' },
{ type: 'bank_hsbc', name: 'HSBC', category: 'bank' },
{ type: 'accounting_contpaqi', name: 'CONTPAQi', category: 'accounting' },
{ type: 'accounting_aspel', name: 'Aspel', category: 'accounting' },
{ type: 'payments_stripe', name: 'Stripe', category: 'payments' },
{ type: 'payments_openpay', name: 'Openpay', category: 'payments' },
{ type: 'webhook', name: 'Webhook personalizado', category: 'custom' },
];
const availableIntegrations = allTypes.filter(t => !configuredTypeSet.has(t.type) || t.type === 'webhook');
const response: ApiResponse = {
success: true,
data: {
configured: result.rows,
available: availableIntegrations,
},
meta: {
total: result.rows.length,
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* GET /api/integrations/:id
* Get integration details
*/
router.get(
'/:id',
authenticate,
validate({ params: IntegrationIdSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
const query = `
SELECT
i.*,
(
SELECT json_agg(
json_build_object(
'id', sj.id,
'status', sj.status,
'started_at', sj.started_at,
'completed_at', sj.completed_at,
'records_processed', sj.records_processed,
'records_created', sj.records_created,
'records_updated', sj.records_updated,
'error_message', sj.error_message
)
ORDER BY sj.created_at DESC
)
FROM (
SELECT * FROM sync_jobs
WHERE integration_id = i.id
ORDER BY created_at DESC
LIMIT 10
) sj
) as recent_syncs
FROM integrations i
WHERE i.id = $1
`;
const result = await db.queryTenant(tenant, query, [id]);
if (result.rows.length === 0) {
throw new NotFoundError('Integracion');
}
// Mask sensitive data in config
const integration = result.rows[0];
if (integration.config) {
const sensitiveFields = ['privateKeyBase64', 'privateKeyPassword', 'fielPrivateKeyBase64',
'fielPrivateKeyPassword', 'clientSecret', 'apiSecret', 'secret', 'accessToken', 'refreshToken'];
for (const field of sensitiveFields) {
if (integration.config[field]) {
integration.config[field] = '********';
}
}
}
const response: ApiResponse = {
success: true,
data: integration,
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* GET /api/integrations/:id/status
* Get integration status
*/
router.get(
'/:id/status',
authenticate,
validate({ params: IntegrationIdSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
const query = `
SELECT
i.id,
i.type,
i.name,
i.status,
i.is_active,
i.last_sync_at,
i.last_sync_status,
i.next_sync_at,
i.error_message,
i.health_check_at,
(
SELECT json_build_object(
'id', sj.id,
'status', sj.status,
'progress', sj.progress,
'started_at', sj.started_at,
'records_processed', sj.records_processed
)
FROM sync_jobs sj
WHERE sj.integration_id = i.id AND sj.status IN ('pending', 'running')
ORDER BY sj.created_at DESC
LIMIT 1
) as current_job
FROM integrations i
WHERE i.id = $1
`;
const result = await db.queryTenant(tenant, query, [id]);
if (result.rows.length === 0) {
throw new NotFoundError('Integracion');
}
const integration = result.rows[0];
// Determine health status
let health: 'healthy' | 'degraded' | 'unhealthy' | 'unknown' = 'unknown';
if (integration.status === 'active' && integration.last_sync_status === 'completed') {
health = 'healthy';
} else if (integration.status === 'error' || integration.last_sync_status === 'failed') {
health = 'unhealthy';
} else if (integration.status === 'active') {
health = 'degraded';
}
const response: ApiResponse = {
success: true,
data: {
...integration,
health,
isConfigured: true,
hasPendingSync: !!integration.current_job,
},
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* POST /api/integrations/sat
* Configure SAT integration
*/
router.post(
'/sat',
authenticate,
requireAdmin,
validate({ body: SatConfigSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const config = req.body as z.infer<typeof SatConfigSchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Check if SAT integration already exists
const existingCheck = await db.queryTenant(
tenant,
"SELECT id FROM integrations WHERE type = 'sat'",
[]
);
if (existingCheck.rows.length > 0) {
throw new ConflictError('Ya existe una integracion con SAT configurada');
}
// TODO: Validate certificate and key with SAT
// This would involve parsing the certificate and verifying it's valid
// Store integration (encrypt sensitive data in production)
const insertQuery = `
INSERT INTO integrations (
type,
name,
status,
is_active,
config,
created_by
)
VALUES ('sat', 'SAT - ${config.rfc}', 'pending', true, $1, $2)
RETURNING id, type, name, status, is_active, created_at
`;
const result = await db.queryTenant(tenant, insertQuery, [
JSON.stringify({
rfc: config.rfc,
certificateBase64: config.certificateBase64,
privateKeyBase64: config.privateKeyBase64,
privateKeyPassword: config.privateKeyPassword,
fielCertificateBase64: config.fielCertificateBase64,
fielPrivateKeyBase64: config.fielPrivateKeyBase64,
fielPrivateKeyPassword: config.fielPrivateKeyPassword,
syncIngresos: config.syncIngresos,
syncEgresos: config.syncEgresos,
syncNomina: config.syncNomina,
syncPagos: config.syncPagos,
autoSync: config.autoSync,
syncFrequency: config.syncFrequency,
}),
req.user!.sub,
]);
// Create initial sync job
await db.queryTenant(
tenant,
`INSERT INTO sync_jobs (integration_id, job_type, status, created_by)
VALUES ($1, 'sat_initial', 'pending', $2)`,
[result.rows[0].id, req.user!.sub]
);
const response: ApiResponse = {
success: true,
data: {
integration: result.rows[0],
message: 'Integracion SAT configurada. Iniciando sincronizacion inicial...',
},
meta: {
timestamp: new Date().toISOString(),
},
};
res.status(201).json(response);
} catch (error) {
next(error);
}
}
);
/**
* POST /api/integrations/:type
* Configure other integration types
*/
router.post(
'/:type',
authenticate,
requireAdmin,
validate({ params: IntegrationTypeParamSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { type } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
// Validate config based on type
const configSchema = getConfigSchema(type);
const parseResult = configSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Configuracion invalida', parseResult.error.flatten().fieldErrors);
}
const config = parseResult.data;
// Check for existing integration (except webhooks which can have multiple)
if (type !== 'webhook') {
const existingCheck = await db.queryTenant(
tenant,
'SELECT id FROM integrations WHERE type = $1',
[type]
);
if (existingCheck.rows.length > 0) {
throw new ConflictError(`Ya existe una integracion de tipo ${type} configurada`);
}
}
// Generate name based on type
let name = type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
if ('name' in config && config.name) {
name = config.name as string;
}
const insertQuery = `
INSERT INTO integrations (
type,
name,
status,
is_active,
config,
created_by
)
VALUES ($1, $2, 'pending', true, $3, $4)
RETURNING id, type, name, status, is_active, created_at
`;
const result = await db.queryTenant(tenant, insertQuery, [
type,
name,
JSON.stringify(config),
req.user!.sub,
]);
const response: ApiResponse = {
success: true,
data: result.rows[0],
meta: {
timestamp: new Date().toISOString(),
},
};
res.status(201).json(response);
} catch (error) {
next(error);
}
}
);
/**
* PUT /api/integrations/:id
* Update integration configuration
*/
router.put(
'/:id',
authenticate,
requireAdmin,
validate({ params: IntegrationIdSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
// Get existing integration
const existingCheck = await db.queryTenant(
tenant,
'SELECT id, type, config FROM integrations WHERE id = $1',
[id]
);
if (existingCheck.rows.length === 0) {
throw new NotFoundError('Integracion');
}
const existing = existingCheck.rows[0];
// Validate config based on type
const configSchema = getConfigSchema(existing.type);
const parseResult = configSchema.partial().safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Configuracion invalida', parseResult.error.flatten().fieldErrors);
}
// Merge with existing config
const newConfig = { ...existing.config, ...parseResult.data };
const updateQuery = `
UPDATE integrations
SET config = $1, updated_at = NOW()
WHERE id = $2
RETURNING id, type, name, status, is_active, updated_at
`;
const result = await db.queryTenant(tenant, updateQuery, [
JSON.stringify(newConfig),
id,
]);
const response: ApiResponse = {
success: true,
data: result.rows[0],
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* POST /api/integrations/:id/sync
* Trigger sync for an integration
*/
router.post(
'/:id/sync',
authenticate,
validate({ params: IntegrationIdSchema, body: SyncBodySchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const syncOptions = req.body as z.infer<typeof SyncBodySchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Check if integration exists and is active
const integrationCheck = await db.queryTenant(
tenant,
'SELECT id, type, status, is_active FROM integrations WHERE id = $1',
[id]
);
if (integrationCheck.rows.length === 0) {
throw new NotFoundError('Integracion');
}
const integration = integrationCheck.rows[0];
if (!integration.is_active) {
throw new ValidationError('La integracion esta desactivada');
}
// Check for existing pending/running job
const activeJobCheck = await db.queryTenant(
tenant,
`SELECT id, status, started_at
FROM sync_jobs
WHERE integration_id = $1 AND status IN ('pending', 'running')
ORDER BY created_at DESC
LIMIT 1`,
[id]
);
if (activeJobCheck.rows.length > 0 && !syncOptions.force) {
const response: ApiResponse = {
success: false,
error: {
code: 'SYNC_IN_PROGRESS',
message: 'Ya hay una sincronizacion en progreso',
details: {
jobId: activeJobCheck.rows[0].id,
status: activeJobCheck.rows[0].status,
startedAt: activeJobCheck.rows[0].started_at,
},
},
meta: {
timestamp: new Date().toISOString(),
},
};
res.status(409).json(response);
return;
}
// Create sync job
const createJobQuery = `
INSERT INTO sync_jobs (
integration_id,
job_type,
status,
parameters,
created_by
)
VALUES ($1, $2, 'pending', $3, $4)
RETURNING id, job_type, status, created_at
`;
const jobType = `${integration.type}_sync`;
const parameters = {
startDate: syncOptions.startDate,
endDate: syncOptions.endDate,
force: syncOptions.force,
};
const result = await db.queryTenant(tenant, createJobQuery, [
id,
jobType,
JSON.stringify(parameters),
req.user!.sub,
]);
// In production, trigger background job here
const response: ApiResponse = {
success: true,
data: {
job: result.rows[0],
message: 'Sincronizacion iniciada',
},
meta: {
timestamp: new Date().toISOString(),
},
};
res.status(202).json(response);
} catch (error) {
next(error);
}
}
);
/**
* DELETE /api/integrations/:id
* Remove an integration
*/
router.delete(
'/:id',
authenticate,
requireAdmin,
validate({ params: IntegrationIdSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
// Check if integration exists
const existingCheck = await db.queryTenant(
tenant,
'SELECT id, type FROM integrations WHERE id = $1',
[id]
);
if (existingCheck.rows.length === 0) {
throw new NotFoundError('Integracion');
}
// Cancel any pending jobs
await db.queryTenant(
tenant,
`UPDATE sync_jobs
SET status = 'cancelled', updated_at = NOW()
WHERE integration_id = $1 AND status IN ('pending', 'running')`,
[id]
);
// Soft delete the integration
await db.queryTenant(
tenant,
`UPDATE integrations
SET is_active = false, status = 'inactive', updated_at = NOW()
WHERE id = $1`,
[id]
);
const response: ApiResponse = {
success: true,
data: { deleted: true, id },
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
export default router;

View File

@@ -0,0 +1,720 @@
/**
* Metrics Routes
*
* Business metrics and KPIs for dashboard
*/
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { authenticate } from '../middleware/auth.middleware';
import { validate } from '../middleware/validate.middleware';
import { getDatabase, TenantContext } from '@horux/database';
import {
ApiResponse,
AppError,
NotFoundError,
ValidationError,
} from '../types';
const router = Router();
// ============================================================================
// Validation Schemas
// ============================================================================
const MetricCodeEnum = z.enum([
'total_income',
'total_expense',
'net_profit',
'profit_margin',
'cash_flow',
'accounts_receivable',
'accounts_payable',
'runway',
'burn_rate',
'recurring_revenue',
'customer_count',
'average_transaction',
'tax_liability',
]);
const PeriodSchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
});
const DashboardQuerySchema = z.object({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
period: z.enum(['today', 'week', 'month', 'quarter', 'year', 'custom']).optional().default('month'),
});
const MetricCodeParamSchema = z.object({
code: MetricCodeEnum,
});
const HistoryQuerySchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
granularity: z.enum(['day', 'week', 'month']).optional().default('day'),
});
const CompareQuerySchema = z.object({
period1Start: z.string().datetime(),
period1End: z.string().datetime(),
period2Start: z.string().datetime(),
period2End: z.string().datetime(),
metrics: z.string().optional().transform((v) => v ? v.split(',') : undefined),
});
// ============================================================================
// Helper Functions
// ============================================================================
const getTenantContext = (req: Request): TenantContext => {
if (!req.user || !req.tenantSchema) {
throw new AppError('Contexto de tenant no disponible', 'TENANT_CONTEXT_ERROR', 500);
}
return {
tenantId: req.user.tenant_id,
schemaName: req.tenantSchema,
userId: req.user.sub,
};
};
const getPeriodDates = (period: string): { startDate: Date; endDate: Date } => {
const now = new Date();
const endDate = new Date(now);
let startDate: Date;
switch (period) {
case 'today':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
break;
case 'week':
startDate = new Date(now);
startDate.setDate(now.getDate() - 7);
break;
case 'month':
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
break;
case 'quarter':
const quarter = Math.floor(now.getMonth() / 3);
startDate = new Date(now.getFullYear(), quarter * 3, 1);
break;
case 'year':
startDate = new Date(now.getFullYear(), 0, 1);
break;
default:
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
}
return { startDate, endDate };
};
// ============================================================================
// Metric Calculation Functions
// ============================================================================
interface MetricResult {
code: string;
value: number;
previousValue?: number;
change?: number;
changePercent?: number;
trend?: 'up' | 'down' | 'stable';
currency?: string;
unit?: string;
}
const calculateMetric = async (
db: ReturnType<typeof getDatabase>,
tenant: TenantContext,
code: string,
startDate: string,
endDate: string
): Promise<MetricResult> => {
let query: string;
let result: MetricResult = { code, value: 0 };
switch (code) {
case 'total_income':
query = `
SELECT COALESCE(SUM(amount), 0) as value
FROM transactions
WHERE type = 'income' AND date >= $1 AND date <= $2 AND status != 'cancelled'
`;
const incomeResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
result = {
code,
value: parseFloat(incomeResult.rows[0]?.value || '0'),
currency: 'MXN',
};
break;
case 'total_expense':
query = `
SELECT COALESCE(SUM(amount), 0) as value
FROM transactions
WHERE type = 'expense' AND date >= $1 AND date <= $2 AND status != 'cancelled'
`;
const expenseResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
result = {
code,
value: parseFloat(expenseResult.rows[0]?.value || '0'),
currency: 'MXN',
};
break;
case 'net_profit':
query = `
SELECT
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as value
FROM transactions
WHERE date >= $1 AND date <= $2 AND status != 'cancelled'
`;
const profitResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
result = {
code,
value: parseFloat(profitResult.rows[0]?.value || '0'),
currency: 'MXN',
};
break;
case 'profit_margin':
query = `
SELECT
CASE
WHEN SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) > 0
THEN (
(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) -
SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END)) /
SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END)
) * 100
ELSE 0
END as value
FROM transactions
WHERE date >= $1 AND date <= $2 AND status != 'cancelled'
`;
const marginResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
result = {
code,
value: parseFloat(marginResult.rows[0]?.value || '0'),
unit: '%',
};
break;
case 'cash_flow':
query = `
SELECT
SUM(CASE WHEN type = 'income' THEN amount ELSE -amount END) as value
FROM transactions
WHERE date >= $1 AND date <= $2 AND status = 'completed'
`;
const cashFlowResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
result = {
code,
value: parseFloat(cashFlowResult.rows[0]?.value || '0'),
currency: 'MXN',
};
break;
case 'accounts_receivable':
query = `
SELECT COALESCE(SUM(amount), 0) as value
FROM transactions
WHERE type = 'income' AND status = 'pending'
`;
const arResult = await db.queryTenant<{ value: string }>(tenant, query, []);
result = {
code,
value: parseFloat(arResult.rows[0]?.value || '0'),
currency: 'MXN',
};
break;
case 'accounts_payable':
query = `
SELECT COALESCE(SUM(amount), 0) as value
FROM transactions
WHERE type = 'expense' AND status = 'pending'
`;
const apResult = await db.queryTenant<{ value: string }>(tenant, query, []);
result = {
code,
value: parseFloat(apResult.rows[0]?.value || '0'),
currency: 'MXN',
};
break;
case 'recurring_revenue':
query = `
SELECT COALESCE(SUM(t.amount), 0) as value
FROM transactions t
JOIN contacts c ON t.contact_id = c.id
WHERE t.type = 'income'
AND c.is_recurring = true
AND t.date >= $1 AND t.date <= $2
AND t.status != 'cancelled'
`;
const rrResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
result = {
code,
value: parseFloat(rrResult.rows[0]?.value || '0'),
currency: 'MXN',
};
break;
case 'customer_count':
query = `
SELECT COUNT(DISTINCT contact_id) as value
FROM transactions
WHERE type = 'income' AND date >= $1 AND date <= $2
`;
const customerResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
result = {
code,
value: parseInt(customerResult.rows[0]?.value || '0', 10),
};
break;
case 'average_transaction':
query = `
SELECT COALESCE(AVG(amount), 0) as value
FROM transactions
WHERE date >= $1 AND date <= $2 AND status != 'cancelled'
`;
const avgResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
result = {
code,
value: parseFloat(avgResult.rows[0]?.value || '0'),
currency: 'MXN',
};
break;
case 'burn_rate':
// Monthly average expense
query = `
SELECT
COALESCE(SUM(amount) / GREATEST(1, EXTRACT(MONTH FROM AGE($2::timestamp, $1::timestamp)) + 1), 0) as value
FROM transactions
WHERE type = 'expense' AND date >= $1 AND date <= $2 AND status != 'cancelled'
`;
const burnResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
result = {
code,
value: parseFloat(burnResult.rows[0]?.value || '0'),
currency: 'MXN',
unit: '/mes',
};
break;
case 'tax_liability':
// Estimated IVA liability (16% of income - 16% of deductible expenses)
query = `
SELECT
COALESCE(
(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) * 0.16) -
(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) * 0.16),
0
) as value
FROM transactions
WHERE date >= $1 AND date <= $2 AND status != 'cancelled'
`;
const taxResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
result = {
code,
value: Math.max(0, parseFloat(taxResult.rows[0]?.value || '0')),
currency: 'MXN',
};
break;
default:
throw new NotFoundError(`Metrica ${code}`);
}
return result;
};
// ============================================================================
// Routes
// ============================================================================
/**
* GET /api/metrics/dashboard
* Get all dashboard metrics
*/
router.get(
'/dashboard',
authenticate,
validate({ query: DashboardQuerySchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const query = req.query as z.infer<typeof DashboardQuerySchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
let startDate: string;
let endDate: string;
if (query.period === 'custom' && query.startDate && query.endDate) {
startDate = query.startDate;
endDate = query.endDate;
} else {
const dates = getPeriodDates(query.period);
startDate = dates.startDate.toISOString();
endDate = dates.endDate.toISOString();
}
// Calculate main metrics
const metricsToCalculate = [
'total_income',
'total_expense',
'net_profit',
'profit_margin',
'cash_flow',
'accounts_receivable',
'accounts_payable',
'customer_count',
'average_transaction',
];
const metrics: MetricResult[] = await Promise.all(
metricsToCalculate.map((code) => calculateMetric(db, tenant, code, startDate, endDate))
);
// Get recent transactions summary
const recentTransactionsQuery = `
SELECT
type,
COUNT(*) as count,
SUM(amount) as total
FROM transactions
WHERE date >= $1 AND date <= $2
GROUP BY type
`;
const recentTransactions = await db.queryTenant(tenant, recentTransactionsQuery, [startDate, endDate]);
// Get top categories
const topCategoriesQuery = `
SELECT
c.id,
c.name,
c.color,
c.icon,
COUNT(*) as transaction_count,
SUM(t.amount) as total_amount
FROM transactions t
JOIN categories c ON t.category_id = c.id
WHERE t.date >= $1 AND t.date <= $2
GROUP BY c.id, c.name, c.color, c.icon
ORDER BY total_amount DESC
LIMIT 5
`;
const topCategories = await db.queryTenant(tenant, topCategoriesQuery, [startDate, endDate]);
// Get daily trend
const dailyTrendQuery = `
SELECT
DATE(date) as day,
SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) as income,
SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) as expense
FROM transactions
WHERE date >= $1 AND date <= $2
GROUP BY DATE(date)
ORDER BY day
`;
const dailyTrend = await db.queryTenant(tenant, dailyTrendQuery, [startDate, endDate]);
const response: ApiResponse = {
success: true,
data: {
period: {
start: startDate,
end: endDate,
type: query.period,
},
metrics: metrics.reduce((acc, m) => {
acc[m.code] = m;
return acc;
}, {} as Record<string, MetricResult>),
summary: {
transactions: recentTransactions.rows,
topCategories: topCategories.rows,
},
trends: {
daily: dailyTrend.rows,
},
},
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* GET /api/metrics/compare
* Compare metrics between two periods
*/
router.get(
'/compare',
authenticate,
validate({ query: CompareQuerySchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const query = req.query as z.infer<typeof CompareQuerySchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
const metricsToCompare = query.metrics || [
'total_income',
'total_expense',
'net_profit',
'profit_margin',
];
// Calculate metrics for period 1
const period1Metrics = await Promise.all(
metricsToCompare.map((code) =>
calculateMetric(db, tenant, code, query.period1Start, query.period1End)
)
);
// Calculate metrics for period 2
const period2Metrics = await Promise.all(
metricsToCompare.map((code) =>
calculateMetric(db, tenant, code, query.period2Start, query.period2End)
)
);
// Calculate changes
const comparison = metricsToCompare.map((code, index) => {
const p1 = period1Metrics[index];
const p2 = period2Metrics[index];
const change = p1.value - p2.value;
const changePercent = p2.value !== 0 ? ((change / p2.value) * 100) : (p1.value > 0 ? 100 : 0);
return {
code,
period1: p1,
period2: p2,
change,
changePercent: Math.round(changePercent * 100) / 100,
trend: change > 0 ? 'up' : change < 0 ? 'down' : 'stable',
};
});
const response: ApiResponse = {
success: true,
data: {
period1: {
start: query.period1Start,
end: query.period1End,
},
period2: {
start: query.period2Start,
end: query.period2End,
},
comparison,
},
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* GET /api/metrics/:code
* Get a specific metric
*/
router.get(
'/:code',
authenticate,
validate({ params: MetricCodeParamSchema, query: PeriodSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { code } = req.params as z.infer<typeof MetricCodeParamSchema>;
const { startDate, endDate } = req.query as z.infer<typeof PeriodSchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
const metric = await calculateMetric(db, tenant, code, startDate, endDate);
// Calculate previous period for comparison
const periodLength = new Date(endDate).getTime() - new Date(startDate).getTime();
const previousStart = new Date(new Date(startDate).getTime() - periodLength).toISOString();
const previousEnd = startDate;
const previousMetric = await calculateMetric(db, tenant, code, previousStart, previousEnd);
const change = metric.value - previousMetric.value;
const changePercent = previousMetric.value !== 0
? ((change / previousMetric.value) * 100)
: (metric.value > 0 ? 100 : 0);
const response: ApiResponse = {
success: true,
data: {
...metric,
previousValue: previousMetric.value,
change,
changePercent: Math.round(changePercent * 100) / 100,
trend: change > 0 ? 'up' : change < 0 ? 'down' : 'stable',
},
meta: {
period: { startDate, endDate },
previousPeriod: { startDate: previousStart, endDate: previousEnd },
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* GET /api/metrics/:code/history
* Get metric history over time
*/
router.get(
'/:code/history',
authenticate,
validate({ params: MetricCodeParamSchema, query: HistoryQuerySchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { code } = req.params as z.infer<typeof MetricCodeParamSchema>;
const { startDate, endDate, granularity } = req.query as z.infer<typeof HistoryQuerySchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Build date format based on granularity
let dateFormat: string;
let dateGroup: string;
switch (granularity) {
case 'day':
dateFormat = 'YYYY-MM-DD';
dateGroup = 'DATE(date)';
break;
case 'week':
dateFormat = 'IYYY-IW';
dateGroup = "DATE_TRUNC('week', date)";
break;
case 'month':
dateFormat = 'YYYY-MM';
dateGroup = "DATE_TRUNC('month', date)";
break;
}
let query: string;
let historyData: { period: string; value: number }[] = [];
// Different queries based on metric type
switch (code) {
case 'total_income':
query = `
SELECT
TO_CHAR(${dateGroup}, '${dateFormat}') as period,
COALESCE(SUM(amount), 0) as value
FROM transactions
WHERE type = 'income' AND date >= $1 AND date <= $2 AND status != 'cancelled'
GROUP BY ${dateGroup}
ORDER BY period
`;
break;
case 'total_expense':
query = `
SELECT
TO_CHAR(${dateGroup}, '${dateFormat}') as period,
COALESCE(SUM(amount), 0) as value
FROM transactions
WHERE type = 'expense' AND date >= $1 AND date <= $2 AND status != 'cancelled'
GROUP BY ${dateGroup}
ORDER BY period
`;
break;
case 'net_profit':
query = `
SELECT
TO_CHAR(${dateGroup}, '${dateFormat}') as period,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE -amount END), 0) as value
FROM transactions
WHERE date >= $1 AND date <= $2 AND status != 'cancelled'
GROUP BY ${dateGroup}
ORDER BY period
`;
break;
case 'customer_count':
query = `
SELECT
TO_CHAR(${dateGroup}, '${dateFormat}') as period,
COUNT(DISTINCT contact_id) as value
FROM transactions
WHERE type = 'income' AND date >= $1 AND date <= $2
GROUP BY ${dateGroup}
ORDER BY period
`;
break;
case 'average_transaction':
query = `
SELECT
TO_CHAR(${dateGroup}, '${dateFormat}') as period,
COALESCE(AVG(amount), 0) as value
FROM transactions
WHERE date >= $1 AND date <= $2 AND status != 'cancelled'
GROUP BY ${dateGroup}
ORDER BY period
`;
break;
default:
throw new ValidationError(`Historial no disponible para metrica: ${code}`);
}
const result = await db.queryTenant<{ period: string; value: string }>(tenant, query, [startDate, endDate]);
historyData = result.rows.map((row) => ({
period: row.period,
value: parseFloat(row.value),
}));
const response: ApiResponse = {
success: true,
data: {
code,
granularity,
history: historyData,
},
meta: {
period: { startDate, endDate },
dataPoints: historyData.length,
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
export default router;

View File

@@ -0,0 +1,617 @@
/**
* Transactions Routes
*
* CRUD operations for transactions
*/
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { authenticate } from '../middleware/auth.middleware';
import { validate } from '../middleware/validate.middleware';
import { getDatabase, TenantContext } from '@horux/database';
import {
ApiResponse,
AppError,
NotFoundError,
ValidationError,
} from '../types';
const router = Router();
// ============================================================================
// Validation Schemas
// ============================================================================
const TransactionTypeEnum = z.enum(['income', 'expense', 'transfer']);
const TransactionStatusEnum = z.enum(['pending', 'completed', 'cancelled', 'reconciled']);
const PaginationSchema = z.object({
page: z.string().optional().transform((v) => (v ? parseInt(v, 10) : 1)),
limit: z.string().optional().transform((v) => (v ? Math.min(parseInt(v, 10), 100) : 20)),
});
const TransactionFiltersSchema = PaginationSchema.extend({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
type: TransactionTypeEnum.optional(),
categoryId: z.string().uuid().optional(),
contactId: z.string().uuid().optional(),
status: TransactionStatusEnum.optional(),
minAmount: z.string().optional().transform((v) => (v ? parseFloat(v) : undefined)),
maxAmount: z.string().optional().transform((v) => (v ? parseFloat(v) : undefined)),
search: z.string().optional(),
sortBy: z.enum(['date', 'amount', 'created_at']).optional().default('date'),
sortOrder: z.enum(['asc', 'desc']).optional().default('desc'),
});
const TransactionIdSchema = z.object({
id: z.string().uuid('ID de transaccion invalido'),
});
const CreateTransactionSchema = z.object({
type: TransactionTypeEnum,
amount: z.number().positive('El monto debe ser positivo'),
currency: z.string().length(3).default('MXN'),
description: z.string().min(1).max(500),
date: z.string().datetime(),
categoryId: z.string().uuid().optional(),
contactId: z.string().uuid().optional(),
accountId: z.string().uuid().optional(),
reference: z.string().max(100).optional(),
notes: z.string().max(2000).optional(),
tags: z.array(z.string()).optional(),
metadata: z.record(z.unknown()).optional(),
});
const UpdateTransactionSchema = z.object({
categoryId: z.string().uuid().optional().nullable(),
notes: z.string().max(2000).optional().nullable(),
tags: z.array(z.string()).optional(),
status: TransactionStatusEnum.optional(),
metadata: z.record(z.unknown()).optional(),
});
const ExportQuerySchema = z.object({
format: z.enum(['csv', 'xlsx']).default('csv'),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
type: TransactionTypeEnum.optional(),
categoryId: z.string().uuid().optional(),
});
// ============================================================================
// Helper Functions
// ============================================================================
const getTenantContext = (req: Request): TenantContext => {
if (!req.user || !req.tenantSchema) {
throw new AppError('Contexto de tenant no disponible', 'TENANT_CONTEXT_ERROR', 500);
}
return {
tenantId: req.user.tenant_id,
schemaName: req.tenantSchema,
userId: req.user.sub,
};
};
const buildFilterQuery = (filters: z.infer<typeof TransactionFiltersSchema>) => {
const conditions: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (filters.startDate) {
conditions.push(`t.date >= $${paramIndex++}`);
params.push(filters.startDate);
}
if (filters.endDate) {
conditions.push(`t.date <= $${paramIndex++}`);
params.push(filters.endDate);
}
if (filters.type) {
conditions.push(`t.type = $${paramIndex++}`);
params.push(filters.type);
}
if (filters.categoryId) {
conditions.push(`t.category_id = $${paramIndex++}`);
params.push(filters.categoryId);
}
if (filters.contactId) {
conditions.push(`t.contact_id = $${paramIndex++}`);
params.push(filters.contactId);
}
if (filters.status) {
conditions.push(`t.status = $${paramIndex++}`);
params.push(filters.status);
}
if (filters.minAmount !== undefined) {
conditions.push(`t.amount >= $${paramIndex++}`);
params.push(filters.minAmount);
}
if (filters.maxAmount !== undefined) {
conditions.push(`t.amount <= $${paramIndex++}`);
params.push(filters.maxAmount);
}
if (filters.search) {
conditions.push(`(t.description ILIKE $${paramIndex} OR t.reference ILIKE $${paramIndex} OR t.notes ILIKE $${paramIndex})`);
params.push(`%${filters.search}%`);
paramIndex++;
}
return { conditions, params, paramIndex };
};
// ============================================================================
// Routes
// ============================================================================
/**
* GET /api/transactions
* List transactions with filters and pagination
*/
router.get(
'/',
authenticate,
validate({ query: TransactionFiltersSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const filters = req.query as z.infer<typeof TransactionFiltersSchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
const { conditions, params, paramIndex } = buildFilterQuery(filters);
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const offset = (filters.page - 1) * filters.limit;
const sortColumn = filters.sortBy === 'date' ? 't.date' : filters.sortBy === 'amount' ? 't.amount' : 't.created_at';
// Get total count
const countQuery = `
SELECT COUNT(*) as total
FROM transactions t
${whereClause}
`;
const countResult = await db.queryTenant<{ total: string }>(tenant, countQuery, params);
const total = parseInt(countResult.rows[0]?.total || '0', 10);
// Get transactions with related data
const dataQuery = `
SELECT
t.id,
t.type,
t.amount,
t.currency,
t.description,
t.date,
t.reference,
t.notes,
t.tags,
t.status,
t.cfdi_id,
t.created_at,
t.updated_at,
json_build_object('id', c.id, 'name', c.name, 'color', c.color, 'icon', c.icon) as category,
json_build_object('id', co.id, 'name', co.name, 'rfc', co.rfc, 'type', co.type) as contact
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN contacts co ON t.contact_id = co.id
${whereClause}
ORDER BY ${sortColumn} ${filters.sortOrder.toUpperCase()}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const dataResult = await db.queryTenant(tenant, dataQuery, [...params, filters.limit, offset]);
const response: ApiResponse = {
success: true,
data: dataResult.rows,
meta: {
page: filters.page,
limit: filters.limit,
total,
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* GET /api/transactions/export
* Export transactions to CSV or Excel
*/
router.get(
'/export',
authenticate,
validate({ query: ExportQuerySchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const query = req.query as z.infer<typeof ExportQuerySchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Build filter conditions
const conditions: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (query.startDate) {
conditions.push(`t.date >= $${paramIndex++}`);
params.push(query.startDate);
}
if (query.endDate) {
conditions.push(`t.date <= $${paramIndex++}`);
params.push(query.endDate);
}
if (query.type) {
conditions.push(`t.type = $${paramIndex++}`);
params.push(query.type);
}
if (query.categoryId) {
conditions.push(`t.category_id = $${paramIndex++}`);
params.push(query.categoryId);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const dataQuery = `
SELECT
t.id,
t.type,
t.amount,
t.currency,
t.description,
t.date,
t.reference,
t.notes,
t.status,
c.name as category_name,
co.name as contact_name,
co.rfc as contact_rfc
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN contacts co ON t.contact_id = co.id
${whereClause}
ORDER BY t.date DESC
`;
const result = await db.queryTenant(tenant, dataQuery, params);
if (query.format === 'csv') {
// Generate CSV
const headers = ['ID', 'Tipo', 'Monto', 'Moneda', 'Descripcion', 'Fecha', 'Referencia', 'Notas', 'Estado', 'Categoria', 'Contacto', 'RFC'];
const csvRows = [headers.join(',')];
for (const row of result.rows) {
const values = [
row.id,
row.type,
row.amount,
row.currency,
`"${(row.description || '').replace(/"/g, '""')}"`,
row.date,
row.reference || '',
`"${(row.notes || '').replace(/"/g, '""')}"`,
row.status,
row.category_name || '',
row.contact_name || '',
row.contact_rfc || '',
];
csvRows.push(values.join(','));
}
const csv = csvRows.join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="transacciones_${new Date().toISOString().split('T')[0]}.csv"`);
res.send(csv);
} else {
// For Excel, return JSON that can be converted client-side or by a worker
// In a production environment, you'd use a library like exceljs
const response: ApiResponse = {
success: true,
data: {
format: 'xlsx',
rows: result.rows,
headers: ['ID', 'Tipo', 'Monto', 'Moneda', 'Descripcion', 'Fecha', 'Referencia', 'Notas', 'Estado', 'Categoria', 'Contacto', 'RFC'],
},
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
}
} catch (error) {
next(error);
}
}
);
/**
* GET /api/transactions/:id
* Get a single transaction by ID
*/
router.get(
'/:id',
authenticate,
validate({ params: TransactionIdSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
const query = `
SELECT
t.*,
json_build_object('id', c.id, 'name', c.name, 'color', c.color, 'icon', c.icon) as category,
json_build_object('id', co.id, 'name', co.name, 'rfc', co.rfc, 'type', co.type, 'email', co.email) as contact,
json_build_object('id', cf.id, 'uuid', cf.uuid, 'folio', cf.folio, 'serie', cf.serie) as cfdi
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN contacts co ON t.contact_id = co.id
LEFT JOIN cfdis cf ON t.cfdi_id = cf.id
WHERE t.id = $1
`;
const result = await db.queryTenant(tenant, query, [id]);
if (result.rows.length === 0) {
throw new NotFoundError('Transaccion');
}
const response: ApiResponse = {
success: true,
data: result.rows[0],
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* POST /api/transactions
* Create a new manual transaction
*/
router.post(
'/',
authenticate,
validate({ body: CreateTransactionSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = req.body as z.infer<typeof CreateTransactionSchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Verify category exists if provided
if (data.categoryId) {
const categoryCheck = await db.queryTenant(
tenant,
'SELECT id FROM categories WHERE id = $1',
[data.categoryId]
);
if (categoryCheck.rows.length === 0) {
throw new ValidationError('Categoria no encontrada', { categoryId: 'Categoria no existe' });
}
}
// Verify contact exists if provided
if (data.contactId) {
const contactCheck = await db.queryTenant(
tenant,
'SELECT id FROM contacts WHERE id = $1',
[data.contactId]
);
if (contactCheck.rows.length === 0) {
throw new ValidationError('Contacto no encontrado', { contactId: 'Contacto no existe' });
}
}
const insertQuery = `
INSERT INTO transactions (
type, amount, currency, description, date,
category_id, contact_id, account_id, reference, notes, tags, metadata,
source, status, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'manual', 'pending', $13)
RETURNING *
`;
const result = await db.queryTenant(tenant, insertQuery, [
data.type,
data.amount,
data.currency,
data.description,
data.date,
data.categoryId || null,
data.contactId || null,
data.accountId || null,
data.reference || null,
data.notes || null,
data.tags || [],
data.metadata || {},
req.user!.sub,
]);
const response: ApiResponse = {
success: true,
data: result.rows[0],
meta: {
timestamp: new Date().toISOString(),
},
};
res.status(201).json(response);
} catch (error) {
next(error);
}
}
);
/**
* PUT /api/transactions/:id
* Update a transaction (category, notes, tags, status)
*/
router.put(
'/:id',
authenticate,
validate({ params: TransactionIdSchema, body: UpdateTransactionSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const data = req.body as z.infer<typeof UpdateTransactionSchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Check if transaction exists
const existingCheck = await db.queryTenant(
tenant,
'SELECT id FROM transactions WHERE id = $1',
[id]
);
if (existingCheck.rows.length === 0) {
throw new NotFoundError('Transaccion');
}
// Build update query dynamically
const updates: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (data.categoryId !== undefined) {
if (data.categoryId !== null) {
const categoryCheck = await db.queryTenant(
tenant,
'SELECT id FROM categories WHERE id = $1',
[data.categoryId]
);
if (categoryCheck.rows.length === 0) {
throw new ValidationError('Categoria no encontrada', { categoryId: 'Categoria no existe' });
}
}
updates.push(`category_id = $${paramIndex++}`);
params.push(data.categoryId);
}
if (data.notes !== undefined) {
updates.push(`notes = $${paramIndex++}`);
params.push(data.notes);
}
if (data.tags !== undefined) {
updates.push(`tags = $${paramIndex++}`);
params.push(data.tags);
}
if (data.status !== undefined) {
updates.push(`status = $${paramIndex++}`);
params.push(data.status);
}
if (data.metadata !== undefined) {
updates.push(`metadata = $${paramIndex++}`);
params.push(data.metadata);
}
if (updates.length === 0) {
throw new ValidationError('No hay campos para actualizar');
}
updates.push(`updated_at = NOW()`);
params.push(id);
const updateQuery = `
UPDATE transactions
SET ${updates.join(', ')}
WHERE id = $${paramIndex}
RETURNING *
`;
const result = await db.queryTenant(tenant, updateQuery, params);
const response: ApiResponse = {
success: true,
data: result.rows[0],
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
/**
* DELETE /api/transactions/:id
* Delete a transaction
*/
router.delete(
'/:id',
authenticate,
validate({ params: TransactionIdSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
// Check if transaction exists and is deletable (manual transactions only)
const existingCheck = await db.queryTenant(
tenant,
'SELECT id, source FROM transactions WHERE id = $1',
[id]
);
if (existingCheck.rows.length === 0) {
throw new NotFoundError('Transaccion');
}
const transaction = existingCheck.rows[0];
// Prevent deletion of CFDI-linked transactions
if (transaction.source === 'cfdi') {
throw new ValidationError('No se pueden eliminar transacciones generadas desde CFDI');
}
await db.queryTenant(tenant, 'DELETE FROM transactions WHERE id = $1', [id]);
const response: ApiResponse = {
success: true,
data: { deleted: true, id },
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
}
);
export default router;

View File

@@ -0,0 +1,754 @@
import bcrypt from 'bcryptjs';
import { Pool, PoolClient } from 'pg';
import { v4 as uuidv4 } from 'uuid';
import { config } from '../config/index.js';
import { jwtService, hashToken } from './jwt.service.js';
import {
User,
Tenant,
Session,
TokenPair,
RegisterInput,
LoginInput,
AppError,
AuthenticationError,
ConflictError,
NotFoundError,
ValidationError,
} from '../types/index.js';
import { logger, auditLog } from '../utils/logger.js';
// ============================================================================
// Database Pool
// ============================================================================
const pool = new Pool({
connectionString: config.database.url,
min: config.database.pool.min,
max: config.database.pool.max,
});
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Generate a slug from company name
*/
const generateSlug = (name: string): string => {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 50);
};
/**
* Generate a unique schema name for a tenant
*/
const generateSchemaName = (slug: string): string => {
const uniqueSuffix = uuidv4().split('-')[0];
return `tenant_${slug.replace(/-/g, '_')}_${uniqueSuffix}`;
};
/**
* Hash a password
*/
const hashPassword = async (password: string): Promise<string> => {
return bcrypt.hash(password, config.security.bcryptRounds);
};
/**
* Compare password with hash
*/
const comparePassword = async (password: string, hash: string): Promise<boolean> => {
return bcrypt.compare(password, hash);
};
// ============================================================================
// Auth Service
// ============================================================================
export class AuthService {
/**
* Register a new user and create their tenant
*/
async register(input: RegisterInput): Promise<{ user: Partial<User>; tokens: TokenPair }> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Check if email already exists
const existingUser = await client.query(
'SELECT id FROM public.users WHERE email = $1',
[input.email.toLowerCase()]
);
if (existingUser.rows.length > 0) {
throw new ConflictError('Este email ya esta registrado');
}
// Generate tenant info
const tenantId = uuidv4();
const slug = generateSlug(input.companyName);
const schemaName = generateSchemaName(slug);
// Get default plan (trial)
const defaultPlan = await client.query(
'SELECT id FROM public.plans WHERE slug = $1',
['trial']
);
let planId = defaultPlan.rows[0]?.id;
// If no trial plan exists, create a basic one
if (!planId) {
const newPlan = await client.query(
`INSERT INTO public.plans (id, name, slug, price_monthly, price_yearly, features, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
[
uuidv4(),
'Trial',
'trial',
0,
0,
JSON.stringify({ users: 3, rfcs: 1, reports: 5 }),
true,
]
);
planId = newPlan.rows[0]?.id;
}
// Create tenant
await client.query(
`INSERT INTO public.tenants (id, name, slug, schema_name, plan_id, is_active, settings, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())`,
[
tenantId,
input.companyName,
slug,
schemaName,
planId,
true,
JSON.stringify({
timezone: 'America/Mexico_City',
currency: 'MXN',
fiscal_year_start_month: 1,
language: 'es',
}),
]
);
// Create tenant schema
await this.createTenantSchema(client, schemaName);
// Hash password and create user
const userId = uuidv4();
const passwordHash = await hashPassword(input.password);
await client.query(
`INSERT INTO public.users (id, email, password_hash, first_name, last_name, role, tenant_id, is_active, email_verified, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())`,
[
userId,
input.email.toLowerCase(),
passwordHash,
input.firstName,
input.lastName,
'owner',
tenantId,
true,
false,
]
);
// Create session
const sessionId = uuidv4();
const tokens = jwtService.generateTokenPair(
{
id: userId,
email: input.email.toLowerCase(),
role: 'owner',
tenant_id: tenantId,
schema_name: schemaName,
},
sessionId
);
const refreshTokenHash = hashToken(tokens.refreshToken);
await client.query(
`INSERT INTO public.user_sessions (id, user_id, tenant_id, refresh_token_hash, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())`,
[sessionId, userId, tenantId, refreshTokenHash, jwtService.getRefreshTokenExpiration()]
);
await client.query('COMMIT');
auditLog('USER_REGISTERED', userId, tenantId, {
email: input.email,
companyName: input.companyName,
});
logger.info('User registered successfully', { userId, tenantId, email: input.email });
return {
user: {
id: userId,
email: input.email.toLowerCase(),
first_name: input.firstName,
last_name: input.lastName,
role: 'owner',
tenant_id: tenantId,
},
tokens,
};
} catch (error) {
await client.query('ROLLBACK');
logger.error('Registration failed', { error, email: input.email });
throw error;
} finally {
client.release();
}
}
/**
* Login a user
*/
async login(
input: LoginInput,
userAgent?: string,
ipAddress?: string
): Promise<{ user: Partial<User>; tenant: Partial<Tenant>; tokens: TokenPair }> {
const client = await pool.connect();
try {
// Find user by email
const userResult = await client.query(
`SELECT u.*, t.schema_name, t.name as tenant_name, t.slug as tenant_slug
FROM public.users u
JOIN public.tenants t ON u.tenant_id = t.id
WHERE u.email = $1 AND u.is_active = true AND t.is_active = true`,
[input.email.toLowerCase()]
);
const user = userResult.rows[0];
if (!user) {
auditLog('LOGIN_FAILED', null, null, { email: input.email, reason: 'user_not_found' }, false);
throw new AuthenticationError('Credenciales invalidas');
}
// Verify password
const isValidPassword = await comparePassword(input.password, user.password_hash);
if (!isValidPassword) {
auditLog('LOGIN_FAILED', user.id, user.tenant_id, { reason: 'invalid_password' }, false);
throw new AuthenticationError('Credenciales invalidas');
}
// Create session
const sessionId = uuidv4();
const tokens = jwtService.generateTokenPair(
{
id: user.id,
email: user.email,
role: user.role,
tenant_id: user.tenant_id,
schema_name: user.schema_name,
},
sessionId
);
const refreshTokenHash = hashToken(tokens.refreshToken);
await client.query(
`INSERT INTO public.user_sessions (id, user_id, tenant_id, refresh_token_hash, user_agent, ip_address, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
[
sessionId,
user.id,
user.tenant_id,
refreshTokenHash,
userAgent || null,
ipAddress || null,
jwtService.getRefreshTokenExpiration(),
]
);
// Update last login
await client.query('UPDATE public.users SET last_login_at = NOW() WHERE id = $1', [user.id]);
auditLog('LOGIN_SUCCESS', user.id, user.tenant_id, { userAgent, ipAddress });
logger.info('User logged in', { userId: user.id, tenantId: user.tenant_id });
return {
user: {
id: user.id,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
tenant_id: user.tenant_id,
},
tenant: {
id: user.tenant_id,
name: user.tenant_name,
slug: user.tenant_slug,
schema_name: user.schema_name,
},
tokens,
};
} finally {
client.release();
}
}
/**
* Refresh access token using refresh token
*/
async refreshToken(refreshToken: string): Promise<TokenPair> {
const client = await pool.connect();
try {
// Verify refresh token
const payload = jwtService.verifyRefreshToken(refreshToken);
const tokenHash = hashToken(refreshToken);
// Find session
const sessionResult = await client.query(
`SELECT s.*, u.email, u.role, u.is_active as user_active, t.schema_name, t.is_active as tenant_active
FROM public.user_sessions s
JOIN public.users u ON s.user_id = u.id
JOIN public.tenants t ON s.tenant_id = t.id
WHERE s.id = $1 AND s.refresh_token_hash = $2 AND s.expires_at > NOW()`,
[payload.session_id, tokenHash]
);
const session = sessionResult.rows[0];
if (!session) {
throw new AuthenticationError('Sesion invalida o expirada');
}
if (!session.user_active || !session.tenant_active) {
throw new AuthenticationError('Cuenta desactivada');
}
// Generate new token pair
const newSessionId = uuidv4();
const tokens = jwtService.generateTokenPair(
{
id: session.user_id,
email: session.email,
role: session.role,
tenant_id: session.tenant_id,
schema_name: session.schema_name,
},
newSessionId
);
const newRefreshTokenHash = hashToken(tokens.refreshToken);
// Delete old session and create new one (rotation)
await client.query('DELETE FROM public.user_sessions WHERE id = $1', [payload.session_id]);
await client.query(
`INSERT INTO public.user_sessions (id, user_id, tenant_id, refresh_token_hash, user_agent, ip_address, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
[
newSessionId,
session.user_id,
session.tenant_id,
newRefreshTokenHash,
session.user_agent,
session.ip_address,
jwtService.getRefreshTokenExpiration(),
]
);
logger.debug('Token refreshed', { userId: session.user_id });
return tokens;
} finally {
client.release();
}
}
/**
* Logout - invalidate session
*/
async logout(refreshToken: string): Promise<void> {
const client = await pool.connect();
try {
const payload = jwtService.verifyRefreshToken(refreshToken);
const tokenHash = hashToken(refreshToken);
const result = await client.query(
'DELETE FROM public.user_sessions WHERE id = $1 AND refresh_token_hash = $2 RETURNING user_id, tenant_id',
[payload.session_id, tokenHash]
);
if (result.rows[0]) {
auditLog('LOGOUT', result.rows[0].user_id, result.rows[0].tenant_id, {});
logger.info('User logged out', { userId: result.rows[0].user_id });
}
} catch (error) {
// Ignore token verification errors during logout
logger.debug('Logout with invalid token', { error });
} finally {
client.release();
}
}
/**
* Logout from all sessions
*/
async logoutAll(userId: string, tenantId: string): Promise<number> {
const result = await pool.query(
'DELETE FROM public.user_sessions WHERE user_id = $1 AND tenant_id = $2 RETURNING id',
[userId, tenantId]
);
auditLog('LOGOUT_ALL', userId, tenantId, { sessionsDeleted: result.rowCount });
logger.info('User logged out from all sessions', { userId, sessionsDeleted: result.rowCount });
return result.rowCount || 0;
}
/**
* Request password reset
*/
async requestPasswordReset(email: string): Promise<void> {
const client = await pool.connect();
try {
const userResult = await client.query(
'SELECT id, email, first_name FROM public.users WHERE email = $1 AND is_active = true',
[email.toLowerCase()]
);
const user = userResult.rows[0];
// Always return success to prevent email enumeration
if (!user) {
logger.debug('Password reset requested for non-existent email', { email });
return;
}
// Generate reset token
const resetToken = jwtService.generateResetToken(user.id, user.email);
// Store reset token hash
const tokenHash = hashToken(resetToken);
await client.query(
`INSERT INTO public.password_reset_tokens (id, user_id, token_hash, expires_at, created_at)
VALUES ($1, $2, $3, NOW() + INTERVAL '1 hour', NOW())
ON CONFLICT (user_id) DO UPDATE SET token_hash = $3, expires_at = NOW() + INTERVAL '1 hour', created_at = NOW()`,
[uuidv4(), user.id, tokenHash]
);
// TODO: Send email with reset link
// For now, log the token (REMOVE IN PRODUCTION)
logger.info('Password reset token generated', {
userId: user.id,
resetLink: `${config.isProduction ? 'https://app.horuxstrategy.com' : 'http://localhost:3000'}/reset-password?token=${resetToken}`,
});
auditLog('PASSWORD_RESET_REQUESTED', user.id, null, { email });
} finally {
client.release();
}
}
/**
* Reset password with token
*/
async resetPassword(token: string, newPassword: string): Promise<void> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Verify token
const { userId, email } = jwtService.verifyResetToken(token);
const tokenHash = hashToken(token);
// Verify token exists in database and not expired
const tokenResult = await client.query(
'SELECT id FROM public.password_reset_tokens WHERE user_id = $1 AND token_hash = $2 AND expires_at > NOW()',
[userId, tokenHash]
);
if (tokenResult.rows.length === 0) {
throw new ValidationError('Enlace de restablecimiento invalido o expirado');
}
// Hash new password
const passwordHash = await hashPassword(newPassword);
// Update password
await client.query('UPDATE public.users SET password_hash = $1, updated_at = NOW() WHERE id = $2', [
passwordHash,
userId,
]);
// Delete reset token
await client.query('DELETE FROM public.password_reset_tokens WHERE user_id = $1', [userId]);
// Invalidate all existing sessions
await client.query('DELETE FROM public.user_sessions WHERE user_id = $1', [userId]);
await client.query('COMMIT');
auditLog('PASSWORD_RESET_SUCCESS', userId, null, { email });
logger.info('Password reset successfully', { userId });
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* Change password (for authenticated users)
*/
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<void> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Get current password hash
const userResult = await client.query(
'SELECT password_hash, tenant_id FROM public.users WHERE id = $1',
[userId]
);
const user = userResult.rows[0];
if (!user) {
throw new NotFoundError('Usuario');
}
// Verify current password
const isValidPassword = await comparePassword(currentPassword, user.password_hash);
if (!isValidPassword) {
throw new ValidationError('La contrasena actual es incorrecta');
}
// Hash new password
const passwordHash = await hashPassword(newPassword);
// Update password
await client.query('UPDATE public.users SET password_hash = $1, updated_at = NOW() WHERE id = $2', [
passwordHash,
userId,
]);
await client.query('COMMIT');
auditLog('PASSWORD_CHANGED', userId, user.tenant_id, {});
logger.info('Password changed', { userId });
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* Get current user profile
*/
async getProfile(userId: string): Promise<Partial<User> & { tenant: Partial<Tenant> }> {
const result = await pool.query(
`SELECT u.id, u.email, u.first_name, u.last_name, u.role, u.tenant_id, u.email_verified, u.created_at, u.last_login_at,
t.id as tenant_id, t.name as tenant_name, t.slug as tenant_slug, t.schema_name
FROM public.users u
JOIN public.tenants t ON u.tenant_id = t.id
WHERE u.id = $1`,
[userId]
);
const user = result.rows[0];
if (!user) {
throw new NotFoundError('Usuario');
}
return {
id: user.id,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
tenant_id: user.tenant_id,
email_verified: user.email_verified,
created_at: user.created_at,
last_login_at: user.last_login_at,
tenant: {
id: user.tenant_id,
name: user.tenant_name,
slug: user.tenant_slug,
schema_name: user.schema_name,
},
};
}
/**
* Create tenant schema with all required tables
*/
private async createTenantSchema(client: PoolClient, schemaName: string): Promise<void> {
// Create schema
await client.query(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
// Create tenant-specific tables
await client.query(`
-- SAT Credentials
CREATE TABLE IF NOT EXISTS "${schemaName}".sat_credentials (
id UUID PRIMARY KEY,
rfc VARCHAR(13) NOT NULL UNIQUE,
certificate_data TEXT,
key_data_encrypted TEXT,
valid_from TIMESTAMP,
valid_to TIMESTAMP,
is_active BOOLEAN DEFAULT true,
last_sync_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Contacts (customers and suppliers)
CREATE TABLE IF NOT EXISTS "${schemaName}".contacts (
id UUID PRIMARY KEY,
type VARCHAR(20) NOT NULL, -- customer, supplier, both
name VARCHAR(255) NOT NULL,
rfc VARCHAR(13),
email VARCHAR(255),
phone VARCHAR(50),
address JSONB,
is_recurring_customer BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- CFDIs
CREATE TABLE IF NOT EXISTS "${schemaName}".cfdis (
id UUID PRIMARY KEY,
uuid_fiscal VARCHAR(36) UNIQUE NOT NULL,
type VARCHAR(10) NOT NULL, -- I=Ingreso, E=Egreso, P=Pago, N=Nomina, T=Traslado
series VARCHAR(25),
folio VARCHAR(40),
issue_date TIMESTAMP NOT NULL,
issuer_rfc VARCHAR(13) NOT NULL,
issuer_name VARCHAR(255),
receiver_rfc VARCHAR(13) NOT NULL,
receiver_name VARCHAR(255),
subtotal DECIMAL(18, 2),
discount DECIMAL(18, 2) DEFAULT 0,
total DECIMAL(18, 2) NOT NULL,
currency VARCHAR(3) DEFAULT 'MXN',
exchange_rate DECIMAL(18, 6) DEFAULT 1,
payment_method VARCHAR(3),
payment_form VARCHAR(2),
status VARCHAR(20) DEFAULT 'active', -- active, cancelled
xml_content TEXT,
is_incoming BOOLEAN NOT NULL, -- true = received, false = issued
contact_id UUID REFERENCES "${schemaName}".contacts(id),
paid_amount DECIMAL(18, 2) DEFAULT 0,
pending_amount DECIMAL(18, 2),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Transactions (unified model)
CREATE TABLE IF NOT EXISTS "${schemaName}".transactions (
id UUID PRIMARY KEY,
type VARCHAR(20) NOT NULL, -- income, expense, transfer
category_id UUID,
contact_id UUID REFERENCES "${schemaName}".contacts(id),
cfdi_id UUID REFERENCES "${schemaName}".cfdis(id),
description TEXT,
amount DECIMAL(18, 2) NOT NULL,
currency VARCHAR(3) DEFAULT 'MXN',
exchange_rate DECIMAL(18, 6) DEFAULT 1,
date DATE NOT NULL,
is_recurring BOOLEAN DEFAULT false,
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Metrics Cache
CREATE TABLE IF NOT EXISTS "${schemaName}".metrics_cache (
id UUID PRIMARY KEY,
metric_key VARCHAR(100) NOT NULL,
period_type VARCHAR(20) NOT NULL, -- daily, weekly, monthly, yearly
period_start DATE NOT NULL,
period_end DATE NOT NULL,
value DECIMAL(18, 4),
metadata JSONB,
calculated_at TIMESTAMP DEFAULT NOW(),
UNIQUE (metric_key, period_type, period_start)
);
-- Alerts
CREATE TABLE IF NOT EXISTS "${schemaName}".alerts (
id UUID PRIMARY KEY,
type VARCHAR(50) NOT NULL,
severity VARCHAR(20) DEFAULT 'info', -- info, warning, critical
title VARCHAR(255) NOT NULL,
message TEXT,
data JSONB,
is_read BOOLEAN DEFAULT false,
is_dismissed BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW()
);
-- Reports
CREATE TABLE IF NOT EXISTS "${schemaName}".reports (
id UUID PRIMARY KEY,
type VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
period_start DATE,
period_end DATE,
status VARCHAR(20) DEFAULT 'pending', -- pending, generating, completed, failed
file_path VARCHAR(500),
file_size INTEGER,
generated_at TIMESTAMP,
error_message TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- Settings
CREATE TABLE IF NOT EXISTS "${schemaName}".settings (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMP DEFAULT NOW()
);
-- Create indexes
CREATE INDEX IF NOT EXISTS idx_cfdis_issue_date ON "${schemaName}".cfdis(issue_date);
CREATE INDEX IF NOT EXISTS idx_cfdis_issuer_rfc ON "${schemaName}".cfdis(issuer_rfc);
CREATE INDEX IF NOT EXISTS idx_cfdis_receiver_rfc ON "${schemaName}".cfdis(receiver_rfc);
CREATE INDEX IF NOT EXISTS idx_cfdis_type ON "${schemaName}".cfdis(type);
CREATE INDEX IF NOT EXISTS idx_transactions_date ON "${schemaName}".transactions(date);
CREATE INDEX IF NOT EXISTS idx_transactions_type ON "${schemaName}".transactions(type);
CREATE INDEX IF NOT EXISTS idx_metrics_cache_key ON "${schemaName}".metrics_cache(metric_key, period_start);
`);
logger.info('Tenant schema created', { schemaName });
}
}
// Export singleton instance
export const authService = new AuthService();
export default authService;

View File

@@ -0,0 +1,17 @@
// Services exports
// Auth
export { authService, AuthService } from './auth.service.js';
export { jwtService, JwtService, hashToken, generateSecureToken } from './jwt.service.js';
// Metrics
export * from './metrics/metrics.types.js';
export * from './metrics/core.metrics.js';
export * from './metrics/startup.metrics.js';
export * from './metrics/enterprise.metrics.js';
export * from './metrics/metrics.cache.js';
// SAT
export * from './sat/sat.types.js';
export * from './sat/cfdi.parser.js';
export * from './sat/fiel.service.js';

View File

@@ -0,0 +1,289 @@
import jwt, { SignOptions, JwtPayload } from 'jsonwebtoken';
import crypto from 'crypto';
import { config } from '../config/index.js';
import {
AccessTokenPayload,
RefreshTokenPayload,
TokenPair,
UserRole,
AppError,
} from '../types/index.js';
import { logger } from '../utils/logger.js';
// ============================================================================
// Constants
// ============================================================================
const ACCESS_TOKEN_TYPE = 'access';
const REFRESH_TOKEN_TYPE = 'refresh';
const RESET_TOKEN_TYPE = 'reset';
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Parse duration string to milliseconds
* Supports: 15m, 1h, 7d, 30d
*/
const parseDuration = (duration: string): number => {
const match = duration.match(/^(\d+)([mhdw])$/);
if (!match) {
throw new Error(`Invalid duration format: ${duration}`);
}
const value = parseInt(match[1] ?? '0', 10);
const unit = match[2];
switch (unit) {
case 'm':
return value * 60 * 1000;
case 'h':
return value * 60 * 60 * 1000;
case 'd':
return value * 24 * 60 * 60 * 1000;
case 'w':
return value * 7 * 24 * 60 * 60 * 1000;
default:
throw new Error(`Unknown duration unit: ${unit}`);
}
};
/**
* Hash a token for secure storage
*/
export const hashToken = (token: string): string => {
return crypto.createHash('sha256').update(token).digest('hex');
};
/**
* Generate a secure random token
*/
export const generateSecureToken = (length: number = 32): string => {
return crypto.randomBytes(length).toString('hex');
};
// ============================================================================
// JWT Service
// ============================================================================
export class JwtService {
private readonly accessSecret: string;
private readonly refreshSecret: string;
private readonly resetSecret: string;
private readonly accessExpiresIn: string;
private readonly refreshExpiresIn: string;
private readonly resetExpiresIn: string;
constructor() {
this.accessSecret = config.jwt.accessSecret;
this.refreshSecret = config.jwt.refreshSecret;
this.resetSecret = config.passwordReset.secret;
this.accessExpiresIn = config.jwt.accessExpiresIn;
this.refreshExpiresIn = config.jwt.refreshExpiresIn;
this.resetExpiresIn = config.passwordReset.expiresIn;
}
/**
* Generate an access token
*/
generateAccessToken(payload: Omit<AccessTokenPayload, 'type'>): string {
const tokenPayload: AccessTokenPayload = {
...payload,
type: ACCESS_TOKEN_TYPE,
};
const options: SignOptions = {
expiresIn: this.accessExpiresIn,
issuer: 'horux-strategy',
audience: 'horux-strategy-api',
};
return jwt.sign(tokenPayload, this.accessSecret, options);
}
/**
* Generate a refresh token
*/
generateRefreshToken(userId: string, sessionId: string): string {
const tokenPayload: RefreshTokenPayload = {
sub: userId,
session_id: sessionId,
type: REFRESH_TOKEN_TYPE,
};
const options: SignOptions = {
expiresIn: this.refreshExpiresIn,
issuer: 'horux-strategy',
audience: 'horux-strategy-api',
};
return jwt.sign(tokenPayload, this.refreshSecret, options);
}
/**
* Generate both access and refresh tokens
*/
generateTokenPair(
user: {
id: string;
email: string;
role: UserRole;
tenant_id: string;
schema_name: string;
},
sessionId: string
): TokenPair {
const accessToken = this.generateAccessToken({
sub: user.id,
email: user.email,
role: user.role,
tenant_id: user.tenant_id,
schema_name: user.schema_name,
});
const refreshToken = this.generateRefreshToken(user.id, sessionId);
return {
accessToken,
refreshToken,
expiresIn: Math.floor(parseDuration(this.accessExpiresIn) / 1000),
};
}
/**
* Verify an access token
*/
verifyAccessToken(token: string): AccessTokenPayload {
try {
const decoded = jwt.verify(token, this.accessSecret, {
issuer: 'horux-strategy',
audience: 'horux-strategy-api',
}) as JwtPayload & AccessTokenPayload;
if (decoded.type !== ACCESS_TOKEN_TYPE) {
throw new AppError('Invalid token type', 'INVALID_TOKEN_TYPE', 401);
}
return decoded;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new AppError('Token expirado', 'TOKEN_EXPIRED', 401);
}
if (error instanceof jwt.JsonWebTokenError) {
throw new AppError('Token invalido', 'INVALID_TOKEN', 401);
}
throw error;
}
}
/**
* Verify a refresh token
*/
verifyRefreshToken(token: string): RefreshTokenPayload {
try {
const decoded = jwt.verify(token, this.refreshSecret, {
issuer: 'horux-strategy',
audience: 'horux-strategy-api',
}) as JwtPayload & RefreshTokenPayload;
if (decoded.type !== REFRESH_TOKEN_TYPE) {
throw new AppError('Invalid token type', 'INVALID_TOKEN_TYPE', 401);
}
return decoded;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new AppError('Refresh token expirado', 'REFRESH_TOKEN_EXPIRED', 401);
}
if (error instanceof jwt.JsonWebTokenError) {
throw new AppError('Refresh token invalido', 'INVALID_REFRESH_TOKEN', 401);
}
throw error;
}
}
/**
* Generate a password reset token
*/
generateResetToken(userId: string, email: string): string {
const payload = {
sub: userId,
email,
type: RESET_TOKEN_TYPE,
};
return jwt.sign(payload, this.resetSecret, {
expiresIn: this.resetExpiresIn,
issuer: 'horux-strategy',
});
}
/**
* Verify a password reset token
*/
verifyResetToken(token: string): { userId: string; email: string } {
try {
const decoded = jwt.verify(token, this.resetSecret, {
issuer: 'horux-strategy',
}) as JwtPayload & { sub: string; email: string; type: string };
if (decoded.type !== RESET_TOKEN_TYPE) {
throw new AppError('Invalid token type', 'INVALID_TOKEN_TYPE', 400);
}
return {
userId: decoded.sub,
email: decoded.email,
};
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new AppError(
'El enlace de restablecimiento ha expirado',
'RESET_TOKEN_EXPIRED',
400
);
}
if (error instanceof jwt.JsonWebTokenError) {
throw new AppError(
'Enlace de restablecimiento invalido',
'INVALID_RESET_TOKEN',
400
);
}
throw error;
}
}
/**
* Decode a token without verification (useful for debugging)
*/
decodeToken(token: string): JwtPayload | null {
try {
return jwt.decode(token) as JwtPayload | null;
} catch (error) {
logger.warn('Failed to decode token', { error });
return null;
}
}
/**
* Get the expiration time of the refresh token in Date
*/
getRefreshTokenExpiration(): Date {
const expiresInMs = parseDuration(this.refreshExpiresIn);
return new Date(Date.now() + expiresInMs);
}
/**
* Get the expiration time of the access token in seconds
*/
getAccessTokenExpiresIn(): number {
return Math.floor(parseDuration(this.accessExpiresIn) / 1000);
}
}
// Export singleton instance
export const jwtService = new JwtService();
export default jwtService;

View File

@@ -0,0 +1,729 @@
/**
* Anomaly Detector
*
* Detects anomalies in financial metrics using rule-based analysis.
* Rules implemented:
* - Significant variation (>20%)
* - Negative trend for 3+ months
* - Values outside expected range
* - Sudden spikes or drops
*/
import { DatabaseConnection } from '@horux/database';
import { v4 as uuidv4 } from 'uuid';
import {
MetricType,
MetricPeriod,
Anomaly,
AnomalyType,
AnomalySeverity,
AnomalyDetectionResult,
getPeriodDateRange,
getPreviousPeriod,
periodToString,
MetricValue,
} from './metrics.types';
import { MetricsService, createMetricsService } from './metrics.service';
// Configuration for anomaly detection rules
interface AnomalyRule {
type: AnomalyType;
threshold: number;
severity: AnomalySeverity;
enabled: boolean;
}
interface MetricRules {
significantVariation: AnomalyRule;
negativeTrend: AnomalyRule;
outOfRange: AnomalyRule;
suddenSpike: AnomalyRule;
suddenDrop: AnomalyRule;
}
// Default rules configuration
const DEFAULT_RULES: MetricRules = {
significantVariation: {
type: 'significant_variation',
threshold: 0.20, // 20% variation
severity: 'medium',
enabled: true,
},
negativeTrend: {
type: 'negative_trend',
threshold: 3, // 3 consecutive periods
severity: 'high',
enabled: true,
},
outOfRange: {
type: 'out_of_range',
threshold: 2, // 2 standard deviations
severity: 'high',
enabled: true,
},
suddenSpike: {
type: 'sudden_spike',
threshold: 0.50, // 50% increase
severity: 'medium',
enabled: true,
},
suddenDrop: {
type: 'sudden_drop',
threshold: 0.30, // 30% decrease
severity: 'high',
enabled: true,
},
};
// Metric-specific expected ranges
interface MetricRange {
min: number;
max: number;
isPercentage: boolean;
}
const METRIC_RANGES: Partial<Record<MetricType, MetricRange>> = {
churn_rate: { min: 0, max: 0.15, isPercentage: true },
current_ratio: { min: 1.0, max: 3.0, isPercentage: false },
quick_ratio: { min: 0.5, max: 2.0, isPercentage: false },
debt_ratio: { min: 0, max: 0.6, isPercentage: true },
ltv_cac_ratio: { min: 3.0, max: 10.0, isPercentage: false },
};
export class AnomalyDetector {
private db: DatabaseConnection;
private metricsService: MetricsService;
private rules: MetricRules;
constructor(
db: DatabaseConnection,
metricsService?: MetricsService,
rules?: Partial<MetricRules>
) {
this.db = db;
this.metricsService = metricsService || createMetricsService(db);
this.rules = { ...DEFAULT_RULES, ...rules };
}
/**
* Detect anomalies for a tenant in a given period
*/
async detectAnomalies(
tenantId: string,
period: MetricPeriod
): Promise<AnomalyDetectionResult> {
const anomalies: Anomaly[] = [];
// Get historical periods for trend analysis (last 6 periods)
const historicalPeriods = this.getHistoricalPeriods(period, 6);
// Metrics to analyze
const metricsToAnalyze: MetricType[] = [
'revenue',
'expenses',
'net_profit',
'cash_flow',
'accounts_receivable',
'accounts_payable',
'mrr',
'churn_rate',
'burn_rate',
'current_ratio',
'quick_ratio',
];
for (const metric of metricsToAnalyze) {
try {
const metricAnomalies = await this.analyzeMetric(
tenantId,
metric,
period,
historicalPeriods
);
anomalies.push(...metricAnomalies);
} catch (error) {
console.error(`Error analyzing ${metric}:`, error);
}
}
// Calculate health score (0-100)
const healthScore = this.calculateHealthScore(anomalies);
// Determine alert level
const alertLevel = this.determineAlertLevel(anomalies);
// Generate summary
const summary = this.generateSummary(anomalies, healthScore);
return {
tenantId,
period,
analyzedAt: new Date(),
anomalies,
healthScore,
alertLevel,
summary,
};
}
/**
* Analyze a specific metric for anomalies
*/
private async analyzeMetric(
tenantId: string,
metric: MetricType,
period: MetricPeriod,
historicalPeriods: MetricPeriod[]
): Promise<Anomaly[]> {
const anomalies: Anomaly[] = [];
// Get metric history
const history = await this.metricsService.getMetricHistory(
tenantId,
metric,
[...historicalPeriods, period]
);
if (history.periods.length < 2) {
return anomalies;
}
const values = history.periods.map(p => p.value.raw);
const currentValue = values[values.length - 1];
const previousValue = values[values.length - 2];
const historicalValues = values.slice(0, -1);
// Check significant variation
if (this.rules.significantVariation.enabled) {
const variation = this.checkSignificantVariation(
currentValue,
previousValue,
this.rules.significantVariation.threshold
);
if (variation) {
anomalies.push(this.createAnomaly(
metric,
period,
'significant_variation',
this.rules.significantVariation.severity,
currentValue,
previousValue,
variation.deviation,
variation.percentage
));
}
}
// Check negative trend
if (this.rules.negativeTrend.enabled && historicalValues.length >= 3) {
const trend = this.checkNegativeTrend(
values,
this.rules.negativeTrend.threshold as number
);
if (trend) {
anomalies.push(this.createAnomaly(
metric,
period,
'negative_trend',
this.rules.negativeTrend.severity,
currentValue,
historicalValues[0],
trend.deviation,
trend.percentage
));
}
}
// Check out of range
if (this.rules.outOfRange.enabled) {
const outOfRange = this.checkOutOfRange(
metric,
currentValue,
historicalValues,
this.rules.outOfRange.threshold
);
if (outOfRange) {
anomalies.push(this.createAnomaly(
metric,
period,
'out_of_range',
outOfRange.severity,
currentValue,
outOfRange.expectedValue,
outOfRange.deviation,
outOfRange.percentage
));
}
}
// Check sudden spike
if (this.rules.suddenSpike.enabled) {
const spike = this.checkSuddenSpike(
currentValue,
previousValue,
this.rules.suddenSpike.threshold
);
if (spike) {
anomalies.push(this.createAnomaly(
metric,
period,
'sudden_spike',
this.rules.suddenSpike.severity,
currentValue,
previousValue,
spike.deviation,
spike.percentage
));
}
}
// Check sudden drop
if (this.rules.suddenDrop.enabled) {
const drop = this.checkSuddenDrop(
currentValue,
previousValue,
this.rules.suddenDrop.threshold
);
if (drop) {
anomalies.push(this.createAnomaly(
metric,
period,
'sudden_drop',
this.rules.suddenDrop.severity,
currentValue,
previousValue,
drop.deviation,
drop.percentage
));
}
}
return anomalies;
}
/**
* Check for significant variation (>threshold)
*/
private checkSignificantVariation(
current: number,
previous: number,
threshold: number
): { deviation: number; percentage: number } | null {
if (previous === 0) return null;
const change = current - previous;
const percentage = Math.abs(change / previous);
if (percentage > threshold) {
return {
deviation: change,
percentage: percentage * 100,
};
}
return null;
}
/**
* Check for negative trend (consecutive decreases)
*/
private checkNegativeTrend(
values: number[],
minPeriods: number
): { deviation: number; percentage: number } | null {
if (values.length < minPeriods) return null;
let consecutiveDecreases = 0;
for (let i = values.length - 1; i > 0; i--) {
if (values[i] < values[i - 1]) {
consecutiveDecreases++;
} else {
break;
}
}
if (consecutiveDecreases >= minPeriods) {
const startValue = values[values.length - 1 - consecutiveDecreases];
const currentValue = values[values.length - 1];
const totalChange = currentValue - startValue;
const percentage = startValue !== 0 ? (totalChange / startValue) * 100 : 0;
return {
deviation: totalChange,
percentage: Math.abs(percentage),
};
}
return null;
}
/**
* Check if value is outside expected range
*/
private checkOutOfRange(
metric: MetricType,
current: number,
historical: number[],
stdDevThreshold: number
): { deviation: number; percentage: number; expectedValue: number; severity: AnomalySeverity } | null {
// Check against predefined ranges first
const range = METRIC_RANGES[metric];
if (range) {
const value = range.isPercentage ? current / 100 : current;
if (value < range.min || value > range.max) {
const expectedValue = (range.min + range.max) / 2;
const deviation = value - expectedValue;
return {
deviation,
percentage: Math.abs(deviation / expectedValue) * 100,
expectedValue: range.isPercentage ? expectedValue * 100 : expectedValue,
severity: value < range.min * 0.5 || value > range.max * 1.5 ? 'critical' : 'high',
};
}
}
// Check against historical standard deviation
if (historical.length < 3) return null;
const mean = historical.reduce((sum, v) => sum + v, 0) / historical.length;
const squaredDiffs = historical.map(v => Math.pow(v - mean, 2));
const stdDev = Math.sqrt(squaredDiffs.reduce((sum, d) => sum + d, 0) / historical.length);
if (stdDev === 0) return null;
const zScore = Math.abs((current - mean) / stdDev);
if (zScore > stdDevThreshold) {
return {
deviation: current - mean,
percentage: (Math.abs(current - mean) / mean) * 100,
expectedValue: mean,
severity: zScore > 3 ? 'critical' : 'high',
};
}
return null;
}
/**
* Check for sudden spike (rapid increase)
*/
private checkSuddenSpike(
current: number,
previous: number,
threshold: number
): { deviation: number; percentage: number } | null {
if (previous === 0 || previous < 0) return null;
const increase = current - previous;
const percentage = increase / previous;
if (percentage > threshold && increase > 0) {
return {
deviation: increase,
percentage: percentage * 100,
};
}
return null;
}
/**
* Check for sudden drop (rapid decrease)
*/
private checkSuddenDrop(
current: number,
previous: number,
threshold: number
): { deviation: number; percentage: number } | null {
if (previous === 0) return null;
const decrease = previous - current;
const percentage = decrease / Math.abs(previous);
if (percentage > threshold && decrease > 0) {
return {
deviation: -decrease,
percentage: percentage * 100,
};
}
return null;
}
/**
* Create an anomaly object
*/
private createAnomaly(
metric: MetricType,
period: MetricPeriod,
type: AnomalyType,
severity: AnomalySeverity,
currentValue: number,
expectedValue: number,
deviation: number,
deviationPercentage: number
): Anomaly {
return {
id: uuidv4(),
metric,
type,
severity,
description: this.getAnomalyDescription(metric, type, deviationPercentage),
detectedAt: new Date(),
period,
currentValue,
expectedValue,
deviation,
deviationPercentage,
recommendation: this.getAnomalyRecommendation(metric, type, deviationPercentage),
};
}
/**
* Get human-readable description for an anomaly
*/
private getAnomalyDescription(
metric: MetricType,
type: AnomalyType,
percentage: number
): string {
const metricNames: Record<MetricType, string> = {
revenue: 'Ingresos',
expenses: 'Gastos',
gross_profit: 'Utilidad Bruta',
net_profit: 'Utilidad Neta',
cash_flow: 'Flujo de Efectivo',
accounts_receivable: 'Cuentas por Cobrar',
accounts_payable: 'Cuentas por Pagar',
aging_receivable: 'Antiguedad de Cuentas por Cobrar',
aging_payable: 'Antiguedad de Cuentas por Pagar',
vat_position: 'Posicion de IVA',
mrr: 'MRR',
arr: 'ARR',
churn_rate: 'Tasa de Churn',
cac: 'CAC',
ltv: 'LTV',
ltv_cac_ratio: 'Ratio LTV/CAC',
runway: 'Runway',
burn_rate: 'Burn Rate',
ebitda: 'EBITDA',
roi: 'ROI',
roe: 'ROE',
current_ratio: 'Ratio Corriente',
quick_ratio: 'Prueba Acida',
debt_ratio: 'Ratio de Deuda',
};
const metricName = metricNames[metric] || metric;
switch (type) {
case 'significant_variation':
return `${metricName} mostro una variacion significativa del ${percentage.toFixed(1)}% respecto al periodo anterior.`;
case 'negative_trend':
return `${metricName} ha mostrado una tendencia negativa durante los ultimos 3+ periodos, con una caida acumulada del ${percentage.toFixed(1)}%.`;
case 'out_of_range':
return `${metricName} esta fuera del rango esperado, desviandose ${percentage.toFixed(1)}% del valor tipico.`;
case 'sudden_spike':
return `${metricName} experimento un incremento repentino del ${percentage.toFixed(1)}%.`;
case 'sudden_drop':
return `${metricName} experimento una caida repentina del ${percentage.toFixed(1)}%.`;
default:
return `Anomalia detectada en ${metricName}.`;
}
}
/**
* Get recommendation for an anomaly
*/
private getAnomalyRecommendation(
metric: MetricType,
type: AnomalyType,
percentage: number
): string {
// Metric-specific recommendations
const recommendations: Partial<Record<MetricType, Record<AnomalyType, string>>> = {
revenue: {
sudden_drop: 'Revisa si hubo problemas con facturacion, clientes perdidos o estacionalidad. Considera contactar a clientes principales.',
negative_trend: 'Evalua tu estrategia de ventas y marketing. Considera revisar precios o expandir a nuevos mercados.',
},
expenses: {
sudden_spike: 'Revisa gastos recientes para identificar la causa. Verifica si son gastos unicos o recurrentes.',
significant_variation: 'Analiza las categorias de gastos que mas contribuyeron al cambio.',
},
cash_flow: {
sudden_drop: 'Revisa cuentas por cobrar vencidas y acelera la cobranza. Considera renegociar terminos con proveedores.',
negative_trend: 'Mejora la gestion de capital de trabajo. Evalua opciones de financiamiento.',
},
churn_rate: {
out_of_range: 'Implementa encuestas de salida con clientes. Revisa el proceso de onboarding y soporte.',
sudden_spike: 'Contacta urgentemente a clientes en riesgo. Revisa cambios recientes en producto o servicio.',
},
current_ratio: {
out_of_range: 'Mejora la gestion de activos corrientes o reduce pasivos a corto plazo.',
},
burn_rate: {
sudden_spike: 'Revisa y optimiza gastos operativos. Prioriza inversiones criticas.',
significant_variation: 'Ajusta el presupuesto y considera reducir gastos no esenciales.',
},
};
const metricRecs = recommendations[metric];
if (metricRecs && metricRecs[type]) {
return metricRecs[type];
}
// Default recommendations by type
switch (type) {
case 'significant_variation':
return 'Investiga las causas de la variacion y determina si requiere accion correctiva.';
case 'negative_trend':
return 'Analiza los factores que contribuyen a la tendencia negativa y desarrolla un plan de accion.';
case 'out_of_range':
return 'Revisa si el valor esta justificado por circunstancias especiales o requiere correccion.';
case 'sudden_spike':
return 'Verifica si el incremento es sostenible o si indica un problema subyacente.';
case 'sudden_drop':
return 'Investiga la causa de la caida y toma acciones correctivas si es necesario.';
default:
return 'Revisa la metrica y toma las acciones necesarias.';
}
}
/**
* Calculate overall health score (0-100)
*/
private calculateHealthScore(anomalies: Anomaly[]): number {
if (anomalies.length === 0) {
return 100;
}
const severityWeights: Record<AnomalySeverity, number> = {
low: 5,
medium: 15,
high: 25,
critical: 40,
};
let totalPenalty = 0;
for (const anomaly of anomalies) {
totalPenalty += severityWeights[anomaly.severity];
}
return Math.max(0, 100 - totalPenalty);
}
/**
* Determine alert level based on anomalies
*/
private determineAlertLevel(anomalies: Anomaly[]): AnomalyDetectionResult['alertLevel'] {
if (anomalies.length === 0) {
return 'none';
}
const hasCritical = anomalies.some(a => a.severity === 'critical');
const hasHigh = anomalies.some(a => a.severity === 'high');
const highCount = anomalies.filter(a => a.severity === 'high' || a.severity === 'critical').length;
if (hasCritical || highCount >= 3) {
return 'critical';
}
if (hasHigh || anomalies.length >= 3) {
return 'warning';
}
return 'watch';
}
/**
* Generate summary of anomalies
*/
private generateSummary(anomalies: Anomaly[], healthScore: number): string {
if (anomalies.length === 0) {
return 'No se detectaron anomalias. Todas las metricas estan dentro de los rangos esperados.';
}
const criticalCount = anomalies.filter(a => a.severity === 'critical').length;
const highCount = anomalies.filter(a => a.severity === 'high').length;
const mediumCount = anomalies.filter(a => a.severity === 'medium').length;
const parts: string[] = [];
parts.push(`Se detectaron ${anomalies.length} anomalias.`);
if (criticalCount > 0) {
parts.push(`${criticalCount} criticas.`);
}
if (highCount > 0) {
parts.push(`${highCount} de alta prioridad.`);
}
if (mediumCount > 0) {
parts.push(`${mediumCount} de prioridad media.`);
}
parts.push(`Puntuacion de salud: ${healthScore}/100.`);
// Add most critical anomaly
const mostCritical = anomalies.sort((a, b) => {
const order: Record<AnomalySeverity, number> = { critical: 0, high: 1, medium: 2, low: 3 };
return order[a.severity] - order[b.severity];
})[0];
if (mostCritical) {
parts.push(`Prioridad: ${mostCritical.description}`);
}
return parts.join(' ');
}
/**
* Get historical periods for analysis
*/
private getHistoricalPeriods(period: MetricPeriod, count: number): MetricPeriod[] {
const periods: MetricPeriod[] = [];
let currentPeriod = period;
for (let i = 0; i < count; i++) {
currentPeriod = getPreviousPeriod(currentPeriod);
periods.unshift(currentPeriod);
}
return periods;
}
/**
* Update detection rules
*/
updateRules(rules: Partial<MetricRules>): void {
this.rules = { ...this.rules, ...rules };
}
/**
* Get current rules configuration
*/
getRules(): MetricRules {
return { ...this.rules };
}
}
// Factory functions
let anomalyDetectorInstance: AnomalyDetector | null = null;
export function getAnomalyDetector(
db: DatabaseConnection,
metricsService?: MetricsService,
rules?: Partial<MetricRules>
): AnomalyDetector {
if (!anomalyDetectorInstance) {
anomalyDetectorInstance = new AnomalyDetector(db, metricsService, rules);
}
return anomalyDetectorInstance;
}
export function createAnomalyDetector(
db: DatabaseConnection,
metricsService?: MetricsService,
rules?: Partial<MetricRules>
): AnomalyDetector {
return new AnomalyDetector(db, metricsService, rules);
}

View File

@@ -0,0 +1,891 @@
/**
* Core Metrics Calculator
*
* Fundamental financial metrics for any business:
* - Revenue, Expenses, Profit calculations
* - Cash Flow analysis
* - Accounts Receivable/Payable
* - Aging Reports
* - VAT Position
*/
import { DatabaseConnection, TenantContext } from '@horux/database';
import {
RevenueResult,
ExpensesResult,
ProfitResult,
CashFlowResult,
AccountsReceivableResult,
AccountsPayableResult,
AgingReportResult,
AgingBucket,
AgingBucketData,
VATPositionResult,
createMonetaryValue,
createPercentageValue,
MetricQueryOptions,
} from './metrics.types';
export class CoreMetricsCalculator {
private db: DatabaseConnection;
constructor(db: DatabaseConnection) {
this.db = db;
}
/**
* Get tenant context for queries
*/
private getTenantContext(tenantId: string, schemaName?: string): TenantContext {
return {
tenantId,
schemaName: schemaName || `tenant_${tenantId}`,
};
}
/**
* Calculate total revenue for a period
*/
async calculateRevenue(
tenantId: string,
dateFrom: Date,
dateTo: Date,
options?: MetricQueryOptions
): Promise<RevenueResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
// Get total revenue from invoices
const totalQuery = await this.db.query<{
total_revenue: string;
invoice_count: string;
avg_invoice_value: string;
}>(
`SELECT
COALESCE(SUM(total_amount), 0) as total_revenue,
COUNT(*) as invoice_count,
COALESCE(AVG(total_amount), 0) as avg_invoice_value
FROM invoices
WHERE status IN ('paid', 'partial')
AND issue_date >= $1
AND issue_date <= $2`,
[dateFrom, dateTo],
{ tenant }
);
// Get revenue by category
const byCategoryQuery = await this.db.query<{
category_id: string;
category_name: string;
amount: string;
}>(
`SELECT
COALESCE(c.id, 'uncategorized') as category_id,
COALESCE(c.name, 'Sin categorizar') as category_name,
SUM(il.total) as amount
FROM invoice_lines il
JOIN invoices i ON i.id = il.invoice_id
LEFT JOIN products p ON p.id = il.product_id
LEFT JOIN categories c ON c.id = p.category_id
WHERE i.status IN ('paid', 'partial')
AND i.issue_date >= $1
AND i.issue_date <= $2
GROUP BY c.id, c.name
ORDER BY amount DESC`,
[dateFrom, dateTo],
{ tenant }
);
// Get revenue by product (optional)
let byProduct: RevenueResult['byProduct'] = undefined;
if (options?.includeDetails) {
const byProductQuery = await this.db.query<{
product_id: string;
product_name: string;
amount: string;
quantity: string;
}>(
`SELECT
COALESCE(p.id, 'service') as product_id,
COALESCE(p.name, il.description) as product_name,
SUM(il.total) as amount,
SUM(il.quantity) as quantity
FROM invoice_lines il
JOIN invoices i ON i.id = il.invoice_id
LEFT JOIN products p ON p.id = il.product_id
WHERE i.status IN ('paid', 'partial')
AND i.issue_date >= $1
AND i.issue_date <= $2
GROUP BY p.id, p.name, il.description
ORDER BY amount DESC
LIMIT 20`,
[dateFrom, dateTo],
{ tenant }
);
byProduct = byProductQuery.rows.map(row => ({
productId: row.product_id,
productName: row.product_name,
amount: createMonetaryValue(parseFloat(row.amount), currency),
quantity: parseFloat(row.quantity),
}));
}
const totalRevenue = parseFloat(totalQuery.rows[0]?.total_revenue || '0');
const invoiceCount = parseInt(totalQuery.rows[0]?.invoice_count || '0');
const avgInvoiceValue = parseFloat(totalQuery.rows[0]?.avg_invoice_value || '0');
const byCategory = byCategoryQuery.rows.map(row => {
const amount = parseFloat(row.amount);
return {
categoryId: row.category_id,
categoryName: row.category_name,
amount: createMonetaryValue(amount, currency),
percentage: totalRevenue > 0 ? (amount / totalRevenue) * 100 : 0,
};
});
return {
totalRevenue: createMonetaryValue(totalRevenue, currency),
byCategory,
byProduct,
invoiceCount,
averageInvoiceValue: createMonetaryValue(avgInvoiceValue, currency),
};
}
/**
* Calculate total expenses for a period
*/
async calculateExpenses(
tenantId: string,
dateFrom: Date,
dateTo: Date,
options?: MetricQueryOptions
): Promise<ExpensesResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
// Get total expenses
const totalQuery = await this.db.query<{
total_expenses: string;
expense_count: string;
}>(
`SELECT
COALESCE(SUM(total_amount), 0) as total_expenses,
COUNT(*) as expense_count
FROM expenses
WHERE status = 'paid'
AND expense_date >= $1
AND expense_date <= $2`,
[dateFrom, dateTo],
{ tenant }
);
// Get expenses by category
const byCategoryQuery = await this.db.query<{
category_id: string;
category_name: string;
amount: string;
is_fixed: boolean;
}>(
`SELECT
COALESCE(c.id, 'uncategorized') as category_id,
COALESCE(c.name, 'Sin categorizar') as category_name,
SUM(e.total_amount) as amount,
COALESCE(c.is_fixed, false) as is_fixed
FROM expenses e
LEFT JOIN categories c ON c.id = e.category_id
WHERE e.status = 'paid'
AND e.expense_date >= $1
AND e.expense_date <= $2
GROUP BY c.id, c.name, c.is_fixed
ORDER BY amount DESC`,
[dateFrom, dateTo],
{ tenant }
);
const totalExpenses = parseFloat(totalQuery.rows[0]?.total_expenses || '0');
const expenseCount = parseInt(totalQuery.rows[0]?.expense_count || '0');
let fixedExpenses = 0;
let variableExpenses = 0;
const byCategory = byCategoryQuery.rows.map(row => {
const amount = parseFloat(row.amount);
if (row.is_fixed) {
fixedExpenses += amount;
} else {
variableExpenses += amount;
}
return {
categoryId: row.category_id,
categoryName: row.category_name,
amount: createMonetaryValue(amount, currency),
percentage: totalExpenses > 0 ? (amount / totalExpenses) * 100 : 0,
};
});
return {
totalExpenses: createMonetaryValue(totalExpenses, currency),
byCategory,
fixedExpenses: createMonetaryValue(fixedExpenses, currency),
variableExpenses: createMonetaryValue(variableExpenses, currency),
expenseCount,
};
}
/**
* Calculate gross profit (Revenue - COGS)
*/
async calculateGrossProfit(
tenantId: string,
dateFrom: Date,
dateTo: Date,
options?: MetricQueryOptions
): Promise<ProfitResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
const query = await this.db.query<{
revenue: string;
cogs: string;
}>(
`WITH revenue AS (
SELECT COALESCE(SUM(total_amount), 0) as amount
FROM invoices
WHERE status IN ('paid', 'partial')
AND issue_date >= $1
AND issue_date <= $2
),
cogs AS (
SELECT COALESCE(SUM(e.total_amount), 0) as amount
FROM expenses e
JOIN categories c ON c.id = e.category_id
WHERE e.status = 'paid'
AND e.expense_date >= $1
AND e.expense_date <= $2
AND c.type = 'cogs'
)
SELECT
(SELECT amount FROM revenue) as revenue,
(SELECT amount FROM cogs) as cogs`,
[dateFrom, dateTo],
{ tenant }
);
const revenue = parseFloat(query.rows[0]?.revenue || '0');
const costs = parseFloat(query.rows[0]?.cogs || '0');
const profit = revenue - costs;
const margin = revenue > 0 ? profit / revenue : 0;
return {
profit: createMonetaryValue(profit, currency),
revenue: createMonetaryValue(revenue, currency),
costs: createMonetaryValue(costs, currency),
margin: createPercentageValue(margin),
};
}
/**
* Calculate net profit (Revenue - All Expenses)
*/
async calculateNetProfit(
tenantId: string,
dateFrom: Date,
dateTo: Date,
options?: MetricQueryOptions
): Promise<ProfitResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
const query = await this.db.query<{
revenue: string;
expenses: string;
}>(
`WITH revenue AS (
SELECT COALESCE(SUM(total_amount), 0) as amount
FROM invoices
WHERE status IN ('paid', 'partial')
AND issue_date >= $1
AND issue_date <= $2
),
expenses AS (
SELECT COALESCE(SUM(total_amount), 0) as amount
FROM expenses
WHERE status = 'paid'
AND expense_date >= $1
AND expense_date <= $2
)
SELECT
(SELECT amount FROM revenue) as revenue,
(SELECT amount FROM expenses) as expenses`,
[dateFrom, dateTo],
{ tenant }
);
const revenue = parseFloat(query.rows[0]?.revenue || '0');
const costs = parseFloat(query.rows[0]?.expenses || '0');
const profit = revenue - costs;
const margin = revenue > 0 ? profit / revenue : 0;
return {
profit: createMonetaryValue(profit, currency),
revenue: createMonetaryValue(revenue, currency),
costs: createMonetaryValue(costs, currency),
margin: createPercentageValue(margin),
};
}
/**
* Calculate cash flow for a period
*/
async calculateCashFlow(
tenantId: string,
dateFrom: Date,
dateTo: Date,
options?: MetricQueryOptions
): Promise<CashFlowResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
// Get opening balance
const openingQuery = await this.db.query<{ balance: string }>(
`SELECT COALESCE(SUM(balance), 0) as balance
FROM bank_accounts
WHERE is_active = true`,
[],
{ tenant }
);
// Get cash inflows (payments received)
const inflowQuery = await this.db.query<{
operating: string;
investing: string;
financing: string;
}>(
`WITH payments AS (
SELECT
p.amount,
COALESCE(c.cash_flow_type, 'operating') as flow_type
FROM payments p
LEFT JOIN categories c ON c.id = p.category_id
WHERE p.type = 'income'
AND p.payment_date >= $1
AND p.payment_date <= $2
)
SELECT
COALESCE(SUM(CASE WHEN flow_type = 'operating' THEN amount END), 0) as operating,
COALESCE(SUM(CASE WHEN flow_type = 'investing' THEN amount END), 0) as investing,
COALESCE(SUM(CASE WHEN flow_type = 'financing' THEN amount END), 0) as financing
FROM payments`,
[dateFrom, dateTo],
{ tenant }
);
// Get cash outflows (payments made)
const outflowQuery = await this.db.query<{
operating: string;
investing: string;
financing: string;
}>(
`WITH payments AS (
SELECT
p.amount,
COALESCE(c.cash_flow_type, 'operating') as flow_type
FROM payments p
LEFT JOIN categories c ON c.id = p.category_id
WHERE p.type = 'expense'
AND p.payment_date >= $1
AND p.payment_date <= $2
)
SELECT
COALESCE(SUM(CASE WHEN flow_type = 'operating' THEN amount END), 0) as operating,
COALESCE(SUM(CASE WHEN flow_type = 'investing' THEN amount END), 0) as investing,
COALESCE(SUM(CASE WHEN flow_type = 'financing' THEN amount END), 0) as financing
FROM payments`,
[dateFrom, dateTo],
{ tenant }
);
// Get daily breakdown
const breakdownQuery = await this.db.query<{
date: Date;
inflow: string;
outflow: string;
}>(
`SELECT
DATE(p.payment_date) as date,
COALESCE(SUM(CASE WHEN p.type = 'income' THEN p.amount ELSE 0 END), 0) as inflow,
COALESCE(SUM(CASE WHEN p.type = 'expense' THEN p.amount ELSE 0 END), 0) as outflow
FROM payments p
WHERE p.payment_date >= $1
AND p.payment_date <= $2
GROUP BY DATE(p.payment_date)
ORDER BY date`,
[dateFrom, dateTo],
{ tenant }
);
const inflow = inflowQuery.rows[0];
const outflow = outflowQuery.rows[0];
const operatingInflow = parseFloat(inflow?.operating || '0');
const investingInflow = parseFloat(inflow?.investing || '0');
const financingInflow = parseFloat(inflow?.financing || '0');
const operatingOutflow = parseFloat(outflow?.operating || '0');
const investingOutflow = parseFloat(outflow?.investing || '0');
const financingOutflow = parseFloat(outflow?.financing || '0');
const operatingActivities = operatingInflow - operatingOutflow;
const investingActivities = investingInflow - investingOutflow;
const financingActivities = financingInflow - financingOutflow;
const netCashFlow = operatingActivities + investingActivities + financingActivities;
const openingBalance = parseFloat(openingQuery.rows[0]?.balance || '0');
const closingBalance = openingBalance + netCashFlow;
// Build breakdown with running balance
let runningBalance = openingBalance;
const breakdown = breakdownQuery.rows.map(row => {
const inflowAmount = parseFloat(row.inflow);
const outflowAmount = parseFloat(row.outflow);
const netFlow = inflowAmount - outflowAmount;
runningBalance += netFlow;
return {
date: row.date,
inflow: inflowAmount,
outflow: outflowAmount,
netFlow,
balance: runningBalance,
};
});
return {
netCashFlow: createMonetaryValue(netCashFlow, currency),
operatingActivities: createMonetaryValue(operatingActivities, currency),
investingActivities: createMonetaryValue(investingActivities, currency),
financingActivities: createMonetaryValue(financingActivities, currency),
openingBalance: createMonetaryValue(openingBalance, currency),
closingBalance: createMonetaryValue(closingBalance, currency),
breakdown,
};
}
/**
* Calculate accounts receivable as of a specific date
*/
async calculateAccountsReceivable(
tenantId: string,
asOfDate: Date,
options?: MetricQueryOptions
): Promise<AccountsReceivableResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
const query = await this.db.query<{
total_receivable: string;
current_amount: string;
overdue_amount: string;
customer_count: string;
invoice_count: string;
avg_days_outstanding: string;
}>(
`WITH receivables AS (
SELECT
i.id,
i.customer_id,
i.total_amount - COALESCE(
(SELECT SUM(p.amount) FROM payments p WHERE p.invoice_id = i.id),
0
) as balance,
i.due_date,
EXTRACT(DAY FROM ($1::date - i.issue_date)) as days_outstanding
FROM invoices i
WHERE i.status IN ('sent', 'partial')
AND i.issue_date <= $1
)
SELECT
COALESCE(SUM(balance), 0) as total_receivable,
COALESCE(SUM(CASE WHEN due_date >= $1 THEN balance ELSE 0 END), 0) as current_amount,
COALESCE(SUM(CASE WHEN due_date < $1 THEN balance ELSE 0 END), 0) as overdue_amount,
COUNT(DISTINCT customer_id) as customer_count,
COUNT(*) as invoice_count,
COALESCE(AVG(days_outstanding), 0) as avg_days_outstanding
FROM receivables
WHERE balance > 0`,
[asOfDate],
{ tenant }
);
const row = query.rows[0];
const totalReceivable = parseFloat(row?.total_receivable || '0');
const current = parseFloat(row?.current_amount || '0');
const overdue = parseFloat(row?.overdue_amount || '0');
const customerCount = parseInt(row?.customer_count || '0');
const invoiceCount = parseInt(row?.invoice_count || '0');
const avgDaysOutstanding = parseFloat(row?.avg_days_outstanding || '0');
const overduePercentage = totalReceivable > 0 ? overdue / totalReceivable : 0;
return {
totalReceivable: createMonetaryValue(totalReceivable, currency),
current: createMonetaryValue(current, currency),
overdue: createMonetaryValue(overdue, currency),
overduePercentage: createPercentageValue(overduePercentage),
customerCount,
invoiceCount,
averageDaysOutstanding: Math.round(avgDaysOutstanding),
};
}
/**
* Calculate accounts payable as of a specific date
*/
async calculateAccountsPayable(
tenantId: string,
asOfDate: Date,
options?: MetricQueryOptions
): Promise<AccountsPayableResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
const query = await this.db.query<{
total_payable: string;
current_amount: string;
overdue_amount: string;
supplier_count: string;
invoice_count: string;
avg_days_payable: string;
}>(
`WITH payables AS (
SELECT
b.id,
b.supplier_id,
b.total_amount - COALESCE(
(SELECT SUM(p.amount) FROM payments p WHERE p.bill_id = b.id),
0
) as balance,
b.due_date,
EXTRACT(DAY FROM ($1::date - b.issue_date)) as days_payable
FROM bills b
WHERE b.status IN ('pending', 'partial')
AND b.issue_date <= $1
)
SELECT
COALESCE(SUM(balance), 0) as total_payable,
COALESCE(SUM(CASE WHEN due_date >= $1 THEN balance ELSE 0 END), 0) as current_amount,
COALESCE(SUM(CASE WHEN due_date < $1 THEN balance ELSE 0 END), 0) as overdue_amount,
COUNT(DISTINCT supplier_id) as supplier_count,
COUNT(*) as invoice_count,
COALESCE(AVG(days_payable), 0) as avg_days_payable
FROM payables
WHERE balance > 0`,
[asOfDate],
{ tenant }
);
const row = query.rows[0];
const totalPayable = parseFloat(row?.total_payable || '0');
const current = parseFloat(row?.current_amount || '0');
const overdue = parseFloat(row?.overdue_amount || '0');
const supplierCount = parseInt(row?.supplier_count || '0');
const invoiceCount = parseInt(row?.invoice_count || '0');
const avgDaysPayable = parseFloat(row?.avg_days_payable || '0');
const overduePercentage = totalPayable > 0 ? overdue / totalPayable : 0;
return {
totalPayable: createMonetaryValue(totalPayable, currency),
current: createMonetaryValue(current, currency),
overdue: createMonetaryValue(overdue, currency),
overduePercentage: createPercentageValue(overduePercentage),
supplierCount,
invoiceCount,
averageDaysPayable: Math.round(avgDaysPayable),
};
}
/**
* Generate aging report for receivables or payables
*/
async calculateAgingReport(
tenantId: string,
type: 'receivable' | 'payable',
asOfDate: Date,
options?: MetricQueryOptions
): Promise<AgingReportResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
const table = type === 'receivable' ? 'invoices' : 'bills';
const entityField = type === 'receivable' ? 'customer_id' : 'supplier_id';
const entityTable = type === 'receivable' ? 'customers' : 'suppliers';
const statusFilter = type === 'receivable'
? `status IN ('sent', 'partial')`
: `status IN ('pending', 'partial')`;
const paymentField = type === 'receivable' ? 'invoice_id' : 'bill_id';
// Get aging buckets
const bucketsQuery = await this.db.query<{
bucket: string;
amount: string;
count: string;
}>(
`WITH items AS (
SELECT
t.id,
t.total_amount - COALESCE(
(SELECT SUM(p.amount) FROM payments p WHERE p.${paymentField} = t.id),
0
) as balance,
EXTRACT(DAY FROM ($1::date - t.due_date)) as days_overdue
FROM ${table} t
WHERE t.${statusFilter}
AND t.issue_date <= $1
)
SELECT
CASE
WHEN days_overdue <= 0 THEN 'current'
WHEN days_overdue BETWEEN 1 AND 30 THEN '1-30'
WHEN days_overdue BETWEEN 31 AND 60 THEN '31-60'
WHEN days_overdue BETWEEN 61 AND 90 THEN '61-90'
ELSE '90+'
END as bucket,
COALESCE(SUM(balance), 0) as amount,
COUNT(*) as count
FROM items
WHERE balance > 0
GROUP BY
CASE
WHEN days_overdue <= 0 THEN 'current'
WHEN days_overdue BETWEEN 1 AND 30 THEN '1-30'
WHEN days_overdue BETWEEN 31 AND 60 THEN '31-60'
WHEN days_overdue BETWEEN 61 AND 90 THEN '61-90'
ELSE '90+'
END`,
[asOfDate],
{ tenant }
);
// Get detailed breakdown by entity
const detailsQuery = await this.db.query<{
entity_id: string;
entity_name: string;
bucket: string;
amount: string;
}>(
`WITH items AS (
SELECT
t.id,
t.${entityField},
e.name as entity_name,
t.total_amount - COALESCE(
(SELECT SUM(p.amount) FROM payments p WHERE p.${paymentField} = t.id),
0
) as balance,
EXTRACT(DAY FROM ($1::date - t.due_date)) as days_overdue
FROM ${table} t
JOIN ${entityTable} e ON e.id = t.${entityField}
WHERE t.${statusFilter}
AND t.issue_date <= $1
)
SELECT
${entityField} as entity_id,
entity_name,
CASE
WHEN days_overdue <= 0 THEN 'current'
WHEN days_overdue BETWEEN 1 AND 30 THEN '1-30'
WHEN days_overdue BETWEEN 31 AND 60 THEN '31-60'
WHEN days_overdue BETWEEN 61 AND 90 THEN '61-90'
ELSE '90+'
END as bucket,
COALESCE(SUM(balance), 0) as amount
FROM items
WHERE balance > 0
GROUP BY ${entityField}, entity_name, bucket
ORDER BY entity_name`,
[asOfDate],
{ tenant }
);
// Process buckets
const bucketLabels: Record<AgingBucket, string> = {
'current': 'Vigente',
'1-30': '1-30 dias',
'31-60': '31-60 dias',
'61-90': '61-90 dias',
'90+': 'Mas de 90 dias',
};
const bucketOrder: AgingBucket[] = ['current', '1-30', '31-60', '61-90', '90+'];
const bucketMap = new Map<AgingBucket, { amount: number; count: number }>();
bucketsQuery.rows.forEach(row => {
bucketMap.set(row.bucket as AgingBucket, {
amount: parseFloat(row.amount),
count: parseInt(row.count),
});
});
let totalAmount = 0;
bucketMap.forEach(data => {
totalAmount += data.amount;
});
const buckets: AgingBucketData[] = bucketOrder.map(bucket => {
const data = bucketMap.get(bucket) || { amount: 0, count: 0 };
return {
bucket,
label: bucketLabels[bucket],
amount: createMonetaryValue(data.amount, currency),
count: data.count,
percentage: totalAmount > 0 ? (data.amount / totalAmount) * 100 : 0,
};
});
// Process details
const entityMap = new Map<string, {
entityId: string;
entityName: string;
buckets: Record<AgingBucket, number>;
total: number;
}>();
detailsQuery.rows.forEach(row => {
const entityId = row.entity_id;
if (!entityMap.has(entityId)) {
entityMap.set(entityId, {
entityId,
entityName: row.entity_name,
buckets: { 'current': 0, '1-30': 0, '31-60': 0, '61-90': 0, '90+': 0 },
total: 0,
});
}
const entity = entityMap.get(entityId)!;
const amount = parseFloat(row.amount);
entity.buckets[row.bucket as AgingBucket] = amount;
entity.total += amount;
});
const details = Array.from(entityMap.values()).sort((a, b) => b.total - a.total);
return {
type,
asOfDate,
totalAmount: createMonetaryValue(totalAmount, currency),
buckets,
details,
};
}
/**
* Calculate VAT position for a period
*/
async calculateVATPosition(
tenantId: string,
dateFrom: Date,
dateTo: Date,
options?: MetricQueryOptions
): Promise<VATPositionResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
// Get VAT collected (from invoices)
const collectedQuery = await this.db.query<{
rate: string;
amount: string;
}>(
`SELECT
il.vat_rate as rate,
SUM(il.vat_amount) as amount
FROM invoice_lines il
JOIN invoices i ON i.id = il.invoice_id
WHERE i.status IN ('paid', 'partial')
AND i.issue_date >= $1
AND i.issue_date <= $2
GROUP BY il.vat_rate`,
[dateFrom, dateTo],
{ tenant }
);
// Get VAT paid (from expenses/bills)
const paidQuery = await this.db.query<{
rate: string;
amount: string;
}>(
`SELECT
COALESCE(e.vat_rate, 0.16) as rate,
SUM(e.vat_amount) as amount
FROM expenses e
WHERE e.status = 'paid'
AND e.expense_date >= $1
AND e.expense_date <= $2
AND e.vat_amount > 0
GROUP BY e.vat_rate`,
[dateFrom, dateTo],
{ tenant }
);
// Combine rates
const rateMap = new Map<number, { collected: number; paid: number }>();
collectedQuery.rows.forEach(row => {
const rate = parseFloat(row.rate);
if (!rateMap.has(rate)) {
rateMap.set(rate, { collected: 0, paid: 0 });
}
rateMap.get(rate)!.collected = parseFloat(row.amount);
});
paidQuery.rows.forEach(row => {
const rate = parseFloat(row.rate);
if (!rateMap.has(rate)) {
rateMap.set(rate, { collected: 0, paid: 0 });
}
rateMap.get(rate)!.paid = parseFloat(row.amount);
});
let totalCollected = 0;
let totalPaid = 0;
const breakdown = Array.from(rateMap.entries())
.map(([rate, data]) => {
totalCollected += data.collected;
totalPaid += data.paid;
return {
rate,
collected: data.collected,
paid: data.paid,
net: data.collected - data.paid,
};
})
.sort((a, b) => b.rate - a.rate);
const netPosition = totalCollected - totalPaid;
return {
vatCollected: createMonetaryValue(totalCollected, currency),
vatPaid: createMonetaryValue(totalPaid, currency),
netPosition: createMonetaryValue(netPosition, currency),
isPayable: netPosition > 0,
breakdown,
};
}
}
// Export singleton factory
let coreMetricsInstance: CoreMetricsCalculator | null = null;
export function getCoreMetricsCalculator(db: DatabaseConnection): CoreMetricsCalculator {
if (!coreMetricsInstance) {
coreMetricsInstance = new CoreMetricsCalculator(db);
}
return coreMetricsInstance;
}
export function createCoreMetricsCalculator(db: DatabaseConnection): CoreMetricsCalculator {
return new CoreMetricsCalculator(db);
}

View File

@@ -0,0 +1,554 @@
/**
* Enterprise Metrics Calculator
*
* Advanced financial metrics for established businesses:
* - EBITDA
* - ROI (Return on Investment)
* - ROE (Return on Equity)
* - Current Ratio
* - Quick Ratio (Acid Test)
* - Debt Ratio
*/
import { DatabaseConnection, TenantContext } from '@horux/database';
import {
EBITDAResult,
ROIResult,
ROEResult,
CurrentRatioResult,
QuickRatioResult,
DebtRatioResult,
createMonetaryValue,
createPercentageValue,
createRatioValue,
MetricQueryOptions,
} from './metrics.types';
export class EnterpriseMetricsCalculator {
private db: DatabaseConnection;
constructor(db: DatabaseConnection) {
this.db = db;
}
/**
* Get tenant context for queries
*/
private getTenantContext(tenantId: string, schemaName?: string): TenantContext {
return {
tenantId,
schemaName: schemaName || `tenant_${tenantId}`,
};
}
/**
* Calculate EBITDA (Earnings Before Interest, Taxes, Depreciation, and Amortization)
*/
async calculateEBITDA(
tenantId: string,
dateFrom: Date,
dateTo: Date,
options?: MetricQueryOptions
): Promise<EBITDAResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
// Get revenue
const revenueQuery = await this.db.query<{ amount: string }>(
`SELECT COALESCE(SUM(total_amount), 0) as amount
FROM invoices
WHERE status IN ('paid', 'partial')
AND issue_date >= $1
AND issue_date <= $2`,
[dateFrom, dateTo],
{ tenant }
);
// Get operating expenses (excluding depreciation, amortization, interest, taxes)
const operatingExpensesQuery = await this.db.query<{ amount: string }>(
`SELECT COALESCE(SUM(e.total_amount), 0) as amount
FROM expenses e
LEFT JOIN categories c ON c.id = e.category_id
WHERE e.status = 'paid'
AND e.expense_date >= $1
AND e.expense_date <= $2
AND COALESCE(c.type, 'operating') NOT IN ('depreciation', 'amortization', 'interest', 'tax')`,
[dateFrom, dateTo],
{ tenant }
);
// Get depreciation
const depreciationQuery = await this.db.query<{ amount: string }>(
`SELECT COALESCE(SUM(e.total_amount), 0) as amount
FROM expenses e
JOIN categories c ON c.id = e.category_id
WHERE e.status = 'paid'
AND e.expense_date >= $1
AND e.expense_date <= $2
AND c.type = 'depreciation'`,
[dateFrom, dateTo],
{ tenant }
);
// Get amortization
const amortizationQuery = await this.db.query<{ amount: string }>(
`SELECT COALESCE(SUM(e.total_amount), 0) as amount
FROM expenses e
JOIN categories c ON c.id = e.category_id
WHERE e.status = 'paid'
AND e.expense_date >= $1
AND e.expense_date <= $2
AND c.type = 'amortization'`,
[dateFrom, dateTo],
{ tenant }
);
const revenue = parseFloat(revenueQuery.rows[0]?.amount || '0');
const operatingExpenses = parseFloat(operatingExpensesQuery.rows[0]?.amount || '0');
const depreciation = parseFloat(depreciationQuery.rows[0]?.amount || '0');
const amortization = parseFloat(amortizationQuery.rows[0]?.amount || '0');
const operatingIncome = revenue - operatingExpenses;
const ebitda = operatingIncome + depreciation + amortization;
const margin = revenue > 0 ? ebitda / revenue : 0;
return {
ebitda: createMonetaryValue(ebitda, currency),
operatingIncome: createMonetaryValue(operatingIncome, currency),
depreciation: createMonetaryValue(depreciation, currency),
amortization: createMonetaryValue(amortization, currency),
margin: createPercentageValue(margin),
revenue: createMonetaryValue(revenue, currency),
};
}
/**
* Calculate ROI (Return on Investment)
*/
async calculateROI(
tenantId: string,
dateFrom: Date,
dateTo: Date,
options?: MetricQueryOptions
): Promise<ROIResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
// Calculate net profit
const profitQuery = await this.db.query<{
revenue: string;
expenses: string;
}>(
`WITH revenue AS (
SELECT COALESCE(SUM(total_amount), 0) as amount
FROM invoices
WHERE status IN ('paid', 'partial')
AND issue_date >= $1
AND issue_date <= $2
),
expenses AS (
SELECT COALESCE(SUM(total_amount), 0) as amount
FROM expenses
WHERE status = 'paid'
AND expense_date >= $1
AND expense_date <= $2
)
SELECT
(SELECT amount FROM revenue) as revenue,
(SELECT amount FROM expenses) as expenses`,
[dateFrom, dateTo],
{ tenant }
);
// Get total investment (initial capital + retained earnings)
const investmentQuery = await this.db.query<{ amount: string }>(
`SELECT COALESCE(
(SELECT SUM(amount) FROM capital_contributions WHERE contribution_date <= $1) +
(SELECT COALESCE(SUM(amount), 0) FROM retained_earnings WHERE period_end <= $1),
0
) as amount`,
[dateTo],
{ tenant }
);
const revenue = parseFloat(profitQuery.rows[0]?.revenue || '0');
const expenses = parseFloat(profitQuery.rows[0]?.expenses || '0');
const netProfit = revenue - expenses;
const totalInvestment = parseFloat(investmentQuery.rows[0]?.amount || '1');
const roi = totalInvestment > 0 ? netProfit / totalInvestment : 0;
// Annualize the ROI
const periodDays = Math.ceil((dateTo.getTime() - dateFrom.getTime()) / (1000 * 60 * 60 * 24));
const annualized = roi * (365 / periodDays);
return {
roi: createPercentageValue(roi),
netProfit: createMonetaryValue(netProfit, currency),
totalInvestment: createMonetaryValue(totalInvestment, currency),
annualized: createPercentageValue(annualized),
};
}
/**
* Calculate ROE (Return on Equity)
*/
async calculateROE(
tenantId: string,
dateFrom: Date,
dateTo: Date,
options?: MetricQueryOptions
): Promise<ROEResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
// Calculate net income
const incomeQuery = await this.db.query<{
revenue: string;
expenses: string;
}>(
`WITH revenue AS (
SELECT COALESCE(SUM(total_amount), 0) as amount
FROM invoices
WHERE status IN ('paid', 'partial')
AND issue_date >= $1
AND issue_date <= $2
),
expenses AS (
SELECT COALESCE(SUM(total_amount), 0) as amount
FROM expenses
WHERE status = 'paid'
AND expense_date >= $1
AND expense_date <= $2
)
SELECT
(SELECT amount FROM revenue) as revenue,
(SELECT amount FROM expenses) as expenses`,
[dateFrom, dateTo],
{ tenant }
);
// Get shareholders' equity
const equityQuery = await this.db.query<{ amount: string }>(
`SELECT COALESCE(
(
SELECT SUM(amount)
FROM (
SELECT amount FROM capital_contributions WHERE contribution_date <= $1
UNION ALL
SELECT amount FROM retained_earnings WHERE period_end <= $1
UNION ALL
SELECT -amount FROM dividends WHERE payment_date <= $1
) equity_items
),
0
) as amount`,
[dateTo],
{ tenant }
);
const revenue = parseFloat(incomeQuery.rows[0]?.revenue || '0');
const expenses = parseFloat(incomeQuery.rows[0]?.expenses || '0');
const netIncome = revenue - expenses;
const shareholdersEquity = parseFloat(equityQuery.rows[0]?.amount || '1');
const roe = shareholdersEquity > 0 ? netIncome / shareholdersEquity : 0;
// Annualize the ROE
const periodDays = Math.ceil((dateTo.getTime() - dateFrom.getTime()) / (1000 * 60 * 60 * 24));
const annualized = roe * (365 / periodDays);
return {
roe: createPercentageValue(roe),
netIncome: createMonetaryValue(netIncome, currency),
shareholdersEquity: createMonetaryValue(shareholdersEquity, currency),
annualized: createPercentageValue(annualized),
};
}
/**
* Calculate Current Ratio
*/
async calculateCurrentRatio(
tenantId: string,
asOfDate: Date,
options?: MetricQueryOptions
): Promise<CurrentRatioResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
// Get current assets (cash, accounts receivable, inventory, prepaid expenses)
const assetsQuery = await this.db.query<{
cash: string;
receivables: string;
inventory: string;
prepaid: string;
}>(
`SELECT
COALESCE((SELECT SUM(balance) FROM bank_accounts WHERE is_active = true), 0) as cash,
COALESCE((
SELECT SUM(i.total_amount - COALESCE(
(SELECT SUM(p.amount) FROM payments p WHERE p.invoice_id = i.id), 0
))
FROM invoices i
WHERE i.status IN ('sent', 'partial')
AND i.issue_date <= $1
), 0) as receivables,
COALESCE((SELECT SUM(quantity * unit_cost) FROM inventory_items WHERE is_active = true), 0) as inventory,
COALESCE((
SELECT SUM(remaining_amount)
FROM prepaid_expenses
WHERE start_date <= $1 AND end_date > $1
), 0) as prepaid`,
[asOfDate],
{ tenant }
);
// Get current liabilities (accounts payable, short-term debt, accrued expenses)
const liabilitiesQuery = await this.db.query<{
payables: string;
short_term_debt: string;
accrued: string;
}>(
`SELECT
COALESCE((
SELECT SUM(b.total_amount - COALESCE(
(SELECT SUM(p.amount) FROM payments p WHERE p.bill_id = b.id), 0
))
FROM bills b
WHERE b.status IN ('pending', 'partial')
AND b.issue_date <= $1
), 0) as payables,
COALESCE((
SELECT SUM(remaining_balance)
FROM loans
WHERE is_active = true AND due_date <= $1 + INTERVAL '12 months'
), 0) as short_term_debt,
COALESCE((
SELECT SUM(amount)
FROM accrued_expenses
WHERE accrual_date <= $1 AND (paid_date IS NULL OR paid_date > $1)
), 0) as accrued`,
[asOfDate],
{ tenant }
);
const cash = parseFloat(assetsQuery.rows[0]?.cash || '0');
const receivables = parseFloat(assetsQuery.rows[0]?.receivables || '0');
const inventory = parseFloat(assetsQuery.rows[0]?.inventory || '0');
const prepaid = parseFloat(assetsQuery.rows[0]?.prepaid || '0');
const payables = parseFloat(liabilitiesQuery.rows[0]?.payables || '0');
const shortTermDebt = parseFloat(liabilitiesQuery.rows[0]?.short_term_debt || '0');
const accrued = parseFloat(liabilitiesQuery.rows[0]?.accrued || '0');
const currentAssets = cash + receivables + inventory + prepaid;
const currentLiabilities = payables + shortTermDebt + accrued;
const ratio = currentLiabilities > 0 ? currentAssets / currentLiabilities : 0;
const isHealthy = ratio >= 1.5;
let interpretation: string;
if (ratio >= 2) {
interpretation = 'Excelente liquidez. La empresa puede cubrir facilmente sus obligaciones a corto plazo.';
} else if (ratio >= 1.5) {
interpretation = 'Buena liquidez. La empresa tiene capacidad adecuada para cubrir sus obligaciones.';
} else if (ratio >= 1) {
interpretation = 'Liquidez limitada. La empresa puede tener dificultades para cubrir obligaciones inesperadas.';
} else {
interpretation = 'Liquidez insuficiente. La empresa puede tener problemas para pagar sus deudas a corto plazo.';
}
return {
ratio: createRatioValue(currentAssets, currentLiabilities),
currentAssets: createMonetaryValue(currentAssets, currency),
currentLiabilities: createMonetaryValue(currentLiabilities, currency),
isHealthy,
interpretation,
};
}
/**
* Calculate Quick Ratio (Acid Test)
*/
async calculateQuickRatio(
tenantId: string,
asOfDate: Date,
options?: MetricQueryOptions
): Promise<QuickRatioResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
// Get current assets (excluding inventory)
const assetsQuery = await this.db.query<{
cash: string;
receivables: string;
inventory: string;
}>(
`SELECT
COALESCE((SELECT SUM(balance) FROM bank_accounts WHERE is_active = true), 0) as cash,
COALESCE((
SELECT SUM(i.total_amount - COALESCE(
(SELECT SUM(p.amount) FROM payments p WHERE p.invoice_id = i.id), 0
))
FROM invoices i
WHERE i.status IN ('sent', 'partial')
AND i.issue_date <= $1
), 0) as receivables,
COALESCE((SELECT SUM(quantity * unit_cost) FROM inventory_items WHERE is_active = true), 0) as inventory`,
[asOfDate],
{ tenant }
);
// Get current liabilities
const liabilitiesQuery = await this.db.query<{
total: string;
}>(
`SELECT COALESCE(
(
SELECT SUM(b.total_amount - COALESCE(
(SELECT SUM(p.amount) FROM payments p WHERE p.bill_id = b.id), 0
))
FROM bills b
WHERE b.status IN ('pending', 'partial')
AND b.issue_date <= $1
) +
COALESCE((
SELECT SUM(remaining_balance)
FROM loans
WHERE is_active = true AND due_date <= $1 + INTERVAL '12 months'
), 0) +
COALESCE((
SELECT SUM(amount)
FROM accrued_expenses
WHERE accrual_date <= $1 AND (paid_date IS NULL OR paid_date > $1)
), 0),
0
) as total`,
[asOfDate],
{ tenant }
);
const cash = parseFloat(assetsQuery.rows[0]?.cash || '0');
const receivables = parseFloat(assetsQuery.rows[0]?.receivables || '0');
const inventory = parseFloat(assetsQuery.rows[0]?.inventory || '0');
const currentAssets = cash + receivables + inventory;
const currentLiabilities = parseFloat(liabilitiesQuery.rows[0]?.total || '0');
const quickAssets = currentAssets - inventory;
const ratio = currentLiabilities > 0 ? quickAssets / currentLiabilities : 0;
const isHealthy = ratio >= 1;
let interpretation: string;
if (ratio >= 1.5) {
interpretation = 'Excelente liquidez inmediata. La empresa puede cubrir sus obligaciones sin depender del inventario.';
} else if (ratio >= 1) {
interpretation = 'Buena liquidez inmediata. La empresa puede cubrir sus obligaciones a corto plazo.';
} else if (ratio >= 0.5) {
interpretation = 'Liquidez inmediata limitada. La empresa depende parcialmente de la venta de inventario.';
} else {
interpretation = 'Liquidez inmediata insuficiente. La empresa tiene alta dependencia del inventario para cubrir deudas.';
}
return {
ratio: createRatioValue(quickAssets, currentLiabilities),
currentAssets: createMonetaryValue(currentAssets, currency),
inventory: createMonetaryValue(inventory, currency),
currentLiabilities: createMonetaryValue(currentLiabilities, currency),
isHealthy,
interpretation,
};
}
/**
* Calculate Debt Ratio
*/
async calculateDebtRatio(
tenantId: string,
asOfDate: Date,
options?: MetricQueryOptions
): Promise<DebtRatioResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
// Get total debt
const debtQuery = await this.db.query<{ amount: string }>(
`SELECT COALESCE(
(
SELECT SUM(b.total_amount - COALESCE(
(SELECT SUM(p.amount) FROM payments p WHERE p.bill_id = b.id), 0
))
FROM bills b
WHERE b.status IN ('pending', 'partial')
AND b.issue_date <= $1
) +
COALESCE((SELECT SUM(remaining_balance) FROM loans WHERE is_active = true), 0) +
COALESCE((
SELECT SUM(amount)
FROM accrued_expenses
WHERE accrual_date <= $1 AND (paid_date IS NULL OR paid_date > $1)
), 0),
0
) as amount`,
[asOfDate],
{ tenant }
);
// Get total assets
const assetsQuery = await this.db.query<{ amount: string }>(
`SELECT COALESCE(
(SELECT SUM(balance) FROM bank_accounts WHERE is_active = true) +
(
SELECT COALESCE(SUM(i.total_amount - COALESCE(
(SELECT SUM(p.amount) FROM payments p WHERE p.invoice_id = i.id), 0
)), 0)
FROM invoices i
WHERE i.status IN ('sent', 'partial')
AND i.issue_date <= $1
) +
COALESCE((SELECT SUM(quantity * unit_cost) FROM inventory_items WHERE is_active = true), 0) +
COALESCE((SELECT SUM(book_value) FROM fixed_assets WHERE is_active = true), 0),
0
) as amount`,
[asOfDate],
{ tenant }
);
const totalDebt = parseFloat(debtQuery.rows[0]?.amount || '0');
const totalAssets = parseFloat(assetsQuery.rows[0]?.amount || '1');
const ratio = totalAssets > 0 ? totalDebt / totalAssets : 0;
let interpretation: string;
if (ratio <= 0.3) {
interpretation = 'Bajo nivel de endeudamiento. La empresa tiene una estructura financiera conservadora.';
} else if (ratio <= 0.5) {
interpretation = 'Nivel de endeudamiento moderado. Equilibrio adecuado entre deuda y capital propio.';
} else if (ratio <= 0.7) {
interpretation = 'Nivel de endeudamiento alto. Mayor riesgo financiero y dependencia de acreedores.';
} else {
interpretation = 'Nivel de endeudamiento muy alto. Alto riesgo de insolvencia en condiciones adversas.';
}
return {
ratio: createRatioValue(totalDebt, totalAssets),
totalDebt: createMonetaryValue(totalDebt, currency),
totalAssets: createMonetaryValue(totalAssets, currency),
interpretation,
};
}
}
// Export singleton factory
let enterpriseMetricsInstance: EnterpriseMetricsCalculator | null = null;
export function getEnterpriseMetricsCalculator(db: DatabaseConnection): EnterpriseMetricsCalculator {
if (!enterpriseMetricsInstance) {
enterpriseMetricsInstance = new EnterpriseMetricsCalculator(db);
}
return enterpriseMetricsInstance;
}
export function createEnterpriseMetricsCalculator(db: DatabaseConnection): EnterpriseMetricsCalculator {
return new EnterpriseMetricsCalculator(db);
}

View File

@@ -0,0 +1,504 @@
/**
* Metrics Cache System
*
* High-performance caching layer for metrics using Redis.
* Supports:
* - TTL-based expiration
* - Stale-while-revalidate pattern
* - Cache invalidation by tenant and period
* - Cache warmup for commonly accessed metrics
*/
import Redis from 'ioredis';
import {
MetricType,
MetricPeriod,
CachedMetric,
CacheConfig,
CacheStats,
periodToString,
CoreMetricType,
StartupMetricType,
EnterpriseMetricType,
} from './metrics.types';
// Default cache configuration
const DEFAULT_CACHE_CONFIG: CacheConfig = {
ttlSeconds: 3600, // 1 hour default TTL
staleWhileRevalidate: true,
warmupEnabled: true,
};
// Metric-specific TTL configuration
const METRIC_TTL_CONFIG: Record<MetricType, number> = {
// Core metrics - shorter TTL for more accuracy
revenue: 1800, // 30 minutes
expenses: 1800, // 30 minutes
gross_profit: 1800, // 30 minutes
net_profit: 1800, // 30 minutes
cash_flow: 900, // 15 minutes
accounts_receivable: 900, // 15 minutes
accounts_payable: 900, // 15 minutes
aging_receivable: 1800, // 30 minutes
aging_payable: 1800, // 30 minutes
vat_position: 3600, // 1 hour
// Startup metrics - moderate TTL
mrr: 3600, // 1 hour
arr: 3600, // 1 hour
churn_rate: 7200, // 2 hours
cac: 7200, // 2 hours
ltv: 14400, // 4 hours
ltv_cac_ratio: 14400, // 4 hours
runway: 3600, // 1 hour
burn_rate: 3600, // 1 hour
// Enterprise metrics - longer TTL
ebitda: 3600, // 1 hour
roi: 7200, // 2 hours
roe: 7200, // 2 hours
current_ratio: 1800, // 30 minutes
quick_ratio: 1800, // 30 minutes
debt_ratio: 3600, // 1 hour
};
export class MetricsCache {
private redis: Redis;
private config: CacheConfig;
private stats: CacheStats;
private prefix: string = 'horux:metrics:';
constructor(redis: Redis, config?: Partial<CacheConfig>) {
this.redis = redis;
this.config = { ...DEFAULT_CACHE_CONFIG, ...config };
this.stats = {
hits: 0,
misses: 0,
hitRate: 0,
totalEntries: 0,
memoryUsage: 0,
};
}
/**
* Generate cache key for a metric
*/
private generateKey(tenantId: string, metric: MetricType, period: MetricPeriod): string {
const periodStr = periodToString(period);
return `${this.prefix}${tenantId}:${metric}:${periodStr}`;
}
/**
* Generate pattern for tenant cache keys
*/
private generateTenantPattern(tenantId: string): string {
return `${this.prefix}${tenantId}:*`;
}
/**
* Get TTL for a specific metric
*/
private getTTL(metric: MetricType): number {
return METRIC_TTL_CONFIG[metric] || this.config.ttlSeconds;
}
/**
* Get a cached metric value
*/
async getCachedMetric<T = unknown>(
tenantId: string,
metric: MetricType,
period: MetricPeriod
): Promise<CachedMetric | null> {
const key = this.generateKey(tenantId, metric, period);
try {
const data = await this.redis.get(key);
if (!data) {
this.stats.misses++;
this.updateHitRate();
return null;
}
this.stats.hits++;
this.updateHitRate();
const cached = JSON.parse(data) as CachedMetric;
// Check if stale
const now = new Date();
if (new Date(cached.expiresAt) < now) {
// If stale-while-revalidate is enabled, return stale data
// The caller should trigger a background refresh
if (this.config.staleWhileRevalidate) {
return { ...cached, value: cached.value as T };
}
return null;
}
return { ...cached, value: cached.value as T };
} catch (error) {
console.error('Cache get error:', error);
this.stats.misses++;
this.updateHitRate();
return null;
}
}
/**
* Set a cached metric value
*/
async setCachedMetric<T = unknown>(
tenantId: string,
metric: MetricType,
period: MetricPeriod,
value: T
): Promise<void> {
const key = this.generateKey(tenantId, metric, period);
const ttl = this.getTTL(metric);
const cached: CachedMetric = {
key,
tenantId,
metric,
period,
value,
calculatedAt: new Date(),
expiresAt: new Date(Date.now() + ttl * 1000),
version: 1,
};
try {
await this.redis.setex(key, ttl, JSON.stringify(cached));
this.stats.totalEntries++;
} catch (error) {
console.error('Cache set error:', error);
}
}
/**
* Invalidate cache for a tenant
*/
async invalidateCache(
tenantId: string,
affectedPeriods?: MetricPeriod[]
): Promise<number> {
try {
if (!affectedPeriods || affectedPeriods.length === 0) {
// Invalidate all cache for tenant
const pattern = this.generateTenantPattern(tenantId);
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
this.stats.totalEntries = Math.max(0, this.stats.totalEntries - keys.length);
}
return keys.length;
}
// Invalidate specific periods
let invalidated = 0;
const metrics: MetricType[] = [
// Core
'revenue', 'expenses', 'gross_profit', 'net_profit', 'cash_flow',
'accounts_receivable', 'accounts_payable', 'aging_receivable',
'aging_payable', 'vat_position',
// Startup
'mrr', 'arr', 'churn_rate', 'cac', 'ltv', 'ltv_cac_ratio',
'runway', 'burn_rate',
// Enterprise
'ebitda', 'roi', 'roe', 'current_ratio', 'quick_ratio', 'debt_ratio',
];
const keysToDelete: string[] = [];
for (const period of affectedPeriods) {
for (const metric of metrics) {
keysToDelete.push(this.generateKey(tenantId, metric, period));
}
}
if (keysToDelete.length > 0) {
// Delete in batches to avoid blocking
const batchSize = 100;
for (let i = 0; i < keysToDelete.length; i += batchSize) {
const batch = keysToDelete.slice(i, i + batchSize);
const deleted = await this.redis.del(...batch);
invalidated += deleted;
}
this.stats.totalEntries = Math.max(0, this.stats.totalEntries - invalidated);
}
return invalidated;
} catch (error) {
console.error('Cache invalidation error:', error);
return 0;
}
}
/**
* Invalidate cache for specific metrics
*/
async invalidateMetrics(
tenantId: string,
metrics: MetricType[],
periods: MetricPeriod[]
): Promise<number> {
try {
const keysToDelete: string[] = [];
for (const period of periods) {
for (const metric of metrics) {
keysToDelete.push(this.generateKey(tenantId, metric, period));
}
}
if (keysToDelete.length > 0) {
const deleted = await this.redis.del(...keysToDelete);
this.stats.totalEntries = Math.max(0, this.stats.totalEntries - deleted);
return deleted;
}
return 0;
} catch (error) {
console.error('Cache invalidation error:', error);
return 0;
}
}
/**
* Warmup cache with commonly accessed metrics
*/
async warmupCache(
tenantId: string,
calculateMetric: (metric: MetricType, period: MetricPeriod) => Promise<unknown>
): Promise<{ success: number; failed: number }> {
if (!this.config.warmupEnabled) {
return { success: 0, failed: 0 };
}
const now = new Date();
const currentMonth: MetricPeriod = {
type: 'monthly',
year: now.getFullYear(),
month: now.getMonth() + 1,
};
const previousMonth: MetricPeriod = {
type: 'monthly',
year: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
month: now.getMonth() === 0 ? 12 : now.getMonth(),
};
const currentYear: MetricPeriod = {
type: 'yearly',
year: now.getFullYear(),
};
// Priority metrics to warmup
const priorityMetrics: CoreMetricType[] = [
'revenue',
'expenses',
'net_profit',
'cash_flow',
'accounts_receivable',
'accounts_payable',
];
const periods = [currentMonth, previousMonth, currentYear];
let success = 0;
let failed = 0;
for (const metric of priorityMetrics) {
for (const period of periods) {
try {
// Check if already cached
const existing = await this.getCachedMetric(tenantId, metric, period);
if (existing) {
continue;
}
// Calculate and cache
const value = await calculateMetric(metric, period);
await this.setCachedMetric(tenantId, metric, period, value);
success++;
} catch (error) {
console.error(`Failed to warmup ${metric} for period ${periodToString(period)}:`, error);
failed++;
}
}
}
return { success, failed };
}
/**
* Get cache statistics
*/
async getStats(): Promise<CacheStats> {
try {
// Get memory info
const info = await this.redis.info('memory');
const memoryMatch = info.match(/used_memory:(\d+)/);
this.stats.memoryUsage = memoryMatch ? parseInt(memoryMatch[1]) : 0;
// Count entries
const keys = await this.redis.keys(`${this.prefix}*`);
this.stats.totalEntries = keys.length;
} catch (error) {
console.error('Error getting cache stats:', error);
}
return { ...this.stats };
}
/**
* Clear all metrics cache
*/
async clearAll(): Promise<void> {
try {
const keys = await this.redis.keys(`${this.prefix}*`);
if (keys.length > 0) {
await this.redis.del(...keys);
}
this.stats = {
hits: 0,
misses: 0,
hitRate: 0,
totalEntries: 0,
memoryUsage: 0,
};
} catch (error) {
console.error('Error clearing cache:', error);
}
}
/**
* Update hit rate calculation
*/
private updateHitRate(): void {
const total = this.stats.hits + this.stats.misses;
this.stats.hitRate = total > 0 ? this.stats.hits / total : 0;
}
/**
* Get or calculate metric with caching
*/
async getOrCalculate<T>(
tenantId: string,
metric: MetricType,
period: MetricPeriod,
calculateFn: () => Promise<T>,
forceRefresh: boolean = false
): Promise<T> {
// Skip cache if force refresh
if (!forceRefresh) {
const cached = await this.getCachedMetric<T>(tenantId, metric, period);
if (cached) {
// Check if we need background refresh (stale)
const now = new Date();
if (new Date(cached.expiresAt) < now && this.config.staleWhileRevalidate) {
// Trigger background refresh
this.backgroundRefresh(tenantId, metric, period, calculateFn).catch(console.error);
}
return cached.value as T;
}
}
// Calculate fresh value
const value = await calculateFn();
await this.setCachedMetric(tenantId, metric, period, value);
return value;
}
/**
* Background refresh for stale cache entries
*/
private async backgroundRefresh<T>(
tenantId: string,
metric: MetricType,
period: MetricPeriod,
calculateFn: () => Promise<T>
): Promise<void> {
try {
const value = await calculateFn();
await this.setCachedMetric(tenantId, metric, period, value);
} catch (error) {
console.error(`Background refresh failed for ${metric}:`, error);
}
}
/**
* Check if a metric is cached
*/
async isCached(
tenantId: string,
metric: MetricType,
period: MetricPeriod
): Promise<boolean> {
const key = this.generateKey(tenantId, metric, period);
const exists = await this.redis.exists(key);
return exists === 1;
}
/**
* Get remaining TTL for a cached metric
*/
async getTTLRemaining(
tenantId: string,
metric: MetricType,
period: MetricPeriod
): Promise<number> {
const key = this.generateKey(tenantId, metric, period);
return await this.redis.ttl(key);
}
/**
* Extend TTL for a cached metric
*/
async extendTTL(
tenantId: string,
metric: MetricType,
period: MetricPeriod,
additionalSeconds: number
): Promise<boolean> {
const key = this.generateKey(tenantId, metric, period);
const currentTTL = await this.redis.ttl(key);
if (currentTTL > 0) {
await this.redis.expire(key, currentTTL + additionalSeconds);
return true;
}
return false;
}
}
// Factory function
let metricsCacheInstance: MetricsCache | null = null;
export function getMetricsCache(redis: Redis, config?: Partial<CacheConfig>): MetricsCache {
if (!metricsCacheInstance) {
metricsCacheInstance = new MetricsCache(redis, config);
}
return metricsCacheInstance;
}
export function createMetricsCache(redis: Redis, config?: Partial<CacheConfig>): MetricsCache {
return new MetricsCache(redis, config);
}
// Export helper to create a mock cache for testing
export function createMockCache(): MetricsCache {
const mockRedis = {
get: async () => null,
setex: async () => 'OK',
del: async () => 0,
keys: async () => [],
exists: async () => 0,
ttl: async () => -1,
expire: async () => 1,
info: async () => 'used_memory:0',
} as unknown as Redis;
return new MetricsCache(mockRedis);
}

View File

@@ -0,0 +1,622 @@
/**
* Metrics Service
*
* Main service that orchestrates all metrics calculations.
* Provides a unified API for:
* - Dashboard metrics
* - Metric history
* - Period comparisons
* - Real-time and cached data
*/
import { DatabaseConnection } from '@horux/database';
import Redis from 'ioredis';
import {
MetricType,
MetricPeriod,
DashboardMetrics,
MetricHistory,
MetricComparisonReport,
MetricComparison,
MetricValue,
createMetricValue,
getPeriodDateRange,
getPreviousPeriod,
periodToString,
CoreMetricType,
StartupMetricType,
EnterpriseMetricType,
} from './metrics.types';
import { CoreMetricsCalculator, createCoreMetricsCalculator } from './core.metrics';
import { StartupMetricsCalculator, createStartupMetricsCalculator } from './startup.metrics';
import { EnterpriseMetricsCalculator, createEnterpriseMetricsCalculator } from './enterprise.metrics';
import { MetricsCache, createMetricsCache } from './metrics.cache';
export interface MetricsServiceOptions {
enableCache?: boolean;
enableStartupMetrics?: boolean;
enableEnterpriseMetrics?: boolean;
defaultCurrency?: string;
}
export class MetricsService {
private db: DatabaseConnection;
private cache: MetricsCache | null;
private coreMetrics: CoreMetricsCalculator;
private startupMetrics: StartupMetricsCalculator;
private enterpriseMetrics: EnterpriseMetricsCalculator;
private options: MetricsServiceOptions;
constructor(
db: DatabaseConnection,
redis?: Redis,
options?: MetricsServiceOptions
) {
this.db = db;
this.options = {
enableCache: true,
enableStartupMetrics: true,
enableEnterpriseMetrics: true,
defaultCurrency: 'MXN',
...options,
};
this.cache = redis && this.options.enableCache
? createMetricsCache(redis)
: null;
this.coreMetrics = createCoreMetricsCalculator(db);
this.startupMetrics = createStartupMetricsCalculator(db);
this.enterpriseMetrics = createEnterpriseMetricsCalculator(db);
}
/**
* Get dashboard metrics for a tenant
*/
async getDashboardMetrics(
tenantId: string,
period: MetricPeriod
): Promise<DashboardMetrics> {
const currency = this.options.defaultCurrency || 'MXN';
const { dateFrom, dateTo } = getPeriodDateRange(period);
const previousPeriod = getPreviousPeriod(period);
const { dateFrom: prevDateFrom, dateTo: prevDateTo } = getPeriodDateRange(previousPeriod);
// Calculate core metrics for current period
const [
revenueResult,
expensesResult,
netProfitResult,
cashFlowResult,
receivableResult,
payableResult,
] = await Promise.all([
this.getMetricWithCache(tenantId, 'revenue', period, async () =>
this.coreMetrics.calculateRevenue(tenantId, dateFrom, dateTo, { currency })
),
this.getMetricWithCache(tenantId, 'expenses', period, async () =>
this.coreMetrics.calculateExpenses(tenantId, dateFrom, dateTo, { currency })
),
this.getMetricWithCache(tenantId, 'net_profit', period, async () =>
this.coreMetrics.calculateNetProfit(tenantId, dateFrom, dateTo, { currency })
),
this.getMetricWithCache(tenantId, 'cash_flow', period, async () =>
this.coreMetrics.calculateCashFlow(tenantId, dateFrom, dateTo, { currency })
),
this.getMetricWithCache(tenantId, 'accounts_receivable', period, async () =>
this.coreMetrics.calculateAccountsReceivable(tenantId, dateTo, { currency })
),
this.getMetricWithCache(tenantId, 'accounts_payable', period, async () =>
this.coreMetrics.calculateAccountsPayable(tenantId, dateTo, { currency })
),
]);
// Calculate previous period metrics for comparison
const [
prevRevenueResult,
prevExpensesResult,
prevNetProfitResult,
prevCashFlowResult,
] = await Promise.all([
this.getMetricWithCache(tenantId, 'revenue', previousPeriod, async () =>
this.coreMetrics.calculateRevenue(tenantId, prevDateFrom, prevDateTo, { currency })
),
this.getMetricWithCache(tenantId, 'expenses', previousPeriod, async () =>
this.coreMetrics.calculateExpenses(tenantId, prevDateFrom, prevDateTo, { currency })
),
this.getMetricWithCache(tenantId, 'net_profit', previousPeriod, async () =>
this.coreMetrics.calculateNetProfit(tenantId, prevDateFrom, prevDateTo, { currency })
),
this.getMetricWithCache(tenantId, 'cash_flow', previousPeriod, async () =>
this.coreMetrics.calculateCashFlow(tenantId, prevDateFrom, prevDateTo, { currency })
),
]);
// Build comparisons
const comparison = {
revenue: this.buildComparison(
revenueResult.totalRevenue.amount,
prevRevenueResult.totalRevenue.amount
),
expenses: this.buildComparison(
expensesResult.totalExpenses.amount,
prevExpensesResult.totalExpenses.amount
),
netProfit: this.buildComparison(
netProfitResult.profit.amount,
prevNetProfitResult.profit.amount
),
cashFlow: this.buildComparison(
cashFlowResult.netCashFlow.amount,
prevCashFlowResult.netCashFlow.amount
),
};
// Build base dashboard
const dashboard: DashboardMetrics = {
period,
generatedAt: new Date(),
currency,
revenue: createMetricValue(revenueResult.totalRevenue.amount, { currency }),
expenses: createMetricValue(expensesResult.totalExpenses.amount, { currency }),
netProfit: createMetricValue(netProfitResult.profit.amount, { currency }),
profitMargin: createMetricValue(netProfitResult.margin.value * 100, { unit: '%' }),
cashFlow: createMetricValue(cashFlowResult.netCashFlow.amount, { currency }),
accountsReceivable: createMetricValue(receivableResult.totalReceivable.amount, { currency }),
accountsPayable: createMetricValue(payableResult.totalPayable.amount, { currency }),
comparison,
};
// Add startup metrics if enabled
if (this.options.enableStartupMetrics) {
const [mrrResult, arrResult, churnResult, runwayResult] = await Promise.all([
this.getMetricWithCache(tenantId, 'mrr', period, async () =>
this.startupMetrics.calculateMRR(tenantId, dateTo, { currency })
),
this.getMetricWithCache(tenantId, 'arr', period, async () =>
this.startupMetrics.calculateARR(tenantId, dateTo, { currency })
),
this.getMetricWithCache(tenantId, 'churn_rate', period, async () =>
this.startupMetrics.calculateChurnRate(tenantId, dateFrom, dateTo, { currency })
),
this.getMetricWithCache(tenantId, 'runway', period, async () => {
// Get current cash for runway calculation
const cashResult = await this.db.query<{ balance: string }>(
`SELECT COALESCE(SUM(balance), 0) as balance FROM bank_accounts WHERE is_active = true`,
[],
{ tenant: { tenantId, schemaName: `tenant_${tenantId}` } }
);
const currentCash = parseFloat(cashResult.rows[0]?.balance || '0');
return this.startupMetrics.calculateRunway(tenantId, currentCash, { currency });
}),
]);
dashboard.startup = {
mrr: createMetricValue(mrrResult.mrr.amount, { currency }),
arr: createMetricValue(arrResult.arr.amount, { currency }),
churnRate: createMetricValue(churnResult.churnRate.value * 100, { unit: '%' }),
runway: createMetricValue(runwayResult.runwayMonths, { unit: 'meses', precision: 1 }),
};
}
// Add enterprise metrics if enabled
if (this.options.enableEnterpriseMetrics) {
const [ebitdaResult, currentRatioResult, quickRatioResult] = await Promise.all([
this.getMetricWithCache(tenantId, 'ebitda', period, async () =>
this.enterpriseMetrics.calculateEBITDA(tenantId, dateFrom, dateTo, { currency })
),
this.getMetricWithCache(tenantId, 'current_ratio', period, async () =>
this.enterpriseMetrics.calculateCurrentRatio(tenantId, dateTo, { currency })
),
this.getMetricWithCache(tenantId, 'quick_ratio', period, async () =>
this.enterpriseMetrics.calculateQuickRatio(tenantId, dateTo, { currency })
),
]);
dashboard.enterprise = {
ebitda: createMetricValue(ebitdaResult.ebitda.amount, { currency }),
currentRatio: createMetricValue(currentRatioResult.ratio.value, { precision: 2 }),
quickRatio: createMetricValue(quickRatioResult.ratio.value, { precision: 2 }),
};
}
return dashboard;
}
/**
* Get historical data for a specific metric
*/
async getMetricHistory(
tenantId: string,
metric: MetricType,
periods: MetricPeriod[]
): Promise<MetricHistory> {
const currency = this.options.defaultCurrency || 'MXN';
const values: MetricHistory['periods'] = [];
for (const period of periods) {
const { dateFrom, dateTo } = getPeriodDateRange(period);
try {
const value = await this.calculateSingleMetric(
tenantId,
metric,
dateFrom,
dateTo,
currency
);
values.push({
period,
value,
timestamp: new Date(),
});
} catch (error) {
console.error(`Error calculating ${metric} for period ${periodToString(period)}:`, error);
// Add zero value for failed calculations
values.push({
period,
value: createMetricValue(0, { currency }),
timestamp: new Date(),
});
}
}
// Calculate trend
const trend = this.calculateTrend(values.map(v => v.value.raw));
return {
metric,
periods: values,
trend,
};
}
/**
* Compare metrics between two periods
*/
async compareMetrics(
tenantId: string,
period1: MetricPeriod,
period2: MetricPeriod
): Promise<MetricComparisonReport> {
const currency = this.options.defaultCurrency || 'MXN';
const { dateFrom: date1From, dateTo: date1To } = getPeriodDateRange(period1);
const { dateFrom: date2From, dateTo: date2To } = getPeriodDateRange(period2);
// Define metrics to compare
const metricsToCompare: MetricType[] = [
'revenue',
'expenses',
'net_profit',
'cash_flow',
];
if (this.options.enableStartupMetrics) {
metricsToCompare.push('mrr', 'arr', 'burn_rate');
}
if (this.options.enableEnterpriseMetrics) {
metricsToCompare.push('ebitda', 'current_ratio');
}
const comparisons: MetricComparisonReport['metrics'] = [];
for (const metric of metricsToCompare) {
try {
const [value1, value2] = await Promise.all([
this.calculateSingleMetric(tenantId, metric, date1From, date1To, currency),
this.calculateSingleMetric(tenantId, metric, date2From, date2To, currency),
]);
const change = value2.raw - value1.raw;
const changePercentage = value1.raw !== 0 ? (change / value1.raw) * 100 : 0;
comparisons.push({
metric,
period1Value: value1,
period2Value: value2,
change,
changePercentage,
trend: change > 0 ? 'up' : change < 0 ? 'down' : 'stable',
});
} catch (error) {
console.error(`Error comparing ${metric}:`, error);
}
}
// Generate summary
const summary = this.generateComparisonSummary(comparisons);
return {
period1,
period2,
metrics: comparisons,
summary,
};
}
/**
* Invalidate cache when data changes
*/
async invalidateMetricsCache(
tenantId: string,
affectedPeriods?: MetricPeriod[]
): Promise<void> {
if (this.cache) {
await this.cache.invalidateCache(tenantId, affectedPeriods);
}
}
/**
* Warmup cache for a tenant
*/
async warmupCache(tenantId: string): Promise<{ success: number; failed: number }> {
if (!this.cache) {
return { success: 0, failed: 0 };
}
return this.cache.warmupCache(tenantId, async (metric, period) => {
const { dateFrom, dateTo } = getPeriodDateRange(period);
return this.calculateSingleMetric(
tenantId,
metric,
dateFrom,
dateTo,
this.options.defaultCurrency || 'MXN'
);
});
}
/**
* Get cache statistics
*/
async getCacheStats() {
if (!this.cache) {
return null;
}
return this.cache.getStats();
}
// ============================================================================
// Private Methods
// ============================================================================
/**
* Get metric with caching support
*/
private async getMetricWithCache<T>(
tenantId: string,
metric: MetricType,
period: MetricPeriod,
calculateFn: () => Promise<T>
): Promise<T> {
if (this.cache) {
return this.cache.getOrCalculate(tenantId, metric, period, calculateFn);
}
return calculateFn();
}
/**
* Calculate a single metric value
*/
private async calculateSingleMetric(
tenantId: string,
metric: MetricType,
dateFrom: Date,
dateTo: Date,
currency: string
): Promise<MetricValue> {
const options = { currency };
switch (metric) {
// Core metrics
case 'revenue': {
const result = await this.coreMetrics.calculateRevenue(tenantId, dateFrom, dateTo, options);
return createMetricValue(result.totalRevenue.amount, { currency });
}
case 'expenses': {
const result = await this.coreMetrics.calculateExpenses(tenantId, dateFrom, dateTo, options);
return createMetricValue(result.totalExpenses.amount, { currency });
}
case 'gross_profit': {
const result = await this.coreMetrics.calculateGrossProfit(tenantId, dateFrom, dateTo, options);
return createMetricValue(result.profit.amount, { currency });
}
case 'net_profit': {
const result = await this.coreMetrics.calculateNetProfit(tenantId, dateFrom, dateTo, options);
return createMetricValue(result.profit.amount, { currency });
}
case 'cash_flow': {
const result = await this.coreMetrics.calculateCashFlow(tenantId, dateFrom, dateTo, options);
return createMetricValue(result.netCashFlow.amount, { currency });
}
case 'accounts_receivable': {
const result = await this.coreMetrics.calculateAccountsReceivable(tenantId, dateTo, options);
return createMetricValue(result.totalReceivable.amount, { currency });
}
case 'accounts_payable': {
const result = await this.coreMetrics.calculateAccountsPayable(tenantId, dateTo, options);
return createMetricValue(result.totalPayable.amount, { currency });
}
case 'vat_position': {
const result = await this.coreMetrics.calculateVATPosition(tenantId, dateFrom, dateTo, options);
return createMetricValue(result.netPosition.amount, { currency });
}
// Startup metrics
case 'mrr': {
const result = await this.startupMetrics.calculateMRR(tenantId, dateTo, options);
return createMetricValue(result.mrr.amount, { currency });
}
case 'arr': {
const result = await this.startupMetrics.calculateARR(tenantId, dateTo, options);
return createMetricValue(result.arr.amount, { currency });
}
case 'churn_rate': {
const result = await this.startupMetrics.calculateChurnRate(tenantId, dateFrom, dateTo, options);
return createMetricValue(result.churnRate.value * 100, { unit: '%' });
}
case 'cac': {
const result = await this.startupMetrics.calculateCAC(tenantId, dateFrom, dateTo, options);
return createMetricValue(result.cac.amount, { currency });
}
case 'ltv': {
const result = await this.startupMetrics.calculateLTV(tenantId, options);
return createMetricValue(result.ltv.amount, { currency });
}
case 'ltv_cac_ratio': {
const result = await this.startupMetrics.calculateLTVCACRatio(tenantId, options);
return createMetricValue(result.ratio.value, { precision: 2 });
}
case 'burn_rate': {
const months = Math.ceil((dateTo.getTime() - dateFrom.getTime()) / (1000 * 60 * 60 * 24 * 30));
const result = await this.startupMetrics.calculateBurnRate(tenantId, months, options);
return createMetricValue(result.netBurnRate.amount, { currency });
}
// Enterprise metrics
case 'ebitda': {
const result = await this.enterpriseMetrics.calculateEBITDA(tenantId, dateFrom, dateTo, options);
return createMetricValue(result.ebitda.amount, { currency });
}
case 'roi': {
const result = await this.enterpriseMetrics.calculateROI(tenantId, dateFrom, dateTo, options);
return createMetricValue(result.roi.value * 100, { unit: '%' });
}
case 'roe': {
const result = await this.enterpriseMetrics.calculateROE(tenantId, dateFrom, dateTo, options);
return createMetricValue(result.roe.value * 100, { unit: '%' });
}
case 'current_ratio': {
const result = await this.enterpriseMetrics.calculateCurrentRatio(tenantId, dateTo, options);
return createMetricValue(result.ratio.value, { precision: 2 });
}
case 'quick_ratio': {
const result = await this.enterpriseMetrics.calculateQuickRatio(tenantId, dateTo, options);
return createMetricValue(result.ratio.value, { precision: 2 });
}
case 'debt_ratio': {
const result = await this.enterpriseMetrics.calculateDebtRatio(tenantId, dateTo, options);
return createMetricValue(result.ratio.value * 100, { unit: '%' });
}
default:
throw new Error(`Unknown metric type: ${metric}`);
}
}
/**
* Build metric comparison object
*/
private buildComparison(current: number, previous: number): MetricComparison {
const change = current - previous;
const changePercentage = previous !== 0 ? (change / previous) * 100 : 0;
return {
current,
previous,
change,
changePercentage,
trend: change > 0 ? 'up' : change < 0 ? 'down' : 'stable',
};
}
/**
* Calculate trend from a series of values
*/
private calculateTrend(values: number[]): MetricHistory['trend'] {
if (values.length < 2) {
return {
direction: 'stable',
averageChange: 0,
volatility: 0,
};
}
const changes: number[] = [];
for (let i = 1; i < values.length; i++) {
changes.push(values[i] - values[i - 1]);
}
const averageChange = changes.reduce((sum, c) => sum + c, 0) / changes.length;
// Calculate volatility (standard deviation of changes)
const squaredDiffs = changes.map(c => Math.pow(c - averageChange, 2));
const volatility = Math.sqrt(squaredDiffs.reduce((sum, d) => sum + d, 0) / changes.length);
let direction: 'up' | 'down' | 'stable';
if (averageChange > 0) {
direction = 'up';
} else if (averageChange < 0) {
direction = 'down';
} else {
direction = 'stable';
}
return {
direction,
averageChange,
volatility,
};
}
/**
* Generate comparison summary
*/
private generateComparisonSummary(comparisons: MetricComparisonReport['metrics']): string {
const improvements = comparisons.filter(c => c.trend === 'up');
const declines = comparisons.filter(c => c.trend === 'down');
const parts: string[] = [];
if (improvements.length > 0) {
parts.push(`${improvements.length} metricas mejoraron`);
}
if (declines.length > 0) {
parts.push(`${declines.length} metricas empeoraron`);
}
if (parts.length === 0) {
return 'Sin cambios significativos entre los periodos.';
}
// Highlight most significant changes
const sorted = [...comparisons].sort((a, b) =>
Math.abs(b.changePercentage) - Math.abs(a.changePercentage)
);
if (sorted.length > 0 && Math.abs(sorted[0].changePercentage) > 10) {
const most = sorted[0];
const direction = most.trend === 'up' ? 'aumento' : 'disminuyo';
parts.push(
`Cambio mas significativo: ${most.metric} ${direction} ${Math.abs(most.changePercentage).toFixed(1)}%`
);
}
return parts.join('. ') + '.';
}
}
// Factory functions
let metricsServiceInstance: MetricsService | null = null;
export function getMetricsService(
db: DatabaseConnection,
redis?: Redis,
options?: MetricsServiceOptions
): MetricsService {
if (!metricsServiceInstance) {
metricsServiceInstance = new MetricsService(db, redis, options);
}
return metricsServiceInstance;
}
export function createMetricsService(
db: DatabaseConnection,
redis?: Redis,
options?: MetricsServiceOptions
): MetricsService {
return new MetricsService(db, redis, options);
}

View File

@@ -0,0 +1,734 @@
/**
* Metrics Engine Types
*
* Type definitions for the Horux Strategy metrics system.
* Supports Core, Startup, and Enterprise metrics with multi-tenant support.
*/
// ============================================================================
// Time Period Types
// ============================================================================
export type PeriodType =
| 'daily'
| 'weekly'
| 'monthly'
| 'quarterly'
| 'yearly'
| 'custom';
export interface DateRange {
dateFrom: Date;
dateTo: Date;
}
export interface MetricPeriod {
type: PeriodType;
year: number;
month?: number; // 1-12
quarter?: number; // 1-4
week?: number; // 1-53
day?: number; // 1-31
dateRange?: DateRange;
}
export interface PeriodComparison {
currentPeriod: MetricPeriod;
previousPeriod: MetricPeriod;
}
// ============================================================================
// Metric Categories
// ============================================================================
export type MetricCategory =
| 'core'
| 'startup'
| 'enterprise';
export type CoreMetricType =
| 'revenue'
| 'expenses'
| 'gross_profit'
| 'net_profit'
| 'cash_flow'
| 'accounts_receivable'
| 'accounts_payable'
| 'aging_receivable'
| 'aging_payable'
| 'vat_position';
export type StartupMetricType =
| 'mrr'
| 'arr'
| 'churn_rate'
| 'cac'
| 'ltv'
| 'ltv_cac_ratio'
| 'runway'
| 'burn_rate';
export type EnterpriseMetricType =
| 'ebitda'
| 'roi'
| 'roe'
| 'current_ratio'
| 'quick_ratio'
| 'debt_ratio';
export type MetricType = CoreMetricType | StartupMetricType | EnterpriseMetricType;
// ============================================================================
// Metric Values
// ============================================================================
export interface MonetaryValue {
amount: number;
currency: string;
}
export interface PercentageValue {
value: number;
formatted: string;
}
export interface RatioValue {
value: number;
numerator: number;
denominator: number;
}
export interface MetricValue {
raw: number;
formatted: string;
currency?: string;
unit?: string;
precision?: number;
}
// ============================================================================
// Core Metric Results
// ============================================================================
export interface RevenueResult {
totalRevenue: MonetaryValue;
byCategory: Array<{
categoryId: string;
categoryName: string;
amount: MonetaryValue;
percentage: number;
}>;
byProduct?: Array<{
productId: string;
productName: string;
amount: MonetaryValue;
quantity: number;
}>;
invoiceCount: number;
averageInvoiceValue: MonetaryValue;
}
export interface ExpensesResult {
totalExpenses: MonetaryValue;
byCategory: Array<{
categoryId: string;
categoryName: string;
amount: MonetaryValue;
percentage: number;
}>;
fixedExpenses: MonetaryValue;
variableExpenses: MonetaryValue;
expenseCount: number;
}
export interface ProfitResult {
profit: MonetaryValue;
revenue: MonetaryValue;
costs: MonetaryValue;
margin: PercentageValue;
}
export interface CashFlowResult {
netCashFlow: MonetaryValue;
operatingActivities: MonetaryValue;
investingActivities: MonetaryValue;
financingActivities: MonetaryValue;
openingBalance: MonetaryValue;
closingBalance: MonetaryValue;
breakdown: Array<{
date: Date;
inflow: number;
outflow: number;
netFlow: number;
balance: number;
}>;
}
export interface AccountsReceivableResult {
totalReceivable: MonetaryValue;
current: MonetaryValue;
overdue: MonetaryValue;
overduePercentage: PercentageValue;
customerCount: number;
invoiceCount: number;
averageDaysOutstanding: number;
}
export interface AccountsPayableResult {
totalPayable: MonetaryValue;
current: MonetaryValue;
overdue: MonetaryValue;
overduePercentage: PercentageValue;
supplierCount: number;
invoiceCount: number;
averageDaysPayable: number;
}
export type AgingBucket =
| 'current'
| '1-30'
| '31-60'
| '61-90'
| '90+';
export interface AgingBucketData {
bucket: AgingBucket;
label: string;
amount: MonetaryValue;
count: number;
percentage: number;
}
export interface AgingReportResult {
type: 'receivable' | 'payable';
asOfDate: Date;
totalAmount: MonetaryValue;
buckets: AgingBucketData[];
details: Array<{
entityId: string;
entityName: string;
buckets: Record<AgingBucket, number>;
total: number;
}>;
}
export interface VATPositionResult {
vatCollected: MonetaryValue;
vatPaid: MonetaryValue;
netPosition: MonetaryValue;
isPayable: boolean;
breakdown: Array<{
rate: number;
collected: number;
paid: number;
net: number;
}>;
}
// ============================================================================
// Startup Metric Results
// ============================================================================
export interface MRRResult {
mrr: MonetaryValue;
newMRR: MonetaryValue;
expansionMRR: MonetaryValue;
contractionMRR: MonetaryValue;
churnedMRR: MonetaryValue;
netNewMRR: MonetaryValue;
customerCount: number;
arpu: MonetaryValue;
}
export interface ARRResult {
arr: MonetaryValue;
mrr: MonetaryValue;
growthRate: PercentageValue;
projectedEndOfYear: MonetaryValue;
}
export interface ChurnRateResult {
churnRate: PercentageValue;
churnedCustomers: number;
totalCustomers: number;
churnedMRR: MonetaryValue;
revenueChurnRate: PercentageValue;
}
export interface CACResult {
cac: MonetaryValue;
totalMarketingSpend: MonetaryValue;
totalSalesSpend: MonetaryValue;
newCustomers: number;
breakdown: {
marketing: MonetaryValue;
sales: MonetaryValue;
other: MonetaryValue;
};
}
export interface LTVResult {
ltv: MonetaryValue;
averageCustomerLifespan: number; // In months
arpu: MonetaryValue;
grossMargin: PercentageValue;
}
export interface LTVCACRatioResult {
ratio: RatioValue;
ltv: MonetaryValue;
cac: MonetaryValue;
isHealthy: boolean;
recommendation: string;
}
export interface RunwayResult {
runwayMonths: number;
currentCash: MonetaryValue;
monthlyBurnRate: MonetaryValue;
projectedZeroDate: Date | null;
isHealthy: boolean;
recommendation: string;
}
export interface BurnRateResult {
grossBurnRate: MonetaryValue;
netBurnRate: MonetaryValue;
revenue: MonetaryValue;
expenses: MonetaryValue;
monthlyTrend: Array<{
month: string;
grossBurn: number;
netBurn: number;
revenue: number;
}>;
}
// ============================================================================
// Enterprise Metric Results
// ============================================================================
export interface EBITDAResult {
ebitda: MonetaryValue;
operatingIncome: MonetaryValue;
depreciation: MonetaryValue;
amortization: MonetaryValue;
margin: PercentageValue;
revenue: MonetaryValue;
}
export interface ROIResult {
roi: PercentageValue;
netProfit: MonetaryValue;
totalInvestment: MonetaryValue;
annualized: PercentageValue;
}
export interface ROEResult {
roe: PercentageValue;
netIncome: MonetaryValue;
shareholdersEquity: MonetaryValue;
annualized: PercentageValue;
}
export interface CurrentRatioResult {
ratio: RatioValue;
currentAssets: MonetaryValue;
currentLiabilities: MonetaryValue;
isHealthy: boolean;
interpretation: string;
}
export interface QuickRatioResult {
ratio: RatioValue;
currentAssets: MonetaryValue;
inventory: MonetaryValue;
currentLiabilities: MonetaryValue;
isHealthy: boolean;
interpretation: string;
}
export interface DebtRatioResult {
ratio: RatioValue;
totalDebt: MonetaryValue;
totalAssets: MonetaryValue;
interpretation: string;
}
// ============================================================================
// Dashboard & Aggregated Results
// ============================================================================
export interface DashboardMetrics {
period: MetricPeriod;
generatedAt: Date;
currency: string;
// Core
revenue: MetricValue;
expenses: MetricValue;
netProfit: MetricValue;
profitMargin: MetricValue;
cashFlow: MetricValue;
accountsReceivable: MetricValue;
accountsPayable: MetricValue;
// Comparison with previous period
comparison: {
revenue: MetricComparison;
expenses: MetricComparison;
netProfit: MetricComparison;
cashFlow: MetricComparison;
};
// Optional startup/enterprise metrics
startup?: {
mrr: MetricValue;
arr: MetricValue;
churnRate: MetricValue;
runway: MetricValue;
};
enterprise?: {
ebitda: MetricValue;
currentRatio: MetricValue;
quickRatio: MetricValue;
};
}
export interface MetricComparison {
current: number;
previous: number;
change: number;
changePercentage: number;
trend: 'up' | 'down' | 'stable';
}
export interface MetricHistory {
metric: MetricType;
periods: Array<{
period: MetricPeriod;
value: MetricValue;
timestamp: Date;
}>;
trend: {
direction: 'up' | 'down' | 'stable';
averageChange: number;
volatility: number;
};
}
export interface MetricComparisonReport {
period1: MetricPeriod;
period2: MetricPeriod;
metrics: Array<{
metric: MetricType;
period1Value: MetricValue;
period2Value: MetricValue;
change: number;
changePercentage: number;
trend: 'up' | 'down' | 'stable';
}>;
summary: string;
}
// ============================================================================
// Anomaly Detection
// ============================================================================
export type AnomalyType =
| 'significant_variation'
| 'negative_trend'
| 'out_of_range'
| 'sudden_spike'
| 'sudden_drop';
export type AnomalySeverity = 'low' | 'medium' | 'high' | 'critical';
export interface Anomaly {
id: string;
metric: MetricType;
type: AnomalyType;
severity: AnomalySeverity;
description: string;
detectedAt: Date;
period: MetricPeriod;
currentValue: number;
expectedValue: number;
deviation: number;
deviationPercentage: number;
recommendation: string;
}
export interface AnomalyDetectionResult {
tenantId: string;
period: MetricPeriod;
analyzedAt: Date;
anomalies: Anomaly[];
healthScore: number; // 0-100
alertLevel: 'none' | 'watch' | 'warning' | 'critical';
summary: string;
}
// ============================================================================
// Cache Types
// ============================================================================
export interface CachedMetric {
key: string;
tenantId: string;
metric: MetricType;
period: MetricPeriod;
value: unknown;
calculatedAt: Date;
expiresAt: Date;
version: number;
}
export interface CacheConfig {
ttlSeconds: number;
staleWhileRevalidate: boolean;
warmupEnabled: boolean;
}
export interface CacheStats {
hits: number;
misses: number;
hitRate: number;
totalEntries: number;
memoryUsage: number;
}
// ============================================================================
// Query Options
// ============================================================================
export interface MetricQueryOptions {
tenantId: string;
schemaName?: string;
currency?: string;
includeDetails?: boolean;
useCache?: boolean;
forceRefresh?: boolean;
}
// ============================================================================
// Utility Types
// ============================================================================
export type MetricResult<T extends MetricType> =
T extends 'revenue' ? RevenueResult :
T extends 'expenses' ? ExpensesResult :
T extends 'gross_profit' ? ProfitResult :
T extends 'net_profit' ? ProfitResult :
T extends 'cash_flow' ? CashFlowResult :
T extends 'accounts_receivable' ? AccountsReceivableResult :
T extends 'accounts_payable' ? AccountsPayableResult :
T extends 'aging_receivable' ? AgingReportResult :
T extends 'aging_payable' ? AgingReportResult :
T extends 'vat_position' ? VATPositionResult :
T extends 'mrr' ? MRRResult :
T extends 'arr' ? ARRResult :
T extends 'churn_rate' ? ChurnRateResult :
T extends 'cac' ? CACResult :
T extends 'ltv' ? LTVResult :
T extends 'ltv_cac_ratio' ? LTVCACRatioResult :
T extends 'runway' ? RunwayResult :
T extends 'burn_rate' ? BurnRateResult :
T extends 'ebitda' ? EBITDAResult :
T extends 'roi' ? ROIResult :
T extends 'roe' ? ROEResult :
T extends 'current_ratio' ? CurrentRatioResult :
T extends 'quick_ratio' ? QuickRatioResult :
T extends 'debt_ratio' ? DebtRatioResult :
never;
// ============================================================================
// Export Helper Functions for Type Formatting
// ============================================================================
export function formatCurrency(amount: number, currency: string = 'MXN'): string {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
export function formatPercentage(value: number, decimals: number = 2): string {
return `${(value * 100).toFixed(decimals)}%`;
}
export function formatNumber(value: number, decimals: number = 2): string {
return new Intl.NumberFormat('es-MX', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
}
export function createMonetaryValue(amount: number, currency: string = 'MXN'): MonetaryValue {
return { amount, currency };
}
export function createPercentageValue(value: number): PercentageValue {
return {
value,
formatted: formatPercentage(value),
};
}
export function createRatioValue(numerator: number, denominator: number): RatioValue {
return {
value: denominator !== 0 ? numerator / denominator : 0,
numerator,
denominator,
};
}
export function createMetricValue(
raw: number,
options?: { currency?: string; unit?: string; precision?: number }
): MetricValue {
const precision = options?.precision ?? 2;
let formatted: string;
if (options?.currency) {
formatted = formatCurrency(raw, options.currency);
} else if (options?.unit === '%') {
formatted = formatPercentage(raw / 100, precision);
} else {
formatted = formatNumber(raw, precision);
}
return {
raw,
formatted,
...options,
};
}
// ============================================================================
// Period Helper Functions
// ============================================================================
export function createPeriodFromDate(date: Date, type: PeriodType): MetricPeriod {
const year = date.getFullYear();
const month = date.getMonth() + 1;
switch (type) {
case 'daily':
return { type, year, month, day: date.getDate() };
case 'weekly':
return { type, year, week: getWeekNumber(date) };
case 'monthly':
return { type, year, month };
case 'quarterly':
return { type, year, quarter: Math.ceil(month / 3) };
case 'yearly':
return { type, year };
default:
return { type: 'monthly', year, month };
}
}
export function getPeriodDateRange(period: MetricPeriod): DateRange {
if (period.dateRange) {
return period.dateRange;
}
let dateFrom: Date;
let dateTo: Date;
switch (period.type) {
case 'daily':
dateFrom = new Date(period.year, (period.month || 1) - 1, period.day || 1);
dateTo = new Date(period.year, (period.month || 1) - 1, period.day || 1, 23, 59, 59, 999);
break;
case 'weekly':
const firstDayOfYear = new Date(period.year, 0, 1);
const daysOffset = (period.week || 1 - 1) * 7;
dateFrom = new Date(firstDayOfYear.getTime() + daysOffset * 24 * 60 * 60 * 1000);
dateTo = new Date(dateFrom.getTime() + 6 * 24 * 60 * 60 * 1000);
dateTo.setHours(23, 59, 59, 999);
break;
case 'monthly':
dateFrom = new Date(period.year, (period.month || 1) - 1, 1);
dateTo = new Date(period.year, period.month || 1, 0, 23, 59, 59, 999);
break;
case 'quarterly':
const quarterStartMonth = ((period.quarter || 1) - 1) * 3;
dateFrom = new Date(period.year, quarterStartMonth, 1);
dateTo = new Date(period.year, quarterStartMonth + 3, 0, 23, 59, 59, 999);
break;
case 'yearly':
dateFrom = new Date(period.year, 0, 1);
dateTo = new Date(period.year, 11, 31, 23, 59, 59, 999);
break;
default:
dateFrom = new Date(period.year, 0, 1);
dateTo = new Date(period.year, 11, 31, 23, 59, 59, 999);
}
return { dateFrom, dateTo };
}
export function getPreviousPeriod(period: MetricPeriod): MetricPeriod {
switch (period.type) {
case 'daily':
const prevDay = new Date(period.year, (period.month || 1) - 1, (period.day || 1) - 1);
return createPeriodFromDate(prevDay, 'daily');
case 'weekly':
if ((period.week || 1) > 1) {
return { ...period, week: (period.week || 1) - 1 };
}
return { type: 'weekly', year: period.year - 1, week: 52 };
case 'monthly':
if ((period.month || 1) > 1) {
return { ...period, month: (period.month || 1) - 1 };
}
return { type: 'monthly', year: period.year - 1, month: 12 };
case 'quarterly':
if ((period.quarter || 1) > 1) {
return { ...period, quarter: (period.quarter || 1) - 1 };
}
return { type: 'quarterly', year: period.year - 1, quarter: 4 };
case 'yearly':
return { type: 'yearly', year: period.year - 1 };
default:
return { ...period, year: period.year - 1 };
}
}
function getWeekNumber(date: Date): number {
const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
const pastDaysOfYear = (date.getTime() - firstDayOfYear.getTime()) / 86400000;
return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7);
}
export function periodToString(period: MetricPeriod): string {
switch (period.type) {
case 'daily':
return `${period.year}-${String(period.month).padStart(2, '0')}-${String(period.day).padStart(2, '0')}`;
case 'weekly':
return `${period.year}-W${String(period.week).padStart(2, '0')}`;
case 'monthly':
return `${period.year}-${String(period.month).padStart(2, '0')}`;
case 'quarterly':
return `${period.year}-Q${period.quarter}`;
case 'yearly':
return `${period.year}`;
default:
return `${period.year}`;
}
}

View File

@@ -0,0 +1,678 @@
/**
* Startup Metrics Calculator
*
* SaaS and subscription-based business metrics:
* - MRR (Monthly Recurring Revenue)
* - ARR (Annual Recurring Revenue)
* - Churn Rate
* - CAC (Customer Acquisition Cost)
* - LTV (Lifetime Value)
* - LTV/CAC Ratio
* - Runway
* - Burn Rate
*/
import { DatabaseConnection, TenantContext } from '@horux/database';
import {
MRRResult,
ARRResult,
ChurnRateResult,
CACResult,
LTVResult,
LTVCACRatioResult,
RunwayResult,
BurnRateResult,
createMonetaryValue,
createPercentageValue,
createRatioValue,
MetricQueryOptions,
} from './metrics.types';
export class StartupMetricsCalculator {
private db: DatabaseConnection;
constructor(db: DatabaseConnection) {
this.db = db;
}
/**
* Get tenant context for queries
*/
private getTenantContext(tenantId: string, schemaName?: string): TenantContext {
return {
tenantId,
schemaName: schemaName || `tenant_${tenantId}`,
};
}
/**
* Calculate MRR (Monthly Recurring Revenue)
*/
async calculateMRR(
tenantId: string,
asOfDate: Date,
options?: MetricQueryOptions
): Promise<MRRResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
// Get first day of month
const monthStart = new Date(asOfDate.getFullYear(), asOfDate.getMonth(), 1);
const monthEnd = new Date(asOfDate.getFullYear(), asOfDate.getMonth() + 1, 0);
const prevMonthStart = new Date(asOfDate.getFullYear(), asOfDate.getMonth() - 1, 1);
const prevMonthEnd = new Date(asOfDate.getFullYear(), asOfDate.getMonth(), 0);
// Get current MRR from active subscriptions
const currentMRRQuery = await this.db.query<{
mrr: string;
customer_count: string;
}>(
`SELECT
COALESCE(SUM(
CASE
WHEN billing_period = 'monthly' THEN amount
WHEN billing_period = 'quarterly' THEN amount / 3
WHEN billing_period = 'yearly' THEN amount / 12
ELSE amount
END
), 0) as mrr,
COUNT(DISTINCT customer_id) as customer_count
FROM subscriptions
WHERE status = 'active'
AND start_date <= $1
AND (end_date IS NULL OR end_date > $1)`,
[asOfDate],
{ tenant }
);
// Get new MRR (new subscriptions this month)
const newMRRQuery = await this.db.query<{ mrr: string }>(
`SELECT COALESCE(SUM(
CASE
WHEN billing_period = 'monthly' THEN amount
WHEN billing_period = 'quarterly' THEN amount / 3
WHEN billing_period = 'yearly' THEN amount / 12
ELSE amount
END
), 0) as mrr
FROM subscriptions
WHERE status = 'active'
AND start_date >= $1
AND start_date <= $2`,
[monthStart, monthEnd],
{ tenant }
);
// Get expansion MRR (upgrades this month)
const expansionMRRQuery = await this.db.query<{ mrr: string }>(
`SELECT COALESCE(SUM(
CASE
WHEN new_billing_period = 'monthly' THEN new_amount - old_amount
WHEN new_billing_period = 'quarterly' THEN (new_amount - old_amount) / 3
WHEN new_billing_period = 'yearly' THEN (new_amount - old_amount) / 12
ELSE new_amount - old_amount
END
), 0) as mrr
FROM subscription_changes
WHERE change_type = 'upgrade'
AND change_date >= $1
AND change_date <= $2`,
[monthStart, monthEnd],
{ tenant }
);
// Get contraction MRR (downgrades this month)
const contractionMRRQuery = await this.db.query<{ mrr: string }>(
`SELECT COALESCE(SUM(
CASE
WHEN new_billing_period = 'monthly' THEN old_amount - new_amount
WHEN new_billing_period = 'quarterly' THEN (old_amount - new_amount) / 3
WHEN new_billing_period = 'yearly' THEN (old_amount - new_amount) / 12
ELSE old_amount - new_amount
END
), 0) as mrr
FROM subscription_changes
WHERE change_type = 'downgrade'
AND change_date >= $1
AND change_date <= $2`,
[monthStart, monthEnd],
{ tenant }
);
// Get churned MRR (cancellations this month)
const churnedMRRQuery = await this.db.query<{ mrr: string }>(
`SELECT COALESCE(SUM(
CASE
WHEN billing_period = 'monthly' THEN amount
WHEN billing_period = 'quarterly' THEN amount / 3
WHEN billing_period = 'yearly' THEN amount / 12
ELSE amount
END
), 0) as mrr
FROM subscriptions
WHERE status = 'cancelled'
AND cancelled_at >= $1
AND cancelled_at <= $2`,
[monthStart, monthEnd],
{ tenant }
);
const mrr = parseFloat(currentMRRQuery.rows[0]?.mrr || '0');
const customerCount = parseInt(currentMRRQuery.rows[0]?.customer_count || '0');
const newMRR = parseFloat(newMRRQuery.rows[0]?.mrr || '0');
const expansionMRR = parseFloat(expansionMRRQuery.rows[0]?.mrr || '0');
const contractionMRR = parseFloat(contractionMRRQuery.rows[0]?.mrr || '0');
const churnedMRR = parseFloat(churnedMRRQuery.rows[0]?.mrr || '0');
const netNewMRR = newMRR + expansionMRR - contractionMRR - churnedMRR;
const arpu = customerCount > 0 ? mrr / customerCount : 0;
return {
mrr: createMonetaryValue(mrr, currency),
newMRR: createMonetaryValue(newMRR, currency),
expansionMRR: createMonetaryValue(expansionMRR, currency),
contractionMRR: createMonetaryValue(contractionMRR, currency),
churnedMRR: createMonetaryValue(churnedMRR, currency),
netNewMRR: createMonetaryValue(netNewMRR, currency),
customerCount,
arpu: createMonetaryValue(arpu, currency),
};
}
/**
* Calculate ARR (Annual Recurring Revenue)
*/
async calculateARR(
tenantId: string,
asOfDate: Date,
options?: MetricQueryOptions
): Promise<ARRResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
// Get current MRR first
const mrrResult = await this.calculateMRR(tenantId, asOfDate, options);
const mrr = mrrResult.mrr.amount;
const arr = mrr * 12;
// Get MRR from 12 months ago to calculate growth rate
const yearAgo = new Date(asOfDate);
yearAgo.setFullYear(yearAgo.getFullYear() - 1);
const prevYearMRRQuery = await this.db.query<{ mrr: string }>(
`SELECT COALESCE(SUM(
CASE
WHEN billing_period = 'monthly' THEN amount
WHEN billing_period = 'quarterly' THEN amount / 3
WHEN billing_period = 'yearly' THEN amount / 12
ELSE amount
END
), 0) as mrr
FROM subscriptions
WHERE status = 'active'
AND start_date <= $1
AND (end_date IS NULL OR end_date > $1)`,
[yearAgo],
{ tenant }
);
const prevMRR = parseFloat(prevYearMRRQuery.rows[0]?.mrr || '0');
const prevARR = prevMRR * 12;
const growthRate = prevARR > 0 ? (arr - prevARR) / prevARR : 0;
// Project end of year ARR based on current growth rate
const monthsRemaining = 12 - (asOfDate.getMonth() + 1);
const monthlyGrowthRate = prevMRR > 0 ? Math.pow(mrr / prevMRR, 1 / 12) - 1 : 0;
const projectedMRR = mrr * Math.pow(1 + monthlyGrowthRate, monthsRemaining);
const projectedEndOfYear = projectedMRR * 12;
return {
arr: createMonetaryValue(arr, currency),
mrr: createMonetaryValue(mrr, currency),
growthRate: createPercentageValue(growthRate),
projectedEndOfYear: createMonetaryValue(projectedEndOfYear, currency),
};
}
/**
* Calculate churn rate
*/
async calculateChurnRate(
tenantId: string,
dateFrom: Date,
dateTo: Date,
options?: MetricQueryOptions
): Promise<ChurnRateResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
// Get customers at start of period
const startCustomersQuery = await this.db.query<{
count: string;
mrr: string;
}>(
`SELECT
COUNT(DISTINCT customer_id) as count,
COALESCE(SUM(
CASE
WHEN billing_period = 'monthly' THEN amount
WHEN billing_period = 'quarterly' THEN amount / 3
WHEN billing_period = 'yearly' THEN amount / 12
ELSE amount
END
), 0) as mrr
FROM subscriptions
WHERE status = 'active'
AND start_date <= $1
AND (end_date IS NULL OR end_date > $1)`,
[dateFrom],
{ tenant }
);
// Get churned customers in period
const churnedQuery = await this.db.query<{
count: string;
mrr: string;
}>(
`SELECT
COUNT(DISTINCT customer_id) as count,
COALESCE(SUM(
CASE
WHEN billing_period = 'monthly' THEN amount
WHEN billing_period = 'quarterly' THEN amount / 3
WHEN billing_period = 'yearly' THEN amount / 12
ELSE amount
END
), 0) as mrr
FROM subscriptions
WHERE status = 'cancelled'
AND cancelled_at >= $1
AND cancelled_at <= $2`,
[dateFrom, dateTo],
{ tenant }
);
const totalCustomers = parseInt(startCustomersQuery.rows[0]?.count || '0');
const startMRR = parseFloat(startCustomersQuery.rows[0]?.mrr || '0');
const churnedCustomers = parseInt(churnedQuery.rows[0]?.count || '0');
const churnedMRR = parseFloat(churnedQuery.rows[0]?.mrr || '0');
const churnRate = totalCustomers > 0 ? churnedCustomers / totalCustomers : 0;
const revenueChurnRate = startMRR > 0 ? churnedMRR / startMRR : 0;
return {
churnRate: createPercentageValue(churnRate),
churnedCustomers,
totalCustomers,
churnedMRR: createMonetaryValue(churnedMRR, currency),
revenueChurnRate: createPercentageValue(revenueChurnRate),
};
}
/**
* Calculate CAC (Customer Acquisition Cost)
*/
async calculateCAC(
tenantId: string,
dateFrom: Date,
dateTo: Date,
options?: MetricQueryOptions
): Promise<CACResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
// Get marketing and sales expenses
const expensesQuery = await this.db.query<{
marketing: string;
sales: string;
other: string;
}>(
`SELECT
COALESCE(SUM(CASE WHEN c.type = 'marketing' THEN e.total_amount ELSE 0 END), 0) as marketing,
COALESCE(SUM(CASE WHEN c.type = 'sales' THEN e.total_amount ELSE 0 END), 0) as sales,
COALESCE(SUM(CASE WHEN c.type = 'acquisition' AND c.type NOT IN ('marketing', 'sales') THEN e.total_amount ELSE 0 END), 0) as other
FROM expenses e
JOIN categories c ON c.id = e.category_id
WHERE e.status = 'paid'
AND e.expense_date >= $1
AND e.expense_date <= $2
AND c.type IN ('marketing', 'sales', 'acquisition')`,
[dateFrom, dateTo],
{ tenant }
);
// Get new customers acquired
const customersQuery = await this.db.query<{ count: string }>(
`SELECT COUNT(DISTINCT customer_id) as count
FROM subscriptions
WHERE start_date >= $1
AND start_date <= $2
AND is_first_subscription = true`,
[dateFrom, dateTo],
{ tenant }
);
const marketing = parseFloat(expensesQuery.rows[0]?.marketing || '0');
const sales = parseFloat(expensesQuery.rows[0]?.sales || '0');
const other = parseFloat(expensesQuery.rows[0]?.other || '0');
const totalSpend = marketing + sales + other;
const newCustomers = parseInt(customersQuery.rows[0]?.count || '0');
const cac = newCustomers > 0 ? totalSpend / newCustomers : 0;
return {
cac: createMonetaryValue(cac, currency),
totalMarketingSpend: createMonetaryValue(marketing, currency),
totalSalesSpend: createMonetaryValue(sales, currency),
newCustomers,
breakdown: {
marketing: createMonetaryValue(marketing, currency),
sales: createMonetaryValue(sales, currency),
other: createMonetaryValue(other, currency),
},
};
}
/**
* Calculate LTV (Customer Lifetime Value)
*/
async calculateLTV(
tenantId: string,
options?: MetricQueryOptions
): Promise<LTVResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
// Get average customer lifespan in months
const lifespanQuery = await this.db.query<{
avg_lifespan_months: string;
}>(
`WITH customer_lifespans AS (
SELECT
customer_id,
MIN(start_date) as first_subscription,
CASE
WHEN MAX(cancelled_at) IS NOT NULL THEN MAX(cancelled_at)
ELSE CURRENT_DATE
END as last_active,
EXTRACT(MONTH FROM AGE(
CASE
WHEN MAX(cancelled_at) IS NOT NULL THEN MAX(cancelled_at)
ELSE CURRENT_DATE
END,
MIN(start_date)
)) + EXTRACT(YEAR FROM AGE(
CASE
WHEN MAX(cancelled_at) IS NOT NULL THEN MAX(cancelled_at)
ELSE CURRENT_DATE
END,
MIN(start_date)
)) * 12 as lifespan_months
FROM subscriptions
GROUP BY customer_id
)
SELECT COALESCE(AVG(lifespan_months), 12) as avg_lifespan_months
FROM customer_lifespans
WHERE lifespan_months > 0`,
[],
{ tenant }
);
// Get current ARPU
const arpuQuery = await this.db.query<{
arpu: string;
}>(
`WITH active_subscriptions AS (
SELECT
customer_id,
SUM(
CASE
WHEN billing_period = 'monthly' THEN amount
WHEN billing_period = 'quarterly' THEN amount / 3
WHEN billing_period = 'yearly' THEN amount / 12
ELSE amount
END
) as monthly_revenue
FROM subscriptions
WHERE status = 'active'
GROUP BY customer_id
)
SELECT COALESCE(AVG(monthly_revenue), 0) as arpu
FROM active_subscriptions`,
[],
{ tenant }
);
// Get gross margin (assuming from settings or calculate from data)
const marginQuery = await this.db.query<{
gross_margin: string;
}>(
`WITH revenue AS (
SELECT COALESCE(SUM(total_amount), 0) as total
FROM invoices
WHERE status IN ('paid', 'partial')
AND issue_date >= CURRENT_DATE - INTERVAL '12 months'
),
cogs AS (
SELECT COALESCE(SUM(e.total_amount), 0) as total
FROM expenses e
JOIN categories c ON c.id = e.category_id
WHERE e.status = 'paid'
AND e.expense_date >= CURRENT_DATE - INTERVAL '12 months'
AND c.type = 'cogs'
)
SELECT
CASE
WHEN (SELECT total FROM revenue) > 0
THEN ((SELECT total FROM revenue) - (SELECT total FROM cogs)) / (SELECT total FROM revenue)
ELSE 0.7
END as gross_margin`,
[],
{ tenant }
);
const avgLifespanMonths = parseFloat(lifespanQuery.rows[0]?.avg_lifespan_months || '12');
const arpu = parseFloat(arpuQuery.rows[0]?.arpu || '0');
const grossMargin = parseFloat(marginQuery.rows[0]?.gross_margin || '0.7');
const ltv = arpu * avgLifespanMonths * grossMargin;
return {
ltv: createMonetaryValue(ltv, currency),
averageCustomerLifespan: avgLifespanMonths,
arpu: createMonetaryValue(arpu, currency),
grossMargin: createPercentageValue(grossMargin),
};
}
/**
* Calculate LTV/CAC Ratio
*/
async calculateLTVCACRatio(
tenantId: string,
options?: MetricQueryOptions
): Promise<LTVCACRatioResult> {
const currency = options?.currency || 'MXN';
// Get LTV
const ltvResult = await this.calculateLTV(tenantId, options);
const ltv = ltvResult.ltv.amount;
// Get CAC for last 12 months
const dateFrom = new Date();
dateFrom.setMonth(dateFrom.getMonth() - 12);
const dateTo = new Date();
const cacResult = await this.calculateCAC(tenantId, dateFrom, dateTo, options);
const cac = cacResult.cac.amount;
const ratio = cac > 0 ? ltv / cac : 0;
const isHealthy = ratio >= 3;
let recommendation: string;
if (ratio >= 5) {
recommendation = 'Excelente ratio. Considera aumentar la inversion en adquisicion de clientes.';
} else if (ratio >= 3) {
recommendation = 'Ratio saludable. El negocio es sostenible a largo plazo.';
} else if (ratio >= 1) {
recommendation = 'Ratio bajo. Necesitas mejorar la retencion o reducir costos de adquisicion.';
} else {
recommendation = 'Ratio critico. Estas perdiendo dinero por cada cliente adquirido.';
}
return {
ratio: createRatioValue(ltv, cac),
ltv: createMonetaryValue(ltv, currency),
cac: createMonetaryValue(cac, currency),
isHealthy,
recommendation,
};
}
/**
* Calculate runway (months of cash remaining)
*/
async calculateRunway(
tenantId: string,
currentCash: number,
options?: MetricQueryOptions
): Promise<RunwayResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
// Calculate average monthly burn rate from last 3 months
const burnRateResult = await this.calculateBurnRate(tenantId, 3, options);
const monthlyBurnRate = burnRateResult.netBurnRate.amount;
// Calculate runway
let runwayMonths: number;
let projectedZeroDate: Date | null = null;
let isHealthy: boolean;
let recommendation: string;
if (monthlyBurnRate <= 0) {
// Company is profitable or break-even
runwayMonths = Infinity;
isHealthy = true;
recommendation = 'La empresa es rentable. No hay runway que calcular.';
} else {
runwayMonths = currentCash / monthlyBurnRate;
projectedZeroDate = new Date();
projectedZeroDate.setMonth(projectedZeroDate.getMonth() + Math.floor(runwayMonths));
if (runwayMonths >= 18) {
isHealthy = true;
recommendation = 'Runway saludable. Tienes tiempo suficiente para crecer.';
} else if (runwayMonths >= 12) {
isHealthy = true;
recommendation = 'Runway adecuado. Considera empezar a planear tu proxima ronda o reducir gastos.';
} else if (runwayMonths >= 6) {
isHealthy = false;
recommendation = 'Runway limitado. Es urgente buscar financiamiento o reducir gastos significativamente.';
} else {
isHealthy = false;
recommendation = 'Runway critico. Accion inmediata requerida para sobrevivir.';
}
}
return {
runwayMonths: runwayMonths === Infinity ? 999 : Math.round(runwayMonths * 10) / 10,
currentCash: createMonetaryValue(currentCash, currency),
monthlyBurnRate: createMonetaryValue(monthlyBurnRate, currency),
projectedZeroDate,
isHealthy,
recommendation,
};
}
/**
* Calculate burn rate
*/
async calculateBurnRate(
tenantId: string,
months: number = 3,
options?: MetricQueryOptions
): Promise<BurnRateResult> {
const tenant = this.getTenantContext(tenantId, options?.schemaName);
const currency = options?.currency || 'MXN';
const dateFrom = new Date();
dateFrom.setMonth(dateFrom.getMonth() - months);
const dateTo = new Date();
// Get monthly breakdown
const monthlyQuery = await this.db.query<{
month: string;
revenue: string;
expenses: string;
}>(
`WITH monthly_data AS (
SELECT
DATE_TRUNC('month', d.date) as month,
COALESCE((
SELECT SUM(total_amount)
FROM invoices
WHERE status IN ('paid', 'partial')
AND DATE_TRUNC('month', issue_date) = DATE_TRUNC('month', d.date)
), 0) as revenue,
COALESCE((
SELECT SUM(total_amount)
FROM expenses
WHERE status = 'paid'
AND DATE_TRUNC('month', expense_date) = DATE_TRUNC('month', d.date)
), 0) as expenses
FROM generate_series($1::date, $2::date, '1 month') as d(date)
)
SELECT
TO_CHAR(month, 'YYYY-MM') as month,
revenue,
expenses
FROM monthly_data
ORDER BY month`,
[dateFrom, dateTo],
{ tenant }
);
let totalRevenue = 0;
let totalExpenses = 0;
const monthlyTrend = monthlyQuery.rows.map(row => {
const revenue = parseFloat(row.revenue);
const expenses = parseFloat(row.expenses);
totalRevenue += revenue;
totalExpenses += expenses;
return {
month: row.month,
revenue,
grossBurn: expenses,
netBurn: expenses - revenue,
};
});
const monthCount = monthlyTrend.length || 1;
const avgRevenue = totalRevenue / monthCount;
const avgExpenses = totalExpenses / monthCount;
const grossBurnRate = avgExpenses;
const netBurnRate = avgExpenses - avgRevenue;
return {
grossBurnRate: createMonetaryValue(grossBurnRate, currency),
netBurnRate: createMonetaryValue(Math.max(0, netBurnRate), currency),
revenue: createMonetaryValue(avgRevenue, currency),
expenses: createMonetaryValue(avgExpenses, currency),
monthlyTrend,
};
}
}
// Export singleton factory
let startupMetricsInstance: StartupMetricsCalculator | null = null;
export function getStartupMetricsCalculator(db: DatabaseConnection): StartupMetricsCalculator {
if (!startupMetricsInstance) {
startupMetricsInstance = new StartupMetricsCalculator(db);
}
return startupMetricsInstance;
}
export function createStartupMetricsCalculator(db: DatabaseConnection): StartupMetricsCalculator {
return new StartupMetricsCalculator(db);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,539 @@
/**
* FIEL Service
* Manejo de la Firma Electrónica Avanzada (FIEL) del SAT
*/
import * as crypto from 'crypto';
import { CertificateInfo, FIELValidationResult, FIELError } from './sat.types.js';
// Constantes para el manejo de certificados
const CERTIFICATE_HEADER = '-----BEGIN CERTIFICATE-----';
const CERTIFICATE_FOOTER = '-----END CERTIFICATE-----';
const RSA_PRIVATE_KEY_HEADER = '-----BEGIN RSA PRIVATE KEY-----';
const RSA_PRIVATE_KEY_FOOTER = '-----END RSA PRIVATE KEY-----';
const ENCRYPTED_PRIVATE_KEY_HEADER = '-----BEGIN ENCRYPTED PRIVATE KEY-----';
const ENCRYPTED_PRIVATE_KEY_FOOTER = '-----END ENCRYPTED PRIVATE KEY-----';
// Algoritmos de encripción
const ENCRYPTION_ALGORITHM = 'aes-256-cbc';
const HASH_ALGORITHM = 'sha256';
const SIGNATURE_ALGORITHM = 'RSA-SHA256';
/**
* Clase para el manejo de FIEL
*/
export class FIELService {
/**
* Valida un par de certificado y llave privada
*/
async validateFIEL(
cer: Buffer,
key: Buffer,
password: string
): Promise<FIELValidationResult> {
try {
// Obtener información del certificado
const certInfo = await this.getCertificateInfo(cer);
// Verificar que el certificado no esté expirado
if (!certInfo.isValid) {
return {
isValid: false,
certificateInfo: certInfo,
error: 'El certificado ha expirado o aún no es válido',
};
}
// Intentar descifrar la llave privada
let privateKey: crypto.KeyObject;
try {
privateKey = await this.decryptPrivateKey(key, password);
} catch (error) {
return {
isValid: false,
certificateInfo: certInfo,
error: 'Contraseña incorrecta o llave privada inválida',
};
}
// Verificar que la llave corresponda al certificado
const isMatch = await this.verifyKeyPair(cer, privateKey);
if (!isMatch) {
return {
isValid: false,
certificateInfo: certInfo,
error: 'La llave privada no corresponde al certificado',
};
}
return {
isValid: true,
certificateInfo: certInfo,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
return {
isValid: false,
error: `Error al validar FIEL: ${errorMessage}`,
};
}
}
/**
* Obtiene información del certificado
*/
async getCertificateInfo(cer: Buffer): Promise<CertificateInfo> {
try {
// Convertir el certificado a formato PEM si es necesario
const pemCert = this.toPEM(cer, 'CERTIFICATE');
// Crear objeto X509Certificate
const x509 = new crypto.X509Certificate(pemCert);
// Extraer información del subject
const subjectParts = this.parseX509Name(x509.subject);
const issuerParts = this.parseX509Name(x509.issuer);
// Extraer RFC del subject (puede estar en diferentes campos)
let rfc = '';
let nombre = '';
let email = '';
// El RFC generalmente está en el campo serialNumber o en el UID
if (subjectParts.serialNumber) {
rfc = subjectParts.serialNumber;
} else if (subjectParts.UID) {
rfc = subjectParts.UID;
} else if (subjectParts['2.5.4.45']) {
// OID para uniqueIdentifier
rfc = subjectParts['2.5.4.45'];
}
// Nombre del titular
nombre = subjectParts.CN || subjectParts.O || '';
// Email
email = subjectParts.emailAddress || '';
const now = new Date();
const validFrom = new Date(x509.validFrom);
const validTo = new Date(x509.validTo);
return {
serialNumber: x509.serialNumber,
subject: {
rfc: this.cleanRFC(rfc),
nombre,
email: email || undefined,
},
issuer: {
cn: issuerParts.CN || '',
o: issuerParts.O || '',
},
validFrom,
validTo,
isValid: now >= validFrom && now <= validTo,
};
} catch (error) {
throw new FIELError(
`Error al leer certificado: ${error instanceof Error ? error.message : 'Error desconocido'}`,
'INVALID_CERTIFICATE'
);
}
}
/**
* Encripta una llave privada con una contraseña
*/
async encryptPrivateKey(key: Buffer, password: string): Promise<Buffer> {
try {
// Primero intentar descifrar la llave si viene encriptada
// o usarla directamente si no lo está
let privateKeyPem: string;
if (this.isEncryptedKey(key)) {
// La llave ya está encriptada, necesitamos la contraseña original
// para poder re-encriptarla
throw new FIELError(
'La llave ya está encriptada. Proporcione la llave sin encriptar.',
'INVALID_KEY'
);
}
// Convertir a PEM si es necesario
privateKeyPem = this.toPEM(key, 'RSA PRIVATE KEY');
// Crear el objeto de llave
const keyObject = crypto.createPrivateKey({
key: privateKeyPem,
format: 'pem',
});
// Encriptar la llave con la contraseña
const encryptedKey = keyObject.export({
type: 'pkcs8',
format: 'pem',
cipher: 'aes-256-cbc',
passphrase: password,
});
return Buffer.from(encryptedKey as string);
} catch (error) {
if (error instanceof FIELError) throw error;
throw new FIELError(
`Error al encriptar llave privada: ${error instanceof Error ? error.message : 'Error desconocido'}`,
'INVALID_KEY'
);
}
}
/**
* Firma datos con la llave privada encriptada
*/
async signRequest(data: string, encryptedKey: Buffer, password: string): Promise<string> {
try {
// Descifrar la llave privada
const privateKey = await this.decryptPrivateKey(encryptedKey, password);
// Firmar los datos
const sign = crypto.createSign(SIGNATURE_ALGORITHM);
sign.update(data);
sign.end();
const signature = sign.sign(privateKey);
return signature.toString('base64');
} catch (error) {
if (error instanceof FIELError) throw error;
throw new FIELError(
`Error al firmar datos: ${error instanceof Error ? error.message : 'Error desconocido'}`,
'PASSWORD_ERROR'
);
}
}
/**
* Firma datos con la llave privada y contraseña
*/
async signWithFIEL(
data: string,
key: Buffer,
password: string
): Promise<string> {
try {
const privateKey = await this.decryptPrivateKey(key, password);
const sign = crypto.createSign(SIGNATURE_ALGORITHM);
sign.update(data);
sign.end();
const signature = sign.sign(privateKey);
return signature.toString('base64');
} catch (error) {
if (error instanceof FIELError) throw error;
throw new FIELError(
`Error al firmar con FIEL: ${error instanceof Error ? error.message : 'Error desconocido'}`,
'PASSWORD_ERROR'
);
}
}
/**
* Genera el token de autenticación para el web service del SAT
*/
async generateAuthToken(
cer: Buffer,
key: Buffer,
password: string,
timestamp: Date = new Date()
): Promise<string> {
// Obtener información del certificado
const certInfo = await this.getCertificateInfo(cer);
// Crear el XML de autenticación
const created = timestamp.toISOString();
const expires = new Date(timestamp.getTime() + 5 * 60 * 1000).toISOString(); // 5 minutos
const tokenXml = `
<u:Timestamp xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" u:Id="_0">
<u:Created>${created}</u:Created>
<u:Expires>${expires}</u:Expires>
</u:Timestamp>`.trim();
// Calcular el digest del timestamp
const digest = crypto.createHash('sha1').update(tokenXml).digest('base64');
// Crear el SignedInfo
const signedInfo = `
<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
<Reference URI="#_0">
<Transforms>
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</Transforms>
<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
<DigestValue>${digest}</DigestValue>
</Reference>
</SignedInfo>`.trim();
// Firmar el SignedInfo
const privateKey = await this.decryptPrivateKey(key, password);
const sign = crypto.createSign('RSA-SHA1');
sign.update(signedInfo);
sign.end();
const signature = sign.sign(privateKey).toString('base64');
// Obtener el certificado en base64
const certBase64 = this.getCertificateBase64(cer);
// Construir el token completo
const securityToken = `
<o:Security xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" s:mustUnderstand="1">
${tokenXml}
<o:BinarySecurityToken u:Id="X509Token" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">${certBase64}</o:BinarySecurityToken>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
${signedInfo}
<SignatureValue>${signature}</SignatureValue>
<KeyInfo>
<o:SecurityTokenReference>
<o:Reference URI="#X509Token" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3"/>
</o:SecurityTokenReference>
</KeyInfo>
</Signature>
</o:Security>`.trim();
return securityToken;
}
/**
* Obtiene el número de certificado
*/
getCertificateNumber(cer: Buffer): string {
const certInfo = this.getCertificateInfoSync(cer);
// El número de certificado es el serial number formateado
return this.formatSerialNumber(certInfo.serialNumber);
}
/**
* Obtiene información del certificado de forma síncrona
*/
private getCertificateInfoSync(cer: Buffer): { serialNumber: string } {
const pemCert = this.toPEM(cer, 'CERTIFICATE');
const x509 = new crypto.X509Certificate(pemCert);
return { serialNumber: x509.serialNumber };
}
/**
* Formatea el número serial del certificado
*/
private formatSerialNumber(serialNumber: string): string {
// El serial number viene en hexadecimal, hay que convertirlo
// El formato del SAT es tomar los caracteres pares del hex
let formatted = '';
for (let i = 0; i < serialNumber.length; i += 2) {
const hexPair = serialNumber.substring(i, i + 2);
const charCode = parseInt(hexPair, 16);
if (charCode >= 48 && charCode <= 57) {
// Es un dígito (0-9)
formatted += String.fromCharCode(charCode);
}
}
return formatted || serialNumber;
}
/**
* Descifra una llave privada con contraseña
*/
private async decryptPrivateKey(key: Buffer, password: string): Promise<crypto.KeyObject> {
try {
// Determinar el formato de la llave
let keyPem: string;
if (this.isEncryptedKey(key)) {
// La llave está encriptada en formato PKCS#8
keyPem = this.toPEM(key, 'ENCRYPTED PRIVATE KEY');
} else if (this.isDERFormat(key)) {
// La llave está en formato DER (archivo .key del SAT)
// Intentar como PKCS#8 encriptado
keyPem = this.toPEM(key, 'ENCRYPTED PRIVATE KEY');
} else {
// Asumir que es PEM
keyPem = key.toString('utf-8');
}
// Crear el objeto de llave
return crypto.createPrivateKey({
key: keyPem,
format: 'pem',
passphrase: password,
});
} catch (error) {
throw new FIELError(
'No se pudo descifrar la llave privada. Verifique la contraseña.',
'PASSWORD_ERROR'
);
}
}
/**
* Verifica que la llave privada corresponda al certificado
*/
private async verifyKeyPair(cer: Buffer, privateKey: crypto.KeyObject): Promise<boolean> {
try {
const pemCert = this.toPEM(cer, 'CERTIFICATE');
const x509 = new crypto.X509Certificate(pemCert);
const publicKey = x509.publicKey;
// Crear datos de prueba
const testData = 'test-data-for-verification';
// Firmar con la llave privada
const sign = crypto.createSign(SIGNATURE_ALGORITHM);
sign.update(testData);
const signature = sign.sign(privateKey);
// Verificar con la llave pública del certificado
const verify = crypto.createVerify(SIGNATURE_ALGORITHM);
verify.update(testData);
return verify.verify(publicKey, signature);
} catch {
return false;
}
}
/**
* Verifica si una llave está encriptada
*/
private isEncryptedKey(key: Buffer): boolean {
const keyStr = key.toString('utf-8');
return keyStr.includes('ENCRYPTED') || keyStr.includes('Proc-Type: 4,ENCRYPTED');
}
/**
* Verifica si el buffer está en formato DER
*/
private isDERFormat(buffer: Buffer): boolean {
// DER comienza con una secuencia (0x30)
return buffer[0] === 0x30;
}
/**
* Convierte un buffer a formato PEM
*/
private toPEM(buffer: Buffer, type: string): string {
const content = buffer.toString('utf-8').trim();
// Si ya es PEM, devolverlo
if (content.includes('-----BEGIN')) {
return content;
}
// Si es DER (binario), convertir a base64
const base64 = buffer.toString('base64');
// Formatear en líneas de 64 caracteres
const lines: string[] = [];
for (let i = 0; i < base64.length; i += 64) {
lines.push(base64.substring(i, i + 64));
}
return `-----BEGIN ${type}-----\n${lines.join('\n')}\n-----END ${type}-----`;
}
/**
* Obtiene el certificado en base64
*/
private getCertificateBase64(cer: Buffer): string {
const pemCert = this.toPEM(cer, 'CERTIFICATE');
// Extraer solo el contenido base64 sin los headers
return pemCert
.replace(CERTIFICATE_HEADER, '')
.replace(CERTIFICATE_FOOTER, '')
.replace(/\s/g, '');
}
/**
* Parsea un nombre X.509 (subject o issuer)
*/
private parseX509Name(name: string): Record<string, string> {
const result: Record<string, string> = {};
// El nombre viene en formato "CN=value\nO=value\n..."
const parts = name.split('\n');
for (const part of parts) {
const [key, ...valueParts] = part.split('=');
if (key && valueParts.length > 0) {
result[key.trim()] = valueParts.join('=').trim();
}
}
return result;
}
/**
* Limpia un RFC (quita espacios y caracteres especiales)
*/
private cleanRFC(rfc: string): string {
return rfc.replace(/[^A-Za-z0-9&]/g, '').toUpperCase();
}
/**
* Genera un digest SHA-256 de los datos
*/
generateDigest(data: string): string {
return crypto.createHash(HASH_ALGORITHM).update(data).digest('base64');
}
/**
* Verifica una firma
*/
async verifySignature(
data: string,
signature: string,
cer: Buffer
): Promise<boolean> {
try {
const pemCert = this.toPEM(cer, 'CERTIFICATE');
const x509 = new crypto.X509Certificate(pemCert);
const publicKey = x509.publicKey;
const verify = crypto.createVerify(SIGNATURE_ALGORITHM);
verify.update(data);
return verify.verify(publicKey, Buffer.from(signature, 'base64'));
} catch {
return false;
}
}
}
// Exportar instancia singleton
export const fielService = new FIELService();
// Exportar funciones helper
export async function validateFIEL(
cer: Buffer,
key: Buffer,
password: string
): Promise<FIELValidationResult> {
return fielService.validateFIEL(cer, key, password);
}
export async function encryptPrivateKey(key: Buffer, password: string): Promise<Buffer> {
return fielService.encryptPrivateKey(key, password);
}
export async function signRequest(
data: string,
encryptedKey: Buffer,
password: string
): Promise<string> {
return fielService.signRequest(data, encryptedKey, password);
}
export async function getCertificateInfo(cer: Buffer): Promise<CertificateInfo> {
return fielService.getCertificateInfo(cer);
}

View File

@@ -0,0 +1,803 @@
/**
* SAT Web Service Client
* Cliente para comunicarse con los servicios web del SAT para descarga masiva de CFDIs
*/
import * as https from 'https';
import * as crypto from 'crypto';
import { URL } from 'url';
import {
AuthResponse,
SolicitudDescarga,
SolicitudDescargaResponse,
VerificacionDescargaResponse,
DescargaPaqueteResponse,
TipoSolicitud,
TipoComprobante,
SATError,
SATAuthError,
EstadoSolicitud,
} from './sat.types.js';
import { FIELService } from './fiel.service.js';
// URLs de los servicios del SAT
const SAT_URLS = {
production: {
autenticacion: 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc',
solicitud: 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitudDescargaService.svc',
verificacion: 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc',
descarga: 'https://cfdidescargamasaborrasolicitud.clouda.sat.gob.mx/DescargaMasivaService.svc',
},
sandbox: {
autenticacion: 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc',
solicitud: 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitudDescargaService.svc',
verificacion: 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc',
descarga: 'https://cfdidescargamasaborrasolicitud.clouda.sat.gob.mx/DescargaMasivaService.svc',
},
};
// Namespaces SOAP
const NAMESPACES = {
soap: 'http://schemas.xmlsoap.org/soap/envelope/',
wsse: 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd',
wsu: 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd',
des: 'http://DescargaMasivaTerceros.sat.gob.mx',
};
// Acciones SOAP
const SOAP_ACTIONS = {
autenticacion: 'http://DescargaMasivaTerceros.gob.mx/IAutenticacion/Autentica',
solicitud: 'http://DescargaMasivaTerceros.sat.gob.mx/ISolicitudDescargaService/SolicitaDescarga',
verificacion: 'http://DescargaMasivaTerceros.sat.gob.mx/IVerificaSolicitudDescargaService/VerificaSolicitudDescarga',
descarga: 'http://DescargaMasivaTerceros.sat.gob.mx/IDescargaMasivaService/Descargar',
};
/**
* Opciones de configuración del cliente SAT
*/
export interface SATClientConfig {
rfc: string;
certificado: Buffer;
llavePrivada: Buffer;
password: string;
sandbox?: boolean;
timeout?: number;
}
/**
* Cliente para el Web Service de Descarga Masiva del SAT
*/
export class SATClient {
private config: SATClientConfig;
private fielService: FIELService;
private urls: typeof SAT_URLS.production;
private authToken: string | null = null;
private authExpires: Date | null = null;
constructor(config: SATClientConfig) {
this.config = {
timeout: 60000,
sandbox: false,
...config,
};
this.fielService = new FIELService();
this.urls = this.config.sandbox ? SAT_URLS.sandbox : SAT_URLS.production;
}
/**
* Autentica con el SAT y obtiene el token de sesión
*/
async authenticate(): Promise<AuthResponse> {
try {
// Verificar si ya tenemos un token válido
if (this.authToken && this.authExpires && this.authExpires > new Date()) {
return {
token: this.authToken,
expiresAt: this.authExpires,
};
}
// Generar el timestamp y UUID para la solicitud
const uuid = crypto.randomUUID();
const timestamp = new Date();
const created = timestamp.toISOString();
const expires = new Date(timestamp.getTime() + 5 * 60 * 1000).toISOString();
// Crear el Timestamp para firmar
const timestampXml = this.createTimestampXml(created, expires, uuid);
// Calcular el digest del timestamp
const canonicalTimestamp = this.canonicalize(timestampXml);
const digest = crypto.createHash('sha1').update(canonicalTimestamp).digest('base64');
// Crear el SignedInfo
const signedInfo = this.createSignedInfo(digest, uuid);
// Firmar el SignedInfo
const signature = await this.fielService.signWithFIEL(
this.canonicalize(signedInfo),
this.config.llavePrivada,
this.config.password
);
// Obtener el certificado en base64
const certBase64 = this.getCertificateBase64();
const certNumber = this.fielService.getCertificateNumber(this.config.certificado);
// Construir el SOAP envelope
const soapEnvelope = this.buildAuthenticationEnvelope(
timestampXml,
signedInfo,
signature,
certBase64,
uuid
);
// Enviar la solicitud
const response = await this.sendSoapRequest(
this.urls.autenticacion,
SOAP_ACTIONS.autenticacion,
soapEnvelope
);
// Parsear la respuesta
const token = this.extractAuthToken(response);
if (!token) {
throw new SATAuthError('No se pudo obtener el token de autenticación');
}
// Guardar el token
this.authToken = token;
this.authExpires = new Date(Date.now() + 5 * 60 * 1000); // 5 minutos
return {
token: this.authToken,
expiresAt: this.authExpires,
};
} catch (error) {
if (error instanceof SATError) throw error;
throw new SATAuthError(
`Error de autenticación: ${error instanceof Error ? error.message : 'Error desconocido'}`
);
}
}
/**
* Solicita la descarga de CFDIs
*/
async requestDownload(
dateFrom: Date,
dateTo: Date,
type: 'emitidos' | 'recibidos',
options?: {
tipoComprobante?: TipoComprobante;
tipoSolicitud?: TipoSolicitud;
complemento?: string;
estadoComprobante?: '0' | '1';
rfcACuentaTerceros?: string;
}
): Promise<SolicitudDescargaResponse> {
// Asegurar que estamos autenticados
await this.authenticate();
const rfcEmisor = type === 'emitidos' ? this.config.rfc : undefined;
const rfcReceptor = type === 'recibidos' ? this.config.rfc : undefined;
// Formatear fechas
const fechaInicio = this.formatDateForSAT(dateFrom);
const fechaFin = this.formatDateForSAT(dateTo);
// Construir el XML de solicitud
const solicitudXml = this.buildSolicitudXml({
rfcSolicitante: this.config.rfc,
fechaInicio,
fechaFin,
tipoSolicitud: options?.tipoSolicitud || 'CFDI',
tipoComprobante: options?.tipoComprobante,
rfcEmisor,
rfcReceptor,
complemento: options?.complemento,
estadoComprobante: options?.estadoComprobante,
rfcACuentaTerceros: options?.rfcACuentaTerceros,
});
// Firmar la solicitud
const signature = await this.fielService.signWithFIEL(
this.canonicalize(solicitudXml),
this.config.llavePrivada,
this.config.password
);
// Construir el SOAP envelope
const soapEnvelope = this.buildSolicitudEnvelope(
solicitudXml,
signature,
this.getCertificateBase64()
);
// Enviar la solicitud
const response = await this.sendSoapRequest(
this.urls.solicitud,
SOAP_ACTIONS.solicitud,
soapEnvelope
);
// Parsear la respuesta
return this.parseSolicitudResponse(response);
}
/**
* Verifica el estado de una solicitud de descarga
*/
async checkDownloadStatus(requestId: string): Promise<VerificacionDescargaResponse> {
// Asegurar que estamos autenticados
await this.authenticate();
// Construir el XML de verificación
const verificacionXml = `<des:VerificaSolicitudDescarga xmlns:des="${NAMESPACES.des}">
<des:solicitud IdSolicitud="${requestId}" RfcSolicitante="${this.config.rfc}"/>
</des:VerificaSolicitudDescarga>`;
// Firmar la verificación
const solicitudAttr = `IdSolicitud="${requestId}" RfcSolicitante="${this.config.rfc}"`;
const signature = await this.fielService.signWithFIEL(
solicitudAttr,
this.config.llavePrivada,
this.config.password
);
// Construir el SOAP envelope
const soapEnvelope = this.buildVerificacionEnvelope(
requestId,
signature,
this.getCertificateBase64()
);
// Enviar la solicitud
const response = await this.sendSoapRequest(
this.urls.verificacion,
SOAP_ACTIONS.verificacion,
soapEnvelope
);
// Parsear la respuesta
return this.parseVerificacionResponse(response);
}
/**
* Descarga un paquete de CFDIs
*/
async downloadPackage(packageId: string): Promise<DescargaPaqueteResponse> {
// Asegurar que estamos autenticados
await this.authenticate();
// Construir el XML de descarga
const peticionAttr = `IdPaquete="${packageId}" RfcSolicitante="${this.config.rfc}"`;
const signature = await this.fielService.signWithFIEL(
peticionAttr,
this.config.llavePrivada,
this.config.password
);
// Construir el SOAP envelope
const soapEnvelope = this.buildDescargaEnvelope(
packageId,
signature,
this.getCertificateBase64()
);
// Enviar la solicitud
const response = await this.sendSoapRequest(
this.urls.descarga,
SOAP_ACTIONS.descarga,
soapEnvelope
);
// Parsear la respuesta
return this.parseDescargaResponse(response);
}
/**
* Proceso completo de descarga: solicitar, esperar y descargar
*/
async downloadCFDIs(
dateFrom: Date,
dateTo: Date,
type: 'emitidos' | 'recibidos',
options?: {
tipoComprobante?: TipoComprobante;
maxWaitTime?: number; // milisegundos
pollInterval?: number; // milisegundos
onStatusChange?: (status: EstadoSolicitud, message: string) => void;
}
): Promise<Buffer[]> {
const maxWaitTime = options?.maxWaitTime || 10 * 60 * 1000; // 10 minutos
const pollInterval = options?.pollInterval || 30 * 1000; // 30 segundos
// 1. Solicitar la descarga
const solicitud = await this.requestDownload(dateFrom, dateTo, type, options);
if (solicitud.codEstatus !== '5000') {
throw new SATError(
`Error en solicitud de descarga: ${solicitud.mensaje}`,
solicitud.codEstatus,
solicitud.mensaje
);
}
options?.onStatusChange?.('1', 'Solicitud aceptada');
// 2. Esperar y verificar el estado
const startTime = Date.now();
let lastStatus: VerificacionDescargaResponse | null = null;
while (Date.now() - startTime < maxWaitTime) {
// Esperar antes de verificar
await this.sleep(pollInterval);
// Verificar estado
lastStatus = await this.checkDownloadStatus(solicitud.idSolicitud);
options?.onStatusChange?.(lastStatus.estadoSolicitud, lastStatus.mensaje);
// Verificar si ya terminó
if (lastStatus.estadoSolicitud === '3') {
// Terminada
break;
}
if (lastStatus.estadoSolicitud === '4' || lastStatus.estadoSolicitud === '5') {
// Error o Rechazada
throw new SATError(
`Error en descarga: ${lastStatus.mensaje}`,
lastStatus.codEstatus,
lastStatus.mensaje
);
}
}
if (!lastStatus || lastStatus.estadoSolicitud !== '3') {
throw new SATError(
'Tiempo de espera agotado para la descarga',
'TIMEOUT',
'La solicitud no se completó en el tiempo esperado'
);
}
// 3. Descargar los paquetes
const paquetes: Buffer[] = [];
for (const paqueteId of lastStatus.paquetes) {
const descarga = await this.downloadPackage(paqueteId);
if (descarga.codEstatus !== '5000') {
throw new SATError(
`Error al descargar paquete: ${descarga.mensaje}`,
descarga.codEstatus,
descarga.mensaje
);
}
paquetes.push(descarga.paquete);
}
return paquetes;
}
// ============================================================================
// Métodos privados de construcción de SOAP
// ============================================================================
/**
* Crea el XML del Timestamp
*/
private createTimestampXml(created: string, expires: string, uuid: string): string {
return `<u:Timestamp xmlns:u="${NAMESPACES.wsu}" u:Id="_0">
<u:Created>${created}</u:Created>
<u:Expires>${expires}</u:Expires>
</u:Timestamp>`;
}
/**
* Crea el SignedInfo para la firma
*/
private createSignedInfo(digest: string, uuid: string): string {
return `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
<Reference URI="#_0">
<Transforms>
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</Transforms>
<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
<DigestValue>${digest}</DigestValue>
</Reference>
</SignedInfo>`;
}
/**
* Construye el envelope SOAP para autenticación
*/
private buildAuthenticationEnvelope(
timestampXml: string,
signedInfo: string,
signature: string,
certBase64: string,
uuid: string
): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="${NAMESPACES.soap}" xmlns:u="${NAMESPACES.wsu}">
<s:Header>
<o:Security xmlns:o="${NAMESPACES.wsse}" s:mustUnderstand="1">
${timestampXml}
<o:BinarySecurityToken u:Id="X509Token" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">${certBase64}</o:BinarySecurityToken>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
${signedInfo}
<SignatureValue>${signature}</SignatureValue>
<KeyInfo>
<o:SecurityTokenReference>
<o:Reference URI="#X509Token" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3"/>
</o:SecurityTokenReference>
</KeyInfo>
</Signature>
</o:Security>
</s:Header>
<s:Body>
<Autentica xmlns="http://DescargaMasivaTerceros.gob.mx"/>
</s:Body>
</s:Envelope>`;
}
/**
* Construye el XML de solicitud de descarga
*/
private buildSolicitudXml(params: {
rfcSolicitante: string;
fechaInicio: string;
fechaFin: string;
tipoSolicitud: string;
tipoComprobante?: TipoComprobante;
rfcEmisor?: string;
rfcReceptor?: string;
complemento?: string;
estadoComprobante?: string;
rfcACuentaTerceros?: string;
}): string {
let attrs = `RfcSolicitante="${params.rfcSolicitante}"`;
attrs += ` FechaInicial="${params.fechaInicio}"`;
attrs += ` FechaFinal="${params.fechaFin}"`;
attrs += ` TipoSolicitud="${params.tipoSolicitud}"`;
if (params.tipoComprobante) {
attrs += ` TipoComprobante="${params.tipoComprobante}"`;
}
if (params.rfcEmisor) {
attrs += ` RfcEmisor="${params.rfcEmisor}"`;
}
if (params.rfcReceptor) {
attrs += ` RfcReceptor="${params.rfcReceptor}"`;
}
if (params.complemento) {
attrs += ` Complemento="${params.complemento}"`;
}
if (params.estadoComprobante) {
attrs += ` EstadoComprobante="${params.estadoComprobante}"`;
}
if (params.rfcACuentaTerceros) {
attrs += ` RfcACuentaTerceros="${params.rfcACuentaTerceros}"`;
}
return `<des:SolicitaDescarga xmlns:des="${NAMESPACES.des}">
<des:solicitud ${attrs}/>
</des:SolicitaDescarga>`;
}
/**
* Construye el envelope SOAP para solicitud de descarga
*/
private buildSolicitudEnvelope(
solicitudXml: string,
signature: string,
certBase64: string
): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="${NAMESPACES.soap}" xmlns:des="${NAMESPACES.des}" xmlns:xd="http://www.w3.org/2000/09/xmldsig#">
<s:Header/>
<s:Body>
<des:SolicitaDescarga>
<des:solicitud>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
<Reference URI="">
<Transforms>
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</Transforms>
<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
<DigestValue></DigestValue>
</Reference>
</SignedInfo>
<SignatureValue>${signature}</SignatureValue>
<KeyInfo>
<X509Data>
<X509Certificate>${certBase64}</X509Certificate>
</X509Data>
</KeyInfo>
</Signature>
</des:solicitud>
</des:SolicitaDescarga>
</s:Body>
</s:Envelope>`;
}
/**
* Construye el envelope SOAP para verificación
*/
private buildVerificacionEnvelope(
requestId: string,
signature: string,
certBase64: string
): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="${NAMESPACES.soap}" xmlns:des="${NAMESPACES.des}" xmlns:xd="http://www.w3.org/2000/09/xmldsig#">
<s:Header/>
<s:Body>
<des:VerificaSolicitudDescarga>
<des:solicitud IdSolicitud="${requestId}" RfcSolicitante="${this.config.rfc}">
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
<Reference URI="">
<Transforms>
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</Transforms>
<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
<DigestValue></DigestValue>
</Reference>
</SignedInfo>
<SignatureValue>${signature}</SignatureValue>
<KeyInfo>
<X509Data>
<X509Certificate>${certBase64}</X509Certificate>
</X509Data>
</KeyInfo>
</Signature>
</des:solicitud>
</des:VerificaSolicitudDescarga>
</s:Body>
</s:Envelope>`;
}
/**
* Construye el envelope SOAP para descarga
*/
private buildDescargaEnvelope(
packageId: string,
signature: string,
certBase64: string
): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="${NAMESPACES.soap}" xmlns:des="${NAMESPACES.des}" xmlns:xd="http://www.w3.org/2000/09/xmldsig#">
<s:Header/>
<s:Body>
<des:PeticionDescargaMasivaTercerosEntrada>
<des:peticionDescarga IdPaquete="${packageId}" RfcSolicitante="${this.config.rfc}">
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
<Reference URI="">
<Transforms>
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</Transforms>
<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
<DigestValue></DigestValue>
</Reference>
</SignedInfo>
<SignatureValue>${signature}</SignatureValue>
<KeyInfo>
<X509Data>
<X509Certificate>${certBase64}</X509Certificate>
</X509Data>
</KeyInfo>
</Signature>
</des:peticionDescarga>
</des:PeticionDescargaMasivaTercerosEntrada>
</s:Body>
</s:Envelope>`;
}
// ============================================================================
// Métodos de comunicación HTTP
// ============================================================================
/**
* Envía una solicitud SOAP
*/
private async sendSoapRequest(
url: string,
soapAction: string,
body: string
): Promise<string> {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const options: https.RequestOptions = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || 443,
path: parsedUrl.pathname,
method: 'POST',
headers: {
'Content-Type': 'text/xml; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'SOAPAction': soapAction,
'Authorization': this.authToken ? `WRAP access_token="${this.authToken}"` : '',
},
timeout: this.config.timeout,
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve(data);
} else {
reject(
new SATError(
`Error HTTP ${res.statusCode}: ${res.statusMessage}`,
String(res.statusCode),
data
)
);
}
});
});
req.on('error', (error) => {
reject(new SATError(`Error de conexión: ${error.message}`, 'CONNECTION_ERROR'));
});
req.on('timeout', () => {
req.destroy();
reject(new SATError('Timeout de conexión', 'TIMEOUT'));
});
req.write(body);
req.end();
});
}
// ============================================================================
// Métodos de parsing de respuestas
// ============================================================================
/**
* Extrae el token de autenticación de la respuesta
*/
private extractAuthToken(response: string): string | null {
// Buscar el token en la respuesta SOAP
const tokenMatch = response.match(/<AutenticaResult>([^<]+)<\/AutenticaResult>/);
return tokenMatch ? tokenMatch[1] || null : null;
}
/**
* Parsea la respuesta de solicitud de descarga
*/
private parseSolicitudResponse(response: string): SolicitudDescargaResponse {
const idMatch = response.match(/IdSolicitud="([^"]+)"/);
const codMatch = response.match(/CodEstatus="([^"]+)"/);
const msgMatch = response.match(/Mensaje="([^"]+)"/);
return {
idSolicitud: idMatch ? idMatch[1] || '' : '',
codEstatus: codMatch ? codMatch[1] || '' : '',
mensaje: msgMatch ? msgMatch[1] || '' : '',
};
}
/**
* Parsea la respuesta de verificación
*/
private parseVerificacionResponse(response: string): VerificacionDescargaResponse {
const codMatch = response.match(/CodEstatus="([^"]+)"/);
const estadoMatch = response.match(/EstadoSolicitud="([^"]+)"/);
const codEstadoMatch = response.match(/CodigoEstadoSolicitud="([^"]+)"/);
const numCFDIsMatch = response.match(/NumeroCFDIs="([^"]+)"/);
const msgMatch = response.match(/Mensaje="([^"]+)"/);
// Extraer los IDs de paquetes
const paquetes: string[] = [];
const paqueteMatches = response.matchAll(/IdPaquete="([^"]+)"/g);
for (const match of paqueteMatches) {
if (match[1]) paquetes.push(match[1]);
}
return {
codEstatus: codMatch ? codMatch[1] || '' : '',
estadoSolicitud: (estadoMatch ? estadoMatch[1] || '1' : '1') as EstadoSolicitud,
codigoEstadoSolicitud: codEstadoMatch ? codEstadoMatch[1] || '' : '',
numeroCFDIs: numCFDIsMatch ? parseInt(numCFDIsMatch[1] || '0', 10) : 0,
mensaje: msgMatch ? msgMatch[1] || '' : '',
paquetes,
};
}
/**
* Parsea la respuesta de descarga
*/
private parseDescargaResponse(response: string): DescargaPaqueteResponse {
const codMatch = response.match(/CodEstatus="([^"]+)"/);
const msgMatch = response.match(/Mensaje="([^"]+)"/);
const paqueteMatch = response.match(/<Paquete>([^<]+)<\/Paquete>/);
const paqueteBase64 = paqueteMatch ? paqueteMatch[1] || '' : '';
const paquete = paqueteBase64 ? Buffer.from(paqueteBase64, 'base64') : Buffer.alloc(0);
return {
codEstatus: codMatch ? codMatch[1] || '' : '',
mensaje: msgMatch ? msgMatch[1] || '' : '',
paquete,
};
}
// ============================================================================
// Utilidades
// ============================================================================
/**
* Obtiene el certificado en base64
*/
private getCertificateBase64(): string {
const content = this.config.certificado.toString('utf-8').trim();
// Si ya tiene headers PEM, extraer solo el contenido
if (content.includes('-----BEGIN')) {
return content
.replace(/-----BEGIN CERTIFICATE-----/, '')
.replace(/-----END CERTIFICATE-----/, '')
.replace(/\s/g, '');
}
// Si es binario (DER), convertir a base64
return this.config.certificado.toString('base64');
}
/**
* Canonicaliza XML (simplificado - C14N exclusivo)
*/
private canonicalize(xml: string): string {
// Implementación simplificada de canonicalización
return xml
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/>\s+</g, '><')
.trim();
}
/**
* Formatea una fecha para el SAT (ISO 8601)
*/
private formatDateForSAT(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, '');
}
/**
* Espera un tiempo determinado
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
/**
* Crea una instancia del cliente SAT
*/
export function createSATClient(config: SATClientConfig): SATClient {
return new SATClient(config);
}

View File

@@ -0,0 +1,871 @@
/**
* SAT Service
* Servicio de alto nivel para sincronización de CFDIs con el SAT
*/
import * as zlib from 'zlib';
import { promisify } from 'util';
import { Pool } from 'pg';
import {
CFDI,
CFDIParsed,
SyncResult,
SyncOptions,
TipoComprobante,
SATError,
} from './sat.types.js';
import { CFDIParser, parseCFDI, extractBasicInfo } from './cfdi.parser.js';
import { SATClient, SATClientConfig, createSATClient } from './sat.client.js';
const unzip = promisify(zlib.unzip);
/**
* Configuración del servicio SAT
*/
export interface SATServiceConfig {
/** Pool de conexiones a la base de datos */
dbPool: Pool;
/** Configuración del cliente SAT (por tenant) */
getSATClientConfig: (tenantId: string) => Promise<SATClientConfig>;
/** Tabla donde se guardan los CFDIs */
cfdiTable?: string;
/** Esquema de la base de datos */
dbSchema?: string;
/** Logger personalizado */
logger?: {
info: (message: string, meta?: Record<string, unknown>) => void;
error: (message: string, meta?: Record<string, unknown>) => void;
warn: (message: string, meta?: Record<string, unknown>) => void;
};
}
/**
* Resultado del procesamiento de un CFDI
*/
interface CFDIProcessResult {
success: boolean;
uuid?: string;
error?: string;
}
/**
* Servicio principal para sincronización con el SAT
*/
export class SATService {
private config: Required<SATServiceConfig>;
private parser: CFDIParser;
private clients: Map<string, SATClient> = new Map();
constructor(config: SATServiceConfig) {
this.config = {
cfdiTable: 'cfdis',
dbSchema: 'public',
logger: {
info: console.log,
error: console.error,
warn: console.warn,
},
...config,
};
this.parser = new CFDIParser();
}
/**
* Sincroniza CFDIs de un tenant para un rango de fechas
*/
async syncCFDIs(options: SyncOptions): Promise<SyncResult> {
const { tenantId, dateFrom, dateTo, tipoComprobante, tipoSolicitud } = options;
this.config.logger.info('Iniciando sincronización de CFDIs', {
tenantId,
dateFrom: dateFrom.toISOString(),
dateTo: dateTo.toISOString(),
tipoComprobante,
});
const result: SyncResult = {
success: false,
totalProcessed: 0,
totalSaved: 0,
totalErrors: 0,
errors: [],
};
try {
// Obtener el cliente SAT para este tenant
const client = await this.getSATClient(tenantId);
// Descargar CFDIs emitidos
if (!tipoComprobante || tipoComprobante === 'emitidos') {
const emitidosResult = await this.downloadAndProcess(
client,
tenantId,
dateFrom,
dateTo,
'emitidos',
tipoSolicitud
);
result.totalProcessed += emitidosResult.totalProcessed;
result.totalSaved += emitidosResult.totalSaved;
result.totalErrors += emitidosResult.totalErrors;
result.errors.push(...emitidosResult.errors);
result.requestId = emitidosResult.requestId;
}
// Descargar CFDIs recibidos
if (!tipoComprobante || tipoComprobante === 'recibidos') {
const recibidosResult = await this.downloadAndProcess(
client,
tenantId,
dateFrom,
dateTo,
'recibidos',
tipoSolicitud
);
result.totalProcessed += recibidosResult.totalProcessed;
result.totalSaved += recibidosResult.totalSaved;
result.totalErrors += recibidosResult.totalErrors;
result.errors.push(...recibidosResult.errors);
}
result.success = result.totalErrors === 0;
this.config.logger.info('Sincronización completada', {
tenantId,
totalProcessed: result.totalProcessed,
totalSaved: result.totalSaved,
totalErrors: result.totalErrors,
});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
this.config.logger.error('Error en sincronización', {
tenantId,
error: errorMessage,
});
result.errors.push({ error: errorMessage });
result.totalErrors++;
return result;
}
}
/**
* Descarga y procesa CFDIs de un tipo específico
*/
private async downloadAndProcess(
client: SATClient,
tenantId: string,
dateFrom: Date,
dateTo: Date,
type: 'emitidos' | 'recibidos',
tipoSolicitud?: 'CFDI' | 'Metadata'
): Promise<SyncResult> {
const result: SyncResult = {
success: false,
totalProcessed: 0,
totalSaved: 0,
totalErrors: 0,
errors: [],
};
try {
this.config.logger.info(`Descargando CFDIs ${type}`, {
tenantId,
dateFrom: dateFrom.toISOString(),
dateTo: dateTo.toISOString(),
});
// Descargar paquetes del SAT
const paquetes = await client.downloadCFDIs(dateFrom, dateTo, type, {
tipoSolicitud,
onStatusChange: (status, message) => {
this.config.logger.info(`Estado de descarga: ${status}`, { message, tenantId });
},
});
// Procesar cada paquete
for (const paquete of paquetes) {
const paqueteResult = await this.processCFDIPackage(paquete, tenantId, type);
result.totalProcessed += paqueteResult.totalProcessed;
result.totalSaved += paqueteResult.totalSaved;
result.totalErrors += paqueteResult.totalErrors;
result.errors.push(...paqueteResult.errors);
}
result.success = true;
result.paquetes = paquetes.map((_, i) => `paquete_${i + 1}`);
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
this.config.logger.error(`Error descargando CFDIs ${type}`, {
tenantId,
error: errorMessage,
});
result.errors.push({ error: errorMessage });
result.totalErrors++;
return result;
}
}
/**
* Procesa un paquete ZIP de CFDIs
*/
async processCFDIPackage(
zipBuffer: Buffer,
tenantId: string,
tipo: 'emitidos' | 'recibidos' = 'recibidos'
): Promise<SyncResult> {
const result: SyncResult = {
success: false,
totalProcessed: 0,
totalSaved: 0,
totalErrors: 0,
errors: [],
};
try {
// Extraer los XMLs del ZIP
const xmlFiles = await this.extractZip(zipBuffer);
this.config.logger.info('Procesando paquete de CFDIs', {
tenantId,
totalFiles: xmlFiles.length,
});
// Parsear y guardar cada CFDI
const cfdis: CFDIParsed[] = [];
for (const xmlContent of xmlFiles) {
result.totalProcessed++;
try {
const cfdiParsed = this.parser.parseCFDI(xmlContent);
cfdis.push(cfdiParsed);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Error de parsing';
// Intentar extraer el UUID para el log
const basicInfo = extractBasicInfo(xmlContent);
result.errors.push({
uuid: basicInfo.uuid || undefined,
error: errorMessage,
});
result.totalErrors++;
this.config.logger.warn('Error parseando CFDI', {
uuid: basicInfo.uuid,
error: errorMessage,
});
}
}
// Guardar los CFDIs en la base de datos
if (cfdis.length > 0) {
const saveResult = await this.saveCFDIsToDB(cfdis, tenantId, tipo);
result.totalSaved = saveResult.saved;
result.totalErrors += saveResult.errors;
result.errors.push(...saveResult.errorDetails);
}
result.success = result.totalErrors === 0;
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
this.config.logger.error('Error procesando paquete', {
tenantId,
error: errorMessage,
});
result.errors.push({ error: errorMessage });
result.totalErrors++;
return result;
}
}
/**
* Guarda los CFDIs en la base de datos
*/
async saveCFDIsToDB(
cfdis: CFDIParsed[],
tenantId: string,
tipo: 'emitidos' | 'recibidos'
): Promise<{ saved: number; errors: number; errorDetails: Array<{ uuid?: string; error: string }> }> {
const result = {
saved: 0,
errors: 0,
errorDetails: [] as Array<{ uuid?: string; error: string }>,
};
const client = await this.config.dbPool.connect();
try {
await client.query('BEGIN');
for (const cfdiParsed of cfdis) {
try {
await this.upsertCFDI(client, cfdiParsed, tenantId, tipo);
result.saved++;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Error de BD';
result.errors++;
result.errorDetails.push({
uuid: cfdiParsed.uuid,
error: errorMessage,
});
this.config.logger.warn('Error guardando CFDI', {
uuid: cfdiParsed.uuid,
error: errorMessage,
});
}
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
return result;
}
/**
* Inserta o actualiza un CFDI en la base de datos
*/
private async upsertCFDI(
client: ReturnType<Pool['connect']> extends Promise<infer T> ? T : never,
cfdiParsed: CFDIParsed,
tenantId: string,
tipo: 'emitidos' | 'recibidos'
): Promise<void> {
const { cfdi, xml, uuid, fechaTimbrado, rfcEmisor, rfcReceptor, total, tipoComprobante } =
cfdiParsed;
const table = `${this.config.dbSchema}.${this.config.cfdiTable}`;
// Extraer datos adicionales del CFDI
const serie = cfdi.Serie || null;
const folio = cfdi.Folio || null;
const fecha = new Date(cfdi.Fecha);
const formaPago = cfdi.FormaPago || null;
const metodoPago = cfdi.MetodoPago || null;
const moneda = cfdi.Moneda;
const tipoCambio = cfdi.TipoCambio || 1;
const subtotal = cfdi.SubTotal;
const descuento = cfdi.Descuento || 0;
const lugarExpedicion = cfdi.LugarExpedicion;
// Impuestos
const totalImpuestosTrasladados = cfdi.Impuestos?.TotalImpuestosTrasladados || 0;
const totalImpuestosRetenidos = cfdi.Impuestos?.TotalImpuestosRetenidos || 0;
// Datos del emisor
const nombreEmisor = cfdi.Emisor.Nombre;
const regimenFiscalEmisor = cfdi.Emisor.RegimenFiscal;
// Datos del receptor
const nombreReceptor = cfdi.Receptor.Nombre;
const regimenFiscalReceptor = cfdi.Receptor.RegimenFiscalReceptor;
const usoCfdi = cfdi.Receptor.UsoCFDI;
const domicilioFiscalReceptor = cfdi.Receptor.DomicilioFiscalReceptor;
// Complementos
const tienePago = !!cfdi.Complemento?.Pagos;
const tieneNomina = !!cfdi.Complemento?.Nomina;
// Datos del timbre
const rfcProvCertif = cfdi.Complemento?.TimbreFiscalDigital?.RfcProvCertif || null;
const noCertificadoSAT = cfdi.Complemento?.TimbreFiscalDigital?.NoCertificadoSAT || null;
const query = `
INSERT INTO ${table} (
tenant_id,
uuid,
tipo,
version,
serie,
folio,
fecha,
fecha_timbrado,
tipo_comprobante,
forma_pago,
metodo_pago,
moneda,
tipo_cambio,
subtotal,
descuento,
total,
lugar_expedicion,
total_impuestos_trasladados,
total_impuestos_retenidos,
rfc_emisor,
nombre_emisor,
regimen_fiscal_emisor,
rfc_receptor,
nombre_receptor,
regimen_fiscal_receptor,
uso_cfdi,
domicilio_fiscal_receptor,
tiene_pago,
tiene_nomina,
rfc_prov_certif,
no_certificado_sat,
xml,
conceptos,
created_at,
updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20,
$21, $22, $23, $24, $25, $26, $27, $28, $29, $30,
$31, $32, $33, NOW(), NOW()
)
ON CONFLICT (tenant_id, uuid) DO UPDATE SET
tipo = EXCLUDED.tipo,
version = EXCLUDED.version,
serie = EXCLUDED.serie,
folio = EXCLUDED.folio,
fecha = EXCLUDED.fecha,
fecha_timbrado = EXCLUDED.fecha_timbrado,
tipo_comprobante = EXCLUDED.tipo_comprobante,
forma_pago = EXCLUDED.forma_pago,
metodo_pago = EXCLUDED.metodo_pago,
moneda = EXCLUDED.moneda,
tipo_cambio = EXCLUDED.tipo_cambio,
subtotal = EXCLUDED.subtotal,
descuento = EXCLUDED.descuento,
total = EXCLUDED.total,
lugar_expedicion = EXCLUDED.lugar_expedicion,
total_impuestos_trasladados = EXCLUDED.total_impuestos_trasladados,
total_impuestos_retenidos = EXCLUDED.total_impuestos_retenidos,
rfc_emisor = EXCLUDED.rfc_emisor,
nombre_emisor = EXCLUDED.nombre_emisor,
regimen_fiscal_emisor = EXCLUDED.regimen_fiscal_emisor,
rfc_receptor = EXCLUDED.rfc_receptor,
nombre_receptor = EXCLUDED.nombre_receptor,
regimen_fiscal_receptor = EXCLUDED.regimen_fiscal_receptor,
uso_cfdi = EXCLUDED.uso_cfdi,
domicilio_fiscal_receptor = EXCLUDED.domicilio_fiscal_receptor,
tiene_pago = EXCLUDED.tiene_pago,
tiene_nomina = EXCLUDED.tiene_nomina,
rfc_prov_certif = EXCLUDED.rfc_prov_certif,
no_certificado_sat = EXCLUDED.no_certificado_sat,
xml = EXCLUDED.xml,
conceptos = EXCLUDED.conceptos,
updated_at = NOW()
`;
// Serializar conceptos como JSON
const conceptosJson = JSON.stringify(
cfdi.Conceptos.map((c) => ({
claveProdServ: c.ClaveProdServ,
noIdentificacion: c.NoIdentificacion,
cantidad: c.Cantidad,
claveUnidad: c.ClaveUnidad,
unidad: c.Unidad,
descripcion: c.Descripcion,
valorUnitario: c.ValorUnitario,
importe: c.Importe,
descuento: c.Descuento,
objetoImp: c.ObjetoImp,
impuestos: c.Impuestos,
}))
);
await client.query(query, [
tenantId,
uuid,
tipo,
cfdi.Version,
serie,
folio,
fecha,
fechaTimbrado,
tipoComprobante,
formaPago,
metodoPago,
moneda,
tipoCambio,
subtotal,
descuento,
total,
lugarExpedicion,
totalImpuestosTrasladados,
totalImpuestosRetenidos,
rfcEmisor,
nombreEmisor,
regimenFiscalEmisor,
rfcReceptor,
nombreReceptor,
regimenFiscalReceptor,
usoCfdi,
domicilioFiscalReceptor,
tienePago,
tieneNomina,
rfcProvCertif,
noCertificadoSAT,
xml,
conceptosJson,
]);
}
/**
* Extrae los archivos XML de un ZIP
*/
private async extractZip(zipBuffer: Buffer): Promise<string[]> {
const xmlFiles: string[] = [];
try {
// El paquete del SAT viene como un ZIP
// Primero intentamos descomprimir si está comprimido con gzip
let uncompressedBuffer: Buffer;
try {
uncompressedBuffer = await unzip(zipBuffer);
} catch {
// Si no es gzip, usar el buffer original
uncompressedBuffer = zipBuffer;
}
// Parsear el ZIP manualmente (formato básico)
// Los archivos ZIP tienen una estructura específica
const files = this.parseZipBuffer(uncompressedBuffer);
for (const file of files) {
if (file.name.toLowerCase().endsWith('.xml')) {
xmlFiles.push(file.content);
}
}
return xmlFiles;
} catch (error) {
this.config.logger.error('Error extrayendo ZIP', {
error: error instanceof Error ? error.message : 'Error desconocido',
});
throw new SATError(
'Error al extraer paquete ZIP',
'ZIP_ERROR',
error instanceof Error ? error.message : undefined
);
}
}
/**
* Parsea un buffer ZIP y extrae los archivos
*/
private parseZipBuffer(buffer: Buffer): Array<{ name: string; content: string }> {
const files: Array<{ name: string; content: string }> = [];
let offset = 0;
const view = buffer;
while (offset < buffer.length - 4) {
// Buscar la firma del Local File Header (0x04034b50)
const signature = view.readUInt32LE(offset);
if (signature !== 0x04034b50) {
// No es un Local File Header, podría ser el Central Directory
break;
}
// Leer el Local File Header
const compressionMethod = view.readUInt16LE(offset + 8);
const compressedSize = view.readUInt32LE(offset + 18);
const uncompressedSize = view.readUInt32LE(offset + 22);
const fileNameLength = view.readUInt16LE(offset + 26);
const extraFieldLength = view.readUInt16LE(offset + 28);
// Nombre del archivo
const fileNameStart = offset + 30;
const fileName = buffer.subarray(fileNameStart, fileNameStart + fileNameLength).toString('utf-8');
// Datos del archivo
const dataStart = fileNameStart + fileNameLength + extraFieldLength;
const dataEnd = dataStart + compressedSize;
const fileData = buffer.subarray(dataStart, dataEnd);
// Descomprimir si es necesario
let content: string;
if (compressionMethod === 0) {
// Sin compresión
content = fileData.toString('utf-8');
} else if (compressionMethod === 8) {
// Deflate
try {
const inflated = zlib.inflateRawSync(fileData);
content = inflated.toString('utf-8');
} catch {
// Si falla la descompresión, intentar como texto plano
content = fileData.toString('utf-8');
}
} else {
// Método de compresión no soportado
this.config.logger.warn(`Método de compresión no soportado: ${compressionMethod}`, {
fileName,
});
content = '';
}
if (content) {
files.push({ name: fileName, content });
}
// Avanzar al siguiente archivo
offset = dataEnd;
}
return files;
}
/**
* Obtiene o crea un cliente SAT para un tenant
*/
private async getSATClient(tenantId: string): Promise<SATClient> {
if (!this.clients.has(tenantId)) {
const config = await this.config.getSATClientConfig(tenantId);
this.clients.set(tenantId, createSATClient(config));
}
return this.clients.get(tenantId)!;
}
/**
* Obtiene un CFDI por UUID
*/
async getCFDIByUUID(tenantId: string, uuid: string): Promise<CFDIParsed | null> {
const table = `${this.config.dbSchema}.${this.config.cfdiTable}`;
const result = await this.config.dbPool.query(
`SELECT xml FROM ${table} WHERE tenant_id = $1 AND uuid = $2`,
[tenantId, uuid]
);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0] as { xml: string };
return parseCFDI(row.xml);
}
/**
* Busca CFDIs por criterios
*/
async searchCFDIs(
tenantId: string,
criteria: {
tipo?: 'emitidos' | 'recibidos';
tipoComprobante?: TipoComprobante;
rfcEmisor?: string;
rfcReceptor?: string;
fechaDesde?: Date;
fechaHasta?: Date;
montoMinimo?: number;
montoMaximo?: number;
limit?: number;
offset?: number;
}
): Promise<{ cfdis: Array<Partial<CFDI> & { uuid: string }>; total: number }> {
const table = `${this.config.dbSchema}.${this.config.cfdiTable}`;
const conditions: string[] = ['tenant_id = $1'];
const params: unknown[] = [tenantId];
let paramIndex = 2;
if (criteria.tipo) {
conditions.push(`tipo = $${paramIndex++}`);
params.push(criteria.tipo);
}
if (criteria.tipoComprobante) {
conditions.push(`tipo_comprobante = $${paramIndex++}`);
params.push(criteria.tipoComprobante);
}
if (criteria.rfcEmisor) {
conditions.push(`rfc_emisor = $${paramIndex++}`);
params.push(criteria.rfcEmisor);
}
if (criteria.rfcReceptor) {
conditions.push(`rfc_receptor = $${paramIndex++}`);
params.push(criteria.rfcReceptor);
}
if (criteria.fechaDesde) {
conditions.push(`fecha >= $${paramIndex++}`);
params.push(criteria.fechaDesde);
}
if (criteria.fechaHasta) {
conditions.push(`fecha <= $${paramIndex++}`);
params.push(criteria.fechaHasta);
}
if (criteria.montoMinimo !== undefined) {
conditions.push(`total >= $${paramIndex++}`);
params.push(criteria.montoMinimo);
}
if (criteria.montoMaximo !== undefined) {
conditions.push(`total <= $${paramIndex++}`);
params.push(criteria.montoMaximo);
}
const whereClause = conditions.join(' AND ');
const limit = criteria.limit || 100;
const offset = criteria.offset || 0;
// Contar total
const countResult = await this.config.dbPool.query(
`SELECT COUNT(*) as total FROM ${table} WHERE ${whereClause}`,
params
);
const total = parseInt((countResult.rows[0] as { total: string }).total, 10);
// Obtener CFDIs
const dataResult = await this.config.dbPool.query(
`SELECT
uuid,
tipo,
version,
serie,
folio,
fecha,
fecha_timbrado,
tipo_comprobante,
forma_pago,
metodo_pago,
moneda,
tipo_cambio,
subtotal,
descuento,
total,
rfc_emisor,
nombre_emisor,
rfc_receptor,
nombre_receptor,
uso_cfdi,
tiene_pago,
tiene_nomina
FROM ${table}
WHERE ${whereClause}
ORDER BY fecha DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
[...params, limit, offset]
);
return {
cfdis: dataResult.rows as Array<Partial<CFDI> & { uuid: string }>,
total,
};
}
/**
* Obtiene estadísticas de CFDIs
*/
async getCFDIStats(
tenantId: string,
fechaDesde?: Date,
fechaHasta?: Date
): Promise<{
totalEmitidos: number;
totalRecibidos: number;
montoEmitidos: number;
montoRecibidos: number;
porTipoComprobante: Record<TipoComprobante, number>;
}> {
const table = `${this.config.dbSchema}.${this.config.cfdiTable}`;
let dateCondition = '';
const params: unknown[] = [tenantId];
if (fechaDesde) {
params.push(fechaDesde);
dateCondition += ` AND fecha >= $${params.length}`;
}
if (fechaHasta) {
params.push(fechaHasta);
dateCondition += ` AND fecha <= $${params.length}`;
}
const query = `
SELECT
tipo,
tipo_comprobante,
COUNT(*) as cantidad,
COALESCE(SUM(total), 0) as monto
FROM ${table}
WHERE tenant_id = $1 ${dateCondition}
GROUP BY tipo, tipo_comprobante
`;
const result = await this.config.dbPool.query(query, params);
const stats = {
totalEmitidos: 0,
totalRecibidos: 0,
montoEmitidos: 0,
montoRecibidos: 0,
porTipoComprobante: {
I: 0,
E: 0,
T: 0,
N: 0,
P: 0,
} as Record<TipoComprobante, number>,
};
for (const row of result.rows as Array<{
tipo: string;
tipo_comprobante: TipoComprobante;
cantidad: string;
monto: string;
}>) {
const cantidad = parseInt(row.cantidad, 10);
const monto = parseFloat(row.monto);
if (row.tipo === 'emitidos') {
stats.totalEmitidos += cantidad;
stats.montoEmitidos += monto;
} else {
stats.totalRecibidos += cantidad;
stats.montoRecibidos += monto;
}
stats.porTipoComprobante[row.tipo_comprobante] =
(stats.porTipoComprobante[row.tipo_comprobante] || 0) + cantidad;
}
return stats;
}
/**
* Limpia la caché de clientes SAT
*/
clearClientCache(): void {
this.clients.clear();
}
/**
* Invalida el cliente de un tenant específico
*/
invalidateClient(tenantId: string): void {
this.clients.delete(tenantId);
}
}
/**
* Crea una instancia del servicio SAT
*/
export function createSATService(config: SATServiceConfig): SATService {
return new SATService(config);
}

View File

@@ -0,0 +1,817 @@
/**
* SAT CFDI 4.0 Types
* Tipos completos para la integración con el SAT de México
*/
// ============================================================================
// Tipos de Comprobante
// ============================================================================
/**
* Tipos de comprobante fiscal según SAT
* I - Ingreso
* E - Egreso
* T - Traslado
* N - Nómina
* P - Pago
*/
export type TipoComprobante = 'I' | 'E' | 'T' | 'N' | 'P';
/**
* Tipos de relación entre CFDIs
*/
export type TipoRelacion =
| '01' // Nota de crédito de los documentos relacionados
| '02' // Nota de débito de los documentos relacionados
| '03' // Devolución de mercancía sobre facturas o traslados previos
| '04' // Sustitución de los CFDI previos
| '05' // Traslados de mercancías facturados previamente
| '06' // Factura generada por los traslados previos
| '07' // CFDI por aplicación de anticipo
| '08' // Factura generada por pagos en parcialidades
| '09'; // Factura generada por pagos diferidos
/**
* Formas de pago
*/
export type FormaPago =
| '01' // Efectivo
| '02' // Cheque nominativo
| '03' // Transferencia electrónica de fondos
| '04' // Tarjeta de crédito
| '05' // Monedero electrónico
| '06' // Dinero electrónico
| '08' // Vales de despensa
| '12' // Dación en pago
| '13' // Pago por subrogación
| '14' // Pago por consignación
| '15' // Condonación
| '17' // Compensación
| '23' // Novación
| '24' // Confusión
| '25' // Remisión de deuda
| '26' // Prescripción o caducidad
| '27' // A satisfacción del acreedor
| '28' // Tarjeta de débito
| '29' // Tarjeta de servicios
| '30' // Aplicación de anticipos
| '31' // Intermediario pagos
| '99'; // Por definir
/**
* Métodos de pago
*/
export type MetodoPago = 'PUE' | 'PPD';
/**
* Uso del CFDI
*/
export type UsoCFDI =
| 'G01' // Adquisición de mercancías
| 'G02' // Devoluciones, descuentos o bonificaciones
| 'G03' // Gastos en general
| 'I01' // Construcciones
| 'I02' // Mobiliario y equipo de oficina por inversiones
| 'I03' // Equipo de transporte
| 'I04' // Equipo de cómputo y accesorios
| 'I05' // Dados, troqueles, moldes, matrices y herramental
| 'I06' // Comunicaciones telefónicas
| 'I07' // Comunicaciones satelitales
| 'I08' // Otra maquinaria y equipo
| 'D01' // Honorarios médicos, dentales y gastos hospitalarios
| 'D02' // Gastos médicos por incapacidad o discapacidad
| 'D03' // Gastos funerales
| 'D04' // Donativos
| 'D05' // Intereses reales efectivamente pagados por créditos hipotecarios
| 'D06' // Aportaciones voluntarias al SAR
| 'D07' // Primas por seguros de gastos médicos
| 'D08' // Gastos de transportación escolar obligatoria
| 'D09' // Depósitos en cuentas para el ahorro, primas de pensiones
| 'D10' // Pagos por servicios educativos
| 'S01' // Sin efectos fiscales
| 'CP01' // Pagos
| 'CN01'; // Nómina
/**
* Régimen fiscal
*/
export type RegimenFiscal =
| '601' // General de Ley Personas Morales
| '603' // Personas Morales con Fines no Lucrativos
| '605' // Sueldos y Salarios e Ingresos Asimilados a Salarios
| '606' // Arrendamiento
| '607' // Régimen de Enajenación o Adquisición de Bienes
| '608' // Demás ingresos
| '609' // Consolidación
| '610' // Residentes en el Extranjero sin Establecimiento Permanente en México
| '611' // Ingresos por Dividendos (socios y accionistas)
| '612' // Personas Físicas con Actividades Empresariales y Profesionales
| '614' // Ingresos por intereses
| '615' // Régimen de los ingresos por obtención de premios
| '616' // Sin obligaciones fiscales
| '620' // Sociedades Cooperativas de Producción que optan por diferir sus ingresos
| '621' // Incorporación Fiscal
| '622' // Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras
| '623' // Opcional para Grupos de Sociedades
| '624' // Coordinados
| '625' // Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas
| '626'; // Régimen Simplificado de Confianza
/**
* Tipos de impuesto
*/
export type TipoImpuesto =
| '001' // ISR
| '002' // IVA
| '003'; // IEPS
/**
* Tipos de factor
*/
export type TipoFactor = 'Tasa' | 'Cuota' | 'Exento';
/**
* Objeto de impuesto
*/
export type ObjetoImp =
| '01' // No objeto de impuesto
| '02' // Sí objeto de impuesto
| '03' // Sí objeto del impuesto y no obligado al desglose
| '04'; // Sí objeto del impuesto y no causa impuesto
/**
* Exportación
*/
export type Exportacion =
| '01' // No aplica
| '02' // Definitiva
| '03' // Temporal
| '04'; // Definitiva con clave distinta a A1
/**
* Moneda
*/
export type Moneda = 'MXN' | 'USD' | 'EUR' | string;
// ============================================================================
// Interfaces principales
// ============================================================================
/**
* Información del emisor del CFDI
*/
export interface Emisor {
Rfc: string;
Nombre: string;
RegimenFiscal: RegimenFiscal;
FacAtrAdquirente?: string;
}
/**
* Información del receptor del CFDI
*/
export interface Receptor {
Rfc: string;
Nombre: string;
DomicilioFiscalReceptor: string;
RegimenFiscalReceptor: RegimenFiscal;
UsoCFDI: UsoCFDI;
ResidenciaFiscal?: string;
NumRegIdTrib?: string;
}
/**
* Impuesto trasladado
*/
export interface Traslado {
Base: number;
Impuesto: TipoImpuesto;
TipoFactor: TipoFactor;
TasaOCuota?: number;
Importe?: number;
}
/**
* Impuesto retenido
*/
export interface Retencion {
Base: number;
Impuesto: TipoImpuesto;
TipoFactor: TipoFactor;
TasaOCuota: number;
Importe: number;
}
/**
* Impuestos de un concepto
*/
export interface ImpuestosConcepto {
Traslados?: Traslado[];
Retenciones?: Retencion[];
}
/**
* Información aduanera
*/
export interface InformacionAduanera {
NumeroPedimento: string;
}
/**
* Cuenta predial
*/
export interface CuentaPredial {
Numero: string;
}
/**
* Parte de un concepto
*/
export interface Parte {
ClaveProdServ: string;
NoIdentificacion?: string;
Cantidad: number;
Unidad?: string;
Descripcion: string;
ValorUnitario?: number;
Importe?: number;
InformacionAduanera?: InformacionAduanera[];
}
/**
* ACuentaTerceros
*/
export interface ACuentaTerceros {
RfcACuentaTerceros: string;
NombreACuentaTerceros: string;
RegimenFiscalACuentaTerceros: RegimenFiscal;
DomicilioFiscalACuentaTerceros: string;
}
/**
* Concepto del CFDI
*/
export interface Concepto {
ClaveProdServ: string;
NoIdentificacion?: string;
Cantidad: number;
ClaveUnidad: string;
Unidad?: string;
Descripcion: string;
ValorUnitario: number;
Importe: number;
Descuento?: number;
ObjetoImp: ObjetoImp;
Impuestos?: ImpuestosConcepto;
ACuentaTerceros?: ACuentaTerceros;
InformacionAduanera?: InformacionAduanera[];
CuentaPredial?: CuentaPredial[];
Parte?: Parte[];
}
/**
* Impuestos del comprobante
*/
export interface Impuestos {
TotalImpuestosRetenidos?: number;
TotalImpuestosTrasladados?: number;
Retenciones?: Array<{
Impuesto: TipoImpuesto;
Importe: number;
}>;
Traslados?: Array<{
Base: number;
Impuesto: TipoImpuesto;
TipoFactor: TipoFactor;
TasaOCuota?: number;
Importe?: number;
}>;
}
/**
* CFDI relacionado
*/
export interface CfdiRelacionado {
UUID: string;
}
/**
* CFDIs relacionados
*/
export interface CfdiRelacionados {
TipoRelacion: TipoRelacion;
CfdiRelacionado: CfdiRelacionado[];
}
// ============================================================================
// Complemento de Pago
// ============================================================================
/**
* Documento relacionado en un pago
*/
export interface DoctoRelacionado {
IdDocumento: string;
Serie?: string;
Folio?: string;
MonedaDR: Moneda;
EquivalenciaDR?: number;
NumParcialidad?: number;
ImpSaldoAnt?: number;
ImpPagado?: number;
ImpSaldoInsoluto?: number;
ObjetoImpDR?: ObjetoImp;
ImpuestosDR?: {
RetencionesDR?: Array<{
BaseDR: number;
ImpuestoDR: TipoImpuesto;
TipoFactorDR: TipoFactor;
TasaOCuotaDR: number;
ImporteDR: number;
}>;
TrasladosDR?: Array<{
BaseDR: number;
ImpuestoDR: TipoImpuesto;
TipoFactorDR: TipoFactor;
TasaOCuotaDR?: number;
ImporteDR?: number;
}>;
};
}
/**
* Pago individual
*/
export interface Pago {
FechaPago: string;
FormaDePagoP: FormaPago;
MonedaP: Moneda;
TipoCambioP?: number;
Monto: number;
NumOperacion?: string;
RfcEmisorCtaOrd?: string;
NomBancoOrdExt?: string;
CtaOrdenante?: string;
RfcEmisorCtaBen?: string;
CtaBeneficiario?: string;
TipoCadPago?: '01';
CertPago?: string;
CadPago?: string;
SelloPago?: string;
DoctoRelacionado: DoctoRelacionado[];
ImpuestosP?: {
RetencionesP?: Array<{
ImpuestoP: TipoImpuesto;
ImporteP: number;
}>;
TrasladosP?: Array<{
BaseP: number;
ImpuestoP: TipoImpuesto;
TipoFactorP: TipoFactor;
TasaOCuotaP?: number;
ImporteP?: number;
}>;
};
}
/**
* Totales del complemento de pagos
*/
export interface TotalesPago {
TotalRetencionesIVA?: number;
TotalRetencionesISR?: number;
TotalRetencionesIEPS?: number;
TotalTrasladosBaseIVA16?: number;
TotalTrasladosImpuestoIVA16?: number;
TotalTrasladosBaseIVA8?: number;
TotalTrasladosImpuestoIVA8?: number;
TotalTrasladosBaseIVA0?: number;
TotalTrasladosImpuestoIVA0?: number;
TotalTrasladosBaseIVAExento?: number;
MontoTotalPagos: number;
}
/**
* Complemento de Pagos 2.0
*/
export interface ComplementoPago {
Version: '2.0';
Totales: TotalesPago;
Pago: Pago[];
}
// ============================================================================
// Complemento de Nómina
// ============================================================================
/**
* Percepción de nómina
*/
export interface Percepcion {
TipoPercepcion: string;
Clave: string;
Concepto: string;
ImporteGravado: number;
ImporteExento: number;
}
/**
* Deducción de nómina
*/
export interface Deduccion {
TipoDeduccion: string;
Clave: string;
Concepto: string;
Importe: number;
}
/**
* Otro pago de nómina
*/
export interface OtroPago {
TipoOtroPago: string;
Clave: string;
Concepto: string;
Importe: number;
SubsidioAlEmpleo?: {
SubsidioCausado: number;
};
CompensacionSaldosAFavor?: {
SaldoAFavor: number;
Ano: number;
RemanenteSalFav: number;
};
}
/**
* Incapacidad
*/
export interface Incapacidad {
DiasIncapacidad: number;
TipoIncapacidad: string;
ImporteMonetario?: number;
}
/**
* Horas extra
*/
export interface HorasExtra {
Dias: number;
TipoHoras: 'Dobles' | 'Triples';
HorasExtra: number;
ImportePagado: number;
}
/**
* Receptor de nómina
*/
export interface ReceptorNomina {
Curp: string;
NumSeguridadSocial?: string;
FechaInicioRelLaboral?: string;
Antiguedad?: string;
TipoContrato: string;
Sindicalizado?: 'Sí' | 'No';
TipoJornada?: string;
TipoRegimen: string;
NumEmpleado: string;
Departamento?: string;
Puesto?: string;
RiesgoContratado?: string;
PeriodicidadPago: string;
Banco?: string;
CuentaBancaria?: string;
SalarioBaseCotApor?: number;
SalarioDiarioIntegrado?: number;
ClaveEntFed: string;
}
/**
* Emisor de nómina
*/
export interface EmisorNomina {
Curp?: string;
RegistroPatronal?: string;
RfcPatronOrigen?: string;
EntidadSNCF?: {
OrigenRecurso: string;
MontoRecursoPropio?: number;
};
}
/**
* Complemento de Nómina 1.2
*/
export interface ComplementoNomina {
Version: '1.2';
TipoNomina: 'O' | 'E';
FechaPago: string;
FechaInicialPago: string;
FechaFinalPago: string;
NumDiasPagados: number;
TotalPercepciones?: number;
TotalDeducciones?: number;
TotalOtrosPagos?: number;
Emisor?: EmisorNomina;
Receptor: ReceptorNomina;
Percepciones?: {
TotalSueldos?: number;
TotalSeparacionIndemnizacion?: number;
TotalJubilacionPensionRetiro?: number;
TotalGravado: number;
TotalExento: number;
Percepcion: Percepcion[];
};
Deducciones?: {
TotalOtrasDeducciones?: number;
TotalImpuestosRetenidos?: number;
Deduccion: Deduccion[];
};
OtrosPagos?: {
OtroPago: OtroPago[];
};
Incapacidades?: {
Incapacidad: Incapacidad[];
};
HorasExtras?: {
HorasExtra: HorasExtra[];
};
}
// ============================================================================
// Timbre Fiscal Digital
// ============================================================================
/**
* Timbre Fiscal Digital
*/
export interface TimbreFiscalDigital {
Version: '1.1';
UUID: string;
FechaTimbrado: string;
RfcProvCertif: string;
Leyenda?: string;
SelloCFD: string;
NoCertificadoSAT: string;
SelloSAT: string;
}
// ============================================================================
// CFDI Completo
// ============================================================================
/**
* Estructura completa del CFDI 4.0
*/
export interface CFDI {
// Atributos requeridos
Version: '4.0';
Serie?: string;
Folio?: string;
Fecha: string;
Sello: string;
FormaPago?: FormaPago;
NoCertificado: string;
Certificado: string;
CondicionesDePago?: string;
SubTotal: number;
Descuento?: number;
Moneda: Moneda;
TipoCambio?: number;
Total: number;
TipoDeComprobante: TipoComprobante;
Exportacion: Exportacion;
MetodoPago?: MetodoPago;
LugarExpedicion: string;
Confirmacion?: string;
// Nodos hijos
InformacionGlobal?: {
Periodicidad: string;
Meses: string;
Ano: number;
};
CfdiRelacionados?: CfdiRelacionados[];
Emisor: Emisor;
Receptor: Receptor;
Conceptos: Concepto[];
Impuestos?: Impuestos;
// Complementos
Complemento?: {
TimbreFiscalDigital?: TimbreFiscalDigital;
Pagos?: ComplementoPago;
Nomina?: ComplementoNomina;
[key: string]: unknown;
};
// Addenda (opcional)
Addenda?: unknown;
}
// ============================================================================
// Tipos para el servicio SAT
// ============================================================================
/**
* Tipo de solicitud de descarga
*/
export type TipoSolicitud = 'CFDI' | 'Metadata';
/**
* Tipo de comprobante para descarga
*/
export type TipoComprobanteDescarga = 'emitidos' | 'recibidos';
/**
* Estado de la solicitud de descarga
*/
export type EstadoSolicitud =
| '1' // Aceptada
| '2' // En proceso
| '3' // Terminada
| '4' // Error
| '5'; // Rechazada
/**
* Códigos de estado del SAT
*/
export interface CodigoEstadoSAT {
codigo: string;
mensaje: string;
}
/**
* Respuesta de autenticación
*/
export interface AuthResponse {
token: string;
expiresAt: Date;
}
/**
* Solicitud de descarga
*/
export interface SolicitudDescarga {
rfcSolicitante: string;
fechaInicio: Date;
fechaFin: Date;
tipoSolicitud: TipoSolicitud;
tipoComprobante?: TipoComprobante;
rfcEmisor?: string;
rfcReceptor?: string;
complemento?: string;
estadoComprobante?: '0' | '1'; // 0: Cancelado, 1: Vigente
rfcACuentaTerceros?: string;
}
/**
* Respuesta de solicitud de descarga
*/
export interface SolicitudDescargaResponse {
idSolicitud: string;
codEstatus: string;
mensaje: string;
}
/**
* Respuesta de verificación de descarga
*/
export interface VerificacionDescargaResponse {
codEstatus: string;
estadoSolicitud: EstadoSolicitud;
codigoEstadoSolicitud: string;
numeroCFDIs: number;
mensaje: string;
paquetes: string[];
}
/**
* Respuesta de descarga de paquete
*/
export interface DescargaPaqueteResponse {
codEstatus: string;
mensaje: string;
paquete: Buffer;
}
/**
* Información del certificado
*/
export interface CertificateInfo {
serialNumber: string;
subject: {
rfc: string;
nombre: string;
email?: string;
};
issuer: {
cn: string;
o: string;
};
validFrom: Date;
validTo: Date;
isValid: boolean;
}
/**
* Resultado de validación de FIEL
*/
export interface FIELValidationResult {
isValid: boolean;
certificateInfo?: CertificateInfo;
error?: string;
}
/**
* CFDI parseado con metadata
*/
export interface CFDIParsed {
cfdi: CFDI;
xml: string;
uuid: string;
fechaTimbrado: Date;
rfcEmisor: string;
rfcReceptor: string;
total: number;
tipoComprobante: TipoComprobante;
}
/**
* Resultado de sincronización
*/
export interface SyncResult {
success: boolean;
totalProcessed: number;
totalSaved: number;
totalErrors: number;
errors: Array<{
uuid?: string;
error: string;
}>;
requestId?: string;
paquetes?: string[];
}
/**
* Opciones de sincronización
*/
export interface SyncOptions {
tenantId: string;
dateFrom: Date;
dateTo: Date;
tipoComprobante?: TipoComprobanteDescarga;
tipoSolicitud?: TipoSolicitud;
complemento?: string;
}
/**
* Errores específicos del SAT
*/
export class SATError extends Error {
constructor(
message: string,
public readonly codigo: string,
public readonly detalles?: string
) {
super(message);
this.name = 'SATError';
}
}
/**
* Error de autenticación con SAT
*/
export class SATAuthError extends SATError {
constructor(message: string, codigo: string = 'AUTH_ERROR') {
super(message, codigo);
this.name = 'SATAuthError';
}
}
/**
* Error de FIEL
*/
export class FIELError extends Error {
constructor(
message: string,
public readonly tipo: 'INVALID_CERTIFICATE' | 'INVALID_KEY' | 'PASSWORD_ERROR' | 'EXPIRED' | 'MISMATCH'
) {
super(message);
this.name = 'FIELError';
}
}
/**
* Error de parsing de CFDI
*/
export class CFDIParseError extends Error {
constructor(
message: string,
public readonly campo?: string,
public readonly valor?: string
) {
super(message);
this.name = 'CFDIParseError';
}
}

271
apps/api/src/types/index.ts Normal file
View File

@@ -0,0 +1,271 @@
import { z } from 'zod';
// ============================================================================
// User & Auth Types
// ============================================================================
export interface User {
id: string;
email: string;
password_hash: string;
first_name: string;
last_name: string;
role: UserRole;
tenant_id: string;
is_active: boolean;
email_verified: boolean;
created_at: Date;
updated_at: Date;
last_login_at: Date | null;
}
export type UserRole = 'owner' | 'admin' | 'member' | 'viewer';
export interface Tenant {
id: string;
name: string;
slug: string;
schema_name: string;
plan_id: string;
is_active: boolean;
settings: TenantSettings;
created_at: Date;
updated_at: Date;
}
export interface TenantSettings {
timezone: string;
currency: string;
fiscal_year_start_month: number;
language: string;
}
export interface Session {
id: string;
user_id: string;
tenant_id: string;
refresh_token_hash: string;
user_agent: string | null;
ip_address: string | null;
expires_at: Date;
created_at: Date;
}
// ============================================================================
// JWT Token Types
// ============================================================================
export interface AccessTokenPayload {
sub: string; // user_id
email: string;
role: UserRole;
tenant_id: string;
schema_name: string;
type: 'access';
}
export interface RefreshTokenPayload {
sub: string; // user_id
session_id: string;
type: 'refresh';
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
// ============================================================================
// Request Types
// ============================================================================
export interface AuthenticatedRequest {
user: AccessTokenPayload;
tenantSchema: string;
}
// ============================================================================
// API Response Types
// ============================================================================
export interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: ApiError;
meta?: ResponseMeta;
}
export interface ApiError {
code: string;
message: string;
details?: Record<string, unknown>;
stack?: string;
}
export interface ResponseMeta {
page?: number;
limit?: number;
total?: number;
timestamp: string;
requestId?: string;
}
// ============================================================================
// Validation Schemas
// ============================================================================
export const RegisterSchema = z.object({
email: z.string().email('Email invalido'),
password: z
.string()
.min(8, 'La contrasena debe tener al menos 8 caracteres')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'La contrasena debe contener al menos una mayuscula, una minuscula y un numero'
),
firstName: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'),
lastName: z.string().min(2, 'El apellido debe tener al menos 2 caracteres'),
companyName: z.string().min(2, 'El nombre de la empresa debe tener al menos 2 caracteres'),
rfc: z
.string()
.regex(
/^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/,
'RFC invalido. Formato esperado: XAXX010101XXX'
)
.optional(),
});
export const LoginSchema = z.object({
email: z.string().email('Email invalido'),
password: z.string().min(1, 'La contrasena es requerida'),
});
export const RefreshTokenSchema = z.object({
refreshToken: z.string().min(1, 'Refresh token es requerido'),
});
export const ResetPasswordRequestSchema = z.object({
email: z.string().email('Email invalido'),
});
export const ResetPasswordSchema = z.object({
token: z.string().min(1, 'Token es requerido'),
password: z
.string()
.min(8, 'La contrasena debe tener al menos 8 caracteres')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'La contrasena debe contener al menos una mayuscula, una minuscula y un numero'
),
});
export const ChangePasswordSchema = z.object({
currentPassword: z.string().min(1, 'Contrasena actual es requerida'),
newPassword: z
.string()
.min(8, 'La nueva contrasena debe tener al menos 8 caracteres')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'La contrasena debe contener al menos una mayuscula, una minuscula y un numero'
),
});
// ============================================================================
// Inferred Types from Schemas
// ============================================================================
export type RegisterInput = z.infer<typeof RegisterSchema>;
export type LoginInput = z.infer<typeof LoginSchema>;
export type RefreshTokenInput = z.infer<typeof RefreshTokenSchema>;
export type ResetPasswordRequestInput = z.infer<typeof ResetPasswordRequestSchema>;
export type ResetPasswordInput = z.infer<typeof ResetPasswordSchema>;
export type ChangePasswordInput = z.infer<typeof ChangePasswordSchema>;
// ============================================================================
// Error Types
// ============================================================================
export class AppError extends Error {
public readonly code: string;
public readonly statusCode: number;
public readonly isOperational: boolean;
public readonly details?: Record<string, unknown>;
constructor(
message: string,
code: string,
statusCode: number = 500,
isOperational: boolean = true,
details?: Record<string, unknown>
) {
super(message);
this.code = code;
this.statusCode = statusCode;
this.isOperational = isOperational;
this.details = details;
Object.setPrototypeOf(this, AppError.prototype);
Error.captureStackTrace(this, this.constructor);
}
}
export class ValidationError extends AppError {
constructor(message: string, details?: Record<string, unknown>) {
super(message, 'VALIDATION_ERROR', 400, true, details);
Object.setPrototypeOf(this, ValidationError.prototype);
}
}
export class AuthenticationError extends AppError {
constructor(message: string = 'No autorizado') {
super(message, 'AUTHENTICATION_ERROR', 401, true);
Object.setPrototypeOf(this, AuthenticationError.prototype);
}
}
export class AuthorizationError extends AppError {
constructor(message: string = 'Acceso denegado') {
super(message, 'AUTHORIZATION_ERROR', 403, true);
Object.setPrototypeOf(this, AuthorizationError.prototype);
}
}
export class NotFoundError extends AppError {
constructor(resource: string = 'Recurso') {
super(`${resource} no encontrado`, 'NOT_FOUND', 404, true);
Object.setPrototypeOf(this, NotFoundError.prototype);
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 'CONFLICT', 409, true);
Object.setPrototypeOf(this, ConflictError.prototype);
}
}
export class RateLimitError extends AppError {
constructor(message: string = 'Demasiadas solicitudes') {
super(message, 'RATE_LIMIT_EXCEEDED', 429, true);
Object.setPrototypeOf(this, RateLimitError.prototype);
}
}
export class DatabaseError extends AppError {
constructor(message: string = 'Error de base de datos') {
super(message, 'DATABASE_ERROR', 500, false);
Object.setPrototypeOf(this, DatabaseError.prototype);
}
}
export class ExternalServiceError extends AppError {
constructor(service: string, message?: string) {
super(
message || `Error al comunicarse con ${service}`,
'EXTERNAL_SERVICE_ERROR',
502,
true
);
Object.setPrototypeOf(this, ExternalServiceError.prototype);
}
}

View File

@@ -0,0 +1,55 @@
import { Request, Response, NextFunction, RequestHandler } from 'express';
/**
* Wrapper for async route handlers that automatically catches errors
* and passes them to the next middleware (error handler).
*
* @example
* router.get('/users', asyncHandler(async (req, res) => {
* const users = await userService.getAll();
* res.json(users);
* }));
*/
export const asyncHandler = <T = void>(
fn: (req: Request, res: Response, next: NextFunction) => Promise<T>
): RequestHandler => {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
/**
* Wrapper for async route handlers that includes typed request body
*
* @example
* router.post('/users', asyncHandlerTyped<CreateUserInput>(async (req, res) => {
* const user = await userService.create(req.body);
* res.json(user);
* }));
*/
export const asyncHandlerTyped = <TBody = unknown, TParams = unknown, TQuery = unknown>(
fn: (
req: Request<TParams, unknown, TBody, TQuery>,
res: Response,
next: NextFunction
) => Promise<void>
): RequestHandler => {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req as Request<TParams, unknown, TBody, TQuery>, res, next)).catch(next);
};
};
/**
* Wraps multiple middleware functions and ensures errors are properly caught
*/
export const asyncMiddleware = (
...middlewares: Array<(req: Request, res: Response, next: NextFunction) => Promise<void> | void>
): RequestHandler[] => {
return middlewares.map((middleware) => {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(middleware(req, res, next)).catch(next);
};
});
};
export default asyncHandler;

View File

@@ -0,0 +1,3 @@
// Utils exports
export { logger, httpLogger, createContextLogger, auditLog } from './logger.js';
export { asyncHandler, asyncHandlerTyped, asyncMiddleware } from './asyncHandler.js';

View File

@@ -0,0 +1,147 @@
import winston from 'winston';
import { config } from '../config/index.js';
// ============================================================================
// Custom Log Formats
// ============================================================================
const { combine, timestamp, printf, colorize, errors, json } = winston.format;
// Simple format for development
const simpleFormat = printf(({ level, message, timestamp, ...metadata }) => {
let msg = `${timestamp} [${level}]: ${message}`;
if (Object.keys(metadata).length > 0) {
// Filter out Symbol properties and internal winston properties
const filteredMeta = Object.entries(metadata).reduce((acc, [key, value]) => {
if (!key.startsWith('Symbol') && key !== 'splat') {
acc[key] = value;
}
return acc;
}, {} as Record<string, unknown>);
if (Object.keys(filteredMeta).length > 0) {
msg += ` ${JSON.stringify(filteredMeta)}`;
}
}
return msg;
});
// JSON format for production
const jsonFormat = combine(
timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
errors({ stack: true }),
json()
);
// Development format with colors
const devFormat = combine(
colorize({ all: true }),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
errors({ stack: true }),
simpleFormat
);
// ============================================================================
// Create Logger Instance
// ============================================================================
const transports: winston.transport[] = [
new winston.transports.Console({
format: config.logging.format === 'json' ? jsonFormat : devFormat,
}),
];
// Add file transports in production
if (config.isProduction) {
transports.push(
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
format: jsonFormat,
maxsize: 5242880, // 5MB
maxFiles: 5,
}),
new winston.transports.File({
filename: 'logs/combined.log',
format: jsonFormat,
maxsize: 5242880, // 5MB
maxFiles: 5,
})
);
}
export const logger = winston.createLogger({
level: config.logging.level,
defaultMeta: {
service: 'horux-api',
version: process.env.npm_package_version || '0.1.0',
},
transports,
exitOnError: false,
});
// ============================================================================
// HTTP Request Logger
// ============================================================================
export const httpLogger = {
write: (message: string) => {
logger.http(message.trim());
},
};
// ============================================================================
// Utility Functions
// ============================================================================
export interface LogContext {
requestId?: string;
userId?: string;
tenantId?: string;
action?: string;
resource?: string;
[key: string]: unknown;
}
export const createContextLogger = (context: LogContext) => {
return {
error: (message: string, meta?: Record<string, unknown>) =>
logger.error(message, { ...context, ...meta }),
warn: (message: string, meta?: Record<string, unknown>) =>
logger.warn(message, { ...context, ...meta }),
info: (message: string, meta?: Record<string, unknown>) =>
logger.info(message, { ...context, ...meta }),
http: (message: string, meta?: Record<string, unknown>) =>
logger.http(message, { ...context, ...meta }),
verbose: (message: string, meta?: Record<string, unknown>) =>
logger.verbose(message, { ...context, ...meta }),
debug: (message: string, meta?: Record<string, unknown>) =>
logger.debug(message, { ...context, ...meta }),
};
};
// ============================================================================
// Audit Logger for Security Events
// ============================================================================
export const auditLog = (
action: string,
userId: string | null,
tenantId: string | null,
details: Record<string, unknown>,
success: boolean = true
) => {
logger.info('AUDIT', {
type: 'audit',
action,
userId,
tenantId,
success,
details,
timestamp: new Date().toISOString(),
});
};
export default logger;

34
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": false,
"noFallthroughCasesInSwitch": true,
"useUnknownInCatchVariables": true,
"noUncheckedIndexedAccess": true,
"baseUrl": "./src",
"paths": {
"@/*": ["./*"]
},
"typeRoots": ["./node_modules/@types", "./src/types"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

40
apps/web/next.config.js Normal file
View File

@@ -0,0 +1,40 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
transpilePackages: ['@horux/shared', '@horux/ui'],
experimental: {
serverActions: {
bodySizeLimit: '2mb',
},
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.githubusercontent.com',
},
{
protocol: 'https',
hostname: 'avatars.githubusercontent.com',
},
],
},
async redirects() {
return [];
},
async headers() {
return [
{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
{ key: 'Access-Control-Allow-Origin', value: '*' },
{ key: 'Access-Control-Allow-Methods', value: 'GET,DELETE,PATCH,POST,PUT' },
{ key: 'Access-Control-Allow-Headers', value: 'Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, Authorization' },
],
},
];
},
};
module.exports = nextConfig;

40
apps/web/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "@horux/web",
"version": "0.1.0",
"private": true,
"description": "Horux Strategy Frontend",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
"typecheck": "tsc --noEmit",
"clean": "rm -rf .next node_modules"
},
"dependencies": {
"@horux/shared": "workspace:*",
"@horux/ui": "workspace:*",
"next": "14.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"zustand": "^4.5.0",
"recharts": "^2.10.4",
"lucide-react": "^0.312.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0",
"date-fns": "^3.3.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-config-next": "14.1.0",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,156 @@
'use client';
import React, { useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth.store';
/**
* Auth Layout
*
* Layout para las paginas de autenticacion (login, register).
* Redirige a dashboard si el usuario ya esta autenticado.
*/
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const { isAuthenticated, isInitialized, checkAuth } = useAuthStore();
useEffect(() => {
checkAuth();
}, [checkAuth]);
useEffect(() => {
if (isInitialized && isAuthenticated) {
router.replace('/dashboard');
}
}, [isAuthenticated, isInitialized, router]);
// Si esta autenticado, no renderizar nada mientras redirige
if (isAuthenticated) {
return null;
}
return (
<div className="min-h-screen flex">
{/* Left Panel - Branding */}
<div className="hidden lg:flex lg:w-1/2 xl:w-2/5 bg-horux-gradient-dark relative overflow-hidden">
{/* Background Pattern */}
<div className="absolute inset-0 opacity-10">
<div
className="absolute inset-0"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}}
/>
</div>
{/* Content */}
<div className="relative z-10 flex flex-col justify-between p-8 lg:p-12">
{/* Logo */}
<Link href="/" className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-white/10 backdrop-blur flex items-center justify-center">
<span className="text-2xl font-bold text-white">H</span>
</div>
<span className="text-2xl font-bold text-white">Horux Strategy</span>
</Link>
{/* Main Content */}
<div className="space-y-6">
<h1 className="text-4xl xl:text-5xl font-bold text-white leading-tight">
Trading Algoritmico
<br />
<span className="text-primary-300">Inteligente</span>
</h1>
<p className="text-lg text-primary-100/80 max-w-md">
Automatiza tus estrategias de inversion con inteligencia artificial
y maximiza tus ganancias en el mercado de criptomonedas.
</p>
{/* Features */}
<div className="space-y-4 pt-4">
<FeatureItem
title="Estrategias Automatizadas"
description="Ejecuta operaciones 24/7 sin intervencion manual"
/>
<FeatureItem
title="Analisis en Tiempo Real"
description="Monitorea el mercado y toma decisiones informadas"
/>
<FeatureItem
title="Gestion de Riesgo"
description="Protege tu capital con limites inteligentes"
/>
</div>
</div>
{/* Footer */}
<div className="text-primary-200/60 text-sm">
&copy; {new Date().getFullYear()} Horux Strategy. Todos los derechos reservados.
</div>
</div>
{/* Decorative Elements */}
<div className="absolute -bottom-32 -right-32 w-96 h-96 rounded-full bg-primary-500/20 blur-3xl" />
<div className="absolute -top-32 -left-32 w-96 h-96 rounded-full bg-primary-400/10 blur-3xl" />
</div>
{/* Right Panel - Auth Form */}
<div className="flex-1 flex items-center justify-center p-4 sm:p-6 lg:p-8 bg-slate-50 dark:bg-slate-950">
<div className="w-full max-w-md">
{/* Mobile Logo */}
<div className="lg:hidden mb-8 text-center">
<Link href="/" className="inline-flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-horux-gradient flex items-center justify-center">
<span className="text-xl font-bold text-white">H</span>
</div>
<span className="text-xl font-bold text-slate-900 dark:text-white">
Horux Strategy
</span>
</Link>
</div>
{children}
</div>
</div>
</div>
);
}
/**
* Feature Item Component
*/
function FeatureItem({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary-400/20 flex items-center justify-center mt-0.5">
<svg
className="w-4 h-4 text-primary-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div>
<h3 className="text-white font-medium">{title}</h3>
<p className="text-primary-200/70 text-sm">{description}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,257 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth.store';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Mail, Lock, AlertCircle } from 'lucide-react';
/**
* Login Page
*
* Pagina de inicio de sesion con formulario de email y password.
*/
export default function LoginPage() {
const router = useRouter();
const { login, isLoading, error, clearError } = useAuthStore();
const [formData, setFormData] = useState({
email: '',
password: '',
remember: false,
});
const [validationErrors, setValidationErrors] = useState<{
email?: string;
password?: string;
}>({});
// Handle input change
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
// Clear validation error on change
if (validationErrors[name as keyof typeof validationErrors]) {
setValidationErrors((prev) => ({ ...prev, [name]: undefined }));
}
// Clear API error on change
if (error) {
clearError();
}
};
// Validate form
const validate = (): boolean => {
const errors: typeof validationErrors = {};
if (!formData.email) {
errors.email = 'El email es requerido';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = 'Email invalido';
}
if (!formData.password) {
errors.password = 'La contrasena es requerida';
} else if (formData.password.length < 6) {
errors.password = 'La contrasena debe tener al menos 6 caracteres';
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
// Handle submit
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
try {
await login({
email: formData.email,
password: formData.password,
remember: formData.remember,
});
router.push('/dashboard');
} catch {
// Error is handled by the store
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="text-center lg:text-left">
<h1 className="text-2xl lg:text-3xl font-bold text-slate-900 dark:text-white">
Bienvenido de nuevo
</h1>
<p className="mt-2 text-slate-600 dark:text-slate-400">
Ingresa tus credenciales para acceder a tu cuenta
</p>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800">
<AlertCircle className="h-5 w-5 text-error-600 dark:text-error-400 flex-shrink-0" />
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Email */}
<Input
type="email"
name="email"
label="Email"
placeholder="tu@email.com"
value={formData.email}
onChange={handleChange}
error={validationErrors.email}
leftIcon={<Mail className="h-5 w-5" />}
autoComplete="email"
disabled={isLoading}
/>
{/* Password */}
<Input
type="password"
name="password"
label="Contrasena"
placeholder="Tu contrasena"
value={formData.password}
onChange={handleChange}
error={validationErrors.password}
leftIcon={<Lock className="h-5 w-5" />}
autoComplete="current-password"
disabled={isLoading}
/>
{/* Remember & Forgot */}
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
name="remember"
checked={formData.remember}
onChange={handleChange}
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-primary-600 focus:ring-primary-500"
disabled={isLoading}
/>
<span className="text-sm text-slate-600 dark:text-slate-400">
Recordarme
</span>
</label>
<Link
href="/forgot-password"
className="text-sm text-primary-600 dark:text-primary-400 hover:underline"
>
Olvidaste tu contrasena?
</Link>
</div>
{/* Submit Button */}
<Button
type="submit"
fullWidth
size="lg"
isLoading={isLoading}
>
Iniciar Sesion
</Button>
</form>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-200 dark:border-slate-700" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-slate-50 dark:bg-slate-950 text-slate-500">
O continua con
</span>
</div>
</div>
{/* Social Login */}
<div className="grid grid-cols-2 gap-4">
<Button
type="button"
variant="outline"
className="gap-2"
disabled={isLoading}
>
<GoogleIcon className="h-5 w-5" />
Google
</Button>
<Button
type="button"
variant="outline"
className="gap-2"
disabled={isLoading}
>
<GithubIcon className="h-5 w-5" />
GitHub
</Button>
</div>
{/* Register Link */}
<p className="text-center text-sm text-slate-600 dark:text-slate-400">
No tienes una cuenta?{' '}
<Link
href="/register"
className="font-medium text-primary-600 dark:text-primary-400 hover:underline"
>
Registrate gratis
</Link>
</p>
</div>
);
}
/**
* Google Icon
*/
function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
);
}
/**
* Github Icon
*/
function GithubIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
}

View File

@@ -0,0 +1,392 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth.store';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Mail, Lock, User, AlertCircle, Check } from 'lucide-react';
/**
* Register Page
*
* Pagina de registro con formulario de nombre, email y password.
*/
export default function RegisterPage() {
const router = useRouter();
const { register, isLoading, error, clearError } = useAuthStore();
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
acceptTerms: false,
});
const [validationErrors, setValidationErrors] = useState<{
name?: string;
email?: string;
password?: string;
confirmPassword?: string;
acceptTerms?: string;
}>({});
// Password strength
const getPasswordStrength = (password: string): {
score: number;
label: string;
color: string;
} => {
let score = 0;
if (password.length >= 8) score++;
if (password.length >= 12) score++;
if (/[A-Z]/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
if (score <= 1) return { score, label: 'Muy debil', color: 'bg-error-500' };
if (score === 2) return { score, label: 'Debil', color: 'bg-warning-500' };
if (score === 3) return { score, label: 'Media', color: 'bg-warning-400' };
if (score === 4) return { score, label: 'Fuerte', color: 'bg-success-400' };
return { score, label: 'Muy fuerte', color: 'bg-success-500' };
};
const passwordStrength = getPasswordStrength(formData.password);
// Handle input change
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
// Clear validation error on change
if (validationErrors[name as keyof typeof validationErrors]) {
setValidationErrors((prev) => ({ ...prev, [name]: undefined }));
}
// Clear API error on change
if (error) {
clearError();
}
};
// Validate form
const validate = (): boolean => {
const errors: typeof validationErrors = {};
if (!formData.name) {
errors.name = 'El nombre es requerido';
} else if (formData.name.length < 2) {
errors.name = 'El nombre debe tener al menos 2 caracteres';
}
if (!formData.email) {
errors.email = 'El email es requerido';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = 'Email invalido';
}
if (!formData.password) {
errors.password = 'La contrasena es requerida';
} else if (formData.password.length < 8) {
errors.password = 'La contrasena debe tener al menos 8 caracteres';
}
if (!formData.confirmPassword) {
errors.confirmPassword = 'Confirma tu contrasena';
} else if (formData.password !== formData.confirmPassword) {
errors.confirmPassword = 'Las contrasenas no coinciden';
}
if (!formData.acceptTerms) {
errors.acceptTerms = 'Debes aceptar los terminos y condiciones';
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
// Handle submit
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
try {
await register({
name: formData.name,
email: formData.email,
password: formData.password,
});
router.push('/dashboard');
} catch {
// Error is handled by the store
}
};
// Password requirements
const requirements = [
{ label: 'Al menos 8 caracteres', met: formData.password.length >= 8 },
{ label: 'Una letra mayuscula', met: /[A-Z]/.test(formData.password) },
{ label: 'Un numero', met: /[0-9]/.test(formData.password) },
{ label: 'Un caracter especial', met: /[^A-Za-z0-9]/.test(formData.password) },
];
return (
<div className="space-y-6">
{/* Header */}
<div className="text-center lg:text-left">
<h1 className="text-2xl lg:text-3xl font-bold text-slate-900 dark:text-white">
Crear cuenta
</h1>
<p className="mt-2 text-slate-600 dark:text-slate-400">
Comienza tu viaje en el trading algoritmico
</p>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800">
<AlertCircle className="h-5 w-5 text-error-600 dark:text-error-400 flex-shrink-0" />
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Name */}
<Input
type="text"
name="name"
label="Nombre completo"
placeholder="Tu nombre"
value={formData.name}
onChange={handleChange}
error={validationErrors.name}
leftIcon={<User className="h-5 w-5" />}
autoComplete="name"
disabled={isLoading}
/>
{/* Email */}
<Input
type="email"
name="email"
label="Email"
placeholder="tu@email.com"
value={formData.email}
onChange={handleChange}
error={validationErrors.email}
leftIcon={<Mail className="h-5 w-5" />}
autoComplete="email"
disabled={isLoading}
/>
{/* Password */}
<div className="space-y-3">
<Input
type="password"
name="password"
label="Contrasena"
placeholder="Crea una contrasena segura"
value={formData.password}
onChange={handleChange}
error={validationErrors.password}
leftIcon={<Lock className="h-5 w-5" />}
autoComplete="new-password"
disabled={isLoading}
/>
{/* Password Strength */}
{formData.password && (
<div className="space-y-2">
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className={`h-1 flex-1 rounded-full transition-all ${
i <= passwordStrength.score
? passwordStrength.color
: 'bg-slate-200 dark:bg-slate-700'
}`}
/>
))}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Seguridad: <span className="font-medium">{passwordStrength.label}</span>
</p>
</div>
)}
{/* Requirements */}
{formData.password && (
<ul className="grid grid-cols-2 gap-2">
{requirements.map((req) => (
<li
key={req.label}
className={`flex items-center gap-2 text-xs ${
req.met
? 'text-success-600 dark:text-success-400'
: 'text-slate-400 dark:text-slate-500'
}`}
>
{req.met ? (
<Check className="h-3 w-3" />
) : (
<div className="h-3 w-3 rounded-full border border-current" />
)}
{req.label}
</li>
))}
</ul>
)}
</div>
{/* Confirm Password */}
<Input
type="password"
name="confirmPassword"
label="Confirmar contrasena"
placeholder="Repite tu contrasena"
value={formData.confirmPassword}
onChange={handleChange}
error={validationErrors.confirmPassword}
leftIcon={<Lock className="h-5 w-5" />}
autoComplete="new-password"
disabled={isLoading}
success={
formData.confirmPassword !== '' &&
formData.password === formData.confirmPassword
}
/>
{/* Terms */}
<div>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
name="acceptTerms"
checked={formData.acceptTerms}
onChange={handleChange}
className="mt-0.5 w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-primary-600 focus:ring-primary-500"
disabled={isLoading}
/>
<span className="text-sm text-slate-600 dark:text-slate-400">
Acepto los{' '}
<Link
href="/terms"
className="text-primary-600 dark:text-primary-400 hover:underline"
>
Terminos de Servicio
</Link>{' '}
y la{' '}
<Link
href="/privacy"
className="text-primary-600 dark:text-primary-400 hover:underline"
>
Politica de Privacidad
</Link>
</span>
</label>
{validationErrors.acceptTerms && (
<p className="mt-1.5 text-sm text-error-600 dark:text-error-400">
{validationErrors.acceptTerms}
</p>
)}
</div>
{/* Submit Button */}
<Button type="submit" fullWidth size="lg" isLoading={isLoading}>
Crear cuenta
</Button>
</form>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-200 dark:border-slate-700" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-slate-50 dark:bg-slate-950 text-slate-500">
O registrate con
</span>
</div>
</div>
{/* Social Register */}
<div className="grid grid-cols-2 gap-4">
<Button
type="button"
variant="outline"
className="gap-2"
disabled={isLoading}
>
<GoogleIcon className="h-5 w-5" />
Google
</Button>
<Button
type="button"
variant="outline"
className="gap-2"
disabled={isLoading}
>
<GithubIcon className="h-5 w-5" />
GitHub
</Button>
</div>
{/* Login Link */}
<p className="text-center text-sm text-slate-600 dark:text-slate-400">
Ya tienes una cuenta?{' '}
<Link
href="/login"
className="font-medium text-primary-600 dark:text-primary-400 hover:underline"
>
Inicia sesion
</Link>
</p>
</div>
);
}
/**
* Google Icon
*/
function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
);
}
/**
* Github Icon
*/
function GithubIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
}

View File

@@ -0,0 +1,788 @@
'use client';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
FileText,
Search,
Filter,
Download,
RefreshCw,
ChevronLeft,
ChevronRight,
Eye,
CheckCircle,
Clock,
XCircle,
AlertCircle,
X,
ExternalLink,
Building2,
Calendar,
DollarSign,
} from 'lucide-react';
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/utils';
import { api } from '@/lib/api';
// ============================================================================
// Types
// ============================================================================
type CFDIType = 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago';
type CFDIStatus = 'vigente' | 'cancelado' | 'pendiente_cancelacion';
type PaymentStatus = 'pagado' | 'parcial' | 'pendiente' | 'vencido';
type CFDITab = 'emitidos' | 'recibidos' | 'complementos';
interface CFDIConcept {
claveProdServ: string;
cantidad: number;
claveUnidad: string;
unidad: string;
descripcion: string;
valorUnitario: number;
importe: number;
descuento?: number;
impuestos?: {
traslados?: { impuesto: string; tasa: number; importe: number }[];
retenciones?: { impuesto: string; tasa: number; importe: number }[];
};
}
interface CFDI {
id: string;
uuid: string;
serie?: string;
folio?: string;
fecha: string;
fechaTimbrado: string;
tipo: CFDIType;
tipoComprobante: string;
metodoPago?: string;
formaPago?: string;
condicionesPago?: string;
status: CFDIStatus;
paymentStatus: PaymentStatus;
emisor: {
rfc: string;
nombre: string;
regimenFiscal: string;
};
receptor: {
rfc: string;
nombre: string;
usoCFDI: string;
};
conceptos: CFDIConcept[];
subtotal: number;
descuento?: number;
impuestos: {
totalTraslados: number;
totalRetenciones: number;
};
total: number;
moneda: string;
tipoCambio?: number;
complementos?: string[];
relacionados?: { tipoRelacion: string; uuid: string }[];
pagosRelacionados?: { id: string; uuid: string; fecha: string; monto: number }[];
montoPagado: number;
montoPendiente: number;
xmlUrl?: string;
pdfUrl?: string;
createdAt: string;
updatedAt: string;
}
interface Filters {
search: string;
tipo: CFDIType | 'all';
status: CFDIStatus | 'all';
paymentStatus: PaymentStatus | 'all';
rfc: string;
dateFrom: string;
dateTo: string;
}
// ============================================================================
// Mock Data
// ============================================================================
const generateMockCFDIs = (): CFDI[] => {
const emisores = [
{ rfc: 'EMP123456789', nombre: 'Mi Empresa S.A. de C.V.', regimenFiscal: '601' },
];
const receptores = [
{ rfc: 'CLI987654321', nombre: 'Cliente Uno S.A.', usoCFDI: 'G03' },
{ rfc: 'PRO456789123', nombre: 'Proveedor Alpha', usoCFDI: 'G01' },
{ rfc: 'SER789123456', nombre: 'Servicios Beta S.A.', usoCFDI: 'G03' },
{ rfc: 'TEC321654987', nombre: 'Tech Solutions', usoCFDI: 'G01' },
{ rfc: 'DIS654987321', nombre: 'Distribuidora Nacional', usoCFDI: 'G03' },
];
const cfdis: CFDI[] = [];
for (let i = 0; i < 80; i++) {
const isEmitted = Math.random() > 0.4;
const tipo: CFDIType = (['ingreso', 'egreso', 'pago'] as CFDIType[])[Math.floor(Math.random() * 3)];
const date = new Date();
date.setDate(date.getDate() - Math.floor(Math.random() * 90));
const total = Math.floor(Math.random() * 100000) + 1000;
const pagado = Math.random() > 0.3 ? (Math.random() > 0.5 ? total : Math.floor(total * Math.random())) : 0;
const receptor = receptores[Math.floor(Math.random() * receptores.length)];
let paymentStatus: PaymentStatus = 'pendiente';
if (pagado >= total) paymentStatus = 'pagado';
else if (pagado > 0) paymentStatus = 'parcial';
else if (date < new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)) paymentStatus = 'vencido';
cfdis.push({
id: `cfdi-${i + 1}`,
uuid: `${Math.random().toString(36).substr(2, 8)}-${Math.random().toString(36).substr(2, 4)}-${Math.random().toString(36).substr(2, 4)}-${Math.random().toString(36).substr(2, 4)}-${Math.random().toString(36).substr(2, 12)}`.toUpperCase(),
serie: 'A',
folio: String(1000 + i),
fecha: date.toISOString(),
fechaTimbrado: date.toISOString(),
tipo,
tipoComprobante: tipo === 'ingreso' ? 'I' : tipo === 'egreso' ? 'E' : 'P',
metodoPago: ['PUE', 'PPD'][Math.floor(Math.random() * 2)],
formaPago: ['01', '03', '04', '28'][Math.floor(Math.random() * 4)],
status: Math.random() > 0.1 ? 'vigente' : 'cancelado',
paymentStatus,
emisor: isEmitted ? emisores[0] : { ...receptor, regimenFiscal: '601' },
receptor: isEmitted ? receptor : emisores[0],
conceptos: [
{
claveProdServ: '84111506',
cantidad: 1,
claveUnidad: 'E48',
unidad: 'Servicio',
descripcion: `Servicio profesional ${i + 1}`,
valorUnitario: total / 1.16,
importe: total / 1.16,
impuestos: {
traslados: [{ impuesto: '002', tasa: 0.16, importe: (total / 1.16) * 0.16 }],
},
},
],
subtotal: total / 1.16,
impuestos: {
totalTraslados: (total / 1.16) * 0.16,
totalRetenciones: 0,
},
total,
moneda: 'MXN',
montoPagado: pagado,
montoPendiente: total - pagado,
pagosRelacionados: pagado > 0 && tipo !== 'pago' ? [
{ id: `pago-${i}`, uuid: `pago-uuid-${i}`, fecha: date.toISOString(), monto: pagado },
] : undefined,
xmlUrl: `/api/cfdis/${i}/xml`,
pdfUrl: `/api/cfdis/${i}/pdf`,
createdAt: date.toISOString(),
updatedAt: date.toISOString(),
});
}
return cfdis.sort((a, b) => new Date(b.fecha).getTime() - new Date(a.fecha).getTime());
};
// ============================================================================
// Loading Skeleton
// ============================================================================
function CFDISkeleton() {
return (
<div className="animate-pulse space-y-4">
<div className="flex gap-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-28" />
))}
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-64" />
</div>
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="px-4 py-4 border-b border-gray-100 dark:border-gray-700 flex gap-4">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded flex-1" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32" />
</div>
))}
</div>
</div>
);
}
// ============================================================================
// Payment Status Badge
// ============================================================================
interface PaymentBadgeProps {
status: PaymentStatus;
montoPagado: number;
total: number;
}
function PaymentBadge({ status, montoPagado, total }: PaymentBadgeProps) {
const configs: Record<PaymentStatus, { icon: React.ReactNode; label: string; classes: string }> = {
pagado: {
icon: <CheckCircle className="w-3.5 h-3.5" />,
label: 'Pagado',
classes: 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400',
},
parcial: {
icon: <Clock className="w-3.5 h-3.5" />,
label: `${Math.round((montoPagado / total) * 100)}% Pagado`,
classes: 'bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-400',
},
pendiente: {
icon: <Clock className="w-3.5 h-3.5" />,
label: 'Pendiente',
classes: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
},
vencido: {
icon: <AlertCircle className="w-3.5 h-3.5" />,
label: 'Vencido',
classes: 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400',
},
};
const config = configs[status];
return (
<span className={cn('inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full', config.classes)}>
{config.icon}
{config.label}
</span>
);
}
// ============================================================================
// Filter Panel
// ============================================================================
interface FilterPanelProps {
filters: Filters;
onChange: (filters: Filters) => void;
onClose: () => void;
isOpen: boolean;
}
function FilterPanel({ filters, onChange, onClose, isOpen }: FilterPanelProps) {
if (!isOpen) return null;
return (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-4 shadow-lg">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-gray-900 dark:text-white">Filtros</h3>
<button onClick={onClose} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tipo</label>
<select
value={filters.tipo}
onChange={(e) => onChange({ ...filters, tipo: e.target.value as Filters['tipo'] })}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
>
<option value="all">Todos</option>
<option value="ingreso">Ingreso</option>
<option value="egreso">Egreso</option>
<option value="pago">Pago</option>
<option value="traslado">Traslado</option>
<option value="nomina">Nomina</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estado CFDI</label>
<select
value={filters.status}
onChange={(e) => onChange({ ...filters, status: e.target.value as Filters['status'] })}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
>
<option value="all">Todos</option>
<option value="vigente">Vigente</option>
<option value="cancelado">Cancelado</option>
<option value="pendiente_cancelacion">Pendiente Cancelacion</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estado de Pago</label>
<select
value={filters.paymentStatus}
onChange={(e) => onChange({ ...filters, paymentStatus: e.target.value as Filters['paymentStatus'] })}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
>
<option value="all">Todos</option>
<option value="pagado">Pagado</option>
<option value="parcial">Parcial</option>
<option value="pendiente">Pendiente</option>
<option value="vencido">Vencido</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">RFC</label>
<input
type="text"
placeholder="Buscar por RFC..."
value={filters.rfc}
onChange={(e) => onChange({ ...filters, rfc: e.target.value.toUpperCase() })}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Desde</label>
<input
type="date"
value={filters.dateFrom}
onChange={(e) => onChange({ ...filters, dateFrom: e.target.value })}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hasta</label>
<input
type="date"
value={filters.dateTo}
onChange={(e) => onChange({ ...filters, dateTo: e.target.value })}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() =>
onChange({
search: '',
tipo: 'all',
status: 'all',
paymentStatus: 'all',
rfc: '',
dateFrom: '',
dateTo: '',
})
}
className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
>
Limpiar filtros
</button>
<button onClick={onClose} className="px-4 py-2 bg-primary-500 text-white rounded-lg text-sm hover:bg-primary-600">
Aplicar
</button>
</div>
</div>
);
}
// ============================================================================
// Main Page Component
// ============================================================================
export default function CFDIsPage() {
const [cfdis, setCfdis] = useState<CFDI[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<CFDITab>('emitidos');
const [showFilters, setShowFilters] = useState(false);
const [page, setPage] = useState(1);
const [filters, setFilters] = useState<Filters>({
search: '',
tipo: 'all',
status: 'all',
paymentStatus: 'all',
rfc: '',
dateFrom: '',
dateTo: '',
});
const limit = 20;
const myRFC = 'EMP123456789';
const fetchCFDIs = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
await new Promise((resolve) => setTimeout(resolve, 600));
setCfdis(generateMockCFDIs());
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar CFDIs');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchCFDIs();
}, [fetchCFDIs]);
// Filter CFDIs by tab and filters
const filteredCFDIs = useMemo(() => {
return cfdis.filter((cfdi) => {
// Tab filter
if (activeTab === 'emitidos' && cfdi.emisor.rfc !== myRFC) return false;
if (activeTab === 'recibidos' && cfdi.receptor.rfc !== myRFC) return false;
if (activeTab === 'complementos' && cfdi.tipo !== 'pago') return false;
// Search filter
if (filters.search) {
const searchLower = filters.search.toLowerCase();
if (
!cfdi.uuid.toLowerCase().includes(searchLower) &&
!cfdi.emisor.nombre.toLowerCase().includes(searchLower) &&
!cfdi.receptor.nombre.toLowerCase().includes(searchLower) &&
!cfdi.folio?.toLowerCase().includes(searchLower)
) {
return false;
}
}
// Other filters
if (filters.tipo !== 'all' && cfdi.tipo !== filters.tipo) return false;
if (filters.status !== 'all' && cfdi.status !== filters.status) return false;
if (filters.paymentStatus !== 'all' && cfdi.paymentStatus !== filters.paymentStatus) return false;
if (filters.rfc) {
if (!cfdi.emisor.rfc.includes(filters.rfc) && !cfdi.receptor.rfc.includes(filters.rfc)) return false;
}
if (filters.dateFrom && cfdi.fecha < filters.dateFrom) return false;
if (filters.dateTo && cfdi.fecha > filters.dateTo) return false;
return true;
});
}, [cfdis, activeTab, filters, myRFC]);
const paginatedCFDIs = useMemo(() => {
const start = (page - 1) * limit;
return filteredCFDIs.slice(start, start + limit);
}, [filteredCFDIs, page]);
const totalPages = Math.ceil(filteredCFDIs.length / limit);
const summary = useMemo(() => {
const total = filteredCFDIs.reduce((sum, c) => sum + c.total, 0);
const pagado = filteredCFDIs.reduce((sum, c) => sum + c.montoPagado, 0);
const pendiente = filteredCFDIs.reduce((sum, c) => sum + c.montoPendiente, 0);
return { total, pagado, pendiente, count: filteredCFDIs.length };
}, [filteredCFDIs]);
const tipoLabels: Record<CFDIType, string> = {
ingreso: 'Ingreso',
egreso: 'Egreso',
traslado: 'Traslado',
nomina: 'Nomina',
pago: 'Pago',
};
const handleViewCFDI = (cfdi: CFDI) => {
window.location.href = `/cfdis/${cfdi.id}`;
};
if (isLoading) {
return (
<div className="p-6">
<CFDISkeleton />
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800 rounded-lg p-6 text-center">
<p className="text-error-700 dark:text-error-400 mb-4">{error}</p>
<button onClick={fetchCFDIs} className="px-4 py-2 bg-error-600 text-white rounded-lg hover:bg-error-700">
Reintentar
</button>
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">CFDIs</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Gestion de comprobantes fiscales digitales
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={fetchCFDIs}
className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
<RefreshCw className="w-4 h-4" />
Sincronizar SAT
</button>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 border-b border-gray-200 dark:border-gray-700">
{([
{ value: 'emitidos', label: 'Emitidos' },
{ value: 'recibidos', label: 'Recibidos' },
{ value: 'complementos', label: 'Complementos de Pago' },
] as { value: CFDITab; label: string }[]).map((tab) => (
<button
key={tab.value}
onClick={() => {
setActiveTab(tab.value);
setPage(1);
}}
className={cn(
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
activeTab === tab.value
? 'border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
)}
>
{tab.label}
</button>
))}
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
<FileText className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Total CFDIs</p>
<p className="text-xl font-bold text-gray-900 dark:text-white">{formatNumber(summary.count)}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
<DollarSign className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Monto Total</p>
<p className="text-xl font-bold text-gray-900 dark:text-white">{formatCurrency(summary.total)}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
<CheckCircle className="w-5 h-5 text-success-600 dark:text-success-400" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Pagado</p>
<p className="text-xl font-bold text-success-600 dark:text-success-400">{formatCurrency(summary.pagado)}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-warning-100 dark:bg-warning-900/30 rounded-lg">
<Clock className="w-5 h-5 text-warning-600 dark:text-warning-400" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Pendiente</p>
<p className="text-xl font-bold text-warning-600 dark:text-warning-400">{formatCurrency(summary.pendiente)}</p>
</div>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Buscar por UUID, folio, nombre..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className={cn(
'flex items-center gap-2 px-4 py-2 border rounded-lg transition-colors',
showFilters
? 'bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-700 text-primary-700 dark:text-primary-400'
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
)}
>
<Filter className="w-4 h-4" />
Filtros
</button>
</div>
{/* Filter Panel */}
<FilterPanel filters={filters} onChange={setFilters} onClose={() => setShowFilters(false)} isOpen={showFilters} />
{/* CFDIs Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-900/50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Folio / UUID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Fecha
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{activeTab === 'emitidos' ? 'Receptor' : 'Emisor'}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Tipo
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Total
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Estado CFDI
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Estado Pago
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Acciones
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{paginatedCFDIs.map((cfdi) => {
const contacto = activeTab === 'emitidos' ? cfdi.receptor : cfdi.emisor;
return (
<tr
key={cfdi.id}
onClick={() => handleViewCFDI(cfdi)}
className="hover:bg-gray-50 dark:hover:bg-gray-900/50 cursor-pointer"
>
<td className="px-6 py-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{cfdi.serie}-{cfdi.folio}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{cfdi.uuid.slice(0, 8)}...
</p>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
{formatDate(cfdi.fecha)}
</td>
<td className="px-6 py-4">
<div>
<p className="text-sm text-gray-900 dark:text-white">{contacto.nombre}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{contacto.rfc}</p>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={cn(
'inline-flex px-2 py-0.5 text-xs font-medium rounded-full',
cfdi.tipo === 'ingreso' && 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400',
cfdi.tipo === 'egreso' && 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400',
cfdi.tipo === 'pago' && 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400',
cfdi.tipo === 'traslado' && 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
cfdi.tipo === 'nomina' && 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
)}
>
{tipoLabels[cfdi.tipo]}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-gray-900 dark:text-white">
{formatCurrency(cfdi.total, cfdi.moneda)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full',
cfdi.status === 'vigente' && 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400',
cfdi.status === 'cancelado' && 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400',
cfdi.status === 'pendiente_cancelacion' && 'bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-400'
)}
>
{cfdi.status === 'vigente' && <CheckCircle className="w-3 h-3" />}
{cfdi.status === 'cancelado' && <XCircle className="w-3 h-3" />}
{cfdi.status === 'pendiente_cancelacion' && <Clock className="w-3 h-3" />}
{cfdi.status === 'vigente' ? 'Vigente' : cfdi.status === 'cancelado' ? 'Cancelado' : 'Pend. Cancel.'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<PaymentBadge status={cfdi.paymentStatus} montoPagado={cfdi.montoPagado} total={cfdi.total} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={(e) => {
e.stopPropagation();
handleViewCFDI(cfdi);
}}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="Ver detalle"
>
<Eye className="w-4 h-4 text-gray-500" />
</button>
<a
href={cfdi.xmlUrl}
onClick={(e) => e.stopPropagation()}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="Descargar XML"
>
<Download className="w-4 h-4 text-gray-500" />
</a>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<p className="text-sm text-gray-500 dark:text-gray-400">
Mostrando {(page - 1) * limit + 1} a {Math.min(page * limit, filteredCFDIs.length)} de{' '}
{filteredCFDIs.length} CFDIs
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-5 h-5" />
</button>
<span className="text-sm text-gray-600 dark:text-gray-400">
Pagina {page} de {totalPages || 1}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages || totalPages === 0}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,441 @@
'use client';
import React from 'react';
import { Card, CardHeader, CardContent, StatsCard } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { cn, formatCurrency, formatPercentage, formatNumber } from '@/lib/utils';
import {
TrendingUp,
TrendingDown,
Wallet,
Activity,
BarChart3,
ArrowUpRight,
ArrowDownRight,
Bot,
Target,
Clock,
AlertTriangle,
Plus,
RefreshCw,
} from 'lucide-react';
/**
* Dashboard Page
*
* Pagina principal del dashboard con KPIs, grafico de portfolio,
* estrategias activas y trades recientes.
*/
export default function DashboardPage() {
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl lg:text-3xl font-bold text-slate-900 dark:text-white">
Dashboard
</h1>
<p className="mt-1 text-slate-500 dark:text-slate-400">
Bienvenido de nuevo. Aqui esta el resumen de tu portfolio.
</p>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" leftIcon={<RefreshCw className="h-4 w-4" />}>
Actualizar
</Button>
<Button size="sm" leftIcon={<Plus className="h-4 w-4" />}>
Nueva Estrategia
</Button>
</div>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
<StatsCard
title="Balance Total"
value={formatCurrency(125847.32)}
change={{ value: 12.5, label: 'vs mes anterior' }}
trend="up"
icon={<Wallet className="h-6 w-6" />}
/>
<StatsCard
title="Ganancia Hoy"
value={formatCurrency(2340.18)}
change={{ value: 8.2, label: 'vs ayer' }}
trend="up"
icon={<TrendingUp className="h-6 w-6" />}
/>
<StatsCard
title="Trades Activos"
value="12"
change={{ value: -2, label: 'vs ayer' }}
trend="down"
icon={<Activity className="h-6 w-6" />}
/>
<StatsCard
title="Win Rate"
value="68.5%"
change={{ value: 3.2, label: 'vs semana anterior' }}
trend="up"
icon={<Target className="h-6 w-6" />}
/>
</div>
{/* Main Content Grid */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Portfolio Chart - Takes 2 columns */}
<Card className="xl:col-span-2">
<CardHeader
title="Rendimiento del Portfolio"
subtitle="Ultimos 30 dias"
action={
<div className="flex items-center gap-2">
<button className="px-3 py-1 text-xs font-medium rounded-lg bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
1M
</button>
<button className="px-3 py-1 text-xs font-medium rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700">
3M
</button>
<button className="px-3 py-1 text-xs font-medium rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700">
6M
</button>
<button className="px-3 py-1 text-xs font-medium rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700">
1A
</button>
</div>
}
/>
<CardContent>
{/* Chart Placeholder */}
<div className="h-64 lg:h-80 flex items-center justify-center bg-slate-50 dark:bg-slate-800/50 rounded-lg border-2 border-dashed border-slate-200 dark:border-slate-700">
<div className="text-center">
<BarChart3 className="h-12 w-12 mx-auto text-slate-300 dark:text-slate-600" />
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
Grafico de rendimiento
</p>
<p className="text-xs text-slate-400 dark:text-slate-500">
Conecta con Recharts para visualizacion
</p>
</div>
</div>
{/* Chart Stats */}
<div className="mt-4 grid grid-cols-3 gap-4">
<div className="text-center p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50">
<p className="text-sm text-slate-500 dark:text-slate-400">Maximo</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
{formatCurrency(132450.00)}
</p>
</div>
<div className="text-center p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50">
<p className="text-sm text-slate-500 dark:text-slate-400">Minimo</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
{formatCurrency(98320.00)}
</p>
</div>
<div className="text-center p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50">
<p className="text-sm text-slate-500 dark:text-slate-400">Promedio</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
{formatCurrency(115385.00)}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Active Strategies */}
<Card>
<CardHeader
title="Estrategias Activas"
subtitle="3 de 5 ejecutando"
action={
<Button variant="ghost" size="xs">
Ver todas
</Button>
}
/>
<CardContent>
<div className="space-y-4">
{strategies.map((strategy) => (
<StrategyItem key={strategy.id} strategy={strategy} />
))}
</div>
</CardContent>
</Card>
</div>
{/* Second Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Trades */}
<Card>
<CardHeader
title="Trades Recientes"
subtitle="Ultimas 24 horas"
action={
<Button variant="ghost" size="xs">
Ver historial
</Button>
}
/>
<CardContent>
<div className="space-y-3">
{recentTrades.map((trade) => (
<TradeItem key={trade.id} trade={trade} />
))}
</div>
</CardContent>
</Card>
{/* Market Overview */}
<Card>
<CardHeader
title="Resumen del Mercado"
subtitle="Precios en tiempo real"
action={
<span className="flex items-center gap-1.5 text-xs text-success-600 dark:text-success-400">
<span className="w-2 h-2 rounded-full bg-success-500 animate-pulse" />
En vivo
</span>
}
/>
<CardContent>
<div className="space-y-3">
{marketData.map((market) => (
<MarketItem key={market.symbol} market={market} />
))}
</div>
</CardContent>
</Card>
</div>
{/* Alerts Section */}
<Card>
<CardHeader
title="Alertas y Notificaciones"
action={
<Button variant="ghost" size="xs">
Configurar alertas
</Button>
}
/>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{alerts.map((alert) => (
<AlertItem key={alert.id} alert={alert} />
))}
</div>
</CardContent>
</Card>
</div>
);
}
// ============================================
// Mock Data
// ============================================
interface Strategy {
id: string;
name: string;
type: string;
status: 'running' | 'paused' | 'stopped';
profit: number;
trades: number;
}
const strategies: Strategy[] = [
{ id: '1', name: 'Grid BTC/USDT', type: 'Grid Trading', status: 'running', profit: 12.5, trades: 45 },
{ id: '2', name: 'DCA ETH', type: 'DCA', status: 'running', profit: 8.2, trades: 12 },
{ id: '3', name: 'Scalping SOL', type: 'Scalping', status: 'paused', profit: -2.1, trades: 128 },
];
interface Trade {
id: string;
pair: string;
type: 'buy' | 'sell';
amount: number;
price: number;
profit?: number;
time: string;
}
const recentTrades: Trade[] = [
{ id: '1', pair: 'BTC/USDT', type: 'buy', amount: 0.05, price: 43250, time: '10:32' },
{ id: '2', pair: 'ETH/USDT', type: 'sell', amount: 1.2, price: 2280, profit: 45.20, time: '10:15' },
{ id: '3', pair: 'SOL/USDT', type: 'buy', amount: 10, price: 98.5, time: '09:58' },
{ id: '4', pair: 'BTC/USDT', type: 'sell', amount: 0.08, price: 43180, profit: 120.50, time: '09:45' },
];
interface Market {
symbol: string;
name: string;
price: number;
change: number;
}
const marketData: Market[] = [
{ symbol: 'BTC', name: 'Bitcoin', price: 43250.00, change: 2.34 },
{ symbol: 'ETH', name: 'Ethereum', price: 2280.50, change: 1.82 },
{ symbol: 'SOL', name: 'Solana', price: 98.45, change: -0.54 },
{ symbol: 'BNB', name: 'BNB', price: 312.80, change: 0.92 },
];
interface Alert {
id: string;
type: 'warning' | 'info' | 'success';
title: string;
message: string;
time: string;
}
const alerts: Alert[] = [
{ id: '1', type: 'warning', title: 'Stop Loss cercano', message: 'BTC/USDT esta a 2% del stop loss', time: '5 min' },
{ id: '2', type: 'success', title: 'Take Profit alcanzado', message: 'ETH/USDT cerro con +3.5%', time: '15 min' },
{ id: '3', type: 'info', title: 'Nueva señal', message: 'SOL/USDT señal de compra detectada', time: '30 min' },
];
// ============================================
// Sub-components
// ============================================
function StrategyItem({ strategy }: { strategy: Strategy }) {
const statusStyles = {
running: 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400',
paused: 'bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-400',
stopped: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-400',
};
return (
<div className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
<Bot className="h-5 w-5 text-primary-600 dark:text-primary-400" />
</div>
<div>
<p className="font-medium text-slate-900 dark:text-white">{strategy.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{strategy.type}</p>
</div>
</div>
<div className="text-right">
<p className={cn(
'font-semibold',
strategy.profit >= 0 ? 'text-success-600 dark:text-success-400' : 'text-error-600 dark:text-error-400'
)}>
{formatPercentage(strategy.profit)}
</p>
<span className={cn('text-xs px-2 py-0.5 rounded-full', statusStyles[strategy.status])}>
{strategy.status === 'running' ? 'Activo' : strategy.status === 'paused' ? 'Pausado' : 'Detenido'}
</span>
</div>
</div>
);
}
function TradeItem({ trade }: { trade: Trade }) {
return (
<div className="flex items-center justify-between p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<div className="flex items-center gap-3">
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center',
trade.type === 'buy'
? 'bg-success-100 dark:bg-success-900/30'
: 'bg-error-100 dark:bg-error-900/30'
)}>
{trade.type === 'buy' ? (
<ArrowDownRight className="h-4 w-4 text-success-600 dark:text-success-400" />
) : (
<ArrowUpRight className="h-4 w-4 text-error-600 dark:text-error-400" />
)}
</div>
<div>
<p className="font-medium text-slate-900 dark:text-white">{trade.pair}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{trade.type === 'buy' ? 'Compra' : 'Venta'} - {trade.amount} @ {formatCurrency(trade.price)}
</p>
</div>
</div>
<div className="text-right">
{trade.profit !== undefined ? (
<p className={cn(
'font-semibold',
trade.profit >= 0 ? 'text-success-600 dark:text-success-400' : 'text-error-600 dark:text-error-400'
)}>
{trade.profit >= 0 ? '+' : ''}{formatCurrency(trade.profit)}
</p>
) : (
<p className="text-sm text-slate-500 dark:text-slate-400">Abierto</p>
)}
<p className="text-xs text-slate-400 dark:text-slate-500">{trade.time}</p>
</div>
</div>
);
}
function MarketItem({ market }: { market: Market }) {
const isPositive = market.change >= 0;
return (
<div className="flex items-center justify-between p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center font-bold text-slate-700 dark:text-slate-300">
{market.symbol.slice(0, 1)}
</div>
<div>
<p className="font-medium text-slate-900 dark:text-white">{market.symbol}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{market.name}</p>
</div>
</div>
<div className="text-right">
<p className="font-semibold text-slate-900 dark:text-white">
{formatCurrency(market.price)}
</p>
<p className={cn(
'text-sm flex items-center gap-1 justify-end',
isPositive ? 'text-success-600 dark:text-success-400' : 'text-error-600 dark:text-error-400'
)}>
{isPositive ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{formatPercentage(market.change)}
</p>
</div>
</div>
);
}
function AlertItem({ alert }: { alert: Alert }) {
const typeStyles = {
warning: {
bg: 'bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800',
icon: 'text-warning-600 dark:text-warning-400',
},
success: {
bg: 'bg-success-50 dark:bg-success-900/20 border-success-200 dark:border-success-800',
icon: 'text-success-600 dark:text-success-400',
},
info: {
bg: 'bg-primary-50 dark:bg-primary-900/20 border-primary-200 dark:border-primary-800',
icon: 'text-primary-600 dark:text-primary-400',
},
};
const icons = {
warning: <AlertTriangle className="h-5 w-5" />,
success: <Target className="h-5 w-5" />,
info: <Activity className="h-5 w-5" />,
};
return (
<div className={cn('p-4 rounded-lg border', typeStyles[alert.type].bg)}>
<div className="flex items-start gap-3">
<span className={typeStyles[alert.type].icon}>{icons[alert.type]}</span>
<div className="flex-1 min-w-0">
<p className="font-medium text-slate-900 dark:text-white">{alert.title}</p>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">{alert.message}</p>
<p className="mt-2 text-xs text-slate-400 dark:text-slate-500 flex items-center gap-1">
<Clock className="h-3 w-3" />
Hace {alert.time}
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
'use client';
import React, { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth.store';
import { useUIStore } from '@/stores/ui.store';
import { Sidebar } from '@/components/layout/Sidebar';
import { Header } from '@/components/layout/Header';
import { cn } from '@/lib/utils';
/**
* Dashboard Layout
*
* Layout principal para las paginas del dashboard.
* Incluye sidebar, header y manejo de responsive.
*/
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const { isAuthenticated, isInitialized, checkAuth, isLoading } = useAuthStore();
const { sidebarCollapsed, isMobile, setIsMobile } = useUIStore();
// Check auth on mount
useEffect(() => {
checkAuth();
}, [checkAuth]);
// Redirect to login if not authenticated
useEffect(() => {
if (isInitialized && !isAuthenticated) {
router.replace('/login');
}
}, [isAuthenticated, isInitialized, router]);
// Handle responsive
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 1024);
};
// Check on mount
checkMobile();
// Listen for resize
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, [setIsMobile]);
// Show loading while checking auth
if (!isInitialized || isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-950">
<div className="text-center">
<div className="relative w-16 h-16 mx-auto mb-4">
<div className="absolute inset-0 rounded-xl bg-horux-gradient animate-pulse" />
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-2xl font-bold text-white">H</span>
</div>
</div>
<p className="text-slate-400 text-sm animate-pulse">Cargando...</p>
</div>
</div>
);
}
// Don't render if not authenticated
if (!isAuthenticated) {
return null;
}
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
{/* Sidebar */}
<Sidebar />
{/* Main Content Area */}
<div
className={cn(
'transition-all duration-300',
// Margin left based on sidebar state
isMobile ? 'ml-0' : (sidebarCollapsed ? 'ml-20' : 'ml-64')
)}
>
{/* Header */}
<Header />
{/* Page Content */}
<main className="pt-16 min-h-screen">
<div className="p-4 lg:p-6 xl:p-8">
{children}
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,769 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import {
LineChart,
Line,
AreaChart,
Area,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
ReferenceLine,
} from 'recharts';
import {
ArrowLeft,
TrendingUp,
TrendingDown,
ArrowUpRight,
ArrowDownRight,
Calendar,
Download,
RefreshCw,
Target,
Info,
ChevronDown,
} from 'lucide-react';
import { cn, formatCurrency, formatNumber, formatPercentage, formatDate } from '@/lib/utils';
import { api } from '@/lib/api';
// ============================================================================
// Types
// ============================================================================
interface MetricValue {
date: string;
value: number;
previousValue?: number;
change?: number;
changePercent?: number;
}
interface MetricDetail {
code: string;
name: string;
description: string;
formula?: string;
value: number;
previousValue: number;
change: number;
changePercent: number;
format: 'currency' | 'number' | 'percentage';
category: 'core' | 'startup' | 'enterprise';
trend: 'up' | 'down' | 'neutral';
target?: number;
benchmark?: number;
history: MetricValue[];
periodComparison: {
period: string;
current: number;
previous: number;
change: number;
changePercent: number;
}[];
}
type ChartType = 'line' | 'area' | 'bar';
type PeriodType = '7d' | '30d' | '90d' | '12m' | 'ytd' | 'all';
// ============================================================================
// Mock Data
// ============================================================================
const generateMockData = (code: string): MetricDetail => {
const isRevenue = ['MRR', 'ARR', 'REVENUE', 'LTV', 'ARPU', 'NET_INCOME'].includes(code);
const isCost = ['CAC', 'EXPENSES', 'BURN_RATE'].includes(code);
const isPercentage = ['GROSS_MARGIN', 'CHURN', 'NRR', 'DAU_MAU'].includes(code);
const isCount = ['ACTIVE_CUSTOMERS', 'NPS', 'SUPPORT_TICKETS', 'RESOLUTION_TIME', 'LTV_CAC'].includes(code);
const baseValue = isRevenue ? 125000 : isCost ? 2500 : isPercentage ? 72.5 : 342;
const variance = baseValue * 0.1;
const history: MetricValue[] = Array.from({ length: 365 }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() - (364 - i));
const trendFactor = i / 365;
const seasonalFactor = Math.sin(i / 30) * 0.05;
const randomFactor = (Math.random() - 0.5) * 0.1;
const value = baseValue * (0.7 + trendFactor * 0.6 + seasonalFactor + randomFactor);
return {
date: date.toISOString().split('T')[0],
value: Math.round(value * 100) / 100,
};
});
// Add comparison data
history.forEach((item, index) => {
if (index >= 30) {
item.previousValue = history[index - 30].value;
item.change = item.value - item.previousValue;
item.changePercent = (item.change / item.previousValue) * 100;
}
});
const metricNames: Record<string, { name: string; description: string; formula?: string }> = {
MRR: {
name: 'Monthly Recurring Revenue',
description: 'Ingresos recurrentes mensuales provenientes de suscripciones activas',
formula: 'Suma de todas las suscripciones activas mensuales',
},
ARR: {
name: 'Annual Recurring Revenue',
description: 'Ingresos recurrentes anuales (MRR x 12)',
formula: 'MRR x 12',
},
REVENUE: {
name: 'Ingresos Totales',
description: 'Total de ingresos del periodo incluyendo one-time y recurrentes',
},
EXPENSES: {
name: 'Gastos Totales',
description: 'Total de gastos operativos del periodo',
},
GROSS_MARGIN: {
name: 'Margen Bruto',
description: 'Porcentaje de ingresos despues de costos directos',
formula: '(Ingresos - Costos Directos) / Ingresos x 100',
},
NET_INCOME: {
name: 'Utilidad Neta',
description: 'Ingresos totales menos todos los gastos',
formula: 'Ingresos Totales - Gastos Totales',
},
CAC: {
name: 'Customer Acquisition Cost',
description: 'Costo promedio para adquirir un nuevo cliente',
formula: 'Gastos de Marketing y Ventas / Nuevos Clientes',
},
LTV: {
name: 'Customer Lifetime Value',
description: 'Valor total esperado de un cliente durante toda su relacion',
formula: 'ARPU / Churn Rate',
},
LTV_CAC: {
name: 'LTV/CAC Ratio',
description: 'Ratio que indica el retorno de inversion en adquisicion',
formula: 'LTV / CAC',
},
CHURN: {
name: 'Churn Rate',
description: 'Porcentaje mensual de clientes que cancelan',
formula: 'Clientes Cancelados / Clientes Inicio de Periodo x 100',
},
NRR: {
name: 'Net Revenue Retention',
description: 'Retencion de ingresos incluyendo expansiones',
formula: '(MRR Inicio + Expansion - Contraction - Churn) / MRR Inicio x 100',
},
BURN_RATE: {
name: 'Burn Rate',
description: 'Tasa mensual de consumo de capital',
formula: 'Gastos Mensuales - Ingresos Mensuales',
},
ACTIVE_CUSTOMERS: {
name: 'Clientes Activos',
description: 'Numero de clientes con suscripcion activa',
},
ARPU: {
name: 'Average Revenue Per User',
description: 'Ingreso promedio mensual por cliente',
formula: 'MRR / Clientes Activos',
},
NPS: {
name: 'Net Promoter Score',
description: 'Indice de satisfaccion y lealtad del cliente',
formula: '% Promotores - % Detractores',
},
DAU_MAU: {
name: 'DAU/MAU Ratio',
description: 'Engagement de usuarios activos',
formula: 'Usuarios Activos Diarios / Usuarios Activos Mensuales',
},
SUPPORT_TICKETS: {
name: 'Tickets de Soporte',
description: 'Cantidad de tickets abiertos en el periodo',
},
RESOLUTION_TIME: {
name: 'Tiempo de Resolucion',
description: 'Tiempo promedio para resolver un ticket (horas)',
},
};
const info = metricNames[code] || { name: code, description: '' };
const currentValue = history[history.length - 1].value;
const previousValue = history[history.length - 31]?.value || currentValue * 0.95;
return {
code,
name: info.name,
description: info.description,
formula: info.formula,
value: currentValue,
previousValue,
change: currentValue - previousValue,
changePercent: ((currentValue - previousValue) / previousValue) * 100,
format: isPercentage ? 'percentage' : isCount ? 'number' : 'currency',
category: ['MRR', 'ARR', 'REVENUE', 'EXPENSES', 'GROSS_MARGIN', 'NET_INCOME'].includes(code)
? 'core'
: ['CAC', 'LTV', 'LTV_CAC', 'CHURN', 'NRR', 'BURN_RATE'].includes(code)
? 'startup'
: 'enterprise',
trend: currentValue > previousValue ? 'up' : 'down',
target: currentValue * 1.2,
benchmark: currentValue * 0.9,
history,
periodComparison: [
{
period: 'Esta semana',
current: currentValue,
previous: currentValue * 0.97,
change: currentValue * 0.03,
changePercent: 3.09,
},
{
period: 'Este mes',
current: currentValue,
previous: previousValue,
change: currentValue - previousValue,
changePercent: ((currentValue - previousValue) / previousValue) * 100,
},
{
period: 'Este trimestre',
current: currentValue,
previous: currentValue * 0.88,
change: currentValue * 0.12,
changePercent: 13.64,
},
{
period: 'Este ano',
current: currentValue,
previous: currentValue * 0.65,
change: currentValue * 0.35,
changePercent: 53.85,
},
],
};
};
// ============================================================================
// Loading Skeleton
// ============================================================================
function MetricDetailSkeleton() {
return (
<div className="animate-pulse space-y-6 p-6">
{/* Header */}
<div className="flex items-center gap-4">
<div className="h-10 w-10 bg-gray-200 dark:bg-gray-700 rounded-lg" />
<div className="flex-1">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-2" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-72" />
</div>
</div>
{/* Value Card */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-36 mb-4" />
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-24" />
</div>
{/* Chart */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-4" />
<div className="h-80 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export default function MetricDetailPage() {
const params = useParams();
const router = useRouter();
const code = params.code as string;
const [metric, setMetric] = useState<MetricDetail | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [period, setPeriod] = useState<PeriodType>('30d');
const [chartType, setChartType] = useState<ChartType>('area');
const [showComparison, setShowComparison] = useState(true);
const fetchMetricDetail = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
// In production, this would be an API call
// const response = await api.get<MetricDetail>(`/metrics/${code}`, { params: { period } });
// setMetric(response.data ?? null);
// Mock data for development
await new Promise((resolve) => setTimeout(resolve, 600));
setMetric(generateMockData(code.toUpperCase()));
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar metrica');
} finally {
setIsLoading(false);
}
}, [code, period]);
useEffect(() => {
fetchMetricDetail();
}, [fetchMetricDetail]);
const formatValue = (value: number) => {
if (!metric) return value.toString();
switch (metric.format) {
case 'currency':
return formatCurrency(value);
case 'percentage':
return `${value.toFixed(2)}%`;
case 'number':
default:
return formatNumber(value, value % 1 === 0 ? 0 : 2);
}
};
const getFilteredHistory = () => {
if (!metric) return [];
const now = new Date();
const history = metric.history;
switch (period) {
case '7d':
return history.slice(-7);
case '30d':
return history.slice(-30);
case '90d':
return history.slice(-90);
case '12m':
return history.slice(-365);
case 'ytd':
const startOfYear = new Date(now.getFullYear(), 0, 1);
return history.filter((h) => new Date(h.date) >= startOfYear);
case 'all':
default:
return history;
}
};
const handleExport = () => {
if (!metric) return;
const data = getFilteredHistory();
const csv = [
['Fecha', 'Valor', 'Valor Anterior', 'Cambio', 'Cambio %'].join(','),
...data.map((row) =>
[row.date, row.value, row.previousValue || '', row.change || '', row.changePercent || ''].join(',')
),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${metric.code}_${period}.csv`;
a.click();
};
if (isLoading) {
return <MetricDetailSkeleton />;
}
if (error || !metric) {
return (
<div className="p-6">
<div className="bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800 rounded-lg p-6 text-center">
<p className="text-error-700 dark:text-error-400 mb-4">{error || 'Metrica no encontrada'}</p>
<button
onClick={() => router.back()}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
Volver
</button>
</div>
</div>
);
}
const isInverseTrend = ['CAC', 'CHURN', 'BURN_RATE', 'EXPENSES', 'SUPPORT_TICKETS', 'RESOLUTION_TIME'].includes(
metric.code
);
const isGoodTrend = isInverseTrend ? metric.change < 0 : metric.change > 0;
const filteredHistory = getFilteredHistory();
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
<div className="flex items-start gap-4">
<button
onClick={() => router.back()}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{metric.name}</h1>
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded">
{metric.code}
</span>
</div>
<p className="text-gray-500 dark:text-gray-400 mt-1">{metric.description}</p>
{metric.formula && (
<p className="text-sm text-gray-400 dark:text-gray-500 mt-1">
<span className="font-medium">Formula:</span> {metric.formula}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleExport}
className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Download className="w-4 h-4" />
Exportar
</button>
<button
onClick={fetchMetricDetail}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<RefreshCw className="w-5 h-5" />
</button>
</div>
</div>
{/* Value Card */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Valor Actual</p>
<p className="text-4xl font-bold text-gray-900 dark:text-white">{formatValue(metric.value)}</p>
<div className="flex items-center gap-3 mt-3">
<div
className={cn(
'flex items-center gap-1 px-2 py-1 rounded-full text-sm font-medium',
isGoodTrend
? 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400'
: 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400'
)}
>
{isGoodTrend ? <ArrowUpRight className="w-4 h-4" /> : <ArrowDownRight className="w-4 h-4" />}
{formatPercentage(Math.abs(metric.changePercent))}
</div>
<span className="text-sm text-gray-500 dark:text-gray-400">vs periodo anterior</span>
</div>
</div>
{isGoodTrend ? (
<TrendingUp className="w-12 h-12 text-success-500" />
) : (
<TrendingDown className="w-12 h-12 text-error-500" />
)}
</div>
{/* Target Progress */}
{metric.target && (
<div className="mt-6 pt-6 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Target className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-600 dark:text-gray-400">Meta</span>
</div>
<span className="text-sm font-medium text-gray-900 dark:text-white">{formatValue(metric.target)}</span>
</div>
<div className="h-3 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
metric.value >= metric.target
? 'bg-success-500'
: metric.value >= metric.target * 0.75
? 'bg-primary-500'
: metric.value >= metric.target * 0.5
? 'bg-warning-500'
: 'bg-error-500'
)}
style={{ width: `${Math.min(100, (metric.value / metric.target) * 100)}%` }}
/>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{((metric.value / metric.target) * 100).toFixed(1)}% completado
</p>
</div>
)}
</div>
{/* Period Comparison */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-4">Comparativos</h3>
<div className="space-y-4">
{metric.periodComparison.map((comp) => (
<div key={comp.period} className="flex items-center justify-between">
<span className="text-sm text-gray-600 dark:text-gray-400">{comp.period}</span>
<div className="text-right">
<p className="text-sm font-medium text-gray-900 dark:text-white">{formatValue(comp.current)}</p>
<p
className={cn(
'text-xs',
comp.changePercent >= 0 ? 'text-success-600 dark:text-success-400' : 'text-error-600 dark:text-error-400'
)}
>
{formatPercentage(comp.changePercent)}
</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* Chart Controls */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-2">
{(['7d', '30d', '90d', '12m', 'ytd', 'all'] as PeriodType[]).map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={cn(
'px-3 py-1.5 text-sm font-medium rounded-md transition-all',
period === p
? 'bg-primary-500 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
)}
>
{p === 'ytd' ? 'YTD' : p === 'all' ? 'Todo' : p}
</button>
))}
</div>
<div className="flex items-center gap-2">
{(['area', 'line', 'bar'] as ChartType[]).map((type) => (
<button
key={type}
onClick={() => setChartType(type)}
className={cn(
'px-3 py-1.5 text-sm font-medium rounded-md capitalize transition-all',
chartType === type
? 'bg-gray-900 dark:bg-white text-white dark:text-gray-900'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
)}
>
{type}
</button>
))}
</div>
</div>
{/* Main Chart */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Historial</h3>
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={showComparison}
onChange={(e) => setShowComparison(e.target.checked)}
className="rounded border-gray-300 text-primary-500 focus:ring-primary-500"
/>
Mostrar periodo anterior
</label>
</div>
<div className="h-96">
<ResponsiveContainer width="100%" height="100%">
{chartType === 'bar' ? (
<BarChart data={filteredHistory}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.2} />
<XAxis
dataKey="date"
stroke="#6b7280"
tick={{ fill: '#6b7280', fontSize: 12 }}
tickFormatter={(value) => formatDate(value, { day: 'numeric', month: 'short' })}
/>
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} tickFormatter={(v) => formatValue(v)} />
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
color: '#fff',
}}
formatter={(value: number) => [formatValue(value), 'Valor']}
labelFormatter={(label) => formatDate(label)}
/>
{showComparison && <Bar dataKey="previousValue" name="Periodo Anterior" fill="#6b7280" opacity={0.5} />}
<Bar dataKey="value" name="Valor" fill="#0c8ce8" />
{metric.target && <ReferenceLine y={metric.target} stroke="#10b981" strokeDasharray="5 5" label="Meta" />}
</BarChart>
) : chartType === 'line' ? (
<LineChart data={filteredHistory}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.2} />
<XAxis
dataKey="date"
stroke="#6b7280"
tick={{ fill: '#6b7280', fontSize: 12 }}
tickFormatter={(value) => formatDate(value, { day: 'numeric', month: 'short' })}
/>
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} tickFormatter={(v) => formatValue(v)} />
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
color: '#fff',
}}
formatter={(value: number) => [formatValue(value), 'Valor']}
labelFormatter={(label) => formatDate(label)}
/>
<Legend />
{showComparison && (
<Line
type="monotone"
dataKey="previousValue"
name="Periodo Anterior"
stroke="#6b7280"
strokeWidth={1}
strokeDasharray="5 5"
dot={false}
/>
)}
<Line type="monotone" dataKey="value" name="Valor" stroke="#0c8ce8" strokeWidth={2} dot={false} activeDot={{ r: 4 }} />
{metric.target && <ReferenceLine y={metric.target} stroke="#10b981" strokeDasharray="5 5" label="Meta" />}
</LineChart>
) : (
<AreaChart data={filteredHistory}>
<defs>
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#0c8ce8" stopOpacity={0.3} />
<stop offset="95%" stopColor="#0c8ce8" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorPrevious" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6b7280" stopOpacity={0.2} />
<stop offset="95%" stopColor="#6b7280" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.2} />
<XAxis
dataKey="date"
stroke="#6b7280"
tick={{ fill: '#6b7280', fontSize: 12 }}
tickFormatter={(value) => formatDate(value, { day: 'numeric', month: 'short' })}
/>
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} tickFormatter={(v) => formatValue(v)} />
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
color: '#fff',
}}
formatter={(value: number) => [formatValue(value), 'Valor']}
labelFormatter={(label) => formatDate(label)}
/>
<Legend />
{showComparison && (
<Area
type="monotone"
dataKey="previousValue"
name="Periodo Anterior"
stroke="#6b7280"
fill="url(#colorPrevious)"
strokeWidth={1}
strokeDasharray="5 5"
/>
)}
<Area type="monotone" dataKey="value" name="Valor" stroke="#0c8ce8" fill="url(#colorValue)" strokeWidth={2} />
{metric.target && <ReferenceLine y={metric.target} stroke="#10b981" strokeDasharray="5 5" label="Meta" />}
</AreaChart>
)}
</ResponsiveContainer>
</div>
</div>
{/* Data Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Valores por Periodo</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-900/50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Fecha
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Valor
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Anterior
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Cambio
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Cambio %
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{filteredHistory
.slice(-30)
.reverse()
.map((row) => (
<tr key={row.date} className="hover:bg-gray-50 dark:hover:bg-gray-900/50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
{formatDate(row.date)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-right font-medium text-gray-900 dark:text-white">
{formatValue(row.value)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">
{row.previousValue ? formatValue(row.previousValue) : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-right">
{row.change !== undefined ? (
<span className={row.change >= 0 ? 'text-success-600' : 'text-error-600'}>
{row.change >= 0 ? '+' : ''}
{formatValue(row.change)}
</span>
) : (
'-'
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-right">
{row.changePercent !== undefined ? (
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
row.changePercent >= 0
? 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400'
: 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400'
)}
>
{formatPercentage(row.changePercent)}
</span>
) : (
'-'
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,824 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import {
LineChart,
Line,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
import {
TrendingUp,
TrendingDown,
Users,
DollarSign,
Activity,
Calendar,
RefreshCw,
ArrowUpRight,
ArrowDownRight,
Target,
Zap,
Building2,
ChevronRight,
} from 'lucide-react';
import { cn, formatCurrency, formatNumber, formatPercentage } from '@/lib/utils';
import { api } from '@/lib/api';
// ============================================================================
// Types
// ============================================================================
interface Metric {
code: string;
name: string;
description: string;
value: number;
previousValue: number;
change: number;
changePercent: number;
format: 'currency' | 'number' | 'percentage';
category: 'core' | 'startup' | 'enterprise';
trend: 'up' | 'down' | 'neutral';
target?: number;
history: { date: string; value: number }[];
}
interface MetricsResponse {
metrics: Metric[];
period: string;
updatedAt: string;
}
type MetricCategory = 'core' | 'startup' | 'enterprise';
type PeriodType = '7d' | '30d' | '90d' | '12m' | 'ytd';
// ============================================================================
// Mock Data for Development
// ============================================================================
const mockMetrics: Metric[] = [
// Core Metrics
{
code: 'MRR',
name: 'Monthly Recurring Revenue',
description: 'Ingresos recurrentes mensuales',
value: 125000,
previousValue: 118000,
change: 7000,
changePercent: 5.93,
format: 'currency',
category: 'core',
trend: 'up',
target: 150000,
history: Array.from({ length: 12 }, (_, i) => ({
date: `2024-${String(i + 1).padStart(2, '0')}`,
value: 80000 + i * 5000 + Math.random() * 3000,
})),
},
{
code: 'ARR',
name: 'Annual Recurring Revenue',
description: 'Ingresos recurrentes anuales',
value: 1500000,
previousValue: 1416000,
change: 84000,
changePercent: 5.93,
format: 'currency',
category: 'core',
trend: 'up',
target: 1800000,
history: Array.from({ length: 12 }, (_, i) => ({
date: `2024-${String(i + 1).padStart(2, '0')}`,
value: 960000 + i * 60000 + Math.random() * 36000,
})),
},
{
code: 'REVENUE',
name: 'Ingresos Totales',
description: 'Ingresos totales del periodo',
value: 245000,
previousValue: 230000,
change: 15000,
changePercent: 6.52,
format: 'currency',
category: 'core',
trend: 'up',
history: Array.from({ length: 12 }, (_, i) => ({
date: `2024-${String(i + 1).padStart(2, '0')}`,
value: 180000 + i * 8000 + Math.random() * 10000,
})),
},
{
code: 'EXPENSES',
name: 'Gastos Totales',
description: 'Gastos totales del periodo',
value: 180000,
previousValue: 175000,
change: 5000,
changePercent: 2.86,
format: 'currency',
category: 'core',
trend: 'down',
history: Array.from({ length: 12 }, (_, i) => ({
date: `2024-${String(i + 1).padStart(2, '0')}`,
value: 150000 + i * 3000 + Math.random() * 5000,
})),
},
{
code: 'GROSS_MARGIN',
name: 'Margen Bruto',
description: 'Porcentaje de margen bruto',
value: 72.5,
previousValue: 70.2,
change: 2.3,
changePercent: 3.28,
format: 'percentage',
category: 'core',
trend: 'up',
target: 75,
history: Array.from({ length: 12 }, (_, i) => ({
date: `2024-${String(i + 1).padStart(2, '0')}`,
value: 65 + i * 0.8 + Math.random() * 2,
})),
},
{
code: 'NET_INCOME',
name: 'Utilidad Neta',
description: 'Ingresos menos gastos',
value: 65000,
previousValue: 55000,
change: 10000,
changePercent: 18.18,
format: 'currency',
category: 'core',
trend: 'up',
history: Array.from({ length: 12 }, (_, i) => ({
date: `2024-${String(i + 1).padStart(2, '0')}`,
value: 30000 + i * 4000 + Math.random() * 5000,
})),
},
// Startup Metrics
{
code: 'CAC',
name: 'Customer Acquisition Cost',
description: 'Costo de adquisicion por cliente',
value: 2500,
previousValue: 2800,
change: -300,
changePercent: -10.71,
format: 'currency',
category: 'startup',
trend: 'up',
target: 2000,
history: Array.from({ length: 12 }, (_, i) => ({
date: `2024-${String(i + 1).padStart(2, '0')}`,
value: 3500 - i * 100 + Math.random() * 200,
})),
},
{
code: 'LTV',
name: 'Customer Lifetime Value',
description: 'Valor de vida del cliente',
value: 15000,
previousValue: 13500,
change: 1500,
changePercent: 11.11,
format: 'currency',
category: 'startup',
trend: 'up',
history: Array.from({ length: 12 }, (_, i) => ({
date: `2024-${String(i + 1).padStart(2, '0')}`,
value: 10000 + i * 500 + Math.random() * 500,
})),
},
{
code: 'LTV_CAC',
name: 'LTV/CAC Ratio',
description: 'Ratio de LTV sobre CAC',
value: 6.0,
previousValue: 4.8,
change: 1.2,
changePercent: 25,
format: 'number',
category: 'startup',
trend: 'up',
target: 5,
history: Array.from({ length: 12 }, (_, i) => ({
date: `2024-${String(i + 1).padStart(2, '0')}`,
value: 3 + i * 0.3 + Math.random() * 0.5,
})),
},
{
code: 'CHURN',
name: 'Churn Rate',
description: 'Tasa de cancelacion mensual',
value: 2.5,
previousValue: 3.2,
change: -0.7,
changePercent: -21.88,
format: 'percentage',
category: 'startup',
trend: 'up',
target: 2,
history: Array.from({ length: 12 }, (_, i) => ({
date: `2024-${String(i + 1).padStart(2, '0')}`,
value: 5 - i * 0.2 + Math.random() * 0.5,
})),
},
{
code: 'NRR',
name: 'Net Revenue Retention',
description: 'Retencion neta de ingresos',
value: 115,
previousValue: 110,
change: 5,
changePercent: 4.55,
format: 'percentage',
category: 'startup',
trend: 'up',
target: 120,
history: Array.from({ length: 12 }, (_, i) => ({
date: `2024-${String(i + 1).padStart(2, '0')}`,
value: 100 + i * 1.5 + Math.random() * 3,
})),
},
{
code: 'BURN_RATE',
name: 'Burn Rate',
description: 'Tasa de quema mensual',
value: 45000,
previousValue: 52000,
change: -7000,
changePercent: -13.46,
format: 'currency',
category: 'startup',
trend: 'up',
history: Array.from({ length: 12 }, (_, i) => ({
date: `2024-${String(i + 1).padStart(2, '0')}`,
value: 70000 - i * 3000 + Math.random() * 5000,
})),
},
// Enterprise Metrics
{
code: 'ACTIVE_CUSTOMERS',
name: 'Clientes Activos',
description: 'Numero de clientes activos',
value: 342,
previousValue: 315,
change: 27,
changePercent: 8.57,
format: 'number',
category: 'enterprise',
trend: 'up',
target: 400,
history: Array.from({ length: 12 }, (_, i) => ({
date: `2024-${String(i + 1).padStart(2, '0')}`,
value: 250 + i * 10 + Math.floor(Math.random() * 10),
})),
},
{
code: 'ARPU',
name: 'Average Revenue Per User',
description: 'Ingreso promedio por usuario',
value: 365,
previousValue: 350,
change: 15,
changePercent: 4.29,
format: 'currency',
category: 'enterprise',
trend: 'up',
history: Array.from({ length: 12 }, (_, i) => ({
date: `2024-${String(i + 1).padStart(2, '0')}`,
value: 300 + i * 8 + Math.random() * 15,
})),
},
{
code: 'NPS',
name: 'Net Promoter Score',
description: 'Indice de promotores neto',
value: 72,
previousValue: 68,
change: 4,
changePercent: 5.88,
format: 'number',
category: 'enterprise',
trend: 'up',
target: 80,
history: Array.from({ length: 12 }, (_, i) => ({
date: `2024-${String(i + 1).padStart(2, '0')}`,
value: 55 + i * 2 + Math.floor(Math.random() * 5),
})),
},
{
code: 'DAU_MAU',
name: 'DAU/MAU Ratio',
description: 'Ratio de usuarios activos diarios vs mensuales',
value: 0.45,
previousValue: 0.42,
change: 0.03,
changePercent: 7.14,
format: 'percentage',
category: 'enterprise',
trend: 'up',
target: 0.5,
history: Array.from({ length: 12 }, (_, i) => ({
date: `2024-${String(i + 1).padStart(2, '0')}`,
value: 0.35 + i * 0.01 + Math.random() * 0.02,
})),
},
{
code: 'SUPPORT_TICKETS',
name: 'Tickets de Soporte',
description: 'Tickets abiertos este periodo',
value: 89,
previousValue: 120,
change: -31,
changePercent: -25.83,
format: 'number',
category: 'enterprise',
trend: 'up',
history: Array.from({ length: 12 }, (_, i) => ({
date: `2024-${String(i + 1).padStart(2, '0')}`,
value: 150 - i * 5 + Math.floor(Math.random() * 20),
})),
},
{
code: 'RESOLUTION_TIME',
name: 'Tiempo de Resolucion',
description: 'Tiempo promedio de resolucion en horas',
value: 4.2,
previousValue: 5.8,
change: -1.6,
changePercent: -27.59,
format: 'number',
category: 'enterprise',
trend: 'up',
target: 4,
history: Array.from({ length: 12 }, (_, i) => ({
date: `2024-${String(i + 1).padStart(2, '0')}`,
value: 8 - i * 0.4 + Math.random() * 1,
})),
},
];
// ============================================================================
// Loading Skeleton
// ============================================================================
function MetricsSkeleton() {
return (
<div className="animate-pulse space-y-6">
{/* Header skeleton */}
<div className="flex items-center justify-between">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48" />
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-36" />
</div>
{/* Tabs skeleton */}
<div className="flex gap-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-28" />
))}
</div>
{/* Cards skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6"
>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2" />
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4" />
<div className="h-20 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
</div>
</div>
);
}
// ============================================================================
// Metric Card Component
// ============================================================================
interface MetricCardProps {
metric: Metric;
onClick?: () => void;
}
function MetricCard({ metric, onClick }: MetricCardProps) {
const formatValue = (value: number, format: Metric['format']) => {
switch (format) {
case 'currency':
return formatCurrency(value);
case 'percentage':
return `${value.toFixed(1)}%`;
case 'number':
default:
return formatNumber(value, value % 1 === 0 ? 0 : 2);
}
};
const isPositiveTrend = metric.trend === 'up';
const TrendIcon = isPositiveTrend ? TrendingUp : TrendingDown;
// For metrics where lower is better (CAC, Churn, etc.)
const isInverseTrend = ['CAC', 'CHURN', 'BURN_RATE', 'EXPENSES', 'SUPPORT_TICKETS', 'RESOLUTION_TIME'].includes(metric.code);
const isGoodTrend = isInverseTrend ? metric.change < 0 : metric.change > 0;
const progressPercent = metric.target
? Math.min(100, (metric.value / metric.target) * 100)
: null;
return (
<div
onClick={onClick}
className={cn(
'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700',
'p-6 hover:shadow-lg transition-all cursor-pointer hover:border-primary-300 dark:hover:border-primary-600',
'group'
)}
>
<div className="flex items-start justify-between mb-2">
<div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
{metric.name}
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{metric.code}
</p>
</div>
<ChevronRight className="w-5 h-5 text-gray-400 group-hover:text-primary-500 transition-colors" />
</div>
<div className="flex items-end justify-between mb-4">
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{formatValue(metric.value, metric.format)}
</p>
<div
className={cn(
'flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium',
isGoodTrend
? 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400'
: 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400'
)}
>
{isGoodTrend ? (
<ArrowUpRight className="w-3 h-3" />
) : (
<ArrowDownRight className="w-3 h-3" />
)}
{formatPercentage(Math.abs(metric.changePercent))}
</div>
</div>
{/* Mini Chart */}
<div className="h-16 mb-3">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={metric.history.slice(-7)}>
<defs>
<linearGradient id={`gradient-${metric.code}`} x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={isGoodTrend ? '#10b981' : '#ef4444'}
stopOpacity={0.3}
/>
<stop
offset="100%"
stopColor={isGoodTrend ? '#10b981' : '#ef4444'}
stopOpacity={0}
/>
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="value"
stroke={isGoodTrend ? '#10b981' : '#ef4444'}
strokeWidth={2}
fill={`url(#gradient-${metric.code})`}
/>
</AreaChart>
</ResponsiveContainer>
</div>
{/* Target Progress */}
{progressPercent !== null && (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-gray-500 dark:text-gray-400">Meta</span>
<span className="text-gray-600 dark:text-gray-300">
{formatValue(metric.target!, metric.format)}
</span>
</div>
<div className="h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
progressPercent >= 100
? 'bg-success-500'
: progressPercent >= 75
? 'bg-primary-500'
: progressPercent >= 50
? 'bg-warning-500'
: 'bg-error-500'
)}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
</div>
);
}
// ============================================================================
// Trend Chart Component
// ============================================================================
interface TrendChartProps {
metrics: Metric[];
selectedMetrics: string[];
}
function TrendChart({ metrics, selectedMetrics }: TrendChartProps) {
const selectedData = metrics.filter((m) => selectedMetrics.includes(m.code));
if (selectedData.length === 0) return null;
// Combine data for multiple metrics
const chartData = selectedData[0].history.map((point, index) => {
const dataPoint: Record<string, unknown> = { date: point.date };
selectedData.forEach((metric) => {
dataPoint[metric.code] = metric.history[index]?.value ?? 0;
});
return dataPoint;
});
const colors = ['#0c8ce8', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Tendencias
</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.2} />
<XAxis
dataKey="date"
stroke="#6b7280"
tick={{ fill: '#6b7280', fontSize: 12 }}
/>
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
color: '#fff',
}}
/>
<Legend />
{selectedData.map((metric, index) => (
<Line
key={metric.code}
type="monotone"
dataKey={metric.code}
name={metric.name}
stroke={colors[index % colors.length]}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}
// ============================================================================
// Period Selector
// ============================================================================
interface PeriodSelectorProps {
value: PeriodType;
onChange: (period: PeriodType) => void;
}
function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
const periods: { value: PeriodType; label: string }[] = [
{ value: '7d', label: '7 dias' },
{ value: '30d', label: '30 dias' },
{ value: '90d', label: '90 dias' },
{ value: '12m', label: '12 meses' },
{ value: 'ytd', label: 'YTD' },
];
return (
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 p-1 rounded-lg">
{periods.map((period) => (
<button
key={period.value}
onClick={() => onChange(period.value)}
className={cn(
'px-3 py-1.5 text-sm font-medium rounded-md transition-all',
value === period.value
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
)}
>
{period.label}
</button>
))}
</div>
);
}
// ============================================================================
// Category Tab
// ============================================================================
interface CategoryTabsProps {
value: MetricCategory;
onChange: (category: MetricCategory) => void;
}
function CategoryTabs({ value, onChange }: CategoryTabsProps) {
const categories: { value: MetricCategory; label: string; icon: React.ReactNode }[] = [
{ value: 'core', label: 'Core', icon: <Activity className="w-4 h-4" /> },
{ value: 'startup', label: 'Startup', icon: <Zap className="w-4 h-4" /> },
{ value: 'enterprise', label: 'Enterprise', icon: <Building2 className="w-4 h-4" /> },
];
return (
<div className="flex gap-2">
{categories.map((category) => (
<button
key={category.value}
onClick={() => onChange(category.value)}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all',
value === category.value
? 'bg-primary-500 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
)}
>
{category.icon}
{category.label}
</button>
))}
</div>
);
}
// ============================================================================
// Main Page Component
// ============================================================================
export default function MetricasPage() {
const [metrics, setMetrics] = useState<Metric[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [category, setCategory] = useState<MetricCategory>('core');
const [period, setPeriod] = useState<PeriodType>('30d');
const [selectedForTrend, setSelectedForTrend] = useState<string[]>(['MRR', 'ARR']);
const fetchMetrics = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
// In production, this would be an API call
// const response = await api.get<MetricsResponse>('/metrics', { params: { period } });
// setMetrics(response.data?.metrics ?? []);
// Mock data for development
await new Promise((resolve) => setTimeout(resolve, 800));
setMetrics(mockMetrics);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar metricas');
} finally {
setIsLoading(false);
}
}, [period]);
useEffect(() => {
fetchMetrics();
}, [fetchMetrics]);
const filteredMetrics = metrics.filter((m) => m.category === category);
const handleMetricClick = (metric: Metric) => {
// Navigate to detail page
window.location.href = `/metricas/${metric.code}`;
};
const toggleTrendSelection = (code: string) => {
setSelectedForTrend((prev) =>
prev.includes(code)
? prev.filter((c) => c !== code)
: prev.length < 4
? [...prev, code]
: prev
);
};
if (isLoading) {
return (
<div className="p-6">
<MetricsSkeleton />
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800 rounded-lg p-6 text-center">
<p className="text-error-700 dark:text-error-400 mb-4">{error}</p>
<button
onClick={fetchMetrics}
className="px-4 py-2 bg-error-600 text-white rounded-lg hover:bg-error-700 transition-colors"
>
Reintentar
</button>
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Metricas</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Analiza el rendimiento de tu negocio
</p>
</div>
<div className="flex items-center gap-3">
<PeriodSelector value={period} onChange={setPeriod} />
<button
onClick={fetchMetrics}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="Actualizar"
>
<RefreshCw className="w-5 h-5" />
</button>
</div>
</div>
{/* Category Tabs */}
<CategoryTabs value={category} onChange={setCategory} />
{/* Metrics Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredMetrics.map((metric) => (
<MetricCard
key={metric.code}
metric={metric}
onClick={() => handleMetricClick(metric)}
/>
))}
</div>
{/* Trend Selection */}
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Selecciona hasta 4 metricas para comparar en el grafico de tendencias:
</p>
<div className="flex flex-wrap gap-2">
{metrics.map((metric) => (
<button
key={metric.code}
onClick={() => toggleTrendSelection(metric.code)}
className={cn(
'px-3 py-1.5 text-sm font-medium rounded-full transition-all',
selectedForTrend.includes(metric.code)
? 'bg-primary-500 text-white'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:border-primary-300 dark:hover:border-primary-600'
)}
>
{metric.code}
</button>
))}
</div>
</div>
{/* Trend Chart */}
<TrendChart metrics={metrics} selectedMetrics={selectedForTrend} />
</div>
);
}

View File

@@ -0,0 +1,912 @@
'use client';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
ArrowUpRight,
ArrowDownRight,
Search,
Filter,
Download,
Plus,
MoreVertical,
Calendar,
X,
ChevronLeft,
ChevronRight,
Eye,
Edit,
Trash2,
FileText,
Building2,
Tag,
} from 'lucide-react';
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/utils';
import { api } from '@/lib/api';
// ============================================================================
// Types
// ============================================================================
type TransactionType = 'income' | 'expense' | 'transfer';
type TransactionStatus = 'pending' | 'completed' | 'cancelled' | 'reconciled';
type PaymentMethod = 'cash' | 'bank_transfer' | 'credit_card' | 'debit_card' | 'check' | 'other';
interface Transaction {
id: string;
date: string;
type: TransactionType;
category: string;
subcategory?: string;
description: string;
amount: number;
currency: string;
status: TransactionStatus;
paymentMethod: PaymentMethod;
contact?: {
id: string;
name: string;
rfc?: string;
type: 'customer' | 'supplier';
};
cfdiId?: string;
cfdiUuid?: string;
bankAccountId?: string;
bankAccountName?: string;
reference?: string;
notes?: string;
tags?: string[];
attachments?: number;
createdAt: string;
updatedAt: string;
}
interface TransactionsResponse {
transactions: Transaction[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
summary: {
totalIncome: number;
totalExpenses: number;
netAmount: number;
};
}
interface Filters {
search: string;
type: TransactionType | 'all';
status: TransactionStatus | 'all';
category: string;
contactId: string;
dateFrom: string;
dateTo: string;
paymentMethod: PaymentMethod | 'all';
}
// ============================================================================
// Mock Data
// ============================================================================
const categories = {
income: ['Ventas', 'Servicios', 'Comisiones', 'Intereses', 'Otros Ingresos'],
expense: ['Nomina', 'Servicios Profesionales', 'Renta', 'Servicios', 'Suministros', 'Marketing', 'Viajes', 'Otros Gastos'],
};
const generateMockTransactions = (): Transaction[] => {
const contacts = [
{ id: '1', name: 'Empresa ABC S.A. de C.V.', rfc: 'EAB123456789', type: 'customer' as const },
{ id: '2', name: 'Servicios XYZ S.A.', rfc: 'SXY987654321', type: 'supplier' as const },
{ id: '3', name: 'Consultores Asociados', rfc: 'CON456789123', type: 'supplier' as const },
{ id: '4', name: 'Tech Solutions MX', rfc: 'TSM789123456', type: 'customer' as const },
{ id: '5', name: 'Distribuidora Nacional', rfc: 'DNA321654987', type: 'customer' as const },
];
const transactions: Transaction[] = [];
for (let i = 0; i < 100; i++) {
const type: TransactionType = Math.random() > 0.4 ? 'income' : 'expense';
const categoryList = type === 'income' ? categories.income : categories.expense;
const contact = contacts[Math.floor(Math.random() * contacts.length)];
const date = new Date();
date.setDate(date.getDate() - Math.floor(Math.random() * 90));
transactions.push({
id: `txn-${i + 1}`,
date: date.toISOString().split('T')[0],
type,
category: categoryList[Math.floor(Math.random() * categoryList.length)],
description: type === 'income' ? `Factura ${1000 + i}` : `Pago ${2000 + i}`,
amount: Math.floor(Math.random() * 50000) + 1000,
currency: 'MXN',
status: (['pending', 'completed', 'reconciled'] as TransactionStatus[])[Math.floor(Math.random() * 3)],
paymentMethod: (['bank_transfer', 'credit_card', 'cash', 'check'] as PaymentMethod[])[Math.floor(Math.random() * 4)],
contact: Math.random() > 0.2 ? contact : undefined,
cfdiId: Math.random() > 0.5 ? `cfdi-${i}` : undefined,
cfdiUuid: Math.random() > 0.5 ? `${crypto.randomUUID?.() || `uuid-${i}`}` : undefined,
bankAccountId: 'bank-1',
bankAccountName: 'Cuenta Principal BBVA',
reference: `REF-${1000 + i}`,
notes: Math.random() > 0.7 ? 'Nota de la transaccion' : undefined,
tags: Math.random() > 0.5 ? ['recurrente', 'prioritario'].slice(0, Math.floor(Math.random() * 2) + 1) : undefined,
attachments: Math.floor(Math.random() * 3),
createdAt: date.toISOString(),
updatedAt: date.toISOString(),
});
}
return transactions.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
};
// ============================================================================
// Loading Skeleton
// ============================================================================
function TransactionsSkeleton() {
return (
<div className="animate-pulse space-y-4">
{/* Summary cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2" />
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-32" />
</div>
))}
</div>
{/* Table skeleton */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-64" />
</div>
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="px-4 py-4 border-b border-gray-100 dark:border-gray-700 flex gap-4">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded flex-1" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20" />
</div>
))}
</div>
</div>
);
}
// ============================================================================
// Transaction Detail Modal
// ============================================================================
interface TransactionModalProps {
transaction: Transaction | null;
isOpen: boolean;
onClose: () => void;
}
function TransactionModal({ transaction, isOpen, onClose }: TransactionModalProps) {
if (!isOpen || !transaction) return null;
const typeLabels: Record<TransactionType, string> = {
income: 'Ingreso',
expense: 'Gasto',
transfer: 'Transferencia',
};
const statusLabels: Record<TransactionStatus, string> = {
pending: 'Pendiente',
completed: 'Completado',
cancelled: 'Cancelado',
reconciled: 'Conciliado',
};
const paymentLabels: Record<PaymentMethod, string> = {
cash: 'Efectivo',
bank_transfer: 'Transferencia',
credit_card: 'Tarjeta de Credito',
debit_card: 'Tarjeta de Debito',
check: 'Cheque',
other: 'Otro',
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={cn(
'p-2 rounded-lg',
transaction.type === 'income'
? 'bg-success-100 dark:bg-success-900/30'
: 'bg-error-100 dark:bg-error-900/30'
)}
>
{transaction.type === 'income' ? (
<ArrowDownRight className="w-5 h-5 text-success-600 dark:text-success-400" />
) : (
<ArrowUpRight className="w-5 h-5 text-error-600 dark:text-error-400" />
)}
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{transaction.description}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">{transaction.id}</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Amount */}
<div className="text-center py-4">
<p
className={cn(
'text-4xl font-bold',
transaction.type === 'income' ? 'text-success-600 dark:text-success-400' : 'text-error-600 dark:text-error-400'
)}
>
{transaction.type === 'income' ? '+' : '-'}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{formatDate(transaction.date)}</p>
</div>
{/* Details Grid */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tipo</label>
<p className="text-sm font-medium text-gray-900 dark:text-white mt-1">{typeLabels[transaction.type]}</p>
</div>
<div>
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Estado</label>
<p className="mt-1">
<span
className={cn(
'inline-flex px-2 py-0.5 text-xs font-medium rounded-full',
transaction.status === 'completed' && 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400',
transaction.status === 'pending' && 'bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-400',
transaction.status === 'cancelled' && 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400',
transaction.status === 'reconciled' && 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
)}
>
{statusLabels[transaction.status]}
</span>
</p>
</div>
<div>
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Categoria</label>
<p className="text-sm font-medium text-gray-900 dark:text-white mt-1">{transaction.category}</p>
</div>
<div>
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Metodo de Pago</label>
<p className="text-sm font-medium text-gray-900 dark:text-white mt-1">{paymentLabels[transaction.paymentMethod]}</p>
</div>
</div>
{/* Contact */}
{transaction.contact && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Contacto</label>
<div className="flex items-center gap-3 mt-2">
<div className="p-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
<Building2 className="w-5 h-5 text-gray-500" />
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{transaction.contact.name}</p>
{transaction.contact.rfc && (
<p className="text-xs text-gray-500 dark:text-gray-400">{transaction.contact.rfc}</p>
)}
</div>
</div>
</div>
)}
{/* CFDI */}
{transaction.cfdiUuid && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">CFDI Relacionado</label>
<div className="flex items-center gap-3 mt-2">
<div className="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
<FileText className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<div className="flex-1">
<p className="text-sm font-mono text-gray-900 dark:text-white">{transaction.cfdiUuid}</p>
</div>
<a
href={`/cfdis/${transaction.cfdiId}`}
className="text-sm text-primary-600 dark:text-primary-400 hover:underline"
>
Ver CFDI
</a>
</div>
</div>
)}
{/* Bank Account */}
{transaction.bankAccountName && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Cuenta Bancaria</label>
<p className="text-sm font-medium text-gray-900 dark:text-white mt-1">{transaction.bankAccountName}</p>
{transaction.reference && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Ref: {transaction.reference}</p>
)}
</div>
)}
{/* Tags */}
{transaction.tags && transaction.tags.length > 0 && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Etiquetas</label>
<div className="flex flex-wrap gap-2 mt-2">
{transaction.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-xs rounded-full"
>
<Tag className="w-3 h-3" />
{tag}
</span>
))}
</div>
</div>
)}
{/* Notes */}
{transaction.notes && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Notas</label>
<p className="text-sm text-gray-700 dark:text-gray-300 mt-1">{transaction.notes}</p>
</div>
)}
</div>
{/* Footer */}
<div className="sticky bottom-0 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 px-6 py-4 flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cerrar
</button>
<button className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600">
<Edit className="w-4 h-4 inline mr-2" />
Editar
</button>
</div>
</div>
</div>
</div>
);
}
// ============================================================================
// Filter Panel
// ============================================================================
interface FilterPanelProps {
filters: Filters;
onChange: (filters: Filters) => void;
onClose: () => void;
isOpen: boolean;
}
function FilterPanel({ filters, onChange, onClose, isOpen }: FilterPanelProps) {
if (!isOpen) return null;
const allCategories = [...categories.income, ...categories.expense];
return (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-4 shadow-lg">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-gray-900 dark:text-white">Filtros</h3>
<button onClick={onClose} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
{/* Type */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tipo</label>
<select
value={filters.type}
onChange={(e) => onChange({ ...filters, type: e.target.value as Filters['type'] })}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
>
<option value="all">Todos</option>
<option value="income">Ingresos</option>
<option value="expense">Gastos</option>
<option value="transfer">Transferencias</option>
</select>
</div>
{/* Status */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estado</label>
<select
value={filters.status}
onChange={(e) => onChange({ ...filters, status: e.target.value as Filters['status'] })}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
>
<option value="all">Todos</option>
<option value="pending">Pendiente</option>
<option value="completed">Completado</option>
<option value="reconciled">Conciliado</option>
<option value="cancelled">Cancelado</option>
</select>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Categoria</label>
<select
value={filters.category}
onChange={(e) => onChange({ ...filters, category: e.target.value })}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
>
<option value="">Todas</option>
{allCategories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
</div>
{/* Payment Method */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Metodo de Pago</label>
<select
value={filters.paymentMethod}
onChange={(e) => onChange({ ...filters, paymentMethod: e.target.value as Filters['paymentMethod'] })}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
>
<option value="all">Todos</option>
<option value="bank_transfer">Transferencia</option>
<option value="cash">Efectivo</option>
<option value="credit_card">Tarjeta de Credito</option>
<option value="debit_card">Tarjeta de Debito</option>
<option value="check">Cheque</option>
</select>
</div>
{/* Date From */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Desde</label>
<input
type="date"
value={filters.dateFrom}
onChange={(e) => onChange({ ...filters, dateFrom: e.target.value })}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
/>
</div>
{/* Date To */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hasta</label>
<input
type="date"
value={filters.dateTo}
onChange={(e) => onChange({ ...filters, dateTo: e.target.value })}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() =>
onChange({
search: '',
type: 'all',
status: 'all',
category: '',
contactId: '',
dateFrom: '',
dateTo: '',
paymentMethod: 'all',
})
}
className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
>
Limpiar filtros
</button>
<button onClick={onClose} className="px-4 py-2 bg-primary-500 text-white rounded-lg text-sm hover:bg-primary-600">
Aplicar
</button>
</div>
</div>
);
}
// ============================================================================
// Main Page Component
// ============================================================================
export default function TransaccionesPage() {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedTransaction, setSelectedTransaction] = useState<Transaction | null>(null);
const [showFilters, setShowFilters] = useState(false);
const [page, setPage] = useState(1);
const [filters, setFilters] = useState<Filters>({
search: '',
type: 'all',
status: 'all',
category: '',
contactId: '',
dateFrom: '',
dateTo: '',
paymentMethod: 'all',
});
const limit = 20;
const fetchTransactions = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
// In production, this would be an API call
await new Promise((resolve) => setTimeout(resolve, 600));
setTransactions(generateMockTransactions());
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar transacciones');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchTransactions();
}, [fetchTransactions]);
// Filter and paginate transactions
const filteredTransactions = useMemo(() => {
return transactions.filter((t) => {
if (filters.search) {
const searchLower = filters.search.toLowerCase();
if (
!t.description.toLowerCase().includes(searchLower) &&
!t.contact?.name.toLowerCase().includes(searchLower) &&
!t.category.toLowerCase().includes(searchLower)
) {
return false;
}
}
if (filters.type !== 'all' && t.type !== filters.type) return false;
if (filters.status !== 'all' && t.status !== filters.status) return false;
if (filters.category && t.category !== filters.category) return false;
if (filters.paymentMethod !== 'all' && t.paymentMethod !== filters.paymentMethod) return false;
if (filters.dateFrom && t.date < filters.dateFrom) return false;
if (filters.dateTo && t.date > filters.dateTo) return false;
return true;
});
}, [transactions, filters]);
const paginatedTransactions = useMemo(() => {
const start = (page - 1) * limit;
return filteredTransactions.slice(start, start + limit);
}, [filteredTransactions, page]);
const totalPages = Math.ceil(filteredTransactions.length / limit);
const summary = useMemo(() => {
const income = filteredTransactions.filter((t) => t.type === 'income').reduce((sum, t) => sum + t.amount, 0);
const expenses = filteredTransactions.filter((t) => t.type === 'expense').reduce((sum, t) => sum + t.amount, 0);
return {
income,
expenses,
net: income - expenses,
};
}, [filteredTransactions]);
const handleExport = () => {
const csv = [
['Fecha', 'Tipo', 'Descripcion', 'Categoria', 'Monto', 'Estado', 'Contacto'].join(','),
...filteredTransactions.map((t) =>
[t.date, t.type, t.description, t.category, t.amount, t.status, t.contact?.name || ''].join(',')
),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `transacciones_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
};
if (isLoading) {
return (
<div className="p-6">
<TransactionsSkeleton />
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800 rounded-lg p-6 text-center">
<p className="text-error-700 dark:text-error-400 mb-4">{error}</p>
<button
onClick={fetchTransactions}
className="px-4 py-2 bg-error-600 text-white rounded-lg hover:bg-error-700"
>
Reintentar
</button>
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Transacciones</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">
{formatNumber(filteredTransactions.length)} transacciones
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleExport}
className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
<Download className="w-4 h-4" />
Exportar
</button>
<button className="flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600">
<Plus className="w-4 h-4" />
Nueva Transaccion
</button>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
<ArrowDownRight className="w-5 h-5 text-success-600 dark:text-success-400" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Ingresos</p>
<p className="text-xl font-bold text-success-600 dark:text-success-400">{formatCurrency(summary.income)}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-error-100 dark:bg-error-900/30 rounded-lg">
<ArrowUpRight className="w-5 h-5 text-error-600 dark:text-error-400" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Gastos</p>
<p className="text-xl font-bold text-error-600 dark:text-error-400">{formatCurrency(summary.expenses)}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center gap-3">
<div className={cn('p-2 rounded-lg', summary.net >= 0 ? 'bg-primary-100 dark:bg-primary-900/30' : 'bg-error-100 dark:bg-error-900/30')}>
<Activity className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Neto</p>
<p className={cn('text-xl font-bold', summary.net >= 0 ? 'text-primary-600 dark:text-primary-400' : 'text-error-600 dark:text-error-400')}>
{formatCurrency(summary.net)}
</p>
</div>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Buscar transacciones..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className={cn(
'flex items-center gap-2 px-4 py-2 border rounded-lg transition-colors',
showFilters
? 'bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-700 text-primary-700 dark:text-primary-400'
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
)}
>
<Filter className="w-4 h-4" />
Filtros
{Object.values(filters).filter((v) => v && v !== 'all').length > 1 && (
<span className="ml-1 px-1.5 py-0.5 bg-primary-500 text-white text-xs rounded-full">
{Object.values(filters).filter((v) => v && v !== 'all').length - 1}
</span>
)}
</button>
</div>
{/* Filter Panel */}
<FilterPanel filters={filters} onChange={setFilters} onClose={() => setShowFilters(false)} isOpen={showFilters} />
{/* Transactions Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-900/50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Fecha
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Descripcion
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Categoria
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Contacto
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Monto
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Estado
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Acciones
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{paginatedTransactions.map((transaction) => (
<tr
key={transaction.id}
onClick={() => setSelectedTransaction(transaction)}
className="hover:bg-gray-50 dark:hover:bg-gray-900/50 cursor-pointer"
>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
{formatDate(transaction.date)}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div
className={cn(
'p-1.5 rounded-lg',
transaction.type === 'income'
? 'bg-success-100 dark:bg-success-900/30'
: 'bg-error-100 dark:bg-error-900/30'
)}
>
{transaction.type === 'income' ? (
<ArrowDownRight className="w-4 h-4 text-success-600 dark:text-success-400" />
) : (
<ArrowUpRight className="w-4 h-4 text-error-600 dark:text-error-400" />
)}
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{transaction.description}</p>
{transaction.cfdiUuid && (
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono">
CFDI: {transaction.cfdiUuid.slice(0, 8)}...
</p>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
{transaction.category}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{transaction.contact ? (
<div>
<p className="text-sm text-gray-900 dark:text-white">{transaction.contact.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{transaction.contact.rfc}</p>
</div>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<span
className={cn(
'text-sm font-medium',
transaction.type === 'income'
? 'text-success-600 dark:text-success-400'
: 'text-error-600 dark:text-error-400'
)}
>
{transaction.type === 'income' ? '+' : '-'}
{formatCurrency(transaction.amount, transaction.currency)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<span
className={cn(
'inline-flex px-2 py-0.5 text-xs font-medium rounded-full',
transaction.status === 'completed' && 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400',
transaction.status === 'pending' && 'bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-400',
transaction.status === 'cancelled' && 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400',
transaction.status === 'reconciled' && 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
)}
>
{transaction.status === 'completed' && 'Completado'}
{transaction.status === 'pending' && 'Pendiente'}
{transaction.status === 'cancelled' && 'Cancelado'}
{transaction.status === 'reconciled' && 'Conciliado'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<button
onClick={(e) => {
e.stopPropagation();
setSelectedTransaction(transaction);
}}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
<Eye className="w-4 h-4 text-gray-500" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<p className="text-sm text-gray-500 dark:text-gray-400">
Mostrando {(page - 1) * limit + 1} a {Math.min(page * limit, filteredTransactions.length)} de{' '}
{filteredTransactions.length} transacciones
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-5 h-5" />
</button>
<span className="text-sm text-gray-600 dark:text-gray-400">
Pagina {page} de {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
</div>
{/* Transaction Detail Modal */}
<TransactionModal
transaction={selectedTransaction}
isOpen={!!selectedTransaction}
onClose={() => setSelectedTransaction(null)}
/>
</div>
);
}
// Need to import Activity for the summary card
import { Activity } from 'lucide-react';

View File

@@ -0,0 +1,374 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ============================================
Base Styles
============================================ */
@layer base {
/* Root variables */
:root {
--background: 255 255 255;
--foreground: 15 23 42;
--primary: 12 140 232;
--primary-foreground: 255 255 255;
--muted: 241 245 249;
--muted-foreground: 100 116 139;
--accent: 241 245 249;
--accent-foreground: 15 23 42;
--border: 226 232 240;
--radius: 0.5rem;
}
.dark {
--background: 10 15 26;
--foreground: 241 245 249;
--primary: 54 167 247;
--primary-foreground: 255 255 255;
--muted: 30 41 59;
--muted-foreground: 148 163 184;
--accent: 30 41 59;
--accent-foreground: 241 245 249;
--border: 51 65 85;
}
/* Base HTML styles */
html {
@apply antialiased;
scroll-behavior: smooth;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
@apply bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100;
@apply min-h-screen;
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
@apply font-semibold tracking-tight;
}
h1 { @apply text-4xl lg:text-5xl; }
h2 { @apply text-3xl lg:text-4xl; }
h3 { @apply text-2xl lg:text-3xl; }
h4 { @apply text-xl lg:text-2xl; }
h5 { @apply text-lg lg:text-xl; }
h6 { @apply text-base lg:text-lg; }
/* Links */
a {
@apply transition-colors duration-200;
}
/* Focus styles */
*:focus-visible {
@apply outline-none ring-2 ring-primary-500 ring-offset-2 ring-offset-white dark:ring-offset-slate-900;
}
/* Selection */
::selection {
@apply bg-primary-500/20 text-primary-900 dark:text-primary-100;
}
/* Scrollbar */
::-webkit-scrollbar {
@apply w-2 h-2;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-slate-300 dark:bg-slate-700 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-slate-400 dark:bg-slate-600;
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: rgb(148 163 184) transparent;
}
.dark * {
scrollbar-color: rgb(51 65 85) transparent;
}
}
/* ============================================
Component Styles
============================================ */
@layer components {
/* Container */
.container-app {
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
}
/* Page wrapper */
.page-wrapper {
@apply min-h-screen flex flex-col;
}
/* Card styles */
.card {
@apply bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700;
}
.card-hover {
@apply card transition-all duration-200 hover:border-primary-300 hover:shadow-md dark:hover:border-primary-700;
}
/* Form styles */
.form-group {
@apply space-y-1.5;
}
.form-label {
@apply block text-sm font-medium text-slate-700 dark:text-slate-200;
}
.form-input {
@apply w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600;
@apply bg-white dark:bg-slate-800 text-slate-900 dark:text-white;
@apply placeholder:text-slate-400 dark:placeholder:text-slate-500;
@apply focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20;
@apply transition-all duration-200;
}
.form-error {
@apply text-sm text-error-600 dark:text-error-400;
}
/* Badge */
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-primary {
@apply badge bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300;
}
.badge-success {
@apply badge bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-300;
}
.badge-warning {
@apply badge bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-300;
}
.badge-error {
@apply badge bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-300;
}
/* Status dot */
.status-dot {
@apply w-2 h-2 rounded-full;
}
.status-online {
@apply status-dot bg-success-500 animate-pulse;
}
.status-offline {
@apply status-dot bg-slate-400;
}
.status-warning {
@apply status-dot bg-warning-500;
}
/* Gradient text */
.gradient-text {
@apply bg-gradient-to-r from-primary-600 to-primary-400 bg-clip-text text-transparent;
}
/* Glass effect */
.glass {
@apply bg-white/70 dark:bg-slate-900/70 backdrop-blur-lg;
}
/* Skeleton loading */
.skeleton {
@apply bg-slate-200 dark:bg-slate-700 animate-pulse rounded;
}
/* Divider */
.divider {
@apply border-t border-slate-200 dark:border-slate-700;
}
/* Avatar */
.avatar {
@apply rounded-full bg-primary-100 dark:bg-primary-900/50 flex items-center justify-center;
@apply text-primary-700 dark:text-primary-300 font-semibold;
}
.avatar-sm { @apply w-8 h-8 text-sm; }
.avatar-md { @apply w-10 h-10 text-base; }
.avatar-lg { @apply w-12 h-12 text-lg; }
.avatar-xl { @apply w-16 h-16 text-xl; }
/* Trading specific */
.price-up {
@apply text-success-600 dark:text-success-400;
}
.price-down {
@apply text-error-600 dark:text-error-400;
}
.price-neutral {
@apply text-slate-600 dark:text-slate-400;
}
}
/* ============================================
Utility Styles
============================================ */
@layer utilities {
/* Hide scrollbar */
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Truncate multiline */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Safe area padding */
.safe-top {
padding-top: env(safe-area-inset-top);
}
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
/* Animation delays */
.animation-delay-100 { animation-delay: 100ms; }
.animation-delay-200 { animation-delay: 200ms; }
.animation-delay-300 { animation-delay: 300ms; }
.animation-delay-400 { animation-delay: 400ms; }
.animation-delay-500 { animation-delay: 500ms; }
/* Glow effects */
.glow-primary {
box-shadow: 0 0 20px rgba(12, 140, 232, 0.3);
}
.glow-success {
box-shadow: 0 0 20px rgba(16, 185, 129, 0.3);
}
.glow-error {
box-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
}
/* Text gradient */
.text-gradient-primary {
@apply bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent;
}
/* Backdrop blur variants */
.backdrop-blur-xs {
backdrop-filter: blur(2px);
}
}
/* ============================================
Animation Keyframes
============================================ */
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
.animate-shimmer {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.2) 50%,
rgba(255, 255, 255, 0) 100%
);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
.animate-gradient {
background-size: 200% 200%;
animation: gradient 4s ease infinite;
}
/* ============================================
Print Styles
============================================ */
@media print {
.no-print {
display: none !important;
}
body {
@apply bg-white text-black;
}
.card {
@apply border border-slate-300;
box-shadow: none;
}
}

144
apps/web/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,144 @@
import type { Metadata, Viewport } from 'next';
import { Inter, JetBrains_Mono } from 'next/font/google';
import './globals.css';
// Fonts
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-mono',
});
// Metadata
export const metadata: Metadata = {
title: {
default: 'Horux Strategy - Trading Algoritmico',
template: '%s | Horux Strategy',
},
description:
'Plataforma de trading algoritmico para automatizar tus estrategias de inversion con inteligencia artificial.',
keywords: [
'trading',
'algoritmico',
'criptomonedas',
'bitcoin',
'estrategias',
'automatizacion',
'inversion',
],
authors: [{ name: 'Horux Team' }],
creator: 'Horux',
publisher: 'Horux',
robots: {
index: true,
follow: true,
},
openGraph: {
type: 'website',
locale: 'es_ES',
url: 'https://horux.io',
siteName: 'Horux Strategy',
title: 'Horux Strategy - Trading Algoritmico',
description:
'Plataforma de trading algoritmico para automatizar tus estrategias de inversion.',
images: [
{
url: '/og-image.png',
width: 1200,
height: 630,
alt: 'Horux Strategy',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Horux Strategy',
description: 'Plataforma de trading algoritmico',
images: ['/og-image.png'],
},
icons: {
icon: '/favicon.ico',
shortcut: '/favicon-16x16.png',
apple: '/apple-touch-icon.png',
},
manifest: '/manifest.json',
};
// Viewport
export const viewport: Viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#0a0f1a' },
],
width: 'device-width',
initialScale: 1,
maximumScale: 1,
};
/**
* Root Layout
*
* Layout principal de la aplicacion que envuelve todas las paginas.
* Incluye providers globales, fonts y meta tags.
*/
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="es"
className={`${inter.variable} ${jetbrainsMono.variable}`}
suppressHydrationWarning
>
<head>
{/* Preconnect to external resources */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
</head>
<body className="font-sans antialiased">
{/* Theme Script - Prevent flash */}
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var stored = localStorage.getItem('horux-ui-storage');
var theme = 'dark';
if (stored) {
var parsed = JSON.parse(stored);
theme = parsed.state?.theme || 'dark';
}
if (theme === 'system') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.classList.add(theme);
} catch (e) {
document.documentElement.classList.add('dark');
}
})();
`,
}}
/>
{/* Main Content */}
<main className="min-h-screen">{children}</main>
{/* Portal containers for modals/toasts */}
<div id="modal-root" />
<div id="toast-root" />
</body>
</html>
);
}

51
apps/web/src/app/page.tsx Normal file
View File

@@ -0,0 +1,51 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth.store';
/**
* Landing Page
*
* Pagina principal que redirige a login o dashboard
* dependiendo del estado de autenticacion.
*/
export default function HomePage() {
const router = useRouter();
const { isAuthenticated, isInitialized, checkAuth } = useAuthStore();
useEffect(() => {
const init = async () => {
await checkAuth();
};
init();
}, [checkAuth]);
useEffect(() => {
if (isInitialized) {
if (isAuthenticated) {
router.replace('/dashboard');
} else {
router.replace('/login');
}
}
}, [isAuthenticated, isInitialized, router]);
// Loading state mientras se verifica la autenticacion
return (
<div className="min-h-screen flex items-center justify-center bg-slate-950">
<div className="text-center">
{/* Logo animado */}
<div className="relative w-20 h-20 mx-auto mb-6">
<div className="absolute inset-0 rounded-2xl bg-horux-gradient animate-pulse" />
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-4xl font-bold text-white">H</span>
</div>
</div>
{/* Loading text */}
<p className="text-slate-400 text-sm animate-pulse">Cargando...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,345 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { cn, getInitials } from '@/lib/utils';
import { useAuthStore } from '@/stores/auth.store';
import { useUIStore, useTheme } from '@/stores/ui.store';
import {
Menu,
Bell,
Sun,
Moon,
Search,
User,
Settings,
LogOut,
ChevronDown,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
/**
* Notificacion mock
*/
interface Notification {
id: string;
title: string;
message: string;
type: 'info' | 'success' | 'warning' | 'error';
timestamp: Date;
read: boolean;
}
/**
* Notificaciones de ejemplo
*/
const mockNotifications: Notification[] = [
{
id: '1',
title: 'Trade ejecutado',
message: 'BTC/USDT compra a $43,250',
type: 'success',
timestamp: new Date(Date.now() - 1000 * 60 * 5),
read: false,
},
{
id: '2',
title: 'Stop Loss activado',
message: 'ETH/USDT posicion cerrada',
type: 'warning',
timestamp: new Date(Date.now() - 1000 * 60 * 30),
read: false,
},
{
id: '3',
title: 'Nueva estrategia disponible',
message: 'Grid Trading actualizado',
type: 'info',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2),
read: true,
},
];
/**
* Componente Header
*
* Header principal con búsqueda, notificaciones, tema y menú de usuario.
*/
export const Header: React.FC = () => {
const router = useRouter();
const { user, logout } = useAuthStore();
const { sidebarCollapsed, isMobile, setSidebarOpen } = useUIStore();
const { isDark, toggleTheme } = useTheme();
const [showSearch, setShowSearch] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const [notifications] = useState<Notification[]>(mockNotifications);
const notificationRef = useRef<HTMLDivElement>(null);
const userMenuRef = useRef<HTMLDivElement>(null);
// Cerrar menus al hacer click fuera
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (notificationRef.current && !notificationRef.current.contains(event.target as Node)) {
setShowNotifications(false);
}
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
setShowUserMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Handle logout
const handleLogout = async () => {
await logout();
router.push('/login');
};
// Unread count
const unreadCount = notifications.filter((n) => !n.read).length;
return (
<header
className={cn(
'fixed top-0 right-0 z-40',
'h-16 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md',
'border-b border-slate-200 dark:border-slate-700',
'transition-all duration-300',
// Ajustar ancho segun sidebar
isMobile ? 'left-0' : (sidebarCollapsed ? 'left-20' : 'left-64')
)}
>
<div className="flex items-center justify-between h-full px-4 lg:px-6">
{/* Left Section */}
<div className="flex items-center gap-4">
{/* Mobile menu button */}
{isMobile && (
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
aria-label="Abrir menu"
>
<Menu className="h-5 w-5" />
</button>
)}
{/* Search */}
<div className="relative">
{showSearch ? (
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Buscar..."
className="w-64 px-4 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<button
onClick={() => setShowSearch(false)}
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<button
onClick={() => setShowSearch(true)}
className="flex items-center gap-2 px-3 py-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<Search className="h-4 w-4" />
<span className="hidden sm:inline">Buscar</span>
<kbd className="hidden lg:inline-flex items-center gap-1 px-2 py-0.5 text-xs font-mono bg-slate-100 dark:bg-slate-700 rounded">
<span className="text-xs">Ctrl</span>K
</kbd>
</button>
)}
</div>
</div>
{/* Right Section */}
<div className="flex items-center gap-2">
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className="p-2 rounded-lg text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors"
aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
>
{isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
{/* Notifications */}
<div ref={notificationRef} className="relative">
<button
onClick={() => setShowNotifications(!showNotifications)}
className="relative p-2 rounded-lg text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors"
aria-label="Notificaciones"
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 w-4 h-4 flex items-center justify-center text-xs font-bold text-white bg-error-500 rounded-full">
{unreadCount}
</span>
)}
</button>
{/* Notifications Dropdown */}
{showNotifications && (
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden z-50">
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">
Notificaciones
</h3>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.length > 0 ? (
notifications.map((notification) => (
<div
key={notification.id}
className={cn(
'px-4 py-3 border-b border-slate-100 dark:border-slate-700 last:border-0',
'hover:bg-slate-50 dark:hover:bg-slate-700/50 cursor-pointer transition-colors',
!notification.read && 'bg-primary-50/50 dark:bg-primary-900/20'
)}
>
<div className="flex items-start gap-3">
<div
className={cn(
'w-2 h-2 mt-2 rounded-full flex-shrink-0',
notification.type === 'success' && 'bg-success-500',
notification.type === 'error' && 'bg-error-500',
notification.type === 'warning' && 'bg-warning-500',
notification.type === 'info' && 'bg-primary-500'
)}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-white">
{notification.title}
</p>
<p className="text-sm text-slate-500 dark:text-slate-400 truncate">
{notification.message}
</p>
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500">
{formatTimeAgo(notification.timestamp)}
</p>
</div>
</div>
</div>
))
) : (
<div className="p-8 text-center text-slate-500 dark:text-slate-400">
No hay notificaciones
</div>
)}
</div>
<div className="p-3 border-t border-slate-200 dark:border-slate-700">
<Link
href="/notifications"
className="block text-center text-sm text-primary-600 dark:text-primary-400 hover:underline"
onClick={() => setShowNotifications(false)}
>
Ver todas
</Link>
</div>
</div>
)}
</div>
{/* User Menu */}
<div ref={userMenuRef} className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-3 p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
{/* Avatar */}
<div className="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900/50 flex items-center justify-center text-primary-700 dark:text-primary-300 font-semibold text-sm">
{user?.avatar ? (
<img
src={user.avatar}
alt={user.name}
className="w-full h-full rounded-full object-cover"
/>
) : (
getInitials(user?.name || 'Usuario')
)}
</div>
<div className="hidden md:block text-left">
<p className="text-sm font-medium text-slate-900 dark:text-white">
{user?.name || 'Usuario'}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{user?.role || 'trader'}
</p>
</div>
<ChevronDown className="hidden md:block h-4 w-4 text-slate-400" />
</button>
{/* User Dropdown */}
{showUserMenu && (
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden z-50">
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
<p className="text-sm font-medium text-slate-900 dark:text-white">
{user?.name || 'Usuario'}
</p>
<p className="text-sm text-slate-500 dark:text-slate-400 truncate">
{user?.email || 'usuario@ejemplo.com'}
</p>
</div>
<div className="py-2">
<Link
href="/profile"
className="flex items-center gap-3 px-4 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700/50"
onClick={() => setShowUserMenu(false)}
>
<User className="h-4 w-4" />
Mi Perfil
</Link>
<Link
href="/settings"
className="flex items-center gap-3 px-4 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700/50"
onClick={() => setShowUserMenu(false)}
>
<Settings className="h-4 w-4" />
Configuracion
</Link>
</div>
<div className="py-2 border-t border-slate-200 dark:border-slate-700">
<button
onClick={handleLogout}
className="flex items-center gap-3 w-full px-4 py-2 text-sm text-error-600 dark:text-error-400 hover:bg-error-50 dark:hover:bg-error-900/20"
>
<LogOut className="h-4 w-4" />
Cerrar sesion
</button>
</div>
</div>
)}
</div>
</div>
</div>
</header>
);
};
/**
* Formatea tiempo relativo
*/
function formatTimeAgo(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (minutes < 1) return 'Ahora';
if (minutes < 60) return `Hace ${minutes} min`;
if (hours < 24) return `Hace ${hours}h`;
return `Hace ${days}d`;
}
export default Header;

View File

@@ -0,0 +1,306 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui.store';
import {
LayoutDashboard,
LineChart,
Wallet,
History,
Settings,
HelpCircle,
ChevronLeft,
ChevronRight,
TrendingUp,
Bot,
Shield,
Bell,
} from 'lucide-react';
/**
* Item de navegación
*/
interface NavItem {
label: string;
href: string;
icon: React.ReactNode;
badge?: string | number;
children?: NavItem[];
}
/**
* Grupos de navegación
*/
interface NavGroup {
label?: string;
items: NavItem[];
}
/**
* Navegación principal
*/
const navigation: NavGroup[] = [
{
items: [
{
label: 'Dashboard',
href: '/dashboard',
icon: <LayoutDashboard className="h-5 w-5" />,
},
],
},
{
label: 'Trading',
items: [
{
label: 'Estrategias',
href: '/strategies',
icon: <Bot className="h-5 w-5" />,
badge: 3,
},
{
label: 'Portfolio',
href: '/portfolio',
icon: <Wallet className="h-5 w-5" />,
},
{
label: 'Mercados',
href: '/markets',
icon: <TrendingUp className="h-5 w-5" />,
},
{
label: 'Historial',
href: '/history',
icon: <History className="h-5 w-5" />,
},
],
},
{
label: 'Analisis',
items: [
{
label: 'Performance',
href: '/analytics',
icon: <LineChart className="h-5 w-5" />,
},
{
label: 'Riesgo',
href: '/risk',
icon: <Shield className="h-5 w-5" />,
},
],
},
{
label: 'Sistema',
items: [
{
label: 'Notificaciones',
href: '/notifications',
icon: <Bell className="h-5 w-5" />,
badge: 5,
},
{
label: 'Configuracion',
href: '/settings',
icon: <Settings className="h-5 w-5" />,
},
{
label: 'Ayuda',
href: '/help',
icon: <HelpCircle className="h-5 w-5" />,
},
],
},
];
/**
* Componente NavLink
*/
interface NavLinkProps {
item: NavItem;
collapsed: boolean;
}
const NavLink: React.FC<NavLinkProps> = ({ item, collapsed }) => {
const pathname = usePathname();
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
href={item.href}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg',
'text-sm font-medium transition-all duration-200',
'group relative',
isActive
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700/50 dark:hover:text-white',
collapsed && 'justify-center px-2'
)}
>
{/* Active Indicator */}
{isActive && (
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-primary-600 dark:bg-primary-400 rounded-r-full" />
)}
{/* Icon */}
<span
className={cn(
'flex-shrink-0 transition-colors',
isActive
? 'text-primary-600 dark:text-primary-400'
: 'text-slate-400 group-hover:text-slate-600 dark:text-slate-500 dark:group-hover:text-slate-300'
)}
>
{item.icon}
</span>
{/* Label */}
{!collapsed && (
<span className="flex-1 truncate">{item.label}</span>
)}
{/* Badge */}
{item.badge && !collapsed && (
<span className="flex-shrink-0 px-2 py-0.5 text-xs font-semibold rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/50 dark:text-primary-300">
{item.badge}
</span>
)}
{/* Tooltip when collapsed */}
{collapsed && (
<span className="absolute left-full ml-2 px-2 py-1 text-sm font-medium text-white bg-slate-900 dark:bg-slate-700 rounded opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
{item.label}
{item.badge && (
<span className="ml-2 px-1.5 py-0.5 text-xs rounded-full bg-primary-500 text-white">
{item.badge}
</span>
)}
</span>
)}
</Link>
);
};
/**
* Componente Sidebar
*
* Barra lateral de navegación con soporte para colapsado y grupos de navegación.
*/
export const Sidebar: React.FC = () => {
const { sidebarOpen, sidebarCollapsed, toggleSidebarCollapsed, isMobile, setSidebarOpen } = useUIStore();
// En mobile, cerrar al hacer click en overlay
const handleOverlayClick = () => {
if (isMobile) {
setSidebarOpen(false);
}
};
return (
<>
{/* Overlay mobile */}
{isMobile && sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={handleOverlayClick}
aria-hidden="true"
/>
)}
{/* Sidebar */}
<aside
className={cn(
'fixed top-0 left-0 z-50 h-full',
'bg-white dark:bg-slate-900',
'border-r border-slate-200 dark:border-slate-700',
'flex flex-col',
'transition-all duration-300 ease-in-out',
// Width
sidebarCollapsed ? 'w-20' : 'w-64',
// Mobile
isMobile && !sidebarOpen && '-translate-x-full',
isMobile && sidebarOpen && 'translate-x-0',
// Desktop
!isMobile && 'translate-x-0'
)}
>
{/* Header */}
<div className={cn(
'flex items-center h-16 px-4',
'border-b border-slate-200 dark:border-slate-700',
sidebarCollapsed && 'justify-center'
)}>
{/* Logo */}
<Link href="/dashboard" className="flex items-center gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-horux-gradient flex items-center justify-center">
<span className="text-white font-bold text-xl">H</span>
</div>
{!sidebarCollapsed && (
<span className="text-xl font-bold text-slate-900 dark:text-white">
Horux
</span>
)}
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-6">
{navigation.map((group, idx) => (
<div key={idx}>
{/* Group Label */}
{group.label && !sidebarCollapsed && (
<h3 className="px-3 mb-2 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
{group.label}
</h3>
)}
{/* Separator when collapsed */}
{group.label && sidebarCollapsed && (
<div className="my-2 border-t border-slate-200 dark:border-slate-700" />
)}
{/* Items */}
<div className="space-y-1">
{group.items.map((item) => (
<NavLink key={item.href} item={item} collapsed={sidebarCollapsed} />
))}
</div>
</div>
))}
</nav>
{/* Footer - Collapse Button */}
{!isMobile && (
<div className="p-3 border-t border-slate-200 dark:border-slate-700">
<button
onClick={toggleSidebarCollapsed}
className={cn(
'flex items-center gap-3 w-full px-3 py-2.5 rounded-lg',
'text-sm font-medium text-slate-600 dark:text-slate-400',
'hover:bg-slate-100 dark:hover:bg-slate-700/50',
'transition-all duration-200',
sidebarCollapsed && 'justify-center'
)}
aria-label={sidebarCollapsed ? 'Expandir sidebar' : 'Colapsar sidebar'}
>
{sidebarCollapsed ? (
<ChevronRight className="h-5 w-5" />
) : (
<>
<ChevronLeft className="h-5 w-5" />
<span>Colapsar</span>
</>
)}
</button>
</div>
)}
</aside>
</>
);
};
export default Sidebar;

View File

@@ -0,0 +1,9 @@
/**
* Layout Components Barrel Export
*
* Re-exporta todos los componentes de layout.
* Ejemplo: import { Sidebar, Header } from '@/components/layout';
*/
export { Sidebar } from './Sidebar';
export { Header } from './Header';

View File

@@ -0,0 +1,215 @@
'use client';
import React, { forwardRef, ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
/**
* Variantes del botón
*/
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success';
/**
* Tamaños del botón
*/
type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
/**
* Props del componente Button
*/
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
fullWidth?: boolean;
}
/**
* Estilos base del botón
*/
const baseStyles = `
inline-flex items-center justify-center
font-medium rounded-lg
transition-all duration-200 ease-in-out
focus:outline-none focus:ring-2 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none
active:scale-[0.98]
`;
/**
* Estilos por variante
*/
const variantStyles: Record<ButtonVariant, string> = {
primary: `
bg-primary-600 text-white
hover:bg-primary-700
focus:ring-primary-500
dark:bg-primary-500 dark:hover:bg-primary-600
`,
secondary: `
bg-slate-100 text-slate-900
hover:bg-slate-200
focus:ring-slate-500
dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-600
`,
outline: `
border-2 border-primary-600 text-primary-600
hover:bg-primary-50
focus:ring-primary-500
dark:border-primary-400 dark:text-primary-400 dark:hover:bg-primary-950
`,
ghost: `
text-slate-700
hover:bg-slate-100
focus:ring-slate-500
dark:text-slate-300 dark:hover:bg-slate-800
`,
danger: `
bg-error-600 text-white
hover:bg-error-700
focus:ring-error-500
dark:bg-error-500 dark:hover:bg-error-600
`,
success: `
bg-success-600 text-white
hover:bg-success-700
focus:ring-success-500
dark:bg-success-500 dark:hover:bg-success-600
`,
};
/**
* Estilos por tamaño
*/
const sizeStyles: Record<ButtonSize, string> = {
xs: 'text-xs px-2.5 py-1.5 gap-1',
sm: 'text-sm px-3 py-2 gap-1.5',
md: 'text-sm px-4 py-2.5 gap-2',
lg: 'text-base px-5 py-3 gap-2',
xl: 'text-lg px-6 py-3.5 gap-2.5',
};
/**
* Componente Spinner para loading
*/
const Spinner: React.FC<{ className?: string }> = ({ className }) => (
<svg
className={cn('animate-spin', className)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
/**
* Componente Button
*
* Botón reutilizable con múltiples variantes y tamaños.
* Soporta estados de loading, iconos y ancho completo.
*/
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant = 'primary',
size = 'md',
isLoading = false,
leftIcon,
rightIcon,
fullWidth = false,
disabled,
children,
...props
},
ref
) => {
// Determinar tamaño del spinner
const spinnerSize = {
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-4 w-4',
lg: 'h-5 w-5',
xl: 'h-5 w-5',
}[size];
return (
<button
ref={ref}
className={cn(
baseStyles,
variantStyles[variant],
sizeStyles[size],
fullWidth && 'w-full',
className
)}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<>
<Spinner className={spinnerSize} />
<span>Cargando...</span>
</>
) : (
<>
{leftIcon && <span className="flex-shrink-0">{leftIcon}</span>}
{children}
{rightIcon && <span className="flex-shrink-0">{rightIcon}</span>}
</>
)}
</button>
);
}
);
Button.displayName = 'Button';
/**
* Botón de icono (solo icono, sin texto)
*/
interface IconButtonProps extends Omit<ButtonProps, 'leftIcon' | 'rightIcon' | 'children'> {
icon: React.ReactNode;
'aria-label': string;
}
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
({ className, size = 'md', icon, ...props }, ref) => {
const iconSizeStyles: Record<ButtonSize, string> = {
xs: 'p-1.5',
sm: 'p-2',
md: 'p-2.5',
lg: 'p-3',
xl: 'p-3.5',
};
return (
<Button
ref={ref}
className={cn(iconSizeStyles[size], className)}
size={size}
{...props}
>
{icon}
</Button>
);
}
);
IconButton.displayName = 'IconButton';
export default Button;

View File

@@ -0,0 +1,256 @@
'use client';
import React, { forwardRef, HTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
/**
* Variantes del Card
*/
type CardVariant = 'default' | 'bordered' | 'elevated' | 'gradient';
/**
* Props del componente Card
*/
interface CardProps extends HTMLAttributes<HTMLDivElement> {
variant?: CardVariant;
padding?: 'none' | 'sm' | 'md' | 'lg';
hoverable?: boolean;
clickable?: boolean;
}
/**
* Estilos base del card
*/
const baseStyles = `
rounded-xl bg-white
dark:bg-slate-800
transition-all duration-200
`;
/**
* Estilos por variante
*/
const variantStyles: Record<CardVariant, string> = {
default: `
border border-slate-200
dark:border-slate-700
`,
bordered: `
border-2 border-slate-300
dark:border-slate-600
`,
elevated: `
shadow-lg shadow-slate-200/50
dark:shadow-slate-900/50
border border-slate-100
dark:border-slate-700
`,
gradient: `
border border-transparent
bg-gradient-to-br from-white to-slate-50
dark:from-slate-800 dark:to-slate-900
shadow-lg shadow-slate-200/50
dark:shadow-slate-900/50
`,
};
/**
* Estilos de padding
*/
const paddingStyles: Record<string, string> = {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
};
/**
* Componente Card
*
* Contenedor reutilizable con múltiples variantes y estados.
*/
export const Card = forwardRef<HTMLDivElement, CardProps>(
(
{
className,
variant = 'default',
padding = 'md',
hoverable = false,
clickable = false,
children,
...props
},
ref
) => {
return (
<div
ref={ref}
className={cn(
baseStyles,
variantStyles[variant],
paddingStyles[padding],
hoverable && 'hover:border-primary-300 hover:shadow-md dark:hover:border-primary-600',
clickable && 'cursor-pointer active:scale-[0.99]',
className
)}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
{...props}
>
{children}
</div>
);
}
);
Card.displayName = 'Card';
/**
* Card Header
*/
interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {
title?: string;
subtitle?: string;
action?: React.ReactNode;
}
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
({ className, title, subtitle, action, children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn('flex items-start justify-between gap-4 mb-4', className)}
{...props}
>
{(title || subtitle) ? (
<div className="flex-1 min-w-0">
{title && (
<h3 className="text-lg font-semibold text-slate-900 dark:text-white truncate">
{title}
</h3>
)}
{subtitle && (
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{subtitle}
</p>
)}
</div>
) : (
children
)}
{action && <div className="flex-shrink-0">{action}</div>}
</div>
);
}
);
CardHeader.displayName = 'CardHeader';
/**
* Card Content
*/
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => {
return (
<div ref={ref} className={cn('', className)} {...props}>
{children}
</div>
);
}
);
CardContent.displayName = 'CardContent';
/**
* Card Footer
*/
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
'mt-4 pt-4 border-t border-slate-200 dark:border-slate-700',
className
)}
{...props}
>
{children}
</div>
);
}
);
CardFooter.displayName = 'CardFooter';
/**
* Stats Card - Card especializado para mostrar estadísticas
*/
interface StatsCardProps extends HTMLAttributes<HTMLDivElement> {
title: string;
value: string | number;
change?: {
value: number;
label?: string;
};
icon?: React.ReactNode;
trend?: 'up' | 'down' | 'neutral';
}
export const StatsCard = forwardRef<HTMLDivElement, StatsCardProps>(
({ className, title, value, change, icon, trend, ...props }, ref) => {
const trendColors = {
up: 'text-success-600 dark:text-success-400',
down: 'text-error-600 dark:text-error-400',
neutral: 'text-slate-500 dark:text-slate-400',
};
const trendBgColors = {
up: 'bg-success-50 dark:bg-success-900/30',
down: 'bg-error-50 dark:bg-error-900/30',
neutral: 'bg-slate-100 dark:bg-slate-700',
};
return (
<Card ref={ref} className={cn('', className)} {...props}>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">
{title}
</p>
<p className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">
{value}
</p>
{change && (
<div className="mt-2 flex items-center gap-2">
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
trendBgColors[trend || 'neutral'],
trendColors[trend || 'neutral']
)}
>
{change.value >= 0 ? '+' : ''}{change.value}%
</span>
{change.label && (
<span className="text-xs text-slate-500 dark:text-slate-400">
{change.label}
</span>
)}
</div>
)}
</div>
{icon && (
<div className="flex-shrink-0 p-3 rounded-lg bg-primary-50 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400">
{icon}
</div>
)}
</div>
</Card>
);
}
);
StatsCard.displayName = 'StatsCard';
export default Card;

View File

@@ -0,0 +1,266 @@
'use client';
import React, { forwardRef, InputHTMLAttributes, useState } from 'react';
import { cn } from '@/lib/utils';
import { Eye, EyeOff, AlertCircle, CheckCircle } from 'lucide-react';
/**
* Tamaños del input
*/
type InputSize = 'sm' | 'md' | 'lg';
/**
* Props del componente Input
*/
interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
label?: string;
error?: string;
hint?: string;
success?: boolean;
size?: InputSize;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
fullWidth?: boolean;
}
/**
* Estilos base del input
*/
const baseStyles = `
w-full rounded-lg border
transition-all duration-200 ease-in-out
focus:outline-none focus:ring-2
disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-slate-50
dark:disabled:bg-slate-900
placeholder:text-slate-400 dark:placeholder:text-slate-500
`;
/**
* Estilos por estado
*/
const stateStyles = {
default: `
border-slate-300 bg-white text-slate-900
hover:border-slate-400
focus:border-primary-500 focus:ring-primary-500/20
dark:border-slate-600 dark:bg-slate-800 dark:text-white
dark:hover:border-slate-500
dark:focus:border-primary-400 dark:focus:ring-primary-400/20
`,
error: `
border-error-500 bg-white text-slate-900
hover:border-error-600
focus:border-error-500 focus:ring-error-500/20
dark:border-error-400 dark:bg-slate-800 dark:text-white
dark:hover:border-error-300
dark:focus:border-error-400 dark:focus:ring-error-400/20
`,
success: `
border-success-500 bg-white text-slate-900
hover:border-success-600
focus:border-success-500 focus:ring-success-500/20
dark:border-success-400 dark:bg-slate-800 dark:text-white
dark:hover:border-success-300
dark:focus:border-success-400 dark:focus:ring-success-400/20
`,
};
/**
* Estilos por tamaño
*/
const sizeStyles: Record<InputSize, string> = {
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2.5 text-sm',
lg: 'px-4 py-3 text-base',
};
/**
* Estilos del label
*/
const labelStyles = `
block text-sm font-medium text-slate-700
dark:text-slate-200 mb-1.5
`;
/**
* Componente Input
*
* Input reutilizable con soporte para label, error, hint,
* iconos y diferentes tamaños.
*/
export const Input = forwardRef<HTMLInputElement, InputProps>(
(
{
className,
label,
error,
hint,
success,
size = 'md',
leftIcon,
rightIcon,
fullWidth = true,
type = 'text',
id,
...props
},
ref
) => {
const [showPassword, setShowPassword] = useState(false);
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
// Determinar el estado actual
const state = error ? 'error' : success ? 'success' : 'default';
// Determinar si es password y toggle visibility
const isPassword = type === 'password';
const inputType = isPassword && showPassword ? 'text' : type;
// Calcular padding extra para iconos
const paddingLeft = leftIcon ? 'pl-10' : '';
const paddingRight = rightIcon || isPassword ? 'pr-10' : '';
return (
<div className={cn(!fullWidth && 'inline-block', fullWidth && 'w-full')}>
{/* Label */}
{label && (
<label htmlFor={inputId} className={labelStyles}>
{label}
</label>
)}
{/* Input Container */}
<div className="relative">
{/* Left Icon */}
{leftIcon && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500">
{leftIcon}
</span>
)}
{/* Input */}
<input
ref={ref}
id={inputId}
type={inputType}
className={cn(
baseStyles,
stateStyles[state],
sizeStyles[size],
paddingLeft,
paddingRight,
className
)}
aria-invalid={!!error}
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
{...props}
/>
{/* Right Icon or Password Toggle or State Icon */}
<span className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
{isPassword ? (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300 transition-colors"
tabIndex={-1}
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
) : error ? (
<AlertCircle className="h-4 w-4 text-error-500" />
) : success ? (
<CheckCircle className="h-4 w-4 text-success-500" />
) : rightIcon ? (
<span className="text-slate-400 dark:text-slate-500">{rightIcon}</span>
) : null}
</span>
</div>
{/* Error Message */}
{error && (
<p
id={`${inputId}-error`}
className="mt-1.5 text-sm text-error-600 dark:text-error-400"
>
{error}
</p>
)}
{/* Hint */}
{hint && !error && (
<p
id={`${inputId}-hint`}
className="mt-1.5 text-sm text-slate-500 dark:text-slate-400"
>
{hint}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
/**
* Componente TextArea
*/
interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
hint?: string;
fullWidth?: boolean;
}
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
({ className, label, error, hint, fullWidth = true, id, ...props }, ref) => {
const inputId = id || `textarea-${Math.random().toString(36).substr(2, 9)}`;
const state = error ? 'error' : 'default';
return (
<div className={cn(!fullWidth && 'inline-block', fullWidth && 'w-full')}>
{label && (
<label htmlFor={inputId} className={labelStyles}>
{label}
</label>
)}
<textarea
ref={ref}
id={inputId}
className={cn(
baseStyles,
stateStyles[state],
'px-4 py-2.5 text-sm min-h-[100px] resize-y',
className
)}
aria-invalid={!!error}
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
{...props}
/>
{error && (
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-error-600 dark:text-error-400">
{error}
</p>
)}
{hint && !error && (
<p id={`${inputId}-hint`} className="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
{hint}
</p>
)}
</div>
);
}
);
TextArea.displayName = 'TextArea';
export default Input;

View File

@@ -0,0 +1,15 @@
/**
* UI Components Barrel Export
*
* Re-exporta todos los componentes UI para facilitar imports.
* Ejemplo: import { Button, Input, Card } from '@/components/ui';
*/
export { Button, IconButton } from './Button';
export type { } from './Button';
export { Input, TextArea } from './Input';
export type { } from './Input';
export { Card, CardHeader, CardContent, CardFooter, StatsCard } from './Card';
export type { } from './Card';

212
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,212 @@
/**
* Cliente API para comunicación con el backend
*/
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
interface ApiResponse<T = unknown> {
data?: T;
error?: string;
message?: string;
status: number;
}
interface RequestOptions extends Omit<RequestInit, 'body'> {
body?: unknown;
params?: Record<string, string | number | boolean | undefined>;
}
class ApiError extends Error {
status: number;
data?: unknown;
constructor(message: string, status: number, data?: unknown) {
super(message);
this.name = 'ApiError';
this.status = status;
this.data = data;
}
}
/**
* Obtiene el token de autenticación del storage
*/
function getAuthToken(): string | null {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem('horux-auth-storage');
if (stored) {
const parsed = JSON.parse(stored);
return parsed?.state?.token || null;
}
} catch {
return null;
}
return null;
}
/**
* Construye la URL con query params
*/
function buildUrl(endpoint: string, params?: Record<string, string | number | boolean | undefined>): string {
const url = new URL(endpoint.startsWith('http') ? endpoint : `${API_BASE_URL}${endpoint}`);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, String(value));
}
});
}
return url.toString();
}
/**
* Cliente fetch base con manejo de errores y auth
*/
async function fetchApi<T>(endpoint: string, options: RequestOptions = {}): Promise<ApiResponse<T>> {
const { body, params, headers: customHeaders, ...restOptions } = options;
const token = getAuthToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...customHeaders,
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const config: RequestInit = {
...restOptions,
headers,
};
if (body) {
config.body = JSON.stringify(body);
}
try {
const url = buildUrl(endpoint, params);
const response = await fetch(url, config);
let data: T | undefined;
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
data = await response.json();
}
if (!response.ok) {
const errorMessage = (data as { message?: string })?.message ||
(data as { error?: string })?.error ||
`Error ${response.status}`;
throw new ApiError(errorMessage, response.status, data);
}
return {
data,
status: response.status,
};
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
throw new ApiError(
error instanceof Error ? error.message : 'Error de conexión',
0
);
}
}
/**
* API Client con métodos HTTP
*/
export const api = {
get<T>(endpoint: string, options?: RequestOptions): Promise<ApiResponse<T>> {
return fetchApi<T>(endpoint, { ...options, method: 'GET' });
},
post<T>(endpoint: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>> {
return fetchApi<T>(endpoint, { ...options, method: 'POST', body });
},
put<T>(endpoint: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>> {
return fetchApi<T>(endpoint, { ...options, method: 'PUT', body });
},
patch<T>(endpoint: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>> {
return fetchApi<T>(endpoint, { ...options, method: 'PATCH', body });
},
delete<T>(endpoint: string, options?: RequestOptions): Promise<ApiResponse<T>> {
return fetchApi<T>(endpoint, { ...options, method: 'DELETE' });
},
};
/**
* Endpoints tipados para la API
*/
export const endpoints = {
// Auth
auth: {
login: '/auth/login',
register: '/auth/register',
logout: '/auth/logout',
me: '/auth/me',
refresh: '/auth/refresh',
},
// Users
users: {
base: '/users',
byId: (id: string) => `/users/${id}`,
profile: '/users/profile',
},
// Strategies
strategies: {
base: '/strategies',
byId: (id: string) => `/strategies/${id}`,
activate: (id: string) => `/strategies/${id}/activate`,
deactivate: (id: string) => `/strategies/${id}/deactivate`,
backtest: (id: string) => `/strategies/${id}/backtest`,
},
// Trades
trades: {
base: '/trades',
byId: (id: string) => `/trades/${id}`,
active: '/trades/active',
history: '/trades/history',
},
// Portfolio
portfolio: {
base: '/portfolio',
balance: '/portfolio/balance',
positions: '/portfolio/positions',
performance: '/portfolio/performance',
},
// Market Data
market: {
prices: '/market/prices',
ticker: (symbol: string) => `/market/ticker/${symbol}`,
candles: (symbol: string) => `/market/candles/${symbol}`,
},
// Analytics
analytics: {
dashboard: '/analytics/dashboard',
performance: '/analytics/performance',
risk: '/analytics/risk',
},
} as const;
export { ApiError };
export type { ApiResponse, RequestOptions };

125
apps/web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,125 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Combina clases de Tailwind de manera inteligente
* Usa clsx para condicionales y twMerge para resolver conflictos
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* Formatea una fecha a string legible
*/
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
year: 'numeric',
...options,
});
}
/**
* Formatea un número como moneda
*/
export function formatCurrency(amount: number, currency: string = 'USD'): string {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
/**
* Formatea un número con separadores de miles
*/
export function formatNumber(num: number, decimals: number = 0): string {
return new Intl.NumberFormat('es-ES', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(num);
}
/**
* Formatea un porcentaje
*/
export function formatPercentage(value: number, decimals: number = 2): string {
return `${value >= 0 ? '+' : ''}${value.toFixed(decimals)}%`;
}
/**
* Genera un ID único
*/
export function generateId(): string {
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
/**
* Debounce function
*/
export function debounce<T extends (...args: unknown[]) => unknown>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
/**
* Trunca un texto con ellipsis
*/
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length) + '...';
}
/**
* Capitaliza la primera letra
*/
export function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}
/**
* Obtiene las iniciales de un nombre
*/
export function getInitials(name: string): string {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
/**
* Verifica si estamos en el cliente
*/
export const isClient = typeof window !== 'undefined';
/**
* Verifica si estamos en producción
*/
export const isProd = process.env.NODE_ENV === 'production';
/**
* Sleep helper para async/await
*/
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Clamp un número entre min y max
*/
export function clamp(num: number, min: number, max: number): number {
return Math.min(Math.max(num, min), max);
}

View File

@@ -0,0 +1,287 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { api, endpoints, ApiError } from '@/lib/api';
/**
* Tipos para el usuario
*/
interface User {
id: string;
email: string;
name: string;
avatar?: string;
role: 'admin' | 'trader' | 'viewer';
createdAt: string;
lastLoginAt?: string;
}
/**
* Credenciales de login
*/
interface LoginCredentials {
email: string;
password: string;
remember?: boolean;
}
/**
* Datos de registro
*/
interface RegisterData {
email: string;
password: string;
name: string;
}
/**
* Respuesta de autenticación
*/
interface AuthResponse {
user: User;
token: string;
refreshToken?: string;
}
/**
* Estado del store de autenticación
*/
interface AuthState {
// Estado
user: User | null;
token: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
isInitialized: boolean;
// Acciones
login: (credentials: LoginCredentials) => Promise<void>;
register: (data: RegisterData) => Promise<void>;
logout: () => Promise<void>;
refreshSession: () => Promise<void>;
checkAuth: () => Promise<void>;
updateUser: (data: Partial<User>) => void;
clearError: () => void;
setLoading: (loading: boolean) => void;
}
/**
* Store de autenticación con persistencia
*/
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
// Estado inicial
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
error: null,
isInitialized: false,
/**
* Iniciar sesión
*/
login: async (credentials: LoginCredentials) => {
set({ isLoading: true, error: null });
try {
const { data } = await api.post<AuthResponse>(endpoints.auth.login, credentials);
if (data) {
set({
user: data.user,
token: data.token,
refreshToken: data.refreshToken || null,
isAuthenticated: true,
isLoading: false,
error: null,
});
}
} catch (error) {
const message = error instanceof ApiError
? error.message
: 'Error al iniciar sesión';
set({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
error: message,
});
throw error;
}
},
/**
* Registrar nuevo usuario
*/
register: async (data: RegisterData) => {
set({ isLoading: true, error: null });
try {
const { data: authData } = await api.post<AuthResponse>(endpoints.auth.register, data);
if (authData) {
set({
user: authData.user,
token: authData.token,
refreshToken: authData.refreshToken || null,
isAuthenticated: true,
isLoading: false,
error: null,
});
}
} catch (error) {
const message = error instanceof ApiError
? error.message
: 'Error al registrar usuario';
set({
isLoading: false,
error: message,
});
throw error;
}
},
/**
* Cerrar sesión
*/
logout: async () => {
const { token } = get();
try {
if (token) {
await api.post(endpoints.auth.logout);
}
} catch {
// Ignorar errores de logout
} finally {
set({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
error: null,
});
}
},
/**
* Refrescar sesión con refresh token
*/
refreshSession: async () => {
const { refreshToken } = get();
if (!refreshToken) {
throw new Error('No refresh token available');
}
try {
const { data } = await api.post<AuthResponse>(endpoints.auth.refresh, {
refreshToken,
});
if (data) {
set({
token: data.token,
refreshToken: data.refreshToken || refreshToken,
user: data.user,
});
}
} catch (error) {
// Si falla el refresh, cerrar sesión
get().logout();
throw error;
}
},
/**
* Verificar autenticación actual
*/
checkAuth: async () => {
const { token } = get();
if (!token) {
set({ isInitialized: true });
return;
}
set({ isLoading: true });
try {
const { data } = await api.get<User>(endpoints.auth.me);
if (data) {
set({
user: data,
isAuthenticated: true,
isLoading: false,
isInitialized: true,
});
}
} catch (error) {
// Token inválido, limpiar estado
set({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
isInitialized: true,
});
}
},
/**
* Actualizar datos del usuario
*/
updateUser: (data: Partial<User>) => {
const { user } = get();
if (user) {
set({ user: { ...user, ...data } });
}
},
/**
* Limpiar error
*/
clearError: () => set({ error: null }),
/**
* Establecer loading
*/
setLoading: (loading: boolean) => set({ isLoading: loading }),
}),
{
name: 'horux-auth-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
user: state.user,
token: state.token,
refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated,
}),
}
)
);
/**
* Selector para verificar si el usuario es admin
*/
export const selectIsAdmin = (state: AuthState): boolean =>
state.user?.role === 'admin';
/**
* Selector para verificar si el usuario puede hacer trading
*/
export const selectCanTrade = (state: AuthState): boolean =>
state.user?.role === 'admin' || state.user?.role === 'trader';

View File

@@ -0,0 +1,297 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
/**
* Tipos de tema
*/
type Theme = 'light' | 'dark' | 'system';
/**
* Tipo de notificación
*/
interface Notification {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title: string;
message?: string;
duration?: number;
action?: {
label: string;
onClick: () => void;
};
}
/**
* Modal state
*/
interface ModalState {
isOpen: boolean;
type: string | null;
data?: unknown;
}
/**
* Estado del store de UI
*/
interface UIState {
// Sidebar
sidebarOpen: boolean;
sidebarCollapsed: boolean;
// Theme
theme: Theme;
resolvedTheme: 'light' | 'dark';
// Notifications
notifications: Notification[];
// Modal
modal: ModalState;
// Loading states
globalLoading: boolean;
loadingMessage: string | null;
// Mobile
isMobile: boolean;
// Acciones Sidebar
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
toggleSidebarCollapsed: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
// Acciones Theme
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
// Acciones Notifications
addNotification: (notification: Omit<Notification, 'id'>) => void;
removeNotification: (id: string) => void;
clearNotifications: () => void;
// Acciones Modal
openModal: (type: string, data?: unknown) => void;
closeModal: () => void;
// Acciones Loading
setGlobalLoading: (loading: boolean, message?: string) => void;
// Acciones Mobile
setIsMobile: (isMobile: boolean) => void;
}
/**
* Genera un ID único para notificaciones
*/
function generateNotificationId(): string {
return `notification-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
/**
* Detecta el tema del sistema
*/
function getSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined') return 'dark';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
/**
* Store de UI con persistencia parcial
*/
export const useUIStore = create<UIState>()(
persist(
(set, get) => ({
// Estado inicial
sidebarOpen: true,
sidebarCollapsed: false,
theme: 'dark',
resolvedTheme: 'dark',
notifications: [],
modal: {
isOpen: false,
type: null,
data: undefined,
},
globalLoading: false,
loadingMessage: null,
isMobile: false,
// Sidebar
toggleSidebar: () => {
set((state) => ({ sidebarOpen: !state.sidebarOpen }));
},
setSidebarOpen: (open: boolean) => {
set({ sidebarOpen: open });
},
toggleSidebarCollapsed: () => {
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed }));
},
setSidebarCollapsed: (collapsed: boolean) => {
set({ sidebarCollapsed: collapsed });
},
// Theme
setTheme: (theme: Theme) => {
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
// Aplicar clase al documento
if (typeof document !== 'undefined') {
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolvedTheme);
}
set({ theme, resolvedTheme });
},
toggleTheme: () => {
const { theme } = get();
const newTheme = theme === 'dark' ? 'light' : 'dark';
get().setTheme(newTheme);
},
// Notifications
addNotification: (notification) => {
const id = generateNotificationId();
const newNotification: Notification = {
...notification,
id,
duration: notification.duration ?? 5000,
};
set((state) => ({
notifications: [...state.notifications, newNotification],
}));
// Auto-remove after duration
if (newNotification.duration && newNotification.duration > 0) {
setTimeout(() => {
get().removeNotification(id);
}, newNotification.duration);
}
},
removeNotification: (id: string) => {
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
}));
},
clearNotifications: () => {
set({ notifications: [] });
},
// Modal
openModal: (type: string, data?: unknown) => {
set({
modal: {
isOpen: true,
type,
data,
},
});
},
closeModal: () => {
set({
modal: {
isOpen: false,
type: null,
data: undefined,
},
});
},
// Loading
setGlobalLoading: (loading: boolean, message?: string) => {
set({
globalLoading: loading,
loadingMessage: loading ? (message || null) : null,
});
},
// Mobile
setIsMobile: (isMobile: boolean) => {
set({ isMobile });
// Cerrar sidebar en mobile
if (isMobile) {
set({ sidebarOpen: false });
}
},
}),
{
name: 'horux-ui-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
theme: state.theme,
sidebarCollapsed: state.sidebarCollapsed,
}),
onRehydrateStorage: () => (state) => {
// Aplicar tema al rehidratar
if (state) {
const resolvedTheme = state.theme === 'system' ? getSystemTheme() : state.theme;
if (typeof document !== 'undefined') {
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolvedTheme);
}
state.resolvedTheme = resolvedTheme;
}
},
}
)
);
/**
* Hook helper para notificaciones
*/
export const useNotifications = () => {
const { addNotification, removeNotification, clearNotifications, notifications } = useUIStore();
return {
notifications,
notify: addNotification,
remove: removeNotification,
clear: clearNotifications,
success: (title: string, message?: string) =>
addNotification({ type: 'success', title, message }),
error: (title: string, message?: string) =>
addNotification({ type: 'error', title, message }),
warning: (title: string, message?: string) =>
addNotification({ type: 'warning', title, message }),
info: (title: string, message?: string) =>
addNotification({ type: 'info', title, message }),
};
};
/**
* Hook helper para modales
*/
export const useModal = () => {
const { modal, openModal, closeModal } = useUIStore();
return {
...modal,
open: openModal,
close: closeModal,
};
};
/**
* Hook helper para el tema
*/
export const useTheme = () => {
const { theme, resolvedTheme, setTheme, toggleTheme } = useUIStore();
return {
theme,
resolvedTheme,
setTheme,
toggleTheme,
isDark: resolvedTheme === 'dark',
isLight: resolvedTheme === 'light',
};
};

152
apps/web/tailwind.config.js Normal file
View File

@@ -0,0 +1,152 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
// Horux Brand Colors
horux: {
50: '#f0f7ff',
100: '#e0effe',
200: '#b9dffd',
300: '#7cc5fb',
400: '#36a7f7',
500: '#0c8ce8',
600: '#006fc6',
700: '#0159a1',
800: '#064c85',
900: '#0b406e',
950: '#072849',
},
// Primary (usando horux como primario)
primary: {
50: '#f0f7ff',
100: '#e0effe',
200: '#b9dffd',
300: '#7cc5fb',
400: '#36a7f7',
500: '#0c8ce8',
600: '#006fc6',
700: '#0159a1',
800: '#064c85',
900: '#0b406e',
950: '#072849',
},
// Grises personalizados
slate: {
850: '#172033',
950: '#0a0f1a',
},
// Estados
success: {
50: '#ecfdf5',
100: '#d1fae5',
200: '#a7f3d0',
300: '#6ee7b7',
400: '#34d399',
500: '#10b981',
600: '#059669',
700: '#047857',
800: '#065f46',
900: '#064e3b',
},
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
},
error: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
},
fontSize: {
'2xs': ['0.625rem', { lineHeight: '0.875rem' }],
},
boxShadow: {
'glow': '0 0 20px rgba(12, 140, 232, 0.3)',
'glow-lg': '0 0 40px rgba(12, 140, 232, 0.4)',
'inner-glow': 'inset 0 0 20px rgba(12, 140, 232, 0.1)',
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
'horux-gradient': 'linear-gradient(135deg, #0c8ce8 0%, #006fc6 50%, #0159a1 100%)',
'horux-gradient-dark': 'linear-gradient(135deg, #072849 0%, #0b406e 50%, #064c85 100%)',
},
animation: {
'fade-in': 'fadeIn 0.3s ease-in-out',
'fade-out': 'fadeOut 0.3s ease-in-out',
'slide-in': 'slideIn 0.3s ease-out',
'slide-out': 'slideOut 0.3s ease-in',
'scale-in': 'scaleIn 0.2s ease-out',
'spin-slow': 'spin 2s linear infinite',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'bounce-slow': 'bounce 2s infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
fadeOut: {
'0%': { opacity: '1' },
'100%': { opacity: '0' },
},
slideIn: {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(0)' },
},
slideOut: {
'0%': { transform: 'translateX(0)' },
'100%': { transform: 'translateX(-100%)' },
},
scaleIn: {
'0%': { transform: 'scale(0.95)', opacity: '0' },
'100%': { transform: 'scale(1)', opacity: '1' },
},
},
borderRadius: {
'4xl': '2rem',
},
spacing: {
'18': '4.5rem',
'88': '22rem',
'112': '28rem',
'128': '32rem',
},
zIndex: {
'60': '60',
'70': '70',
'80': '80',
'90': '90',
'100': '100',
},
},
},
plugins: [],
};

39
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,39 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/stores/*": ["./src/stores/*"],
"@/hooks/*": ["./src/hooks/*"],
"@/types/*": ["./src/types/*"]
},
"target": "ES2017",
"forceConsistentCasingInFileNames": true,
"baseUrl": "."
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
}