diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f3bdbcb --- /dev/null +++ b/.env.example @@ -0,0 +1,173 @@ +# ============================================================================= +# Horux Strategy - Variables de Entorno +# ============================================================================= +# Copia este archivo a .env y configura los valores segun tu entorno. +# +# IMPORTANTE: Nunca subas el archivo .env a control de versiones. +# Contiene secretos y credenciales sensibles. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Entorno +# ----------------------------------------------------------------------------- +NODE_ENV=development +APP_NAME=HoruxStrategy +APP_VERSION=0.1.0 + +# ----------------------------------------------------------------------------- +# URLs de la aplicacion +# ----------------------------------------------------------------------------- +# URL base del frontend (Next.js) +APP_URL=http://localhost:3000 + +# URL base del API (Express) +API_URL=http://localhost:4000 + +# Puerto del servidor API +API_PORT=4000 + +# ----------------------------------------------------------------------------- +# Base de datos PostgreSQL +# ----------------------------------------------------------------------------- +# Formato: postgresql://usuario:password@host:puerto/database +DATABASE_URL=postgresql://horux:horux_secret_2024@localhost:5432/horux_strategy + +# Configuracion individual (alternativa a DATABASE_URL) +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_USER=horux +POSTGRES_PASSWORD=horux_secret_2024 +POSTGRES_DB=horux_strategy + +# Usuario de aplicacion (con permisos limitados) +POSTGRES_APP_USER=horux_app +POSTGRES_APP_PASSWORD=horux_app_secret + +# Pool de conexiones +DATABASE_POOL_MIN=2 +DATABASE_POOL_MAX=10 + +# ----------------------------------------------------------------------------- +# Redis +# ----------------------------------------------------------------------------- +# Formato: redis://[:password@]host:puerto[/database] +REDIS_URL=redis://localhost:6379/0 + +# Configuracion individual +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +# ----------------------------------------------------------------------------- +# MinIO (Almacenamiento S3-compatible) +# ----------------------------------------------------------------------------- +MINIO_ENDPOINT=localhost +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=horux_minio +MINIO_SECRET_KEY=horux_minio_secret + +# Nombres de buckets +MINIO_BUCKET_REPORTS=horux-reports +MINIO_BUCKET_ATTACHMENTS=horux-attachments +MINIO_BUCKET_EXPORTS=horux-exports + +# Configuracion de consola MinIO +MINIO_CONSOLE_PORT=9001 +MINIO_ROOT_USER=horux_minio +MINIO_ROOT_PASSWORD=horux_minio_secret + +# ----------------------------------------------------------------------------- +# Autenticacion JWT +# ----------------------------------------------------------------------------- +# Secreto para tokens de acceso (genera uno seguro en produccion) +# Puedes generar uno con: openssl rand -base64 64 +JWT_SECRET=horux_jwt_secret_change_in_production_abc123xyz + +# Secreto para refresh tokens (diferente al de acceso) +JWT_REFRESH_SECRET=horux_refresh_secret_change_in_production_xyz789abc + +# Tiempo de expiracion de tokens +JWT_ACCESS_EXPIRES_IN=15m +JWT_REFRESH_EXPIRES_IN=7d + +# Algoritmo de firma +JWT_ALGORITHM=HS256 + +# ----------------------------------------------------------------------------- +# DeepSeek AI (Asistente financiero) +# ----------------------------------------------------------------------------- +# API Key de DeepSeek para el CFO digital +DEEPSEEK_API_KEY=your_deepseek_api_key_here +DEEPSEEK_BASE_URL=https://api.deepseek.com +DEEPSEEK_MODEL=deepseek-chat + +# Configuracion de rate limiting para AI +AI_RATE_LIMIT_REQUESTS=100 +AI_RATE_LIMIT_WINDOW_MS=60000 + +# ----------------------------------------------------------------------------- +# Email (SMTP) +# ----------------------------------------------------------------------------- +# En desarrollo usamos Mailhog (no envia emails reales) +SMTP_HOST=localhost +SMTP_PORT=1025 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASSWORD= + +# Configuracion de emails +EMAIL_FROM_NAME=Horux Strategy +EMAIL_FROM_ADDRESS=noreply@horuxstrategy.com + +# URL de Mailhog UI (solo desarrollo) +MAILHOG_UI_PORT=8025 + +# ----------------------------------------------------------------------------- +# Seguridad +# ----------------------------------------------------------------------------- +# Secreto para encriptar datos sensibles +ENCRYPTION_KEY=32_character_encryption_key_here + +# Configuracion de CORS +CORS_ORIGIN=http://localhost:3000 + +# Rate limiting global +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX_REQUESTS=100 + +# Configuracion de cookies +COOKIE_SECURE=false +COOKIE_SAME_SITE=lax + +# ----------------------------------------------------------------------------- +# Logging +# ----------------------------------------------------------------------------- +LOG_LEVEL=debug +LOG_FORMAT=pretty + +# Sentry (opcional - para monitoreo de errores) +SENTRY_DSN= +SENTRY_ENVIRONMENT=development + +# ----------------------------------------------------------------------------- +# Feature Flags (desarrollo) +# ----------------------------------------------------------------------------- +FEATURE_AI_ASSISTANT=true +FEATURE_MULTI_COMPANY=true +FEATURE_SAT_INTEGRATION=false +FEATURE_BANKING_INTEGRATION=false + +# ----------------------------------------------------------------------------- +# Integraciones externas (para futuro) +# ----------------------------------------------------------------------------- +# SAT (Servicio de Administracion Tributaria) +SAT_API_URL= +SAT_CERTIFICATE_PATH= +SAT_PRIVATE_KEY_PATH= + +# Bancos (Open Banking) +BANKING_API_URL= +BANKING_CLIENT_ID= +BANKING_CLIENT_SECRET= diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..059edac --- /dev/null +++ b/Makefile @@ -0,0 +1,236 @@ +# ============================================================================= +# Horux Strategy - Makefile +# ============================================================================= +# Comandos utiles para el desarrollo del proyecto. +# +# Uso: +# make help - Ver todos los comandos disponibles +# make dev - Levantar entorno de desarrollo +# make down - Detener servicios +# ============================================================================= + +# Variables +DOCKER_COMPOSE = docker compose +PNPM = pnpm + +# Colores para output +GREEN := $(shell tput -Txterm setaf 2) +YELLOW := $(shell tput -Txterm setaf 3) +BLUE := $(shell tput -Txterm setaf 4) +RESET := $(shell tput -Txterm sgr0) + +# Default target +.DEFAULT_GOAL := help + +# ============================================================================= +# Ayuda +# ============================================================================= + +.PHONY: help +help: ## Mostrar esta ayuda + @echo '' + @echo '${BLUE}Horux Strategy - Comandos disponibles${RESET}' + @echo '' + @echo '${YELLOW}Desarrollo:${RESET}' + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " ${GREEN}%-15s${RESET} %s\n", $$1, $$2}' + @echo '' + +# ============================================================================= +# Docker y Servicios +# ============================================================================= + +.PHONY: dev +dev: ## Levantar todos los servicios de desarrollo + @echo "${BLUE}Levantando servicios de desarrollo...${RESET}" + @./scripts/dev-setup.sh --skip-migrations + @echo "${GREEN}Servicios listos!${RESET}" + +.PHONY: dev-full +dev-full: ## Setup completo (incluye migraciones) + @echo "${BLUE}Ejecutando setup completo...${RESET}" + @./scripts/dev-setup.sh + +.PHONY: up +up: ## Levantar contenedores Docker (sin setup) + @echo "${BLUE}Levantando contenedores...${RESET}" + @$(DOCKER_COMPOSE) up -d + @echo "${GREEN}Contenedores levantados${RESET}" + +.PHONY: down +down: ## Detener todos los servicios (mantiene datos) + @echo "${BLUE}Deteniendo servicios...${RESET}" + @./scripts/dev-down.sh + @echo "${GREEN}Servicios detenidos${RESET}" + +.PHONY: down-clean +down-clean: ## Detener servicios y eliminar volumenes + @./scripts/dev-down.sh --clean + +.PHONY: restart +restart: down up ## Reiniciar todos los servicios + +.PHONY: logs +logs: ## Ver logs de todos los servicios + @$(DOCKER_COMPOSE) logs -f + +.PHONY: logs-api +logs-api: ## Ver logs solo del API (si esta en Docker) + @$(DOCKER_COMPOSE) logs -f api 2>/dev/null || echo "API no esta en Docker, usa: pnpm --filter @horux/api dev" + +.PHONY: logs-postgres +logs-postgres: ## Ver logs de PostgreSQL + @$(DOCKER_COMPOSE) logs -f postgres + +.PHONY: logs-redis +logs-redis: ## Ver logs de Redis + @$(DOCKER_COMPOSE) logs -f redis + +.PHONY: logs-minio +logs-minio: ## Ver logs de MinIO + @$(DOCKER_COMPOSE) logs -f minio + +.PHONY: status +status: ## Ver estado de los contenedores + @echo "${BLUE}Estado de contenedores:${RESET}" + @$(DOCKER_COMPOSE) ps + +# ============================================================================= +# Base de Datos +# ============================================================================= + +.PHONY: migrate +migrate: ## Ejecutar migraciones de base de datos + @echo "${BLUE}Ejecutando migraciones...${RESET}" + @$(PNPM) db:migrate:dev + @echo "${GREEN}Migraciones completadas${RESET}" + +.PHONY: migrate-prod +migrate-prod: ## Ejecutar migraciones (produccion) + @echo "${BLUE}Ejecutando migraciones de produccion...${RESET}" + @$(PNPM) db:migrate + @echo "${GREEN}Migraciones completadas${RESET}" + +.PHONY: seed +seed: ## Ejecutar seeds (datos de prueba) + @echo "${BLUE}Ejecutando seeds...${RESET}" + @$(PNPM) db:seed + @echo "${GREEN}Seeds completados${RESET}" + +.PHONY: db-reset +db-reset: ## Resetear base de datos (cuidado!) + @echo "${YELLOW}Esto eliminara todos los datos de la base de datos${RESET}" + @read -p "Estas seguro? (y/N): " confirm && [ "$$confirm" = "y" ] || exit 1 + @$(DOCKER_COMPOSE) exec -T postgres psql -U horux -d horux_strategy -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" + @$(PNPM) db:migrate:dev + @echo "${GREEN}Base de datos reseteada${RESET}" + +.PHONY: db-shell +db-shell: ## Abrir shell de PostgreSQL + @$(DOCKER_COMPOSE) exec postgres psql -U horux -d horux_strategy + +.PHONY: redis-shell +redis-shell: ## Abrir shell de Redis + @$(DOCKER_COMPOSE) exec redis redis-cli + +# ============================================================================= +# Desarrollo de Aplicacion +# ============================================================================= + +.PHONY: install +install: ## Instalar dependencias con pnpm + @echo "${BLUE}Instalando dependencias...${RESET}" + @$(PNPM) install + @echo "${GREEN}Dependencias instaladas${RESET}" + +.PHONY: start +start: ## Iniciar aplicacion en modo desarrollo + @echo "${BLUE}Iniciando aplicacion...${RESET}" + @$(PNPM) dev + +.PHONY: build +build: ## Compilar la aplicacion + @echo "${BLUE}Compilando aplicacion...${RESET}" + @$(PNPM) build + @echo "${GREEN}Compilacion completada${RESET}" + +.PHONY: test +test: ## Ejecutar tests + @echo "${BLUE}Ejecutando tests...${RESET}" + @$(PNPM) test + +.PHONY: test-watch +test-watch: ## Ejecutar tests en modo watch + @$(PNPM) test:watch + +.PHONY: lint +lint: ## Ejecutar linter + @echo "${BLUE}Ejecutando linter...${RESET}" + @$(PNPM) lint + +.PHONY: lint-fix +lint-fix: ## Ejecutar linter y corregir errores + @echo "${BLUE}Corrigiendo errores de lint...${RESET}" + @$(PNPM) lint:fix + +.PHONY: format +format: ## Formatear codigo con Prettier + @echo "${BLUE}Formateando codigo...${RESET}" + @$(PNPM) format + +.PHONY: typecheck +typecheck: ## Verificar tipos de TypeScript + @echo "${BLUE}Verificando tipos...${RESET}" + @$(PNPM) typecheck + +# ============================================================================= +# Limpieza +# ============================================================================= + +.PHONY: clean +clean: ## Limpiar artefactos de build + @echo "${BLUE}Limpiando artefactos...${RESET}" + @$(PNPM) clean + @echo "${GREEN}Limpieza completada${RESET}" + +.PHONY: clean-all +clean-all: down-clean clean ## Limpiar todo (contenedores + artefactos) + @echo "${GREEN}Limpieza completa realizada${RESET}" + +.PHONY: prune +prune: ## Limpiar recursos de Docker no utilizados + @echo "${BLUE}Limpiando recursos de Docker...${RESET}" + @docker system prune -f + @echo "${GREEN}Recursos limpiados${RESET}" + +# ============================================================================= +# Utilidades +# ============================================================================= + +.PHONY: env +env: ## Crear archivo .env desde .env.example + @if [ -f .env ]; then \ + echo "${YELLOW}El archivo .env ya existe${RESET}"; \ + else \ + cp .env.example .env; \ + echo "${GREEN}Archivo .env creado${RESET}"; \ + fi + +.PHONY: minio-console +minio-console: ## Abrir consola de MinIO en el navegador + @echo "${BLUE}Abriendo consola de MinIO...${RESET}" + @open http://localhost:9001 2>/dev/null || xdg-open http://localhost:9001 2>/dev/null || echo "Abre http://localhost:9001 en tu navegador" + +.PHONY: mailhog +mailhog: ## Abrir Mailhog en el navegador + @echo "${BLUE}Abriendo Mailhog...${RESET}" + @open http://localhost:8025 2>/dev/null || xdg-open http://localhost:8025 2>/dev/null || echo "Abre http://localhost:8025 en tu navegador" + +.PHONY: check-deps +check-deps: ## Verificar dependencias del sistema + @echo "${BLUE}Verificando dependencias...${RESET}" + @echo "" + @echo "Docker: $$(docker --version 2>/dev/null || echo 'No instalado')" + @echo "Docker Compose: $$(docker compose version 2>/dev/null || echo 'No instalado')" + @echo "Node.js: $$(node --version 2>/dev/null || echo 'No instalado')" + @echo "pnpm: $$(pnpm --version 2>/dev/null || echo 'No instalado')" + @echo "" diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..abf4610 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,58 @@ +# ============================================================================ +# Horux Strategy API - Environment Variables +# ============================================================================ +# Copy this file to .env and fill in the values + +# Server Configuration +NODE_ENV=development +PORT=4000 +HOST=0.0.0.0 +API_VERSION=v1 + +# Database (PostgreSQL) +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/horux_strategy +DATABASE_POOL_MIN=2 +DATABASE_POOL_MAX=10 + +# Redis +REDIS_URL=redis://localhost:6379 + +# JWT Secrets (minimum 32 characters each) +# IMPORTANT: Generate secure random strings for production! +JWT_ACCESS_SECRET=your-access-secret-at-least-32-characters +JWT_REFRESH_SECRET=your-refresh-secret-at-least-32-characters +JWT_ACCESS_EXPIRES_IN=15m +JWT_REFRESH_EXPIRES_IN=7d + +# Password Reset +PASSWORD_RESET_SECRET=your-reset-secret-at-least-32-characters! +PASSWORD_RESET_EXPIRES_IN=1h + +# Security +BCRYPT_ROUNDS=12 +CORS_ORIGINS=http://localhost:3000,http://localhost:5173 +RATE_LIMIT_WINDOW_MS=60000 +RATE_LIMIT_MAX_REQUESTS=100 + +# MinIO / S3 Object Storage +MINIO_ENDPOINT=localhost +MINIO_PORT=9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET=horux-strategy +MINIO_USE_SSL=false + +# Email (SMTP) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +EMAIL_FROM=noreply@horuxstrategy.com + +# Logging +LOG_LEVEL=info +LOG_FORMAT=simple + +# Feature Flags +ENABLE_SWAGGER=true +ENABLE_METRICS=true diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..3d981c9 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,51 @@ +{ + "name": "@horux/api", + "version": "0.1.0", + "private": true, + "description": "Horux Strategy Backend API", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "lint": "eslint src --ext .ts", + "lint:fix": "eslint src --ext .ts --fix", + "typecheck": "tsc --noEmit", + "test": "vitest", + "test:watch": "vitest --watch", + "clean": "rm -rf dist node_modules" + }, + "dependencies": { + "@horux/database": "workspace:*", + "@horux/shared": "workspace:*", + "bcryptjs": "^2.4.3", + "bullmq": "^5.1.0", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dotenv": "^16.4.0", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "helmet": "^7.1.0", + "ioredis": "^5.3.2", + "jsonwebtoken": "^9.0.2", + "minio": "^7.1.3", + "pg": "^8.11.3", + "uuid": "^9.0.1", + "winston": "^3.11.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/compression": "^1.7.5", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.5", + "@types/node": "^20.11.0", + "@types/pg": "^8.10.9", + "@types/uuid": "^9.0.7", + "eslint": "^8.56.0", + "tsx": "^4.7.0", + "typescript": "^5.3.3", + "vitest": "^1.2.0" + } +} diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts new file mode 100644 index 0000000..253585c --- /dev/null +++ b/apps/api/src/config/index.ts @@ -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; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..d22e253 --- /dev/null +++ b/apps/api/src/index.ts @@ -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 => { + 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 => { + 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; diff --git a/apps/api/src/middleware/auth.middleware.ts b/apps/api/src/middleware/auth.middleware.ts new file mode 100644 index 0000000..b60f601 --- /dev/null +++ b/apps/api/src/middleware/auth.middleware.ts @@ -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 => { + 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 => { + 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; diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts new file mode 100644 index 0000000..f2552d1 --- /dev/null +++ b/apps/api/src/middleware/auth.ts @@ -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 => { + 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 '); + } + + 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 => { + 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; diff --git a/apps/api/src/middleware/errorHandler.ts b/apps/api/src/middleware/errorHandler.ts new file mode 100644 index 0000000..100800c --- /dev/null +++ b/apps/api/src/middleware/errorHandler.ts @@ -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 => { + const errors: Record = {}; + + 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, + 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 +) => { + return (req: Request, res: Response, next: NextFunction): void => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +}; + +export default errorHandler; diff --git a/apps/api/src/middleware/index.ts b/apps/api/src/middleware/index.ts new file mode 100644 index 0000000..831106b --- /dev/null +++ b/apps/api/src/middleware/index.ts @@ -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'; diff --git a/apps/api/src/middleware/tenant.ts b/apps/api/src/middleware/tenant.ts new file mode 100644 index 0000000..befbc43 --- /dev/null +++ b/apps/api/src/middleware/tenant.ts @@ -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; + } + } +} + +// ============================================================================ +// 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 => { + 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 ( + schemaName: string, + queryText: string, + values?: unknown[] +): Promise => { + 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 ( + schemaName: string, + callback: (client: ReturnType extends Promise ? C : never) => Promise +): Promise => { + 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 => { + 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; diff --git a/apps/api/src/middleware/validate.middleware.ts b/apps/api/src/middleware/validate.middleware.ts new file mode 100644 index 0000000..c847d2b --- /dev/null +++ b/apps/api/src/middleware/validate.middleware.ts @@ -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 => { + 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 + ); + + 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; diff --git a/apps/api/src/routes/alerts.routes.ts b/apps/api/src/routes/alerts.routes.ts new file mode 100644 index 0000000..3baca36 --- /dev/null +++ b/apps/api/src/routes/alerts.routes.ts @@ -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; + 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; diff --git a/apps/api/src/routes/auth.routes.ts b/apps/api/src/routes/auth.routes.ts new file mode 100644 index 0000000..ffec232 --- /dev/null +++ b/apps/api/src/routes/auth.routes.ts @@ -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 = (data: T, meta?: Record): ApiResponse => ({ + 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; diff --git a/apps/api/src/routes/categories.routes.ts b/apps/api/src/routes/categories.routes.ts new file mode 100644 index 0000000..ddb40b6 --- /dev/null +++ b/apps/api/src/routes/categories.routes.ts @@ -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; + 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(); + 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; + 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; + 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 = { + 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; diff --git a/apps/api/src/routes/cfdis.routes.ts b/apps/api/src/routes/cfdis.routes.ts new file mode 100644 index 0000000..351e6ed --- /dev/null +++ b/apps/api/src/routes/cfdis.routes.ts @@ -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; + 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; + 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; + 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; diff --git a/apps/api/src/routes/contacts.routes.ts b/apps/api/src/routes/contacts.routes.ts new file mode 100644 index 0000000..b58707d --- /dev/null +++ b/apps/api/src/routes/contacts.routes.ts @@ -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; + 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; + 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; + 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 = { + 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; + 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; diff --git a/apps/api/src/routes/health.routes.ts b/apps/api/src/routes/health.routes.ts new file mode 100644 index 0000000..d54fee8 --- /dev/null +++ b/apps/api/src/routes/health.routes.ts @@ -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; +} + +// ============================================================================ +// Health Check Functions +// ============================================================================ + +/** + * Check database connectivity + */ +const checkDatabase = async (): Promise => { + 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 => { + 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; diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts new file mode 100644 index 0000000..4a3eebd --- /dev/null +++ b/apps/api/src/routes/index.ts @@ -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'; diff --git a/apps/api/src/routes/integrations.routes.ts b/apps/api/src/routes/integrations.routes.ts new file mode 100644 index 0000000..4d38a8b --- /dev/null +++ b/apps/api/src/routes/integrations.routes.ts @@ -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; + 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; + 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; + 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; diff --git a/apps/api/src/routes/metrics.routes.ts b/apps/api/src/routes/metrics.routes.ts new file mode 100644 index 0000000..6d842bb --- /dev/null +++ b/apps/api/src/routes/metrics.routes.ts @@ -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, + tenant: TenantContext, + code: string, + startDate: string, + endDate: string +): Promise => { + 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; + 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), + 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; + 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; + const { startDate, endDate } = req.query as z.infer; + 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; + const { startDate, endDate, granularity } = req.query as z.infer; + 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; diff --git a/apps/api/src/routes/transactions.routes.ts b/apps/api/src/routes/transactions.routes.ts new file mode 100644 index 0000000..34661b6 --- /dev/null +++ b/apps/api/src/routes/transactions.routes.ts @@ -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) => { + 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; + 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; + 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; + 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; + 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; diff --git a/apps/api/src/services/auth.service.ts b/apps/api/src/services/auth.service.ts new file mode 100644 index 0000000..d8ed531 --- /dev/null +++ b/apps/api/src/services/auth.service.ts @@ -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 => { + return bcrypt.hash(password, config.security.bcryptRounds); +}; + +/** + * Compare password with hash + */ +const comparePassword = async (password: string, hash: string): Promise => { + 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; 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; tenant: Partial; 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 & { tenant: Partial }> { + 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 { + // 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; diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts new file mode 100644 index 0000000..026cc4e --- /dev/null +++ b/apps/api/src/services/index.ts @@ -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'; diff --git a/apps/api/src/services/jwt.service.ts b/apps/api/src/services/jwt.service.ts new file mode 100644 index 0000000..a0c4b49 --- /dev/null +++ b/apps/api/src/services/jwt.service.ts @@ -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): 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; diff --git a/apps/api/src/services/metrics/anomaly.detector.ts b/apps/api/src/services/metrics/anomaly.detector.ts new file mode 100644 index 0000000..a4a64f8 --- /dev/null +++ b/apps/api/src/services/metrics/anomaly.detector.ts @@ -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> = { + 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 + ) { + 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 { + 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 { + 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 = { + 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>> = { + 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 = { + 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 = { 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): 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 +): AnomalyDetector { + if (!anomalyDetectorInstance) { + anomalyDetectorInstance = new AnomalyDetector(db, metricsService, rules); + } + return anomalyDetectorInstance; +} + +export function createAnomalyDetector( + db: DatabaseConnection, + metricsService?: MetricsService, + rules?: Partial +): AnomalyDetector { + return new AnomalyDetector(db, metricsService, rules); +} diff --git a/apps/api/src/services/metrics/core.metrics.ts b/apps/api/src/services/metrics/core.metrics.ts new file mode 100644 index 0000000..0284e0f --- /dev/null +++ b/apps/api/src/services/metrics/core.metrics.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = { + '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(); + + 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; + 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 { + 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(); + + 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); +} diff --git a/apps/api/src/services/metrics/enterprise.metrics.ts b/apps/api/src/services/metrics/enterprise.metrics.ts new file mode 100644 index 0000000..fe33f48 --- /dev/null +++ b/apps/api/src/services/metrics/enterprise.metrics.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/apps/api/src/services/metrics/metrics.cache.ts b/apps/api/src/services/metrics/metrics.cache.ts new file mode 100644 index 0000000..472ae3c --- /dev/null +++ b/apps/api/src/services/metrics/metrics.cache.ts @@ -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 = { + // 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) { + 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( + tenantId: string, + metric: MetricType, + period: MetricPeriod + ): Promise { + 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( + tenantId: string, + metric: MetricType, + period: MetricPeriod, + value: T + ): Promise { + 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 { + 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 { + 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 + ): 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 { + 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 { + 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( + tenantId: string, + metric: MetricType, + period: MetricPeriod, + calculateFn: () => Promise, + forceRefresh: boolean = false + ): Promise { + // Skip cache if force refresh + if (!forceRefresh) { + const cached = await this.getCachedMetric(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( + tenantId: string, + metric: MetricType, + period: MetricPeriod, + calculateFn: () => Promise + ): Promise { + 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 { + 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 { + 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 { + 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): MetricsCache { + if (!metricsCacheInstance) { + metricsCacheInstance = new MetricsCache(redis, config); + } + return metricsCacheInstance; +} + +export function createMetricsCache(redis: Redis, config?: Partial): 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); +} diff --git a/apps/api/src/services/metrics/metrics.service.ts b/apps/api/src/services/metrics/metrics.service.ts new file mode 100644 index 0000000..8bc305a --- /dev/null +++ b/apps/api/src/services/metrics/metrics.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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( + tenantId: string, + metric: MetricType, + period: MetricPeriod, + calculateFn: () => Promise + ): Promise { + 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 { + 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); +} diff --git a/apps/api/src/services/metrics/metrics.types.ts b/apps/api/src/services/metrics/metrics.types.ts new file mode 100644 index 0000000..b3d6103 --- /dev/null +++ b/apps/api/src/services/metrics/metrics.types.ts @@ -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; + 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 '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}`; + } +} diff --git a/apps/api/src/services/metrics/startup.metrics.ts b/apps/api/src/services/metrics/startup.metrics.ts new file mode 100644 index 0000000..47462fb --- /dev/null +++ b/apps/api/src/services/metrics/startup.metrics.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/apps/api/src/services/sat/cfdi.parser.ts b/apps/api/src/services/sat/cfdi.parser.ts new file mode 100644 index 0000000..642ffcc --- /dev/null +++ b/apps/api/src/services/sat/cfdi.parser.ts @@ -0,0 +1,1161 @@ +/** + * CFDI 4.0 XML Parser + * Parser completo para documentos CFDI del SAT + */ + +import { XMLParser, XMLValidator } from 'fast-xml-parser'; +import { + CFDI, + Emisor, + Receptor, + Concepto, + Impuestos, + ImpuestosConcepto, + Traslado, + Retencion, + CfdiRelacionados, + ComplementoPago, + ComplementoNomina, + TimbreFiscalDigital, + Pago, + DoctoRelacionado, + TipoComprobante, + FormaPago, + MetodoPago, + UsoCFDI, + RegimenFiscal, + TipoRelacion, + ObjetoImp, + Exportacion, + TipoImpuesto, + TipoFactor, + CFDIParsed, + CFDIParseError, +} from './sat.types.js'; + +// Namespaces del CFDI 4.0 +const CFDI_NAMESPACE = 'cfdi'; +const PAGO_NAMESPACE = 'pago20'; +const NOMINA_NAMESPACE = 'nomina12'; +const TFD_NAMESPACE = 'tfd'; + +/** + * Configuración del parser XML + */ +const parserOptions = { + ignoreAttributes: false, + attributeNamePrefix: '@_', + textNodeName: '#text', + parseAttributeValue: false, + trimValues: true, + parseTagValue: false, + isArray: (name: string): boolean => { + // Elementos que siempre deben ser arrays + const arrayElements = [ + 'Concepto', + 'CfdiRelacionado', + 'CfdiRelacionados', + 'Traslado', + 'Retencion', + 'Pago', + 'DoctoRelacionado', + 'Percepcion', + 'Deduccion', + 'OtroPago', + 'Incapacidad', + 'HorasExtra', + 'InformacionAduanera', + 'CuentaPredial', + 'Parte', + 'RetencionesDR', + 'TrasladosDR', + 'RetencionesP', + 'TrasladosP', + ]; + return arrayElements.some((elem) => name.endsWith(elem)); + }, + removeNSPrefix: false, +}; + +/** + * Clase principal para parsear CFDIs + */ +export class CFDIParser { + private parser: XMLParser; + + constructor() { + this.parser = new XMLParser(parserOptions); + } + + /** + * Parsea un XML de CFDI y retorna el objeto tipado + */ + parseCFDI(xml: string): CFDIParsed { + // Validar que el XML sea válido + const validationResult = XMLValidator.validate(xml); + if (validationResult !== true) { + throw new CFDIParseError( + `XML inválido: ${validationResult.err.msg}`, + validationResult.err.line?.toString() + ); + } + + const parsed = this.parser.parse(xml); + + // Buscar el nodo Comprobante (puede tener prefijo cfdi:) + const comprobante = this.findComprobante(parsed); + if (!comprobante) { + throw new CFDIParseError('No se encontró el nodo Comprobante en el XML'); + } + + // Extraer el CFDI + const cfdi = this.extractCFDI(comprobante); + + // Extraer UUID y fecha de timbrado + const tfd = cfdi.Complemento?.TimbreFiscalDigital; + if (!tfd) { + throw new CFDIParseError('No se encontró el Timbre Fiscal Digital'); + } + + return { + cfdi, + xml, + uuid: tfd.UUID, + fechaTimbrado: new Date(tfd.FechaTimbrado), + rfcEmisor: cfdi.Emisor.Rfc, + rfcReceptor: cfdi.Receptor.Rfc, + total: cfdi.Total, + tipoComprobante: cfdi.TipoDeComprobante, + }; + } + + /** + * Busca el nodo Comprobante en el objeto parseado + */ + private findComprobante(parsed: Record): Record | null { + // Intentar diferentes formas del nodo + const possibleKeys = [ + 'cfdi:Comprobante', + 'Comprobante', + 'comprobante:Comprobante', + ]; + + for (const key of possibleKeys) { + if (parsed[key]) { + return parsed[key] as Record; + } + } + + // Buscar recursivamente + for (const value of Object.values(parsed)) { + if (typeof value === 'object' && value !== null) { + const found = this.findComprobante(value as Record); + if (found) return found; + } + } + + return null; + } + + /** + * Extrae el CFDI completo del nodo parseado + */ + private extractCFDI(comprobante: Record): CFDI { + // Extraer atributos + const version = this.getAttr(comprobante, 'Version'); + if (version !== '4.0') { + throw new CFDIParseError(`Versión de CFDI no soportada: ${version}`, 'Version', version); + } + + const cfdi: CFDI = { + Version: '4.0', + Fecha: this.getAttr(comprobante, 'Fecha'), + Sello: this.getAttr(comprobante, 'Sello'), + NoCertificado: this.getAttr(comprobante, 'NoCertificado'), + Certificado: this.getAttr(comprobante, 'Certificado'), + SubTotal: this.parseNumber(this.getAttr(comprobante, 'SubTotal')), + Moneda: this.getAttr(comprobante, 'Moneda'), + Total: this.parseNumber(this.getAttr(comprobante, 'Total')), + TipoDeComprobante: this.getAttr(comprobante, 'TipoDeComprobante') as TipoComprobante, + Exportacion: this.getAttr(comprobante, 'Exportacion') as Exportacion, + LugarExpedicion: this.getAttr(comprobante, 'LugarExpedicion'), + Emisor: this.extractEmisor(comprobante), + Receptor: this.extractReceptor(comprobante), + Conceptos: this.extractConceptos(comprobante), + }; + + // Atributos opcionales + const serie = this.getAttr(comprobante, 'Serie', false); + if (serie) cfdi.Serie = serie; + + const folio = this.getAttr(comprobante, 'Folio', false); + if (folio) cfdi.Folio = folio; + + const formaPago = this.getAttr(comprobante, 'FormaPago', false); + if (formaPago) cfdi.FormaPago = formaPago as FormaPago; + + const condicionesDePago = this.getAttr(comprobante, 'CondicionesDePago', false); + if (condicionesDePago) cfdi.CondicionesDePago = condicionesDePago; + + const descuento = this.getAttr(comprobante, 'Descuento', false); + if (descuento) cfdi.Descuento = this.parseNumber(descuento); + + const tipoCambio = this.getAttr(comprobante, 'TipoCambio', false); + if (tipoCambio) cfdi.TipoCambio = this.parseNumber(tipoCambio); + + const metodoPago = this.getAttr(comprobante, 'MetodoPago', false); + if (metodoPago) cfdi.MetodoPago = metodoPago as MetodoPago; + + const confirmacion = this.getAttr(comprobante, 'Confirmacion', false); + if (confirmacion) cfdi.Confirmacion = confirmacion; + + // Información Global + const infoGlobal = this.findNode(comprobante, 'InformacionGlobal'); + if (infoGlobal) { + cfdi.InformacionGlobal = { + Periodicidad: this.getAttr(infoGlobal, 'Periodicidad'), + Meses: this.getAttr(infoGlobal, 'Meses'), + Ano: parseInt(this.getAttr(infoGlobal, 'Ano'), 10), + }; + } + + // CFDIs Relacionados + const relacionados = this.extractCfdiRelacionados(comprobante); + if (relacionados.length > 0) { + cfdi.CfdiRelacionados = relacionados; + } + + // Impuestos del comprobante + const impuestos = this.extractImpuestos(comprobante); + if (impuestos) { + cfdi.Impuestos = impuestos; + } + + // Complementos + cfdi.Complemento = this.extractComplementos(comprobante); + + // Addenda + const addenda = this.findNode(comprobante, 'Addenda'); + if (addenda) { + cfdi.Addenda = addenda; + } + + return cfdi; + } + + /** + * Extrae el emisor + */ + private extractEmisor(comprobante: Record): Emisor { + const emisorNode = this.findNode(comprobante, 'Emisor'); + if (!emisorNode) { + throw new CFDIParseError('No se encontró el nodo Emisor'); + } + + const emisor: Emisor = { + Rfc: this.getAttr(emisorNode, 'Rfc'), + Nombre: this.getAttr(emisorNode, 'Nombre'), + RegimenFiscal: this.getAttr(emisorNode, 'RegimenFiscal') as RegimenFiscal, + }; + + const facAtrAdquirente = this.getAttr(emisorNode, 'FacAtrAdquirente', false); + if (facAtrAdquirente) { + emisor.FacAtrAdquirente = facAtrAdquirente; + } + + return emisor; + } + + /** + * Extrae el receptor + */ + private extractReceptor(comprobante: Record): Receptor { + const receptorNode = this.findNode(comprobante, 'Receptor'); + if (!receptorNode) { + throw new CFDIParseError('No se encontró el nodo Receptor'); + } + + const receptor: Receptor = { + Rfc: this.getAttr(receptorNode, 'Rfc'), + Nombre: this.getAttr(receptorNode, 'Nombre'), + DomicilioFiscalReceptor: this.getAttr(receptorNode, 'DomicilioFiscalReceptor'), + RegimenFiscalReceptor: this.getAttr(receptorNode, 'RegimenFiscalReceptor') as RegimenFiscal, + UsoCFDI: this.getAttr(receptorNode, 'UsoCFDI') as UsoCFDI, + }; + + const residenciaFiscal = this.getAttr(receptorNode, 'ResidenciaFiscal', false); + if (residenciaFiscal) receptor.ResidenciaFiscal = residenciaFiscal; + + const numRegIdTrib = this.getAttr(receptorNode, 'NumRegIdTrib', false); + if (numRegIdTrib) receptor.NumRegIdTrib = numRegIdTrib; + + return receptor; + } + + /** + * Extrae los conceptos + */ + private extractConceptos(comprobante: Record): Concepto[] { + const conceptosNode = this.findNode(comprobante, 'Conceptos'); + if (!conceptosNode) { + throw new CFDIParseError('No se encontró el nodo Conceptos'); + } + + const conceptos = this.findNodes(conceptosNode, 'Concepto'); + if (conceptos.length === 0) { + throw new CFDIParseError('No se encontraron conceptos en el CFDI'); + } + + return conceptos.map((c) => this.extractConcepto(c)); + } + + /** + * Extrae un concepto individual + */ + private extractConcepto(conceptoNode: Record): Concepto { + const concepto: Concepto = { + ClaveProdServ: this.getAttr(conceptoNode, 'ClaveProdServ'), + Cantidad: this.parseNumber(this.getAttr(conceptoNode, 'Cantidad')), + ClaveUnidad: this.getAttr(conceptoNode, 'ClaveUnidad'), + Descripcion: this.getAttr(conceptoNode, 'Descripcion'), + ValorUnitario: this.parseNumber(this.getAttr(conceptoNode, 'ValorUnitario')), + Importe: this.parseNumber(this.getAttr(conceptoNode, 'Importe')), + ObjetoImp: this.getAttr(conceptoNode, 'ObjetoImp') as ObjetoImp, + }; + + // Atributos opcionales + const noIdentificacion = this.getAttr(conceptoNode, 'NoIdentificacion', false); + if (noIdentificacion) concepto.NoIdentificacion = noIdentificacion; + + const unidad = this.getAttr(conceptoNode, 'Unidad', false); + if (unidad) concepto.Unidad = unidad; + + const descuento = this.getAttr(conceptoNode, 'Descuento', false); + if (descuento) concepto.Descuento = this.parseNumber(descuento); + + // Impuestos del concepto + const impuestosNode = this.findNode(conceptoNode, 'Impuestos'); + if (impuestosNode) { + concepto.Impuestos = this.extractImpuestosConcepto(impuestosNode); + } + + // ACuentaTerceros + const aCuentaTerceros = this.findNode(conceptoNode, 'ACuentaTerceros'); + if (aCuentaTerceros) { + concepto.ACuentaTerceros = { + RfcACuentaTerceros: this.getAttr(aCuentaTerceros, 'RfcACuentaTerceros'), + NombreACuentaTerceros: this.getAttr(aCuentaTerceros, 'NombreACuentaTerceros'), + RegimenFiscalACuentaTerceros: this.getAttr(aCuentaTerceros, 'RegimenFiscalACuentaTerceros') as RegimenFiscal, + DomicilioFiscalACuentaTerceros: this.getAttr(aCuentaTerceros, 'DomicilioFiscalACuentaTerceros'), + }; + } + + // Información Aduanera + const infoAduanera = this.findNodes(conceptoNode, 'InformacionAduanera'); + if (infoAduanera.length > 0) { + concepto.InformacionAduanera = infoAduanera.map((ia) => ({ + NumeroPedimento: this.getAttr(ia, 'NumeroPedimento'), + })); + } + + // Cuenta Predial + const cuentaPredial = this.findNodes(conceptoNode, 'CuentaPredial'); + if (cuentaPredial.length > 0) { + concepto.CuentaPredial = cuentaPredial.map((cp) => ({ + Numero: this.getAttr(cp, 'Numero'), + })); + } + + // Partes + const partes = this.findNodes(conceptoNode, 'Parte'); + if (partes.length > 0) { + concepto.Parte = partes.map((p) => { + const parte: Concepto['Parte'] extends (infer U)[] | undefined ? U : never = { + ClaveProdServ: this.getAttr(p, 'ClaveProdServ'), + Cantidad: this.parseNumber(this.getAttr(p, 'Cantidad')), + Descripcion: this.getAttr(p, 'Descripcion'), + }; + + const noIdent = this.getAttr(p, 'NoIdentificacion', false); + if (noIdent) parte.NoIdentificacion = noIdent; + + const unidadP = this.getAttr(p, 'Unidad', false); + if (unidadP) parte.Unidad = unidadP; + + const valorUnit = this.getAttr(p, 'ValorUnitario', false); + if (valorUnit) parte.ValorUnitario = this.parseNumber(valorUnit); + + const importeP = this.getAttr(p, 'Importe', false); + if (importeP) parte.Importe = this.parseNumber(importeP); + + return parte; + }); + } + + return concepto; + } + + /** + * Extrae los impuestos de un concepto + */ + private extractImpuestosConcepto(impuestosNode: Record): ImpuestosConcepto { + const impuestos: ImpuestosConcepto = {}; + + // Traslados + const trasladosNode = this.findNode(impuestosNode, 'Traslados'); + if (trasladosNode) { + const traslados = this.findNodes(trasladosNode, 'Traslado'); + impuestos.Traslados = traslados.map((t) => { + const traslado: Traslado = { + Base: this.parseNumber(this.getAttr(t, 'Base')), + Impuesto: this.getAttr(t, 'Impuesto') as TipoImpuesto, + TipoFactor: this.getAttr(t, 'TipoFactor') as TipoFactor, + }; + + const tasaOCuota = this.getAttr(t, 'TasaOCuota', false); + if (tasaOCuota) traslado.TasaOCuota = this.parseNumber(tasaOCuota); + + const importe = this.getAttr(t, 'Importe', false); + if (importe) traslado.Importe = this.parseNumber(importe); + + return traslado; + }); + } + + // Retenciones + const retencionesNode = this.findNode(impuestosNode, 'Retenciones'); + if (retencionesNode) { + const retenciones = this.findNodes(retencionesNode, 'Retencion'); + impuestos.Retenciones = retenciones.map((r) => ({ + Base: this.parseNumber(this.getAttr(r, 'Base')), + Impuesto: this.getAttr(r, 'Impuesto') as TipoImpuesto, + TipoFactor: this.getAttr(r, 'TipoFactor') as TipoFactor, + TasaOCuota: this.parseNumber(this.getAttr(r, 'TasaOCuota')), + Importe: this.parseNumber(this.getAttr(r, 'Importe')), + })); + } + + return impuestos; + } + + /** + * Extrae los impuestos del comprobante + */ + private extractImpuestos(comprobante: Record): Impuestos | undefined { + const impuestosNode = this.findNode(comprobante, 'Impuestos'); + if (!impuestosNode) return undefined; + + const impuestos: Impuestos = {}; + + const totalRetenidos = this.getAttr(impuestosNode, 'TotalImpuestosRetenidos', false); + if (totalRetenidos) { + impuestos.TotalImpuestosRetenidos = this.parseNumber(totalRetenidos); + } + + const totalTrasladados = this.getAttr(impuestosNode, 'TotalImpuestosTrasladados', false); + if (totalTrasladados) { + impuestos.TotalImpuestosTrasladados = this.parseNumber(totalTrasladados); + } + + // Retenciones + const retencionesNode = this.findNode(impuestosNode, 'Retenciones'); + if (retencionesNode) { + const retenciones = this.findNodes(retencionesNode, 'Retencion'); + impuestos.Retenciones = retenciones.map((r) => ({ + Impuesto: this.getAttr(r, 'Impuesto') as TipoImpuesto, + Importe: this.parseNumber(this.getAttr(r, 'Importe')), + })); + } + + // Traslados + const trasladosNode = this.findNode(impuestosNode, 'Traslados'); + if (trasladosNode) { + const traslados = this.findNodes(trasladosNode, 'Traslado'); + impuestos.Traslados = traslados.map((t) => { + const traslado: Impuestos['Traslados'] extends (infer U)[] | undefined ? U : never = { + Base: this.parseNumber(this.getAttr(t, 'Base')), + Impuesto: this.getAttr(t, 'Impuesto') as TipoImpuesto, + TipoFactor: this.getAttr(t, 'TipoFactor') as TipoFactor, + }; + + const tasaOCuota = this.getAttr(t, 'TasaOCuota', false); + if (tasaOCuota) traslado.TasaOCuota = this.parseNumber(tasaOCuota); + + const importe = this.getAttr(t, 'Importe', false); + if (importe) traslado.Importe = this.parseNumber(importe); + + return traslado; + }); + } + + return impuestos; + } + + /** + * Extrae los CFDIs relacionados + */ + private extractCfdiRelacionados(comprobante: Record): CfdiRelacionados[] { + const relacionadosNodes = this.findNodes(comprobante, 'CfdiRelacionados'); + if (relacionadosNodes.length === 0) return []; + + return relacionadosNodes.map((node) => { + const cfdiRel = this.findNodes(node, 'CfdiRelacionado'); + return { + TipoRelacion: this.getAttr(node, 'TipoRelacion') as TipoRelacion, + CfdiRelacionado: cfdiRel.map((r) => ({ + UUID: this.getAttr(r, 'UUID'), + })), + }; + }); + } + + /** + * Extrae los complementos + */ + private extractComplementos(comprobante: Record): CFDI['Complemento'] { + const complementoNode = this.findNode(comprobante, 'Complemento'); + if (!complementoNode) return undefined; + + const complemento: CFDI['Complemento'] = {}; + + // Timbre Fiscal Digital + const tfd = this.findNode(complementoNode, 'TimbreFiscalDigital'); + if (tfd) { + complemento.TimbreFiscalDigital = this.extractTimbreFiscalDigital(tfd); + } + + // Complemento de Pagos + const pagos = this.findNode(complementoNode, 'Pagos'); + if (pagos) { + complemento.Pagos = this.extractComplementoPago(pagos); + } + + // Complemento de Nómina + const nomina = this.findNode(complementoNode, 'Nomina'); + if (nomina) { + complemento.Nomina = this.extractComplementoNomina(nomina); + } + + return complemento; + } + + /** + * Extrae el Timbre Fiscal Digital + */ + private extractTimbreFiscalDigital(tfd: Record): TimbreFiscalDigital { + const timbre: TimbreFiscalDigital = { + Version: '1.1', + UUID: this.getAttr(tfd, 'UUID'), + FechaTimbrado: this.getAttr(tfd, 'FechaTimbrado'), + RfcProvCertif: this.getAttr(tfd, 'RfcProvCertif'), + SelloCFD: this.getAttr(tfd, 'SelloCFD'), + NoCertificadoSAT: this.getAttr(tfd, 'NoCertificadoSAT'), + SelloSAT: this.getAttr(tfd, 'SelloSAT'), + }; + + const leyenda = this.getAttr(tfd, 'Leyenda', false); + if (leyenda) timbre.Leyenda = leyenda; + + return timbre; + } + + /** + * Extrae el Complemento de Pagos 2.0 + */ + private extractComplementoPago(pagosNode: Record): ComplementoPago { + const totalesNode = this.findNode(pagosNode, 'Totales'); + if (!totalesNode) { + throw new CFDIParseError('No se encontró el nodo Totales en el complemento de pagos'); + } + + const totales: ComplementoPago['Totales'] = { + MontoTotalPagos: this.parseNumber(this.getAttr(totalesNode, 'MontoTotalPagos')), + }; + + // Atributos opcionales de totales + const optionalTotales: Array = [ + 'TotalRetencionesIVA', + 'TotalRetencionesISR', + 'TotalRetencionesIEPS', + 'TotalTrasladosBaseIVA16', + 'TotalTrasladosImpuestoIVA16', + 'TotalTrasladosBaseIVA8', + 'TotalTrasladosImpuestoIVA8', + 'TotalTrasladosBaseIVA0', + 'TotalTrasladosImpuestoIVA0', + 'TotalTrasladosBaseIVAExento', + ]; + + for (const attr of optionalTotales) { + const value = this.getAttr(totalesNode, attr, false); + if (value) { + (totales as Record)[attr] = this.parseNumber(value); + } + } + + // Extraer pagos + const pagos = this.findNodes(pagosNode, 'Pago'); + const pagosList: Pago[] = pagos.map((p) => this.extractPago(p)); + + return { + Version: '2.0', + Totales: totales, + Pago: pagosList, + }; + } + + /** + * Extrae un pago individual + */ + private extractPago(pagoNode: Record): Pago { + const pago: Pago = { + FechaPago: this.getAttr(pagoNode, 'FechaPago'), + FormaDePagoP: this.getAttr(pagoNode, 'FormaDePagoP') as FormaPago, + MonedaP: this.getAttr(pagoNode, 'MonedaP'), + Monto: this.parseNumber(this.getAttr(pagoNode, 'Monto')), + DoctoRelacionado: [], + }; + + // Atributos opcionales + const tipoCambioP = this.getAttr(pagoNode, 'TipoCambioP', false); + if (tipoCambioP) pago.TipoCambioP = this.parseNumber(tipoCambioP); + + const numOperacion = this.getAttr(pagoNode, 'NumOperacion', false); + if (numOperacion) pago.NumOperacion = numOperacion; + + const rfcEmisorCtaOrd = this.getAttr(pagoNode, 'RfcEmisorCtaOrd', false); + if (rfcEmisorCtaOrd) pago.RfcEmisorCtaOrd = rfcEmisorCtaOrd; + + const nomBancoOrdExt = this.getAttr(pagoNode, 'NomBancoOrdExt', false); + if (nomBancoOrdExt) pago.NomBancoOrdExt = nomBancoOrdExt; + + const ctaOrdenante = this.getAttr(pagoNode, 'CtaOrdenante', false); + if (ctaOrdenante) pago.CtaOrdenante = ctaOrdenante; + + const rfcEmisorCtaBen = this.getAttr(pagoNode, 'RfcEmisorCtaBen', false); + if (rfcEmisorCtaBen) pago.RfcEmisorCtaBen = rfcEmisorCtaBen; + + const ctaBeneficiario = this.getAttr(pagoNode, 'CtaBeneficiario', false); + if (ctaBeneficiario) pago.CtaBeneficiario = ctaBeneficiario; + + // Documentos relacionados + const doctos = this.findNodes(pagoNode, 'DoctoRelacionado'); + pago.DoctoRelacionado = doctos.map((d) => this.extractDoctoRelacionado(d)); + + // Impuestos del pago + const impuestosP = this.findNode(pagoNode, 'ImpuestosP'); + if (impuestosP) { + pago.ImpuestosP = this.extractImpuestosPago(impuestosP); + } + + return pago; + } + + /** + * Extrae un documento relacionado + */ + private extractDoctoRelacionado(doctoNode: Record): DoctoRelacionado { + const docto: DoctoRelacionado = { + IdDocumento: this.getAttr(doctoNode, 'IdDocumento'), + MonedaDR: this.getAttr(doctoNode, 'MonedaDR'), + }; + + // Atributos opcionales + const serie = this.getAttr(doctoNode, 'Serie', false); + if (serie) docto.Serie = serie; + + const folio = this.getAttr(doctoNode, 'Folio', false); + if (folio) docto.Folio = folio; + + const equivalenciaDR = this.getAttr(doctoNode, 'EquivalenciaDR', false); + if (equivalenciaDR) docto.EquivalenciaDR = this.parseNumber(equivalenciaDR); + + const numParcialidad = this.getAttr(doctoNode, 'NumParcialidad', false); + if (numParcialidad) docto.NumParcialidad = parseInt(numParcialidad, 10); + + const impSaldoAnt = this.getAttr(doctoNode, 'ImpSaldoAnt', false); + if (impSaldoAnt) docto.ImpSaldoAnt = this.parseNumber(impSaldoAnt); + + const impPagado = this.getAttr(doctoNode, 'ImpPagado', false); + if (impPagado) docto.ImpPagado = this.parseNumber(impPagado); + + const impSaldoInsoluto = this.getAttr(doctoNode, 'ImpSaldoInsoluto', false); + if (impSaldoInsoluto) docto.ImpSaldoInsoluto = this.parseNumber(impSaldoInsoluto); + + const objetoImpDR = this.getAttr(doctoNode, 'ObjetoImpDR', false); + if (objetoImpDR) docto.ObjetoImpDR = objetoImpDR as ObjetoImp; + + // Impuestos del documento relacionado + const impuestosDR = this.findNode(doctoNode, 'ImpuestosDR'); + if (impuestosDR) { + docto.ImpuestosDR = this.extractImpuestosDR(impuestosDR); + } + + return docto; + } + + /** + * Extrae impuestos del documento relacionado + */ + private extractImpuestosDR(impuestosNode: Record): DoctoRelacionado['ImpuestosDR'] { + const impuestos: DoctoRelacionado['ImpuestosDR'] = {}; + + const retencionesNode = this.findNode(impuestosNode, 'RetencionesDR'); + if (retencionesNode) { + const retenciones = this.findNodes(retencionesNode, 'RetencionDR'); + impuestos.RetencionesDR = retenciones.map((r) => ({ + BaseDR: this.parseNumber(this.getAttr(r, 'BaseDR')), + ImpuestoDR: this.getAttr(r, 'ImpuestoDR') as TipoImpuesto, + TipoFactorDR: this.getAttr(r, 'TipoFactorDR') as TipoFactor, + TasaOCuotaDR: this.parseNumber(this.getAttr(r, 'TasaOCuotaDR')), + ImporteDR: this.parseNumber(this.getAttr(r, 'ImporteDR')), + })); + } + + const trasladosNode = this.findNode(impuestosNode, 'TrasladosDR'); + if (trasladosNode) { + const traslados = this.findNodes(trasladosNode, 'TrasladoDR'); + impuestos.TrasladosDR = traslados.map((t) => { + const traslado: NonNullable['TrasladosDR'] extends (infer U)[] | undefined ? U : never = { + BaseDR: this.parseNumber(this.getAttr(t, 'BaseDR')), + ImpuestoDR: this.getAttr(t, 'ImpuestoDR') as TipoImpuesto, + TipoFactorDR: this.getAttr(t, 'TipoFactorDR') as TipoFactor, + }; + + const tasaOCuota = this.getAttr(t, 'TasaOCuotaDR', false); + if (tasaOCuota) traslado.TasaOCuotaDR = this.parseNumber(tasaOCuota); + + const importe = this.getAttr(t, 'ImporteDR', false); + if (importe) traslado.ImporteDR = this.parseNumber(importe); + + return traslado; + }); + } + + return impuestos; + } + + /** + * Extrae impuestos del pago + */ + private extractImpuestosPago(impuestosNode: Record): Pago['ImpuestosP'] { + const impuestos: Pago['ImpuestosP'] = {}; + + const retencionesNode = this.findNode(impuestosNode, 'RetencionesP'); + if (retencionesNode) { + const retenciones = this.findNodes(retencionesNode, 'RetencionP'); + impuestos.RetencionesP = retenciones.map((r) => ({ + ImpuestoP: this.getAttr(r, 'ImpuestoP') as TipoImpuesto, + ImporteP: this.parseNumber(this.getAttr(r, 'ImporteP')), + })); + } + + const trasladosNode = this.findNode(impuestosNode, 'TrasladosP'); + if (trasladosNode) { + const traslados = this.findNodes(trasladosNode, 'TrasladoP'); + impuestos.TrasladosP = traslados.map((t) => { + const traslado: NonNullable['TrasladosP'] extends (infer U)[] | undefined ? U : never = { + BaseP: this.parseNumber(this.getAttr(t, 'BaseP')), + ImpuestoP: this.getAttr(t, 'ImpuestoP') as TipoImpuesto, + TipoFactorP: this.getAttr(t, 'TipoFactorP') as TipoFactor, + }; + + const tasaOCuota = this.getAttr(t, 'TasaOCuotaP', false); + if (tasaOCuota) traslado.TasaOCuotaP = this.parseNumber(tasaOCuota); + + const importe = this.getAttr(t, 'ImporteP', false); + if (importe) traslado.ImporteP = this.parseNumber(importe); + + return traslado; + }); + } + + return impuestos; + } + + /** + * Extrae el Complemento de Nómina 1.2 + */ + private extractComplementoNomina(nominaNode: Record): ComplementoNomina { + const nomina: ComplementoNomina = { + Version: '1.2', + TipoNomina: this.getAttr(nominaNode, 'TipoNomina') as 'O' | 'E', + FechaPago: this.getAttr(nominaNode, 'FechaPago'), + FechaInicialPago: this.getAttr(nominaNode, 'FechaInicialPago'), + FechaFinalPago: this.getAttr(nominaNode, 'FechaFinalPago'), + NumDiasPagados: this.parseNumber(this.getAttr(nominaNode, 'NumDiasPagados')), + Receptor: this.extractReceptorNomina(nominaNode), + }; + + // Atributos opcionales + const totalPercepciones = this.getAttr(nominaNode, 'TotalPercepciones', false); + if (totalPercepciones) nomina.TotalPercepciones = this.parseNumber(totalPercepciones); + + const totalDeducciones = this.getAttr(nominaNode, 'TotalDeducciones', false); + if (totalDeducciones) nomina.TotalDeducciones = this.parseNumber(totalDeducciones); + + const totalOtrosPagos = this.getAttr(nominaNode, 'TotalOtrosPagos', false); + if (totalOtrosPagos) nomina.TotalOtrosPagos = this.parseNumber(totalOtrosPagos); + + // Emisor de nómina + const emisorNode = this.findNode(nominaNode, 'Emisor'); + if (emisorNode) { + nomina.Emisor = this.extractEmisorNomina(emisorNode); + } + + // Percepciones + const percepcionesNode = this.findNode(nominaNode, 'Percepciones'); + if (percepcionesNode) { + nomina.Percepciones = this.extractPercepcionesNomina(percepcionesNode); + } + + // Deducciones + const deduccionesNode = this.findNode(nominaNode, 'Deducciones'); + if (deduccionesNode) { + nomina.Deducciones = this.extractDeduccionesNomina(deduccionesNode); + } + + // Otros Pagos + const otrosPagosNode = this.findNode(nominaNode, 'OtrosPagos'); + if (otrosPagosNode) { + const otrosPagos = this.findNodes(otrosPagosNode, 'OtroPago'); + nomina.OtrosPagos = { + OtroPago: otrosPagos.map((op) => this.extractOtroPago(op)), + }; + } + + // Incapacidades + const incapacidadesNode = this.findNode(nominaNode, 'Incapacidades'); + if (incapacidadesNode) { + const incapacidades = this.findNodes(incapacidadesNode, 'Incapacidad'); + nomina.Incapacidades = { + Incapacidad: incapacidades.map((i) => { + const incapacidad: ComplementoNomina['Incapacidades'] extends { Incapacidad: infer U } | undefined + ? U extends (infer V)[] ? V : never + : never = { + DiasIncapacidad: parseInt(this.getAttr(i, 'DiasIncapacidad'), 10), + TipoIncapacidad: this.getAttr(i, 'TipoIncapacidad'), + }; + + const importeMonetario = this.getAttr(i, 'ImporteMonetario', false); + if (importeMonetario) incapacidad.ImporteMonetario = this.parseNumber(importeMonetario); + + return incapacidad; + }), + }; + } + + return nomina; + } + + /** + * Extrae el receptor de nómina + */ + private extractReceptorNomina(nominaNode: Record): ComplementoNomina['Receptor'] { + const receptorNode = this.findNode(nominaNode, 'Receptor'); + if (!receptorNode) { + throw new CFDIParseError('No se encontró el nodo Receptor en el complemento de nómina'); + } + + const receptor: ComplementoNomina['Receptor'] = { + Curp: this.getAttr(receptorNode, 'Curp'), + TipoContrato: this.getAttr(receptorNode, 'TipoContrato'), + TipoRegimen: this.getAttr(receptorNode, 'TipoRegimen'), + NumEmpleado: this.getAttr(receptorNode, 'NumEmpleado'), + PeriodicidadPago: this.getAttr(receptorNode, 'PeriodicidadPago'), + ClaveEntFed: this.getAttr(receptorNode, 'ClaveEntFed'), + }; + + // Atributos opcionales + const optionalAttrs: Array = [ + 'NumSeguridadSocial', + 'FechaInicioRelLaboral', + 'Antiguedad', + 'TipoJornada', + 'Departamento', + 'Puesto', + 'RiesgoContratado', + 'Banco', + 'CuentaBancaria', + ]; + + for (const attr of optionalAttrs) { + const value = this.getAttr(receptorNode, attr, false); + if (value) { + (receptor as Record)[attr] = value; + } + } + + const sindicalizado = this.getAttr(receptorNode, 'Sindicalizado', false); + if (sindicalizado) receptor.Sindicalizado = sindicalizado as 'Sí' | 'No'; + + const salarioBase = this.getAttr(receptorNode, 'SalarioBaseCotApor', false); + if (salarioBase) receptor.SalarioBaseCotApor = this.parseNumber(salarioBase); + + const salarioDiario = this.getAttr(receptorNode, 'SalarioDiarioIntegrado', false); + if (salarioDiario) receptor.SalarioDiarioIntegrado = this.parseNumber(salarioDiario); + + return receptor; + } + + /** + * Extrae el emisor de nómina + */ + private extractEmisorNomina(emisorNode: Record): ComplementoNomina['Emisor'] { + const emisor: ComplementoNomina['Emisor'] = {}; + + const curp = this.getAttr(emisorNode, 'Curp', false); + if (curp) emisor.Curp = curp; + + const registroPatronal = this.getAttr(emisorNode, 'RegistroPatronal', false); + if (registroPatronal) emisor.RegistroPatronal = registroPatronal; + + const rfcPatronOrigen = this.getAttr(emisorNode, 'RfcPatronOrigen', false); + if (rfcPatronOrigen) emisor.RfcPatronOrigen = rfcPatronOrigen; + + const entidadSNCF = this.findNode(emisorNode, 'EntidadSNCF'); + if (entidadSNCF) { + emisor.EntidadSNCF = { + OrigenRecurso: this.getAttr(entidadSNCF, 'OrigenRecurso'), + }; + + const montoRecursoPropio = this.getAttr(entidadSNCF, 'MontoRecursoPropio', false); + if (montoRecursoPropio) { + emisor.EntidadSNCF.MontoRecursoPropio = this.parseNumber(montoRecursoPropio); + } + } + + return emisor; + } + + /** + * Extrae las percepciones de nómina + */ + private extractPercepcionesNomina(percepcionesNode: Record): NonNullable { + const percepciones = this.findNodes(percepcionesNode, 'Percepcion'); + + const result: NonNullable = { + TotalGravado: this.parseNumber(this.getAttr(percepcionesNode, 'TotalGravado')), + TotalExento: this.parseNumber(this.getAttr(percepcionesNode, 'TotalExento')), + Percepcion: percepciones.map((p) => ({ + TipoPercepcion: this.getAttr(p, 'TipoPercepcion'), + Clave: this.getAttr(p, 'Clave'), + Concepto: this.getAttr(p, 'Concepto'), + ImporteGravado: this.parseNumber(this.getAttr(p, 'ImporteGravado')), + ImporteExento: this.parseNumber(this.getAttr(p, 'ImporteExento')), + })), + }; + + const totalSueldos = this.getAttr(percepcionesNode, 'TotalSueldos', false); + if (totalSueldos) result.TotalSueldos = this.parseNumber(totalSueldos); + + const totalSeparacion = this.getAttr(percepcionesNode, 'TotalSeparacionIndemnizacion', false); + if (totalSeparacion) result.TotalSeparacionIndemnizacion = this.parseNumber(totalSeparacion); + + const totalJubilacion = this.getAttr(percepcionesNode, 'TotalJubilacionPensionRetiro', false); + if (totalJubilacion) result.TotalJubilacionPensionRetiro = this.parseNumber(totalJubilacion); + + return result; + } + + /** + * Extrae las deducciones de nómina + */ + private extractDeduccionesNomina(deduccionesNode: Record): NonNullable { + const deducciones = this.findNodes(deduccionesNode, 'Deduccion'); + + const result: NonNullable = { + Deduccion: deducciones.map((d) => ({ + TipoDeduccion: this.getAttr(d, 'TipoDeduccion'), + Clave: this.getAttr(d, 'Clave'), + Concepto: this.getAttr(d, 'Concepto'), + Importe: this.parseNumber(this.getAttr(d, 'Importe')), + })), + }; + + const totalOtras = this.getAttr(deduccionesNode, 'TotalOtrasDeducciones', false); + if (totalOtras) result.TotalOtrasDeducciones = this.parseNumber(totalOtras); + + const totalImpuestos = this.getAttr(deduccionesNode, 'TotalImpuestosRetenidos', false); + if (totalImpuestos) result.TotalImpuestosRetenidos = this.parseNumber(totalImpuestos); + + return result; + } + + /** + * Extrae un otro pago de nómina + */ + private extractOtroPago(otroNode: Record): ComplementoNomina['OtrosPagos'] extends { OtroPago: infer U } | undefined + ? U extends (infer V)[] ? V : never + : never { + const otroPago: ReturnType = { + TipoOtroPago: this.getAttr(otroNode, 'TipoOtroPago'), + Clave: this.getAttr(otroNode, 'Clave'), + Concepto: this.getAttr(otroNode, 'Concepto'), + Importe: this.parseNumber(this.getAttr(otroNode, 'Importe')), + }; + + const subsidio = this.findNode(otroNode, 'SubsidioAlEmpleo'); + if (subsidio) { + otroPago.SubsidioAlEmpleo = { + SubsidioCausado: this.parseNumber(this.getAttr(subsidio, 'SubsidioCausado')), + }; + } + + const compensacion = this.findNode(otroNode, 'CompensacionSaldosAFavor'); + if (compensacion) { + otroPago.CompensacionSaldosAFavor = { + SaldoAFavor: this.parseNumber(this.getAttr(compensacion, 'SaldoAFavor')), + Ano: parseInt(this.getAttr(compensacion, 'Ano'), 10), + RemanenteSalFav: this.parseNumber(this.getAttr(compensacion, 'RemanenteSalFav')), + }; + } + + return otroPago; + } + + // ============================================================================ + // Utilidades + // ============================================================================ + + /** + * Obtiene un atributo de un nodo + */ + private getAttr(node: Record, name: string, required = true): string { + const value = node[`@_${name}`]; + if (value === undefined || value === null) { + if (required) { + throw new CFDIParseError(`Atributo requerido no encontrado: ${name}`, name); + } + return ''; + } + return String(value); + } + + /** + * Busca un nodo hijo (con o sin prefijo de namespace) + */ + private findNode(parent: Record, name: string): Record | null { + // Intentar con diferentes prefijos de namespace + const prefixes = ['', 'cfdi:', 'pago20:', 'nomina12:', 'tfd:']; + + for (const prefix of prefixes) { + const key = `${prefix}${name}`; + if (parent[key] !== undefined) { + return parent[key] as Record; + } + } + + return null; + } + + /** + * Busca nodos hijos (retorna array) + */ + private findNodes(parent: Record, name: string): Array> { + const node = this.findNode(parent, name); + if (!node) return []; + + if (Array.isArray(node)) { + return node; + } + + return [node]; + } + + /** + * Parsea un número desde string + */ + private parseNumber(value: string): number { + if (!value || value === '') return 0; + const num = parseFloat(value); + if (isNaN(num)) { + throw new CFDIParseError(`Valor numérico inválido: ${value}`); + } + return num; + } +} + +/** + * Función helper para parsear un CFDI + */ +export function parseCFDI(xml: string): CFDIParsed { + const parser = new CFDIParser(); + return parser.parseCFDI(xml); +} + +/** + * Valida que un XML sea un CFDI válido (estructura básica) + */ +export function validateCFDIStructure(xml: string): { isValid: boolean; errors: string[] } { + const errors: string[] = []; + + try { + const validationResult = XMLValidator.validate(xml); + if (validationResult !== true) { + errors.push(`XML inválido: ${validationResult.err.msg}`); + return { isValid: false, errors }; + } + + const parser = new CFDIParser(); + parser.parseCFDI(xml); + + return { isValid: true, errors: [] }; + } catch (error) { + if (error instanceof CFDIParseError) { + errors.push(error.message); + } else if (error instanceof Error) { + errors.push(`Error de parsing: ${error.message}`); + } else { + errors.push('Error desconocido al validar CFDI'); + } + return { isValid: false, errors }; + } +} + +/** + * Extrae el UUID de un XML de CFDI sin parsear todo el documento + */ +export function extractUUID(xml: string): string | null { + // Usar regex para extraer rápidamente el UUID + const uuidRegex = /UUID="([A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12})"/i; + const match = xml.match(uuidRegex); + return match ? match[1]?.toUpperCase() ?? null : null; +} + +/** + * Extrae información básica de un CFDI sin parsear completamente + */ +export function extractBasicInfo(xml: string): { + uuid: string | null; + rfcEmisor: string | null; + rfcReceptor: string | null; + total: number | null; + fecha: string | null; + tipoComprobante: string | null; +} { + const extractAttr = (attrName: string): string | null => { + const regex = new RegExp(`${attrName}="([^"]*)"`, 'i'); + const match = xml.match(regex); + return match ? match[1] ?? null : null; + }; + + // Extraer RFC del emisor + const emisorMatch = xml.match(/<[^>]*Emisor[^>]*Rfc="([^"]+)"/i); + const rfcEmisor = emisorMatch ? emisorMatch[1] ?? null : null; + + // Extraer RFC del receptor + const receptorMatch = xml.match(/<[^>]*Receptor[^>]*Rfc="([^"]+)"/i); + const rfcReceptor = receptorMatch ? receptorMatch[1] ?? null : null; + + const totalStr = extractAttr('Total'); + + return { + uuid: extractUUID(xml), + rfcEmisor, + rfcReceptor, + total: totalStr ? parseFloat(totalStr) : null, + fecha: extractAttr('Fecha'), + tipoComprobante: extractAttr('TipoDeComprobante'), + }; +} diff --git a/apps/api/src/services/sat/fiel.service.ts b/apps/api/src/services/sat/fiel.service.ts new file mode 100644 index 0000000..ce8ba53 --- /dev/null +++ b/apps/api/src/services/sat/fiel.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 = ` + + ${created} + ${expires} +`.trim(); + + // Calcular el digest del timestamp + const digest = crypto.createHash('sha1').update(tokenXml).digest('base64'); + + // Crear el SignedInfo + const signedInfo = ` + + + + + + + + + ${digest} + +`.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 = ` + + ${tokenXml} + ${certBase64} + + ${signedInfo} + ${signature} + + + + + + +`.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 { + 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 { + 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 { + const result: Record = {}; + + // 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 { + 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 { + return fielService.validateFIEL(cer, key, password); +} + +export async function encryptPrivateKey(key: Buffer, password: string): Promise { + return fielService.encryptPrivateKey(key, password); +} + +export async function signRequest( + data: string, + encryptedKey: Buffer, + password: string +): Promise { + return fielService.signRequest(data, encryptedKey, password); +} + +export async function getCertificateInfo(cer: Buffer): Promise { + return fielService.getCertificateInfo(cer); +} diff --git a/apps/api/src/services/sat/sat.client.ts b/apps/api/src/services/sat/sat.client.ts new file mode 100644 index 0000000..2071831 --- /dev/null +++ b/apps/api/src/services/sat/sat.client.ts @@ -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 { + 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 { + // 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 { + // Asegurar que estamos autenticados + await this.authenticate(); + + // Construir el XML de verificación + const verificacionXml = ` + +`; + + // 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 { + // 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 { + 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 ` + ${created} + ${expires} +`; + } + + /** + * Crea el SignedInfo para la firma + */ + private createSignedInfo(digest: string, uuid: string): string { + return ` + + + + + + + + ${digest} + +`; + } + + /** + * Construye el envelope SOAP para autenticación + */ + private buildAuthenticationEnvelope( + timestampXml: string, + signedInfo: string, + signature: string, + certBase64: string, + uuid: string + ): string { + return ` + + + + ${timestampXml} + ${certBase64} + + ${signedInfo} + ${signature} + + + + + + + + + + + +`; + } + + /** + * 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 ` + +`; + } + + /** + * Construye el envelope SOAP para solicitud de descarga + */ + private buildSolicitudEnvelope( + solicitudXml: string, + signature: string, + certBase64: string + ): string { + return ` + + + + + + + + + + + + + + + + + + ${signature} + + + ${certBase64} + + + + + + +`; + } + + /** + * Construye el envelope SOAP para verificación + */ + private buildVerificacionEnvelope( + requestId: string, + signature: string, + certBase64: string + ): string { + return ` + + + + + + + + + + + + + + + + + + ${signature} + + + ${certBase64} + + + + + + +`; + } + + /** + * Construye el envelope SOAP para descarga + */ + private buildDescargaEnvelope( + packageId: string, + signature: string, + certBase64: string + ): string { + return ` + + + + + + + + + + + + + + + + + + ${signature} + + + ${certBase64} + + + + + + +`; + } + + // ============================================================================ + // Métodos de comunicación HTTP + // ============================================================================ + + /** + * Envía una solicitud SOAP + */ + private async sendSoapRequest( + url: string, + soapAction: string, + body: string + ): Promise { + 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>/); + 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>/); + + 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+<') + .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 { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +/** + * Crea una instancia del cliente SAT + */ +export function createSATClient(config: SATClientConfig): SATClient { + return new SATClient(config); +} diff --git a/apps/api/src/services/sat/sat.service.ts b/apps/api/src/services/sat/sat.service.ts new file mode 100644 index 0000000..3862e72 --- /dev/null +++ b/apps/api/src/services/sat/sat.service.ts @@ -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; + /** Tabla donde se guardan los CFDIs */ + cfdiTable?: string; + /** Esquema de la base de datos */ + dbSchema?: string; + /** Logger personalizado */ + logger?: { + info: (message: string, meta?: Record) => void; + error: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => 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; + private parser: CFDIParser; + private clients: Map = 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 { + 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 { + 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 { + 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 extends Promise ? T : never, + cfdiParsed: CFDIParsed, + tenantId: string, + tipo: 'emitidos' | 'recibidos' + ): Promise { + 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 { + 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 { + 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 { + 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 & { 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 & { 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; + }> { + 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, + }; + + 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); +} diff --git a/apps/api/src/services/sat/sat.types.ts b/apps/api/src/services/sat/sat.types.ts new file mode 100644 index 0000000..5e8e94e --- /dev/null +++ b/apps/api/src/services/sat/sat.types.ts @@ -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'; + } +} diff --git a/apps/api/src/types/index.ts b/apps/api/src/types/index.ts new file mode 100644 index 0000000..427ebc1 --- /dev/null +++ b/apps/api/src/types/index.ts @@ -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 { + success: boolean; + data?: T; + error?: ApiError; + meta?: ResponseMeta; +} + +export interface ApiError { + code: string; + message: string; + details?: Record; + 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; +export type LoginInput = z.infer; +export type RefreshTokenInput = z.infer; +export type ResetPasswordRequestInput = z.infer; +export type ResetPasswordInput = z.infer; +export type ChangePasswordInput = z.infer; + +// ============================================================================ +// Error Types +// ============================================================================ + +export class AppError extends Error { + public readonly code: string; + public readonly statusCode: number; + public readonly isOperational: boolean; + public readonly details?: Record; + + constructor( + message: string, + code: string, + statusCode: number = 500, + isOperational: boolean = true, + details?: Record + ) { + 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) { + 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); + } +} diff --git a/apps/api/src/utils/asyncHandler.ts b/apps/api/src/utils/asyncHandler.ts new file mode 100644 index 0000000..76e9e13 --- /dev/null +++ b/apps/api/src/utils/asyncHandler.ts @@ -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 = ( + fn: (req: Request, res: Response, next: NextFunction) => Promise +): 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(async (req, res) => { + * const user = await userService.create(req.body); + * res.json(user); + * })); + */ +export const asyncHandlerTyped = ( + fn: ( + req: Request, + res: Response, + next: NextFunction + ) => Promise +): RequestHandler => { + return (req: Request, res: Response, next: NextFunction) => { + Promise.resolve(fn(req as Request, 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> +): RequestHandler[] => { + return middlewares.map((middleware) => { + return (req: Request, res: Response, next: NextFunction) => { + Promise.resolve(middleware(req, res, next)).catch(next); + }; + }); +}; + +export default asyncHandler; diff --git a/apps/api/src/utils/index.ts b/apps/api/src/utils/index.ts new file mode 100644 index 0000000..e89aa66 --- /dev/null +++ b/apps/api/src/utils/index.ts @@ -0,0 +1,3 @@ +// Utils exports +export { logger, httpLogger, createContextLogger, auditLog } from './logger.js'; +export { asyncHandler, asyncHandlerTyped, asyncMiddleware } from './asyncHandler.js'; diff --git a/apps/api/src/utils/logger.ts b/apps/api/src/utils/logger.ts new file mode 100644 index 0000000..1f99e51 --- /dev/null +++ b/apps/api/src/utils/logger.ts @@ -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); + + 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) => + logger.error(message, { ...context, ...meta }), + warn: (message: string, meta?: Record) => + logger.warn(message, { ...context, ...meta }), + info: (message: string, meta?: Record) => + logger.info(message, { ...context, ...meta }), + http: (message: string, meta?: Record) => + logger.http(message, { ...context, ...meta }), + verbose: (message: string, meta?: Record) => + logger.verbose(message, { ...context, ...meta }), + debug: (message: string, meta?: Record) => + logger.debug(message, { ...context, ...meta }), + }; +}; + +// ============================================================================ +// Audit Logger for Security Events +// ============================================================================ + +export const auditLog = ( + action: string, + userId: string | null, + tenantId: string | null, + details: Record, + success: boolean = true +) => { + logger.info('AUDIT', { + type: 'audit', + action, + userId, + tenantId, + success, + details, + timestamp: new Date().toISOString(), + }); +}; + +export default logger; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..7e8580c --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": false, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": true, + "noUncheckedIndexedAccess": true, + "baseUrl": "./src", + "paths": { + "@/*": ["./*"] + }, + "typeRoots": ["./node_modules/@types", "./src/types"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/web/next.config.js b/apps/web/next.config.js new file mode 100644 index 0000000..da573c0 --- /dev/null +++ b/apps/web/next.config.js @@ -0,0 +1,40 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + transpilePackages: ['@horux/shared', '@horux/ui'], + experimental: { + serverActions: { + bodySizeLimit: '2mb', + }, + }, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: '*.githubusercontent.com', + }, + { + protocol: 'https', + hostname: 'avatars.githubusercontent.com', + }, + ], + }, + async redirects() { + return []; + }, + async headers() { + return [ + { + source: '/api/:path*', + headers: [ + { key: 'Access-Control-Allow-Credentials', value: 'true' }, + { key: 'Access-Control-Allow-Origin', value: '*' }, + { key: 'Access-Control-Allow-Methods', value: 'GET,DELETE,PATCH,POST,PUT' }, + { key: 'Access-Control-Allow-Headers', value: 'Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, Authorization' }, + ], + }, + ]; + }, +}; + +module.exports = nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..159a256 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,40 @@ +{ + "name": "@horux/web", + "version": "0.1.0", + "private": true, + "description": "Horux Strategy Frontend", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "lint:fix": "next lint --fix", + "typecheck": "tsc --noEmit", + "clean": "rm -rf .next node_modules" + }, + "dependencies": { + "@horux/shared": "workspace:*", + "@horux/ui": "workspace:*", + "next": "14.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zustand": "^4.5.0", + "recharts": "^2.10.4", + "lucide-react": "^0.312.0", + "clsx": "^2.1.0", + "tailwind-merge": "^2.2.0", + "date-fns": "^3.3.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "autoprefixer": "^10.4.17", + "eslint": "^8.56.0", + "eslint-config-next": "14.1.0", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..2057921 --- /dev/null +++ b/apps/web/src/app/(auth)/layout.tsx @@ -0,0 +1,156 @@ +'use client'; + +import React, { useEffect } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/stores/auth.store'; + +/** + * Auth Layout + * + * Layout para las paginas de autenticacion (login, register). + * Redirige a dashboard si el usuario ya esta autenticado. + */ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + const router = useRouter(); + const { isAuthenticated, isInitialized, checkAuth } = useAuthStore(); + + useEffect(() => { + checkAuth(); + }, [checkAuth]); + + useEffect(() => { + if (isInitialized && isAuthenticated) { + router.replace('/dashboard'); + } + }, [isAuthenticated, isInitialized, router]); + + // Si esta autenticado, no renderizar nada mientras redirige + if (isAuthenticated) { + return null; + } + + return ( +
+ {/* Left Panel - Branding */} +
+ {/* Background Pattern */} +
+
+
+ + {/* Content */} +
+ {/* Logo */} + +
+ H +
+ Horux Strategy + + + {/* Main Content */} +
+

+ Trading Algoritmico +
+ Inteligente +

+

+ Automatiza tus estrategias de inversion con inteligencia artificial + y maximiza tus ganancias en el mercado de criptomonedas. +

+ + {/* Features */} +
+ + + +
+
+ + {/* Footer */} +
+ © {new Date().getFullYear()} Horux Strategy. Todos los derechos reservados. +
+
+ + {/* Decorative Elements */} +
+
+
+ + {/* Right Panel - Auth Form */} +
+
+ {/* Mobile Logo */} +
+ +
+ H +
+ + Horux Strategy + + +
+ + {children} +
+
+
+ ); +} + +/** + * Feature Item Component + */ +function FeatureItem({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( +
+
+ + + +
+
+

{title}

+

{description}

+
+
+ ); +} diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..99a1b2c --- /dev/null +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -0,0 +1,257 @@ +'use client'; + +import React, { useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/stores/auth.store'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Mail, Lock, AlertCircle } from 'lucide-react'; + +/** + * Login Page + * + * Pagina de inicio de sesion con formulario de email y password. + */ +export default function LoginPage() { + const router = useRouter(); + const { login, isLoading, error, clearError } = useAuthStore(); + + const [formData, setFormData] = useState({ + email: '', + password: '', + remember: false, + }); + + const [validationErrors, setValidationErrors] = useState<{ + email?: string; + password?: string; + }>({}); + + // Handle input change + const handleChange = (e: React.ChangeEvent) => { + const { name, value, type, checked } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: type === 'checkbox' ? checked : value, + })); + + // Clear validation error on change + if (validationErrors[name as keyof typeof validationErrors]) { + setValidationErrors((prev) => ({ ...prev, [name]: undefined })); + } + + // Clear API error on change + if (error) { + clearError(); + } + }; + + // Validate form + const validate = (): boolean => { + const errors: typeof validationErrors = {}; + + if (!formData.email) { + errors.email = 'El email es requerido'; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + errors.email = 'Email invalido'; + } + + if (!formData.password) { + errors.password = 'La contrasena es requerida'; + } else if (formData.password.length < 6) { + errors.password = 'La contrasena debe tener al menos 6 caracteres'; + } + + setValidationErrors(errors); + return Object.keys(errors).length === 0; + }; + + // Handle submit + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validate()) return; + + try { + await login({ + email: formData.email, + password: formData.password, + remember: formData.remember, + }); + + router.push('/dashboard'); + } catch { + // Error is handled by the store + } + }; + + return ( +
+ {/* Header */} +
+

+ Bienvenido de nuevo +

+

+ Ingresa tus credenciales para acceder a tu cuenta +

+
+ + {/* Error Alert */} + {error && ( +
+ +

{error}

+
+ )} + + {/* Form */} +
+ {/* Email */} + } + autoComplete="email" + disabled={isLoading} + /> + + {/* Password */} + } + autoComplete="current-password" + disabled={isLoading} + /> + + {/* Remember & Forgot */} +
+ + + + Olvidaste tu contrasena? + +
+ + {/* Submit Button */} + +
+ + {/* Divider */} +
+
+
+
+
+ + O continua con + +
+
+ + {/* Social Login */} +
+ + +
+ + {/* Register Link */} +

+ No tienes una cuenta?{' '} + + Registrate gratis + +

+
+ ); +} + +/** + * Google Icon + */ +function GoogleIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +/** + * Github Icon + */ +function GithubIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/web/src/app/(auth)/register/page.tsx b/apps/web/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..cd27482 --- /dev/null +++ b/apps/web/src/app/(auth)/register/page.tsx @@ -0,0 +1,392 @@ +'use client'; + +import React, { useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/stores/auth.store'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Mail, Lock, User, AlertCircle, Check } from 'lucide-react'; + +/** + * Register Page + * + * Pagina de registro con formulario de nombre, email y password. + */ +export default function RegisterPage() { + const router = useRouter(); + const { register, isLoading, error, clearError } = useAuthStore(); + + const [formData, setFormData] = useState({ + name: '', + email: '', + password: '', + confirmPassword: '', + acceptTerms: false, + }); + + const [validationErrors, setValidationErrors] = useState<{ + name?: string; + email?: string; + password?: string; + confirmPassword?: string; + acceptTerms?: string; + }>({}); + + // Password strength + const getPasswordStrength = (password: string): { + score: number; + label: string; + color: string; + } => { + let score = 0; + if (password.length >= 8) score++; + if (password.length >= 12) score++; + if (/[A-Z]/.test(password)) score++; + if (/[0-9]/.test(password)) score++; + if (/[^A-Za-z0-9]/.test(password)) score++; + + if (score <= 1) return { score, label: 'Muy debil', color: 'bg-error-500' }; + if (score === 2) return { score, label: 'Debil', color: 'bg-warning-500' }; + if (score === 3) return { score, label: 'Media', color: 'bg-warning-400' }; + if (score === 4) return { score, label: 'Fuerte', color: 'bg-success-400' }; + return { score, label: 'Muy fuerte', color: 'bg-success-500' }; + }; + + const passwordStrength = getPasswordStrength(formData.password); + + // Handle input change + const handleChange = (e: React.ChangeEvent) => { + const { name, value, type, checked } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: type === 'checkbox' ? checked : value, + })); + + // Clear validation error on change + if (validationErrors[name as keyof typeof validationErrors]) { + setValidationErrors((prev) => ({ ...prev, [name]: undefined })); + } + + // Clear API error on change + if (error) { + clearError(); + } + }; + + // Validate form + const validate = (): boolean => { + const errors: typeof validationErrors = {}; + + if (!formData.name) { + errors.name = 'El nombre es requerido'; + } else if (formData.name.length < 2) { + errors.name = 'El nombre debe tener al menos 2 caracteres'; + } + + if (!formData.email) { + errors.email = 'El email es requerido'; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + errors.email = 'Email invalido'; + } + + if (!formData.password) { + errors.password = 'La contrasena es requerida'; + } else if (formData.password.length < 8) { + errors.password = 'La contrasena debe tener al menos 8 caracteres'; + } + + if (!formData.confirmPassword) { + errors.confirmPassword = 'Confirma tu contrasena'; + } else if (formData.password !== formData.confirmPassword) { + errors.confirmPassword = 'Las contrasenas no coinciden'; + } + + if (!formData.acceptTerms) { + errors.acceptTerms = 'Debes aceptar los terminos y condiciones'; + } + + setValidationErrors(errors); + return Object.keys(errors).length === 0; + }; + + // Handle submit + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validate()) return; + + try { + await register({ + name: formData.name, + email: formData.email, + password: formData.password, + }); + + router.push('/dashboard'); + } catch { + // Error is handled by the store + } + }; + + // Password requirements + const requirements = [ + { label: 'Al menos 8 caracteres', met: formData.password.length >= 8 }, + { label: 'Una letra mayuscula', met: /[A-Z]/.test(formData.password) }, + { label: 'Un numero', met: /[0-9]/.test(formData.password) }, + { label: 'Un caracter especial', met: /[^A-Za-z0-9]/.test(formData.password) }, + ]; + + return ( +
+ {/* Header */} +
+

+ Crear cuenta +

+

+ Comienza tu viaje en el trading algoritmico +

+
+ + {/* Error Alert */} + {error && ( +
+ +

{error}

+
+ )} + + {/* Form */} +
+ {/* Name */} + } + autoComplete="name" + disabled={isLoading} + /> + + {/* Email */} + } + autoComplete="email" + disabled={isLoading} + /> + + {/* Password */} +
+ } + autoComplete="new-password" + disabled={isLoading} + /> + + {/* Password Strength */} + {formData.password && ( +
+
+ {[1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+

+ Seguridad: {passwordStrength.label} +

+
+ )} + + {/* Requirements */} + {formData.password && ( +
    + {requirements.map((req) => ( +
  • + {req.met ? ( + + ) : ( +
    + )} + {req.label} +
  • + ))} +
+ )} +
+ + {/* Confirm Password */} + } + autoComplete="new-password" + disabled={isLoading} + success={ + formData.confirmPassword !== '' && + formData.password === formData.confirmPassword + } + /> + + {/* Terms */} +
+ + {validationErrors.acceptTerms && ( +

+ {validationErrors.acceptTerms} +

+ )} +
+ + {/* Submit Button */} + + + + {/* Divider */} +
+
+
+
+
+ + O registrate con + +
+
+ + {/* Social Register */} +
+ + +
+ + {/* Login Link */} +

+ Ya tienes una cuenta?{' '} + + Inicia sesion + +

+
+ ); +} + +/** + * Google Icon + */ +function GoogleIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +/** + * Github Icon + */ +function GithubIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/web/src/app/(dashboard)/cfdis/page.tsx b/apps/web/src/app/(dashboard)/cfdis/page.tsx new file mode 100644 index 0000000..fb4eda6 --- /dev/null +++ b/apps/web/src/app/(dashboard)/cfdis/page.tsx @@ -0,0 +1,788 @@ +'use client'; + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + FileText, + Search, + Filter, + Download, + RefreshCw, + ChevronLeft, + ChevronRight, + Eye, + CheckCircle, + Clock, + XCircle, + AlertCircle, + X, + ExternalLink, + Building2, + Calendar, + DollarSign, +} from 'lucide-react'; +import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/utils'; +import { api } from '@/lib/api'; + +// ============================================================================ +// Types +// ============================================================================ + +type CFDIType = 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago'; +type CFDIStatus = 'vigente' | 'cancelado' | 'pendiente_cancelacion'; +type PaymentStatus = 'pagado' | 'parcial' | 'pendiente' | 'vencido'; +type CFDITab = 'emitidos' | 'recibidos' | 'complementos'; + +interface CFDIConcept { + claveProdServ: string; + cantidad: number; + claveUnidad: string; + unidad: string; + descripcion: string; + valorUnitario: number; + importe: number; + descuento?: number; + impuestos?: { + traslados?: { impuesto: string; tasa: number; importe: number }[]; + retenciones?: { impuesto: string; tasa: number; importe: number }[]; + }; +} + +interface CFDI { + id: string; + uuid: string; + serie?: string; + folio?: string; + fecha: string; + fechaTimbrado: string; + tipo: CFDIType; + tipoComprobante: string; + metodoPago?: string; + formaPago?: string; + condicionesPago?: string; + status: CFDIStatus; + paymentStatus: PaymentStatus; + emisor: { + rfc: string; + nombre: string; + regimenFiscal: string; + }; + receptor: { + rfc: string; + nombre: string; + usoCFDI: string; + }; + conceptos: CFDIConcept[]; + subtotal: number; + descuento?: number; + impuestos: { + totalTraslados: number; + totalRetenciones: number; + }; + total: number; + moneda: string; + tipoCambio?: number; + complementos?: string[]; + relacionados?: { tipoRelacion: string; uuid: string }[]; + pagosRelacionados?: { id: string; uuid: string; fecha: string; monto: number }[]; + montoPagado: number; + montoPendiente: number; + xmlUrl?: string; + pdfUrl?: string; + createdAt: string; + updatedAt: string; +} + +interface Filters { + search: string; + tipo: CFDIType | 'all'; + status: CFDIStatus | 'all'; + paymentStatus: PaymentStatus | 'all'; + rfc: string; + dateFrom: string; + dateTo: string; +} + +// ============================================================================ +// Mock Data +// ============================================================================ + +const generateMockCFDIs = (): CFDI[] => { + const emisores = [ + { rfc: 'EMP123456789', nombre: 'Mi Empresa S.A. de C.V.', regimenFiscal: '601' }, + ]; + + const receptores = [ + { rfc: 'CLI987654321', nombre: 'Cliente Uno S.A.', usoCFDI: 'G03' }, + { rfc: 'PRO456789123', nombre: 'Proveedor Alpha', usoCFDI: 'G01' }, + { rfc: 'SER789123456', nombre: 'Servicios Beta S.A.', usoCFDI: 'G03' }, + { rfc: 'TEC321654987', nombre: 'Tech Solutions', usoCFDI: 'G01' }, + { rfc: 'DIS654987321', nombre: 'Distribuidora Nacional', usoCFDI: 'G03' }, + ]; + + const cfdis: CFDI[] = []; + + for (let i = 0; i < 80; i++) { + const isEmitted = Math.random() > 0.4; + const tipo: CFDIType = (['ingreso', 'egreso', 'pago'] as CFDIType[])[Math.floor(Math.random() * 3)]; + const date = new Date(); + date.setDate(date.getDate() - Math.floor(Math.random() * 90)); + const total = Math.floor(Math.random() * 100000) + 1000; + const pagado = Math.random() > 0.3 ? (Math.random() > 0.5 ? total : Math.floor(total * Math.random())) : 0; + + const receptor = receptores[Math.floor(Math.random() * receptores.length)]; + + let paymentStatus: PaymentStatus = 'pendiente'; + if (pagado >= total) paymentStatus = 'pagado'; + else if (pagado > 0) paymentStatus = 'parcial'; + else if (date < new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)) paymentStatus = 'vencido'; + + cfdis.push({ + id: `cfdi-${i + 1}`, + uuid: `${Math.random().toString(36).substr(2, 8)}-${Math.random().toString(36).substr(2, 4)}-${Math.random().toString(36).substr(2, 4)}-${Math.random().toString(36).substr(2, 4)}-${Math.random().toString(36).substr(2, 12)}`.toUpperCase(), + serie: 'A', + folio: String(1000 + i), + fecha: date.toISOString(), + fechaTimbrado: date.toISOString(), + tipo, + tipoComprobante: tipo === 'ingreso' ? 'I' : tipo === 'egreso' ? 'E' : 'P', + metodoPago: ['PUE', 'PPD'][Math.floor(Math.random() * 2)], + formaPago: ['01', '03', '04', '28'][Math.floor(Math.random() * 4)], + status: Math.random() > 0.1 ? 'vigente' : 'cancelado', + paymentStatus, + emisor: isEmitted ? emisores[0] : { ...receptor, regimenFiscal: '601' }, + receptor: isEmitted ? receptor : emisores[0], + conceptos: [ + { + claveProdServ: '84111506', + cantidad: 1, + claveUnidad: 'E48', + unidad: 'Servicio', + descripcion: `Servicio profesional ${i + 1}`, + valorUnitario: total / 1.16, + importe: total / 1.16, + impuestos: { + traslados: [{ impuesto: '002', tasa: 0.16, importe: (total / 1.16) * 0.16 }], + }, + }, + ], + subtotal: total / 1.16, + impuestos: { + totalTraslados: (total / 1.16) * 0.16, + totalRetenciones: 0, + }, + total, + moneda: 'MXN', + montoPagado: pagado, + montoPendiente: total - pagado, + pagosRelacionados: pagado > 0 && tipo !== 'pago' ? [ + { id: `pago-${i}`, uuid: `pago-uuid-${i}`, fecha: date.toISOString(), monto: pagado }, + ] : undefined, + xmlUrl: `/api/cfdis/${i}/xml`, + pdfUrl: `/api/cfdis/${i}/pdf`, + createdAt: date.toISOString(), + updatedAt: date.toISOString(), + }); + } + + return cfdis.sort((a, b) => new Date(b.fecha).getTime() - new Date(a.fecha).getTime()); +}; + +// ============================================================================ +// Loading Skeleton +// ============================================================================ + +function CFDISkeleton() { + return ( +
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+
+
+
+ ))} +
+
+ ); +} + +// ============================================================================ +// Payment Status Badge +// ============================================================================ + +interface PaymentBadgeProps { + status: PaymentStatus; + montoPagado: number; + total: number; +} + +function PaymentBadge({ status, montoPagado, total }: PaymentBadgeProps) { + const configs: Record = { + pagado: { + icon: , + label: 'Pagado', + classes: 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400', + }, + parcial: { + icon: , + label: `${Math.round((montoPagado / total) * 100)}% Pagado`, + classes: 'bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-400', + }, + pendiente: { + icon: , + label: 'Pendiente', + classes: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', + }, + vencido: { + icon: , + label: 'Vencido', + classes: 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400', + }, + }; + + const config = configs[status]; + + return ( + + {config.icon} + {config.label} + + ); +} + +// ============================================================================ +// Filter Panel +// ============================================================================ + +interface FilterPanelProps { + filters: Filters; + onChange: (filters: Filters) => void; + onClose: () => void; + isOpen: boolean; +} + +function FilterPanel({ filters, onChange, onClose, isOpen }: FilterPanelProps) { + if (!isOpen) return null; + + return ( +
+
+

Filtros

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + onChange({ ...filters, rfc: e.target.value.toUpperCase() })} + className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm" + /> +
+ +
+ + onChange({ ...filters, dateFrom: e.target.value })} + className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm" + /> +
+ +
+ + onChange({ ...filters, dateTo: e.target.value })} + className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm" + /> +
+
+ +
+ + +
+
+ ); +} + +// ============================================================================ +// Main Page Component +// ============================================================================ + +export default function CFDIsPage() { + const [cfdis, setCfdis] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState('emitidos'); + const [showFilters, setShowFilters] = useState(false); + const [page, setPage] = useState(1); + const [filters, setFilters] = useState({ + search: '', + tipo: 'all', + status: 'all', + paymentStatus: 'all', + rfc: '', + dateFrom: '', + dateTo: '', + }); + + const limit = 20; + const myRFC = 'EMP123456789'; + + const fetchCFDIs = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + await new Promise((resolve) => setTimeout(resolve, 600)); + setCfdis(generateMockCFDIs()); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar CFDIs'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchCFDIs(); + }, [fetchCFDIs]); + + // Filter CFDIs by tab and filters + const filteredCFDIs = useMemo(() => { + return cfdis.filter((cfdi) => { + // Tab filter + if (activeTab === 'emitidos' && cfdi.emisor.rfc !== myRFC) return false; + if (activeTab === 'recibidos' && cfdi.receptor.rfc !== myRFC) return false; + if (activeTab === 'complementos' && cfdi.tipo !== 'pago') return false; + + // Search filter + if (filters.search) { + const searchLower = filters.search.toLowerCase(); + if ( + !cfdi.uuid.toLowerCase().includes(searchLower) && + !cfdi.emisor.nombre.toLowerCase().includes(searchLower) && + !cfdi.receptor.nombre.toLowerCase().includes(searchLower) && + !cfdi.folio?.toLowerCase().includes(searchLower) + ) { + return false; + } + } + + // Other filters + if (filters.tipo !== 'all' && cfdi.tipo !== filters.tipo) return false; + if (filters.status !== 'all' && cfdi.status !== filters.status) return false; + if (filters.paymentStatus !== 'all' && cfdi.paymentStatus !== filters.paymentStatus) return false; + if (filters.rfc) { + if (!cfdi.emisor.rfc.includes(filters.rfc) && !cfdi.receptor.rfc.includes(filters.rfc)) return false; + } + if (filters.dateFrom && cfdi.fecha < filters.dateFrom) return false; + if (filters.dateTo && cfdi.fecha > filters.dateTo) return false; + + return true; + }); + }, [cfdis, activeTab, filters, myRFC]); + + const paginatedCFDIs = useMemo(() => { + const start = (page - 1) * limit; + return filteredCFDIs.slice(start, start + limit); + }, [filteredCFDIs, page]); + + const totalPages = Math.ceil(filteredCFDIs.length / limit); + + const summary = useMemo(() => { + const total = filteredCFDIs.reduce((sum, c) => sum + c.total, 0); + const pagado = filteredCFDIs.reduce((sum, c) => sum + c.montoPagado, 0); + const pendiente = filteredCFDIs.reduce((sum, c) => sum + c.montoPendiente, 0); + return { total, pagado, pendiente, count: filteredCFDIs.length }; + }, [filteredCFDIs]); + + const tipoLabels: Record = { + ingreso: 'Ingreso', + egreso: 'Egreso', + traslado: 'Traslado', + nomina: 'Nomina', + pago: 'Pago', + }; + + const handleViewCFDI = (cfdi: CFDI) => { + window.location.href = `/cfdis/${cfdi.id}`; + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

CFDIs

+

+ Gestion de comprobantes fiscales digitales +

+
+
+ +
+
+ + {/* Tabs */} +
+ {([ + { value: 'emitidos', label: 'Emitidos' }, + { value: 'recibidos', label: 'Recibidos' }, + { value: 'complementos', label: 'Complementos de Pago' }, + ] as { value: CFDITab; label: string }[]).map((tab) => ( + + ))} +
+ + {/* Summary Cards */} +
+
+
+
+ +
+
+

Total CFDIs

+

{formatNumber(summary.count)}

+
+
+
+
+
+
+ +
+
+

Monto Total

+

{formatCurrency(summary.total)}

+
+
+
+
+
+
+ +
+
+

Pagado

+

{formatCurrency(summary.pagado)}

+
+
+
+
+
+
+ +
+
+

Pendiente

+

{formatCurrency(summary.pendiente)}

+
+
+
+
+ + {/* Search and Filters */} +
+
+ + setFilters({ ...filters, search: e.target.value })} + className="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+ +
+ + {/* Filter Panel */} + setShowFilters(false)} isOpen={showFilters} /> + + {/* CFDIs Table */} +
+
+ + + + + + + + + + + + + + + {paginatedCFDIs.map((cfdi) => { + const contacto = activeTab === 'emitidos' ? cfdi.receptor : cfdi.emisor; + return ( + handleViewCFDI(cfdi)} + className="hover:bg-gray-50 dark:hover:bg-gray-900/50 cursor-pointer" + > + + + + + + + + + + ); + })} + +
+ Folio / UUID + + Fecha + + {activeTab === 'emitidos' ? 'Receptor' : 'Emisor'} + + Tipo + + Total + + Estado CFDI + + Estado Pago + + Acciones +
+
+

+ {cfdi.serie}-{cfdi.folio} +

+

+ {cfdi.uuid.slice(0, 8)}... +

+
+
+ {formatDate(cfdi.fecha)} + +
+

{contacto.nombre}

+

{contacto.rfc}

+
+
+ + {tipoLabels[cfdi.tipo]} + + + {formatCurrency(cfdi.total, cfdi.moneda)} + + + {cfdi.status === 'vigente' && } + {cfdi.status === 'cancelado' && } + {cfdi.status === 'pendiente_cancelacion' && } + {cfdi.status === 'vigente' ? 'Vigente' : cfdi.status === 'cancelado' ? 'Cancelado' : 'Pend. Cancel.'} + + + + +
+ + e.stopPropagation()} + className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" + title="Descargar XML" + > + + +
+
+
+ + {/* Pagination */} +
+

+ Mostrando {(page - 1) * limit + 1} a {Math.min(page * limit, filteredCFDIs.length)} de{' '} + {filteredCFDIs.length} CFDIs +

+
+ + + Pagina {page} de {totalPages || 1} + + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/dashboard/page.tsx b/apps/web/src/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..df60846 --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,441 @@ +'use client'; + +import React from 'react'; +import { Card, CardHeader, CardContent, StatsCard } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { cn, formatCurrency, formatPercentage, formatNumber } from '@/lib/utils'; +import { + TrendingUp, + TrendingDown, + Wallet, + Activity, + BarChart3, + ArrowUpRight, + ArrowDownRight, + Bot, + Target, + Clock, + AlertTriangle, + Plus, + RefreshCw, +} from 'lucide-react'; + +/** + * Dashboard Page + * + * Pagina principal del dashboard con KPIs, grafico de portfolio, + * estrategias activas y trades recientes. + */ +export default function DashboardPage() { + return ( +
+ {/* Page Header */} +
+
+

+ Dashboard +

+

+ Bienvenido de nuevo. Aqui esta el resumen de tu portfolio. +

+
+
+ + +
+
+ + {/* KPI Cards */} +
+ } + /> + } + /> + } + /> + } + /> +
+ + {/* Main Content Grid */} +
+ {/* Portfolio Chart - Takes 2 columns */} + + + + + + +
+ } + /> + + {/* Chart Placeholder */} +
+
+ +

+ Grafico de rendimiento +

+

+ Conecta con Recharts para visualizacion +

+
+
+ + {/* Chart Stats */} +
+
+

Maximo

+

+ {formatCurrency(132450.00)} +

+
+
+

Minimo

+

+ {formatCurrency(98320.00)} +

+
+
+

Promedio

+

+ {formatCurrency(115385.00)} +

+
+
+
+ + + {/* Active Strategies */} + + + Ver todas + + } + /> + +
+ {strategies.map((strategy) => ( + + ))} +
+
+
+
+ + {/* Second Row */} +
+ {/* Recent Trades */} + + + Ver historial + + } + /> + +
+ {recentTrades.map((trade) => ( + + ))} +
+
+
+ + {/* Market Overview */} + + + + En vivo + + } + /> + +
+ {marketData.map((market) => ( + + ))} +
+
+
+
+ + {/* Alerts Section */} + + + Configurar alertas + + } + /> + +
+ {alerts.map((alert) => ( + + ))} +
+
+
+
+ ); +} + +// ============================================ +// Mock Data +// ============================================ + +interface Strategy { + id: string; + name: string; + type: string; + status: 'running' | 'paused' | 'stopped'; + profit: number; + trades: number; +} + +const strategies: Strategy[] = [ + { id: '1', name: 'Grid BTC/USDT', type: 'Grid Trading', status: 'running', profit: 12.5, trades: 45 }, + { id: '2', name: 'DCA ETH', type: 'DCA', status: 'running', profit: 8.2, trades: 12 }, + { id: '3', name: 'Scalping SOL', type: 'Scalping', status: 'paused', profit: -2.1, trades: 128 }, +]; + +interface Trade { + id: string; + pair: string; + type: 'buy' | 'sell'; + amount: number; + price: number; + profit?: number; + time: string; +} + +const recentTrades: Trade[] = [ + { id: '1', pair: 'BTC/USDT', type: 'buy', amount: 0.05, price: 43250, time: '10:32' }, + { id: '2', pair: 'ETH/USDT', type: 'sell', amount: 1.2, price: 2280, profit: 45.20, time: '10:15' }, + { id: '3', pair: 'SOL/USDT', type: 'buy', amount: 10, price: 98.5, time: '09:58' }, + { id: '4', pair: 'BTC/USDT', type: 'sell', amount: 0.08, price: 43180, profit: 120.50, time: '09:45' }, +]; + +interface Market { + symbol: string; + name: string; + price: number; + change: number; +} + +const marketData: Market[] = [ + { symbol: 'BTC', name: 'Bitcoin', price: 43250.00, change: 2.34 }, + { symbol: 'ETH', name: 'Ethereum', price: 2280.50, change: 1.82 }, + { symbol: 'SOL', name: 'Solana', price: 98.45, change: -0.54 }, + { symbol: 'BNB', name: 'BNB', price: 312.80, change: 0.92 }, +]; + +interface Alert { + id: string; + type: 'warning' | 'info' | 'success'; + title: string; + message: string; + time: string; +} + +const alerts: Alert[] = [ + { id: '1', type: 'warning', title: 'Stop Loss cercano', message: 'BTC/USDT esta a 2% del stop loss', time: '5 min' }, + { id: '2', type: 'success', title: 'Take Profit alcanzado', message: 'ETH/USDT cerro con +3.5%', time: '15 min' }, + { id: '3', type: 'info', title: 'Nueva señal', message: 'SOL/USDT señal de compra detectada', time: '30 min' }, +]; + +// ============================================ +// Sub-components +// ============================================ + +function StrategyItem({ strategy }: { strategy: Strategy }) { + const statusStyles = { + running: 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400', + paused: 'bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-400', + stopped: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-400', + }; + + return ( +
+
+
+ +
+
+

{strategy.name}

+

{strategy.type}

+
+
+
+

= 0 ? 'text-success-600 dark:text-success-400' : 'text-error-600 dark:text-error-400' + )}> + {formatPercentage(strategy.profit)} +

+ + {strategy.status === 'running' ? 'Activo' : strategy.status === 'paused' ? 'Pausado' : 'Detenido'} + +
+
+ ); +} + +function TradeItem({ trade }: { trade: Trade }) { + return ( +
+
+
+ {trade.type === 'buy' ? ( + + ) : ( + + )} +
+
+

{trade.pair}

+

+ {trade.type === 'buy' ? 'Compra' : 'Venta'} - {trade.amount} @ {formatCurrency(trade.price)} +

+
+
+
+ {trade.profit !== undefined ? ( +

= 0 ? 'text-success-600 dark:text-success-400' : 'text-error-600 dark:text-error-400' + )}> + {trade.profit >= 0 ? '+' : ''}{formatCurrency(trade.profit)} +

+ ) : ( +

Abierto

+ )} +

{trade.time}

+
+
+ ); +} + +function MarketItem({ market }: { market: Market }) { + const isPositive = market.change >= 0; + + return ( +
+
+
+ {market.symbol.slice(0, 1)} +
+
+

{market.symbol}

+

{market.name}

+
+
+
+

+ {formatCurrency(market.price)} +

+

+ {isPositive ? : } + {formatPercentage(market.change)} +

+
+
+ ); +} + +function AlertItem({ alert }: { alert: Alert }) { + const typeStyles = { + warning: { + bg: 'bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800', + icon: 'text-warning-600 dark:text-warning-400', + }, + success: { + bg: 'bg-success-50 dark:bg-success-900/20 border-success-200 dark:border-success-800', + icon: 'text-success-600 dark:text-success-400', + }, + info: { + bg: 'bg-primary-50 dark:bg-primary-900/20 border-primary-200 dark:border-primary-800', + icon: 'text-primary-600 dark:text-primary-400', + }, + }; + + const icons = { + warning: , + success: , + info: , + }; + + return ( +
+
+ {icons[alert.type]} +
+

{alert.title}

+

{alert.message}

+

+ + Hace {alert.time} +

+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..9a310a7 --- /dev/null +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -0,0 +1,99 @@ +'use client'; + +import React, { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/stores/auth.store'; +import { useUIStore } from '@/stores/ui.store'; +import { Sidebar } from '@/components/layout/Sidebar'; +import { Header } from '@/components/layout/Header'; +import { cn } from '@/lib/utils'; + +/** + * Dashboard Layout + * + * Layout principal para las paginas del dashboard. + * Incluye sidebar, header y manejo de responsive. + */ +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + const router = useRouter(); + const { isAuthenticated, isInitialized, checkAuth, isLoading } = useAuthStore(); + const { sidebarCollapsed, isMobile, setIsMobile } = useUIStore(); + + // Check auth on mount + useEffect(() => { + checkAuth(); + }, [checkAuth]); + + // Redirect to login if not authenticated + useEffect(() => { + if (isInitialized && !isAuthenticated) { + router.replace('/login'); + } + }, [isAuthenticated, isInitialized, router]); + + // Handle responsive + useEffect(() => { + const checkMobile = () => { + setIsMobile(window.innerWidth < 1024); + }; + + // Check on mount + checkMobile(); + + // Listen for resize + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, [setIsMobile]); + + // Show loading while checking auth + if (!isInitialized || isLoading) { + return ( +
+
+
+
+
+ H +
+
+

Cargando...

+
+
+ ); + } + + // Don't render if not authenticated + if (!isAuthenticated) { + return null; + } + + return ( +
+ {/* Sidebar */} + + + {/* Main Content Area */} +
+ {/* Header */} +
+ + {/* Page Content */} +
+
+ {children} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/metricas/[code]/page.tsx b/apps/web/src/app/(dashboard)/metricas/[code]/page.tsx new file mode 100644 index 0000000..87649a4 --- /dev/null +++ b/apps/web/src/app/(dashboard)/metricas/[code]/page.tsx @@ -0,0 +1,769 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { + LineChart, + Line, + AreaChart, + Area, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, + ReferenceLine, +} from 'recharts'; +import { + ArrowLeft, + TrendingUp, + TrendingDown, + ArrowUpRight, + ArrowDownRight, + Calendar, + Download, + RefreshCw, + Target, + Info, + ChevronDown, +} from 'lucide-react'; +import { cn, formatCurrency, formatNumber, formatPercentage, formatDate } from '@/lib/utils'; +import { api } from '@/lib/api'; + +// ============================================================================ +// Types +// ============================================================================ + +interface MetricValue { + date: string; + value: number; + previousValue?: number; + change?: number; + changePercent?: number; +} + +interface MetricDetail { + code: string; + name: string; + description: string; + formula?: string; + value: number; + previousValue: number; + change: number; + changePercent: number; + format: 'currency' | 'number' | 'percentage'; + category: 'core' | 'startup' | 'enterprise'; + trend: 'up' | 'down' | 'neutral'; + target?: number; + benchmark?: number; + history: MetricValue[]; + periodComparison: { + period: string; + current: number; + previous: number; + change: number; + changePercent: number; + }[]; +} + +type ChartType = 'line' | 'area' | 'bar'; +type PeriodType = '7d' | '30d' | '90d' | '12m' | 'ytd' | 'all'; + +// ============================================================================ +// Mock Data +// ============================================================================ + +const generateMockData = (code: string): MetricDetail => { + const isRevenue = ['MRR', 'ARR', 'REVENUE', 'LTV', 'ARPU', 'NET_INCOME'].includes(code); + const isCost = ['CAC', 'EXPENSES', 'BURN_RATE'].includes(code); + const isPercentage = ['GROSS_MARGIN', 'CHURN', 'NRR', 'DAU_MAU'].includes(code); + const isCount = ['ACTIVE_CUSTOMERS', 'NPS', 'SUPPORT_TICKETS', 'RESOLUTION_TIME', 'LTV_CAC'].includes(code); + + const baseValue = isRevenue ? 125000 : isCost ? 2500 : isPercentage ? 72.5 : 342; + const variance = baseValue * 0.1; + + const history: MetricValue[] = Array.from({ length: 365 }, (_, i) => { + const date = new Date(); + date.setDate(date.getDate() - (364 - i)); + const trendFactor = i / 365; + const seasonalFactor = Math.sin(i / 30) * 0.05; + const randomFactor = (Math.random() - 0.5) * 0.1; + const value = baseValue * (0.7 + trendFactor * 0.6 + seasonalFactor + randomFactor); + + return { + date: date.toISOString().split('T')[0], + value: Math.round(value * 100) / 100, + }; + }); + + // Add comparison data + history.forEach((item, index) => { + if (index >= 30) { + item.previousValue = history[index - 30].value; + item.change = item.value - item.previousValue; + item.changePercent = (item.change / item.previousValue) * 100; + } + }); + + const metricNames: Record = { + MRR: { + name: 'Monthly Recurring Revenue', + description: 'Ingresos recurrentes mensuales provenientes de suscripciones activas', + formula: 'Suma de todas las suscripciones activas mensuales', + }, + ARR: { + name: 'Annual Recurring Revenue', + description: 'Ingresos recurrentes anuales (MRR x 12)', + formula: 'MRR x 12', + }, + REVENUE: { + name: 'Ingresos Totales', + description: 'Total de ingresos del periodo incluyendo one-time y recurrentes', + }, + EXPENSES: { + name: 'Gastos Totales', + description: 'Total de gastos operativos del periodo', + }, + GROSS_MARGIN: { + name: 'Margen Bruto', + description: 'Porcentaje de ingresos despues de costos directos', + formula: '(Ingresos - Costos Directos) / Ingresos x 100', + }, + NET_INCOME: { + name: 'Utilidad Neta', + description: 'Ingresos totales menos todos los gastos', + formula: 'Ingresos Totales - Gastos Totales', + }, + CAC: { + name: 'Customer Acquisition Cost', + description: 'Costo promedio para adquirir un nuevo cliente', + formula: 'Gastos de Marketing y Ventas / Nuevos Clientes', + }, + LTV: { + name: 'Customer Lifetime Value', + description: 'Valor total esperado de un cliente durante toda su relacion', + formula: 'ARPU / Churn Rate', + }, + LTV_CAC: { + name: 'LTV/CAC Ratio', + description: 'Ratio que indica el retorno de inversion en adquisicion', + formula: 'LTV / CAC', + }, + CHURN: { + name: 'Churn Rate', + description: 'Porcentaje mensual de clientes que cancelan', + formula: 'Clientes Cancelados / Clientes Inicio de Periodo x 100', + }, + NRR: { + name: 'Net Revenue Retention', + description: 'Retencion de ingresos incluyendo expansiones', + formula: '(MRR Inicio + Expansion - Contraction - Churn) / MRR Inicio x 100', + }, + BURN_RATE: { + name: 'Burn Rate', + description: 'Tasa mensual de consumo de capital', + formula: 'Gastos Mensuales - Ingresos Mensuales', + }, + ACTIVE_CUSTOMERS: { + name: 'Clientes Activos', + description: 'Numero de clientes con suscripcion activa', + }, + ARPU: { + name: 'Average Revenue Per User', + description: 'Ingreso promedio mensual por cliente', + formula: 'MRR / Clientes Activos', + }, + NPS: { + name: 'Net Promoter Score', + description: 'Indice de satisfaccion y lealtad del cliente', + formula: '% Promotores - % Detractores', + }, + DAU_MAU: { + name: 'DAU/MAU Ratio', + description: 'Engagement de usuarios activos', + formula: 'Usuarios Activos Diarios / Usuarios Activos Mensuales', + }, + SUPPORT_TICKETS: { + name: 'Tickets de Soporte', + description: 'Cantidad de tickets abiertos en el periodo', + }, + RESOLUTION_TIME: { + name: 'Tiempo de Resolucion', + description: 'Tiempo promedio para resolver un ticket (horas)', + }, + }; + + const info = metricNames[code] || { name: code, description: '' }; + const currentValue = history[history.length - 1].value; + const previousValue = history[history.length - 31]?.value || currentValue * 0.95; + + return { + code, + name: info.name, + description: info.description, + formula: info.formula, + value: currentValue, + previousValue, + change: currentValue - previousValue, + changePercent: ((currentValue - previousValue) / previousValue) * 100, + format: isPercentage ? 'percentage' : isCount ? 'number' : 'currency', + category: ['MRR', 'ARR', 'REVENUE', 'EXPENSES', 'GROSS_MARGIN', 'NET_INCOME'].includes(code) + ? 'core' + : ['CAC', 'LTV', 'LTV_CAC', 'CHURN', 'NRR', 'BURN_RATE'].includes(code) + ? 'startup' + : 'enterprise', + trend: currentValue > previousValue ? 'up' : 'down', + target: currentValue * 1.2, + benchmark: currentValue * 0.9, + history, + periodComparison: [ + { + period: 'Esta semana', + current: currentValue, + previous: currentValue * 0.97, + change: currentValue * 0.03, + changePercent: 3.09, + }, + { + period: 'Este mes', + current: currentValue, + previous: previousValue, + change: currentValue - previousValue, + changePercent: ((currentValue - previousValue) / previousValue) * 100, + }, + { + period: 'Este trimestre', + current: currentValue, + previous: currentValue * 0.88, + change: currentValue * 0.12, + changePercent: 13.64, + }, + { + period: 'Este ano', + current: currentValue, + previous: currentValue * 0.65, + change: currentValue * 0.35, + changePercent: 53.85, + }, + ], + }; +}; + +// ============================================================================ +// Loading Skeleton +// ============================================================================ + +function MetricDetailSkeleton() { + return ( +
+ {/* Header */} +
+
+
+
+
+
+
+ + {/* Value Card */} +
+
+
+
+ + {/* Chart */} +
+
+
+
+
+ ); +} + +// ============================================================================ +// Main Component +// ============================================================================ + +export default function MetricDetailPage() { + const params = useParams(); + const router = useRouter(); + const code = params.code as string; + + const [metric, setMetric] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [period, setPeriod] = useState('30d'); + const [chartType, setChartType] = useState('area'); + const [showComparison, setShowComparison] = useState(true); + + const fetchMetricDetail = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + // In production, this would be an API call + // const response = await api.get(`/metrics/${code}`, { params: { period } }); + // setMetric(response.data ?? null); + + // Mock data for development + await new Promise((resolve) => setTimeout(resolve, 600)); + setMetric(generateMockData(code.toUpperCase())); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar metrica'); + } finally { + setIsLoading(false); + } + }, [code, period]); + + useEffect(() => { + fetchMetricDetail(); + }, [fetchMetricDetail]); + + const formatValue = (value: number) => { + if (!metric) return value.toString(); + switch (metric.format) { + case 'currency': + return formatCurrency(value); + case 'percentage': + return `${value.toFixed(2)}%`; + case 'number': + default: + return formatNumber(value, value % 1 === 0 ? 0 : 2); + } + }; + + const getFilteredHistory = () => { + if (!metric) return []; + const now = new Date(); + const history = metric.history; + + switch (period) { + case '7d': + return history.slice(-7); + case '30d': + return history.slice(-30); + case '90d': + return history.slice(-90); + case '12m': + return history.slice(-365); + case 'ytd': + const startOfYear = new Date(now.getFullYear(), 0, 1); + return history.filter((h) => new Date(h.date) >= startOfYear); + case 'all': + default: + return history; + } + }; + + const handleExport = () => { + if (!metric) return; + const data = getFilteredHistory(); + const csv = [ + ['Fecha', 'Valor', 'Valor Anterior', 'Cambio', 'Cambio %'].join(','), + ...data.map((row) => + [row.date, row.value, row.previousValue || '', row.change || '', row.changePercent || ''].join(',') + ), + ].join('\n'); + + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${metric.code}_${period}.csv`; + a.click(); + }; + + if (isLoading) { + return ; + } + + if (error || !metric) { + return ( +
+
+

{error || 'Metrica no encontrada'}

+ +
+
+ ); + } + + const isInverseTrend = ['CAC', 'CHURN', 'BURN_RATE', 'EXPENSES', 'SUPPORT_TICKETS', 'RESOLUTION_TIME'].includes( + metric.code + ); + const isGoodTrend = isInverseTrend ? metric.change < 0 : metric.change > 0; + const filteredHistory = getFilteredHistory(); + + return ( +
+ {/* Header */} +
+
+ +
+
+

{metric.name}

+ + {metric.code} + +
+

{metric.description}

+ {metric.formula && ( +

+ Formula: {metric.formula} +

+ )} +
+
+
+ + +
+
+ + {/* Value Card */} +
+
+
+
+

Valor Actual

+

{formatValue(metric.value)}

+
+
+ {isGoodTrend ? : } + {formatPercentage(Math.abs(metric.changePercent))} +
+ vs periodo anterior +
+
+ {isGoodTrend ? ( + + ) : ( + + )} +
+ + {/* Target Progress */} + {metric.target && ( +
+
+
+ + Meta +
+ {formatValue(metric.target)} +
+
+
= metric.target + ? 'bg-success-500' + : metric.value >= metric.target * 0.75 + ? 'bg-primary-500' + : metric.value >= metric.target * 0.5 + ? 'bg-warning-500' + : 'bg-error-500' + )} + style={{ width: `${Math.min(100, (metric.value / metric.target) * 100)}%` }} + /> +
+

+ {((metric.value / metric.target) * 100).toFixed(1)}% completado +

+
+ )} +
+ + {/* Period Comparison */} +
+

Comparativos

+
+ {metric.periodComparison.map((comp) => ( +
+ {comp.period} +
+

{formatValue(comp.current)}

+

= 0 ? 'text-success-600 dark:text-success-400' : 'text-error-600 dark:text-error-400' + )} + > + {formatPercentage(comp.changePercent)} +

+
+
+ ))} +
+
+
+ + {/* Chart Controls */} +
+
+ {(['7d', '30d', '90d', '12m', 'ytd', 'all'] as PeriodType[]).map((p) => ( + + ))} +
+
+ {(['area', 'line', 'bar'] as ChartType[]).map((type) => ( + + ))} +
+
+ + {/* Main Chart */} +
+
+

Historial

+ +
+
+ + {chartType === 'bar' ? ( + + + formatDate(value, { day: 'numeric', month: 'short' })} + /> + formatValue(v)} /> + [formatValue(value), 'Valor']} + labelFormatter={(label) => formatDate(label)} + /> + {showComparison && } + + {metric.target && } + + ) : chartType === 'line' ? ( + + + formatDate(value, { day: 'numeric', month: 'short' })} + /> + formatValue(v)} /> + [formatValue(value), 'Valor']} + labelFormatter={(label) => formatDate(label)} + /> + + {showComparison && ( + + )} + + {metric.target && } + + ) : ( + + + + + + + + + + + + + formatDate(value, { day: 'numeric', month: 'short' })} + /> + formatValue(v)} /> + [formatValue(value), 'Valor']} + labelFormatter={(label) => formatDate(label)} + /> + + {showComparison && ( + + )} + + {metric.target && } + + )} + +
+
+ + {/* Data Table */} +
+
+

Valores por Periodo

+
+
+ + + + + + + + + + + + {filteredHistory + .slice(-30) + .reverse() + .map((row) => ( + + + + + + + + ))} + +
+ Fecha + + Valor + + Anterior + + Cambio + + Cambio % +
+ {formatDate(row.date)} + + {formatValue(row.value)} + + {row.previousValue ? formatValue(row.previousValue) : '-'} + + {row.change !== undefined ? ( + = 0 ? 'text-success-600' : 'text-error-600'}> + {row.change >= 0 ? '+' : ''} + {formatValue(row.change)} + + ) : ( + '-' + )} + + {row.changePercent !== undefined ? ( + = 0 + ? 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400' + : 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400' + )} + > + {formatPercentage(row.changePercent)} + + ) : ( + '-' + )} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/metricas/page.tsx b/apps/web/src/app/(dashboard)/metricas/page.tsx new file mode 100644 index 0000000..5254a7b --- /dev/null +++ b/apps/web/src/app/(dashboard)/metricas/page.tsx @@ -0,0 +1,824 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { + LineChart, + Line, + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from 'recharts'; +import { + TrendingUp, + TrendingDown, + Users, + DollarSign, + Activity, + Calendar, + RefreshCw, + ArrowUpRight, + ArrowDownRight, + Target, + Zap, + Building2, + ChevronRight, +} from 'lucide-react'; +import { cn, formatCurrency, formatNumber, formatPercentage } from '@/lib/utils'; +import { api } from '@/lib/api'; + +// ============================================================================ +// Types +// ============================================================================ + +interface Metric { + code: string; + name: string; + description: string; + value: number; + previousValue: number; + change: number; + changePercent: number; + format: 'currency' | 'number' | 'percentage'; + category: 'core' | 'startup' | 'enterprise'; + trend: 'up' | 'down' | 'neutral'; + target?: number; + history: { date: string; value: number }[]; +} + +interface MetricsResponse { + metrics: Metric[]; + period: string; + updatedAt: string; +} + +type MetricCategory = 'core' | 'startup' | 'enterprise'; +type PeriodType = '7d' | '30d' | '90d' | '12m' | 'ytd'; + +// ============================================================================ +// Mock Data for Development +// ============================================================================ + +const mockMetrics: Metric[] = [ + // Core Metrics + { + code: 'MRR', + name: 'Monthly Recurring Revenue', + description: 'Ingresos recurrentes mensuales', + value: 125000, + previousValue: 118000, + change: 7000, + changePercent: 5.93, + format: 'currency', + category: 'core', + trend: 'up', + target: 150000, + history: Array.from({ length: 12 }, (_, i) => ({ + date: `2024-${String(i + 1).padStart(2, '0')}`, + value: 80000 + i * 5000 + Math.random() * 3000, + })), + }, + { + code: 'ARR', + name: 'Annual Recurring Revenue', + description: 'Ingresos recurrentes anuales', + value: 1500000, + previousValue: 1416000, + change: 84000, + changePercent: 5.93, + format: 'currency', + category: 'core', + trend: 'up', + target: 1800000, + history: Array.from({ length: 12 }, (_, i) => ({ + date: `2024-${String(i + 1).padStart(2, '0')}`, + value: 960000 + i * 60000 + Math.random() * 36000, + })), + }, + { + code: 'REVENUE', + name: 'Ingresos Totales', + description: 'Ingresos totales del periodo', + value: 245000, + previousValue: 230000, + change: 15000, + changePercent: 6.52, + format: 'currency', + category: 'core', + trend: 'up', + history: Array.from({ length: 12 }, (_, i) => ({ + date: `2024-${String(i + 1).padStart(2, '0')}`, + value: 180000 + i * 8000 + Math.random() * 10000, + })), + }, + { + code: 'EXPENSES', + name: 'Gastos Totales', + description: 'Gastos totales del periodo', + value: 180000, + previousValue: 175000, + change: 5000, + changePercent: 2.86, + format: 'currency', + category: 'core', + trend: 'down', + history: Array.from({ length: 12 }, (_, i) => ({ + date: `2024-${String(i + 1).padStart(2, '0')}`, + value: 150000 + i * 3000 + Math.random() * 5000, + })), + }, + { + code: 'GROSS_MARGIN', + name: 'Margen Bruto', + description: 'Porcentaje de margen bruto', + value: 72.5, + previousValue: 70.2, + change: 2.3, + changePercent: 3.28, + format: 'percentage', + category: 'core', + trend: 'up', + target: 75, + history: Array.from({ length: 12 }, (_, i) => ({ + date: `2024-${String(i + 1).padStart(2, '0')}`, + value: 65 + i * 0.8 + Math.random() * 2, + })), + }, + { + code: 'NET_INCOME', + name: 'Utilidad Neta', + description: 'Ingresos menos gastos', + value: 65000, + previousValue: 55000, + change: 10000, + changePercent: 18.18, + format: 'currency', + category: 'core', + trend: 'up', + history: Array.from({ length: 12 }, (_, i) => ({ + date: `2024-${String(i + 1).padStart(2, '0')}`, + value: 30000 + i * 4000 + Math.random() * 5000, + })), + }, + // Startup Metrics + { + code: 'CAC', + name: 'Customer Acquisition Cost', + description: 'Costo de adquisicion por cliente', + value: 2500, + previousValue: 2800, + change: -300, + changePercent: -10.71, + format: 'currency', + category: 'startup', + trend: 'up', + target: 2000, + history: Array.from({ length: 12 }, (_, i) => ({ + date: `2024-${String(i + 1).padStart(2, '0')}`, + value: 3500 - i * 100 + Math.random() * 200, + })), + }, + { + code: 'LTV', + name: 'Customer Lifetime Value', + description: 'Valor de vida del cliente', + value: 15000, + previousValue: 13500, + change: 1500, + changePercent: 11.11, + format: 'currency', + category: 'startup', + trend: 'up', + history: Array.from({ length: 12 }, (_, i) => ({ + date: `2024-${String(i + 1).padStart(2, '0')}`, + value: 10000 + i * 500 + Math.random() * 500, + })), + }, + { + code: 'LTV_CAC', + name: 'LTV/CAC Ratio', + description: 'Ratio de LTV sobre CAC', + value: 6.0, + previousValue: 4.8, + change: 1.2, + changePercent: 25, + format: 'number', + category: 'startup', + trend: 'up', + target: 5, + history: Array.from({ length: 12 }, (_, i) => ({ + date: `2024-${String(i + 1).padStart(2, '0')}`, + value: 3 + i * 0.3 + Math.random() * 0.5, + })), + }, + { + code: 'CHURN', + name: 'Churn Rate', + description: 'Tasa de cancelacion mensual', + value: 2.5, + previousValue: 3.2, + change: -0.7, + changePercent: -21.88, + format: 'percentage', + category: 'startup', + trend: 'up', + target: 2, + history: Array.from({ length: 12 }, (_, i) => ({ + date: `2024-${String(i + 1).padStart(2, '0')}`, + value: 5 - i * 0.2 + Math.random() * 0.5, + })), + }, + { + code: 'NRR', + name: 'Net Revenue Retention', + description: 'Retencion neta de ingresos', + value: 115, + previousValue: 110, + change: 5, + changePercent: 4.55, + format: 'percentage', + category: 'startup', + trend: 'up', + target: 120, + history: Array.from({ length: 12 }, (_, i) => ({ + date: `2024-${String(i + 1).padStart(2, '0')}`, + value: 100 + i * 1.5 + Math.random() * 3, + })), + }, + { + code: 'BURN_RATE', + name: 'Burn Rate', + description: 'Tasa de quema mensual', + value: 45000, + previousValue: 52000, + change: -7000, + changePercent: -13.46, + format: 'currency', + category: 'startup', + trend: 'up', + history: Array.from({ length: 12 }, (_, i) => ({ + date: `2024-${String(i + 1).padStart(2, '0')}`, + value: 70000 - i * 3000 + Math.random() * 5000, + })), + }, + // Enterprise Metrics + { + code: 'ACTIVE_CUSTOMERS', + name: 'Clientes Activos', + description: 'Numero de clientes activos', + value: 342, + previousValue: 315, + change: 27, + changePercent: 8.57, + format: 'number', + category: 'enterprise', + trend: 'up', + target: 400, + history: Array.from({ length: 12 }, (_, i) => ({ + date: `2024-${String(i + 1).padStart(2, '0')}`, + value: 250 + i * 10 + Math.floor(Math.random() * 10), + })), + }, + { + code: 'ARPU', + name: 'Average Revenue Per User', + description: 'Ingreso promedio por usuario', + value: 365, + previousValue: 350, + change: 15, + changePercent: 4.29, + format: 'currency', + category: 'enterprise', + trend: 'up', + history: Array.from({ length: 12 }, (_, i) => ({ + date: `2024-${String(i + 1).padStart(2, '0')}`, + value: 300 + i * 8 + Math.random() * 15, + })), + }, + { + code: 'NPS', + name: 'Net Promoter Score', + description: 'Indice de promotores neto', + value: 72, + previousValue: 68, + change: 4, + changePercent: 5.88, + format: 'number', + category: 'enterprise', + trend: 'up', + target: 80, + history: Array.from({ length: 12 }, (_, i) => ({ + date: `2024-${String(i + 1).padStart(2, '0')}`, + value: 55 + i * 2 + Math.floor(Math.random() * 5), + })), + }, + { + code: 'DAU_MAU', + name: 'DAU/MAU Ratio', + description: 'Ratio de usuarios activos diarios vs mensuales', + value: 0.45, + previousValue: 0.42, + change: 0.03, + changePercent: 7.14, + format: 'percentage', + category: 'enterprise', + trend: 'up', + target: 0.5, + history: Array.from({ length: 12 }, (_, i) => ({ + date: `2024-${String(i + 1).padStart(2, '0')}`, + value: 0.35 + i * 0.01 + Math.random() * 0.02, + })), + }, + { + code: 'SUPPORT_TICKETS', + name: 'Tickets de Soporte', + description: 'Tickets abiertos este periodo', + value: 89, + previousValue: 120, + change: -31, + changePercent: -25.83, + format: 'number', + category: 'enterprise', + trend: 'up', + history: Array.from({ length: 12 }, (_, i) => ({ + date: `2024-${String(i + 1).padStart(2, '0')}`, + value: 150 - i * 5 + Math.floor(Math.random() * 20), + })), + }, + { + code: 'RESOLUTION_TIME', + name: 'Tiempo de Resolucion', + description: 'Tiempo promedio de resolucion en horas', + value: 4.2, + previousValue: 5.8, + change: -1.6, + changePercent: -27.59, + format: 'number', + category: 'enterprise', + trend: 'up', + target: 4, + history: Array.from({ length: 12 }, (_, i) => ({ + date: `2024-${String(i + 1).padStart(2, '0')}`, + value: 8 - i * 0.4 + Math.random() * 1, + })), + }, +]; + +// ============================================================================ +// Loading Skeleton +// ============================================================================ + +function MetricsSkeleton() { + return ( +
+ {/* Header skeleton */} +
+
+
+
+ + {/* Tabs skeleton */} +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ + {/* Cards skeleton */} +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+
+
+
+
+ ))} +
+
+ ); +} + +// ============================================================================ +// Metric Card Component +// ============================================================================ + +interface MetricCardProps { + metric: Metric; + onClick?: () => void; +} + +function MetricCard({ metric, onClick }: MetricCardProps) { + const formatValue = (value: number, format: Metric['format']) => { + switch (format) { + case 'currency': + return formatCurrency(value); + case 'percentage': + return `${value.toFixed(1)}%`; + case 'number': + default: + return formatNumber(value, value % 1 === 0 ? 0 : 2); + } + }; + + const isPositiveTrend = metric.trend === 'up'; + const TrendIcon = isPositiveTrend ? TrendingUp : TrendingDown; + + // For metrics where lower is better (CAC, Churn, etc.) + const isInverseTrend = ['CAC', 'CHURN', 'BURN_RATE', 'EXPENSES', 'SUPPORT_TICKETS', 'RESOLUTION_TIME'].includes(metric.code); + const isGoodTrend = isInverseTrend ? metric.change < 0 : metric.change > 0; + + const progressPercent = metric.target + ? Math.min(100, (metric.value / metric.target) * 100) + : null; + + return ( +
+
+
+

+ {metric.name} +

+

+ {metric.code} +

+
+ +
+ +
+

+ {formatValue(metric.value, metric.format)} +

+
+ {isGoodTrend ? ( + + ) : ( + + )} + {formatPercentage(Math.abs(metric.changePercent))} +
+
+ + {/* Mini Chart */} +
+ + + + + + + + + + + +
+ + {/* Target Progress */} + {progressPercent !== null && ( +
+
+ Meta + + {formatValue(metric.target!, metric.format)} + +
+
+
= 100 + ? 'bg-success-500' + : progressPercent >= 75 + ? 'bg-primary-500' + : progressPercent >= 50 + ? 'bg-warning-500' + : 'bg-error-500' + )} + style={{ width: `${progressPercent}%` }} + /> +
+
+ )} +
+ ); +} + +// ============================================================================ +// Trend Chart Component +// ============================================================================ + +interface TrendChartProps { + metrics: Metric[]; + selectedMetrics: string[]; +} + +function TrendChart({ metrics, selectedMetrics }: TrendChartProps) { + const selectedData = metrics.filter((m) => selectedMetrics.includes(m.code)); + + if (selectedData.length === 0) return null; + + // Combine data for multiple metrics + const chartData = selectedData[0].history.map((point, index) => { + const dataPoint: Record = { date: point.date }; + selectedData.forEach((metric) => { + dataPoint[metric.code] = metric.history[index]?.value ?? 0; + }); + return dataPoint; + }); + + const colors = ['#0c8ce8', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4']; + + return ( +
+

+ Tendencias +

+
+ + + + + + + + {selectedData.map((metric, index) => ( + + ))} + + +
+
+ ); +} + +// ============================================================================ +// Period Selector +// ============================================================================ + +interface PeriodSelectorProps { + value: PeriodType; + onChange: (period: PeriodType) => void; +} + +function PeriodSelector({ value, onChange }: PeriodSelectorProps) { + const periods: { value: PeriodType; label: string }[] = [ + { value: '7d', label: '7 dias' }, + { value: '30d', label: '30 dias' }, + { value: '90d', label: '90 dias' }, + { value: '12m', label: '12 meses' }, + { value: 'ytd', label: 'YTD' }, + ]; + + return ( +
+ {periods.map((period) => ( + + ))} +
+ ); +} + +// ============================================================================ +// Category Tab +// ============================================================================ + +interface CategoryTabsProps { + value: MetricCategory; + onChange: (category: MetricCategory) => void; +} + +function CategoryTabs({ value, onChange }: CategoryTabsProps) { + const categories: { value: MetricCategory; label: string; icon: React.ReactNode }[] = [ + { value: 'core', label: 'Core', icon: }, + { value: 'startup', label: 'Startup', icon: }, + { value: 'enterprise', label: 'Enterprise', icon: }, + ]; + + return ( +
+ {categories.map((category) => ( + + ))} +
+ ); +} + +// ============================================================================ +// Main Page Component +// ============================================================================ + +export default function MetricasPage() { + const [metrics, setMetrics] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [category, setCategory] = useState('core'); + const [period, setPeriod] = useState('30d'); + const [selectedForTrend, setSelectedForTrend] = useState(['MRR', 'ARR']); + + const fetchMetrics = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + // In production, this would be an API call + // const response = await api.get('/metrics', { params: { period } }); + // setMetrics(response.data?.metrics ?? []); + + // Mock data for development + await new Promise((resolve) => setTimeout(resolve, 800)); + setMetrics(mockMetrics); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar metricas'); + } finally { + setIsLoading(false); + } + }, [period]); + + useEffect(() => { + fetchMetrics(); + }, [fetchMetrics]); + + const filteredMetrics = metrics.filter((m) => m.category === category); + + const handleMetricClick = (metric: Metric) => { + // Navigate to detail page + window.location.href = `/metricas/${metric.code}`; + }; + + const toggleTrendSelection = (code: string) => { + setSelectedForTrend((prev) => + prev.includes(code) + ? prev.filter((c) => c !== code) + : prev.length < 4 + ? [...prev, code] + : prev + ); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Metricas

+

+ Analiza el rendimiento de tu negocio +

+
+
+ + +
+
+ + {/* Category Tabs */} + + + {/* Metrics Grid */} +
+ {filteredMetrics.map((metric) => ( + handleMetricClick(metric)} + /> + ))} +
+ + {/* Trend Selection */} +
+

+ Selecciona hasta 4 metricas para comparar en el grafico de tendencias: +

+
+ {metrics.map((metric) => ( + + ))} +
+
+ + {/* Trend Chart */} + +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/transacciones/page.tsx b/apps/web/src/app/(dashboard)/transacciones/page.tsx new file mode 100644 index 0000000..833f70a --- /dev/null +++ b/apps/web/src/app/(dashboard)/transacciones/page.tsx @@ -0,0 +1,912 @@ +'use client'; + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + ArrowUpRight, + ArrowDownRight, + Search, + Filter, + Download, + Plus, + MoreVertical, + Calendar, + X, + ChevronLeft, + ChevronRight, + Eye, + Edit, + Trash2, + FileText, + Building2, + Tag, +} from 'lucide-react'; +import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/utils'; +import { api } from '@/lib/api'; + +// ============================================================================ +// Types +// ============================================================================ + +type TransactionType = 'income' | 'expense' | 'transfer'; +type TransactionStatus = 'pending' | 'completed' | 'cancelled' | 'reconciled'; +type PaymentMethod = 'cash' | 'bank_transfer' | 'credit_card' | 'debit_card' | 'check' | 'other'; + +interface Transaction { + id: string; + date: string; + type: TransactionType; + category: string; + subcategory?: string; + description: string; + amount: number; + currency: string; + status: TransactionStatus; + paymentMethod: PaymentMethod; + contact?: { + id: string; + name: string; + rfc?: string; + type: 'customer' | 'supplier'; + }; + cfdiId?: string; + cfdiUuid?: string; + bankAccountId?: string; + bankAccountName?: string; + reference?: string; + notes?: string; + tags?: string[]; + attachments?: number; + createdAt: string; + updatedAt: string; +} + +interface TransactionsResponse { + transactions: Transaction[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; + summary: { + totalIncome: number; + totalExpenses: number; + netAmount: number; + }; +} + +interface Filters { + search: string; + type: TransactionType | 'all'; + status: TransactionStatus | 'all'; + category: string; + contactId: string; + dateFrom: string; + dateTo: string; + paymentMethod: PaymentMethod | 'all'; +} + +// ============================================================================ +// Mock Data +// ============================================================================ + +const categories = { + income: ['Ventas', 'Servicios', 'Comisiones', 'Intereses', 'Otros Ingresos'], + expense: ['Nomina', 'Servicios Profesionales', 'Renta', 'Servicios', 'Suministros', 'Marketing', 'Viajes', 'Otros Gastos'], +}; + +const generateMockTransactions = (): Transaction[] => { + const contacts = [ + { id: '1', name: 'Empresa ABC S.A. de C.V.', rfc: 'EAB123456789', type: 'customer' as const }, + { id: '2', name: 'Servicios XYZ S.A.', rfc: 'SXY987654321', type: 'supplier' as const }, + { id: '3', name: 'Consultores Asociados', rfc: 'CON456789123', type: 'supplier' as const }, + { id: '4', name: 'Tech Solutions MX', rfc: 'TSM789123456', type: 'customer' as const }, + { id: '5', name: 'Distribuidora Nacional', rfc: 'DNA321654987', type: 'customer' as const }, + ]; + + const transactions: Transaction[] = []; + + for (let i = 0; i < 100; i++) { + const type: TransactionType = Math.random() > 0.4 ? 'income' : 'expense'; + const categoryList = type === 'income' ? categories.income : categories.expense; + const contact = contacts[Math.floor(Math.random() * contacts.length)]; + const date = new Date(); + date.setDate(date.getDate() - Math.floor(Math.random() * 90)); + + transactions.push({ + id: `txn-${i + 1}`, + date: date.toISOString().split('T')[0], + type, + category: categoryList[Math.floor(Math.random() * categoryList.length)], + description: type === 'income' ? `Factura ${1000 + i}` : `Pago ${2000 + i}`, + amount: Math.floor(Math.random() * 50000) + 1000, + currency: 'MXN', + status: (['pending', 'completed', 'reconciled'] as TransactionStatus[])[Math.floor(Math.random() * 3)], + paymentMethod: (['bank_transfer', 'credit_card', 'cash', 'check'] as PaymentMethod[])[Math.floor(Math.random() * 4)], + contact: Math.random() > 0.2 ? contact : undefined, + cfdiId: Math.random() > 0.5 ? `cfdi-${i}` : undefined, + cfdiUuid: Math.random() > 0.5 ? `${crypto.randomUUID?.() || `uuid-${i}`}` : undefined, + bankAccountId: 'bank-1', + bankAccountName: 'Cuenta Principal BBVA', + reference: `REF-${1000 + i}`, + notes: Math.random() > 0.7 ? 'Nota de la transaccion' : undefined, + tags: Math.random() > 0.5 ? ['recurrente', 'prioritario'].slice(0, Math.floor(Math.random() * 2) + 1) : undefined, + attachments: Math.floor(Math.random() * 3), + createdAt: date.toISOString(), + updatedAt: date.toISOString(), + }); + } + + return transactions.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); +}; + +// ============================================================================ +// Loading Skeleton +// ============================================================================ + +function TransactionsSkeleton() { + return ( +
+ {/* Summary cards */} +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+ {/* Table skeleton */} +
+
+
+
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+
+
+
+
+ ))} +
+
+ ); +} + +// ============================================================================ +// Transaction Detail Modal +// ============================================================================ + +interface TransactionModalProps { + transaction: Transaction | null; + isOpen: boolean; + onClose: () => void; +} + +function TransactionModal({ transaction, isOpen, onClose }: TransactionModalProps) { + if (!isOpen || !transaction) return null; + + const typeLabels: Record = { + income: 'Ingreso', + expense: 'Gasto', + transfer: 'Transferencia', + }; + + const statusLabels: Record = { + pending: 'Pendiente', + completed: 'Completado', + cancelled: 'Cancelado', + reconciled: 'Conciliado', + }; + + const paymentLabels: Record = { + cash: 'Efectivo', + bank_transfer: 'Transferencia', + credit_card: 'Tarjeta de Credito', + debit_card: 'Tarjeta de Debito', + check: 'Cheque', + other: 'Otro', + }; + + return ( +
+
+
+
+ {/* Header */} +
+
+
+ {transaction.type === 'income' ? ( + + ) : ( + + )} +
+
+

{transaction.description}

+

{transaction.id}

+
+
+ +
+ + {/* Content */} +
+ {/* Amount */} +
+

+ {transaction.type === 'income' ? '+' : '-'} + {formatCurrency(transaction.amount, transaction.currency)} +

+

{formatDate(transaction.date)}

+
+ + {/* Details Grid */} +
+
+ +

{typeLabels[transaction.type]}

+
+
+ +

+ + {statusLabels[transaction.status]} + +

+
+
+ +

{transaction.category}

+
+
+ +

{paymentLabels[transaction.paymentMethod]}

+
+
+ + {/* Contact */} + {transaction.contact && ( +
+ +
+
+ +
+
+

{transaction.contact.name}

+ {transaction.contact.rfc && ( +

{transaction.contact.rfc}

+ )} +
+
+
+ )} + + {/* CFDI */} + {transaction.cfdiUuid && ( +
+ +
+
+ +
+
+

{transaction.cfdiUuid}

+
+ + Ver CFDI + +
+
+ )} + + {/* Bank Account */} + {transaction.bankAccountName && ( +
+ +

{transaction.bankAccountName}

+ {transaction.reference && ( +

Ref: {transaction.reference}

+ )} +
+ )} + + {/* Tags */} + {transaction.tags && transaction.tags.length > 0 && ( +
+ +
+ {transaction.tags.map((tag) => ( + + + {tag} + + ))} +
+
+ )} + + {/* Notes */} + {transaction.notes && ( +
+ +

{transaction.notes}

+
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+
+ ); +} + +// ============================================================================ +// Filter Panel +// ============================================================================ + +interface FilterPanelProps { + filters: Filters; + onChange: (filters: Filters) => void; + onClose: () => void; + isOpen: boolean; +} + +function FilterPanel({ filters, onChange, onClose, isOpen }: FilterPanelProps) { + if (!isOpen) return null; + + const allCategories = [...categories.income, ...categories.expense]; + + return ( +
+
+

Filtros

+ +
+ +
+ {/* Type */} +
+ + +
+ + {/* Status */} +
+ + +
+ + {/* Category */} +
+ + +
+ + {/* Payment Method */} +
+ + +
+ + {/* Date From */} +
+ + onChange({ ...filters, dateFrom: e.target.value })} + className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm" + /> +
+ + {/* Date To */} +
+ + onChange({ ...filters, dateTo: e.target.value })} + className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm" + /> +
+
+ +
+ + +
+
+ ); +} + +// ============================================================================ +// Main Page Component +// ============================================================================ + +export default function TransaccionesPage() { + const [transactions, setTransactions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedTransaction, setSelectedTransaction] = useState(null); + const [showFilters, setShowFilters] = useState(false); + const [page, setPage] = useState(1); + const [filters, setFilters] = useState({ + search: '', + type: 'all', + status: 'all', + category: '', + contactId: '', + dateFrom: '', + dateTo: '', + paymentMethod: 'all', + }); + + const limit = 20; + + const fetchTransactions = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + // In production, this would be an API call + await new Promise((resolve) => setTimeout(resolve, 600)); + setTransactions(generateMockTransactions()); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar transacciones'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchTransactions(); + }, [fetchTransactions]); + + // Filter and paginate transactions + const filteredTransactions = useMemo(() => { + return transactions.filter((t) => { + if (filters.search) { + const searchLower = filters.search.toLowerCase(); + if ( + !t.description.toLowerCase().includes(searchLower) && + !t.contact?.name.toLowerCase().includes(searchLower) && + !t.category.toLowerCase().includes(searchLower) + ) { + return false; + } + } + if (filters.type !== 'all' && t.type !== filters.type) return false; + if (filters.status !== 'all' && t.status !== filters.status) return false; + if (filters.category && t.category !== filters.category) return false; + if (filters.paymentMethod !== 'all' && t.paymentMethod !== filters.paymentMethod) return false; + if (filters.dateFrom && t.date < filters.dateFrom) return false; + if (filters.dateTo && t.date > filters.dateTo) return false; + return true; + }); + }, [transactions, filters]); + + const paginatedTransactions = useMemo(() => { + const start = (page - 1) * limit; + return filteredTransactions.slice(start, start + limit); + }, [filteredTransactions, page]); + + const totalPages = Math.ceil(filteredTransactions.length / limit); + + const summary = useMemo(() => { + const income = filteredTransactions.filter((t) => t.type === 'income').reduce((sum, t) => sum + t.amount, 0); + const expenses = filteredTransactions.filter((t) => t.type === 'expense').reduce((sum, t) => sum + t.amount, 0); + return { + income, + expenses, + net: income - expenses, + }; + }, [filteredTransactions]); + + const handleExport = () => { + const csv = [ + ['Fecha', 'Tipo', 'Descripcion', 'Categoria', 'Monto', 'Estado', 'Contacto'].join(','), + ...filteredTransactions.map((t) => + [t.date, t.type, t.description, t.category, t.amount, t.status, t.contact?.name || ''].join(',') + ), + ].join('\n'); + + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `transacciones_${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Transacciones

+

+ {formatNumber(filteredTransactions.length)} transacciones +

+
+
+ + +
+
+ + {/* Summary Cards */} +
+
+
+
+ +
+
+

Ingresos

+

{formatCurrency(summary.income)}

+
+
+
+
+
+
+ +
+
+

Gastos

+

{formatCurrency(summary.expenses)}

+
+
+
+
+
+
= 0 ? 'bg-primary-100 dark:bg-primary-900/30' : 'bg-error-100 dark:bg-error-900/30')}> + +
+
+

Neto

+

= 0 ? 'text-primary-600 dark:text-primary-400' : 'text-error-600 dark:text-error-400')}> + {formatCurrency(summary.net)} +

+
+
+
+
+ + {/* Search and Filters */} +
+
+ + setFilters({ ...filters, search: e.target.value })} + className="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+ +
+ + {/* Filter Panel */} + setShowFilters(false)} isOpen={showFilters} /> + + {/* Transactions Table */} +
+
+ + + + + + + + + + + + + + {paginatedTransactions.map((transaction) => ( + setSelectedTransaction(transaction)} + className="hover:bg-gray-50 dark:hover:bg-gray-900/50 cursor-pointer" + > + + + + + + + + + ))} + +
+ Fecha + + Descripcion + + Categoria + + Contacto + + Monto + + Estado + + Acciones +
+ {formatDate(transaction.date)} + +
+
+ {transaction.type === 'income' ? ( + + ) : ( + + )} +
+
+

{transaction.description}

+ {transaction.cfdiUuid && ( +

+ CFDI: {transaction.cfdiUuid.slice(0, 8)}... +

+ )} +
+
+
+ {transaction.category} + + {transaction.contact ? ( +
+

{transaction.contact.name}

+

{transaction.contact.rfc}

+
+ ) : ( + - + )} +
+ + {transaction.type === 'income' ? '+' : '-'} + {formatCurrency(transaction.amount, transaction.currency)} + + + + {transaction.status === 'completed' && 'Completado'} + {transaction.status === 'pending' && 'Pendiente'} + {transaction.status === 'cancelled' && 'Cancelado'} + {transaction.status === 'reconciled' && 'Conciliado'} + + + +
+
+ + {/* Pagination */} +
+

+ Mostrando {(page - 1) * limit + 1} a {Math.min(page * limit, filteredTransactions.length)} de{' '} + {filteredTransactions.length} transacciones +

+
+ + + Pagina {page} de {totalPages} + + +
+
+
+ + {/* Transaction Detail Modal */} + setSelectedTransaction(null)} + /> +
+ ); +} + +// Need to import Activity for the summary card +import { Activity } from 'lucide-react'; diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css new file mode 100644 index 0000000..5675e74 --- /dev/null +++ b/apps/web/src/app/globals.css @@ -0,0 +1,374 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* ============================================ + Base Styles + ============================================ */ + +@layer base { + /* Root variables */ + :root { + --background: 255 255 255; + --foreground: 15 23 42; + --primary: 12 140 232; + --primary-foreground: 255 255 255; + --muted: 241 245 249; + --muted-foreground: 100 116 139; + --accent: 241 245 249; + --accent-foreground: 15 23 42; + --border: 226 232 240; + --radius: 0.5rem; + } + + .dark { + --background: 10 15 26; + --foreground: 241 245 249; + --primary: 54 167 247; + --primary-foreground: 255 255 255; + --muted: 30 41 59; + --muted-foreground: 148 163 184; + --accent: 30 41 59; + --accent-foreground: 241 245 249; + --border: 51 65 85; + } + + /* Base HTML styles */ + html { + @apply antialiased; + scroll-behavior: smooth; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + body { + @apply bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100; + @apply min-h-screen; + } + + /* Typography */ + h1, h2, h3, h4, h5, h6 { + @apply font-semibold tracking-tight; + } + + h1 { @apply text-4xl lg:text-5xl; } + h2 { @apply text-3xl lg:text-4xl; } + h3 { @apply text-2xl lg:text-3xl; } + h4 { @apply text-xl lg:text-2xl; } + h5 { @apply text-lg lg:text-xl; } + h6 { @apply text-base lg:text-lg; } + + /* Links */ + a { + @apply transition-colors duration-200; + } + + /* Focus styles */ + *:focus-visible { + @apply outline-none ring-2 ring-primary-500 ring-offset-2 ring-offset-white dark:ring-offset-slate-900; + } + + /* Selection */ + ::selection { + @apply bg-primary-500/20 text-primary-900 dark:text-primary-100; + } + + /* Scrollbar */ + ::-webkit-scrollbar { + @apply w-2 h-2; + } + + ::-webkit-scrollbar-track { + @apply bg-transparent; + } + + ::-webkit-scrollbar-thumb { + @apply bg-slate-300 dark:bg-slate-700 rounded-full; + } + + ::-webkit-scrollbar-thumb:hover { + @apply bg-slate-400 dark:bg-slate-600; + } + + /* Firefox scrollbar */ + * { + scrollbar-width: thin; + scrollbar-color: rgb(148 163 184) transparent; + } + + .dark * { + scrollbar-color: rgb(51 65 85) transparent; + } +} + +/* ============================================ + Component Styles + ============================================ */ + +@layer components { + /* Container */ + .container-app { + @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8; + } + + /* Page wrapper */ + .page-wrapper { + @apply min-h-screen flex flex-col; + } + + /* Card styles */ + .card { + @apply bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700; + } + + .card-hover { + @apply card transition-all duration-200 hover:border-primary-300 hover:shadow-md dark:hover:border-primary-700; + } + + /* Form styles */ + .form-group { + @apply space-y-1.5; + } + + .form-label { + @apply block text-sm font-medium text-slate-700 dark:text-slate-200; + } + + .form-input { + @apply w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600; + @apply bg-white dark:bg-slate-800 text-slate-900 dark:text-white; + @apply placeholder:text-slate-400 dark:placeholder:text-slate-500; + @apply focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20; + @apply transition-all duration-200; + } + + .form-error { + @apply text-sm text-error-600 dark:text-error-400; + } + + /* Badge */ + .badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + } + + .badge-primary { + @apply badge bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300; + } + + .badge-success { + @apply badge bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-300; + } + + .badge-warning { + @apply badge bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-300; + } + + .badge-error { + @apply badge bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-300; + } + + /* Status dot */ + .status-dot { + @apply w-2 h-2 rounded-full; + } + + .status-online { + @apply status-dot bg-success-500 animate-pulse; + } + + .status-offline { + @apply status-dot bg-slate-400; + } + + .status-warning { + @apply status-dot bg-warning-500; + } + + /* Gradient text */ + .gradient-text { + @apply bg-gradient-to-r from-primary-600 to-primary-400 bg-clip-text text-transparent; + } + + /* Glass effect */ + .glass { + @apply bg-white/70 dark:bg-slate-900/70 backdrop-blur-lg; + } + + /* Skeleton loading */ + .skeleton { + @apply bg-slate-200 dark:bg-slate-700 animate-pulse rounded; + } + + /* Divider */ + .divider { + @apply border-t border-slate-200 dark:border-slate-700; + } + + /* Avatar */ + .avatar { + @apply rounded-full bg-primary-100 dark:bg-primary-900/50 flex items-center justify-center; + @apply text-primary-700 dark:text-primary-300 font-semibold; + } + + .avatar-sm { @apply w-8 h-8 text-sm; } + .avatar-md { @apply w-10 h-10 text-base; } + .avatar-lg { @apply w-12 h-12 text-lg; } + .avatar-xl { @apply w-16 h-16 text-xl; } + + /* Trading specific */ + .price-up { + @apply text-success-600 dark:text-success-400; + } + + .price-down { + @apply text-error-600 dark:text-error-400; + } + + .price-neutral { + @apply text-slate-600 dark:text-slate-400; + } +} + +/* ============================================ + Utility Styles + ============================================ */ + +@layer utilities { + /* Hide scrollbar */ + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } + + .no-scrollbar::-webkit-scrollbar { + display: none; + } + + /* Truncate multiline */ + .line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .line-clamp-3 { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } + + /* Safe area padding */ + .safe-top { + padding-top: env(safe-area-inset-top); + } + + .safe-bottom { + padding-bottom: env(safe-area-inset-bottom); + } + + /* Animation delays */ + .animation-delay-100 { animation-delay: 100ms; } + .animation-delay-200 { animation-delay: 200ms; } + .animation-delay-300 { animation-delay: 300ms; } + .animation-delay-400 { animation-delay: 400ms; } + .animation-delay-500 { animation-delay: 500ms; } + + /* Glow effects */ + .glow-primary { + box-shadow: 0 0 20px rgba(12, 140, 232, 0.3); + } + + .glow-success { + box-shadow: 0 0 20px rgba(16, 185, 129, 0.3); + } + + .glow-error { + box-shadow: 0 0 20px rgba(239, 68, 68, 0.3); + } + + /* Text gradient */ + .text-gradient-primary { + @apply bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent; + } + + /* Backdrop blur variants */ + .backdrop-blur-xs { + backdrop-filter: blur(2px); + } +} + +/* ============================================ + Animation Keyframes + ============================================ */ + +@keyframes float { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +@keyframes gradient { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +} + +.animate-float { + animation: float 3s ease-in-out infinite; +} + +.animate-shimmer { + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.2) 50%, + rgba(255, 255, 255, 0) 100% + ); + background-size: 200% 100%; + animation: shimmer 2s infinite; +} + +.animate-gradient { + background-size: 200% 200%; + animation: gradient 4s ease infinite; +} + +/* ============================================ + Print Styles + ============================================ */ + +@media print { + .no-print { + display: none !important; + } + + body { + @apply bg-white text-black; + } + + .card { + @apply border border-slate-300; + box-shadow: none; + } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx new file mode 100644 index 0000000..71f0dda --- /dev/null +++ b/apps/web/src/app/layout.tsx @@ -0,0 +1,144 @@ +import type { Metadata, Viewport } from 'next'; +import { Inter, JetBrains_Mono } from 'next/font/google'; +import './globals.css'; + +// Fonts +const inter = Inter({ + subsets: ['latin'], + display: 'swap', + variable: '--font-inter', +}); + +const jetbrainsMono = JetBrains_Mono({ + subsets: ['latin'], + display: 'swap', + variable: '--font-mono', +}); + +// Metadata +export const metadata: Metadata = { + title: { + default: 'Horux Strategy - Trading Algoritmico', + template: '%s | Horux Strategy', + }, + description: + 'Plataforma de trading algoritmico para automatizar tus estrategias de inversion con inteligencia artificial.', + keywords: [ + 'trading', + 'algoritmico', + 'criptomonedas', + 'bitcoin', + 'estrategias', + 'automatizacion', + 'inversion', + ], + authors: [{ name: 'Horux Team' }], + creator: 'Horux', + publisher: 'Horux', + robots: { + index: true, + follow: true, + }, + openGraph: { + type: 'website', + locale: 'es_ES', + url: 'https://horux.io', + siteName: 'Horux Strategy', + title: 'Horux Strategy - Trading Algoritmico', + description: + 'Plataforma de trading algoritmico para automatizar tus estrategias de inversion.', + images: [ + { + url: '/og-image.png', + width: 1200, + height: 630, + alt: 'Horux Strategy', + }, + ], + }, + twitter: { + card: 'summary_large_image', + title: 'Horux Strategy', + description: 'Plataforma de trading algoritmico', + images: ['/og-image.png'], + }, + icons: { + icon: '/favicon.ico', + shortcut: '/favicon-16x16.png', + apple: '/apple-touch-icon.png', + }, + manifest: '/manifest.json', +}; + +// Viewport +export const viewport: Viewport = { + themeColor: [ + { media: '(prefers-color-scheme: light)', color: '#ffffff' }, + { media: '(prefers-color-scheme: dark)', color: '#0a0f1a' }, + ], + width: 'device-width', + initialScale: 1, + maximumScale: 1, +}; + +/** + * Root Layout + * + * Layout principal de la aplicacion que envuelve todas las paginas. + * Incluye providers globales, fonts y meta tags. + */ +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {/* Preconnect to external resources */} + + + + + {/* Theme Script - Prevent flash */} +