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:
205
apps/api/src/config/index.ts
Normal file
205
apps/api/src/config/index.ts
Normal 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
281
apps/api/src/index.ts
Normal 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;
|
||||
131
apps/api/src/middleware/auth.middleware.ts
Normal file
131
apps/api/src/middleware/auth.middleware.ts
Normal 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;
|
||||
178
apps/api/src/middleware/auth.ts
Normal file
178
apps/api/src/middleware/auth.ts
Normal 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;
|
||||
281
apps/api/src/middleware/errorHandler.ts
Normal file
281
apps/api/src/middleware/errorHandler.ts
Normal 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;
|
||||
16
apps/api/src/middleware/index.ts
Normal file
16
apps/api/src/middleware/index.ts
Normal 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';
|
||||
203
apps/api/src/middleware/tenant.ts
Normal file
203
apps/api/src/middleware/tenant.ts
Normal 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;
|
||||
70
apps/api/src/middleware/validate.middleware.ts
Normal file
70
apps/api/src/middleware/validate.middleware.ts
Normal 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;
|
||||
453
apps/api/src/routes/alerts.routes.ts
Normal file
453
apps/api/src/routes/alerts.routes.ts
Normal 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;
|
||||
324
apps/api/src/routes/auth.routes.ts
Normal file
324
apps/api/src/routes/auth.routes.ts
Normal 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;
|
||||
596
apps/api/src/routes/categories.routes.ts
Normal file
596
apps/api/src/routes/categories.routes.ts
Normal 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;
|
||||
514
apps/api/src/routes/cfdis.routes.ts
Normal file
514
apps/api/src/routes/cfdis.routes.ts
Normal 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;
|
||||
587
apps/api/src/routes/contacts.routes.ts
Normal file
587
apps/api/src/routes/contacts.routes.ts
Normal 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;
|
||||
284
apps/api/src/routes/health.routes.ts
Normal file
284
apps/api/src/routes/health.routes.ts
Normal 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;
|
||||
9
apps/api/src/routes/index.ts
Normal file
9
apps/api/src/routes/index.ts
Normal 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';
|
||||
819
apps/api/src/routes/integrations.routes.ts
Normal file
819
apps/api/src/routes/integrations.routes.ts
Normal 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;
|
||||
720
apps/api/src/routes/metrics.routes.ts
Normal file
720
apps/api/src/routes/metrics.routes.ts
Normal 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;
|
||||
617
apps/api/src/routes/transactions.routes.ts
Normal file
617
apps/api/src/routes/transactions.routes.ts
Normal 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;
|
||||
754
apps/api/src/services/auth.service.ts
Normal file
754
apps/api/src/services/auth.service.ts
Normal 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;
|
||||
17
apps/api/src/services/index.ts
Normal file
17
apps/api/src/services/index.ts
Normal 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';
|
||||
289
apps/api/src/services/jwt.service.ts
Normal file
289
apps/api/src/services/jwt.service.ts
Normal 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;
|
||||
729
apps/api/src/services/metrics/anomaly.detector.ts
Normal file
729
apps/api/src/services/metrics/anomaly.detector.ts
Normal 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);
|
||||
}
|
||||
891
apps/api/src/services/metrics/core.metrics.ts
Normal file
891
apps/api/src/services/metrics/core.metrics.ts
Normal 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);
|
||||
}
|
||||
554
apps/api/src/services/metrics/enterprise.metrics.ts
Normal file
554
apps/api/src/services/metrics/enterprise.metrics.ts
Normal 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);
|
||||
}
|
||||
504
apps/api/src/services/metrics/metrics.cache.ts
Normal file
504
apps/api/src/services/metrics/metrics.cache.ts
Normal 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);
|
||||
}
|
||||
622
apps/api/src/services/metrics/metrics.service.ts
Normal file
622
apps/api/src/services/metrics/metrics.service.ts
Normal 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);
|
||||
}
|
||||
734
apps/api/src/services/metrics/metrics.types.ts
Normal file
734
apps/api/src/services/metrics/metrics.types.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
678
apps/api/src/services/metrics/startup.metrics.ts
Normal file
678
apps/api/src/services/metrics/startup.metrics.ts
Normal 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);
|
||||
}
|
||||
1161
apps/api/src/services/sat/cfdi.parser.ts
Normal file
1161
apps/api/src/services/sat/cfdi.parser.ts
Normal file
File diff suppressed because it is too large
Load Diff
539
apps/api/src/services/sat/fiel.service.ts
Normal file
539
apps/api/src/services/sat/fiel.service.ts
Normal 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);
|
||||
}
|
||||
803
apps/api/src/services/sat/sat.client.ts
Normal file
803
apps/api/src/services/sat/sat.client.ts
Normal 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);
|
||||
}
|
||||
871
apps/api/src/services/sat/sat.service.ts
Normal file
871
apps/api/src/services/sat/sat.service.ts
Normal 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);
|
||||
}
|
||||
817
apps/api/src/services/sat/sat.types.ts
Normal file
817
apps/api/src/services/sat/sat.types.ts
Normal 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
271
apps/api/src/types/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
55
apps/api/src/utils/asyncHandler.ts
Normal file
55
apps/api/src/utils/asyncHandler.ts
Normal 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;
|
||||
3
apps/api/src/utils/index.ts
Normal file
3
apps/api/src/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Utils exports
|
||||
export { logger, httpLogger, createContextLogger, auditLog } from './logger.js';
|
||||
export { asyncHandler, asyncHandlerTyped, asyncMiddleware } from './asyncHandler.js';
|
||||
147
apps/api/src/utils/logger.ts
Normal file
147
apps/api/src/utils/logger.ts
Normal 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;
|
||||
Reference in New Issue
Block a user