feat: Implement Phase 1 & 2 - Full monorepo architecture
## Backend API (apps/api) - Express.js server with TypeScript - JWT authentication with access/refresh tokens - Multi-tenant middleware (schema per tenant) - Complete CRUD routes: auth, cfdis, transactions, contacts, categories, metrics, alerts - SAT integration: CFDI 4.0 XML parser, FIEL authentication - Metrics engine: 50+ financial metrics (Core, Startup, Enterprise) - Rate limiting, CORS, Helmet security ## Frontend Web (apps/web) - Next.js 14 with App Router - Authentication pages: login, register, forgot-password - Dashboard layout with Sidebar and Header - Dashboard pages: overview, cash-flow, revenue, expenses, metrics - Zustand stores for auth and UI state - Theme support with flash prevention ## Database Package (packages/database) - PostgreSQL migrations with multi-tenant architecture - Public schema: plans, tenants, users, sessions, subscriptions - Tenant schema: sat_credentials, cfdis, transactions, contacts, accounts, alerts - Tenant management functions - Seed data for plans and super admin ## Shared Package (packages/shared) - TypeScript types: auth, tenant, financial, metrics, reports - Zod validation schemas for all entities - Utility functions for formatting ## UI Package (packages/ui) - Chart components: LineChart, BarChart, AreaChart, PieChart - Data components: DataTable, MetricCard, KPICard, AlertBadge - PeriodSelector and Skeleton components ## Infrastructure - Docker Compose: PostgreSQL 15, Redis 7, MinIO, Mailhog - Makefile with 25+ development commands - Development scripts: dev-setup.sh, dev-down.sh - Complete .env.example template Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
173
.env.example
Normal file
173
.env.example
Normal file
@@ -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=
|
||||||
236
Makefile
Normal file
236
Makefile
Normal file
@@ -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 ""
|
||||||
58
apps/api/.env.example
Normal file
58
apps/api/.env.example
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# Horux Strategy API - Environment Variables
|
||||||
|
# ============================================================================
|
||||||
|
# Copy this file to .env and fill in the values
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=4000
|
||||||
|
HOST=0.0.0.0
|
||||||
|
API_VERSION=v1
|
||||||
|
|
||||||
|
# Database (PostgreSQL)
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/horux_strategy
|
||||||
|
DATABASE_POOL_MIN=2
|
||||||
|
DATABASE_POOL_MAX=10
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# JWT Secrets (minimum 32 characters each)
|
||||||
|
# IMPORTANT: Generate secure random strings for production!
|
||||||
|
JWT_ACCESS_SECRET=your-access-secret-at-least-32-characters
|
||||||
|
JWT_REFRESH_SECRET=your-refresh-secret-at-least-32-characters
|
||||||
|
JWT_ACCESS_EXPIRES_IN=15m
|
||||||
|
JWT_REFRESH_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# Password Reset
|
||||||
|
PASSWORD_RESET_SECRET=your-reset-secret-at-least-32-characters!
|
||||||
|
PASSWORD_RESET_EXPIRES_IN=1h
|
||||||
|
|
||||||
|
# Security
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||||
|
RATE_LIMIT_WINDOW_MS=60000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
|
||||||
|
# MinIO / S3 Object Storage
|
||||||
|
MINIO_ENDPOINT=localhost
|
||||||
|
MINIO_PORT=9000
|
||||||
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
|
MINIO_SECRET_KEY=minioadmin
|
||||||
|
MINIO_BUCKET=horux-strategy
|
||||||
|
MINIO_USE_SSL=false
|
||||||
|
|
||||||
|
# Email (SMTP)
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
EMAIL_FROM=noreply@horuxstrategy.com
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_FORMAT=simple
|
||||||
|
|
||||||
|
# Feature Flags
|
||||||
|
ENABLE_SWAGGER=true
|
||||||
|
ENABLE_METRICS=true
|
||||||
51
apps/api/package.json
Normal file
51
apps/api/package.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "@horux/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Horux Strategy Backend API",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"lint": "eslint src --ext .ts",
|
||||||
|
"lint:fix": "eslint src --ext .ts --fix",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:watch": "vitest --watch",
|
||||||
|
"clean": "rm -rf dist node_modules"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@horux/database": "workspace:*",
|
||||||
|
"@horux/shared": "workspace:*",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"bullmq": "^5.1.0",
|
||||||
|
"compression": "^1.7.4",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.0",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"minio": "^7.1.3",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"winston": "^3.11.0",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/compression": "^1.7.5",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"@types/pg": "^8.10.9",
|
||||||
|
"@types/uuid": "^9.0.7",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vitest": "^1.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
205
apps/api/src/config/index.ts
Normal file
205
apps/api/src/config/index.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Environment Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
// Server
|
||||||
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
|
PORT: z.string().transform(Number).default('4000'),
|
||||||
|
HOST: z.string().default('0.0.0.0'),
|
||||||
|
API_VERSION: z.string().default('v1'),
|
||||||
|
|
||||||
|
// Database
|
||||||
|
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
|
||||||
|
DATABASE_POOL_MIN: z.string().transform(Number).default('2'),
|
||||||
|
DATABASE_POOL_MAX: z.string().transform(Number).default('10'),
|
||||||
|
|
||||||
|
// Redis
|
||||||
|
REDIS_URL: z.string().default('redis://localhost:6379'),
|
||||||
|
|
||||||
|
// JWT
|
||||||
|
JWT_ACCESS_SECRET: z.string().min(32, 'JWT_ACCESS_SECRET must be at least 32 characters'),
|
||||||
|
JWT_REFRESH_SECRET: z.string().min(32, 'JWT_REFRESH_SECRET must be at least 32 characters'),
|
||||||
|
JWT_ACCESS_EXPIRES_IN: z.string().default('15m'),
|
||||||
|
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
|
||||||
|
|
||||||
|
// Password Reset
|
||||||
|
PASSWORD_RESET_SECRET: z.string().min(32, 'PASSWORD_RESET_SECRET must be at least 32 characters'),
|
||||||
|
PASSWORD_RESET_EXPIRES_IN: z.string().default('1h'),
|
||||||
|
|
||||||
|
// Security
|
||||||
|
BCRYPT_ROUNDS: z.string().transform(Number).default('12'),
|
||||||
|
CORS_ORIGINS: z.string().default('http://localhost:3000'),
|
||||||
|
RATE_LIMIT_WINDOW_MS: z.string().transform(Number).default('60000'),
|
||||||
|
RATE_LIMIT_MAX_REQUESTS: z.string().transform(Number).default('100'),
|
||||||
|
|
||||||
|
// MinIO / S3
|
||||||
|
MINIO_ENDPOINT: z.string().default('localhost'),
|
||||||
|
MINIO_PORT: z.string().transform(Number).default('9000'),
|
||||||
|
MINIO_ACCESS_KEY: z.string().default('minioadmin'),
|
||||||
|
MINIO_SECRET_KEY: z.string().default('minioadmin'),
|
||||||
|
MINIO_BUCKET: z.string().default('horux-strategy'),
|
||||||
|
MINIO_USE_SSL: z.string().transform((v) => v === 'true').default('false'),
|
||||||
|
|
||||||
|
// Email (optional for now)
|
||||||
|
SMTP_HOST: z.string().optional(),
|
||||||
|
SMTP_PORT: z.string().transform(Number).optional(),
|
||||||
|
SMTP_USER: z.string().optional(),
|
||||||
|
SMTP_PASS: z.string().optional(),
|
||||||
|
EMAIL_FROM: z.string().default('noreply@horuxstrategy.com'),
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
LOG_LEVEL: z.enum(['error', 'warn', 'info', 'http', 'verbose', 'debug', 'silly']).default('info'),
|
||||||
|
LOG_FORMAT: z.enum(['json', 'simple']).default('json'),
|
||||||
|
|
||||||
|
// Feature Flags
|
||||||
|
ENABLE_SWAGGER: z.string().transform((v) => v === 'true').default('true'),
|
||||||
|
ENABLE_METRICS: z.string().transform((v) => v === 'true').default('true'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Parse and Validate Environment
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const parseEnv = () => {
|
||||||
|
const result = envSchema.safeParse(process.env);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('Environment validation failed:');
|
||||||
|
console.error(result.error.format());
|
||||||
|
|
||||||
|
// In development, provide default values for required fields
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
console.warn('Using default development configuration...');
|
||||||
|
return {
|
||||||
|
NODE_ENV: 'development' as const,
|
||||||
|
PORT: 4000,
|
||||||
|
HOST: '0.0.0.0',
|
||||||
|
API_VERSION: 'v1',
|
||||||
|
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/horux_strategy',
|
||||||
|
DATABASE_POOL_MIN: 2,
|
||||||
|
DATABASE_POOL_MAX: 10,
|
||||||
|
REDIS_URL: 'redis://localhost:6379',
|
||||||
|
JWT_ACCESS_SECRET: 'dev-access-secret-change-in-production-32chars',
|
||||||
|
JWT_REFRESH_SECRET: 'dev-refresh-secret-change-in-production-32chars',
|
||||||
|
JWT_ACCESS_EXPIRES_IN: '15m',
|
||||||
|
JWT_REFRESH_EXPIRES_IN: '7d',
|
||||||
|
PASSWORD_RESET_SECRET: 'dev-reset-secret-change-in-production-32chars!',
|
||||||
|
PASSWORD_RESET_EXPIRES_IN: '1h',
|
||||||
|
BCRYPT_ROUNDS: 12,
|
||||||
|
CORS_ORIGINS: 'http://localhost:3000',
|
||||||
|
RATE_LIMIT_WINDOW_MS: 60000,
|
||||||
|
RATE_LIMIT_MAX_REQUESTS: 100,
|
||||||
|
MINIO_ENDPOINT: 'localhost',
|
||||||
|
MINIO_PORT: 9000,
|
||||||
|
MINIO_ACCESS_KEY: 'minioadmin',
|
||||||
|
MINIO_SECRET_KEY: 'minioadmin',
|
||||||
|
MINIO_BUCKET: 'horux-strategy',
|
||||||
|
MINIO_USE_SSL: false,
|
||||||
|
SMTP_HOST: undefined,
|
||||||
|
SMTP_PORT: undefined,
|
||||||
|
SMTP_USER: undefined,
|
||||||
|
SMTP_PASS: undefined,
|
||||||
|
EMAIL_FROM: 'noreply@horuxstrategy.com',
|
||||||
|
LOG_LEVEL: 'info' as const,
|
||||||
|
LOG_FORMAT: 'simple' as const,
|
||||||
|
ENABLE_SWAGGER: true,
|
||||||
|
ENABLE_METRICS: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const env = parseEnv();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Configuration Object
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
env: env.NODE_ENV,
|
||||||
|
isDevelopment: env.NODE_ENV === 'development',
|
||||||
|
isProduction: env.NODE_ENV === 'production',
|
||||||
|
isTest: env.NODE_ENV === 'test',
|
||||||
|
|
||||||
|
server: {
|
||||||
|
port: env.PORT,
|
||||||
|
host: env.HOST,
|
||||||
|
apiVersion: env.API_VERSION,
|
||||||
|
apiPrefix: `/api/${env.API_VERSION}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
database: {
|
||||||
|
url: env.DATABASE_URL,
|
||||||
|
pool: {
|
||||||
|
min: env.DATABASE_POOL_MIN,
|
||||||
|
max: env.DATABASE_POOL_MAX,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
redis: {
|
||||||
|
url: env.REDIS_URL,
|
||||||
|
},
|
||||||
|
|
||||||
|
jwt: {
|
||||||
|
accessSecret: env.JWT_ACCESS_SECRET,
|
||||||
|
refreshSecret: env.JWT_REFRESH_SECRET,
|
||||||
|
accessExpiresIn: env.JWT_ACCESS_EXPIRES_IN,
|
||||||
|
refreshExpiresIn: env.JWT_REFRESH_EXPIRES_IN,
|
||||||
|
},
|
||||||
|
|
||||||
|
passwordReset: {
|
||||||
|
secret: env.PASSWORD_RESET_SECRET,
|
||||||
|
expiresIn: env.PASSWORD_RESET_EXPIRES_IN,
|
||||||
|
},
|
||||||
|
|
||||||
|
security: {
|
||||||
|
bcryptRounds: env.BCRYPT_ROUNDS,
|
||||||
|
corsOrigins: env.CORS_ORIGINS.split(',').map((origin) => origin.trim()),
|
||||||
|
rateLimit: {
|
||||||
|
windowMs: env.RATE_LIMIT_WINDOW_MS,
|
||||||
|
maxRequests: env.RATE_LIMIT_MAX_REQUESTS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
minio: {
|
||||||
|
endpoint: env.MINIO_ENDPOINT,
|
||||||
|
port: env.MINIO_PORT,
|
||||||
|
accessKey: env.MINIO_ACCESS_KEY,
|
||||||
|
secretKey: env.MINIO_SECRET_KEY,
|
||||||
|
bucket: env.MINIO_BUCKET,
|
||||||
|
useSSL: env.MINIO_USE_SSL,
|
||||||
|
},
|
||||||
|
|
||||||
|
email: {
|
||||||
|
host: env.SMTP_HOST,
|
||||||
|
port: env.SMTP_PORT,
|
||||||
|
user: env.SMTP_USER,
|
||||||
|
pass: env.SMTP_PASS,
|
||||||
|
from: env.EMAIL_FROM,
|
||||||
|
},
|
||||||
|
|
||||||
|
logging: {
|
||||||
|
level: env.LOG_LEVEL,
|
||||||
|
format: env.LOG_FORMAT,
|
||||||
|
},
|
||||||
|
|
||||||
|
features: {
|
||||||
|
swagger: env.ENABLE_SWAGGER,
|
||||||
|
metrics: env.ENABLE_METRICS,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Config = typeof config;
|
||||||
|
|
||||||
|
export default config;
|
||||||
281
apps/api/src/index.ts
Normal file
281
apps/api/src/index.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import express, { Application, Request, Response, NextFunction } from 'express';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import cors from 'cors';
|
||||||
|
import compression from 'compression';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
import { config } from './config/index.js';
|
||||||
|
import { logger, httpLogger } from './utils/logger.js';
|
||||||
|
import { errorHandler, notFoundHandler } from './middleware/errorHandler.js';
|
||||||
|
import { authenticate } from './middleware/auth.js';
|
||||||
|
import { tenantContext } from './middleware/tenant.js';
|
||||||
|
import authRoutes from './routes/auth.routes.js';
|
||||||
|
import healthRoutes from './routes/health.routes.js';
|
||||||
|
import metricsRoutes from './routes/metrics.routes.js';
|
||||||
|
import transactionsRoutes from './routes/transactions.routes.js';
|
||||||
|
import contactsRoutes from './routes/contacts.routes.js';
|
||||||
|
import cfdisRoutes from './routes/cfdis.routes.js';
|
||||||
|
import categoriesRoutes from './routes/categories.routes.js';
|
||||||
|
import alertsRoutes from './routes/alerts.routes.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Application Setup
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const app: Application = express();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Trust Proxy (for reverse proxies like nginx)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Request ID Middleware
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
app.use((req: Request, _res: Response, next: NextFunction) => {
|
||||||
|
req.headers['x-request-id'] = req.headers['x-request-id'] || uuidv4();
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Security Middleware
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Helmet - sets various HTTP headers for security
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: config.isProduction,
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// CORS configuration
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: config.security.corsOrigins,
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID', 'X-Tenant-ID'],
|
||||||
|
exposedHeaders: ['X-Request-ID', 'X-RateLimit-Limit', 'X-RateLimit-Remaining'],
|
||||||
|
maxAge: 86400, // 24 hours
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Compression Middleware
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
compression({
|
||||||
|
filter: (req, res) => {
|
||||||
|
if (req.headers['x-no-compression']) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return compression.filter(req, res);
|
||||||
|
},
|
||||||
|
level: 6,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Body Parsing Middleware
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Request Logging Middleware
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
res.on('finish', () => {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
const logMessage = `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`;
|
||||||
|
|
||||||
|
if (res.statusCode >= 500) {
|
||||||
|
logger.error(logMessage, {
|
||||||
|
requestId: req.headers['x-request-id'],
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'],
|
||||||
|
});
|
||||||
|
} else if (res.statusCode >= 400) {
|
||||||
|
logger.warn(logMessage, {
|
||||||
|
requestId: req.headers['x-request-id'],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
httpLogger.write(logMessage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Rate Limiting
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// General rate limiter
|
||||||
|
const generalLimiter = rateLimit({
|
||||||
|
windowMs: config.security.rateLimit.windowMs,
|
||||||
|
max: config.security.rateLimit.maxRequests,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'RATE_LIMIT_EXCEEDED',
|
||||||
|
message: 'Demasiadas solicitudes. Por favor intenta de nuevo mas tarde.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
skip: (req) => {
|
||||||
|
// Skip rate limiting for health checks
|
||||||
|
return req.path.startsWith('/health');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stricter rate limiter for auth endpoints
|
||||||
|
const authLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 20, // 20 requests per window
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'AUTH_RATE_LIMIT_EXCEEDED',
|
||||||
|
message: 'Demasiados intentos de autenticacion. Por favor intenta de nuevo en 15 minutos.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(generalLimiter);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API Routes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const apiPrefix = config.server.apiPrefix;
|
||||||
|
|
||||||
|
// Health check routes (no prefix, no rate limit)
|
||||||
|
app.use('/health', healthRoutes);
|
||||||
|
|
||||||
|
// Auth routes with stricter rate limiting
|
||||||
|
app.use(`${apiPrefix}/auth`, authLimiter, authRoutes);
|
||||||
|
|
||||||
|
// Protected routes (require authentication and tenant context)
|
||||||
|
app.use(`${apiPrefix}/metrics`, authenticate, tenantContext, metricsRoutes);
|
||||||
|
app.use(`${apiPrefix}/transactions`, authenticate, tenantContext, transactionsRoutes);
|
||||||
|
app.use(`${apiPrefix}/contacts`, authenticate, tenantContext, contactsRoutes);
|
||||||
|
app.use(`${apiPrefix}/cfdis`, authenticate, tenantContext, cfdisRoutes);
|
||||||
|
app.use(`${apiPrefix}/categories`, authenticate, tenantContext, categoriesRoutes);
|
||||||
|
app.use(`${apiPrefix}/alerts`, authenticate, tenantContext, alertsRoutes);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API Info Route
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
app.get(apiPrefix, (_req: Request, res: Response) => {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
name: 'Horux Strategy API',
|
||||||
|
version: process.env.npm_package_version || '0.1.0',
|
||||||
|
environment: config.env,
|
||||||
|
documentation: config.features.swagger ? `${apiPrefix}/docs` : undefined,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 404 Handler
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
app.use(notFoundHandler);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Handler
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Server Startup
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const startServer = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// Start listening
|
||||||
|
const server = app.listen(config.server.port, config.server.host, () => {
|
||||||
|
logger.info(`Horux Strategy API started`, {
|
||||||
|
port: config.server.port,
|
||||||
|
host: config.server.host,
|
||||||
|
environment: config.env,
|
||||||
|
apiPrefix: config.server.apiPrefix,
|
||||||
|
nodeVersion: process.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.isDevelopment) {
|
||||||
|
logger.info(`API available at http://${config.server.host}:${config.server.port}${config.server.apiPrefix}`);
|
||||||
|
logger.info(`Health check at http://${config.server.host}:${config.server.port}/health`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown handling
|
||||||
|
const gracefulShutdown = async (signal: string): Promise<void> => {
|
||||||
|
logger.info(`${signal} received. Starting graceful shutdown...`);
|
||||||
|
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error('Error during server close', { error: err });
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Server closed. Cleaning up...');
|
||||||
|
|
||||||
|
// Add any cleanup logic here (close database connections, etc.)
|
||||||
|
|
||||||
|
logger.info('Graceful shutdown completed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force shutdown after 30 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
logger.error('Forced shutdown after timeout');
|
||||||
|
process.exit(1);
|
||||||
|
}, 30000);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||||
|
|
||||||
|
// Handle uncaught exceptions
|
||||||
|
process.on('uncaughtException', (error: Error) => {
|
||||||
|
logger.error('Uncaught Exception', { error: error.message, stack: error.stack });
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle unhandled promise rejections
|
||||||
|
process.on('unhandledRejection', (reason: unknown) => {
|
||||||
|
logger.error('Unhandled Rejection', { reason });
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to start server', { error });
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
startServer();
|
||||||
|
|
||||||
|
// Export for testing
|
||||||
|
export default app;
|
||||||
131
apps/api/src/middleware/auth.middleware.ts
Normal file
131
apps/api/src/middleware/auth.middleware.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Authentication Middleware
|
||||||
|
*
|
||||||
|
* Handles JWT token validation and tenant context setup
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { AccessTokenPayload, AuthenticationError, AuthorizationError, UserRole } from '../types';
|
||||||
|
|
||||||
|
// Extend Express Request to include user and tenant context
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user?: AccessTokenPayload;
|
||||||
|
tenantSchema?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify JWT access token and attach user info to request
|
||||||
|
*/
|
||||||
|
export const authenticate = async (
|
||||||
|
req: Request,
|
||||||
|
_res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader) {
|
||||||
|
throw new AuthenticationError('Token de autorizacion no proporcionado');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [type, token] = authHeader.split(' ');
|
||||||
|
|
||||||
|
if (type !== 'Bearer' || !token) {
|
||||||
|
throw new AuthenticationError('Formato de token invalido');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = jwt.verify(token, config.jwt.accessSecret) as AccessTokenPayload;
|
||||||
|
|
||||||
|
if (payload.type !== 'access') {
|
||||||
|
throw new AuthenticationError('Tipo de token invalido');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach user info to request
|
||||||
|
req.user = payload;
|
||||||
|
req.tenantSchema = payload.schema_name;
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof jwt.JsonWebTokenError) {
|
||||||
|
next(new AuthenticationError('Token invalido'));
|
||||||
|
} else if (error instanceof jwt.TokenExpiredError) {
|
||||||
|
next(new AuthenticationError('Token expirado'));
|
||||||
|
} else {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has required role
|
||||||
|
*/
|
||||||
|
export const requireRole = (...roles: UserRole[]) => {
|
||||||
|
return (req: Request, _res: Response, next: NextFunction): void => {
|
||||||
|
if (!req.user) {
|
||||||
|
next(new AuthenticationError('Usuario no autenticado'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!roles.includes(req.user.role)) {
|
||||||
|
next(new AuthorizationError('No tienes permisos para realizar esta accion'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require owner or admin role
|
||||||
|
*/
|
||||||
|
export const requireAdmin = requireRole('owner', 'admin');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require owner role only
|
||||||
|
*/
|
||||||
|
export const requireOwner = requireRole('owner');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional authentication - doesn't fail if no token provided
|
||||||
|
*/
|
||||||
|
export const optionalAuth = async (
|
||||||
|
req: Request,
|
||||||
|
_res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [type, token] = authHeader.split(' ');
|
||||||
|
|
||||||
|
if (type !== 'Bearer' || !token) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = jwt.verify(token, config.jwt.accessSecret) as AccessTokenPayload;
|
||||||
|
|
||||||
|
if (payload.type === 'access') {
|
||||||
|
req.user = payload;
|
||||||
|
req.tenantSchema = payload.schema_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
// Ignore errors in optional auth
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default authenticate;
|
||||||
178
apps/api/src/middleware/auth.ts
Normal file
178
apps/api/src/middleware/auth.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { jwtService } from '../services/jwt.service.js';
|
||||||
|
import { AccessTokenPayload, AuthenticationError, AuthorizationError, UserRole } from '../types/index.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Extend Express Request Type
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user?: AccessTokenPayload;
|
||||||
|
tenantSchema?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth Middleware
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to authenticate requests using JWT
|
||||||
|
*/
|
||||||
|
export const authenticate = async (
|
||||||
|
req: Request,
|
||||||
|
_res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader) {
|
||||||
|
throw new AuthenticationError('Token de autenticacion no proporcionado');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = authHeader.split(' ');
|
||||||
|
|
||||||
|
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||||
|
throw new AuthenticationError('Formato de token invalido. Use: Bearer <token>');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = parts[1];
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new AuthenticationError('Token no proporcionado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
const payload = jwtService.verifyAccessToken(token);
|
||||||
|
|
||||||
|
// Attach user info to request
|
||||||
|
req.user = payload;
|
||||||
|
req.tenantSchema = payload.schema_name;
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to optionally authenticate (doesn't fail if no token)
|
||||||
|
*/
|
||||||
|
export const optionalAuth = async (
|
||||||
|
req: Request,
|
||||||
|
_res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = authHeader.split(' ');
|
||||||
|
|
||||||
|
if (parts.length !== 2 || parts[0] !== 'Bearer' || !parts[1]) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = jwtService.verifyAccessToken(parts[1]);
|
||||||
|
req.user = payload;
|
||||||
|
req.tenantSchema = payload.schema_name;
|
||||||
|
} catch {
|
||||||
|
// Ignore token errors in optional auth
|
||||||
|
logger.debug('Optional auth failed', { error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create role-based authorization middleware
|
||||||
|
*/
|
||||||
|
export const authorize = (...allowedRoles: UserRole[]) => {
|
||||||
|
return (req: Request, _res: Response, next: NextFunction): void => {
|
||||||
|
if (!req.user) {
|
||||||
|
return next(new AuthenticationError('No autenticado'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedRoles.includes(req.user.role)) {
|
||||||
|
logger.warn('Authorization failed', {
|
||||||
|
userId: req.user.sub,
|
||||||
|
userRole: req.user.role,
|
||||||
|
requiredRoles: allowedRoles,
|
||||||
|
path: req.path,
|
||||||
|
});
|
||||||
|
return next(new AuthorizationError('No tienes permisos para realizar esta accion'));
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to ensure user has owner role
|
||||||
|
*/
|
||||||
|
export const requireOwner = authorize('owner');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to ensure user has admin or owner role
|
||||||
|
*/
|
||||||
|
export const requireAdmin = authorize('owner', 'admin');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to ensure user has at least member role
|
||||||
|
*/
|
||||||
|
export const requireMember = authorize('owner', 'admin', 'member');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to check if user can access a specific tenant
|
||||||
|
*/
|
||||||
|
export const requireTenantAccess = (
|
||||||
|
req: Request,
|
||||||
|
_res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): void => {
|
||||||
|
if (!req.user) {
|
||||||
|
return next(new AuthenticationError('No autenticado'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantId = req.params.tenantId || req.body?.tenantId;
|
||||||
|
|
||||||
|
if (tenantId && tenantId !== req.user.tenant_id) {
|
||||||
|
logger.warn('Cross-tenant access attempt', {
|
||||||
|
userId: req.user.sub,
|
||||||
|
userTenantId: req.user.tenant_id,
|
||||||
|
requestedTenantId: tenantId,
|
||||||
|
path: req.path,
|
||||||
|
});
|
||||||
|
return next(new AuthorizationError('No tienes acceso a este tenant'));
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limiting helper - checks if user has exceeded request limits
|
||||||
|
* This is a placeholder - actual rate limiting is handled by express-rate-limit
|
||||||
|
*/
|
||||||
|
export const checkRateLimit = (
|
||||||
|
req: Request,
|
||||||
|
_res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): void => {
|
||||||
|
// This middleware can be extended to implement custom rate limiting logic
|
||||||
|
// For now, we rely on express-rate-limit configured in the main app
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default authenticate;
|
||||||
281
apps/api/src/middleware/errorHandler.ts
Normal file
281
apps/api/src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import { Request, Response, NextFunction, ErrorRequestHandler } from 'express';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
import { AppError, ApiResponse, ApiError } from '../types/index.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { config } from '../config/index.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Response Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Zod validation errors into a readable format
|
||||||
|
*/
|
||||||
|
const formatZodError = (error: ZodError): Record<string, string[]> => {
|
||||||
|
const errors: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
error.errors.forEach((err) => {
|
||||||
|
const path = err.path.join('.');
|
||||||
|
if (!errors[path]) {
|
||||||
|
errors[path] = [];
|
||||||
|
}
|
||||||
|
errors[path]?.push(err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a standardized error response
|
||||||
|
*/
|
||||||
|
const createErrorResponse = (
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
statusCode: number,
|
||||||
|
details?: Record<string, unknown>,
|
||||||
|
stack?: string
|
||||||
|
): { statusCode: number; body: ApiResponse } => {
|
||||||
|
const error: ApiError = {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (details) {
|
||||||
|
error.details = details;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include stack trace in development
|
||||||
|
if (stack && config.isDevelopment) {
|
||||||
|
error.stack = stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
body: {
|
||||||
|
success: false,
|
||||||
|
error,
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Handler Middleware
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global error handler middleware
|
||||||
|
* Catches all errors and returns a standardized response
|
||||||
|
*/
|
||||||
|
export const errorHandler: ErrorRequestHandler = (
|
||||||
|
err: Error,
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
_next: NextFunction
|
||||||
|
): void => {
|
||||||
|
// Log the error
|
||||||
|
const logContext = {
|
||||||
|
error: err.message,
|
||||||
|
stack: err.stack,
|
||||||
|
path: req.path,
|
||||||
|
method: req.method,
|
||||||
|
userId: req.user?.sub,
|
||||||
|
tenantId: req.user?.tenant_id,
|
||||||
|
requestId: req.headers['x-request-id'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle known error types
|
||||||
|
if (err instanceof AppError) {
|
||||||
|
// Operational errors are expected and logged as warnings
|
||||||
|
if (err.isOperational) {
|
||||||
|
logger.warn('Operational error', logContext);
|
||||||
|
} else {
|
||||||
|
logger.error('Non-operational error', logContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { statusCode, body } = createErrorResponse(
|
||||||
|
err.code,
|
||||||
|
err.message,
|
||||||
|
err.statusCode,
|
||||||
|
err.details,
|
||||||
|
err.stack
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(statusCode).json(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Zod validation errors
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
logger.warn('Validation error', { ...logContext, validationErrors: err.errors });
|
||||||
|
|
||||||
|
const { statusCode, body } = createErrorResponse(
|
||||||
|
'VALIDATION_ERROR',
|
||||||
|
'Error de validacion',
|
||||||
|
400,
|
||||||
|
{ fields: formatZodError(err) }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(statusCode).json(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle JWT errors (in case they're not caught earlier)
|
||||||
|
if (err.name === 'JsonWebTokenError') {
|
||||||
|
logger.warn('JWT error', logContext);
|
||||||
|
|
||||||
|
const { statusCode, body } = createErrorResponse(
|
||||||
|
'INVALID_TOKEN',
|
||||||
|
'Token invalido',
|
||||||
|
401
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(statusCode).json(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.name === 'TokenExpiredError') {
|
||||||
|
logger.warn('Token expired', logContext);
|
||||||
|
|
||||||
|
const { statusCode, body } = createErrorResponse(
|
||||||
|
'TOKEN_EXPIRED',
|
||||||
|
'Token expirado',
|
||||||
|
401
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(statusCode).json(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle syntax errors (malformed JSON)
|
||||||
|
if (err instanceof SyntaxError && 'body' in err) {
|
||||||
|
logger.warn('Syntax error in request body', logContext);
|
||||||
|
|
||||||
|
const { statusCode, body } = createErrorResponse(
|
||||||
|
'INVALID_JSON',
|
||||||
|
'JSON invalido en el cuerpo de la solicitud',
|
||||||
|
400
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(statusCode).json(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle PostgreSQL errors
|
||||||
|
if ('code' in err && typeof (err as { code: unknown }).code === 'string') {
|
||||||
|
const pgError = err as { code: string; constraint?: string; detail?: string };
|
||||||
|
|
||||||
|
// Unique constraint violation
|
||||||
|
if (pgError.code === '23505') {
|
||||||
|
logger.warn('Database unique constraint violation', { ...logContext, constraint: pgError.constraint });
|
||||||
|
|
||||||
|
const { statusCode, body } = createErrorResponse(
|
||||||
|
'DUPLICATE_ENTRY',
|
||||||
|
'El registro ya existe',
|
||||||
|
409,
|
||||||
|
{ constraint: pgError.constraint }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(statusCode).json(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Foreign key violation
|
||||||
|
if (pgError.code === '23503') {
|
||||||
|
logger.warn('Database foreign key violation', { ...logContext, constraint: pgError.constraint });
|
||||||
|
|
||||||
|
const { statusCode, body } = createErrorResponse(
|
||||||
|
'REFERENCE_ERROR',
|
||||||
|
'Referencia invalida',
|
||||||
|
400,
|
||||||
|
{ constraint: pgError.constraint }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(statusCode).json(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not null violation
|
||||||
|
if (pgError.code === '23502') {
|
||||||
|
logger.warn('Database not null violation', logContext);
|
||||||
|
|
||||||
|
const { statusCode, body } = createErrorResponse(
|
||||||
|
'MISSING_REQUIRED_FIELD',
|
||||||
|
'Campo requerido faltante',
|
||||||
|
400
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(statusCode).json(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection errors
|
||||||
|
if (pgError.code === 'ECONNREFUSED' || pgError.code === '57P01') {
|
||||||
|
logger.error('Database connection error', logContext);
|
||||||
|
|
||||||
|
const { statusCode, body } = createErrorResponse(
|
||||||
|
'DATABASE_ERROR',
|
||||||
|
'Error de conexion a la base de datos',
|
||||||
|
503
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(statusCode).json(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown errors - log as error and return generic message
|
||||||
|
logger.error('Unhandled error', logContext);
|
||||||
|
|
||||||
|
const { statusCode, body } = createErrorResponse(
|
||||||
|
'INTERNAL_ERROR',
|
||||||
|
config.isProduction ? 'Error interno del servidor' : err.message,
|
||||||
|
500,
|
||||||
|
undefined,
|
||||||
|
err.stack
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(statusCode).json(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Not Found Handler
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle 404 errors for undefined routes
|
||||||
|
*/
|
||||||
|
export const notFoundHandler = (req: Request, res: Response): void => {
|
||||||
|
logger.warn('Route not found', {
|
||||||
|
path: req.path,
|
||||||
|
method: req.method,
|
||||||
|
ip: req.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { body } = createErrorResponse(
|
||||||
|
'NOT_FOUND',
|
||||||
|
`Ruta no encontrada: ${req.method} ${req.path}`,
|
||||||
|
404
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(404).json(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Async Error Wrapper
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap async route handlers to catch errors
|
||||||
|
*/
|
||||||
|
export const catchAsync = (
|
||||||
|
fn: (req: Request, res: Response, next: NextFunction) => Promise<void>
|
||||||
|
) => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default errorHandler;
|
||||||
16
apps/api/src/middleware/index.ts
Normal file
16
apps/api/src/middleware/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// Middleware exports
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
export { authenticate, optionalAuth, authorize, requireOwner, requireAdmin, requireMember, requireTenantAccess } from './auth.js';
|
||||||
|
|
||||||
|
// Legacy auth middleware (for backward compatibility)
|
||||||
|
export { authenticate as authenticateMiddleware, requireRole, requireAdmin as requireAdminLegacy, requireOwner as requireOwnerLegacy, optionalAuth as optionalAuthMiddleware } from './auth.middleware.js';
|
||||||
|
|
||||||
|
// Tenant context
|
||||||
|
export { tenantContext, getTenantClient, queryWithTenant, transactionWithTenant, validateTenantParam, getTenantPool } from './tenant.js';
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
export { errorHandler, notFoundHandler, catchAsync } from './errorHandler.js';
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
export { validate, validateBody, validateQuery, validateParams, type ValidateOptions } from './validate.middleware.js';
|
||||||
203
apps/api/src/middleware/tenant.ts
Normal file
203
apps/api/src/middleware/tenant.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { config } from '../config/index.js';
|
||||||
|
import { AppError, AuthenticationError, NotFoundError } from '../types/index.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Database Pool for Tenant Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: config.database.url,
|
||||||
|
min: config.database.pool.min,
|
||||||
|
max: config.database.pool.max,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Extend Express Request Type
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
tenantSchema?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
setTenantSchema?: (schema: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tenant Context Middleware
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to set the tenant schema for multi-tenant database operations
|
||||||
|
* This should be used AFTER the auth middleware
|
||||||
|
*/
|
||||||
|
export const tenantContext = async (
|
||||||
|
req: Request,
|
||||||
|
_res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// User should be set by auth middleware
|
||||||
|
if (!req.user) {
|
||||||
|
throw new AuthenticationError('Contexto de usuario no disponible');
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemaName = req.user.schema_name;
|
||||||
|
const tenantId = req.user.tenant_id;
|
||||||
|
|
||||||
|
if (!schemaName || !tenantId) {
|
||||||
|
throw new AppError('Informacion de tenant no disponible', 'TENANT_INFO_MISSING', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tenant exists and is active
|
||||||
|
const tenantResult = await pool.query(
|
||||||
|
'SELECT id, schema_name, is_active FROM public.tenants WHERE id = $1',
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tenant = tenantResult.rows[0];
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
throw new NotFoundError('Tenant');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenant.is_active) {
|
||||||
|
throw new AppError('Cuenta desactivada', 'TENANT_INACTIVE', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify schema exists
|
||||||
|
const schemaExists = await pool.query(
|
||||||
|
"SELECT schema_name FROM information_schema.schemata WHERE schema_name = $1",
|
||||||
|
[schemaName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (schemaExists.rows.length === 0) {
|
||||||
|
logger.error('Tenant schema not found', { tenantId, schemaName });
|
||||||
|
throw new AppError('Error de configuracion del tenant', 'SCHEMA_NOT_FOUND', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set tenant info on request
|
||||||
|
req.tenantSchema = schemaName;
|
||||||
|
req.tenantId = tenantId;
|
||||||
|
|
||||||
|
// Helper function to execute queries in tenant context
|
||||||
|
req.setTenantSchema = async (schema: string) => {
|
||||||
|
await pool.query(`SET search_path TO "${schema}", public`);
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug('Tenant context set', { tenantId, schemaName });
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a database client with tenant schema set
|
||||||
|
*/
|
||||||
|
export const getTenantClient = async (schemaName: string) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set search path to tenant schema
|
||||||
|
await client.query(`SET search_path TO "${schemaName}", public`);
|
||||||
|
return client;
|
||||||
|
} catch (error) {
|
||||||
|
client.release();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query within tenant context
|
||||||
|
*/
|
||||||
|
export const queryWithTenant = async <T>(
|
||||||
|
schemaName: string,
|
||||||
|
queryText: string,
|
||||||
|
values?: unknown[]
|
||||||
|
): Promise<T[]> => {
|
||||||
|
const client = await getTenantClient(schemaName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.query(queryText, values);
|
||||||
|
return result.rows as T[];
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a transaction within tenant context
|
||||||
|
*/
|
||||||
|
export const transactionWithTenant = async <T>(
|
||||||
|
schemaName: string,
|
||||||
|
callback: (client: ReturnType<typeof pool.connect> extends Promise<infer C> ? C : never) => Promise<T>
|
||||||
|
): Promise<T> => {
|
||||||
|
const client = await getTenantClient(schemaName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const result = await callback(client);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to validate tenant from URL parameter
|
||||||
|
* Useful for admin routes that need to access specific tenants
|
||||||
|
*/
|
||||||
|
export const validateTenantParam = async (
|
||||||
|
req: Request,
|
||||||
|
_res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.params.tenantId;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
throw new AppError('Tenant ID es requerido', 'TENANT_ID_REQUIRED', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantResult = await pool.query(
|
||||||
|
'SELECT id, schema_name, name, is_active FROM public.tenants WHERE id = $1',
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tenant = tenantResult.rows[0];
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
throw new NotFoundError('Tenant');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenant.is_active) {
|
||||||
|
throw new AppError('Tenant inactivo', 'TENANT_INACTIVE', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set tenant info from param
|
||||||
|
req.tenantSchema = tenant.schema_name;
|
||||||
|
req.tenantId = tenant.id;
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get the current tenant pool
|
||||||
|
*/
|
||||||
|
export const getTenantPool = () => pool;
|
||||||
|
|
||||||
|
export default tenantContext;
|
||||||
70
apps/api/src/middleware/validate.middleware.ts
Normal file
70
apps/api/src/middleware/validate.middleware.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Validation Middleware
|
||||||
|
*
|
||||||
|
* Validates request body, query, and params using Zod schemas
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { ZodSchema, ZodError } from 'zod';
|
||||||
|
import { ValidationError } from '../types';
|
||||||
|
|
||||||
|
export interface ValidateOptions {
|
||||||
|
body?: ZodSchema;
|
||||||
|
query?: ZodSchema;
|
||||||
|
params?: ZodSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate request against Zod schemas
|
||||||
|
*/
|
||||||
|
export const validate = (schemas: ValidateOptions) => {
|
||||||
|
return async (req: Request, _res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (schemas.body) {
|
||||||
|
req.body = schemas.body.parse(req.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schemas.query) {
|
||||||
|
req.query = schemas.query.parse(req.query);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schemas.params) {
|
||||||
|
req.params = schemas.params.parse(req.params);
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
const details = error.errors.reduce(
|
||||||
|
(acc, err) => {
|
||||||
|
const path = err.path.join('.');
|
||||||
|
acc[path] = err.message;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>
|
||||||
|
);
|
||||||
|
|
||||||
|
next(new ValidationError('Datos de entrada invalidos', details));
|
||||||
|
} else {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate only request body
|
||||||
|
*/
|
||||||
|
export const validateBody = (schema: ZodSchema) => validate({ body: schema });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate only query parameters
|
||||||
|
*/
|
||||||
|
export const validateQuery = (schema: ZodSchema) => validate({ query: schema });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate only path parameters
|
||||||
|
*/
|
||||||
|
export const validateParams = (schema: ZodSchema) => validate({ params: schema });
|
||||||
|
|
||||||
|
export default validate;
|
||||||
453
apps/api/src/routes/alerts.routes.ts
Normal file
453
apps/api/src/routes/alerts.routes.ts
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
/**
|
||||||
|
* Alerts Routes
|
||||||
|
*
|
||||||
|
* Handles system alerts and notifications
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { validate } from '../middleware/validate.middleware';
|
||||||
|
import { getDatabase, TenantContext } from '@horux/database';
|
||||||
|
import {
|
||||||
|
ApiResponse,
|
||||||
|
AppError,
|
||||||
|
NotFoundError,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const AlertTypeEnum = z.enum([
|
||||||
|
'info',
|
||||||
|
'warning',
|
||||||
|
'error',
|
||||||
|
'success',
|
||||||
|
'payment_due',
|
||||||
|
'sync_error',
|
||||||
|
'budget_exceeded',
|
||||||
|
'low_balance',
|
||||||
|
'anomaly_detected',
|
||||||
|
'tax_reminder',
|
||||||
|
'cfdi_cancelled',
|
||||||
|
'recurring_expected',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const AlertPriorityEnum = z.enum(['low', 'medium', 'high', 'critical']);
|
||||||
|
const AlertStatusEnum = z.enum(['unread', 'read', 'dismissed', 'actioned']);
|
||||||
|
|
||||||
|
const AlertFiltersSchema = z.object({
|
||||||
|
page: z.string().optional().transform((v) => (v ? parseInt(v, 10) : 1)),
|
||||||
|
limit: z.string().optional().transform((v) => (v ? Math.min(parseInt(v, 10), 100) : 20)),
|
||||||
|
type: AlertTypeEnum.optional(),
|
||||||
|
priority: AlertPriorityEnum.optional(),
|
||||||
|
status: AlertStatusEnum.optional(),
|
||||||
|
unreadOnly: z.string().optional().transform((v) => v === 'true'),
|
||||||
|
startDate: z.string().datetime().optional(),
|
||||||
|
endDate: z.string().datetime().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const AlertIdSchema = z.object({
|
||||||
|
id: z.string().uuid('ID de alerta invalido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const getTenantContext = (req: Request): TenantContext => {
|
||||||
|
if (!req.user || !req.tenantSchema) {
|
||||||
|
throw new AppError('Contexto de tenant no disponible', 'TENANT_CONTEXT_ERROR', 500);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: req.user.tenant_id,
|
||||||
|
schemaName: req.tenantSchema,
|
||||||
|
userId: req.user.sub,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Routes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/alerts
|
||||||
|
* List active alerts with filters
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
authenticate,
|
||||||
|
validate({ query: AlertFiltersSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const filters = req.query as z.infer<typeof AlertFiltersSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Build filter conditions
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// Filter by user or global alerts
|
||||||
|
conditions.push(`(a.user_id = $${paramIndex} OR a.user_id IS NULL)`);
|
||||||
|
params.push(req.user!.sub);
|
||||||
|
paramIndex++;
|
||||||
|
|
||||||
|
if (filters.unreadOnly) {
|
||||||
|
conditions.push(`a.status = 'unread'`);
|
||||||
|
} else if (filters.status) {
|
||||||
|
conditions.push(`a.status = $${paramIndex++}`);
|
||||||
|
params.push(filters.status);
|
||||||
|
} else {
|
||||||
|
conditions.push(`a.status != 'dismissed'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.type) {
|
||||||
|
conditions.push(`a.type = $${paramIndex++}`);
|
||||||
|
params.push(filters.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.priority) {
|
||||||
|
conditions.push(`a.priority = $${paramIndex++}`);
|
||||||
|
params.push(filters.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.startDate) {
|
||||||
|
conditions.push(`a.created_at >= $${paramIndex++}`);
|
||||||
|
params.push(filters.startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.endDate) {
|
||||||
|
conditions.push(`a.created_at <= $${paramIndex++}`);
|
||||||
|
params.push(filters.endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude expired alerts
|
||||||
|
conditions.push(`(a.expires_at IS NULL OR a.expires_at > NOW())`);
|
||||||
|
|
||||||
|
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||||
|
const offset = (filters.page - 1) * filters.limit;
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const countQuery = `SELECT COUNT(*) as total FROM alerts a ${whereClause}`;
|
||||||
|
const countResult = await db.queryTenant<{ total: string }>(tenant, countQuery, params);
|
||||||
|
const total = parseInt(countResult.rows[0]?.total || '0', 10);
|
||||||
|
|
||||||
|
// Get unread count
|
||||||
|
const unreadQuery = `
|
||||||
|
SELECT COUNT(*) as unread
|
||||||
|
FROM alerts a
|
||||||
|
WHERE (a.user_id = $1 OR a.user_id IS NULL)
|
||||||
|
AND a.status = 'unread'
|
||||||
|
AND (a.expires_at IS NULL OR a.expires_at > NOW())
|
||||||
|
`;
|
||||||
|
const unreadResult = await db.queryTenant<{ unread: string }>(tenant, unreadQuery, [req.user!.sub]);
|
||||||
|
const unreadCount = parseInt(unreadResult.rows[0]?.unread || '0', 10);
|
||||||
|
|
||||||
|
// Get alerts
|
||||||
|
const dataQuery = `
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
a.type,
|
||||||
|
a.priority,
|
||||||
|
a.title,
|
||||||
|
a.message,
|
||||||
|
a.status,
|
||||||
|
a.action_url,
|
||||||
|
a.action_label,
|
||||||
|
a.metadata,
|
||||||
|
a.created_at,
|
||||||
|
a.read_at,
|
||||||
|
a.expires_at
|
||||||
|
FROM alerts a
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY
|
||||||
|
CASE a.priority
|
||||||
|
WHEN 'critical' THEN 1
|
||||||
|
WHEN 'high' THEN 2
|
||||||
|
WHEN 'medium' THEN 3
|
||||||
|
ELSE 4
|
||||||
|
END,
|
||||||
|
a.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dataResult = await db.queryTenant(tenant, dataQuery, [...params, filters.limit, offset]);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: dataResult.rows,
|
||||||
|
meta: {
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
total,
|
||||||
|
unreadCount,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/alerts/summary
|
||||||
|
* Get alert summary counts by type and priority
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/summary',
|
||||||
|
authenticate,
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status = 'unread') as total_unread,
|
||||||
|
COUNT(*) FILTER (WHERE priority = 'critical' AND status = 'unread') as critical_unread,
|
||||||
|
COUNT(*) FILTER (WHERE priority = 'high' AND status = 'unread') as high_unread,
|
||||||
|
COUNT(*) FILTER (WHERE priority = 'medium' AND status = 'unread') as medium_unread,
|
||||||
|
COUNT(*) FILTER (WHERE priority = 'low' AND status = 'unread') as low_unread,
|
||||||
|
json_object_agg(
|
||||||
|
type,
|
||||||
|
type_count
|
||||||
|
) FILTER (WHERE type IS NOT NULL) as by_type
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
priority,
|
||||||
|
status,
|
||||||
|
type,
|
||||||
|
COUNT(*) OVER (PARTITION BY type) as type_count
|
||||||
|
FROM alerts
|
||||||
|
WHERE (user_id = $1 OR user_id IS NULL)
|
||||||
|
AND status != 'dismissed'
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
) sub
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, query, [req.user!.sub]);
|
||||||
|
|
||||||
|
const summary = result.rows[0] || {
|
||||||
|
total_unread: 0,
|
||||||
|
critical_unread: 0,
|
||||||
|
high_unread: 0,
|
||||||
|
medium_unread: 0,
|
||||||
|
low_unread: 0,
|
||||||
|
by_type: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
unread: {
|
||||||
|
total: parseInt(summary.total_unread || '0', 10),
|
||||||
|
critical: parseInt(summary.critical_unread || '0', 10),
|
||||||
|
high: parseInt(summary.high_unread || '0', 10),
|
||||||
|
medium: parseInt(summary.medium_unread || '0', 10),
|
||||||
|
low: parseInt(summary.low_unread || '0', 10),
|
||||||
|
},
|
||||||
|
byType: summary.by_type || {},
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/alerts/:id
|
||||||
|
* Get a single alert
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: AlertIdSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT *
|
||||||
|
FROM alerts
|
||||||
|
WHERE id = $1 AND (user_id = $2 OR user_id IS NULL)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, query, [id, req.user!.sub]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Alerta');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/alerts/:id/read
|
||||||
|
* Mark alert as read
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
'/:id/read',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: AlertIdSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Check if alert exists and belongs to user
|
||||||
|
const existingCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id, status FROM alerts WHERE id = $1 AND (user_id = $2 OR user_id IS NULL)',
|
||||||
|
[id, req.user!.sub]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Alerta');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update to read
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE alerts
|
||||||
|
SET status = 'read', read_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, updateQuery, [id]);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/alerts/:id/dismiss
|
||||||
|
* Dismiss an alert
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
'/:id/dismiss',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: AlertIdSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Check if alert exists and belongs to user
|
||||||
|
const existingCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id, status FROM alerts WHERE id = $1 AND (user_id = $2 OR user_id IS NULL)',
|
||||||
|
[id, req.user!.sub]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Alerta');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update to dismissed
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE alerts
|
||||||
|
SET status = 'dismissed', dismissed_at = NOW(), dismissed_by = $2, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, updateQuery, [id, req.user!.sub]);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/alerts/read-all
|
||||||
|
* Mark all alerts as read
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
'/read-all',
|
||||||
|
authenticate,
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE alerts
|
||||||
|
SET status = 'read', read_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE (user_id = $1 OR user_id IS NULL)
|
||||||
|
AND status = 'unread'
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, updateQuery, [req.user!.sub]);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
updatedCount: result.rowCount || 0,
|
||||||
|
updatedIds: result.rows.map(r => r.id),
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
324
apps/api/src/routes/auth.routes.ts
Normal file
324
apps/api/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { authService } from '../services/auth.service.js';
|
||||||
|
import { authenticate, requireMember } from '../middleware/auth.js';
|
||||||
|
import { tenantContext } from '../middleware/tenant.js';
|
||||||
|
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||||
|
import { logger, auditLog } from '../utils/logger.js';
|
||||||
|
import {
|
||||||
|
RegisterSchema,
|
||||||
|
LoginSchema,
|
||||||
|
RefreshTokenSchema,
|
||||||
|
ResetPasswordRequestSchema,
|
||||||
|
ResetPasswordSchema,
|
||||||
|
ChangePasswordSchema,
|
||||||
|
ValidationError,
|
||||||
|
ApiResponse,
|
||||||
|
} from '../types/index.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Router Setup
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract client info from request
|
||||||
|
*/
|
||||||
|
const getClientInfo = (req: Request) => ({
|
||||||
|
userAgent: req.headers['user-agent'],
|
||||||
|
ipAddress: req.ip || req.socket.remoteAddress,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create success response
|
||||||
|
*/
|
||||||
|
const successResponse = <T>(data: T, meta?: Record<string, unknown>): ApiResponse<T> => ({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
...meta,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Public Routes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/auth/register
|
||||||
|
* Register a new user and create their organization
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/register',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
// Validate input
|
||||||
|
const parseResult = RegisterSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de registro invalidos', {
|
||||||
|
errors: parseResult.error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, tokens } = await authService.register(parseResult.data);
|
||||||
|
|
||||||
|
logger.info('User registered via API', { userId: user.id, email: user.email });
|
||||||
|
|
||||||
|
res.status(201).json(
|
||||||
|
successResponse({
|
||||||
|
user,
|
||||||
|
tokens: {
|
||||||
|
accessToken: tokens.accessToken,
|
||||||
|
refreshToken: tokens.refreshToken,
|
||||||
|
expiresIn: tokens.expiresIn,
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/auth/login
|
||||||
|
* Authenticate user and return tokens
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/login',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
// Validate input
|
||||||
|
const parseResult = LoginSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Credenciales invalidas', {
|
||||||
|
errors: parseResult.error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userAgent, ipAddress } = getClientInfo(req);
|
||||||
|
const { user, tenant, tokens } = await authService.login(
|
||||||
|
parseResult.data,
|
||||||
|
userAgent,
|
||||||
|
ipAddress
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(
|
||||||
|
successResponse({
|
||||||
|
user,
|
||||||
|
tenant,
|
||||||
|
tokens: {
|
||||||
|
accessToken: tokens.accessToken,
|
||||||
|
refreshToken: tokens.refreshToken,
|
||||||
|
expiresIn: tokens.expiresIn,
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/auth/refresh
|
||||||
|
* Refresh access token using refresh token
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/refresh',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
// Validate input
|
||||||
|
const parseResult = RefreshTokenSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Refresh token invalido', {
|
||||||
|
errors: parseResult.error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await authService.refreshToken(parseResult.data.refreshToken);
|
||||||
|
|
||||||
|
res.json(
|
||||||
|
successResponse({
|
||||||
|
tokens: {
|
||||||
|
accessToken: tokens.accessToken,
|
||||||
|
refreshToken: tokens.refreshToken,
|
||||||
|
expiresIn: tokens.expiresIn,
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/auth/logout
|
||||||
|
* Invalidate the current session
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/logout',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const refreshToken = req.body?.refreshToken;
|
||||||
|
|
||||||
|
if (refreshToken) {
|
||||||
|
await authService.logout(refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(successResponse({ message: 'Sesion cerrada exitosamente' }));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/auth/forgot-password
|
||||||
|
* Request password reset email
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/forgot-password',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
// Validate input
|
||||||
|
const parseResult = ResetPasswordRequestSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Email invalido', {
|
||||||
|
errors: parseResult.error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await authService.requestPasswordReset(parseResult.data.email);
|
||||||
|
|
||||||
|
// Always return success to prevent email enumeration
|
||||||
|
res.json(
|
||||||
|
successResponse({
|
||||||
|
message: 'Si el email existe, recibiras instrucciones para restablecer tu contrasena',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/auth/reset-password
|
||||||
|
* Reset password using token
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/reset-password',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
// Validate input
|
||||||
|
const parseResult = ResetPasswordSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', {
|
||||||
|
errors: parseResult.error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await authService.resetPassword(parseResult.data.token, parseResult.data.password);
|
||||||
|
|
||||||
|
res.json(
|
||||||
|
successResponse({
|
||||||
|
message: 'Contrasena restablecida exitosamente. Por favor inicia sesion con tu nueva contrasena.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Protected Routes (require authentication)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/auth/me
|
||||||
|
* Get current user profile
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/me',
|
||||||
|
authenticate,
|
||||||
|
tenantContext,
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const userId = req.user!.sub;
|
||||||
|
const profile = await authService.getProfile(userId);
|
||||||
|
|
||||||
|
res.json(successResponse(profile));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/auth/change-password
|
||||||
|
* Change password for authenticated user
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/change-password',
|
||||||
|
authenticate,
|
||||||
|
tenantContext,
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
// Validate input
|
||||||
|
const parseResult = ChangePasswordSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', {
|
||||||
|
errors: parseResult.error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.user!.sub;
|
||||||
|
|
||||||
|
await authService.changePassword(
|
||||||
|
userId,
|
||||||
|
parseResult.data.currentPassword,
|
||||||
|
parseResult.data.newPassword
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(
|
||||||
|
successResponse({
|
||||||
|
message: 'Contrasena actualizada exitosamente',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/auth/logout-all
|
||||||
|
* Logout from all sessions
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/logout-all',
|
||||||
|
authenticate,
|
||||||
|
tenantContext,
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const userId = req.user!.sub;
|
||||||
|
const tenantId = req.user!.tenant_id;
|
||||||
|
|
||||||
|
const sessionsDeleted = await authService.logoutAll(userId, tenantId);
|
||||||
|
|
||||||
|
res.json(
|
||||||
|
successResponse({
|
||||||
|
message: 'Todas las sesiones han sido cerradas',
|
||||||
|
sessionsDeleted,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/auth/verify
|
||||||
|
* Verify if current token is valid
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/verify',
|
||||||
|
authenticate,
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
res.json(
|
||||||
|
successResponse({
|
||||||
|
valid: true,
|
||||||
|
user: {
|
||||||
|
id: req.user!.sub,
|
||||||
|
email: req.user!.email,
|
||||||
|
role: req.user!.role,
|
||||||
|
tenantId: req.user!.tenant_id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
596
apps/api/src/routes/categories.routes.ts
Normal file
596
apps/api/src/routes/categories.routes.ts
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
/**
|
||||||
|
* Categories Routes
|
||||||
|
*
|
||||||
|
* CRUD operations for transaction categories
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { validate } from '../middleware/validate.middleware';
|
||||||
|
import { getDatabase, TenantContext } from '@horux/database';
|
||||||
|
import {
|
||||||
|
ApiResponse,
|
||||||
|
AppError,
|
||||||
|
NotFoundError,
|
||||||
|
ValidationError,
|
||||||
|
ConflictError,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const CategoryTypeEnum = z.enum(['income', 'expense', 'both']);
|
||||||
|
|
||||||
|
const CategoryFiltersSchema = z.object({
|
||||||
|
type: CategoryTypeEnum.optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
parentId: z.string().uuid().optional().nullable(),
|
||||||
|
includeInactive: z.string().optional().transform((v) => v === 'true'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CategoryIdSchema = z.object({
|
||||||
|
id: z.string().uuid('ID de categoria invalido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CreateCategorySchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
type: CategoryTypeEnum,
|
||||||
|
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Color debe ser un codigo hexadecimal valido').optional(),
|
||||||
|
icon: z.string().max(50).optional(),
|
||||||
|
parentId: z.string().uuid().optional().nullable(),
|
||||||
|
budget: z.number().positive().optional().nullable(),
|
||||||
|
isSystem: z.boolean().optional().default(false),
|
||||||
|
sortOrder: z.number().int().optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const UpdateCategorySchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
description: z.string().max(500).optional().nullable(),
|
||||||
|
type: CategoryTypeEnum.optional(),
|
||||||
|
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Color debe ser un codigo hexadecimal valido').optional(),
|
||||||
|
icon: z.string().max(50).optional().nullable(),
|
||||||
|
parentId: z.string().uuid().optional().nullable(),
|
||||||
|
budget: z.number().positive().optional().nullable(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
sortOrder: z.number().int().optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const getTenantContext = (req: Request): TenantContext => {
|
||||||
|
if (!req.user || !req.tenantSchema) {
|
||||||
|
throw new AppError('Contexto de tenant no disponible', 'TENANT_CONTEXT_ERROR', 500);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: req.user.tenant_id,
|
||||||
|
schemaName: req.tenantSchema,
|
||||||
|
userId: req.user.sub,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Routes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/categories
|
||||||
|
* List all categories with optional filters
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
authenticate,
|
||||||
|
validate({ query: CategoryFiltersSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const filters = req.query as z.infer<typeof CategoryFiltersSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Build filter conditions
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (!filters.includeInactive) {
|
||||||
|
conditions.push('c.is_active = true');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.type) {
|
||||||
|
conditions.push(`(c.type = $${paramIndex} OR c.type = 'both')`);
|
||||||
|
params.push(filters.type);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
conditions.push(`(c.name ILIKE $${paramIndex} OR c.description ILIKE $${paramIndex})`);
|
||||||
|
params.push(`%${filters.search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.parentId !== undefined) {
|
||||||
|
if (filters.parentId === null) {
|
||||||
|
conditions.push('c.parent_id IS NULL');
|
||||||
|
} else {
|
||||||
|
conditions.push(`c.parent_id = $${paramIndex++}`);
|
||||||
|
params.push(filters.parentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
// Get categories with transaction counts and subcategory info
|
||||||
|
const query = `
|
||||||
|
WITH RECURSIVE category_tree AS (
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.name,
|
||||||
|
c.description,
|
||||||
|
c.type,
|
||||||
|
c.color,
|
||||||
|
c.icon,
|
||||||
|
c.parent_id,
|
||||||
|
c.budget,
|
||||||
|
c.is_system,
|
||||||
|
c.is_active,
|
||||||
|
c.sort_order,
|
||||||
|
c.created_at,
|
||||||
|
c.updated_at,
|
||||||
|
0 as level,
|
||||||
|
ARRAY[c.id] as path
|
||||||
|
FROM categories c
|
||||||
|
WHERE c.parent_id IS NULL
|
||||||
|
${conditions.length > 0 ? 'AND ' + conditions.filter(c => !c.includes('parent_id')).join(' AND ') : ''}
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.name,
|
||||||
|
c.description,
|
||||||
|
c.type,
|
||||||
|
c.color,
|
||||||
|
c.icon,
|
||||||
|
c.parent_id,
|
||||||
|
c.budget,
|
||||||
|
c.is_system,
|
||||||
|
c.is_active,
|
||||||
|
c.sort_order,
|
||||||
|
c.created_at,
|
||||||
|
c.updated_at,
|
||||||
|
ct.level + 1,
|
||||||
|
ct.path || c.id
|
||||||
|
FROM categories c
|
||||||
|
JOIN category_tree ct ON c.parent_id = ct.id
|
||||||
|
WHERE c.is_active = true OR $${paramIndex} = true
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
ct.*,
|
||||||
|
COALESCE(stats.transaction_count, 0) as transaction_count,
|
||||||
|
COALESCE(stats.total_amount, 0) as total_amount,
|
||||||
|
COALESCE(stats.last_30_days_amount, 0) as last_30_days_amount,
|
||||||
|
(
|
||||||
|
SELECT json_agg(
|
||||||
|
json_build_object('id', sub.id, 'name', sub.name, 'color', sub.color, 'icon', sub.icon)
|
||||||
|
)
|
||||||
|
FROM categories sub
|
||||||
|
WHERE sub.parent_id = ct.id AND sub.is_active = true
|
||||||
|
) as subcategories
|
||||||
|
FROM category_tree ct
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as transaction_count,
|
||||||
|
SUM(amount) as total_amount,
|
||||||
|
SUM(CASE WHEN date >= NOW() - INTERVAL '30 days' THEN amount ELSE 0 END) as last_30_days_amount
|
||||||
|
FROM transactions
|
||||||
|
WHERE category_id = ct.id
|
||||||
|
) stats ON true
|
||||||
|
ORDER BY ct.sort_order, ct.name
|
||||||
|
`;
|
||||||
|
|
||||||
|
params.push(filters.includeInactive || false);
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, query, params);
|
||||||
|
|
||||||
|
// Build hierarchical structure
|
||||||
|
const categoriesMap = new Map<string, unknown>();
|
||||||
|
const rootCategories: unknown[] = [];
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
categoriesMap.set(row.id, { ...row, children: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const category = categoriesMap.get(row.id);
|
||||||
|
if (row.parent_id && categoriesMap.has(row.parent_id)) {
|
||||||
|
const parent = categoriesMap.get(row.parent_id) as { children: unknown[] };
|
||||||
|
parent.children.push(category);
|
||||||
|
} else if (!row.parent_id) {
|
||||||
|
rootCategories.push(category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
categories: result.rows,
|
||||||
|
tree: rootCategories,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
total: result.rows.length,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/categories/:id
|
||||||
|
* Get a single category by ID
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: CategoryIdSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
c.*,
|
||||||
|
json_build_object('id', p.id, 'name', p.name, 'color', p.color) as parent,
|
||||||
|
(
|
||||||
|
SELECT json_agg(
|
||||||
|
json_build_object('id', sub.id, 'name', sub.name, 'color', sub.color, 'icon', sub.icon)
|
||||||
|
)
|
||||||
|
FROM categories sub
|
||||||
|
WHERE sub.parent_id = c.id AND sub.is_active = true
|
||||||
|
) as subcategories,
|
||||||
|
(
|
||||||
|
SELECT json_build_object(
|
||||||
|
'transaction_count', COUNT(*),
|
||||||
|
'total_amount', COALESCE(SUM(amount), 0),
|
||||||
|
'income_amount', COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0),
|
||||||
|
'expense_amount', COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0),
|
||||||
|
'last_30_days', COALESCE(SUM(CASE WHEN date >= NOW() - INTERVAL '30 days' THEN amount ELSE 0 END), 0),
|
||||||
|
'first_transaction', MIN(date),
|
||||||
|
'last_transaction', MAX(date)
|
||||||
|
)
|
||||||
|
FROM transactions
|
||||||
|
WHERE category_id = c.id
|
||||||
|
) as statistics
|
||||||
|
FROM categories c
|
||||||
|
LEFT JOIN categories p ON c.parent_id = p.id
|
||||||
|
WHERE c.id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, query, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Categoria');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/categories
|
||||||
|
* Create a new category
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
authenticate,
|
||||||
|
validate({ body: CreateCategorySchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const data = req.body as z.infer<typeof CreateCategorySchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Check for duplicate name at same level
|
||||||
|
const duplicateQuery = data.parentId
|
||||||
|
? 'SELECT id FROM categories WHERE name = $1 AND parent_id = $2 AND is_active = true'
|
||||||
|
: 'SELECT id FROM categories WHERE name = $1 AND parent_id IS NULL AND is_active = true';
|
||||||
|
|
||||||
|
const duplicateParams = data.parentId ? [data.name, data.parentId] : [data.name];
|
||||||
|
const duplicateCheck = await db.queryTenant(tenant, duplicateQuery, duplicateParams);
|
||||||
|
|
||||||
|
if (duplicateCheck.rows.length > 0) {
|
||||||
|
throw new ConflictError('Ya existe una categoria con este nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify parent exists if provided
|
||||||
|
if (data.parentId) {
|
||||||
|
const parentCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id FROM categories WHERE id = $1 AND is_active = true',
|
||||||
|
[data.parentId]
|
||||||
|
);
|
||||||
|
if (parentCheck.rows.length === 0) {
|
||||||
|
throw new ValidationError('Categoria padre no encontrada', { parentId: 'No existe' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get next sort order if not provided
|
||||||
|
let sortOrder = data.sortOrder;
|
||||||
|
if (sortOrder === undefined) {
|
||||||
|
const sortQuery = data.parentId
|
||||||
|
? 'SELECT COALESCE(MAX(sort_order), 0) + 1 as next_order FROM categories WHERE parent_id = $1'
|
||||||
|
: 'SELECT COALESCE(MAX(sort_order), 0) + 1 as next_order FROM categories WHERE parent_id IS NULL';
|
||||||
|
|
||||||
|
const sortParams = data.parentId ? [data.parentId] : [];
|
||||||
|
const sortResult = await db.queryTenant<{ next_order: number }>(tenant, sortQuery, sortParams);
|
||||||
|
sortOrder = sortResult.rows[0]?.next_order || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO categories (
|
||||||
|
name, description, type, color, icon, parent_id,
|
||||||
|
budget, is_system, sort_order, metadata, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, insertQuery, [
|
||||||
|
data.name,
|
||||||
|
data.description || null,
|
||||||
|
data.type,
|
||||||
|
data.color || '#6B7280',
|
||||||
|
data.icon || null,
|
||||||
|
data.parentId || null,
|
||||||
|
data.budget || null,
|
||||||
|
data.isSystem || false,
|
||||||
|
sortOrder,
|
||||||
|
data.metadata || {},
|
||||||
|
req.user!.sub,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/categories/:id
|
||||||
|
* Update a category
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: CategoryIdSchema, body: UpdateCategorySchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const data = req.body as z.infer<typeof UpdateCategorySchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Check if category exists
|
||||||
|
const existingCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id, is_system, name, parent_id FROM categories WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Categoria');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = existingCheck.rows[0];
|
||||||
|
|
||||||
|
// Prevent modification of system categories (except budget)
|
||||||
|
if (existing.is_system) {
|
||||||
|
const allowedFields = ['budget', 'sortOrder'];
|
||||||
|
const attemptedFields = Object.keys(data);
|
||||||
|
const disallowedFields = attemptedFields.filter(f => !allowedFields.includes(f));
|
||||||
|
|
||||||
|
if (disallowedFields.length > 0) {
|
||||||
|
throw new ValidationError('No se pueden modificar categorias del sistema', {
|
||||||
|
fields: disallowedFields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate name if changing name
|
||||||
|
if (data.name && data.name !== existing.name) {
|
||||||
|
const parentId = data.parentId !== undefined ? data.parentId : existing.parent_id;
|
||||||
|
const duplicateQuery = parentId
|
||||||
|
? 'SELECT id FROM categories WHERE name = $1 AND parent_id = $2 AND id != $3 AND is_active = true'
|
||||||
|
: 'SELECT id FROM categories WHERE name = $1 AND parent_id IS NULL AND id != $2 AND is_active = true';
|
||||||
|
|
||||||
|
const duplicateParams = parentId ? [data.name, parentId, id] : [data.name, id];
|
||||||
|
const duplicateCheck = await db.queryTenant(tenant, duplicateQuery, duplicateParams);
|
||||||
|
|
||||||
|
if (duplicateCheck.rows.length > 0) {
|
||||||
|
throw new ConflictError('Ya existe una categoria con este nombre');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent circular parent reference
|
||||||
|
if (data.parentId === id) {
|
||||||
|
throw new ValidationError('Una categoria no puede ser su propio padre');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify parent exists if changing
|
||||||
|
if (data.parentId) {
|
||||||
|
const parentCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id FROM categories WHERE id = $1 AND is_active = true',
|
||||||
|
[data.parentId]
|
||||||
|
);
|
||||||
|
if (parentCheck.rows.length === 0) {
|
||||||
|
throw new ValidationError('Categoria padre no encontrada', { parentId: 'No existe' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update query dynamically
|
||||||
|
const updates: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldMappings: Record<string, string> = {
|
||||||
|
name: 'name',
|
||||||
|
description: 'description',
|
||||||
|
type: 'type',
|
||||||
|
color: 'color',
|
||||||
|
icon: 'icon',
|
||||||
|
parentId: 'parent_id',
|
||||||
|
budget: 'budget',
|
||||||
|
isActive: 'is_active',
|
||||||
|
sortOrder: 'sort_order',
|
||||||
|
metadata: 'metadata',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, column] of Object.entries(fieldMappings)) {
|
||||||
|
if (data[key as keyof typeof data] !== undefined) {
|
||||||
|
updates.push(`${column} = $${paramIndex++}`);
|
||||||
|
params.push(data[key as keyof typeof data]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
throw new ValidationError('No hay campos para actualizar');
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push(`updated_at = NOW()`);
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE categories
|
||||||
|
SET ${updates.join(', ')}
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, updateQuery, params);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/categories/:id
|
||||||
|
* Soft delete a category
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: CategoryIdSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Check if category exists
|
||||||
|
const existingCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id, is_system FROM categories WHERE id = $1 AND is_active = true',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Categoria');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deletion of system categories
|
||||||
|
if (existingCheck.rows[0].is_system) {
|
||||||
|
throw new ValidationError('No se pueden eliminar categorias del sistema');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for subcategories
|
||||||
|
const subcategoryCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id FROM categories WHERE parent_id = $1 AND is_active = true LIMIT 1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (subcategoryCheck.rows.length > 0) {
|
||||||
|
throw new ValidationError('No se puede eliminar una categoria con subcategorias activas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for linked transactions
|
||||||
|
const transactionCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id FROM transactions WHERE category_id = $1 LIMIT 1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (transactionCheck.rows.length > 0) {
|
||||||
|
// Soft delete - just mark as inactive
|
||||||
|
await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'UPDATE categories SET is_active = false, updated_at = NOW() WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Hard delete if no transactions
|
||||||
|
await db.queryTenant(tenant, 'DELETE FROM categories WHERE id = $1', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: { deleted: true, id },
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
514
apps/api/src/routes/cfdis.routes.ts
Normal file
514
apps/api/src/routes/cfdis.routes.ts
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
/**
|
||||||
|
* CFDIs Routes
|
||||||
|
*
|
||||||
|
* Handles CFDI (Comprobante Fiscal Digital por Internet) operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { validate } from '../middleware/validate.middleware';
|
||||||
|
import { getDatabase, TenantContext } from '@horux/database';
|
||||||
|
import {
|
||||||
|
ApiResponse,
|
||||||
|
AppError,
|
||||||
|
NotFoundError,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const CfdiTypeEnum = z.enum(['ingreso', 'egreso', 'traslado', 'nomina', 'pago']);
|
||||||
|
const CfdiStatusEnum = z.enum(['vigente', 'cancelado', 'pendiente']);
|
||||||
|
|
||||||
|
const PaginationSchema = z.object({
|
||||||
|
page: z.string().optional().transform((v) => (v ? parseInt(v, 10) : 1)),
|
||||||
|
limit: z.string().optional().transform((v) => (v ? Math.min(parseInt(v, 10), 100) : 20)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CfdiFiltersSchema = PaginationSchema.extend({
|
||||||
|
tipo: CfdiTypeEnum.optional(),
|
||||||
|
estado: CfdiStatusEnum.optional(),
|
||||||
|
startDate: z.string().datetime().optional(),
|
||||||
|
endDate: z.string().datetime().optional(),
|
||||||
|
rfcEmisor: z.string().optional(),
|
||||||
|
rfcReceptor: z.string().optional(),
|
||||||
|
minTotal: z.string().optional().transform((v) => (v ? parseFloat(v) : undefined)),
|
||||||
|
maxTotal: z.string().optional().transform((v) => (v ? parseFloat(v) : undefined)),
|
||||||
|
search: z.string().optional(),
|
||||||
|
sortBy: z.enum(['fecha', 'total', 'created_at']).optional().default('fecha'),
|
||||||
|
sortOrder: z.enum(['asc', 'desc']).optional().default('desc'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CfdiIdSchema = z.object({
|
||||||
|
id: z.string().uuid('ID de CFDI invalido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const SummaryQuerySchema = z.object({
|
||||||
|
startDate: z.string().datetime(),
|
||||||
|
endDate: z.string().datetime(),
|
||||||
|
groupBy: z.enum(['day', 'week', 'month']).optional().default('month'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const SyncBodySchema = z.object({
|
||||||
|
startDate: z.string().datetime().optional(),
|
||||||
|
endDate: z.string().datetime().optional(),
|
||||||
|
tipoComprobante: CfdiTypeEnum.optional(),
|
||||||
|
force: z.boolean().optional().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const getTenantContext = (req: Request): TenantContext => {
|
||||||
|
if (!req.user || !req.tenantSchema) {
|
||||||
|
throw new AppError('Contexto de tenant no disponible', 'TENANT_CONTEXT_ERROR', 500);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: req.user.tenant_id,
|
||||||
|
schemaName: req.tenantSchema,
|
||||||
|
userId: req.user.sub,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Routes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cfdis
|
||||||
|
* List CFDIs with filters and pagination
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
authenticate,
|
||||||
|
validate({ query: CfdiFiltersSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const filters = req.query as z.infer<typeof CfdiFiltersSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Build filter conditions
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (filters.tipo) {
|
||||||
|
conditions.push(`c.tipo_comprobante = $${paramIndex++}`);
|
||||||
|
params.push(filters.tipo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.estado) {
|
||||||
|
conditions.push(`c.estado = $${paramIndex++}`);
|
||||||
|
params.push(filters.estado);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.startDate) {
|
||||||
|
conditions.push(`c.fecha >= $${paramIndex++}`);
|
||||||
|
params.push(filters.startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.endDate) {
|
||||||
|
conditions.push(`c.fecha <= $${paramIndex++}`);
|
||||||
|
params.push(filters.endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.rfcEmisor) {
|
||||||
|
conditions.push(`c.rfc_emisor ILIKE $${paramIndex++}`);
|
||||||
|
params.push(`%${filters.rfcEmisor}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.rfcReceptor) {
|
||||||
|
conditions.push(`c.rfc_receptor ILIKE $${paramIndex++}`);
|
||||||
|
params.push(`%${filters.rfcReceptor}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.minTotal !== undefined) {
|
||||||
|
conditions.push(`c.total >= $${paramIndex++}`);
|
||||||
|
params.push(filters.minTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.maxTotal !== undefined) {
|
||||||
|
conditions.push(`c.total <= $${paramIndex++}`);
|
||||||
|
params.push(filters.maxTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
conditions.push(`(
|
||||||
|
c.uuid ILIKE $${paramIndex} OR
|
||||||
|
c.nombre_emisor ILIKE $${paramIndex} OR
|
||||||
|
c.nombre_receptor ILIKE $${paramIndex} OR
|
||||||
|
c.folio ILIKE $${paramIndex}
|
||||||
|
)`);
|
||||||
|
params.push(`%${filters.search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
const offset = (filters.page - 1) * filters.limit;
|
||||||
|
|
||||||
|
const sortColumn = filters.sortBy === 'fecha' ? 'c.fecha' : filters.sortBy === 'total' ? 'c.total' : 'c.created_at';
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const countQuery = `SELECT COUNT(*) as total FROM cfdis c ${whereClause}`;
|
||||||
|
const countResult = await db.queryTenant<{ total: string }>(tenant, countQuery, params);
|
||||||
|
const total = parseInt(countResult.rows[0]?.total || '0', 10);
|
||||||
|
|
||||||
|
// Get CFDIs
|
||||||
|
const dataQuery = `
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.uuid,
|
||||||
|
c.version,
|
||||||
|
c.serie,
|
||||||
|
c.folio,
|
||||||
|
c.fecha,
|
||||||
|
c.tipo_comprobante,
|
||||||
|
c.forma_pago,
|
||||||
|
c.metodo_pago,
|
||||||
|
c.moneda,
|
||||||
|
c.tipo_cambio,
|
||||||
|
c.subtotal,
|
||||||
|
c.descuento,
|
||||||
|
c.total,
|
||||||
|
c.rfc_emisor,
|
||||||
|
c.nombre_emisor,
|
||||||
|
c.rfc_receptor,
|
||||||
|
c.nombre_receptor,
|
||||||
|
c.uso_cfdi,
|
||||||
|
c.estado,
|
||||||
|
c.fecha_cancelacion,
|
||||||
|
c.created_at,
|
||||||
|
c.updated_at
|
||||||
|
FROM cfdis c
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY ${sortColumn} ${filters.sortOrder.toUpperCase()}
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dataResult = await db.queryTenant(tenant, dataQuery, [...params, filters.limit, offset]);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: dataResult.rows,
|
||||||
|
meta: {
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
total,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cfdis/summary
|
||||||
|
* Get CFDI summary for a period
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/summary',
|
||||||
|
authenticate,
|
||||||
|
validate({ query: SummaryQuerySchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const query = req.query as z.infer<typeof SummaryQuerySchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Get summary by type
|
||||||
|
const summaryQuery = `
|
||||||
|
SELECT
|
||||||
|
tipo_comprobante,
|
||||||
|
estado,
|
||||||
|
COUNT(*) as count,
|
||||||
|
SUM(total) as total_amount,
|
||||||
|
SUM(subtotal) as subtotal_amount,
|
||||||
|
SUM(COALESCE(descuento, 0)) as discount_amount,
|
||||||
|
AVG(total) as avg_amount
|
||||||
|
FROM cfdis
|
||||||
|
WHERE fecha >= $1 AND fecha <= $2
|
||||||
|
GROUP BY tipo_comprobante, estado
|
||||||
|
ORDER BY tipo_comprobante, estado
|
||||||
|
`;
|
||||||
|
|
||||||
|
const summaryResult = await db.queryTenant(tenant, summaryQuery, [query.startDate, query.endDate]);
|
||||||
|
|
||||||
|
// Get totals by period
|
||||||
|
let dateFormat: string;
|
||||||
|
switch (query.groupBy) {
|
||||||
|
case 'day':
|
||||||
|
dateFormat = 'YYYY-MM-DD';
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
dateFormat = 'IYYY-IW';
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
default:
|
||||||
|
dateFormat = 'YYYY-MM';
|
||||||
|
}
|
||||||
|
|
||||||
|
const periodQuery = `
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(fecha, '${dateFormat}') as period,
|
||||||
|
tipo_comprobante,
|
||||||
|
COUNT(*) as count,
|
||||||
|
SUM(total) as total_amount
|
||||||
|
FROM cfdis
|
||||||
|
WHERE fecha >= $1 AND fecha <= $2 AND estado = 'vigente'
|
||||||
|
GROUP BY period, tipo_comprobante
|
||||||
|
ORDER BY period
|
||||||
|
`;
|
||||||
|
|
||||||
|
const periodResult = await db.queryTenant(tenant, periodQuery, [query.startDate, query.endDate]);
|
||||||
|
|
||||||
|
// Get overall totals
|
||||||
|
const totalsQuery = `
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_count,
|
||||||
|
COUNT(*) FILTER (WHERE tipo_comprobante = 'ingreso') as ingresos_count,
|
||||||
|
COUNT(*) FILTER (WHERE tipo_comprobante = 'egreso') as egresos_count,
|
||||||
|
COALESCE(SUM(total) FILTER (WHERE tipo_comprobante = 'ingreso' AND estado = 'vigente'), 0) as total_ingresos,
|
||||||
|
COALESCE(SUM(total) FILTER (WHERE tipo_comprobante = 'egreso' AND estado = 'vigente'), 0) as total_egresos,
|
||||||
|
COUNT(*) FILTER (WHERE estado = 'cancelado') as cancelados_count
|
||||||
|
FROM cfdis
|
||||||
|
WHERE fecha >= $1 AND fecha <= $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
const totalsResult = await db.queryTenant(tenant, totalsQuery, [query.startDate, query.endDate]);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
totals: totalsResult.rows[0],
|
||||||
|
byType: summaryResult.rows,
|
||||||
|
byPeriod: periodResult.rows,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
startDate: query.startDate,
|
||||||
|
endDate: query.endDate,
|
||||||
|
groupBy: query.groupBy,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cfdis/:id
|
||||||
|
* Get CFDI detail by ID
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: CfdiIdSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
c.*,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', cc.id,
|
||||||
|
'clave_prod_serv', cc.clave_prod_serv,
|
||||||
|
'descripcion', cc.descripcion,
|
||||||
|
'cantidad', cc.cantidad,
|
||||||
|
'unidad', cc.unidad,
|
||||||
|
'valor_unitario', cc.valor_unitario,
|
||||||
|
'importe', cc.importe,
|
||||||
|
'descuento', cc.descuento
|
||||||
|
)
|
||||||
|
) FILTER (WHERE cc.id IS NOT NULL) as conceptos,
|
||||||
|
(
|
||||||
|
SELECT json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', t.id,
|
||||||
|
'type', t.type,
|
||||||
|
'amount', t.amount,
|
||||||
|
'date', t.date,
|
||||||
|
'status', t.status
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM transactions t
|
||||||
|
WHERE t.cfdi_id = c.id
|
||||||
|
) as transactions
|
||||||
|
FROM cfdis c
|
||||||
|
LEFT JOIN cfdi_conceptos cc ON cc.cfdi_id = c.id
|
||||||
|
WHERE c.id = $1
|
||||||
|
GROUP BY c.id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, query, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new NotFoundError('CFDI');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cfdis/:id/xml
|
||||||
|
* Get original XML for a CFDI
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:id/xml',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: CfdiIdSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT uuid, xml_content, serie, folio
|
||||||
|
FROM cfdis
|
||||||
|
WHERE id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, query, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new NotFoundError('CFDI');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfdi = result.rows[0];
|
||||||
|
|
||||||
|
if (!cfdi.xml_content) {
|
||||||
|
throw new NotFoundError('XML del CFDI');
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = `${cfdi.serie || ''}${cfdi.folio || cfdi.uuid}.xml`;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/xml; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.send(cfdi.xml_content);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/cfdis/sync
|
||||||
|
* Trigger SAT synchronization
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/sync',
|
||||||
|
authenticate,
|
||||||
|
validate({ body: SyncBodySchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const data = req.body as z.infer<typeof SyncBodySchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Check for existing active sync job
|
||||||
|
const activeJobQuery = `
|
||||||
|
SELECT id, status, started_at
|
||||||
|
FROM sync_jobs
|
||||||
|
WHERE status IN ('pending', 'running')
|
||||||
|
AND job_type = 'sat_cfdis'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const activeJob = await db.queryTenant(tenant, activeJobQuery, []);
|
||||||
|
|
||||||
|
if (activeJob.rows.length > 0 && !data.force) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'SYNC_IN_PROGRESS',
|
||||||
|
message: 'Ya hay una sincronizacion en progreso',
|
||||||
|
details: {
|
||||||
|
jobId: activeJob.rows[0].id,
|
||||||
|
status: activeJob.rows[0].status,
|
||||||
|
startedAt: activeJob.rows[0].started_at,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.status(409).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new sync job
|
||||||
|
const createJobQuery = `
|
||||||
|
INSERT INTO sync_jobs (
|
||||||
|
job_type,
|
||||||
|
status,
|
||||||
|
parameters,
|
||||||
|
created_by
|
||||||
|
)
|
||||||
|
VALUES ('sat_cfdis', 'pending', $1, $2)
|
||||||
|
RETURNING id, job_type, status, created_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const parameters = {
|
||||||
|
startDate: data.startDate,
|
||||||
|
endDate: data.endDate,
|
||||||
|
tipoComprobante: data.tipoComprobante,
|
||||||
|
force: data.force,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, createJobQuery, [
|
||||||
|
JSON.stringify(parameters),
|
||||||
|
req.user!.sub,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// In a real implementation, you would trigger a background job here
|
||||||
|
// using a queue system like Bull or similar
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
job: result.rows[0],
|
||||||
|
message: 'Sincronizacion iniciada. Recibiras una notificacion cuando termine.',
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(202).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
587
apps/api/src/routes/contacts.routes.ts
Normal file
587
apps/api/src/routes/contacts.routes.ts
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
/**
|
||||||
|
* Contacts Routes
|
||||||
|
*
|
||||||
|
* CRUD operations for contacts (clients, providers, etc.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { validate } from '../middleware/validate.middleware';
|
||||||
|
import { getDatabase, TenantContext } from '@horux/database';
|
||||||
|
import {
|
||||||
|
ApiResponse,
|
||||||
|
AppError,
|
||||||
|
NotFoundError,
|
||||||
|
ValidationError,
|
||||||
|
ConflictError,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const ContactTypeEnum = z.enum(['cliente', 'proveedor', 'empleado', 'otro']);
|
||||||
|
|
||||||
|
const PaginationSchema = z.object({
|
||||||
|
page: z.string().optional().transform((v) => (v ? parseInt(v, 10) : 1)),
|
||||||
|
limit: z.string().optional().transform((v) => (v ? Math.min(parseInt(v, 10), 100) : 20)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ContactFiltersSchema = PaginationSchema.extend({
|
||||||
|
type: ContactTypeEnum.optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
isRecurring: z.string().optional().transform((v) => v === 'true' ? true : v === 'false' ? false : undefined),
|
||||||
|
hasDebt: z.string().optional().transform((v) => v === 'true'),
|
||||||
|
sortBy: z.enum(['name', 'created_at', 'total_transactions']).optional().default('name'),
|
||||||
|
sortOrder: z.enum(['asc', 'desc']).optional().default('asc'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ContactIdSchema = z.object({
|
||||||
|
id: z.string().uuid('ID de contacto invalido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RfcSchema = z.string().regex(
|
||||||
|
/^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/,
|
||||||
|
'RFC invalido. Formato esperado: XAXX010101XXX'
|
||||||
|
);
|
||||||
|
|
||||||
|
const CreateContactSchema = z.object({
|
||||||
|
name: z.string().min(2).max(200),
|
||||||
|
rfc: RfcSchema.optional(),
|
||||||
|
type: ContactTypeEnum,
|
||||||
|
email: z.string().email().optional().nullable(),
|
||||||
|
phone: z.string().max(20).optional().nullable(),
|
||||||
|
address: z.object({
|
||||||
|
street: z.string().optional(),
|
||||||
|
exterior: z.string().optional(),
|
||||||
|
interior: z.string().optional(),
|
||||||
|
neighborhood: z.string().optional(),
|
||||||
|
city: z.string().optional(),
|
||||||
|
state: z.string().optional(),
|
||||||
|
postalCode: z.string().optional(),
|
||||||
|
country: z.string().default('MX'),
|
||||||
|
}).optional(),
|
||||||
|
taxRegime: z.string().optional(),
|
||||||
|
usoCfdi: z.string().optional(),
|
||||||
|
notes: z.string().max(2000).optional(),
|
||||||
|
isRecurring: z.boolean().optional().default(false),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const UpdateContactSchema = CreateContactSchema.partial().extend({
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RecurringSchema = z.object({
|
||||||
|
isRecurring: z.boolean(),
|
||||||
|
recurringPattern: z.object({
|
||||||
|
frequency: z.enum(['weekly', 'biweekly', 'monthly', 'quarterly', 'yearly']),
|
||||||
|
dayOfMonth: z.number().min(1).max(31).optional(),
|
||||||
|
dayOfWeek: z.number().min(0).max(6).optional(),
|
||||||
|
expectedAmount: z.number().positive().optional(),
|
||||||
|
}).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const getTenantContext = (req: Request): TenantContext => {
|
||||||
|
if (!req.user || !req.tenantSchema) {
|
||||||
|
throw new AppError('Contexto de tenant no disponible', 'TENANT_CONTEXT_ERROR', 500);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: req.user.tenant_id,
|
||||||
|
schemaName: req.tenantSchema,
|
||||||
|
userId: req.user.sub,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Routes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/contacts
|
||||||
|
* List contacts with filters and pagination
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
authenticate,
|
||||||
|
validate({ query: ContactFiltersSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const filters = req.query as z.infer<typeof ContactFiltersSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Build filter conditions
|
||||||
|
const conditions: string[] = ['c.is_active = true'];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (filters.type) {
|
||||||
|
conditions.push(`c.type = $${paramIndex++}`);
|
||||||
|
params.push(filters.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
conditions.push(`(
|
||||||
|
c.name ILIKE $${paramIndex} OR
|
||||||
|
c.rfc ILIKE $${paramIndex} OR
|
||||||
|
c.email ILIKE $${paramIndex}
|
||||||
|
)`);
|
||||||
|
params.push(`%${filters.search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.isRecurring !== undefined) {
|
||||||
|
conditions.push(`c.is_recurring = $${paramIndex++}`);
|
||||||
|
params.push(filters.isRecurring);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||||
|
const offset = (filters.page - 1) * filters.limit;
|
||||||
|
|
||||||
|
let sortColumn: string;
|
||||||
|
switch (filters.sortBy) {
|
||||||
|
case 'total_transactions':
|
||||||
|
sortColumn = 'transaction_count';
|
||||||
|
break;
|
||||||
|
case 'created_at':
|
||||||
|
sortColumn = 'c.created_at';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
sortColumn = 'c.name';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const countQuery = `SELECT COUNT(*) as total FROM contacts c ${whereClause}`;
|
||||||
|
const countResult = await db.queryTenant<{ total: string }>(tenant, countQuery, params);
|
||||||
|
const total = parseInt(countResult.rows[0]?.total || '0', 10);
|
||||||
|
|
||||||
|
// Get contacts with transaction stats
|
||||||
|
const dataQuery = `
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.name,
|
||||||
|
c.rfc,
|
||||||
|
c.type,
|
||||||
|
c.email,
|
||||||
|
c.phone,
|
||||||
|
c.address,
|
||||||
|
c.tax_regime,
|
||||||
|
c.uso_cfdi,
|
||||||
|
c.is_recurring,
|
||||||
|
c.recurring_pattern,
|
||||||
|
c.tags,
|
||||||
|
c.created_at,
|
||||||
|
c.updated_at,
|
||||||
|
COALESCE(stats.transaction_count, 0) as transaction_count,
|
||||||
|
COALESCE(stats.total_income, 0) as total_income,
|
||||||
|
COALESCE(stats.total_expense, 0) as total_expense,
|
||||||
|
stats.last_transaction_date
|
||||||
|
FROM contacts c
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as transaction_count,
|
||||||
|
SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) as total_income,
|
||||||
|
SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) as total_expense,
|
||||||
|
MAX(date) as last_transaction_date
|
||||||
|
FROM transactions
|
||||||
|
WHERE contact_id = c.id
|
||||||
|
) stats ON true
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY ${sortColumn} ${filters.sortOrder.toUpperCase()}
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dataResult = await db.queryTenant(tenant, dataQuery, [...params, filters.limit, offset]);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: dataResult.rows,
|
||||||
|
meta: {
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
total,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/contacts/:id
|
||||||
|
* Get contact with statistics
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: ContactIdSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
c.*,
|
||||||
|
(
|
||||||
|
SELECT json_build_object(
|
||||||
|
'transaction_count', COUNT(*),
|
||||||
|
'total_income', COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0),
|
||||||
|
'total_expense', COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0),
|
||||||
|
'first_transaction', MIN(date),
|
||||||
|
'last_transaction', MAX(date),
|
||||||
|
'average_transaction', AVG(amount)
|
||||||
|
)
|
||||||
|
FROM transactions
|
||||||
|
WHERE contact_id = c.id
|
||||||
|
) as statistics,
|
||||||
|
(
|
||||||
|
SELECT json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', t.id,
|
||||||
|
'type', t.type,
|
||||||
|
'amount', t.amount,
|
||||||
|
'description', t.description,
|
||||||
|
'date', t.date,
|
||||||
|
'status', t.status
|
||||||
|
)
|
||||||
|
ORDER BY t.date DESC
|
||||||
|
)
|
||||||
|
FROM (
|
||||||
|
SELECT * FROM transactions
|
||||||
|
WHERE contact_id = c.id
|
||||||
|
ORDER BY date DESC
|
||||||
|
LIMIT 10
|
||||||
|
) t
|
||||||
|
) as recent_transactions,
|
||||||
|
(
|
||||||
|
SELECT json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', cf.id,
|
||||||
|
'uuid', cf.uuid,
|
||||||
|
'tipo_comprobante', cf.tipo_comprobante,
|
||||||
|
'total', cf.total,
|
||||||
|
'fecha', cf.fecha,
|
||||||
|
'estado', cf.estado
|
||||||
|
)
|
||||||
|
ORDER BY cf.fecha DESC
|
||||||
|
)
|
||||||
|
FROM (
|
||||||
|
SELECT * FROM cfdis
|
||||||
|
WHERE rfc_emisor = c.rfc OR rfc_receptor = c.rfc
|
||||||
|
ORDER BY fecha DESC
|
||||||
|
LIMIT 10
|
||||||
|
) cf
|
||||||
|
) as recent_cfdis
|
||||||
|
FROM contacts c
|
||||||
|
WHERE c.id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, query, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Contacto');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/contacts
|
||||||
|
* Create a new contact
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
authenticate,
|
||||||
|
validate({ body: CreateContactSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const data = req.body as z.infer<typeof CreateContactSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Check for duplicate RFC
|
||||||
|
if (data.rfc) {
|
||||||
|
const duplicateCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id FROM contacts WHERE rfc = $1 AND is_active = true',
|
||||||
|
[data.rfc]
|
||||||
|
);
|
||||||
|
if (duplicateCheck.rows.length > 0) {
|
||||||
|
throw new ConflictError('Ya existe un contacto con este RFC');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO contacts (
|
||||||
|
name, rfc, type, email, phone, address,
|
||||||
|
tax_regime, uso_cfdi, notes, is_recurring, tags, metadata,
|
||||||
|
created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, insertQuery, [
|
||||||
|
data.name,
|
||||||
|
data.rfc || null,
|
||||||
|
data.type,
|
||||||
|
data.email || null,
|
||||||
|
data.phone || null,
|
||||||
|
data.address ? JSON.stringify(data.address) : null,
|
||||||
|
data.taxRegime || null,
|
||||||
|
data.usoCfdi || null,
|
||||||
|
data.notes || null,
|
||||||
|
data.isRecurring || false,
|
||||||
|
data.tags || [],
|
||||||
|
data.metadata || {},
|
||||||
|
req.user!.sub,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/contacts/:id
|
||||||
|
* Update a contact
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: ContactIdSchema, body: UpdateContactSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const data = req.body as z.infer<typeof UpdateContactSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Check if contact exists
|
||||||
|
const existingCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id, rfc FROM contacts WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Contacto');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate RFC if changing
|
||||||
|
if (data.rfc && data.rfc !== existingCheck.rows[0].rfc) {
|
||||||
|
const duplicateCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id FROM contacts WHERE rfc = $1 AND id != $2 AND is_active = true',
|
||||||
|
[data.rfc, id]
|
||||||
|
);
|
||||||
|
if (duplicateCheck.rows.length > 0) {
|
||||||
|
throw new ConflictError('Ya existe un contacto con este RFC');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update query dynamically
|
||||||
|
const updates: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldMappings: Record<string, string> = {
|
||||||
|
name: 'name',
|
||||||
|
rfc: 'rfc',
|
||||||
|
type: 'type',
|
||||||
|
email: 'email',
|
||||||
|
phone: 'phone',
|
||||||
|
taxRegime: 'tax_regime',
|
||||||
|
usoCfdi: 'uso_cfdi',
|
||||||
|
notes: 'notes',
|
||||||
|
isRecurring: 'is_recurring',
|
||||||
|
tags: 'tags',
|
||||||
|
metadata: 'metadata',
|
||||||
|
isActive: 'is_active',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, column] of Object.entries(fieldMappings)) {
|
||||||
|
if (data[key as keyof typeof data] !== undefined) {
|
||||||
|
updates.push(`${column} = $${paramIndex++}`);
|
||||||
|
params.push(data[key as keyof typeof data]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.address !== undefined) {
|
||||||
|
updates.push(`address = $${paramIndex++}`);
|
||||||
|
params.push(data.address ? JSON.stringify(data.address) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
throw new ValidationError('No hay campos para actualizar');
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push(`updated_at = NOW()`);
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE contacts
|
||||||
|
SET ${updates.join(', ')}
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, updateQuery, params);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/contacts/:id
|
||||||
|
* Soft delete a contact
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: ContactIdSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Check if contact exists
|
||||||
|
const existingCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id FROM contacts WHERE id = $1 AND is_active = true',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Contacto');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'UPDATE contacts SET is_active = false, updated_at = NOW() WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: { deleted: true, id },
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/contacts/:id/recurring
|
||||||
|
* Mark contact as recurring
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
'/:id/recurring',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: ContactIdSchema, body: RecurringSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const data = req.body as z.infer<typeof RecurringSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Check if contact exists
|
||||||
|
const existingCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id FROM contacts WHERE id = $1 AND is_active = true',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Contacto');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE contacts
|
||||||
|
SET
|
||||||
|
is_recurring = $1,
|
||||||
|
recurring_pattern = $2,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $3
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, updateQuery, [
|
||||||
|
data.isRecurring,
|
||||||
|
data.recurringPattern ? JSON.stringify(data.recurringPattern) : null,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
284
apps/api/src/routes/health.routes.ts
Normal file
284
apps/api/src/routes/health.routes.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { config } from '../config/index.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Router Setup
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Health Check Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface HealthCheckResult {
|
||||||
|
status: 'healthy' | 'unhealthy' | 'degraded';
|
||||||
|
timestamp: string;
|
||||||
|
uptime: number;
|
||||||
|
version: string;
|
||||||
|
environment: string;
|
||||||
|
checks: {
|
||||||
|
database: ComponentHealth;
|
||||||
|
redis: ComponentHealth;
|
||||||
|
memory: ComponentHealth;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComponentHealth {
|
||||||
|
status: 'up' | 'down';
|
||||||
|
latency?: number;
|
||||||
|
message?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Health Check Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check database connectivity
|
||||||
|
*/
|
||||||
|
const checkDatabase = async (): Promise<ComponentHealth> => {
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: config.database.url,
|
||||||
|
max: 1,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await pool.connect();
|
||||||
|
await client.query('SELECT 1');
|
||||||
|
client.release();
|
||||||
|
await pool.end();
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'up',
|
||||||
|
latency: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await pool.end().catch(() => {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'down',
|
||||||
|
latency: Date.now() - startTime,
|
||||||
|
message: error instanceof Error ? error.message : 'Database connection failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Redis connectivity
|
||||||
|
*/
|
||||||
|
const checkRedis = async (): Promise<ComponentHealth> => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redis = new Redis(config.redis.url, {
|
||||||
|
connectTimeout: 5000,
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
lazyConnect: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await redis.connect();
|
||||||
|
await redis.ping();
|
||||||
|
await redis.quit();
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'up',
|
||||||
|
latency: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: 'down',
|
||||||
|
latency: Date.now() - startTime,
|
||||||
|
message: error instanceof Error ? error.message : 'Redis connection failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check memory usage
|
||||||
|
*/
|
||||||
|
const checkMemory = (): ComponentHealth => {
|
||||||
|
const used = process.memoryUsage();
|
||||||
|
const heapUsedMB = Math.round(used.heapUsed / 1024 / 1024);
|
||||||
|
const heapTotalMB = Math.round(used.heapTotal / 1024 / 1024);
|
||||||
|
const rssMB = Math.round(used.rss / 1024 / 1024);
|
||||||
|
|
||||||
|
// Consider unhealthy if heap usage > 90%
|
||||||
|
const heapUsagePercent = (used.heapUsed / used.heapTotal) * 100;
|
||||||
|
const isHealthy = heapUsagePercent < 90;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: isHealthy ? 'up' : 'down',
|
||||||
|
details: {
|
||||||
|
heapUsedMB,
|
||||||
|
heapTotalMB,
|
||||||
|
rssMB,
|
||||||
|
heapUsagePercent: Math.round(heapUsagePercent),
|
||||||
|
externalMB: Math.round(used.external / 1024 / 1024),
|
||||||
|
},
|
||||||
|
message: isHealthy ? undefined : 'High memory usage detected',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Routes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /health
|
||||||
|
* Basic health check - fast response for load balancers
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /health/live
|
||||||
|
* Liveness probe - is the application running?
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/live',
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
res.json({
|
||||||
|
status: 'alive',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime(),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /health/ready
|
||||||
|
* Readiness probe - is the application ready to receive traffic?
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/ready',
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
const [dbHealth, redisHealth] = await Promise.all([
|
||||||
|
checkDatabase(),
|
||||||
|
checkRedis(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isReady = dbHealth.status === 'up';
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
res.status(503).json({
|
||||||
|
status: 'not_ready',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
checks: {
|
||||||
|
database: dbHealth,
|
||||||
|
redis: redisHealth,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'ready',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
checks: {
|
||||||
|
database: dbHealth,
|
||||||
|
redis: redisHealth,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /health/detailed
|
||||||
|
* Detailed health check with all component statuses
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/detailed',
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const [dbHealth, redisHealth] = await Promise.all([
|
||||||
|
checkDatabase(),
|
||||||
|
checkRedis(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const memoryHealth = checkMemory();
|
||||||
|
|
||||||
|
// Determine overall status
|
||||||
|
let overallStatus: 'healthy' | 'unhealthy' | 'degraded' = 'healthy';
|
||||||
|
|
||||||
|
if (dbHealth.status === 'down') {
|
||||||
|
overallStatus = 'unhealthy';
|
||||||
|
} else if (redisHealth.status === 'down' || memoryHealth.status === 'down') {
|
||||||
|
overallStatus = 'degraded';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: HealthCheckResult = {
|
||||||
|
status: overallStatus,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime(),
|
||||||
|
version: process.env.npm_package_version || '0.1.0',
|
||||||
|
environment: config.env,
|
||||||
|
checks: {
|
||||||
|
database: dbHealth,
|
||||||
|
redis: redisHealth,
|
||||||
|
memory: memoryHealth,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusCode = overallStatus === 'unhealthy' ? 503 : 200;
|
||||||
|
|
||||||
|
logger.debug('Health check completed', {
|
||||||
|
status: overallStatus,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(statusCode).json(result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /health/metrics
|
||||||
|
* Basic metrics endpoint (can be extended for Prometheus)
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/metrics',
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
const memoryUsage = process.memoryUsage();
|
||||||
|
const cpuUsage = process.cpuUsage();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime(),
|
||||||
|
memory: {
|
||||||
|
rss: memoryUsage.rss,
|
||||||
|
heapTotal: memoryUsage.heapTotal,
|
||||||
|
heapUsed: memoryUsage.heapUsed,
|
||||||
|
external: memoryUsage.external,
|
||||||
|
arrayBuffers: memoryUsage.arrayBuffers,
|
||||||
|
},
|
||||||
|
cpu: {
|
||||||
|
user: cpuUsage.user,
|
||||||
|
system: cpuUsage.system,
|
||||||
|
},
|
||||||
|
process: {
|
||||||
|
pid: process.pid,
|
||||||
|
version: process.version,
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
9
apps/api/src/routes/index.ts
Normal file
9
apps/api/src/routes/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Routes exports
|
||||||
|
export { default as authRoutes } from './auth.routes.js';
|
||||||
|
export { default as healthRoutes } from './health.routes.js';
|
||||||
|
export { default as metricsRoutes } from './metrics.routes.js';
|
||||||
|
export { default as transactionsRoutes } from './transactions.routes.js';
|
||||||
|
export { default as contactsRoutes } from './contacts.routes.js';
|
||||||
|
export { default as cfdisRoutes } from './cfdis.routes.js';
|
||||||
|
export { default as categoriesRoutes } from './categories.routes.js';
|
||||||
|
export { default as alertsRoutes } from './alerts.routes.js';
|
||||||
819
apps/api/src/routes/integrations.routes.ts
Normal file
819
apps/api/src/routes/integrations.routes.ts
Normal file
@@ -0,0 +1,819 @@
|
|||||||
|
/**
|
||||||
|
* Integrations Routes
|
||||||
|
*
|
||||||
|
* Manages external integrations (SAT, banks, etc.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authenticate, requireAdmin } from '../middleware/auth.middleware';
|
||||||
|
import { validate } from '../middleware/validate.middleware';
|
||||||
|
import { getDatabase, TenantContext } from '@horux/database';
|
||||||
|
import {
|
||||||
|
ApiResponse,
|
||||||
|
AppError,
|
||||||
|
NotFoundError,
|
||||||
|
ValidationError,
|
||||||
|
ConflictError,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const IntegrationTypeEnum = z.enum([
|
||||||
|
'sat',
|
||||||
|
'bank_bbva',
|
||||||
|
'bank_banamex',
|
||||||
|
'bank_santander',
|
||||||
|
'bank_banorte',
|
||||||
|
'bank_hsbc',
|
||||||
|
'accounting_contpaqi',
|
||||||
|
'accounting_aspel',
|
||||||
|
'erp_sap',
|
||||||
|
'erp_odoo',
|
||||||
|
'payments_stripe',
|
||||||
|
'payments_openpay',
|
||||||
|
'webhook',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const IntegrationStatusEnum = z.enum(['active', 'inactive', 'error', 'pending', 'expired']);
|
||||||
|
|
||||||
|
const IntegrationFiltersSchema = z.object({
|
||||||
|
type: IntegrationTypeEnum.optional(),
|
||||||
|
status: IntegrationStatusEnum.optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const IntegrationIdSchema = z.object({
|
||||||
|
id: z.string().uuid('ID de integracion invalido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const IntegrationTypeParamSchema = z.object({
|
||||||
|
type: IntegrationTypeEnum,
|
||||||
|
});
|
||||||
|
|
||||||
|
// SAT-specific configuration
|
||||||
|
const SatConfigSchema = z.object({
|
||||||
|
rfc: z.string().regex(
|
||||||
|
/^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/,
|
||||||
|
'RFC invalido. Formato esperado: XAXX010101XXX'
|
||||||
|
),
|
||||||
|
certificateBase64: z.string().min(1, 'Certificado es requerido'),
|
||||||
|
privateKeyBase64: z.string().min(1, 'Llave privada es requerida'),
|
||||||
|
privateKeyPassword: z.string().min(1, 'Contrasena de llave privada es requerida'),
|
||||||
|
// Optional FIEL credentials
|
||||||
|
fielCertificateBase64: z.string().optional(),
|
||||||
|
fielPrivateKeyBase64: z.string().optional(),
|
||||||
|
fielPrivateKeyPassword: z.string().optional(),
|
||||||
|
// Sync options
|
||||||
|
syncIngresos: z.boolean().default(true),
|
||||||
|
syncEgresos: z.boolean().default(true),
|
||||||
|
syncNomina: z.boolean().default(false),
|
||||||
|
syncPagos: z.boolean().default(true),
|
||||||
|
autoSync: z.boolean().default(true),
|
||||||
|
syncFrequency: z.enum(['hourly', 'daily', 'weekly']).default('daily'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bank configuration
|
||||||
|
const BankConfigSchema = z.object({
|
||||||
|
accountNumber: z.string().optional(),
|
||||||
|
clabe: z.string().regex(/^\d{18}$/, 'CLABE debe tener 18 digitos').optional(),
|
||||||
|
accessToken: z.string().optional(),
|
||||||
|
refreshToken: z.string().optional(),
|
||||||
|
clientId: z.string().optional(),
|
||||||
|
clientSecret: z.string().optional(),
|
||||||
|
autoSync: z.boolean().default(true),
|
||||||
|
syncFrequency: z.enum(['hourly', 'daily', 'weekly']).default('daily'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Webhook configuration
|
||||||
|
const WebhookConfigSchema = z.object({
|
||||||
|
url: z.string().url('URL invalida'),
|
||||||
|
secret: z.string().min(16, 'Secret debe tener al menos 16 caracteres'),
|
||||||
|
events: z.array(z.string()).min(1, 'Debe seleccionar al menos un evento'),
|
||||||
|
isActive: z.boolean().default(true),
|
||||||
|
retryAttempts: z.number().int().min(0).max(10).default(3),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generic integration configuration
|
||||||
|
const GenericIntegrationConfigSchema = z.object({
|
||||||
|
name: z.string().max(100).optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
apiSecret: z.string().optional(),
|
||||||
|
endpoint: z.string().url().optional(),
|
||||||
|
settings: z.record(z.unknown()).optional(),
|
||||||
|
autoSync: z.boolean().default(true),
|
||||||
|
syncFrequency: z.enum(['hourly', 'daily', 'weekly']).default('daily'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const SyncBodySchema = z.object({
|
||||||
|
force: z.boolean().optional().default(false),
|
||||||
|
startDate: z.string().datetime().optional(),
|
||||||
|
endDate: z.string().datetime().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const getTenantContext = (req: Request): TenantContext => {
|
||||||
|
if (!req.user || !req.tenantSchema) {
|
||||||
|
throw new AppError('Contexto de tenant no disponible', 'TENANT_CONTEXT_ERROR', 500);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: req.user.tenant_id,
|
||||||
|
schemaName: req.tenantSchema,
|
||||||
|
userId: req.user.sub,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConfigSchema = (type: string) => {
|
||||||
|
if (type === 'sat') return SatConfigSchema;
|
||||||
|
if (type.startsWith('bank_')) return BankConfigSchema;
|
||||||
|
if (type === 'webhook') return WebhookConfigSchema;
|
||||||
|
return GenericIntegrationConfigSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Routes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/integrations
|
||||||
|
* List all configured integrations
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
authenticate,
|
||||||
|
validate({ query: IntegrationFiltersSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const filters = req.query as z.infer<typeof IntegrationFiltersSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Build filter conditions
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (filters.type) {
|
||||||
|
conditions.push(`i.type = $${paramIndex++}`);
|
||||||
|
params.push(filters.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.status) {
|
||||||
|
conditions.push(`i.status = $${paramIndex++}`);
|
||||||
|
params.push(filters.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
conditions.push(`(i.name ILIKE $${paramIndex} OR i.type ILIKE $${paramIndex})`);
|
||||||
|
params.push(`%${filters.search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
i.type,
|
||||||
|
i.name,
|
||||||
|
i.status,
|
||||||
|
i.is_active,
|
||||||
|
i.last_sync_at,
|
||||||
|
i.last_sync_status,
|
||||||
|
i.next_sync_at,
|
||||||
|
i.error_message,
|
||||||
|
i.created_at,
|
||||||
|
i.updated_at,
|
||||||
|
(
|
||||||
|
SELECT json_build_object(
|
||||||
|
'total_syncs', COUNT(*),
|
||||||
|
'successful_syncs', COUNT(*) FILTER (WHERE status = 'completed'),
|
||||||
|
'failed_syncs', COUNT(*) FILTER (WHERE status = 'failed'),
|
||||||
|
'last_duration_ms', (
|
||||||
|
SELECT EXTRACT(EPOCH FROM (completed_at - started_at)) * 1000
|
||||||
|
FROM sync_jobs
|
||||||
|
WHERE integration_id = i.id
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM sync_jobs
|
||||||
|
WHERE integration_id = i.id
|
||||||
|
) as sync_stats
|
||||||
|
FROM integrations i
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY i.created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, query, params);
|
||||||
|
|
||||||
|
// Get available integrations (not yet configured)
|
||||||
|
const availableQuery = `
|
||||||
|
SELECT DISTINCT type
|
||||||
|
FROM integrations
|
||||||
|
WHERE type IN ('sat', 'bank_bbva', 'bank_banamex', 'bank_santander', 'bank_banorte', 'bank_hsbc')
|
||||||
|
`;
|
||||||
|
const configuredTypes = await db.queryTenant<{ type: string }>(tenant, availableQuery, []);
|
||||||
|
const configuredTypeSet = new Set(configuredTypes.rows.map(r => r.type));
|
||||||
|
|
||||||
|
const allTypes = [
|
||||||
|
{ type: 'sat', name: 'SAT (Servicio de Administracion Tributaria)', category: 'fiscal' },
|
||||||
|
{ type: 'bank_bbva', name: 'BBVA Mexico', category: 'bank' },
|
||||||
|
{ type: 'bank_banamex', name: 'Banamex / Citibanamex', category: 'bank' },
|
||||||
|
{ type: 'bank_santander', name: 'Santander', category: 'bank' },
|
||||||
|
{ type: 'bank_banorte', name: 'Banorte', category: 'bank' },
|
||||||
|
{ type: 'bank_hsbc', name: 'HSBC', category: 'bank' },
|
||||||
|
{ type: 'accounting_contpaqi', name: 'CONTPAQi', category: 'accounting' },
|
||||||
|
{ type: 'accounting_aspel', name: 'Aspel', category: 'accounting' },
|
||||||
|
{ type: 'payments_stripe', name: 'Stripe', category: 'payments' },
|
||||||
|
{ type: 'payments_openpay', name: 'Openpay', category: 'payments' },
|
||||||
|
{ type: 'webhook', name: 'Webhook personalizado', category: 'custom' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const availableIntegrations = allTypes.filter(t => !configuredTypeSet.has(t.type) || t.type === 'webhook');
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
configured: result.rows,
|
||||||
|
available: availableIntegrations,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
total: result.rows.length,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/integrations/:id
|
||||||
|
* Get integration details
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: IntegrationIdSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
i.*,
|
||||||
|
(
|
||||||
|
SELECT json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', sj.id,
|
||||||
|
'status', sj.status,
|
||||||
|
'started_at', sj.started_at,
|
||||||
|
'completed_at', sj.completed_at,
|
||||||
|
'records_processed', sj.records_processed,
|
||||||
|
'records_created', sj.records_created,
|
||||||
|
'records_updated', sj.records_updated,
|
||||||
|
'error_message', sj.error_message
|
||||||
|
)
|
||||||
|
ORDER BY sj.created_at DESC
|
||||||
|
)
|
||||||
|
FROM (
|
||||||
|
SELECT * FROM sync_jobs
|
||||||
|
WHERE integration_id = i.id
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 10
|
||||||
|
) sj
|
||||||
|
) as recent_syncs
|
||||||
|
FROM integrations i
|
||||||
|
WHERE i.id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, query, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Integracion');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mask sensitive data in config
|
||||||
|
const integration = result.rows[0];
|
||||||
|
if (integration.config) {
|
||||||
|
const sensitiveFields = ['privateKeyBase64', 'privateKeyPassword', 'fielPrivateKeyBase64',
|
||||||
|
'fielPrivateKeyPassword', 'clientSecret', 'apiSecret', 'secret', 'accessToken', 'refreshToken'];
|
||||||
|
for (const field of sensitiveFields) {
|
||||||
|
if (integration.config[field]) {
|
||||||
|
integration.config[field] = '********';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: integration,
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/integrations/:id/status
|
||||||
|
* Get integration status
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:id/status',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: IntegrationIdSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
i.type,
|
||||||
|
i.name,
|
||||||
|
i.status,
|
||||||
|
i.is_active,
|
||||||
|
i.last_sync_at,
|
||||||
|
i.last_sync_status,
|
||||||
|
i.next_sync_at,
|
||||||
|
i.error_message,
|
||||||
|
i.health_check_at,
|
||||||
|
(
|
||||||
|
SELECT json_build_object(
|
||||||
|
'id', sj.id,
|
||||||
|
'status', sj.status,
|
||||||
|
'progress', sj.progress,
|
||||||
|
'started_at', sj.started_at,
|
||||||
|
'records_processed', sj.records_processed
|
||||||
|
)
|
||||||
|
FROM sync_jobs sj
|
||||||
|
WHERE sj.integration_id = i.id AND sj.status IN ('pending', 'running')
|
||||||
|
ORDER BY sj.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) as current_job
|
||||||
|
FROM integrations i
|
||||||
|
WHERE i.id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, query, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Integracion');
|
||||||
|
}
|
||||||
|
|
||||||
|
const integration = result.rows[0];
|
||||||
|
|
||||||
|
// Determine health status
|
||||||
|
let health: 'healthy' | 'degraded' | 'unhealthy' | 'unknown' = 'unknown';
|
||||||
|
if (integration.status === 'active' && integration.last_sync_status === 'completed') {
|
||||||
|
health = 'healthy';
|
||||||
|
} else if (integration.status === 'error' || integration.last_sync_status === 'failed') {
|
||||||
|
health = 'unhealthy';
|
||||||
|
} else if (integration.status === 'active') {
|
||||||
|
health = 'degraded';
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...integration,
|
||||||
|
health,
|
||||||
|
isConfigured: true,
|
||||||
|
hasPendingSync: !!integration.current_job,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/integrations/sat
|
||||||
|
* Configure SAT integration
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/sat',
|
||||||
|
authenticate,
|
||||||
|
requireAdmin,
|
||||||
|
validate({ body: SatConfigSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const config = req.body as z.infer<typeof SatConfigSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Check if SAT integration already exists
|
||||||
|
const existingCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
"SELECT id FROM integrations WHERE type = 'sat'",
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck.rows.length > 0) {
|
||||||
|
throw new ConflictError('Ya existe una integracion con SAT configurada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Validate certificate and key with SAT
|
||||||
|
// This would involve parsing the certificate and verifying it's valid
|
||||||
|
|
||||||
|
// Store integration (encrypt sensitive data in production)
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO integrations (
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
is_active,
|
||||||
|
config,
|
||||||
|
created_by
|
||||||
|
)
|
||||||
|
VALUES ('sat', 'SAT - ${config.rfc}', 'pending', true, $1, $2)
|
||||||
|
RETURNING id, type, name, status, is_active, created_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, insertQuery, [
|
||||||
|
JSON.stringify({
|
||||||
|
rfc: config.rfc,
|
||||||
|
certificateBase64: config.certificateBase64,
|
||||||
|
privateKeyBase64: config.privateKeyBase64,
|
||||||
|
privateKeyPassword: config.privateKeyPassword,
|
||||||
|
fielCertificateBase64: config.fielCertificateBase64,
|
||||||
|
fielPrivateKeyBase64: config.fielPrivateKeyBase64,
|
||||||
|
fielPrivateKeyPassword: config.fielPrivateKeyPassword,
|
||||||
|
syncIngresos: config.syncIngresos,
|
||||||
|
syncEgresos: config.syncEgresos,
|
||||||
|
syncNomina: config.syncNomina,
|
||||||
|
syncPagos: config.syncPagos,
|
||||||
|
autoSync: config.autoSync,
|
||||||
|
syncFrequency: config.syncFrequency,
|
||||||
|
}),
|
||||||
|
req.user!.sub,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create initial sync job
|
||||||
|
await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
`INSERT INTO sync_jobs (integration_id, job_type, status, created_by)
|
||||||
|
VALUES ($1, 'sat_initial', 'pending', $2)`,
|
||||||
|
[result.rows[0].id, req.user!.sub]
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
integration: result.rows[0],
|
||||||
|
message: 'Integracion SAT configurada. Iniciando sincronizacion inicial...',
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/integrations/:type
|
||||||
|
* Configure other integration types
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/:type',
|
||||||
|
authenticate,
|
||||||
|
requireAdmin,
|
||||||
|
validate({ params: IntegrationTypeParamSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { type } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Validate config based on type
|
||||||
|
const configSchema = getConfigSchema(type);
|
||||||
|
const parseResult = configSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Configuracion invalida', parseResult.error.flatten().fieldErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = parseResult.data;
|
||||||
|
|
||||||
|
// Check for existing integration (except webhooks which can have multiple)
|
||||||
|
if (type !== 'webhook') {
|
||||||
|
const existingCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id FROM integrations WHERE type = $1',
|
||||||
|
[type]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck.rows.length > 0) {
|
||||||
|
throw new ConflictError(`Ya existe una integracion de tipo ${type} configurada`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate name based on type
|
||||||
|
let name = type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||||
|
if ('name' in config && config.name) {
|
||||||
|
name = config.name as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO integrations (
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
is_active,
|
||||||
|
config,
|
||||||
|
created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, 'pending', true, $3, $4)
|
||||||
|
RETURNING id, type, name, status, is_active, created_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, insertQuery, [
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
JSON.stringify(config),
|
||||||
|
req.user!.sub,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/integrations/:id
|
||||||
|
* Update integration configuration
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
requireAdmin,
|
||||||
|
validate({ params: IntegrationIdSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Get existing integration
|
||||||
|
const existingCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id, type, config FROM integrations WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Integracion');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = existingCheck.rows[0];
|
||||||
|
|
||||||
|
// Validate config based on type
|
||||||
|
const configSchema = getConfigSchema(existing.type);
|
||||||
|
const parseResult = configSchema.partial().safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Configuracion invalida', parseResult.error.flatten().fieldErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with existing config
|
||||||
|
const newConfig = { ...existing.config, ...parseResult.data };
|
||||||
|
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE integrations
|
||||||
|
SET config = $1, updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING id, type, name, status, is_active, updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, updateQuery, [
|
||||||
|
JSON.stringify(newConfig),
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/integrations/:id/sync
|
||||||
|
* Trigger sync for an integration
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/:id/sync',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: IntegrationIdSchema, body: SyncBodySchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const syncOptions = req.body as z.infer<typeof SyncBodySchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Check if integration exists and is active
|
||||||
|
const integrationCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id, type, status, is_active FROM integrations WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (integrationCheck.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Integracion');
|
||||||
|
}
|
||||||
|
|
||||||
|
const integration = integrationCheck.rows[0];
|
||||||
|
|
||||||
|
if (!integration.is_active) {
|
||||||
|
throw new ValidationError('La integracion esta desactivada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing pending/running job
|
||||||
|
const activeJobCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
`SELECT id, status, started_at
|
||||||
|
FROM sync_jobs
|
||||||
|
WHERE integration_id = $1 AND status IN ('pending', 'running')
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activeJobCheck.rows.length > 0 && !syncOptions.force) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'SYNC_IN_PROGRESS',
|
||||||
|
message: 'Ya hay una sincronizacion en progreso',
|
||||||
|
details: {
|
||||||
|
jobId: activeJobCheck.rows[0].id,
|
||||||
|
status: activeJobCheck.rows[0].status,
|
||||||
|
startedAt: activeJobCheck.rows[0].started_at,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.status(409).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sync job
|
||||||
|
const createJobQuery = `
|
||||||
|
INSERT INTO sync_jobs (
|
||||||
|
integration_id,
|
||||||
|
job_type,
|
||||||
|
status,
|
||||||
|
parameters,
|
||||||
|
created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, 'pending', $3, $4)
|
||||||
|
RETURNING id, job_type, status, created_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const jobType = `${integration.type}_sync`;
|
||||||
|
const parameters = {
|
||||||
|
startDate: syncOptions.startDate,
|
||||||
|
endDate: syncOptions.endDate,
|
||||||
|
force: syncOptions.force,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, createJobQuery, [
|
||||||
|
id,
|
||||||
|
jobType,
|
||||||
|
JSON.stringify(parameters),
|
||||||
|
req.user!.sub,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// In production, trigger background job here
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
job: result.rows[0],
|
||||||
|
message: 'Sincronizacion iniciada',
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(202).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/integrations/:id
|
||||||
|
* Remove an integration
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
requireAdmin,
|
||||||
|
validate({ params: IntegrationIdSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Check if integration exists
|
||||||
|
const existingCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id, type FROM integrations WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Integracion');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel any pending jobs
|
||||||
|
await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
`UPDATE sync_jobs
|
||||||
|
SET status = 'cancelled', updated_at = NOW()
|
||||||
|
WHERE integration_id = $1 AND status IN ('pending', 'running')`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Soft delete the integration
|
||||||
|
await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
`UPDATE integrations
|
||||||
|
SET is_active = false, status = 'inactive', updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: { deleted: true, id },
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
720
apps/api/src/routes/metrics.routes.ts
Normal file
720
apps/api/src/routes/metrics.routes.ts
Normal file
@@ -0,0 +1,720 @@
|
|||||||
|
/**
|
||||||
|
* Metrics Routes
|
||||||
|
*
|
||||||
|
* Business metrics and KPIs for dashboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { validate } from '../middleware/validate.middleware';
|
||||||
|
import { getDatabase, TenantContext } from '@horux/database';
|
||||||
|
import {
|
||||||
|
ApiResponse,
|
||||||
|
AppError,
|
||||||
|
NotFoundError,
|
||||||
|
ValidationError,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const MetricCodeEnum = z.enum([
|
||||||
|
'total_income',
|
||||||
|
'total_expense',
|
||||||
|
'net_profit',
|
||||||
|
'profit_margin',
|
||||||
|
'cash_flow',
|
||||||
|
'accounts_receivable',
|
||||||
|
'accounts_payable',
|
||||||
|
'runway',
|
||||||
|
'burn_rate',
|
||||||
|
'recurring_revenue',
|
||||||
|
'customer_count',
|
||||||
|
'average_transaction',
|
||||||
|
'tax_liability',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PeriodSchema = z.object({
|
||||||
|
startDate: z.string().datetime(),
|
||||||
|
endDate: z.string().datetime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const DashboardQuerySchema = z.object({
|
||||||
|
startDate: z.string().datetime().optional(),
|
||||||
|
endDate: z.string().datetime().optional(),
|
||||||
|
period: z.enum(['today', 'week', 'month', 'quarter', 'year', 'custom']).optional().default('month'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const MetricCodeParamSchema = z.object({
|
||||||
|
code: MetricCodeEnum,
|
||||||
|
});
|
||||||
|
|
||||||
|
const HistoryQuerySchema = z.object({
|
||||||
|
startDate: z.string().datetime(),
|
||||||
|
endDate: z.string().datetime(),
|
||||||
|
granularity: z.enum(['day', 'week', 'month']).optional().default('day'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CompareQuerySchema = z.object({
|
||||||
|
period1Start: z.string().datetime(),
|
||||||
|
period1End: z.string().datetime(),
|
||||||
|
period2Start: z.string().datetime(),
|
||||||
|
period2End: z.string().datetime(),
|
||||||
|
metrics: z.string().optional().transform((v) => v ? v.split(',') : undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const getTenantContext = (req: Request): TenantContext => {
|
||||||
|
if (!req.user || !req.tenantSchema) {
|
||||||
|
throw new AppError('Contexto de tenant no disponible', 'TENANT_CONTEXT_ERROR', 500);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: req.user.tenant_id,
|
||||||
|
schemaName: req.tenantSchema,
|
||||||
|
userId: req.user.sub,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPeriodDates = (period: string): { startDate: Date; endDate: Date } => {
|
||||||
|
const now = new Date();
|
||||||
|
const endDate = new Date(now);
|
||||||
|
let startDate: Date;
|
||||||
|
|
||||||
|
switch (period) {
|
||||||
|
case 'today':
|
||||||
|
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
startDate = new Date(now);
|
||||||
|
startDate.setDate(now.getDate() - 7);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
break;
|
||||||
|
case 'quarter':
|
||||||
|
const quarter = Math.floor(now.getMonth() / 3);
|
||||||
|
startDate = new Date(now.getFullYear(), quarter * 3, 1);
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
startDate = new Date(now.getFullYear(), 0, 1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { startDate, endDate };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metric Calculation Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface MetricResult {
|
||||||
|
code: string;
|
||||||
|
value: number;
|
||||||
|
previousValue?: number;
|
||||||
|
change?: number;
|
||||||
|
changePercent?: number;
|
||||||
|
trend?: 'up' | 'down' | 'stable';
|
||||||
|
currency?: string;
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateMetric = async (
|
||||||
|
db: ReturnType<typeof getDatabase>,
|
||||||
|
tenant: TenantContext,
|
||||||
|
code: string,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string
|
||||||
|
): Promise<MetricResult> => {
|
||||||
|
let query: string;
|
||||||
|
let result: MetricResult = { code, value: 0 };
|
||||||
|
|
||||||
|
switch (code) {
|
||||||
|
case 'total_income':
|
||||||
|
query = `
|
||||||
|
SELECT COALESCE(SUM(amount), 0) as value
|
||||||
|
FROM transactions
|
||||||
|
WHERE type = 'income' AND date >= $1 AND date <= $2 AND status != 'cancelled'
|
||||||
|
`;
|
||||||
|
const incomeResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
|
||||||
|
result = {
|
||||||
|
code,
|
||||||
|
value: parseFloat(incomeResult.rows[0]?.value || '0'),
|
||||||
|
currency: 'MXN',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'total_expense':
|
||||||
|
query = `
|
||||||
|
SELECT COALESCE(SUM(amount), 0) as value
|
||||||
|
FROM transactions
|
||||||
|
WHERE type = 'expense' AND date >= $1 AND date <= $2 AND status != 'cancelled'
|
||||||
|
`;
|
||||||
|
const expenseResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
|
||||||
|
result = {
|
||||||
|
code,
|
||||||
|
value: parseFloat(expenseResult.rows[0]?.value || '0'),
|
||||||
|
currency: 'MXN',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'net_profit':
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) -
|
||||||
|
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as value
|
||||||
|
FROM transactions
|
||||||
|
WHERE date >= $1 AND date <= $2 AND status != 'cancelled'
|
||||||
|
`;
|
||||||
|
const profitResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
|
||||||
|
result = {
|
||||||
|
code,
|
||||||
|
value: parseFloat(profitResult.rows[0]?.value || '0'),
|
||||||
|
currency: 'MXN',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'profit_margin':
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) > 0
|
||||||
|
THEN (
|
||||||
|
(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) -
|
||||||
|
SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END)) /
|
||||||
|
SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END)
|
||||||
|
) * 100
|
||||||
|
ELSE 0
|
||||||
|
END as value
|
||||||
|
FROM transactions
|
||||||
|
WHERE date >= $1 AND date <= $2 AND status != 'cancelled'
|
||||||
|
`;
|
||||||
|
const marginResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
|
||||||
|
result = {
|
||||||
|
code,
|
||||||
|
value: parseFloat(marginResult.rows[0]?.value || '0'),
|
||||||
|
unit: '%',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cash_flow':
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN type = 'income' THEN amount ELSE -amount END) as value
|
||||||
|
FROM transactions
|
||||||
|
WHERE date >= $1 AND date <= $2 AND status = 'completed'
|
||||||
|
`;
|
||||||
|
const cashFlowResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
|
||||||
|
result = {
|
||||||
|
code,
|
||||||
|
value: parseFloat(cashFlowResult.rows[0]?.value || '0'),
|
||||||
|
currency: 'MXN',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'accounts_receivable':
|
||||||
|
query = `
|
||||||
|
SELECT COALESCE(SUM(amount), 0) as value
|
||||||
|
FROM transactions
|
||||||
|
WHERE type = 'income' AND status = 'pending'
|
||||||
|
`;
|
||||||
|
const arResult = await db.queryTenant<{ value: string }>(tenant, query, []);
|
||||||
|
result = {
|
||||||
|
code,
|
||||||
|
value: parseFloat(arResult.rows[0]?.value || '0'),
|
||||||
|
currency: 'MXN',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'accounts_payable':
|
||||||
|
query = `
|
||||||
|
SELECT COALESCE(SUM(amount), 0) as value
|
||||||
|
FROM transactions
|
||||||
|
WHERE type = 'expense' AND status = 'pending'
|
||||||
|
`;
|
||||||
|
const apResult = await db.queryTenant<{ value: string }>(tenant, query, []);
|
||||||
|
result = {
|
||||||
|
code,
|
||||||
|
value: parseFloat(apResult.rows[0]?.value || '0'),
|
||||||
|
currency: 'MXN',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'recurring_revenue':
|
||||||
|
query = `
|
||||||
|
SELECT COALESCE(SUM(t.amount), 0) as value
|
||||||
|
FROM transactions t
|
||||||
|
JOIN contacts c ON t.contact_id = c.id
|
||||||
|
WHERE t.type = 'income'
|
||||||
|
AND c.is_recurring = true
|
||||||
|
AND t.date >= $1 AND t.date <= $2
|
||||||
|
AND t.status != 'cancelled'
|
||||||
|
`;
|
||||||
|
const rrResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
|
||||||
|
result = {
|
||||||
|
code,
|
||||||
|
value: parseFloat(rrResult.rows[0]?.value || '0'),
|
||||||
|
currency: 'MXN',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'customer_count':
|
||||||
|
query = `
|
||||||
|
SELECT COUNT(DISTINCT contact_id) as value
|
||||||
|
FROM transactions
|
||||||
|
WHERE type = 'income' AND date >= $1 AND date <= $2
|
||||||
|
`;
|
||||||
|
const customerResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
|
||||||
|
result = {
|
||||||
|
code,
|
||||||
|
value: parseInt(customerResult.rows[0]?.value || '0', 10),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'average_transaction':
|
||||||
|
query = `
|
||||||
|
SELECT COALESCE(AVG(amount), 0) as value
|
||||||
|
FROM transactions
|
||||||
|
WHERE date >= $1 AND date <= $2 AND status != 'cancelled'
|
||||||
|
`;
|
||||||
|
const avgResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
|
||||||
|
result = {
|
||||||
|
code,
|
||||||
|
value: parseFloat(avgResult.rows[0]?.value || '0'),
|
||||||
|
currency: 'MXN',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'burn_rate':
|
||||||
|
// Monthly average expense
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(amount) / GREATEST(1, EXTRACT(MONTH FROM AGE($2::timestamp, $1::timestamp)) + 1), 0) as value
|
||||||
|
FROM transactions
|
||||||
|
WHERE type = 'expense' AND date >= $1 AND date <= $2 AND status != 'cancelled'
|
||||||
|
`;
|
||||||
|
const burnResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
|
||||||
|
result = {
|
||||||
|
code,
|
||||||
|
value: parseFloat(burnResult.rows[0]?.value || '0'),
|
||||||
|
currency: 'MXN',
|
||||||
|
unit: '/mes',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tax_liability':
|
||||||
|
// Estimated IVA liability (16% of income - 16% of deductible expenses)
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
COALESCE(
|
||||||
|
(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) * 0.16) -
|
||||||
|
(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) * 0.16),
|
||||||
|
0
|
||||||
|
) as value
|
||||||
|
FROM transactions
|
||||||
|
WHERE date >= $1 AND date <= $2 AND status != 'cancelled'
|
||||||
|
`;
|
||||||
|
const taxResult = await db.queryTenant<{ value: string }>(tenant, query, [startDate, endDate]);
|
||||||
|
result = {
|
||||||
|
code,
|
||||||
|
value: Math.max(0, parseFloat(taxResult.rows[0]?.value || '0')),
|
||||||
|
currency: 'MXN',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new NotFoundError(`Metrica ${code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Routes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/metrics/dashboard
|
||||||
|
* Get all dashboard metrics
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/dashboard',
|
||||||
|
authenticate,
|
||||||
|
validate({ query: DashboardQuerySchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const query = req.query as z.infer<typeof DashboardQuerySchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
let startDate: string;
|
||||||
|
let endDate: string;
|
||||||
|
|
||||||
|
if (query.period === 'custom' && query.startDate && query.endDate) {
|
||||||
|
startDate = query.startDate;
|
||||||
|
endDate = query.endDate;
|
||||||
|
} else {
|
||||||
|
const dates = getPeriodDates(query.period);
|
||||||
|
startDate = dates.startDate.toISOString();
|
||||||
|
endDate = dates.endDate.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate main metrics
|
||||||
|
const metricsToCalculate = [
|
||||||
|
'total_income',
|
||||||
|
'total_expense',
|
||||||
|
'net_profit',
|
||||||
|
'profit_margin',
|
||||||
|
'cash_flow',
|
||||||
|
'accounts_receivable',
|
||||||
|
'accounts_payable',
|
||||||
|
'customer_count',
|
||||||
|
'average_transaction',
|
||||||
|
];
|
||||||
|
|
||||||
|
const metrics: MetricResult[] = await Promise.all(
|
||||||
|
metricsToCalculate.map((code) => calculateMetric(db, tenant, code, startDate, endDate))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get recent transactions summary
|
||||||
|
const recentTransactionsQuery = `
|
||||||
|
SELECT
|
||||||
|
type,
|
||||||
|
COUNT(*) as count,
|
||||||
|
SUM(amount) as total
|
||||||
|
FROM transactions
|
||||||
|
WHERE date >= $1 AND date <= $2
|
||||||
|
GROUP BY type
|
||||||
|
`;
|
||||||
|
const recentTransactions = await db.queryTenant(tenant, recentTransactionsQuery, [startDate, endDate]);
|
||||||
|
|
||||||
|
// Get top categories
|
||||||
|
const topCategoriesQuery = `
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.name,
|
||||||
|
c.color,
|
||||||
|
c.icon,
|
||||||
|
COUNT(*) as transaction_count,
|
||||||
|
SUM(t.amount) as total_amount
|
||||||
|
FROM transactions t
|
||||||
|
JOIN categories c ON t.category_id = c.id
|
||||||
|
WHERE t.date >= $1 AND t.date <= $2
|
||||||
|
GROUP BY c.id, c.name, c.color, c.icon
|
||||||
|
ORDER BY total_amount DESC
|
||||||
|
LIMIT 5
|
||||||
|
`;
|
||||||
|
const topCategories = await db.queryTenant(tenant, topCategoriesQuery, [startDate, endDate]);
|
||||||
|
|
||||||
|
// Get daily trend
|
||||||
|
const dailyTrendQuery = `
|
||||||
|
SELECT
|
||||||
|
DATE(date) as day,
|
||||||
|
SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) as income,
|
||||||
|
SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) as expense
|
||||||
|
FROM transactions
|
||||||
|
WHERE date >= $1 AND date <= $2
|
||||||
|
GROUP BY DATE(date)
|
||||||
|
ORDER BY day
|
||||||
|
`;
|
||||||
|
const dailyTrend = await db.queryTenant(tenant, dailyTrendQuery, [startDate, endDate]);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
period: {
|
||||||
|
start: startDate,
|
||||||
|
end: endDate,
|
||||||
|
type: query.period,
|
||||||
|
},
|
||||||
|
metrics: metrics.reduce((acc, m) => {
|
||||||
|
acc[m.code] = m;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, MetricResult>),
|
||||||
|
summary: {
|
||||||
|
transactions: recentTransactions.rows,
|
||||||
|
topCategories: topCategories.rows,
|
||||||
|
},
|
||||||
|
trends: {
|
||||||
|
daily: dailyTrend.rows,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/metrics/compare
|
||||||
|
* Compare metrics between two periods
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/compare',
|
||||||
|
authenticate,
|
||||||
|
validate({ query: CompareQuerySchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const query = req.query as z.infer<typeof CompareQuerySchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const metricsToCompare = query.metrics || [
|
||||||
|
'total_income',
|
||||||
|
'total_expense',
|
||||||
|
'net_profit',
|
||||||
|
'profit_margin',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Calculate metrics for period 1
|
||||||
|
const period1Metrics = await Promise.all(
|
||||||
|
metricsToCompare.map((code) =>
|
||||||
|
calculateMetric(db, tenant, code, query.period1Start, query.period1End)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate metrics for period 2
|
||||||
|
const period2Metrics = await Promise.all(
|
||||||
|
metricsToCompare.map((code) =>
|
||||||
|
calculateMetric(db, tenant, code, query.period2Start, query.period2End)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate changes
|
||||||
|
const comparison = metricsToCompare.map((code, index) => {
|
||||||
|
const p1 = period1Metrics[index];
|
||||||
|
const p2 = period2Metrics[index];
|
||||||
|
const change = p1.value - p2.value;
|
||||||
|
const changePercent = p2.value !== 0 ? ((change / p2.value) * 100) : (p1.value > 0 ? 100 : 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
period1: p1,
|
||||||
|
period2: p2,
|
||||||
|
change,
|
||||||
|
changePercent: Math.round(changePercent * 100) / 100,
|
||||||
|
trend: change > 0 ? 'up' : change < 0 ? 'down' : 'stable',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
period1: {
|
||||||
|
start: query.period1Start,
|
||||||
|
end: query.period1End,
|
||||||
|
},
|
||||||
|
period2: {
|
||||||
|
start: query.period2Start,
|
||||||
|
end: query.period2End,
|
||||||
|
},
|
||||||
|
comparison,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/metrics/:code
|
||||||
|
* Get a specific metric
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:code',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: MetricCodeParamSchema, query: PeriodSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { code } = req.params as z.infer<typeof MetricCodeParamSchema>;
|
||||||
|
const { startDate, endDate } = req.query as z.infer<typeof PeriodSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const metric = await calculateMetric(db, tenant, code, startDate, endDate);
|
||||||
|
|
||||||
|
// Calculate previous period for comparison
|
||||||
|
const periodLength = new Date(endDate).getTime() - new Date(startDate).getTime();
|
||||||
|
const previousStart = new Date(new Date(startDate).getTime() - periodLength).toISOString();
|
||||||
|
const previousEnd = startDate;
|
||||||
|
|
||||||
|
const previousMetric = await calculateMetric(db, tenant, code, previousStart, previousEnd);
|
||||||
|
|
||||||
|
const change = metric.value - previousMetric.value;
|
||||||
|
const changePercent = previousMetric.value !== 0
|
||||||
|
? ((change / previousMetric.value) * 100)
|
||||||
|
: (metric.value > 0 ? 100 : 0);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...metric,
|
||||||
|
previousValue: previousMetric.value,
|
||||||
|
change,
|
||||||
|
changePercent: Math.round(changePercent * 100) / 100,
|
||||||
|
trend: change > 0 ? 'up' : change < 0 ? 'down' : 'stable',
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
period: { startDate, endDate },
|
||||||
|
previousPeriod: { startDate: previousStart, endDate: previousEnd },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/metrics/:code/history
|
||||||
|
* Get metric history over time
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:code/history',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: MetricCodeParamSchema, query: HistoryQuerySchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { code } = req.params as z.infer<typeof MetricCodeParamSchema>;
|
||||||
|
const { startDate, endDate, granularity } = req.query as z.infer<typeof HistoryQuerySchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Build date format based on granularity
|
||||||
|
let dateFormat: string;
|
||||||
|
let dateGroup: string;
|
||||||
|
switch (granularity) {
|
||||||
|
case 'day':
|
||||||
|
dateFormat = 'YYYY-MM-DD';
|
||||||
|
dateGroup = 'DATE(date)';
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
dateFormat = 'IYYY-IW';
|
||||||
|
dateGroup = "DATE_TRUNC('week', date)";
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
dateFormat = 'YYYY-MM';
|
||||||
|
dateGroup = "DATE_TRUNC('month', date)";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let query: string;
|
||||||
|
let historyData: { period: string; value: number }[] = [];
|
||||||
|
|
||||||
|
// Different queries based on metric type
|
||||||
|
switch (code) {
|
||||||
|
case 'total_income':
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(${dateGroup}, '${dateFormat}') as period,
|
||||||
|
COALESCE(SUM(amount), 0) as value
|
||||||
|
FROM transactions
|
||||||
|
WHERE type = 'income' AND date >= $1 AND date <= $2 AND status != 'cancelled'
|
||||||
|
GROUP BY ${dateGroup}
|
||||||
|
ORDER BY period
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'total_expense':
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(${dateGroup}, '${dateFormat}') as period,
|
||||||
|
COALESCE(SUM(amount), 0) as value
|
||||||
|
FROM transactions
|
||||||
|
WHERE type = 'expense' AND date >= $1 AND date <= $2 AND status != 'cancelled'
|
||||||
|
GROUP BY ${dateGroup}
|
||||||
|
ORDER BY period
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'net_profit':
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(${dateGroup}, '${dateFormat}') as period,
|
||||||
|
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE -amount END), 0) as value
|
||||||
|
FROM transactions
|
||||||
|
WHERE date >= $1 AND date <= $2 AND status != 'cancelled'
|
||||||
|
GROUP BY ${dateGroup}
|
||||||
|
ORDER BY period
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'customer_count':
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(${dateGroup}, '${dateFormat}') as period,
|
||||||
|
COUNT(DISTINCT contact_id) as value
|
||||||
|
FROM transactions
|
||||||
|
WHERE type = 'income' AND date >= $1 AND date <= $2
|
||||||
|
GROUP BY ${dateGroup}
|
||||||
|
ORDER BY period
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'average_transaction':
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(${dateGroup}, '${dateFormat}') as period,
|
||||||
|
COALESCE(AVG(amount), 0) as value
|
||||||
|
FROM transactions
|
||||||
|
WHERE date >= $1 AND date <= $2 AND status != 'cancelled'
|
||||||
|
GROUP BY ${dateGroup}
|
||||||
|
ORDER BY period
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new ValidationError(`Historial no disponible para metrica: ${code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.queryTenant<{ period: string; value: string }>(tenant, query, [startDate, endDate]);
|
||||||
|
historyData = result.rows.map((row) => ({
|
||||||
|
period: row.period,
|
||||||
|
value: parseFloat(row.value),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
code,
|
||||||
|
granularity,
|
||||||
|
history: historyData,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
period: { startDate, endDate },
|
||||||
|
dataPoints: historyData.length,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
617
apps/api/src/routes/transactions.routes.ts
Normal file
617
apps/api/src/routes/transactions.routes.ts
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
/**
|
||||||
|
* Transactions Routes
|
||||||
|
*
|
||||||
|
* CRUD operations for transactions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { validate } from '../middleware/validate.middleware';
|
||||||
|
import { getDatabase, TenantContext } from '@horux/database';
|
||||||
|
import {
|
||||||
|
ApiResponse,
|
||||||
|
AppError,
|
||||||
|
NotFoundError,
|
||||||
|
ValidationError,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const TransactionTypeEnum = z.enum(['income', 'expense', 'transfer']);
|
||||||
|
const TransactionStatusEnum = z.enum(['pending', 'completed', 'cancelled', 'reconciled']);
|
||||||
|
|
||||||
|
const PaginationSchema = z.object({
|
||||||
|
page: z.string().optional().transform((v) => (v ? parseInt(v, 10) : 1)),
|
||||||
|
limit: z.string().optional().transform((v) => (v ? Math.min(parseInt(v, 10), 100) : 20)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TransactionFiltersSchema = PaginationSchema.extend({
|
||||||
|
startDate: z.string().datetime().optional(),
|
||||||
|
endDate: z.string().datetime().optional(),
|
||||||
|
type: TransactionTypeEnum.optional(),
|
||||||
|
categoryId: z.string().uuid().optional(),
|
||||||
|
contactId: z.string().uuid().optional(),
|
||||||
|
status: TransactionStatusEnum.optional(),
|
||||||
|
minAmount: z.string().optional().transform((v) => (v ? parseFloat(v) : undefined)),
|
||||||
|
maxAmount: z.string().optional().transform((v) => (v ? parseFloat(v) : undefined)),
|
||||||
|
search: z.string().optional(),
|
||||||
|
sortBy: z.enum(['date', 'amount', 'created_at']).optional().default('date'),
|
||||||
|
sortOrder: z.enum(['asc', 'desc']).optional().default('desc'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TransactionIdSchema = z.object({
|
||||||
|
id: z.string().uuid('ID de transaccion invalido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CreateTransactionSchema = z.object({
|
||||||
|
type: TransactionTypeEnum,
|
||||||
|
amount: z.number().positive('El monto debe ser positivo'),
|
||||||
|
currency: z.string().length(3).default('MXN'),
|
||||||
|
description: z.string().min(1).max(500),
|
||||||
|
date: z.string().datetime(),
|
||||||
|
categoryId: z.string().uuid().optional(),
|
||||||
|
contactId: z.string().uuid().optional(),
|
||||||
|
accountId: z.string().uuid().optional(),
|
||||||
|
reference: z.string().max(100).optional(),
|
||||||
|
notes: z.string().max(2000).optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const UpdateTransactionSchema = z.object({
|
||||||
|
categoryId: z.string().uuid().optional().nullable(),
|
||||||
|
notes: z.string().max(2000).optional().nullable(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
status: TransactionStatusEnum.optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ExportQuerySchema = z.object({
|
||||||
|
format: z.enum(['csv', 'xlsx']).default('csv'),
|
||||||
|
startDate: z.string().datetime().optional(),
|
||||||
|
endDate: z.string().datetime().optional(),
|
||||||
|
type: TransactionTypeEnum.optional(),
|
||||||
|
categoryId: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const getTenantContext = (req: Request): TenantContext => {
|
||||||
|
if (!req.user || !req.tenantSchema) {
|
||||||
|
throw new AppError('Contexto de tenant no disponible', 'TENANT_CONTEXT_ERROR', 500);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: req.user.tenant_id,
|
||||||
|
schemaName: req.tenantSchema,
|
||||||
|
userId: req.user.sub,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFilterQuery = (filters: z.infer<typeof TransactionFiltersSchema>) => {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (filters.startDate) {
|
||||||
|
conditions.push(`t.date >= $${paramIndex++}`);
|
||||||
|
params.push(filters.startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.endDate) {
|
||||||
|
conditions.push(`t.date <= $${paramIndex++}`);
|
||||||
|
params.push(filters.endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.type) {
|
||||||
|
conditions.push(`t.type = $${paramIndex++}`);
|
||||||
|
params.push(filters.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.categoryId) {
|
||||||
|
conditions.push(`t.category_id = $${paramIndex++}`);
|
||||||
|
params.push(filters.categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.contactId) {
|
||||||
|
conditions.push(`t.contact_id = $${paramIndex++}`);
|
||||||
|
params.push(filters.contactId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.status) {
|
||||||
|
conditions.push(`t.status = $${paramIndex++}`);
|
||||||
|
params.push(filters.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.minAmount !== undefined) {
|
||||||
|
conditions.push(`t.amount >= $${paramIndex++}`);
|
||||||
|
params.push(filters.minAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.maxAmount !== undefined) {
|
||||||
|
conditions.push(`t.amount <= $${paramIndex++}`);
|
||||||
|
params.push(filters.maxAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
conditions.push(`(t.description ILIKE $${paramIndex} OR t.reference ILIKE $${paramIndex} OR t.notes ILIKE $${paramIndex})`);
|
||||||
|
params.push(`%${filters.search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { conditions, params, paramIndex };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Routes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/transactions
|
||||||
|
* List transactions with filters and pagination
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
authenticate,
|
||||||
|
validate({ query: TransactionFiltersSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const filters = req.query as z.infer<typeof TransactionFiltersSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const { conditions, params, paramIndex } = buildFilterQuery(filters);
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const offset = (filters.page - 1) * filters.limit;
|
||||||
|
const sortColumn = filters.sortBy === 'date' ? 't.date' : filters.sortBy === 'amount' ? 't.amount' : 't.created_at';
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const countQuery = `
|
||||||
|
SELECT COUNT(*) as total
|
||||||
|
FROM transactions t
|
||||||
|
${whereClause}
|
||||||
|
`;
|
||||||
|
const countResult = await db.queryTenant<{ total: string }>(tenant, countQuery, params);
|
||||||
|
const total = parseInt(countResult.rows[0]?.total || '0', 10);
|
||||||
|
|
||||||
|
// Get transactions with related data
|
||||||
|
const dataQuery = `
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.type,
|
||||||
|
t.amount,
|
||||||
|
t.currency,
|
||||||
|
t.description,
|
||||||
|
t.date,
|
||||||
|
t.reference,
|
||||||
|
t.notes,
|
||||||
|
t.tags,
|
||||||
|
t.status,
|
||||||
|
t.cfdi_id,
|
||||||
|
t.created_at,
|
||||||
|
t.updated_at,
|
||||||
|
json_build_object('id', c.id, 'name', c.name, 'color', c.color, 'icon', c.icon) as category,
|
||||||
|
json_build_object('id', co.id, 'name', co.name, 'rfc', co.rfc, 'type', co.type) as contact
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
LEFT JOIN contacts co ON t.contact_id = co.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY ${sortColumn} ${filters.sortOrder.toUpperCase()}
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dataResult = await db.queryTenant(tenant, dataQuery, [...params, filters.limit, offset]);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: dataResult.rows,
|
||||||
|
meta: {
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
total,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/transactions/export
|
||||||
|
* Export transactions to CSV or Excel
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/export',
|
||||||
|
authenticate,
|
||||||
|
validate({ query: ExportQuerySchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const query = req.query as z.infer<typeof ExportQuerySchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Build filter conditions
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (query.startDate) {
|
||||||
|
conditions.push(`t.date >= $${paramIndex++}`);
|
||||||
|
params.push(query.startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.endDate) {
|
||||||
|
conditions.push(`t.date <= $${paramIndex++}`);
|
||||||
|
params.push(query.endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.type) {
|
||||||
|
conditions.push(`t.type = $${paramIndex++}`);
|
||||||
|
params.push(query.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.categoryId) {
|
||||||
|
conditions.push(`t.category_id = $${paramIndex++}`);
|
||||||
|
params.push(query.categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const dataQuery = `
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.type,
|
||||||
|
t.amount,
|
||||||
|
t.currency,
|
||||||
|
t.description,
|
||||||
|
t.date,
|
||||||
|
t.reference,
|
||||||
|
t.notes,
|
||||||
|
t.status,
|
||||||
|
c.name as category_name,
|
||||||
|
co.name as contact_name,
|
||||||
|
co.rfc as contact_rfc
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
LEFT JOIN contacts co ON t.contact_id = co.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY t.date DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, dataQuery, params);
|
||||||
|
|
||||||
|
if (query.format === 'csv') {
|
||||||
|
// Generate CSV
|
||||||
|
const headers = ['ID', 'Tipo', 'Monto', 'Moneda', 'Descripcion', 'Fecha', 'Referencia', 'Notas', 'Estado', 'Categoria', 'Contacto', 'RFC'];
|
||||||
|
const csvRows = [headers.join(',')];
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const values = [
|
||||||
|
row.id,
|
||||||
|
row.type,
|
||||||
|
row.amount,
|
||||||
|
row.currency,
|
||||||
|
`"${(row.description || '').replace(/"/g, '""')}"`,
|
||||||
|
row.date,
|
||||||
|
row.reference || '',
|
||||||
|
`"${(row.notes || '').replace(/"/g, '""')}"`,
|
||||||
|
row.status,
|
||||||
|
row.category_name || '',
|
||||||
|
row.contact_name || '',
|
||||||
|
row.contact_rfc || '',
|
||||||
|
];
|
||||||
|
csvRows.push(values.join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
const csv = csvRows.join('\n');
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="transacciones_${new Date().toISOString().split('T')[0]}.csv"`);
|
||||||
|
res.send(csv);
|
||||||
|
} else {
|
||||||
|
// For Excel, return JSON that can be converted client-side or by a worker
|
||||||
|
// In a production environment, you'd use a library like exceljs
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
format: 'xlsx',
|
||||||
|
rows: result.rows,
|
||||||
|
headers: ['ID', 'Tipo', 'Monto', 'Moneda', 'Descripcion', 'Fecha', 'Referencia', 'Notas', 'Estado', 'Categoria', 'Contacto', 'RFC'],
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/transactions/:id
|
||||||
|
* Get a single transaction by ID
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: TransactionIdSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
t.*,
|
||||||
|
json_build_object('id', c.id, 'name', c.name, 'color', c.color, 'icon', c.icon) as category,
|
||||||
|
json_build_object('id', co.id, 'name', co.name, 'rfc', co.rfc, 'type', co.type, 'email', co.email) as contact,
|
||||||
|
json_build_object('id', cf.id, 'uuid', cf.uuid, 'folio', cf.folio, 'serie', cf.serie) as cfdi
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
LEFT JOIN contacts co ON t.contact_id = co.id
|
||||||
|
LEFT JOIN cfdis cf ON t.cfdi_id = cf.id
|
||||||
|
WHERE t.id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, query, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Transaccion');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/transactions
|
||||||
|
* Create a new manual transaction
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
authenticate,
|
||||||
|
validate({ body: CreateTransactionSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const data = req.body as z.infer<typeof CreateTransactionSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Verify category exists if provided
|
||||||
|
if (data.categoryId) {
|
||||||
|
const categoryCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id FROM categories WHERE id = $1',
|
||||||
|
[data.categoryId]
|
||||||
|
);
|
||||||
|
if (categoryCheck.rows.length === 0) {
|
||||||
|
throw new ValidationError('Categoria no encontrada', { categoryId: 'Categoria no existe' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify contact exists if provided
|
||||||
|
if (data.contactId) {
|
||||||
|
const contactCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id FROM contacts WHERE id = $1',
|
||||||
|
[data.contactId]
|
||||||
|
);
|
||||||
|
if (contactCheck.rows.length === 0) {
|
||||||
|
throw new ValidationError('Contacto no encontrado', { contactId: 'Contacto no existe' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO transactions (
|
||||||
|
type, amount, currency, description, date,
|
||||||
|
category_id, contact_id, account_id, reference, notes, tags, metadata,
|
||||||
|
source, status, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'manual', 'pending', $13)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, insertQuery, [
|
||||||
|
data.type,
|
||||||
|
data.amount,
|
||||||
|
data.currency,
|
||||||
|
data.description,
|
||||||
|
data.date,
|
||||||
|
data.categoryId || null,
|
||||||
|
data.contactId || null,
|
||||||
|
data.accountId || null,
|
||||||
|
data.reference || null,
|
||||||
|
data.notes || null,
|
||||||
|
data.tags || [],
|
||||||
|
data.metadata || {},
|
||||||
|
req.user!.sub,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/transactions/:id
|
||||||
|
* Update a transaction (category, notes, tags, status)
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: TransactionIdSchema, body: UpdateTransactionSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const data = req.body as z.infer<typeof UpdateTransactionSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Check if transaction exists
|
||||||
|
const existingCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id FROM transactions WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Transaccion');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update query dynamically
|
||||||
|
const updates: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (data.categoryId !== undefined) {
|
||||||
|
if (data.categoryId !== null) {
|
||||||
|
const categoryCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id FROM categories WHERE id = $1',
|
||||||
|
[data.categoryId]
|
||||||
|
);
|
||||||
|
if (categoryCheck.rows.length === 0) {
|
||||||
|
throw new ValidationError('Categoria no encontrada', { categoryId: 'Categoria no existe' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updates.push(`category_id = $${paramIndex++}`);
|
||||||
|
params.push(data.categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.notes !== undefined) {
|
||||||
|
updates.push(`notes = $${paramIndex++}`);
|
||||||
|
params.push(data.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.tags !== undefined) {
|
||||||
|
updates.push(`tags = $${paramIndex++}`);
|
||||||
|
params.push(data.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status !== undefined) {
|
||||||
|
updates.push(`status = $${paramIndex++}`);
|
||||||
|
params.push(data.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.metadata !== undefined) {
|
||||||
|
updates.push(`metadata = $${paramIndex++}`);
|
||||||
|
params.push(data.metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
throw new ValidationError('No hay campos para actualizar');
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push(`updated_at = NOW()`);
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE transactions
|
||||||
|
SET ${updates.join(', ')}
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, updateQuery, params);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/transactions/:id
|
||||||
|
* Delete a transaction
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: TransactionIdSchema }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Check if transaction exists and is deletable (manual transactions only)
|
||||||
|
const existingCheck = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id, source FROM transactions WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Transaccion');
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = existingCheck.rows[0];
|
||||||
|
|
||||||
|
// Prevent deletion of CFDI-linked transactions
|
||||||
|
if (transaction.source === 'cfdi') {
|
||||||
|
throw new ValidationError('No se pueden eliminar transacciones generadas desde CFDI');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.queryTenant(tenant, 'DELETE FROM transactions WHERE id = $1', [id]);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: { deleted: true, id },
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
754
apps/api/src/services/auth.service.ts
Normal file
754
apps/api/src/services/auth.service.ts
Normal file
@@ -0,0 +1,754 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { Pool, PoolClient } from 'pg';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { config } from '../config/index.js';
|
||||||
|
import { jwtService, hashToken } from './jwt.service.js';
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
Tenant,
|
||||||
|
Session,
|
||||||
|
TokenPair,
|
||||||
|
RegisterInput,
|
||||||
|
LoginInput,
|
||||||
|
AppError,
|
||||||
|
AuthenticationError,
|
||||||
|
ConflictError,
|
||||||
|
NotFoundError,
|
||||||
|
ValidationError,
|
||||||
|
} from '../types/index.js';
|
||||||
|
import { logger, auditLog } from '../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Database Pool
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: config.database.url,
|
||||||
|
min: config.database.pool.min,
|
||||||
|
max: config.database.pool.max,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a slug from company name
|
||||||
|
*/
|
||||||
|
const generateSlug = (name: string): string => {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.substring(0, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique schema name for a tenant
|
||||||
|
*/
|
||||||
|
const generateSchemaName = (slug: string): string => {
|
||||||
|
const uniqueSuffix = uuidv4().split('-')[0];
|
||||||
|
return `tenant_${slug.replace(/-/g, '_')}_${uniqueSuffix}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash a password
|
||||||
|
*/
|
||||||
|
const hashPassword = async (password: string): Promise<string> => {
|
||||||
|
return bcrypt.hash(password, config.security.bcryptRounds);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare password with hash
|
||||||
|
*/
|
||||||
|
const comparePassword = async (password: string, hash: string): Promise<boolean> => {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth Service
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class AuthService {
|
||||||
|
/**
|
||||||
|
* Register a new user and create their tenant
|
||||||
|
*/
|
||||||
|
async register(input: RegisterInput): Promise<{ user: Partial<User>; tokens: TokenPair }> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Check if email already exists
|
||||||
|
const existingUser = await client.query(
|
||||||
|
'SELECT id FROM public.users WHERE email = $1',
|
||||||
|
[input.email.toLowerCase()]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUser.rows.length > 0) {
|
||||||
|
throw new ConflictError('Este email ya esta registrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tenant info
|
||||||
|
const tenantId = uuidv4();
|
||||||
|
const slug = generateSlug(input.companyName);
|
||||||
|
const schemaName = generateSchemaName(slug);
|
||||||
|
|
||||||
|
// Get default plan (trial)
|
||||||
|
const defaultPlan = await client.query(
|
||||||
|
'SELECT id FROM public.plans WHERE slug = $1',
|
||||||
|
['trial']
|
||||||
|
);
|
||||||
|
|
||||||
|
let planId = defaultPlan.rows[0]?.id;
|
||||||
|
|
||||||
|
// If no trial plan exists, create a basic one
|
||||||
|
if (!planId) {
|
||||||
|
const newPlan = await client.query(
|
||||||
|
`INSERT INTO public.plans (id, name, slug, price_monthly, price_yearly, features, is_active)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
uuidv4(),
|
||||||
|
'Trial',
|
||||||
|
'trial',
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
JSON.stringify({ users: 3, rfcs: 1, reports: 5 }),
|
||||||
|
true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
planId = newPlan.rows[0]?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tenant
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO public.tenants (id, name, slug, schema_name, plan_id, is_active, settings, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
input.companyName,
|
||||||
|
slug,
|
||||||
|
schemaName,
|
||||||
|
planId,
|
||||||
|
true,
|
||||||
|
JSON.stringify({
|
||||||
|
timezone: 'America/Mexico_City',
|
||||||
|
currency: 'MXN',
|
||||||
|
fiscal_year_start_month: 1,
|
||||||
|
language: 'es',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create tenant schema
|
||||||
|
await this.createTenantSchema(client, schemaName);
|
||||||
|
|
||||||
|
// Hash password and create user
|
||||||
|
const userId = uuidv4();
|
||||||
|
const passwordHash = await hashPassword(input.password);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO public.users (id, email, password_hash, first_name, last_name, role, tenant_id, is_active, email_verified, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
input.email.toLowerCase(),
|
||||||
|
passwordHash,
|
||||||
|
input.firstName,
|
||||||
|
input.lastName,
|
||||||
|
'owner',
|
||||||
|
tenantId,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const sessionId = uuidv4();
|
||||||
|
const tokens = jwtService.generateTokenPair(
|
||||||
|
{
|
||||||
|
id: userId,
|
||||||
|
email: input.email.toLowerCase(),
|
||||||
|
role: 'owner',
|
||||||
|
tenant_id: tenantId,
|
||||||
|
schema_name: schemaName,
|
||||||
|
},
|
||||||
|
sessionId
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshTokenHash = hashToken(tokens.refreshToken);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO public.user_sessions (id, user_id, tenant_id, refresh_token_hash, expires_at, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, NOW())`,
|
||||||
|
[sessionId, userId, tenantId, refreshTokenHash, jwtService.getRefreshTokenExpiration()]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
auditLog('USER_REGISTERED', userId, tenantId, {
|
||||||
|
email: input.email,
|
||||||
|
companyName: input.companyName,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('User registered successfully', { userId, tenantId, email: input.email });
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: userId,
|
||||||
|
email: input.email.toLowerCase(),
|
||||||
|
first_name: input.firstName,
|
||||||
|
last_name: input.lastName,
|
||||||
|
role: 'owner',
|
||||||
|
tenant_id: tenantId,
|
||||||
|
},
|
||||||
|
tokens,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
logger.error('Registration failed', { error, email: input.email });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login a user
|
||||||
|
*/
|
||||||
|
async login(
|
||||||
|
input: LoginInput,
|
||||||
|
userAgent?: string,
|
||||||
|
ipAddress?: string
|
||||||
|
): Promise<{ user: Partial<User>; tenant: Partial<Tenant>; tokens: TokenPair }> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find user by email
|
||||||
|
const userResult = await client.query(
|
||||||
|
`SELECT u.*, t.schema_name, t.name as tenant_name, t.slug as tenant_slug
|
||||||
|
FROM public.users u
|
||||||
|
JOIN public.tenants t ON u.tenant_id = t.id
|
||||||
|
WHERE u.email = $1 AND u.is_active = true AND t.is_active = true`,
|
||||||
|
[input.email.toLowerCase()]
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userResult.rows[0];
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
auditLog('LOGIN_FAILED', null, null, { email: input.email, reason: 'user_not_found' }, false);
|
||||||
|
throw new AuthenticationError('Credenciales invalidas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isValidPassword = await comparePassword(input.password, user.password_hash);
|
||||||
|
|
||||||
|
if (!isValidPassword) {
|
||||||
|
auditLog('LOGIN_FAILED', user.id, user.tenant_id, { reason: 'invalid_password' }, false);
|
||||||
|
throw new AuthenticationError('Credenciales invalidas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const sessionId = uuidv4();
|
||||||
|
const tokens = jwtService.generateTokenPair(
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
tenant_id: user.tenant_id,
|
||||||
|
schema_name: user.schema_name,
|
||||||
|
},
|
||||||
|
sessionId
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshTokenHash = hashToken(tokens.refreshToken);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO public.user_sessions (id, user_id, tenant_id, refresh_token_hash, user_agent, ip_address, expires_at, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
|
||||||
|
[
|
||||||
|
sessionId,
|
||||||
|
user.id,
|
||||||
|
user.tenant_id,
|
||||||
|
refreshTokenHash,
|
||||||
|
userAgent || null,
|
||||||
|
ipAddress || null,
|
||||||
|
jwtService.getRefreshTokenExpiration(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
await client.query('UPDATE public.users SET last_login_at = NOW() WHERE id = $1', [user.id]);
|
||||||
|
|
||||||
|
auditLog('LOGIN_SUCCESS', user.id, user.tenant_id, { userAgent, ipAddress });
|
||||||
|
|
||||||
|
logger.info('User logged in', { userId: user.id, tenantId: user.tenant_id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name,
|
||||||
|
role: user.role,
|
||||||
|
tenant_id: user.tenant_id,
|
||||||
|
},
|
||||||
|
tenant: {
|
||||||
|
id: user.tenant_id,
|
||||||
|
name: user.tenant_name,
|
||||||
|
slug: user.tenant_slug,
|
||||||
|
schema_name: user.schema_name,
|
||||||
|
},
|
||||||
|
tokens,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh access token using refresh token
|
||||||
|
*/
|
||||||
|
async refreshToken(refreshToken: string): Promise<TokenPair> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify refresh token
|
||||||
|
const payload = jwtService.verifyRefreshToken(refreshToken);
|
||||||
|
const tokenHash = hashToken(refreshToken);
|
||||||
|
|
||||||
|
// Find session
|
||||||
|
const sessionResult = await client.query(
|
||||||
|
`SELECT s.*, u.email, u.role, u.is_active as user_active, t.schema_name, t.is_active as tenant_active
|
||||||
|
FROM public.user_sessions s
|
||||||
|
JOIN public.users u ON s.user_id = u.id
|
||||||
|
JOIN public.tenants t ON s.tenant_id = t.id
|
||||||
|
WHERE s.id = $1 AND s.refresh_token_hash = $2 AND s.expires_at > NOW()`,
|
||||||
|
[payload.session_id, tokenHash]
|
||||||
|
);
|
||||||
|
|
||||||
|
const session = sessionResult.rows[0];
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new AuthenticationError('Sesion invalida o expirada');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.user_active || !session.tenant_active) {
|
||||||
|
throw new AuthenticationError('Cuenta desactivada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new token pair
|
||||||
|
const newSessionId = uuidv4();
|
||||||
|
const tokens = jwtService.generateTokenPair(
|
||||||
|
{
|
||||||
|
id: session.user_id,
|
||||||
|
email: session.email,
|
||||||
|
role: session.role,
|
||||||
|
tenant_id: session.tenant_id,
|
||||||
|
schema_name: session.schema_name,
|
||||||
|
},
|
||||||
|
newSessionId
|
||||||
|
);
|
||||||
|
|
||||||
|
const newRefreshTokenHash = hashToken(tokens.refreshToken);
|
||||||
|
|
||||||
|
// Delete old session and create new one (rotation)
|
||||||
|
await client.query('DELETE FROM public.user_sessions WHERE id = $1', [payload.session_id]);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO public.user_sessions (id, user_id, tenant_id, refresh_token_hash, user_agent, ip_address, expires_at, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
|
||||||
|
[
|
||||||
|
newSessionId,
|
||||||
|
session.user_id,
|
||||||
|
session.tenant_id,
|
||||||
|
newRefreshTokenHash,
|
||||||
|
session.user_agent,
|
||||||
|
session.ip_address,
|
||||||
|
jwtService.getRefreshTokenExpiration(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug('Token refreshed', { userId: session.user_id });
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout - invalidate session
|
||||||
|
*/
|
||||||
|
async logout(refreshToken: string): Promise<void> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = jwtService.verifyRefreshToken(refreshToken);
|
||||||
|
const tokenHash = hashToken(refreshToken);
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
'DELETE FROM public.user_sessions WHERE id = $1 AND refresh_token_hash = $2 RETURNING user_id, tenant_id',
|
||||||
|
[payload.session_id, tokenHash]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows[0]) {
|
||||||
|
auditLog('LOGOUT', result.rows[0].user_id, result.rows[0].tenant_id, {});
|
||||||
|
logger.info('User logged out', { userId: result.rows[0].user_id });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore token verification errors during logout
|
||||||
|
logger.debug('Logout with invalid token', { error });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout from all sessions
|
||||||
|
*/
|
||||||
|
async logoutAll(userId: string, tenantId: string): Promise<number> {
|
||||||
|
const result = await pool.query(
|
||||||
|
'DELETE FROM public.user_sessions WHERE user_id = $1 AND tenant_id = $2 RETURNING id',
|
||||||
|
[userId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
auditLog('LOGOUT_ALL', userId, tenantId, { sessionsDeleted: result.rowCount });
|
||||||
|
logger.info('User logged out from all sessions', { userId, sessionsDeleted: result.rowCount });
|
||||||
|
|
||||||
|
return result.rowCount || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request password reset
|
||||||
|
*/
|
||||||
|
async requestPasswordReset(email: string): Promise<void> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userResult = await client.query(
|
||||||
|
'SELECT id, email, first_name FROM public.users WHERE email = $1 AND is_active = true',
|
||||||
|
[email.toLowerCase()]
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userResult.rows[0];
|
||||||
|
|
||||||
|
// Always return success to prevent email enumeration
|
||||||
|
if (!user) {
|
||||||
|
logger.debug('Password reset requested for non-existent email', { email });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate reset token
|
||||||
|
const resetToken = jwtService.generateResetToken(user.id, user.email);
|
||||||
|
|
||||||
|
// Store reset token hash
|
||||||
|
const tokenHash = hashToken(resetToken);
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO public.password_reset_tokens (id, user_id, token_hash, expires_at, created_at)
|
||||||
|
VALUES ($1, $2, $3, NOW() + INTERVAL '1 hour', NOW())
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET token_hash = $3, expires_at = NOW() + INTERVAL '1 hour', created_at = NOW()`,
|
||||||
|
[uuidv4(), user.id, tokenHash]
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Send email with reset link
|
||||||
|
// For now, log the token (REMOVE IN PRODUCTION)
|
||||||
|
logger.info('Password reset token generated', {
|
||||||
|
userId: user.id,
|
||||||
|
resetLink: `${config.isProduction ? 'https://app.horuxstrategy.com' : 'http://localhost:3000'}/reset-password?token=${resetToken}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
auditLog('PASSWORD_RESET_REQUESTED', user.id, null, { email });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset password with token
|
||||||
|
*/
|
||||||
|
async resetPassword(token: string, newPassword: string): Promise<void> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
const { userId, email } = jwtService.verifyResetToken(token);
|
||||||
|
const tokenHash = hashToken(token);
|
||||||
|
|
||||||
|
// Verify token exists in database and not expired
|
||||||
|
const tokenResult = await client.query(
|
||||||
|
'SELECT id FROM public.password_reset_tokens WHERE user_id = $1 AND token_hash = $2 AND expires_at > NOW()',
|
||||||
|
[userId, tokenHash]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tokenResult.rows.length === 0) {
|
||||||
|
throw new ValidationError('Enlace de restablecimiento invalido o expirado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
const passwordHash = await hashPassword(newPassword);
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
await client.query('UPDATE public.users SET password_hash = $1, updated_at = NOW() WHERE id = $2', [
|
||||||
|
passwordHash,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Delete reset token
|
||||||
|
await client.query('DELETE FROM public.password_reset_tokens WHERE user_id = $1', [userId]);
|
||||||
|
|
||||||
|
// Invalidate all existing sessions
|
||||||
|
await client.query('DELETE FROM public.user_sessions WHERE user_id = $1', [userId]);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
auditLog('PASSWORD_RESET_SUCCESS', userId, null, { email });
|
||||||
|
logger.info('Password reset successfully', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change password (for authenticated users)
|
||||||
|
*/
|
||||||
|
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<void> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Get current password hash
|
||||||
|
const userResult = await client.query(
|
||||||
|
'SELECT password_hash, tenant_id FROM public.users WHERE id = $1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userResult.rows[0];
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Usuario');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const isValidPassword = await comparePassword(currentPassword, user.password_hash);
|
||||||
|
|
||||||
|
if (!isValidPassword) {
|
||||||
|
throw new ValidationError('La contrasena actual es incorrecta');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
const passwordHash = await hashPassword(newPassword);
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
await client.query('UPDATE public.users SET password_hash = $1, updated_at = NOW() WHERE id = $2', [
|
||||||
|
passwordHash,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
auditLog('PASSWORD_CHANGED', userId, user.tenant_id, {});
|
||||||
|
logger.info('Password changed', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user profile
|
||||||
|
*/
|
||||||
|
async getProfile(userId: string): Promise<Partial<User> & { tenant: Partial<Tenant> }> {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT u.id, u.email, u.first_name, u.last_name, u.role, u.tenant_id, u.email_verified, u.created_at, u.last_login_at,
|
||||||
|
t.id as tenant_id, t.name as tenant_name, t.slug as tenant_slug, t.schema_name
|
||||||
|
FROM public.users u
|
||||||
|
JOIN public.tenants t ON u.tenant_id = t.id
|
||||||
|
WHERE u.id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = result.rows[0];
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Usuario');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name,
|
||||||
|
role: user.role,
|
||||||
|
tenant_id: user.tenant_id,
|
||||||
|
email_verified: user.email_verified,
|
||||||
|
created_at: user.created_at,
|
||||||
|
last_login_at: user.last_login_at,
|
||||||
|
tenant: {
|
||||||
|
id: user.tenant_id,
|
||||||
|
name: user.tenant_name,
|
||||||
|
slug: user.tenant_slug,
|
||||||
|
schema_name: user.schema_name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create tenant schema with all required tables
|
||||||
|
*/
|
||||||
|
private async createTenantSchema(client: PoolClient, schemaName: string): Promise<void> {
|
||||||
|
// Create schema
|
||||||
|
await client.query(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
|
||||||
|
|
||||||
|
// Create tenant-specific tables
|
||||||
|
await client.query(`
|
||||||
|
-- SAT Credentials
|
||||||
|
CREATE TABLE IF NOT EXISTS "${schemaName}".sat_credentials (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
rfc VARCHAR(13) NOT NULL UNIQUE,
|
||||||
|
certificate_data TEXT,
|
||||||
|
key_data_encrypted TEXT,
|
||||||
|
valid_from TIMESTAMP,
|
||||||
|
valid_to TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
last_sync_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Contacts (customers and suppliers)
|
||||||
|
CREATE TABLE IF NOT EXISTS "${schemaName}".contacts (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
type VARCHAR(20) NOT NULL, -- customer, supplier, both
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
rfc VARCHAR(13),
|
||||||
|
email VARCHAR(255),
|
||||||
|
phone VARCHAR(50),
|
||||||
|
address JSONB,
|
||||||
|
is_recurring_customer BOOLEAN DEFAULT false,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CFDIs
|
||||||
|
CREATE TABLE IF NOT EXISTS "${schemaName}".cfdis (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
uuid_fiscal VARCHAR(36) UNIQUE NOT NULL,
|
||||||
|
type VARCHAR(10) NOT NULL, -- I=Ingreso, E=Egreso, P=Pago, N=Nomina, T=Traslado
|
||||||
|
series VARCHAR(25),
|
||||||
|
folio VARCHAR(40),
|
||||||
|
issue_date TIMESTAMP NOT NULL,
|
||||||
|
issuer_rfc VARCHAR(13) NOT NULL,
|
||||||
|
issuer_name VARCHAR(255),
|
||||||
|
receiver_rfc VARCHAR(13) NOT NULL,
|
||||||
|
receiver_name VARCHAR(255),
|
||||||
|
subtotal DECIMAL(18, 2),
|
||||||
|
discount DECIMAL(18, 2) DEFAULT 0,
|
||||||
|
total DECIMAL(18, 2) NOT NULL,
|
||||||
|
currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
exchange_rate DECIMAL(18, 6) DEFAULT 1,
|
||||||
|
payment_method VARCHAR(3),
|
||||||
|
payment_form VARCHAR(2),
|
||||||
|
status VARCHAR(20) DEFAULT 'active', -- active, cancelled
|
||||||
|
xml_content TEXT,
|
||||||
|
is_incoming BOOLEAN NOT NULL, -- true = received, false = issued
|
||||||
|
contact_id UUID REFERENCES "${schemaName}".contacts(id),
|
||||||
|
paid_amount DECIMAL(18, 2) DEFAULT 0,
|
||||||
|
pending_amount DECIMAL(18, 2),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Transactions (unified model)
|
||||||
|
CREATE TABLE IF NOT EXISTS "${schemaName}".transactions (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
type VARCHAR(20) NOT NULL, -- income, expense, transfer
|
||||||
|
category_id UUID,
|
||||||
|
contact_id UUID REFERENCES "${schemaName}".contacts(id),
|
||||||
|
cfdi_id UUID REFERENCES "${schemaName}".cfdis(id),
|
||||||
|
description TEXT,
|
||||||
|
amount DECIMAL(18, 2) NOT NULL,
|
||||||
|
currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
exchange_rate DECIMAL(18, 6) DEFAULT 1,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
is_recurring BOOLEAN DEFAULT false,
|
||||||
|
metadata JSONB,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Metrics Cache
|
||||||
|
CREATE TABLE IF NOT EXISTS "${schemaName}".metrics_cache (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
metric_key VARCHAR(100) NOT NULL,
|
||||||
|
period_type VARCHAR(20) NOT NULL, -- daily, weekly, monthly, yearly
|
||||||
|
period_start DATE NOT NULL,
|
||||||
|
period_end DATE NOT NULL,
|
||||||
|
value DECIMAL(18, 4),
|
||||||
|
metadata JSONB,
|
||||||
|
calculated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE (metric_key, period_type, period_start)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Alerts
|
||||||
|
CREATE TABLE IF NOT EXISTS "${schemaName}".alerts (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
severity VARCHAR(20) DEFAULT 'info', -- info, warning, critical
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
data JSONB,
|
||||||
|
is_read BOOLEAN DEFAULT false,
|
||||||
|
is_dismissed BOOLEAN DEFAULT false,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Reports
|
||||||
|
CREATE TABLE IF NOT EXISTS "${schemaName}".reports (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
period_start DATE,
|
||||||
|
period_end DATE,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending', -- pending, generating, completed, failed
|
||||||
|
file_path VARCHAR(500),
|
||||||
|
file_size INTEGER,
|
||||||
|
generated_at TIMESTAMP,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Settings
|
||||||
|
CREATE TABLE IF NOT EXISTS "${schemaName}".settings (
|
||||||
|
key VARCHAR(100) PRIMARY KEY,
|
||||||
|
value JSONB NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cfdis_issue_date ON "${schemaName}".cfdis(issue_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cfdis_issuer_rfc ON "${schemaName}".cfdis(issuer_rfc);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cfdis_receiver_rfc ON "${schemaName}".cfdis(receiver_rfc);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cfdis_type ON "${schemaName}".cfdis(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transactions_date ON "${schemaName}".transactions(date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transactions_type ON "${schemaName}".transactions(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_metrics_cache_key ON "${schemaName}".metrics_cache(metric_key, period_start);
|
||||||
|
`);
|
||||||
|
|
||||||
|
logger.info('Tenant schema created', { schemaName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const authService = new AuthService();
|
||||||
|
|
||||||
|
export default authService;
|
||||||
17
apps/api/src/services/index.ts
Normal file
17
apps/api/src/services/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Services exports
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
export { authService, AuthService } from './auth.service.js';
|
||||||
|
export { jwtService, JwtService, hashToken, generateSecureToken } from './jwt.service.js';
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
export * from './metrics/metrics.types.js';
|
||||||
|
export * from './metrics/core.metrics.js';
|
||||||
|
export * from './metrics/startup.metrics.js';
|
||||||
|
export * from './metrics/enterprise.metrics.js';
|
||||||
|
export * from './metrics/metrics.cache.js';
|
||||||
|
|
||||||
|
// SAT
|
||||||
|
export * from './sat/sat.types.js';
|
||||||
|
export * from './sat/cfdi.parser.js';
|
||||||
|
export * from './sat/fiel.service.js';
|
||||||
289
apps/api/src/services/jwt.service.ts
Normal file
289
apps/api/src/services/jwt.service.ts
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import jwt, { SignOptions, JwtPayload } from 'jsonwebtoken';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { config } from '../config/index.js';
|
||||||
|
import {
|
||||||
|
AccessTokenPayload,
|
||||||
|
RefreshTokenPayload,
|
||||||
|
TokenPair,
|
||||||
|
UserRole,
|
||||||
|
AppError,
|
||||||
|
} from '../types/index.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_TYPE = 'access';
|
||||||
|
const REFRESH_TOKEN_TYPE = 'refresh';
|
||||||
|
const RESET_TOKEN_TYPE = 'reset';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse duration string to milliseconds
|
||||||
|
* Supports: 15m, 1h, 7d, 30d
|
||||||
|
*/
|
||||||
|
const parseDuration = (duration: string): number => {
|
||||||
|
const match = duration.match(/^(\d+)([mhdw])$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Invalid duration format: ${duration}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = parseInt(match[1] ?? '0', 10);
|
||||||
|
const unit = match[2];
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case 'm':
|
||||||
|
return value * 60 * 1000;
|
||||||
|
case 'h':
|
||||||
|
return value * 60 * 60 * 1000;
|
||||||
|
case 'd':
|
||||||
|
return value * 24 * 60 * 60 * 1000;
|
||||||
|
case 'w':
|
||||||
|
return value * 7 * 24 * 60 * 60 * 1000;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown duration unit: ${unit}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash a token for secure storage
|
||||||
|
*/
|
||||||
|
export const hashToken = (token: string): string => {
|
||||||
|
return crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a secure random token
|
||||||
|
*/
|
||||||
|
export const generateSecureToken = (length: number = 32): string => {
|
||||||
|
return crypto.randomBytes(length).toString('hex');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// JWT Service
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class JwtService {
|
||||||
|
private readonly accessSecret: string;
|
||||||
|
private readonly refreshSecret: string;
|
||||||
|
private readonly resetSecret: string;
|
||||||
|
private readonly accessExpiresIn: string;
|
||||||
|
private readonly refreshExpiresIn: string;
|
||||||
|
private readonly resetExpiresIn: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.accessSecret = config.jwt.accessSecret;
|
||||||
|
this.refreshSecret = config.jwt.refreshSecret;
|
||||||
|
this.resetSecret = config.passwordReset.secret;
|
||||||
|
this.accessExpiresIn = config.jwt.accessExpiresIn;
|
||||||
|
this.refreshExpiresIn = config.jwt.refreshExpiresIn;
|
||||||
|
this.resetExpiresIn = config.passwordReset.expiresIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an access token
|
||||||
|
*/
|
||||||
|
generateAccessToken(payload: Omit<AccessTokenPayload, 'type'>): string {
|
||||||
|
const tokenPayload: AccessTokenPayload = {
|
||||||
|
...payload,
|
||||||
|
type: ACCESS_TOKEN_TYPE,
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: SignOptions = {
|
||||||
|
expiresIn: this.accessExpiresIn,
|
||||||
|
issuer: 'horux-strategy',
|
||||||
|
audience: 'horux-strategy-api',
|
||||||
|
};
|
||||||
|
|
||||||
|
return jwt.sign(tokenPayload, this.accessSecret, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a refresh token
|
||||||
|
*/
|
||||||
|
generateRefreshToken(userId: string, sessionId: string): string {
|
||||||
|
const tokenPayload: RefreshTokenPayload = {
|
||||||
|
sub: userId,
|
||||||
|
session_id: sessionId,
|
||||||
|
type: REFRESH_TOKEN_TYPE,
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: SignOptions = {
|
||||||
|
expiresIn: this.refreshExpiresIn,
|
||||||
|
issuer: 'horux-strategy',
|
||||||
|
audience: 'horux-strategy-api',
|
||||||
|
};
|
||||||
|
|
||||||
|
return jwt.sign(tokenPayload, this.refreshSecret, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate both access and refresh tokens
|
||||||
|
*/
|
||||||
|
generateTokenPair(
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: UserRole;
|
||||||
|
tenant_id: string;
|
||||||
|
schema_name: string;
|
||||||
|
},
|
||||||
|
sessionId: string
|
||||||
|
): TokenPair {
|
||||||
|
const accessToken = this.generateAccessToken({
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
tenant_id: user.tenant_id,
|
||||||
|
schema_name: user.schema_name,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshToken = this.generateRefreshToken(user.id, sessionId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
expiresIn: Math.floor(parseDuration(this.accessExpiresIn) / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify an access token
|
||||||
|
*/
|
||||||
|
verifyAccessToken(token: string): AccessTokenPayload {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, this.accessSecret, {
|
||||||
|
issuer: 'horux-strategy',
|
||||||
|
audience: 'horux-strategy-api',
|
||||||
|
}) as JwtPayload & AccessTokenPayload;
|
||||||
|
|
||||||
|
if (decoded.type !== ACCESS_TOKEN_TYPE) {
|
||||||
|
throw new AppError('Invalid token type', 'INVALID_TOKEN_TYPE', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof jwt.TokenExpiredError) {
|
||||||
|
throw new AppError('Token expirado', 'TOKEN_EXPIRED', 401);
|
||||||
|
}
|
||||||
|
if (error instanceof jwt.JsonWebTokenError) {
|
||||||
|
throw new AppError('Token invalido', 'INVALID_TOKEN', 401);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a refresh token
|
||||||
|
*/
|
||||||
|
verifyRefreshToken(token: string): RefreshTokenPayload {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, this.refreshSecret, {
|
||||||
|
issuer: 'horux-strategy',
|
||||||
|
audience: 'horux-strategy-api',
|
||||||
|
}) as JwtPayload & RefreshTokenPayload;
|
||||||
|
|
||||||
|
if (decoded.type !== REFRESH_TOKEN_TYPE) {
|
||||||
|
throw new AppError('Invalid token type', 'INVALID_TOKEN_TYPE', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof jwt.TokenExpiredError) {
|
||||||
|
throw new AppError('Refresh token expirado', 'REFRESH_TOKEN_EXPIRED', 401);
|
||||||
|
}
|
||||||
|
if (error instanceof jwt.JsonWebTokenError) {
|
||||||
|
throw new AppError('Refresh token invalido', 'INVALID_REFRESH_TOKEN', 401);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a password reset token
|
||||||
|
*/
|
||||||
|
generateResetToken(userId: string, email: string): string {
|
||||||
|
const payload = {
|
||||||
|
sub: userId,
|
||||||
|
email,
|
||||||
|
type: RESET_TOKEN_TYPE,
|
||||||
|
};
|
||||||
|
|
||||||
|
return jwt.sign(payload, this.resetSecret, {
|
||||||
|
expiresIn: this.resetExpiresIn,
|
||||||
|
issuer: 'horux-strategy',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a password reset token
|
||||||
|
*/
|
||||||
|
verifyResetToken(token: string): { userId: string; email: string } {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, this.resetSecret, {
|
||||||
|
issuer: 'horux-strategy',
|
||||||
|
}) as JwtPayload & { sub: string; email: string; type: string };
|
||||||
|
|
||||||
|
if (decoded.type !== RESET_TOKEN_TYPE) {
|
||||||
|
throw new AppError('Invalid token type', 'INVALID_TOKEN_TYPE', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: decoded.sub,
|
||||||
|
email: decoded.email,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof jwt.TokenExpiredError) {
|
||||||
|
throw new AppError(
|
||||||
|
'El enlace de restablecimiento ha expirado',
|
||||||
|
'RESET_TOKEN_EXPIRED',
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error instanceof jwt.JsonWebTokenError) {
|
||||||
|
throw new AppError(
|
||||||
|
'Enlace de restablecimiento invalido',
|
||||||
|
'INVALID_RESET_TOKEN',
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a token without verification (useful for debugging)
|
||||||
|
*/
|
||||||
|
decodeToken(token: string): JwtPayload | null {
|
||||||
|
try {
|
||||||
|
return jwt.decode(token) as JwtPayload | null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to decode token', { error });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the expiration time of the refresh token in Date
|
||||||
|
*/
|
||||||
|
getRefreshTokenExpiration(): Date {
|
||||||
|
const expiresInMs = parseDuration(this.refreshExpiresIn);
|
||||||
|
return new Date(Date.now() + expiresInMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the expiration time of the access token in seconds
|
||||||
|
*/
|
||||||
|
getAccessTokenExpiresIn(): number {
|
||||||
|
return Math.floor(parseDuration(this.accessExpiresIn) / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const jwtService = new JwtService();
|
||||||
|
|
||||||
|
export default jwtService;
|
||||||
729
apps/api/src/services/metrics/anomaly.detector.ts
Normal file
729
apps/api/src/services/metrics/anomaly.detector.ts
Normal file
@@ -0,0 +1,729 @@
|
|||||||
|
/**
|
||||||
|
* Anomaly Detector
|
||||||
|
*
|
||||||
|
* Detects anomalies in financial metrics using rule-based analysis.
|
||||||
|
* Rules implemented:
|
||||||
|
* - Significant variation (>20%)
|
||||||
|
* - Negative trend for 3+ months
|
||||||
|
* - Values outside expected range
|
||||||
|
* - Sudden spikes or drops
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DatabaseConnection } from '@horux/database';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import {
|
||||||
|
MetricType,
|
||||||
|
MetricPeriod,
|
||||||
|
Anomaly,
|
||||||
|
AnomalyType,
|
||||||
|
AnomalySeverity,
|
||||||
|
AnomalyDetectionResult,
|
||||||
|
getPeriodDateRange,
|
||||||
|
getPreviousPeriod,
|
||||||
|
periodToString,
|
||||||
|
MetricValue,
|
||||||
|
} from './metrics.types';
|
||||||
|
import { MetricsService, createMetricsService } from './metrics.service';
|
||||||
|
|
||||||
|
// Configuration for anomaly detection rules
|
||||||
|
interface AnomalyRule {
|
||||||
|
type: AnomalyType;
|
||||||
|
threshold: number;
|
||||||
|
severity: AnomalySeverity;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricRules {
|
||||||
|
significantVariation: AnomalyRule;
|
||||||
|
negativeTrend: AnomalyRule;
|
||||||
|
outOfRange: AnomalyRule;
|
||||||
|
suddenSpike: AnomalyRule;
|
||||||
|
suddenDrop: AnomalyRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default rules configuration
|
||||||
|
const DEFAULT_RULES: MetricRules = {
|
||||||
|
significantVariation: {
|
||||||
|
type: 'significant_variation',
|
||||||
|
threshold: 0.20, // 20% variation
|
||||||
|
severity: 'medium',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
negativeTrend: {
|
||||||
|
type: 'negative_trend',
|
||||||
|
threshold: 3, // 3 consecutive periods
|
||||||
|
severity: 'high',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
outOfRange: {
|
||||||
|
type: 'out_of_range',
|
||||||
|
threshold: 2, // 2 standard deviations
|
||||||
|
severity: 'high',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
suddenSpike: {
|
||||||
|
type: 'sudden_spike',
|
||||||
|
threshold: 0.50, // 50% increase
|
||||||
|
severity: 'medium',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
suddenDrop: {
|
||||||
|
type: 'sudden_drop',
|
||||||
|
threshold: 0.30, // 30% decrease
|
||||||
|
severity: 'high',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Metric-specific expected ranges
|
||||||
|
interface MetricRange {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
isPercentage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const METRIC_RANGES: Partial<Record<MetricType, MetricRange>> = {
|
||||||
|
churn_rate: { min: 0, max: 0.15, isPercentage: true },
|
||||||
|
current_ratio: { min: 1.0, max: 3.0, isPercentage: false },
|
||||||
|
quick_ratio: { min: 0.5, max: 2.0, isPercentage: false },
|
||||||
|
debt_ratio: { min: 0, max: 0.6, isPercentage: true },
|
||||||
|
ltv_cac_ratio: { min: 3.0, max: 10.0, isPercentage: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AnomalyDetector {
|
||||||
|
private db: DatabaseConnection;
|
||||||
|
private metricsService: MetricsService;
|
||||||
|
private rules: MetricRules;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
metricsService?: MetricsService,
|
||||||
|
rules?: Partial<MetricRules>
|
||||||
|
) {
|
||||||
|
this.db = db;
|
||||||
|
this.metricsService = metricsService || createMetricsService(db);
|
||||||
|
this.rules = { ...DEFAULT_RULES, ...rules };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect anomalies for a tenant in a given period
|
||||||
|
*/
|
||||||
|
async detectAnomalies(
|
||||||
|
tenantId: string,
|
||||||
|
period: MetricPeriod
|
||||||
|
): Promise<AnomalyDetectionResult> {
|
||||||
|
const anomalies: Anomaly[] = [];
|
||||||
|
|
||||||
|
// Get historical periods for trend analysis (last 6 periods)
|
||||||
|
const historicalPeriods = this.getHistoricalPeriods(period, 6);
|
||||||
|
|
||||||
|
// Metrics to analyze
|
||||||
|
const metricsToAnalyze: MetricType[] = [
|
||||||
|
'revenue',
|
||||||
|
'expenses',
|
||||||
|
'net_profit',
|
||||||
|
'cash_flow',
|
||||||
|
'accounts_receivable',
|
||||||
|
'accounts_payable',
|
||||||
|
'mrr',
|
||||||
|
'churn_rate',
|
||||||
|
'burn_rate',
|
||||||
|
'current_ratio',
|
||||||
|
'quick_ratio',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const metric of metricsToAnalyze) {
|
||||||
|
try {
|
||||||
|
const metricAnomalies = await this.analyzeMetric(
|
||||||
|
tenantId,
|
||||||
|
metric,
|
||||||
|
period,
|
||||||
|
historicalPeriods
|
||||||
|
);
|
||||||
|
anomalies.push(...metricAnomalies);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error analyzing ${metric}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate health score (0-100)
|
||||||
|
const healthScore = this.calculateHealthScore(anomalies);
|
||||||
|
|
||||||
|
// Determine alert level
|
||||||
|
const alertLevel = this.determineAlertLevel(anomalies);
|
||||||
|
|
||||||
|
// Generate summary
|
||||||
|
const summary = this.generateSummary(anomalies, healthScore);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenantId,
|
||||||
|
period,
|
||||||
|
analyzedAt: new Date(),
|
||||||
|
anomalies,
|
||||||
|
healthScore,
|
||||||
|
alertLevel,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze a specific metric for anomalies
|
||||||
|
*/
|
||||||
|
private async analyzeMetric(
|
||||||
|
tenantId: string,
|
||||||
|
metric: MetricType,
|
||||||
|
period: MetricPeriod,
|
||||||
|
historicalPeriods: MetricPeriod[]
|
||||||
|
): Promise<Anomaly[]> {
|
||||||
|
const anomalies: Anomaly[] = [];
|
||||||
|
|
||||||
|
// Get metric history
|
||||||
|
const history = await this.metricsService.getMetricHistory(
|
||||||
|
tenantId,
|
||||||
|
metric,
|
||||||
|
[...historicalPeriods, period]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (history.periods.length < 2) {
|
||||||
|
return anomalies;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = history.periods.map(p => p.value.raw);
|
||||||
|
const currentValue = values[values.length - 1];
|
||||||
|
const previousValue = values[values.length - 2];
|
||||||
|
const historicalValues = values.slice(0, -1);
|
||||||
|
|
||||||
|
// Check significant variation
|
||||||
|
if (this.rules.significantVariation.enabled) {
|
||||||
|
const variation = this.checkSignificantVariation(
|
||||||
|
currentValue,
|
||||||
|
previousValue,
|
||||||
|
this.rules.significantVariation.threshold
|
||||||
|
);
|
||||||
|
if (variation) {
|
||||||
|
anomalies.push(this.createAnomaly(
|
||||||
|
metric,
|
||||||
|
period,
|
||||||
|
'significant_variation',
|
||||||
|
this.rules.significantVariation.severity,
|
||||||
|
currentValue,
|
||||||
|
previousValue,
|
||||||
|
variation.deviation,
|
||||||
|
variation.percentage
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check negative trend
|
||||||
|
if (this.rules.negativeTrend.enabled && historicalValues.length >= 3) {
|
||||||
|
const trend = this.checkNegativeTrend(
|
||||||
|
values,
|
||||||
|
this.rules.negativeTrend.threshold as number
|
||||||
|
);
|
||||||
|
if (trend) {
|
||||||
|
anomalies.push(this.createAnomaly(
|
||||||
|
metric,
|
||||||
|
period,
|
||||||
|
'negative_trend',
|
||||||
|
this.rules.negativeTrend.severity,
|
||||||
|
currentValue,
|
||||||
|
historicalValues[0],
|
||||||
|
trend.deviation,
|
||||||
|
trend.percentage
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check out of range
|
||||||
|
if (this.rules.outOfRange.enabled) {
|
||||||
|
const outOfRange = this.checkOutOfRange(
|
||||||
|
metric,
|
||||||
|
currentValue,
|
||||||
|
historicalValues,
|
||||||
|
this.rules.outOfRange.threshold
|
||||||
|
);
|
||||||
|
if (outOfRange) {
|
||||||
|
anomalies.push(this.createAnomaly(
|
||||||
|
metric,
|
||||||
|
period,
|
||||||
|
'out_of_range',
|
||||||
|
outOfRange.severity,
|
||||||
|
currentValue,
|
||||||
|
outOfRange.expectedValue,
|
||||||
|
outOfRange.deviation,
|
||||||
|
outOfRange.percentage
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check sudden spike
|
||||||
|
if (this.rules.suddenSpike.enabled) {
|
||||||
|
const spike = this.checkSuddenSpike(
|
||||||
|
currentValue,
|
||||||
|
previousValue,
|
||||||
|
this.rules.suddenSpike.threshold
|
||||||
|
);
|
||||||
|
if (spike) {
|
||||||
|
anomalies.push(this.createAnomaly(
|
||||||
|
metric,
|
||||||
|
period,
|
||||||
|
'sudden_spike',
|
||||||
|
this.rules.suddenSpike.severity,
|
||||||
|
currentValue,
|
||||||
|
previousValue,
|
||||||
|
spike.deviation,
|
||||||
|
spike.percentage
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check sudden drop
|
||||||
|
if (this.rules.suddenDrop.enabled) {
|
||||||
|
const drop = this.checkSuddenDrop(
|
||||||
|
currentValue,
|
||||||
|
previousValue,
|
||||||
|
this.rules.suddenDrop.threshold
|
||||||
|
);
|
||||||
|
if (drop) {
|
||||||
|
anomalies.push(this.createAnomaly(
|
||||||
|
metric,
|
||||||
|
period,
|
||||||
|
'sudden_drop',
|
||||||
|
this.rules.suddenDrop.severity,
|
||||||
|
currentValue,
|
||||||
|
previousValue,
|
||||||
|
drop.deviation,
|
||||||
|
drop.percentage
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return anomalies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for significant variation (>threshold)
|
||||||
|
*/
|
||||||
|
private checkSignificantVariation(
|
||||||
|
current: number,
|
||||||
|
previous: number,
|
||||||
|
threshold: number
|
||||||
|
): { deviation: number; percentage: number } | null {
|
||||||
|
if (previous === 0) return null;
|
||||||
|
|
||||||
|
const change = current - previous;
|
||||||
|
const percentage = Math.abs(change / previous);
|
||||||
|
|
||||||
|
if (percentage > threshold) {
|
||||||
|
return {
|
||||||
|
deviation: change,
|
||||||
|
percentage: percentage * 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for negative trend (consecutive decreases)
|
||||||
|
*/
|
||||||
|
private checkNegativeTrend(
|
||||||
|
values: number[],
|
||||||
|
minPeriods: number
|
||||||
|
): { deviation: number; percentage: number } | null {
|
||||||
|
if (values.length < minPeriods) return null;
|
||||||
|
|
||||||
|
let consecutiveDecreases = 0;
|
||||||
|
for (let i = values.length - 1; i > 0; i--) {
|
||||||
|
if (values[i] < values[i - 1]) {
|
||||||
|
consecutiveDecreases++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (consecutiveDecreases >= minPeriods) {
|
||||||
|
const startValue = values[values.length - 1 - consecutiveDecreases];
|
||||||
|
const currentValue = values[values.length - 1];
|
||||||
|
const totalChange = currentValue - startValue;
|
||||||
|
const percentage = startValue !== 0 ? (totalChange / startValue) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviation: totalChange,
|
||||||
|
percentage: Math.abs(percentage),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if value is outside expected range
|
||||||
|
*/
|
||||||
|
private checkOutOfRange(
|
||||||
|
metric: MetricType,
|
||||||
|
current: number,
|
||||||
|
historical: number[],
|
||||||
|
stdDevThreshold: number
|
||||||
|
): { deviation: number; percentage: number; expectedValue: number; severity: AnomalySeverity } | null {
|
||||||
|
// Check against predefined ranges first
|
||||||
|
const range = METRIC_RANGES[metric];
|
||||||
|
if (range) {
|
||||||
|
const value = range.isPercentage ? current / 100 : current;
|
||||||
|
if (value < range.min || value > range.max) {
|
||||||
|
const expectedValue = (range.min + range.max) / 2;
|
||||||
|
const deviation = value - expectedValue;
|
||||||
|
return {
|
||||||
|
deviation,
|
||||||
|
percentage: Math.abs(deviation / expectedValue) * 100,
|
||||||
|
expectedValue: range.isPercentage ? expectedValue * 100 : expectedValue,
|
||||||
|
severity: value < range.min * 0.5 || value > range.max * 1.5 ? 'critical' : 'high',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check against historical standard deviation
|
||||||
|
if (historical.length < 3) return null;
|
||||||
|
|
||||||
|
const mean = historical.reduce((sum, v) => sum + v, 0) / historical.length;
|
||||||
|
const squaredDiffs = historical.map(v => Math.pow(v - mean, 2));
|
||||||
|
const stdDev = Math.sqrt(squaredDiffs.reduce((sum, d) => sum + d, 0) / historical.length);
|
||||||
|
|
||||||
|
if (stdDev === 0) return null;
|
||||||
|
|
||||||
|
const zScore = Math.abs((current - mean) / stdDev);
|
||||||
|
|
||||||
|
if (zScore > stdDevThreshold) {
|
||||||
|
return {
|
||||||
|
deviation: current - mean,
|
||||||
|
percentage: (Math.abs(current - mean) / mean) * 100,
|
||||||
|
expectedValue: mean,
|
||||||
|
severity: zScore > 3 ? 'critical' : 'high',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for sudden spike (rapid increase)
|
||||||
|
*/
|
||||||
|
private checkSuddenSpike(
|
||||||
|
current: number,
|
||||||
|
previous: number,
|
||||||
|
threshold: number
|
||||||
|
): { deviation: number; percentage: number } | null {
|
||||||
|
if (previous === 0 || previous < 0) return null;
|
||||||
|
|
||||||
|
const increase = current - previous;
|
||||||
|
const percentage = increase / previous;
|
||||||
|
|
||||||
|
if (percentage > threshold && increase > 0) {
|
||||||
|
return {
|
||||||
|
deviation: increase,
|
||||||
|
percentage: percentage * 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for sudden drop (rapid decrease)
|
||||||
|
*/
|
||||||
|
private checkSuddenDrop(
|
||||||
|
current: number,
|
||||||
|
previous: number,
|
||||||
|
threshold: number
|
||||||
|
): { deviation: number; percentage: number } | null {
|
||||||
|
if (previous === 0) return null;
|
||||||
|
|
||||||
|
const decrease = previous - current;
|
||||||
|
const percentage = decrease / Math.abs(previous);
|
||||||
|
|
||||||
|
if (percentage > threshold && decrease > 0) {
|
||||||
|
return {
|
||||||
|
deviation: -decrease,
|
||||||
|
percentage: percentage * 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an anomaly object
|
||||||
|
*/
|
||||||
|
private createAnomaly(
|
||||||
|
metric: MetricType,
|
||||||
|
period: MetricPeriod,
|
||||||
|
type: AnomalyType,
|
||||||
|
severity: AnomalySeverity,
|
||||||
|
currentValue: number,
|
||||||
|
expectedValue: number,
|
||||||
|
deviation: number,
|
||||||
|
deviationPercentage: number
|
||||||
|
): Anomaly {
|
||||||
|
return {
|
||||||
|
id: uuidv4(),
|
||||||
|
metric,
|
||||||
|
type,
|
||||||
|
severity,
|
||||||
|
description: this.getAnomalyDescription(metric, type, deviationPercentage),
|
||||||
|
detectedAt: new Date(),
|
||||||
|
period,
|
||||||
|
currentValue,
|
||||||
|
expectedValue,
|
||||||
|
deviation,
|
||||||
|
deviationPercentage,
|
||||||
|
recommendation: this.getAnomalyRecommendation(metric, type, deviationPercentage),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable description for an anomaly
|
||||||
|
*/
|
||||||
|
private getAnomalyDescription(
|
||||||
|
metric: MetricType,
|
||||||
|
type: AnomalyType,
|
||||||
|
percentage: number
|
||||||
|
): string {
|
||||||
|
const metricNames: Record<MetricType, string> = {
|
||||||
|
revenue: 'Ingresos',
|
||||||
|
expenses: 'Gastos',
|
||||||
|
gross_profit: 'Utilidad Bruta',
|
||||||
|
net_profit: 'Utilidad Neta',
|
||||||
|
cash_flow: 'Flujo de Efectivo',
|
||||||
|
accounts_receivable: 'Cuentas por Cobrar',
|
||||||
|
accounts_payable: 'Cuentas por Pagar',
|
||||||
|
aging_receivable: 'Antiguedad de Cuentas por Cobrar',
|
||||||
|
aging_payable: 'Antiguedad de Cuentas por Pagar',
|
||||||
|
vat_position: 'Posicion de IVA',
|
||||||
|
mrr: 'MRR',
|
||||||
|
arr: 'ARR',
|
||||||
|
churn_rate: 'Tasa de Churn',
|
||||||
|
cac: 'CAC',
|
||||||
|
ltv: 'LTV',
|
||||||
|
ltv_cac_ratio: 'Ratio LTV/CAC',
|
||||||
|
runway: 'Runway',
|
||||||
|
burn_rate: 'Burn Rate',
|
||||||
|
ebitda: 'EBITDA',
|
||||||
|
roi: 'ROI',
|
||||||
|
roe: 'ROE',
|
||||||
|
current_ratio: 'Ratio Corriente',
|
||||||
|
quick_ratio: 'Prueba Acida',
|
||||||
|
debt_ratio: 'Ratio de Deuda',
|
||||||
|
};
|
||||||
|
|
||||||
|
const metricName = metricNames[metric] || metric;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'significant_variation':
|
||||||
|
return `${metricName} mostro una variacion significativa del ${percentage.toFixed(1)}% respecto al periodo anterior.`;
|
||||||
|
case 'negative_trend':
|
||||||
|
return `${metricName} ha mostrado una tendencia negativa durante los ultimos 3+ periodos, con una caida acumulada del ${percentage.toFixed(1)}%.`;
|
||||||
|
case 'out_of_range':
|
||||||
|
return `${metricName} esta fuera del rango esperado, desviandose ${percentage.toFixed(1)}% del valor tipico.`;
|
||||||
|
case 'sudden_spike':
|
||||||
|
return `${metricName} experimento un incremento repentino del ${percentage.toFixed(1)}%.`;
|
||||||
|
case 'sudden_drop':
|
||||||
|
return `${metricName} experimento una caida repentina del ${percentage.toFixed(1)}%.`;
|
||||||
|
default:
|
||||||
|
return `Anomalia detectada en ${metricName}.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recommendation for an anomaly
|
||||||
|
*/
|
||||||
|
private getAnomalyRecommendation(
|
||||||
|
metric: MetricType,
|
||||||
|
type: AnomalyType,
|
||||||
|
percentage: number
|
||||||
|
): string {
|
||||||
|
// Metric-specific recommendations
|
||||||
|
const recommendations: Partial<Record<MetricType, Record<AnomalyType, string>>> = {
|
||||||
|
revenue: {
|
||||||
|
sudden_drop: 'Revisa si hubo problemas con facturacion, clientes perdidos o estacionalidad. Considera contactar a clientes principales.',
|
||||||
|
negative_trend: 'Evalua tu estrategia de ventas y marketing. Considera revisar precios o expandir a nuevos mercados.',
|
||||||
|
},
|
||||||
|
expenses: {
|
||||||
|
sudden_spike: 'Revisa gastos recientes para identificar la causa. Verifica si son gastos unicos o recurrentes.',
|
||||||
|
significant_variation: 'Analiza las categorias de gastos que mas contribuyeron al cambio.',
|
||||||
|
},
|
||||||
|
cash_flow: {
|
||||||
|
sudden_drop: 'Revisa cuentas por cobrar vencidas y acelera la cobranza. Considera renegociar terminos con proveedores.',
|
||||||
|
negative_trend: 'Mejora la gestion de capital de trabajo. Evalua opciones de financiamiento.',
|
||||||
|
},
|
||||||
|
churn_rate: {
|
||||||
|
out_of_range: 'Implementa encuestas de salida con clientes. Revisa el proceso de onboarding y soporte.',
|
||||||
|
sudden_spike: 'Contacta urgentemente a clientes en riesgo. Revisa cambios recientes en producto o servicio.',
|
||||||
|
},
|
||||||
|
current_ratio: {
|
||||||
|
out_of_range: 'Mejora la gestion de activos corrientes o reduce pasivos a corto plazo.',
|
||||||
|
},
|
||||||
|
burn_rate: {
|
||||||
|
sudden_spike: 'Revisa y optimiza gastos operativos. Prioriza inversiones criticas.',
|
||||||
|
significant_variation: 'Ajusta el presupuesto y considera reducir gastos no esenciales.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const metricRecs = recommendations[metric];
|
||||||
|
if (metricRecs && metricRecs[type]) {
|
||||||
|
return metricRecs[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default recommendations by type
|
||||||
|
switch (type) {
|
||||||
|
case 'significant_variation':
|
||||||
|
return 'Investiga las causas de la variacion y determina si requiere accion correctiva.';
|
||||||
|
case 'negative_trend':
|
||||||
|
return 'Analiza los factores que contribuyen a la tendencia negativa y desarrolla un plan de accion.';
|
||||||
|
case 'out_of_range':
|
||||||
|
return 'Revisa si el valor esta justificado por circunstancias especiales o requiere correccion.';
|
||||||
|
case 'sudden_spike':
|
||||||
|
return 'Verifica si el incremento es sostenible o si indica un problema subyacente.';
|
||||||
|
case 'sudden_drop':
|
||||||
|
return 'Investiga la causa de la caida y toma acciones correctivas si es necesario.';
|
||||||
|
default:
|
||||||
|
return 'Revisa la metrica y toma las acciones necesarias.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate overall health score (0-100)
|
||||||
|
*/
|
||||||
|
private calculateHealthScore(anomalies: Anomaly[]): number {
|
||||||
|
if (anomalies.length === 0) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const severityWeights: Record<AnomalySeverity, number> = {
|
||||||
|
low: 5,
|
||||||
|
medium: 15,
|
||||||
|
high: 25,
|
||||||
|
critical: 40,
|
||||||
|
};
|
||||||
|
|
||||||
|
let totalPenalty = 0;
|
||||||
|
for (const anomaly of anomalies) {
|
||||||
|
totalPenalty += severityWeights[anomaly.severity];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, 100 - totalPenalty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine alert level based on anomalies
|
||||||
|
*/
|
||||||
|
private determineAlertLevel(anomalies: Anomaly[]): AnomalyDetectionResult['alertLevel'] {
|
||||||
|
if (anomalies.length === 0) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCritical = anomalies.some(a => a.severity === 'critical');
|
||||||
|
const hasHigh = anomalies.some(a => a.severity === 'high');
|
||||||
|
const highCount = anomalies.filter(a => a.severity === 'high' || a.severity === 'critical').length;
|
||||||
|
|
||||||
|
if (hasCritical || highCount >= 3) {
|
||||||
|
return 'critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasHigh || anomalies.length >= 3) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'watch';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate summary of anomalies
|
||||||
|
*/
|
||||||
|
private generateSummary(anomalies: Anomaly[], healthScore: number): string {
|
||||||
|
if (anomalies.length === 0) {
|
||||||
|
return 'No se detectaron anomalias. Todas las metricas estan dentro de los rangos esperados.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const criticalCount = anomalies.filter(a => a.severity === 'critical').length;
|
||||||
|
const highCount = anomalies.filter(a => a.severity === 'high').length;
|
||||||
|
const mediumCount = anomalies.filter(a => a.severity === 'medium').length;
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
parts.push(`Se detectaron ${anomalies.length} anomalias.`);
|
||||||
|
|
||||||
|
if (criticalCount > 0) {
|
||||||
|
parts.push(`${criticalCount} criticas.`);
|
||||||
|
}
|
||||||
|
if (highCount > 0) {
|
||||||
|
parts.push(`${highCount} de alta prioridad.`);
|
||||||
|
}
|
||||||
|
if (mediumCount > 0) {
|
||||||
|
parts.push(`${mediumCount} de prioridad media.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(`Puntuacion de salud: ${healthScore}/100.`);
|
||||||
|
|
||||||
|
// Add most critical anomaly
|
||||||
|
const mostCritical = anomalies.sort((a, b) => {
|
||||||
|
const order: Record<AnomalySeverity, number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||||
|
return order[a.severity] - order[b.severity];
|
||||||
|
})[0];
|
||||||
|
|
||||||
|
if (mostCritical) {
|
||||||
|
parts.push(`Prioridad: ${mostCritical.description}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get historical periods for analysis
|
||||||
|
*/
|
||||||
|
private getHistoricalPeriods(period: MetricPeriod, count: number): MetricPeriod[] {
|
||||||
|
const periods: MetricPeriod[] = [];
|
||||||
|
let currentPeriod = period;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
currentPeriod = getPreviousPeriod(currentPeriod);
|
||||||
|
periods.unshift(currentPeriod);
|
||||||
|
}
|
||||||
|
|
||||||
|
return periods;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update detection rules
|
||||||
|
*/
|
||||||
|
updateRules(rules: Partial<MetricRules>): void {
|
||||||
|
this.rules = { ...this.rules, ...rules };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current rules configuration
|
||||||
|
*/
|
||||||
|
getRules(): MetricRules {
|
||||||
|
return { ...this.rules };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factory functions
|
||||||
|
let anomalyDetectorInstance: AnomalyDetector | null = null;
|
||||||
|
|
||||||
|
export function getAnomalyDetector(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
metricsService?: MetricsService,
|
||||||
|
rules?: Partial<MetricRules>
|
||||||
|
): AnomalyDetector {
|
||||||
|
if (!anomalyDetectorInstance) {
|
||||||
|
anomalyDetectorInstance = new AnomalyDetector(db, metricsService, rules);
|
||||||
|
}
|
||||||
|
return anomalyDetectorInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAnomalyDetector(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
metricsService?: MetricsService,
|
||||||
|
rules?: Partial<MetricRules>
|
||||||
|
): AnomalyDetector {
|
||||||
|
return new AnomalyDetector(db, metricsService, rules);
|
||||||
|
}
|
||||||
891
apps/api/src/services/metrics/core.metrics.ts
Normal file
891
apps/api/src/services/metrics/core.metrics.ts
Normal file
@@ -0,0 +1,891 @@
|
|||||||
|
/**
|
||||||
|
* Core Metrics Calculator
|
||||||
|
*
|
||||||
|
* Fundamental financial metrics for any business:
|
||||||
|
* - Revenue, Expenses, Profit calculations
|
||||||
|
* - Cash Flow analysis
|
||||||
|
* - Accounts Receivable/Payable
|
||||||
|
* - Aging Reports
|
||||||
|
* - VAT Position
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DatabaseConnection, TenantContext } from '@horux/database';
|
||||||
|
import {
|
||||||
|
RevenueResult,
|
||||||
|
ExpensesResult,
|
||||||
|
ProfitResult,
|
||||||
|
CashFlowResult,
|
||||||
|
AccountsReceivableResult,
|
||||||
|
AccountsPayableResult,
|
||||||
|
AgingReportResult,
|
||||||
|
AgingBucket,
|
||||||
|
AgingBucketData,
|
||||||
|
VATPositionResult,
|
||||||
|
createMonetaryValue,
|
||||||
|
createPercentageValue,
|
||||||
|
MetricQueryOptions,
|
||||||
|
} from './metrics.types';
|
||||||
|
|
||||||
|
export class CoreMetricsCalculator {
|
||||||
|
private db: DatabaseConnection;
|
||||||
|
|
||||||
|
constructor(db: DatabaseConnection) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tenant context for queries
|
||||||
|
*/
|
||||||
|
private getTenantContext(tenantId: string, schemaName?: string): TenantContext {
|
||||||
|
return {
|
||||||
|
tenantId,
|
||||||
|
schemaName: schemaName || `tenant_${tenantId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total revenue for a period
|
||||||
|
*/
|
||||||
|
async calculateRevenue(
|
||||||
|
tenantId: string,
|
||||||
|
dateFrom: Date,
|
||||||
|
dateTo: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<RevenueResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
// Get total revenue from invoices
|
||||||
|
const totalQuery = await this.db.query<{
|
||||||
|
total_revenue: string;
|
||||||
|
invoice_count: string;
|
||||||
|
avg_invoice_value: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(total_amount), 0) as total_revenue,
|
||||||
|
COUNT(*) as invoice_count,
|
||||||
|
COALESCE(AVG(total_amount), 0) as avg_invoice_value
|
||||||
|
FROM invoices
|
||||||
|
WHERE status IN ('paid', 'partial')
|
||||||
|
AND issue_date >= $1
|
||||||
|
AND issue_date <= $2`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get revenue by category
|
||||||
|
const byCategoryQuery = await this.db.query<{
|
||||||
|
category_id: string;
|
||||||
|
category_name: string;
|
||||||
|
amount: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(c.id, 'uncategorized') as category_id,
|
||||||
|
COALESCE(c.name, 'Sin categorizar') as category_name,
|
||||||
|
SUM(il.total) as amount
|
||||||
|
FROM invoice_lines il
|
||||||
|
JOIN invoices i ON i.id = il.invoice_id
|
||||||
|
LEFT JOIN products p ON p.id = il.product_id
|
||||||
|
LEFT JOIN categories c ON c.id = p.category_id
|
||||||
|
WHERE i.status IN ('paid', 'partial')
|
||||||
|
AND i.issue_date >= $1
|
||||||
|
AND i.issue_date <= $2
|
||||||
|
GROUP BY c.id, c.name
|
||||||
|
ORDER BY amount DESC`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get revenue by product (optional)
|
||||||
|
let byProduct: RevenueResult['byProduct'] = undefined;
|
||||||
|
if (options?.includeDetails) {
|
||||||
|
const byProductQuery = await this.db.query<{
|
||||||
|
product_id: string;
|
||||||
|
product_name: string;
|
||||||
|
amount: string;
|
||||||
|
quantity: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(p.id, 'service') as product_id,
|
||||||
|
COALESCE(p.name, il.description) as product_name,
|
||||||
|
SUM(il.total) as amount,
|
||||||
|
SUM(il.quantity) as quantity
|
||||||
|
FROM invoice_lines il
|
||||||
|
JOIN invoices i ON i.id = il.invoice_id
|
||||||
|
LEFT JOIN products p ON p.id = il.product_id
|
||||||
|
WHERE i.status IN ('paid', 'partial')
|
||||||
|
AND i.issue_date >= $1
|
||||||
|
AND i.issue_date <= $2
|
||||||
|
GROUP BY p.id, p.name, il.description
|
||||||
|
ORDER BY amount DESC
|
||||||
|
LIMIT 20`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
byProduct = byProductQuery.rows.map(row => ({
|
||||||
|
productId: row.product_id,
|
||||||
|
productName: row.product_name,
|
||||||
|
amount: createMonetaryValue(parseFloat(row.amount), currency),
|
||||||
|
quantity: parseFloat(row.quantity),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRevenue = parseFloat(totalQuery.rows[0]?.total_revenue || '0');
|
||||||
|
const invoiceCount = parseInt(totalQuery.rows[0]?.invoice_count || '0');
|
||||||
|
const avgInvoiceValue = parseFloat(totalQuery.rows[0]?.avg_invoice_value || '0');
|
||||||
|
|
||||||
|
const byCategory = byCategoryQuery.rows.map(row => {
|
||||||
|
const amount = parseFloat(row.amount);
|
||||||
|
return {
|
||||||
|
categoryId: row.category_id,
|
||||||
|
categoryName: row.category_name,
|
||||||
|
amount: createMonetaryValue(amount, currency),
|
||||||
|
percentage: totalRevenue > 0 ? (amount / totalRevenue) * 100 : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalRevenue: createMonetaryValue(totalRevenue, currency),
|
||||||
|
byCategory,
|
||||||
|
byProduct,
|
||||||
|
invoiceCount,
|
||||||
|
averageInvoiceValue: createMonetaryValue(avgInvoiceValue, currency),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total expenses for a period
|
||||||
|
*/
|
||||||
|
async calculateExpenses(
|
||||||
|
tenantId: string,
|
||||||
|
dateFrom: Date,
|
||||||
|
dateTo: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<ExpensesResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
// Get total expenses
|
||||||
|
const totalQuery = await this.db.query<{
|
||||||
|
total_expenses: string;
|
||||||
|
expense_count: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(total_amount), 0) as total_expenses,
|
||||||
|
COUNT(*) as expense_count
|
||||||
|
FROM expenses
|
||||||
|
WHERE status = 'paid'
|
||||||
|
AND expense_date >= $1
|
||||||
|
AND expense_date <= $2`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get expenses by category
|
||||||
|
const byCategoryQuery = await this.db.query<{
|
||||||
|
category_id: string;
|
||||||
|
category_name: string;
|
||||||
|
amount: string;
|
||||||
|
is_fixed: boolean;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(c.id, 'uncategorized') as category_id,
|
||||||
|
COALESCE(c.name, 'Sin categorizar') as category_name,
|
||||||
|
SUM(e.total_amount) as amount,
|
||||||
|
COALESCE(c.is_fixed, false) as is_fixed
|
||||||
|
FROM expenses e
|
||||||
|
LEFT JOIN categories c ON c.id = e.category_id
|
||||||
|
WHERE e.status = 'paid'
|
||||||
|
AND e.expense_date >= $1
|
||||||
|
AND e.expense_date <= $2
|
||||||
|
GROUP BY c.id, c.name, c.is_fixed
|
||||||
|
ORDER BY amount DESC`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalExpenses = parseFloat(totalQuery.rows[0]?.total_expenses || '0');
|
||||||
|
const expenseCount = parseInt(totalQuery.rows[0]?.expense_count || '0');
|
||||||
|
|
||||||
|
let fixedExpenses = 0;
|
||||||
|
let variableExpenses = 0;
|
||||||
|
|
||||||
|
const byCategory = byCategoryQuery.rows.map(row => {
|
||||||
|
const amount = parseFloat(row.amount);
|
||||||
|
if (row.is_fixed) {
|
||||||
|
fixedExpenses += amount;
|
||||||
|
} else {
|
||||||
|
variableExpenses += amount;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
categoryId: row.category_id,
|
||||||
|
categoryName: row.category_name,
|
||||||
|
amount: createMonetaryValue(amount, currency),
|
||||||
|
percentage: totalExpenses > 0 ? (amount / totalExpenses) * 100 : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalExpenses: createMonetaryValue(totalExpenses, currency),
|
||||||
|
byCategory,
|
||||||
|
fixedExpenses: createMonetaryValue(fixedExpenses, currency),
|
||||||
|
variableExpenses: createMonetaryValue(variableExpenses, currency),
|
||||||
|
expenseCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate gross profit (Revenue - COGS)
|
||||||
|
*/
|
||||||
|
async calculateGrossProfit(
|
||||||
|
tenantId: string,
|
||||||
|
dateFrom: Date,
|
||||||
|
dateTo: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<ProfitResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
const query = await this.db.query<{
|
||||||
|
revenue: string;
|
||||||
|
cogs: string;
|
||||||
|
}>(
|
||||||
|
`WITH revenue AS (
|
||||||
|
SELECT COALESCE(SUM(total_amount), 0) as amount
|
||||||
|
FROM invoices
|
||||||
|
WHERE status IN ('paid', 'partial')
|
||||||
|
AND issue_date >= $1
|
||||||
|
AND issue_date <= $2
|
||||||
|
),
|
||||||
|
cogs AS (
|
||||||
|
SELECT COALESCE(SUM(e.total_amount), 0) as amount
|
||||||
|
FROM expenses e
|
||||||
|
JOIN categories c ON c.id = e.category_id
|
||||||
|
WHERE e.status = 'paid'
|
||||||
|
AND e.expense_date >= $1
|
||||||
|
AND e.expense_date <= $2
|
||||||
|
AND c.type = 'cogs'
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT amount FROM revenue) as revenue,
|
||||||
|
(SELECT amount FROM cogs) as cogs`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
const revenue = parseFloat(query.rows[0]?.revenue || '0');
|
||||||
|
const costs = parseFloat(query.rows[0]?.cogs || '0');
|
||||||
|
const profit = revenue - costs;
|
||||||
|
const margin = revenue > 0 ? profit / revenue : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
profit: createMonetaryValue(profit, currency),
|
||||||
|
revenue: createMonetaryValue(revenue, currency),
|
||||||
|
costs: createMonetaryValue(costs, currency),
|
||||||
|
margin: createPercentageValue(margin),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate net profit (Revenue - All Expenses)
|
||||||
|
*/
|
||||||
|
async calculateNetProfit(
|
||||||
|
tenantId: string,
|
||||||
|
dateFrom: Date,
|
||||||
|
dateTo: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<ProfitResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
const query = await this.db.query<{
|
||||||
|
revenue: string;
|
||||||
|
expenses: string;
|
||||||
|
}>(
|
||||||
|
`WITH revenue AS (
|
||||||
|
SELECT COALESCE(SUM(total_amount), 0) as amount
|
||||||
|
FROM invoices
|
||||||
|
WHERE status IN ('paid', 'partial')
|
||||||
|
AND issue_date >= $1
|
||||||
|
AND issue_date <= $2
|
||||||
|
),
|
||||||
|
expenses AS (
|
||||||
|
SELECT COALESCE(SUM(total_amount), 0) as amount
|
||||||
|
FROM expenses
|
||||||
|
WHERE status = 'paid'
|
||||||
|
AND expense_date >= $1
|
||||||
|
AND expense_date <= $2
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT amount FROM revenue) as revenue,
|
||||||
|
(SELECT amount FROM expenses) as expenses`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
const revenue = parseFloat(query.rows[0]?.revenue || '0');
|
||||||
|
const costs = parseFloat(query.rows[0]?.expenses || '0');
|
||||||
|
const profit = revenue - costs;
|
||||||
|
const margin = revenue > 0 ? profit / revenue : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
profit: createMonetaryValue(profit, currency),
|
||||||
|
revenue: createMonetaryValue(revenue, currency),
|
||||||
|
costs: createMonetaryValue(costs, currency),
|
||||||
|
margin: createPercentageValue(margin),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate cash flow for a period
|
||||||
|
*/
|
||||||
|
async calculateCashFlow(
|
||||||
|
tenantId: string,
|
||||||
|
dateFrom: Date,
|
||||||
|
dateTo: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<CashFlowResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
// Get opening balance
|
||||||
|
const openingQuery = await this.db.query<{ balance: string }>(
|
||||||
|
`SELECT COALESCE(SUM(balance), 0) as balance
|
||||||
|
FROM bank_accounts
|
||||||
|
WHERE is_active = true`,
|
||||||
|
[],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get cash inflows (payments received)
|
||||||
|
const inflowQuery = await this.db.query<{
|
||||||
|
operating: string;
|
||||||
|
investing: string;
|
||||||
|
financing: string;
|
||||||
|
}>(
|
||||||
|
`WITH payments AS (
|
||||||
|
SELECT
|
||||||
|
p.amount,
|
||||||
|
COALESCE(c.cash_flow_type, 'operating') as flow_type
|
||||||
|
FROM payments p
|
||||||
|
LEFT JOIN categories c ON c.id = p.category_id
|
||||||
|
WHERE p.type = 'income'
|
||||||
|
AND p.payment_date >= $1
|
||||||
|
AND p.payment_date <= $2
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN flow_type = 'operating' THEN amount END), 0) as operating,
|
||||||
|
COALESCE(SUM(CASE WHEN flow_type = 'investing' THEN amount END), 0) as investing,
|
||||||
|
COALESCE(SUM(CASE WHEN flow_type = 'financing' THEN amount END), 0) as financing
|
||||||
|
FROM payments`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get cash outflows (payments made)
|
||||||
|
const outflowQuery = await this.db.query<{
|
||||||
|
operating: string;
|
||||||
|
investing: string;
|
||||||
|
financing: string;
|
||||||
|
}>(
|
||||||
|
`WITH payments AS (
|
||||||
|
SELECT
|
||||||
|
p.amount,
|
||||||
|
COALESCE(c.cash_flow_type, 'operating') as flow_type
|
||||||
|
FROM payments p
|
||||||
|
LEFT JOIN categories c ON c.id = p.category_id
|
||||||
|
WHERE p.type = 'expense'
|
||||||
|
AND p.payment_date >= $1
|
||||||
|
AND p.payment_date <= $2
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN flow_type = 'operating' THEN amount END), 0) as operating,
|
||||||
|
COALESCE(SUM(CASE WHEN flow_type = 'investing' THEN amount END), 0) as investing,
|
||||||
|
COALESCE(SUM(CASE WHEN flow_type = 'financing' THEN amount END), 0) as financing
|
||||||
|
FROM payments`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get daily breakdown
|
||||||
|
const breakdownQuery = await this.db.query<{
|
||||||
|
date: Date;
|
||||||
|
inflow: string;
|
||||||
|
outflow: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
DATE(p.payment_date) as date,
|
||||||
|
COALESCE(SUM(CASE WHEN p.type = 'income' THEN p.amount ELSE 0 END), 0) as inflow,
|
||||||
|
COALESCE(SUM(CASE WHEN p.type = 'expense' THEN p.amount ELSE 0 END), 0) as outflow
|
||||||
|
FROM payments p
|
||||||
|
WHERE p.payment_date >= $1
|
||||||
|
AND p.payment_date <= $2
|
||||||
|
GROUP BY DATE(p.payment_date)
|
||||||
|
ORDER BY date`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
const inflow = inflowQuery.rows[0];
|
||||||
|
const outflow = outflowQuery.rows[0];
|
||||||
|
|
||||||
|
const operatingInflow = parseFloat(inflow?.operating || '0');
|
||||||
|
const investingInflow = parseFloat(inflow?.investing || '0');
|
||||||
|
const financingInflow = parseFloat(inflow?.financing || '0');
|
||||||
|
|
||||||
|
const operatingOutflow = parseFloat(outflow?.operating || '0');
|
||||||
|
const investingOutflow = parseFloat(outflow?.investing || '0');
|
||||||
|
const financingOutflow = parseFloat(outflow?.financing || '0');
|
||||||
|
|
||||||
|
const operatingActivities = operatingInflow - operatingOutflow;
|
||||||
|
const investingActivities = investingInflow - investingOutflow;
|
||||||
|
const financingActivities = financingInflow - financingOutflow;
|
||||||
|
|
||||||
|
const netCashFlow = operatingActivities + investingActivities + financingActivities;
|
||||||
|
const openingBalance = parseFloat(openingQuery.rows[0]?.balance || '0');
|
||||||
|
const closingBalance = openingBalance + netCashFlow;
|
||||||
|
|
||||||
|
// Build breakdown with running balance
|
||||||
|
let runningBalance = openingBalance;
|
||||||
|
const breakdown = breakdownQuery.rows.map(row => {
|
||||||
|
const inflowAmount = parseFloat(row.inflow);
|
||||||
|
const outflowAmount = parseFloat(row.outflow);
|
||||||
|
const netFlow = inflowAmount - outflowAmount;
|
||||||
|
runningBalance += netFlow;
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: row.date,
|
||||||
|
inflow: inflowAmount,
|
||||||
|
outflow: outflowAmount,
|
||||||
|
netFlow,
|
||||||
|
balance: runningBalance,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
netCashFlow: createMonetaryValue(netCashFlow, currency),
|
||||||
|
operatingActivities: createMonetaryValue(operatingActivities, currency),
|
||||||
|
investingActivities: createMonetaryValue(investingActivities, currency),
|
||||||
|
financingActivities: createMonetaryValue(financingActivities, currency),
|
||||||
|
openingBalance: createMonetaryValue(openingBalance, currency),
|
||||||
|
closingBalance: createMonetaryValue(closingBalance, currency),
|
||||||
|
breakdown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate accounts receivable as of a specific date
|
||||||
|
*/
|
||||||
|
async calculateAccountsReceivable(
|
||||||
|
tenantId: string,
|
||||||
|
asOfDate: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<AccountsReceivableResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
const query = await this.db.query<{
|
||||||
|
total_receivable: string;
|
||||||
|
current_amount: string;
|
||||||
|
overdue_amount: string;
|
||||||
|
customer_count: string;
|
||||||
|
invoice_count: string;
|
||||||
|
avg_days_outstanding: string;
|
||||||
|
}>(
|
||||||
|
`WITH receivables AS (
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
i.customer_id,
|
||||||
|
i.total_amount - COALESCE(
|
||||||
|
(SELECT SUM(p.amount) FROM payments p WHERE p.invoice_id = i.id),
|
||||||
|
0
|
||||||
|
) as balance,
|
||||||
|
i.due_date,
|
||||||
|
EXTRACT(DAY FROM ($1::date - i.issue_date)) as days_outstanding
|
||||||
|
FROM invoices i
|
||||||
|
WHERE i.status IN ('sent', 'partial')
|
||||||
|
AND i.issue_date <= $1
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(balance), 0) as total_receivable,
|
||||||
|
COALESCE(SUM(CASE WHEN due_date >= $1 THEN balance ELSE 0 END), 0) as current_amount,
|
||||||
|
COALESCE(SUM(CASE WHEN due_date < $1 THEN balance ELSE 0 END), 0) as overdue_amount,
|
||||||
|
COUNT(DISTINCT customer_id) as customer_count,
|
||||||
|
COUNT(*) as invoice_count,
|
||||||
|
COALESCE(AVG(days_outstanding), 0) as avg_days_outstanding
|
||||||
|
FROM receivables
|
||||||
|
WHERE balance > 0`,
|
||||||
|
[asOfDate],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = query.rows[0];
|
||||||
|
const totalReceivable = parseFloat(row?.total_receivable || '0');
|
||||||
|
const current = parseFloat(row?.current_amount || '0');
|
||||||
|
const overdue = parseFloat(row?.overdue_amount || '0');
|
||||||
|
const customerCount = parseInt(row?.customer_count || '0');
|
||||||
|
const invoiceCount = parseInt(row?.invoice_count || '0');
|
||||||
|
const avgDaysOutstanding = parseFloat(row?.avg_days_outstanding || '0');
|
||||||
|
|
||||||
|
const overduePercentage = totalReceivable > 0 ? overdue / totalReceivable : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalReceivable: createMonetaryValue(totalReceivable, currency),
|
||||||
|
current: createMonetaryValue(current, currency),
|
||||||
|
overdue: createMonetaryValue(overdue, currency),
|
||||||
|
overduePercentage: createPercentageValue(overduePercentage),
|
||||||
|
customerCount,
|
||||||
|
invoiceCount,
|
||||||
|
averageDaysOutstanding: Math.round(avgDaysOutstanding),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate accounts payable as of a specific date
|
||||||
|
*/
|
||||||
|
async calculateAccountsPayable(
|
||||||
|
tenantId: string,
|
||||||
|
asOfDate: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<AccountsPayableResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
const query = await this.db.query<{
|
||||||
|
total_payable: string;
|
||||||
|
current_amount: string;
|
||||||
|
overdue_amount: string;
|
||||||
|
supplier_count: string;
|
||||||
|
invoice_count: string;
|
||||||
|
avg_days_payable: string;
|
||||||
|
}>(
|
||||||
|
`WITH payables AS (
|
||||||
|
SELECT
|
||||||
|
b.id,
|
||||||
|
b.supplier_id,
|
||||||
|
b.total_amount - COALESCE(
|
||||||
|
(SELECT SUM(p.amount) FROM payments p WHERE p.bill_id = b.id),
|
||||||
|
0
|
||||||
|
) as balance,
|
||||||
|
b.due_date,
|
||||||
|
EXTRACT(DAY FROM ($1::date - b.issue_date)) as days_payable
|
||||||
|
FROM bills b
|
||||||
|
WHERE b.status IN ('pending', 'partial')
|
||||||
|
AND b.issue_date <= $1
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(balance), 0) as total_payable,
|
||||||
|
COALESCE(SUM(CASE WHEN due_date >= $1 THEN balance ELSE 0 END), 0) as current_amount,
|
||||||
|
COALESCE(SUM(CASE WHEN due_date < $1 THEN balance ELSE 0 END), 0) as overdue_amount,
|
||||||
|
COUNT(DISTINCT supplier_id) as supplier_count,
|
||||||
|
COUNT(*) as invoice_count,
|
||||||
|
COALESCE(AVG(days_payable), 0) as avg_days_payable
|
||||||
|
FROM payables
|
||||||
|
WHERE balance > 0`,
|
||||||
|
[asOfDate],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = query.rows[0];
|
||||||
|
const totalPayable = parseFloat(row?.total_payable || '0');
|
||||||
|
const current = parseFloat(row?.current_amount || '0');
|
||||||
|
const overdue = parseFloat(row?.overdue_amount || '0');
|
||||||
|
const supplierCount = parseInt(row?.supplier_count || '0');
|
||||||
|
const invoiceCount = parseInt(row?.invoice_count || '0');
|
||||||
|
const avgDaysPayable = parseFloat(row?.avg_days_payable || '0');
|
||||||
|
|
||||||
|
const overduePercentage = totalPayable > 0 ? overdue / totalPayable : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalPayable: createMonetaryValue(totalPayable, currency),
|
||||||
|
current: createMonetaryValue(current, currency),
|
||||||
|
overdue: createMonetaryValue(overdue, currency),
|
||||||
|
overduePercentage: createPercentageValue(overduePercentage),
|
||||||
|
supplierCount,
|
||||||
|
invoiceCount,
|
||||||
|
averageDaysPayable: Math.round(avgDaysPayable),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate aging report for receivables or payables
|
||||||
|
*/
|
||||||
|
async calculateAgingReport(
|
||||||
|
tenantId: string,
|
||||||
|
type: 'receivable' | 'payable',
|
||||||
|
asOfDate: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<AgingReportResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
const table = type === 'receivable' ? 'invoices' : 'bills';
|
||||||
|
const entityField = type === 'receivable' ? 'customer_id' : 'supplier_id';
|
||||||
|
const entityTable = type === 'receivable' ? 'customers' : 'suppliers';
|
||||||
|
const statusFilter = type === 'receivable'
|
||||||
|
? `status IN ('sent', 'partial')`
|
||||||
|
: `status IN ('pending', 'partial')`;
|
||||||
|
const paymentField = type === 'receivable' ? 'invoice_id' : 'bill_id';
|
||||||
|
|
||||||
|
// Get aging buckets
|
||||||
|
const bucketsQuery = await this.db.query<{
|
||||||
|
bucket: string;
|
||||||
|
amount: string;
|
||||||
|
count: string;
|
||||||
|
}>(
|
||||||
|
`WITH items AS (
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.total_amount - COALESCE(
|
||||||
|
(SELECT SUM(p.amount) FROM payments p WHERE p.${paymentField} = t.id),
|
||||||
|
0
|
||||||
|
) as balance,
|
||||||
|
EXTRACT(DAY FROM ($1::date - t.due_date)) as days_overdue
|
||||||
|
FROM ${table} t
|
||||||
|
WHERE t.${statusFilter}
|
||||||
|
AND t.issue_date <= $1
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN days_overdue <= 0 THEN 'current'
|
||||||
|
WHEN days_overdue BETWEEN 1 AND 30 THEN '1-30'
|
||||||
|
WHEN days_overdue BETWEEN 31 AND 60 THEN '31-60'
|
||||||
|
WHEN days_overdue BETWEEN 61 AND 90 THEN '61-90'
|
||||||
|
ELSE '90+'
|
||||||
|
END as bucket,
|
||||||
|
COALESCE(SUM(balance), 0) as amount,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM items
|
||||||
|
WHERE balance > 0
|
||||||
|
GROUP BY
|
||||||
|
CASE
|
||||||
|
WHEN days_overdue <= 0 THEN 'current'
|
||||||
|
WHEN days_overdue BETWEEN 1 AND 30 THEN '1-30'
|
||||||
|
WHEN days_overdue BETWEEN 31 AND 60 THEN '31-60'
|
||||||
|
WHEN days_overdue BETWEEN 61 AND 90 THEN '61-90'
|
||||||
|
ELSE '90+'
|
||||||
|
END`,
|
||||||
|
[asOfDate],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get detailed breakdown by entity
|
||||||
|
const detailsQuery = await this.db.query<{
|
||||||
|
entity_id: string;
|
||||||
|
entity_name: string;
|
||||||
|
bucket: string;
|
||||||
|
amount: string;
|
||||||
|
}>(
|
||||||
|
`WITH items AS (
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.${entityField},
|
||||||
|
e.name as entity_name,
|
||||||
|
t.total_amount - COALESCE(
|
||||||
|
(SELECT SUM(p.amount) FROM payments p WHERE p.${paymentField} = t.id),
|
||||||
|
0
|
||||||
|
) as balance,
|
||||||
|
EXTRACT(DAY FROM ($1::date - t.due_date)) as days_overdue
|
||||||
|
FROM ${table} t
|
||||||
|
JOIN ${entityTable} e ON e.id = t.${entityField}
|
||||||
|
WHERE t.${statusFilter}
|
||||||
|
AND t.issue_date <= $1
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
${entityField} as entity_id,
|
||||||
|
entity_name,
|
||||||
|
CASE
|
||||||
|
WHEN days_overdue <= 0 THEN 'current'
|
||||||
|
WHEN days_overdue BETWEEN 1 AND 30 THEN '1-30'
|
||||||
|
WHEN days_overdue BETWEEN 31 AND 60 THEN '31-60'
|
||||||
|
WHEN days_overdue BETWEEN 61 AND 90 THEN '61-90'
|
||||||
|
ELSE '90+'
|
||||||
|
END as bucket,
|
||||||
|
COALESCE(SUM(balance), 0) as amount
|
||||||
|
FROM items
|
||||||
|
WHERE balance > 0
|
||||||
|
GROUP BY ${entityField}, entity_name, bucket
|
||||||
|
ORDER BY entity_name`,
|
||||||
|
[asOfDate],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process buckets
|
||||||
|
const bucketLabels: Record<AgingBucket, string> = {
|
||||||
|
'current': 'Vigente',
|
||||||
|
'1-30': '1-30 dias',
|
||||||
|
'31-60': '31-60 dias',
|
||||||
|
'61-90': '61-90 dias',
|
||||||
|
'90+': 'Mas de 90 dias',
|
||||||
|
};
|
||||||
|
|
||||||
|
const bucketOrder: AgingBucket[] = ['current', '1-30', '31-60', '61-90', '90+'];
|
||||||
|
const bucketMap = new Map<AgingBucket, { amount: number; count: number }>();
|
||||||
|
|
||||||
|
bucketsQuery.rows.forEach(row => {
|
||||||
|
bucketMap.set(row.bucket as AgingBucket, {
|
||||||
|
amount: parseFloat(row.amount),
|
||||||
|
count: parseInt(row.count),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let totalAmount = 0;
|
||||||
|
bucketMap.forEach(data => {
|
||||||
|
totalAmount += data.amount;
|
||||||
|
});
|
||||||
|
|
||||||
|
const buckets: AgingBucketData[] = bucketOrder.map(bucket => {
|
||||||
|
const data = bucketMap.get(bucket) || { amount: 0, count: 0 };
|
||||||
|
return {
|
||||||
|
bucket,
|
||||||
|
label: bucketLabels[bucket],
|
||||||
|
amount: createMonetaryValue(data.amount, currency),
|
||||||
|
count: data.count,
|
||||||
|
percentage: totalAmount > 0 ? (data.amount / totalAmount) * 100 : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process details
|
||||||
|
const entityMap = new Map<string, {
|
||||||
|
entityId: string;
|
||||||
|
entityName: string;
|
||||||
|
buckets: Record<AgingBucket, number>;
|
||||||
|
total: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
detailsQuery.rows.forEach(row => {
|
||||||
|
const entityId = row.entity_id;
|
||||||
|
if (!entityMap.has(entityId)) {
|
||||||
|
entityMap.set(entityId, {
|
||||||
|
entityId,
|
||||||
|
entityName: row.entity_name,
|
||||||
|
buckets: { 'current': 0, '1-30': 0, '31-60': 0, '61-90': 0, '90+': 0 },
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const entity = entityMap.get(entityId)!;
|
||||||
|
const amount = parseFloat(row.amount);
|
||||||
|
entity.buckets[row.bucket as AgingBucket] = amount;
|
||||||
|
entity.total += amount;
|
||||||
|
});
|
||||||
|
|
||||||
|
const details = Array.from(entityMap.values()).sort((a, b) => b.total - a.total);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
asOfDate,
|
||||||
|
totalAmount: createMonetaryValue(totalAmount, currency),
|
||||||
|
buckets,
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate VAT position for a period
|
||||||
|
*/
|
||||||
|
async calculateVATPosition(
|
||||||
|
tenantId: string,
|
||||||
|
dateFrom: Date,
|
||||||
|
dateTo: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<VATPositionResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
// Get VAT collected (from invoices)
|
||||||
|
const collectedQuery = await this.db.query<{
|
||||||
|
rate: string;
|
||||||
|
amount: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
il.vat_rate as rate,
|
||||||
|
SUM(il.vat_amount) as amount
|
||||||
|
FROM invoice_lines il
|
||||||
|
JOIN invoices i ON i.id = il.invoice_id
|
||||||
|
WHERE i.status IN ('paid', 'partial')
|
||||||
|
AND i.issue_date >= $1
|
||||||
|
AND i.issue_date <= $2
|
||||||
|
GROUP BY il.vat_rate`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get VAT paid (from expenses/bills)
|
||||||
|
const paidQuery = await this.db.query<{
|
||||||
|
rate: string;
|
||||||
|
amount: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(e.vat_rate, 0.16) as rate,
|
||||||
|
SUM(e.vat_amount) as amount
|
||||||
|
FROM expenses e
|
||||||
|
WHERE e.status = 'paid'
|
||||||
|
AND e.expense_date >= $1
|
||||||
|
AND e.expense_date <= $2
|
||||||
|
AND e.vat_amount > 0
|
||||||
|
GROUP BY e.vat_rate`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Combine rates
|
||||||
|
const rateMap = new Map<number, { collected: number; paid: number }>();
|
||||||
|
|
||||||
|
collectedQuery.rows.forEach(row => {
|
||||||
|
const rate = parseFloat(row.rate);
|
||||||
|
if (!rateMap.has(rate)) {
|
||||||
|
rateMap.set(rate, { collected: 0, paid: 0 });
|
||||||
|
}
|
||||||
|
rateMap.get(rate)!.collected = parseFloat(row.amount);
|
||||||
|
});
|
||||||
|
|
||||||
|
paidQuery.rows.forEach(row => {
|
||||||
|
const rate = parseFloat(row.rate);
|
||||||
|
if (!rateMap.has(rate)) {
|
||||||
|
rateMap.set(rate, { collected: 0, paid: 0 });
|
||||||
|
}
|
||||||
|
rateMap.get(rate)!.paid = parseFloat(row.amount);
|
||||||
|
});
|
||||||
|
|
||||||
|
let totalCollected = 0;
|
||||||
|
let totalPaid = 0;
|
||||||
|
|
||||||
|
const breakdown = Array.from(rateMap.entries())
|
||||||
|
.map(([rate, data]) => {
|
||||||
|
totalCollected += data.collected;
|
||||||
|
totalPaid += data.paid;
|
||||||
|
return {
|
||||||
|
rate,
|
||||||
|
collected: data.collected,
|
||||||
|
paid: data.paid,
|
||||||
|
net: data.collected - data.paid,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.rate - a.rate);
|
||||||
|
|
||||||
|
const netPosition = totalCollected - totalPaid;
|
||||||
|
|
||||||
|
return {
|
||||||
|
vatCollected: createMonetaryValue(totalCollected, currency),
|
||||||
|
vatPaid: createMonetaryValue(totalPaid, currency),
|
||||||
|
netPosition: createMonetaryValue(netPosition, currency),
|
||||||
|
isPayable: netPosition > 0,
|
||||||
|
breakdown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton factory
|
||||||
|
let coreMetricsInstance: CoreMetricsCalculator | null = null;
|
||||||
|
|
||||||
|
export function getCoreMetricsCalculator(db: DatabaseConnection): CoreMetricsCalculator {
|
||||||
|
if (!coreMetricsInstance) {
|
||||||
|
coreMetricsInstance = new CoreMetricsCalculator(db);
|
||||||
|
}
|
||||||
|
return coreMetricsInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCoreMetricsCalculator(db: DatabaseConnection): CoreMetricsCalculator {
|
||||||
|
return new CoreMetricsCalculator(db);
|
||||||
|
}
|
||||||
554
apps/api/src/services/metrics/enterprise.metrics.ts
Normal file
554
apps/api/src/services/metrics/enterprise.metrics.ts
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
/**
|
||||||
|
* Enterprise Metrics Calculator
|
||||||
|
*
|
||||||
|
* Advanced financial metrics for established businesses:
|
||||||
|
* - EBITDA
|
||||||
|
* - ROI (Return on Investment)
|
||||||
|
* - ROE (Return on Equity)
|
||||||
|
* - Current Ratio
|
||||||
|
* - Quick Ratio (Acid Test)
|
||||||
|
* - Debt Ratio
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DatabaseConnection, TenantContext } from '@horux/database';
|
||||||
|
import {
|
||||||
|
EBITDAResult,
|
||||||
|
ROIResult,
|
||||||
|
ROEResult,
|
||||||
|
CurrentRatioResult,
|
||||||
|
QuickRatioResult,
|
||||||
|
DebtRatioResult,
|
||||||
|
createMonetaryValue,
|
||||||
|
createPercentageValue,
|
||||||
|
createRatioValue,
|
||||||
|
MetricQueryOptions,
|
||||||
|
} from './metrics.types';
|
||||||
|
|
||||||
|
export class EnterpriseMetricsCalculator {
|
||||||
|
private db: DatabaseConnection;
|
||||||
|
|
||||||
|
constructor(db: DatabaseConnection) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tenant context for queries
|
||||||
|
*/
|
||||||
|
private getTenantContext(tenantId: string, schemaName?: string): TenantContext {
|
||||||
|
return {
|
||||||
|
tenantId,
|
||||||
|
schemaName: schemaName || `tenant_${tenantId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate EBITDA (Earnings Before Interest, Taxes, Depreciation, and Amortization)
|
||||||
|
*/
|
||||||
|
async calculateEBITDA(
|
||||||
|
tenantId: string,
|
||||||
|
dateFrom: Date,
|
||||||
|
dateTo: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<EBITDAResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
// Get revenue
|
||||||
|
const revenueQuery = await this.db.query<{ amount: string }>(
|
||||||
|
`SELECT COALESCE(SUM(total_amount), 0) as amount
|
||||||
|
FROM invoices
|
||||||
|
WHERE status IN ('paid', 'partial')
|
||||||
|
AND issue_date >= $1
|
||||||
|
AND issue_date <= $2`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get operating expenses (excluding depreciation, amortization, interest, taxes)
|
||||||
|
const operatingExpensesQuery = await this.db.query<{ amount: string }>(
|
||||||
|
`SELECT COALESCE(SUM(e.total_amount), 0) as amount
|
||||||
|
FROM expenses e
|
||||||
|
LEFT JOIN categories c ON c.id = e.category_id
|
||||||
|
WHERE e.status = 'paid'
|
||||||
|
AND e.expense_date >= $1
|
||||||
|
AND e.expense_date <= $2
|
||||||
|
AND COALESCE(c.type, 'operating') NOT IN ('depreciation', 'amortization', 'interest', 'tax')`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get depreciation
|
||||||
|
const depreciationQuery = await this.db.query<{ amount: string }>(
|
||||||
|
`SELECT COALESCE(SUM(e.total_amount), 0) as amount
|
||||||
|
FROM expenses e
|
||||||
|
JOIN categories c ON c.id = e.category_id
|
||||||
|
WHERE e.status = 'paid'
|
||||||
|
AND e.expense_date >= $1
|
||||||
|
AND e.expense_date <= $2
|
||||||
|
AND c.type = 'depreciation'`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get amortization
|
||||||
|
const amortizationQuery = await this.db.query<{ amount: string }>(
|
||||||
|
`SELECT COALESCE(SUM(e.total_amount), 0) as amount
|
||||||
|
FROM expenses e
|
||||||
|
JOIN categories c ON c.id = e.category_id
|
||||||
|
WHERE e.status = 'paid'
|
||||||
|
AND e.expense_date >= $1
|
||||||
|
AND e.expense_date <= $2
|
||||||
|
AND c.type = 'amortization'`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
const revenue = parseFloat(revenueQuery.rows[0]?.amount || '0');
|
||||||
|
const operatingExpenses = parseFloat(operatingExpensesQuery.rows[0]?.amount || '0');
|
||||||
|
const depreciation = parseFloat(depreciationQuery.rows[0]?.amount || '0');
|
||||||
|
const amortization = parseFloat(amortizationQuery.rows[0]?.amount || '0');
|
||||||
|
|
||||||
|
const operatingIncome = revenue - operatingExpenses;
|
||||||
|
const ebitda = operatingIncome + depreciation + amortization;
|
||||||
|
const margin = revenue > 0 ? ebitda / revenue : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ebitda: createMonetaryValue(ebitda, currency),
|
||||||
|
operatingIncome: createMonetaryValue(operatingIncome, currency),
|
||||||
|
depreciation: createMonetaryValue(depreciation, currency),
|
||||||
|
amortization: createMonetaryValue(amortization, currency),
|
||||||
|
margin: createPercentageValue(margin),
|
||||||
|
revenue: createMonetaryValue(revenue, currency),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate ROI (Return on Investment)
|
||||||
|
*/
|
||||||
|
async calculateROI(
|
||||||
|
tenantId: string,
|
||||||
|
dateFrom: Date,
|
||||||
|
dateTo: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<ROIResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
// Calculate net profit
|
||||||
|
const profitQuery = await this.db.query<{
|
||||||
|
revenue: string;
|
||||||
|
expenses: string;
|
||||||
|
}>(
|
||||||
|
`WITH revenue AS (
|
||||||
|
SELECT COALESCE(SUM(total_amount), 0) as amount
|
||||||
|
FROM invoices
|
||||||
|
WHERE status IN ('paid', 'partial')
|
||||||
|
AND issue_date >= $1
|
||||||
|
AND issue_date <= $2
|
||||||
|
),
|
||||||
|
expenses AS (
|
||||||
|
SELECT COALESCE(SUM(total_amount), 0) as amount
|
||||||
|
FROM expenses
|
||||||
|
WHERE status = 'paid'
|
||||||
|
AND expense_date >= $1
|
||||||
|
AND expense_date <= $2
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT amount FROM revenue) as revenue,
|
||||||
|
(SELECT amount FROM expenses) as expenses`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get total investment (initial capital + retained earnings)
|
||||||
|
const investmentQuery = await this.db.query<{ amount: string }>(
|
||||||
|
`SELECT COALESCE(
|
||||||
|
(SELECT SUM(amount) FROM capital_contributions WHERE contribution_date <= $1) +
|
||||||
|
(SELECT COALESCE(SUM(amount), 0) FROM retained_earnings WHERE period_end <= $1),
|
||||||
|
0
|
||||||
|
) as amount`,
|
||||||
|
[dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
const revenue = parseFloat(profitQuery.rows[0]?.revenue || '0');
|
||||||
|
const expenses = parseFloat(profitQuery.rows[0]?.expenses || '0');
|
||||||
|
const netProfit = revenue - expenses;
|
||||||
|
const totalInvestment = parseFloat(investmentQuery.rows[0]?.amount || '1');
|
||||||
|
|
||||||
|
const roi = totalInvestment > 0 ? netProfit / totalInvestment : 0;
|
||||||
|
|
||||||
|
// Annualize the ROI
|
||||||
|
const periodDays = Math.ceil((dateTo.getTime() - dateFrom.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
const annualized = roi * (365 / periodDays);
|
||||||
|
|
||||||
|
return {
|
||||||
|
roi: createPercentageValue(roi),
|
||||||
|
netProfit: createMonetaryValue(netProfit, currency),
|
||||||
|
totalInvestment: createMonetaryValue(totalInvestment, currency),
|
||||||
|
annualized: createPercentageValue(annualized),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate ROE (Return on Equity)
|
||||||
|
*/
|
||||||
|
async calculateROE(
|
||||||
|
tenantId: string,
|
||||||
|
dateFrom: Date,
|
||||||
|
dateTo: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<ROEResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
// Calculate net income
|
||||||
|
const incomeQuery = await this.db.query<{
|
||||||
|
revenue: string;
|
||||||
|
expenses: string;
|
||||||
|
}>(
|
||||||
|
`WITH revenue AS (
|
||||||
|
SELECT COALESCE(SUM(total_amount), 0) as amount
|
||||||
|
FROM invoices
|
||||||
|
WHERE status IN ('paid', 'partial')
|
||||||
|
AND issue_date >= $1
|
||||||
|
AND issue_date <= $2
|
||||||
|
),
|
||||||
|
expenses AS (
|
||||||
|
SELECT COALESCE(SUM(total_amount), 0) as amount
|
||||||
|
FROM expenses
|
||||||
|
WHERE status = 'paid'
|
||||||
|
AND expense_date >= $1
|
||||||
|
AND expense_date <= $2
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT amount FROM revenue) as revenue,
|
||||||
|
(SELECT amount FROM expenses) as expenses`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get shareholders' equity
|
||||||
|
const equityQuery = await this.db.query<{ amount: string }>(
|
||||||
|
`SELECT COALESCE(
|
||||||
|
(
|
||||||
|
SELECT SUM(amount)
|
||||||
|
FROM (
|
||||||
|
SELECT amount FROM capital_contributions WHERE contribution_date <= $1
|
||||||
|
UNION ALL
|
||||||
|
SELECT amount FROM retained_earnings WHERE period_end <= $1
|
||||||
|
UNION ALL
|
||||||
|
SELECT -amount FROM dividends WHERE payment_date <= $1
|
||||||
|
) equity_items
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) as amount`,
|
||||||
|
[dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
const revenue = parseFloat(incomeQuery.rows[0]?.revenue || '0');
|
||||||
|
const expenses = parseFloat(incomeQuery.rows[0]?.expenses || '0');
|
||||||
|
const netIncome = revenue - expenses;
|
||||||
|
const shareholdersEquity = parseFloat(equityQuery.rows[0]?.amount || '1');
|
||||||
|
|
||||||
|
const roe = shareholdersEquity > 0 ? netIncome / shareholdersEquity : 0;
|
||||||
|
|
||||||
|
// Annualize the ROE
|
||||||
|
const periodDays = Math.ceil((dateTo.getTime() - dateFrom.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
const annualized = roe * (365 / periodDays);
|
||||||
|
|
||||||
|
return {
|
||||||
|
roe: createPercentageValue(roe),
|
||||||
|
netIncome: createMonetaryValue(netIncome, currency),
|
||||||
|
shareholdersEquity: createMonetaryValue(shareholdersEquity, currency),
|
||||||
|
annualized: createPercentageValue(annualized),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate Current Ratio
|
||||||
|
*/
|
||||||
|
async calculateCurrentRatio(
|
||||||
|
tenantId: string,
|
||||||
|
asOfDate: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<CurrentRatioResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
// Get current assets (cash, accounts receivable, inventory, prepaid expenses)
|
||||||
|
const assetsQuery = await this.db.query<{
|
||||||
|
cash: string;
|
||||||
|
receivables: string;
|
||||||
|
inventory: string;
|
||||||
|
prepaid: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COALESCE((SELECT SUM(balance) FROM bank_accounts WHERE is_active = true), 0) as cash,
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(i.total_amount - COALESCE(
|
||||||
|
(SELECT SUM(p.amount) FROM payments p WHERE p.invoice_id = i.id), 0
|
||||||
|
))
|
||||||
|
FROM invoices i
|
||||||
|
WHERE i.status IN ('sent', 'partial')
|
||||||
|
AND i.issue_date <= $1
|
||||||
|
), 0) as receivables,
|
||||||
|
COALESCE((SELECT SUM(quantity * unit_cost) FROM inventory_items WHERE is_active = true), 0) as inventory,
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(remaining_amount)
|
||||||
|
FROM prepaid_expenses
|
||||||
|
WHERE start_date <= $1 AND end_date > $1
|
||||||
|
), 0) as prepaid`,
|
||||||
|
[asOfDate],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get current liabilities (accounts payable, short-term debt, accrued expenses)
|
||||||
|
const liabilitiesQuery = await this.db.query<{
|
||||||
|
payables: string;
|
||||||
|
short_term_debt: string;
|
||||||
|
accrued: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(b.total_amount - COALESCE(
|
||||||
|
(SELECT SUM(p.amount) FROM payments p WHERE p.bill_id = b.id), 0
|
||||||
|
))
|
||||||
|
FROM bills b
|
||||||
|
WHERE b.status IN ('pending', 'partial')
|
||||||
|
AND b.issue_date <= $1
|
||||||
|
), 0) as payables,
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(remaining_balance)
|
||||||
|
FROM loans
|
||||||
|
WHERE is_active = true AND due_date <= $1 + INTERVAL '12 months'
|
||||||
|
), 0) as short_term_debt,
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(amount)
|
||||||
|
FROM accrued_expenses
|
||||||
|
WHERE accrual_date <= $1 AND (paid_date IS NULL OR paid_date > $1)
|
||||||
|
), 0) as accrued`,
|
||||||
|
[asOfDate],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
const cash = parseFloat(assetsQuery.rows[0]?.cash || '0');
|
||||||
|
const receivables = parseFloat(assetsQuery.rows[0]?.receivables || '0');
|
||||||
|
const inventory = parseFloat(assetsQuery.rows[0]?.inventory || '0');
|
||||||
|
const prepaid = parseFloat(assetsQuery.rows[0]?.prepaid || '0');
|
||||||
|
|
||||||
|
const payables = parseFloat(liabilitiesQuery.rows[0]?.payables || '0');
|
||||||
|
const shortTermDebt = parseFloat(liabilitiesQuery.rows[0]?.short_term_debt || '0');
|
||||||
|
const accrued = parseFloat(liabilitiesQuery.rows[0]?.accrued || '0');
|
||||||
|
|
||||||
|
const currentAssets = cash + receivables + inventory + prepaid;
|
||||||
|
const currentLiabilities = payables + shortTermDebt + accrued;
|
||||||
|
|
||||||
|
const ratio = currentLiabilities > 0 ? currentAssets / currentLiabilities : 0;
|
||||||
|
const isHealthy = ratio >= 1.5;
|
||||||
|
|
||||||
|
let interpretation: string;
|
||||||
|
if (ratio >= 2) {
|
||||||
|
interpretation = 'Excelente liquidez. La empresa puede cubrir facilmente sus obligaciones a corto plazo.';
|
||||||
|
} else if (ratio >= 1.5) {
|
||||||
|
interpretation = 'Buena liquidez. La empresa tiene capacidad adecuada para cubrir sus obligaciones.';
|
||||||
|
} else if (ratio >= 1) {
|
||||||
|
interpretation = 'Liquidez limitada. La empresa puede tener dificultades para cubrir obligaciones inesperadas.';
|
||||||
|
} else {
|
||||||
|
interpretation = 'Liquidez insuficiente. La empresa puede tener problemas para pagar sus deudas a corto plazo.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ratio: createRatioValue(currentAssets, currentLiabilities),
|
||||||
|
currentAssets: createMonetaryValue(currentAssets, currency),
|
||||||
|
currentLiabilities: createMonetaryValue(currentLiabilities, currency),
|
||||||
|
isHealthy,
|
||||||
|
interpretation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate Quick Ratio (Acid Test)
|
||||||
|
*/
|
||||||
|
async calculateQuickRatio(
|
||||||
|
tenantId: string,
|
||||||
|
asOfDate: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<QuickRatioResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
// Get current assets (excluding inventory)
|
||||||
|
const assetsQuery = await this.db.query<{
|
||||||
|
cash: string;
|
||||||
|
receivables: string;
|
||||||
|
inventory: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COALESCE((SELECT SUM(balance) FROM bank_accounts WHERE is_active = true), 0) as cash,
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(i.total_amount - COALESCE(
|
||||||
|
(SELECT SUM(p.amount) FROM payments p WHERE p.invoice_id = i.id), 0
|
||||||
|
))
|
||||||
|
FROM invoices i
|
||||||
|
WHERE i.status IN ('sent', 'partial')
|
||||||
|
AND i.issue_date <= $1
|
||||||
|
), 0) as receivables,
|
||||||
|
COALESCE((SELECT SUM(quantity * unit_cost) FROM inventory_items WHERE is_active = true), 0) as inventory`,
|
||||||
|
[asOfDate],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get current liabilities
|
||||||
|
const liabilitiesQuery = await this.db.query<{
|
||||||
|
total: string;
|
||||||
|
}>(
|
||||||
|
`SELECT COALESCE(
|
||||||
|
(
|
||||||
|
SELECT SUM(b.total_amount - COALESCE(
|
||||||
|
(SELECT SUM(p.amount) FROM payments p WHERE p.bill_id = b.id), 0
|
||||||
|
))
|
||||||
|
FROM bills b
|
||||||
|
WHERE b.status IN ('pending', 'partial')
|
||||||
|
AND b.issue_date <= $1
|
||||||
|
) +
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(remaining_balance)
|
||||||
|
FROM loans
|
||||||
|
WHERE is_active = true AND due_date <= $1 + INTERVAL '12 months'
|
||||||
|
), 0) +
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(amount)
|
||||||
|
FROM accrued_expenses
|
||||||
|
WHERE accrual_date <= $1 AND (paid_date IS NULL OR paid_date > $1)
|
||||||
|
), 0),
|
||||||
|
0
|
||||||
|
) as total`,
|
||||||
|
[asOfDate],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
const cash = parseFloat(assetsQuery.rows[0]?.cash || '0');
|
||||||
|
const receivables = parseFloat(assetsQuery.rows[0]?.receivables || '0');
|
||||||
|
const inventory = parseFloat(assetsQuery.rows[0]?.inventory || '0');
|
||||||
|
const currentAssets = cash + receivables + inventory;
|
||||||
|
const currentLiabilities = parseFloat(liabilitiesQuery.rows[0]?.total || '0');
|
||||||
|
|
||||||
|
const quickAssets = currentAssets - inventory;
|
||||||
|
const ratio = currentLiabilities > 0 ? quickAssets / currentLiabilities : 0;
|
||||||
|
const isHealthy = ratio >= 1;
|
||||||
|
|
||||||
|
let interpretation: string;
|
||||||
|
if (ratio >= 1.5) {
|
||||||
|
interpretation = 'Excelente liquidez inmediata. La empresa puede cubrir sus obligaciones sin depender del inventario.';
|
||||||
|
} else if (ratio >= 1) {
|
||||||
|
interpretation = 'Buena liquidez inmediata. La empresa puede cubrir sus obligaciones a corto plazo.';
|
||||||
|
} else if (ratio >= 0.5) {
|
||||||
|
interpretation = 'Liquidez inmediata limitada. La empresa depende parcialmente de la venta de inventario.';
|
||||||
|
} else {
|
||||||
|
interpretation = 'Liquidez inmediata insuficiente. La empresa tiene alta dependencia del inventario para cubrir deudas.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ratio: createRatioValue(quickAssets, currentLiabilities),
|
||||||
|
currentAssets: createMonetaryValue(currentAssets, currency),
|
||||||
|
inventory: createMonetaryValue(inventory, currency),
|
||||||
|
currentLiabilities: createMonetaryValue(currentLiabilities, currency),
|
||||||
|
isHealthy,
|
||||||
|
interpretation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate Debt Ratio
|
||||||
|
*/
|
||||||
|
async calculateDebtRatio(
|
||||||
|
tenantId: string,
|
||||||
|
asOfDate: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<DebtRatioResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
// Get total debt
|
||||||
|
const debtQuery = await this.db.query<{ amount: string }>(
|
||||||
|
`SELECT COALESCE(
|
||||||
|
(
|
||||||
|
SELECT SUM(b.total_amount - COALESCE(
|
||||||
|
(SELECT SUM(p.amount) FROM payments p WHERE p.bill_id = b.id), 0
|
||||||
|
))
|
||||||
|
FROM bills b
|
||||||
|
WHERE b.status IN ('pending', 'partial')
|
||||||
|
AND b.issue_date <= $1
|
||||||
|
) +
|
||||||
|
COALESCE((SELECT SUM(remaining_balance) FROM loans WHERE is_active = true), 0) +
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(amount)
|
||||||
|
FROM accrued_expenses
|
||||||
|
WHERE accrual_date <= $1 AND (paid_date IS NULL OR paid_date > $1)
|
||||||
|
), 0),
|
||||||
|
0
|
||||||
|
) as amount`,
|
||||||
|
[asOfDate],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get total assets
|
||||||
|
const assetsQuery = await this.db.query<{ amount: string }>(
|
||||||
|
`SELECT COALESCE(
|
||||||
|
(SELECT SUM(balance) FROM bank_accounts WHERE is_active = true) +
|
||||||
|
(
|
||||||
|
SELECT COALESCE(SUM(i.total_amount - COALESCE(
|
||||||
|
(SELECT SUM(p.amount) FROM payments p WHERE p.invoice_id = i.id), 0
|
||||||
|
)), 0)
|
||||||
|
FROM invoices i
|
||||||
|
WHERE i.status IN ('sent', 'partial')
|
||||||
|
AND i.issue_date <= $1
|
||||||
|
) +
|
||||||
|
COALESCE((SELECT SUM(quantity * unit_cost) FROM inventory_items WHERE is_active = true), 0) +
|
||||||
|
COALESCE((SELECT SUM(book_value) FROM fixed_assets WHERE is_active = true), 0),
|
||||||
|
0
|
||||||
|
) as amount`,
|
||||||
|
[asOfDate],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalDebt = parseFloat(debtQuery.rows[0]?.amount || '0');
|
||||||
|
const totalAssets = parseFloat(assetsQuery.rows[0]?.amount || '1');
|
||||||
|
|
||||||
|
const ratio = totalAssets > 0 ? totalDebt / totalAssets : 0;
|
||||||
|
|
||||||
|
let interpretation: string;
|
||||||
|
if (ratio <= 0.3) {
|
||||||
|
interpretation = 'Bajo nivel de endeudamiento. La empresa tiene una estructura financiera conservadora.';
|
||||||
|
} else if (ratio <= 0.5) {
|
||||||
|
interpretation = 'Nivel de endeudamiento moderado. Equilibrio adecuado entre deuda y capital propio.';
|
||||||
|
} else if (ratio <= 0.7) {
|
||||||
|
interpretation = 'Nivel de endeudamiento alto. Mayor riesgo financiero y dependencia de acreedores.';
|
||||||
|
} else {
|
||||||
|
interpretation = 'Nivel de endeudamiento muy alto. Alto riesgo de insolvencia en condiciones adversas.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ratio: createRatioValue(totalDebt, totalAssets),
|
||||||
|
totalDebt: createMonetaryValue(totalDebt, currency),
|
||||||
|
totalAssets: createMonetaryValue(totalAssets, currency),
|
||||||
|
interpretation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton factory
|
||||||
|
let enterpriseMetricsInstance: EnterpriseMetricsCalculator | null = null;
|
||||||
|
|
||||||
|
export function getEnterpriseMetricsCalculator(db: DatabaseConnection): EnterpriseMetricsCalculator {
|
||||||
|
if (!enterpriseMetricsInstance) {
|
||||||
|
enterpriseMetricsInstance = new EnterpriseMetricsCalculator(db);
|
||||||
|
}
|
||||||
|
return enterpriseMetricsInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEnterpriseMetricsCalculator(db: DatabaseConnection): EnterpriseMetricsCalculator {
|
||||||
|
return new EnterpriseMetricsCalculator(db);
|
||||||
|
}
|
||||||
504
apps/api/src/services/metrics/metrics.cache.ts
Normal file
504
apps/api/src/services/metrics/metrics.cache.ts
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
/**
|
||||||
|
* Metrics Cache System
|
||||||
|
*
|
||||||
|
* High-performance caching layer for metrics using Redis.
|
||||||
|
* Supports:
|
||||||
|
* - TTL-based expiration
|
||||||
|
* - Stale-while-revalidate pattern
|
||||||
|
* - Cache invalidation by tenant and period
|
||||||
|
* - Cache warmup for commonly accessed metrics
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import {
|
||||||
|
MetricType,
|
||||||
|
MetricPeriod,
|
||||||
|
CachedMetric,
|
||||||
|
CacheConfig,
|
||||||
|
CacheStats,
|
||||||
|
periodToString,
|
||||||
|
CoreMetricType,
|
||||||
|
StartupMetricType,
|
||||||
|
EnterpriseMetricType,
|
||||||
|
} from './metrics.types';
|
||||||
|
|
||||||
|
// Default cache configuration
|
||||||
|
const DEFAULT_CACHE_CONFIG: CacheConfig = {
|
||||||
|
ttlSeconds: 3600, // 1 hour default TTL
|
||||||
|
staleWhileRevalidate: true,
|
||||||
|
warmupEnabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Metric-specific TTL configuration
|
||||||
|
const METRIC_TTL_CONFIG: Record<MetricType, number> = {
|
||||||
|
// Core metrics - shorter TTL for more accuracy
|
||||||
|
revenue: 1800, // 30 minutes
|
||||||
|
expenses: 1800, // 30 minutes
|
||||||
|
gross_profit: 1800, // 30 minutes
|
||||||
|
net_profit: 1800, // 30 minutes
|
||||||
|
cash_flow: 900, // 15 minutes
|
||||||
|
accounts_receivable: 900, // 15 minutes
|
||||||
|
accounts_payable: 900, // 15 minutes
|
||||||
|
aging_receivable: 1800, // 30 minutes
|
||||||
|
aging_payable: 1800, // 30 minutes
|
||||||
|
vat_position: 3600, // 1 hour
|
||||||
|
|
||||||
|
// Startup metrics - moderate TTL
|
||||||
|
mrr: 3600, // 1 hour
|
||||||
|
arr: 3600, // 1 hour
|
||||||
|
churn_rate: 7200, // 2 hours
|
||||||
|
cac: 7200, // 2 hours
|
||||||
|
ltv: 14400, // 4 hours
|
||||||
|
ltv_cac_ratio: 14400, // 4 hours
|
||||||
|
runway: 3600, // 1 hour
|
||||||
|
burn_rate: 3600, // 1 hour
|
||||||
|
|
||||||
|
// Enterprise metrics - longer TTL
|
||||||
|
ebitda: 3600, // 1 hour
|
||||||
|
roi: 7200, // 2 hours
|
||||||
|
roe: 7200, // 2 hours
|
||||||
|
current_ratio: 1800, // 30 minutes
|
||||||
|
quick_ratio: 1800, // 30 minutes
|
||||||
|
debt_ratio: 3600, // 1 hour
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MetricsCache {
|
||||||
|
private redis: Redis;
|
||||||
|
private config: CacheConfig;
|
||||||
|
private stats: CacheStats;
|
||||||
|
private prefix: string = 'horux:metrics:';
|
||||||
|
|
||||||
|
constructor(redis: Redis, config?: Partial<CacheConfig>) {
|
||||||
|
this.redis = redis;
|
||||||
|
this.config = { ...DEFAULT_CACHE_CONFIG, ...config };
|
||||||
|
this.stats = {
|
||||||
|
hits: 0,
|
||||||
|
misses: 0,
|
||||||
|
hitRate: 0,
|
||||||
|
totalEntries: 0,
|
||||||
|
memoryUsage: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cache key for a metric
|
||||||
|
*/
|
||||||
|
private generateKey(tenantId: string, metric: MetricType, period: MetricPeriod): string {
|
||||||
|
const periodStr = periodToString(period);
|
||||||
|
return `${this.prefix}${tenantId}:${metric}:${periodStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate pattern for tenant cache keys
|
||||||
|
*/
|
||||||
|
private generateTenantPattern(tenantId: string): string {
|
||||||
|
return `${this.prefix}${tenantId}:*`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get TTL for a specific metric
|
||||||
|
*/
|
||||||
|
private getTTL(metric: MetricType): number {
|
||||||
|
return METRIC_TTL_CONFIG[metric] || this.config.ttlSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a cached metric value
|
||||||
|
*/
|
||||||
|
async getCachedMetric<T = unknown>(
|
||||||
|
tenantId: string,
|
||||||
|
metric: MetricType,
|
||||||
|
period: MetricPeriod
|
||||||
|
): Promise<CachedMetric | null> {
|
||||||
|
const key = this.generateKey(tenantId, metric, period);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await this.redis.get(key);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
this.stats.misses++;
|
||||||
|
this.updateHitRate();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stats.hits++;
|
||||||
|
this.updateHitRate();
|
||||||
|
|
||||||
|
const cached = JSON.parse(data) as CachedMetric;
|
||||||
|
|
||||||
|
// Check if stale
|
||||||
|
const now = new Date();
|
||||||
|
if (new Date(cached.expiresAt) < now) {
|
||||||
|
// If stale-while-revalidate is enabled, return stale data
|
||||||
|
// The caller should trigger a background refresh
|
||||||
|
if (this.config.staleWhileRevalidate) {
|
||||||
|
return { ...cached, value: cached.value as T };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...cached, value: cached.value as T };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cache get error:', error);
|
||||||
|
this.stats.misses++;
|
||||||
|
this.updateHitRate();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a cached metric value
|
||||||
|
*/
|
||||||
|
async setCachedMetric<T = unknown>(
|
||||||
|
tenantId: string,
|
||||||
|
metric: MetricType,
|
||||||
|
period: MetricPeriod,
|
||||||
|
value: T
|
||||||
|
): Promise<void> {
|
||||||
|
const key = this.generateKey(tenantId, metric, period);
|
||||||
|
const ttl = this.getTTL(metric);
|
||||||
|
|
||||||
|
const cached: CachedMetric = {
|
||||||
|
key,
|
||||||
|
tenantId,
|
||||||
|
metric,
|
||||||
|
period,
|
||||||
|
value,
|
||||||
|
calculatedAt: new Date(),
|
||||||
|
expiresAt: new Date(Date.now() + ttl * 1000),
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.redis.setex(key, ttl, JSON.stringify(cached));
|
||||||
|
this.stats.totalEntries++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cache set error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate cache for a tenant
|
||||||
|
*/
|
||||||
|
async invalidateCache(
|
||||||
|
tenantId: string,
|
||||||
|
affectedPeriods?: MetricPeriod[]
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
if (!affectedPeriods || affectedPeriods.length === 0) {
|
||||||
|
// Invalidate all cache for tenant
|
||||||
|
const pattern = this.generateTenantPattern(tenantId);
|
||||||
|
const keys = await this.redis.keys(pattern);
|
||||||
|
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await this.redis.del(...keys);
|
||||||
|
this.stats.totalEntries = Math.max(0, this.stats.totalEntries - keys.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate specific periods
|
||||||
|
let invalidated = 0;
|
||||||
|
const metrics: MetricType[] = [
|
||||||
|
// Core
|
||||||
|
'revenue', 'expenses', 'gross_profit', 'net_profit', 'cash_flow',
|
||||||
|
'accounts_receivable', 'accounts_payable', 'aging_receivable',
|
||||||
|
'aging_payable', 'vat_position',
|
||||||
|
// Startup
|
||||||
|
'mrr', 'arr', 'churn_rate', 'cac', 'ltv', 'ltv_cac_ratio',
|
||||||
|
'runway', 'burn_rate',
|
||||||
|
// Enterprise
|
||||||
|
'ebitda', 'roi', 'roe', 'current_ratio', 'quick_ratio', 'debt_ratio',
|
||||||
|
];
|
||||||
|
|
||||||
|
const keysToDelete: string[] = [];
|
||||||
|
for (const period of affectedPeriods) {
|
||||||
|
for (const metric of metrics) {
|
||||||
|
keysToDelete.push(this.generateKey(tenantId, metric, period));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keysToDelete.length > 0) {
|
||||||
|
// Delete in batches to avoid blocking
|
||||||
|
const batchSize = 100;
|
||||||
|
for (let i = 0; i < keysToDelete.length; i += batchSize) {
|
||||||
|
const batch = keysToDelete.slice(i, i + batchSize);
|
||||||
|
const deleted = await this.redis.del(...batch);
|
||||||
|
invalidated += deleted;
|
||||||
|
}
|
||||||
|
this.stats.totalEntries = Math.max(0, this.stats.totalEntries - invalidated);
|
||||||
|
}
|
||||||
|
|
||||||
|
return invalidated;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cache invalidation error:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate cache for specific metrics
|
||||||
|
*/
|
||||||
|
async invalidateMetrics(
|
||||||
|
tenantId: string,
|
||||||
|
metrics: MetricType[],
|
||||||
|
periods: MetricPeriod[]
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
const keysToDelete: string[] = [];
|
||||||
|
|
||||||
|
for (const period of periods) {
|
||||||
|
for (const metric of metrics) {
|
||||||
|
keysToDelete.push(this.generateKey(tenantId, metric, period));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keysToDelete.length > 0) {
|
||||||
|
const deleted = await this.redis.del(...keysToDelete);
|
||||||
|
this.stats.totalEntries = Math.max(0, this.stats.totalEntries - deleted);
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cache invalidation error:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warmup cache with commonly accessed metrics
|
||||||
|
*/
|
||||||
|
async warmupCache(
|
||||||
|
tenantId: string,
|
||||||
|
calculateMetric: (metric: MetricType, period: MetricPeriod) => Promise<unknown>
|
||||||
|
): Promise<{ success: number; failed: number }> {
|
||||||
|
if (!this.config.warmupEnabled) {
|
||||||
|
return { success: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const currentMonth: MetricPeriod = {
|
||||||
|
type: 'monthly',
|
||||||
|
year: now.getFullYear(),
|
||||||
|
month: now.getMonth() + 1,
|
||||||
|
};
|
||||||
|
const previousMonth: MetricPeriod = {
|
||||||
|
type: 'monthly',
|
||||||
|
year: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
||||||
|
month: now.getMonth() === 0 ? 12 : now.getMonth(),
|
||||||
|
};
|
||||||
|
const currentYear: MetricPeriod = {
|
||||||
|
type: 'yearly',
|
||||||
|
year: now.getFullYear(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Priority metrics to warmup
|
||||||
|
const priorityMetrics: CoreMetricType[] = [
|
||||||
|
'revenue',
|
||||||
|
'expenses',
|
||||||
|
'net_profit',
|
||||||
|
'cash_flow',
|
||||||
|
'accounts_receivable',
|
||||||
|
'accounts_payable',
|
||||||
|
];
|
||||||
|
|
||||||
|
const periods = [currentMonth, previousMonth, currentYear];
|
||||||
|
let success = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const metric of priorityMetrics) {
|
||||||
|
for (const period of periods) {
|
||||||
|
try {
|
||||||
|
// Check if already cached
|
||||||
|
const existing = await this.getCachedMetric(tenantId, metric, period);
|
||||||
|
if (existing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate and cache
|
||||||
|
const value = await calculateMetric(metric, period);
|
||||||
|
await this.setCachedMetric(tenantId, metric, period, value);
|
||||||
|
success++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to warmup ${metric} for period ${periodToString(period)}:`, error);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics
|
||||||
|
*/
|
||||||
|
async getStats(): Promise<CacheStats> {
|
||||||
|
try {
|
||||||
|
// Get memory info
|
||||||
|
const info = await this.redis.info('memory');
|
||||||
|
const memoryMatch = info.match(/used_memory:(\d+)/);
|
||||||
|
this.stats.memoryUsage = memoryMatch ? parseInt(memoryMatch[1]) : 0;
|
||||||
|
|
||||||
|
// Count entries
|
||||||
|
const keys = await this.redis.keys(`${this.prefix}*`);
|
||||||
|
this.stats.totalEntries = keys.length;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting cache stats:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...this.stats };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all metrics cache
|
||||||
|
*/
|
||||||
|
async clearAll(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const keys = await this.redis.keys(`${this.prefix}*`);
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await this.redis.del(...keys);
|
||||||
|
}
|
||||||
|
this.stats = {
|
||||||
|
hits: 0,
|
||||||
|
misses: 0,
|
||||||
|
hitRate: 0,
|
||||||
|
totalEntries: 0,
|
||||||
|
memoryUsage: 0,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing cache:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update hit rate calculation
|
||||||
|
*/
|
||||||
|
private updateHitRate(): void {
|
||||||
|
const total = this.stats.hits + this.stats.misses;
|
||||||
|
this.stats.hitRate = total > 0 ? this.stats.hits / total : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or calculate metric with caching
|
||||||
|
*/
|
||||||
|
async getOrCalculate<T>(
|
||||||
|
tenantId: string,
|
||||||
|
metric: MetricType,
|
||||||
|
period: MetricPeriod,
|
||||||
|
calculateFn: () => Promise<T>,
|
||||||
|
forceRefresh: boolean = false
|
||||||
|
): Promise<T> {
|
||||||
|
// Skip cache if force refresh
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const cached = await this.getCachedMetric<T>(tenantId, metric, period);
|
||||||
|
if (cached) {
|
||||||
|
// Check if we need background refresh (stale)
|
||||||
|
const now = new Date();
|
||||||
|
if (new Date(cached.expiresAt) < now && this.config.staleWhileRevalidate) {
|
||||||
|
// Trigger background refresh
|
||||||
|
this.backgroundRefresh(tenantId, metric, period, calculateFn).catch(console.error);
|
||||||
|
}
|
||||||
|
return cached.value as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate fresh value
|
||||||
|
const value = await calculateFn();
|
||||||
|
await this.setCachedMetric(tenantId, metric, period, value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background refresh for stale cache entries
|
||||||
|
*/
|
||||||
|
private async backgroundRefresh<T>(
|
||||||
|
tenantId: string,
|
||||||
|
metric: MetricType,
|
||||||
|
period: MetricPeriod,
|
||||||
|
calculateFn: () => Promise<T>
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const value = await calculateFn();
|
||||||
|
await this.setCachedMetric(tenantId, metric, period, value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Background refresh failed for ${metric}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a metric is cached
|
||||||
|
*/
|
||||||
|
async isCached(
|
||||||
|
tenantId: string,
|
||||||
|
metric: MetricType,
|
||||||
|
period: MetricPeriod
|
||||||
|
): Promise<boolean> {
|
||||||
|
const key = this.generateKey(tenantId, metric, period);
|
||||||
|
const exists = await this.redis.exists(key);
|
||||||
|
return exists === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remaining TTL for a cached metric
|
||||||
|
*/
|
||||||
|
async getTTLRemaining(
|
||||||
|
tenantId: string,
|
||||||
|
metric: MetricType,
|
||||||
|
period: MetricPeriod
|
||||||
|
): Promise<number> {
|
||||||
|
const key = this.generateKey(tenantId, metric, period);
|
||||||
|
return await this.redis.ttl(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extend TTL for a cached metric
|
||||||
|
*/
|
||||||
|
async extendTTL(
|
||||||
|
tenantId: string,
|
||||||
|
metric: MetricType,
|
||||||
|
period: MetricPeriod,
|
||||||
|
additionalSeconds: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
const key = this.generateKey(tenantId, metric, period);
|
||||||
|
const currentTTL = await this.redis.ttl(key);
|
||||||
|
|
||||||
|
if (currentTTL > 0) {
|
||||||
|
await this.redis.expire(key, currentTTL + additionalSeconds);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factory function
|
||||||
|
let metricsCacheInstance: MetricsCache | null = null;
|
||||||
|
|
||||||
|
export function getMetricsCache(redis: Redis, config?: Partial<CacheConfig>): MetricsCache {
|
||||||
|
if (!metricsCacheInstance) {
|
||||||
|
metricsCacheInstance = new MetricsCache(redis, config);
|
||||||
|
}
|
||||||
|
return metricsCacheInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMetricsCache(redis: Redis, config?: Partial<CacheConfig>): MetricsCache {
|
||||||
|
return new MetricsCache(redis, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export helper to create a mock cache for testing
|
||||||
|
export function createMockCache(): MetricsCache {
|
||||||
|
const mockRedis = {
|
||||||
|
get: async () => null,
|
||||||
|
setex: async () => 'OK',
|
||||||
|
del: async () => 0,
|
||||||
|
keys: async () => [],
|
||||||
|
exists: async () => 0,
|
||||||
|
ttl: async () => -1,
|
||||||
|
expire: async () => 1,
|
||||||
|
info: async () => 'used_memory:0',
|
||||||
|
} as unknown as Redis;
|
||||||
|
|
||||||
|
return new MetricsCache(mockRedis);
|
||||||
|
}
|
||||||
622
apps/api/src/services/metrics/metrics.service.ts
Normal file
622
apps/api/src/services/metrics/metrics.service.ts
Normal file
@@ -0,0 +1,622 @@
|
|||||||
|
/**
|
||||||
|
* Metrics Service
|
||||||
|
*
|
||||||
|
* Main service that orchestrates all metrics calculations.
|
||||||
|
* Provides a unified API for:
|
||||||
|
* - Dashboard metrics
|
||||||
|
* - Metric history
|
||||||
|
* - Period comparisons
|
||||||
|
* - Real-time and cached data
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DatabaseConnection } from '@horux/database';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import {
|
||||||
|
MetricType,
|
||||||
|
MetricPeriod,
|
||||||
|
DashboardMetrics,
|
||||||
|
MetricHistory,
|
||||||
|
MetricComparisonReport,
|
||||||
|
MetricComparison,
|
||||||
|
MetricValue,
|
||||||
|
createMetricValue,
|
||||||
|
getPeriodDateRange,
|
||||||
|
getPreviousPeriod,
|
||||||
|
periodToString,
|
||||||
|
CoreMetricType,
|
||||||
|
StartupMetricType,
|
||||||
|
EnterpriseMetricType,
|
||||||
|
} from './metrics.types';
|
||||||
|
import { CoreMetricsCalculator, createCoreMetricsCalculator } from './core.metrics';
|
||||||
|
import { StartupMetricsCalculator, createStartupMetricsCalculator } from './startup.metrics';
|
||||||
|
import { EnterpriseMetricsCalculator, createEnterpriseMetricsCalculator } from './enterprise.metrics';
|
||||||
|
import { MetricsCache, createMetricsCache } from './metrics.cache';
|
||||||
|
|
||||||
|
export interface MetricsServiceOptions {
|
||||||
|
enableCache?: boolean;
|
||||||
|
enableStartupMetrics?: boolean;
|
||||||
|
enableEnterpriseMetrics?: boolean;
|
||||||
|
defaultCurrency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MetricsService {
|
||||||
|
private db: DatabaseConnection;
|
||||||
|
private cache: MetricsCache | null;
|
||||||
|
private coreMetrics: CoreMetricsCalculator;
|
||||||
|
private startupMetrics: StartupMetricsCalculator;
|
||||||
|
private enterpriseMetrics: EnterpriseMetricsCalculator;
|
||||||
|
private options: MetricsServiceOptions;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
redis?: Redis,
|
||||||
|
options?: MetricsServiceOptions
|
||||||
|
) {
|
||||||
|
this.db = db;
|
||||||
|
this.options = {
|
||||||
|
enableCache: true,
|
||||||
|
enableStartupMetrics: true,
|
||||||
|
enableEnterpriseMetrics: true,
|
||||||
|
defaultCurrency: 'MXN',
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cache = redis && this.options.enableCache
|
||||||
|
? createMetricsCache(redis)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
this.coreMetrics = createCoreMetricsCalculator(db);
|
||||||
|
this.startupMetrics = createStartupMetricsCalculator(db);
|
||||||
|
this.enterpriseMetrics = createEnterpriseMetricsCalculator(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dashboard metrics for a tenant
|
||||||
|
*/
|
||||||
|
async getDashboardMetrics(
|
||||||
|
tenantId: string,
|
||||||
|
period: MetricPeriod
|
||||||
|
): Promise<DashboardMetrics> {
|
||||||
|
const currency = this.options.defaultCurrency || 'MXN';
|
||||||
|
const { dateFrom, dateTo } = getPeriodDateRange(period);
|
||||||
|
const previousPeriod = getPreviousPeriod(period);
|
||||||
|
const { dateFrom: prevDateFrom, dateTo: prevDateTo } = getPeriodDateRange(previousPeriod);
|
||||||
|
|
||||||
|
// Calculate core metrics for current period
|
||||||
|
const [
|
||||||
|
revenueResult,
|
||||||
|
expensesResult,
|
||||||
|
netProfitResult,
|
||||||
|
cashFlowResult,
|
||||||
|
receivableResult,
|
||||||
|
payableResult,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.getMetricWithCache(tenantId, 'revenue', period, async () =>
|
||||||
|
this.coreMetrics.calculateRevenue(tenantId, dateFrom, dateTo, { currency })
|
||||||
|
),
|
||||||
|
this.getMetricWithCache(tenantId, 'expenses', period, async () =>
|
||||||
|
this.coreMetrics.calculateExpenses(tenantId, dateFrom, dateTo, { currency })
|
||||||
|
),
|
||||||
|
this.getMetricWithCache(tenantId, 'net_profit', period, async () =>
|
||||||
|
this.coreMetrics.calculateNetProfit(tenantId, dateFrom, dateTo, { currency })
|
||||||
|
),
|
||||||
|
this.getMetricWithCache(tenantId, 'cash_flow', period, async () =>
|
||||||
|
this.coreMetrics.calculateCashFlow(tenantId, dateFrom, dateTo, { currency })
|
||||||
|
),
|
||||||
|
this.getMetricWithCache(tenantId, 'accounts_receivable', period, async () =>
|
||||||
|
this.coreMetrics.calculateAccountsReceivable(tenantId, dateTo, { currency })
|
||||||
|
),
|
||||||
|
this.getMetricWithCache(tenantId, 'accounts_payable', period, async () =>
|
||||||
|
this.coreMetrics.calculateAccountsPayable(tenantId, dateTo, { currency })
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Calculate previous period metrics for comparison
|
||||||
|
const [
|
||||||
|
prevRevenueResult,
|
||||||
|
prevExpensesResult,
|
||||||
|
prevNetProfitResult,
|
||||||
|
prevCashFlowResult,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.getMetricWithCache(tenantId, 'revenue', previousPeriod, async () =>
|
||||||
|
this.coreMetrics.calculateRevenue(tenantId, prevDateFrom, prevDateTo, { currency })
|
||||||
|
),
|
||||||
|
this.getMetricWithCache(tenantId, 'expenses', previousPeriod, async () =>
|
||||||
|
this.coreMetrics.calculateExpenses(tenantId, prevDateFrom, prevDateTo, { currency })
|
||||||
|
),
|
||||||
|
this.getMetricWithCache(tenantId, 'net_profit', previousPeriod, async () =>
|
||||||
|
this.coreMetrics.calculateNetProfit(tenantId, prevDateFrom, prevDateTo, { currency })
|
||||||
|
),
|
||||||
|
this.getMetricWithCache(tenantId, 'cash_flow', previousPeriod, async () =>
|
||||||
|
this.coreMetrics.calculateCashFlow(tenantId, prevDateFrom, prevDateTo, { currency })
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Build comparisons
|
||||||
|
const comparison = {
|
||||||
|
revenue: this.buildComparison(
|
||||||
|
revenueResult.totalRevenue.amount,
|
||||||
|
prevRevenueResult.totalRevenue.amount
|
||||||
|
),
|
||||||
|
expenses: this.buildComparison(
|
||||||
|
expensesResult.totalExpenses.amount,
|
||||||
|
prevExpensesResult.totalExpenses.amount
|
||||||
|
),
|
||||||
|
netProfit: this.buildComparison(
|
||||||
|
netProfitResult.profit.amount,
|
||||||
|
prevNetProfitResult.profit.amount
|
||||||
|
),
|
||||||
|
cashFlow: this.buildComparison(
|
||||||
|
cashFlowResult.netCashFlow.amount,
|
||||||
|
prevCashFlowResult.netCashFlow.amount
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build base dashboard
|
||||||
|
const dashboard: DashboardMetrics = {
|
||||||
|
period,
|
||||||
|
generatedAt: new Date(),
|
||||||
|
currency,
|
||||||
|
revenue: createMetricValue(revenueResult.totalRevenue.amount, { currency }),
|
||||||
|
expenses: createMetricValue(expensesResult.totalExpenses.amount, { currency }),
|
||||||
|
netProfit: createMetricValue(netProfitResult.profit.amount, { currency }),
|
||||||
|
profitMargin: createMetricValue(netProfitResult.margin.value * 100, { unit: '%' }),
|
||||||
|
cashFlow: createMetricValue(cashFlowResult.netCashFlow.amount, { currency }),
|
||||||
|
accountsReceivable: createMetricValue(receivableResult.totalReceivable.amount, { currency }),
|
||||||
|
accountsPayable: createMetricValue(payableResult.totalPayable.amount, { currency }),
|
||||||
|
comparison,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add startup metrics if enabled
|
||||||
|
if (this.options.enableStartupMetrics) {
|
||||||
|
const [mrrResult, arrResult, churnResult, runwayResult] = await Promise.all([
|
||||||
|
this.getMetricWithCache(tenantId, 'mrr', period, async () =>
|
||||||
|
this.startupMetrics.calculateMRR(tenantId, dateTo, { currency })
|
||||||
|
),
|
||||||
|
this.getMetricWithCache(tenantId, 'arr', period, async () =>
|
||||||
|
this.startupMetrics.calculateARR(tenantId, dateTo, { currency })
|
||||||
|
),
|
||||||
|
this.getMetricWithCache(tenantId, 'churn_rate', period, async () =>
|
||||||
|
this.startupMetrics.calculateChurnRate(tenantId, dateFrom, dateTo, { currency })
|
||||||
|
),
|
||||||
|
this.getMetricWithCache(tenantId, 'runway', period, async () => {
|
||||||
|
// Get current cash for runway calculation
|
||||||
|
const cashResult = await this.db.query<{ balance: string }>(
|
||||||
|
`SELECT COALESCE(SUM(balance), 0) as balance FROM bank_accounts WHERE is_active = true`,
|
||||||
|
[],
|
||||||
|
{ tenant: { tenantId, schemaName: `tenant_${tenantId}` } }
|
||||||
|
);
|
||||||
|
const currentCash = parseFloat(cashResult.rows[0]?.balance || '0');
|
||||||
|
return this.startupMetrics.calculateRunway(tenantId, currentCash, { currency });
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
dashboard.startup = {
|
||||||
|
mrr: createMetricValue(mrrResult.mrr.amount, { currency }),
|
||||||
|
arr: createMetricValue(arrResult.arr.amount, { currency }),
|
||||||
|
churnRate: createMetricValue(churnResult.churnRate.value * 100, { unit: '%' }),
|
||||||
|
runway: createMetricValue(runwayResult.runwayMonths, { unit: 'meses', precision: 1 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add enterprise metrics if enabled
|
||||||
|
if (this.options.enableEnterpriseMetrics) {
|
||||||
|
const [ebitdaResult, currentRatioResult, quickRatioResult] = await Promise.all([
|
||||||
|
this.getMetricWithCache(tenantId, 'ebitda', period, async () =>
|
||||||
|
this.enterpriseMetrics.calculateEBITDA(tenantId, dateFrom, dateTo, { currency })
|
||||||
|
),
|
||||||
|
this.getMetricWithCache(tenantId, 'current_ratio', period, async () =>
|
||||||
|
this.enterpriseMetrics.calculateCurrentRatio(tenantId, dateTo, { currency })
|
||||||
|
),
|
||||||
|
this.getMetricWithCache(tenantId, 'quick_ratio', period, async () =>
|
||||||
|
this.enterpriseMetrics.calculateQuickRatio(tenantId, dateTo, { currency })
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
dashboard.enterprise = {
|
||||||
|
ebitda: createMetricValue(ebitdaResult.ebitda.amount, { currency }),
|
||||||
|
currentRatio: createMetricValue(currentRatioResult.ratio.value, { precision: 2 }),
|
||||||
|
quickRatio: createMetricValue(quickRatioResult.ratio.value, { precision: 2 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return dashboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get historical data for a specific metric
|
||||||
|
*/
|
||||||
|
async getMetricHistory(
|
||||||
|
tenantId: string,
|
||||||
|
metric: MetricType,
|
||||||
|
periods: MetricPeriod[]
|
||||||
|
): Promise<MetricHistory> {
|
||||||
|
const currency = this.options.defaultCurrency || 'MXN';
|
||||||
|
const values: MetricHistory['periods'] = [];
|
||||||
|
|
||||||
|
for (const period of periods) {
|
||||||
|
const { dateFrom, dateTo } = getPeriodDateRange(period);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const value = await this.calculateSingleMetric(
|
||||||
|
tenantId,
|
||||||
|
metric,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
currency
|
||||||
|
);
|
||||||
|
|
||||||
|
values.push({
|
||||||
|
period,
|
||||||
|
value,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error calculating ${metric} for period ${periodToString(period)}:`, error);
|
||||||
|
// Add zero value for failed calculations
|
||||||
|
values.push({
|
||||||
|
period,
|
||||||
|
value: createMetricValue(0, { currency }),
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate trend
|
||||||
|
const trend = this.calculateTrend(values.map(v => v.value.raw));
|
||||||
|
|
||||||
|
return {
|
||||||
|
metric,
|
||||||
|
periods: values,
|
||||||
|
trend,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare metrics between two periods
|
||||||
|
*/
|
||||||
|
async compareMetrics(
|
||||||
|
tenantId: string,
|
||||||
|
period1: MetricPeriod,
|
||||||
|
period2: MetricPeriod
|
||||||
|
): Promise<MetricComparisonReport> {
|
||||||
|
const currency = this.options.defaultCurrency || 'MXN';
|
||||||
|
const { dateFrom: date1From, dateTo: date1To } = getPeriodDateRange(period1);
|
||||||
|
const { dateFrom: date2From, dateTo: date2To } = getPeriodDateRange(period2);
|
||||||
|
|
||||||
|
// Define metrics to compare
|
||||||
|
const metricsToCompare: MetricType[] = [
|
||||||
|
'revenue',
|
||||||
|
'expenses',
|
||||||
|
'net_profit',
|
||||||
|
'cash_flow',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (this.options.enableStartupMetrics) {
|
||||||
|
metricsToCompare.push('mrr', 'arr', 'burn_rate');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.enableEnterpriseMetrics) {
|
||||||
|
metricsToCompare.push('ebitda', 'current_ratio');
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparisons: MetricComparisonReport['metrics'] = [];
|
||||||
|
|
||||||
|
for (const metric of metricsToCompare) {
|
||||||
|
try {
|
||||||
|
const [value1, value2] = await Promise.all([
|
||||||
|
this.calculateSingleMetric(tenantId, metric, date1From, date1To, currency),
|
||||||
|
this.calculateSingleMetric(tenantId, metric, date2From, date2To, currency),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const change = value2.raw - value1.raw;
|
||||||
|
const changePercentage = value1.raw !== 0 ? (change / value1.raw) * 100 : 0;
|
||||||
|
|
||||||
|
comparisons.push({
|
||||||
|
metric,
|
||||||
|
period1Value: value1,
|
||||||
|
period2Value: value2,
|
||||||
|
change,
|
||||||
|
changePercentage,
|
||||||
|
trend: change > 0 ? 'up' : change < 0 ? 'down' : 'stable',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error comparing ${metric}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate summary
|
||||||
|
const summary = this.generateComparisonSummary(comparisons);
|
||||||
|
|
||||||
|
return {
|
||||||
|
period1,
|
||||||
|
period2,
|
||||||
|
metrics: comparisons,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate cache when data changes
|
||||||
|
*/
|
||||||
|
async invalidateMetricsCache(
|
||||||
|
tenantId: string,
|
||||||
|
affectedPeriods?: MetricPeriod[]
|
||||||
|
): Promise<void> {
|
||||||
|
if (this.cache) {
|
||||||
|
await this.cache.invalidateCache(tenantId, affectedPeriods);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warmup cache for a tenant
|
||||||
|
*/
|
||||||
|
async warmupCache(tenantId: string): Promise<{ success: number; failed: number }> {
|
||||||
|
if (!this.cache) {
|
||||||
|
return { success: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.cache.warmupCache(tenantId, async (metric, period) => {
|
||||||
|
const { dateFrom, dateTo } = getPeriodDateRange(period);
|
||||||
|
return this.calculateSingleMetric(
|
||||||
|
tenantId,
|
||||||
|
metric,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
this.options.defaultCurrency || 'MXN'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics
|
||||||
|
*/
|
||||||
|
async getCacheStats() {
|
||||||
|
if (!this.cache) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.cache.getStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Private Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get metric with caching support
|
||||||
|
*/
|
||||||
|
private async getMetricWithCache<T>(
|
||||||
|
tenantId: string,
|
||||||
|
metric: MetricType,
|
||||||
|
period: MetricPeriod,
|
||||||
|
calculateFn: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
if (this.cache) {
|
||||||
|
return this.cache.getOrCalculate(tenantId, metric, period, calculateFn);
|
||||||
|
}
|
||||||
|
return calculateFn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate a single metric value
|
||||||
|
*/
|
||||||
|
private async calculateSingleMetric(
|
||||||
|
tenantId: string,
|
||||||
|
metric: MetricType,
|
||||||
|
dateFrom: Date,
|
||||||
|
dateTo: Date,
|
||||||
|
currency: string
|
||||||
|
): Promise<MetricValue> {
|
||||||
|
const options = { currency };
|
||||||
|
|
||||||
|
switch (metric) {
|
||||||
|
// Core metrics
|
||||||
|
case 'revenue': {
|
||||||
|
const result = await this.coreMetrics.calculateRevenue(tenantId, dateFrom, dateTo, options);
|
||||||
|
return createMetricValue(result.totalRevenue.amount, { currency });
|
||||||
|
}
|
||||||
|
case 'expenses': {
|
||||||
|
const result = await this.coreMetrics.calculateExpenses(tenantId, dateFrom, dateTo, options);
|
||||||
|
return createMetricValue(result.totalExpenses.amount, { currency });
|
||||||
|
}
|
||||||
|
case 'gross_profit': {
|
||||||
|
const result = await this.coreMetrics.calculateGrossProfit(tenantId, dateFrom, dateTo, options);
|
||||||
|
return createMetricValue(result.profit.amount, { currency });
|
||||||
|
}
|
||||||
|
case 'net_profit': {
|
||||||
|
const result = await this.coreMetrics.calculateNetProfit(tenantId, dateFrom, dateTo, options);
|
||||||
|
return createMetricValue(result.profit.amount, { currency });
|
||||||
|
}
|
||||||
|
case 'cash_flow': {
|
||||||
|
const result = await this.coreMetrics.calculateCashFlow(tenantId, dateFrom, dateTo, options);
|
||||||
|
return createMetricValue(result.netCashFlow.amount, { currency });
|
||||||
|
}
|
||||||
|
case 'accounts_receivable': {
|
||||||
|
const result = await this.coreMetrics.calculateAccountsReceivable(tenantId, dateTo, options);
|
||||||
|
return createMetricValue(result.totalReceivable.amount, { currency });
|
||||||
|
}
|
||||||
|
case 'accounts_payable': {
|
||||||
|
const result = await this.coreMetrics.calculateAccountsPayable(tenantId, dateTo, options);
|
||||||
|
return createMetricValue(result.totalPayable.amount, { currency });
|
||||||
|
}
|
||||||
|
case 'vat_position': {
|
||||||
|
const result = await this.coreMetrics.calculateVATPosition(tenantId, dateFrom, dateTo, options);
|
||||||
|
return createMetricValue(result.netPosition.amount, { currency });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Startup metrics
|
||||||
|
case 'mrr': {
|
||||||
|
const result = await this.startupMetrics.calculateMRR(tenantId, dateTo, options);
|
||||||
|
return createMetricValue(result.mrr.amount, { currency });
|
||||||
|
}
|
||||||
|
case 'arr': {
|
||||||
|
const result = await this.startupMetrics.calculateARR(tenantId, dateTo, options);
|
||||||
|
return createMetricValue(result.arr.amount, { currency });
|
||||||
|
}
|
||||||
|
case 'churn_rate': {
|
||||||
|
const result = await this.startupMetrics.calculateChurnRate(tenantId, dateFrom, dateTo, options);
|
||||||
|
return createMetricValue(result.churnRate.value * 100, { unit: '%' });
|
||||||
|
}
|
||||||
|
case 'cac': {
|
||||||
|
const result = await this.startupMetrics.calculateCAC(tenantId, dateFrom, dateTo, options);
|
||||||
|
return createMetricValue(result.cac.amount, { currency });
|
||||||
|
}
|
||||||
|
case 'ltv': {
|
||||||
|
const result = await this.startupMetrics.calculateLTV(tenantId, options);
|
||||||
|
return createMetricValue(result.ltv.amount, { currency });
|
||||||
|
}
|
||||||
|
case 'ltv_cac_ratio': {
|
||||||
|
const result = await this.startupMetrics.calculateLTVCACRatio(tenantId, options);
|
||||||
|
return createMetricValue(result.ratio.value, { precision: 2 });
|
||||||
|
}
|
||||||
|
case 'burn_rate': {
|
||||||
|
const months = Math.ceil((dateTo.getTime() - dateFrom.getTime()) / (1000 * 60 * 60 * 24 * 30));
|
||||||
|
const result = await this.startupMetrics.calculateBurnRate(tenantId, months, options);
|
||||||
|
return createMetricValue(result.netBurnRate.amount, { currency });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enterprise metrics
|
||||||
|
case 'ebitda': {
|
||||||
|
const result = await this.enterpriseMetrics.calculateEBITDA(tenantId, dateFrom, dateTo, options);
|
||||||
|
return createMetricValue(result.ebitda.amount, { currency });
|
||||||
|
}
|
||||||
|
case 'roi': {
|
||||||
|
const result = await this.enterpriseMetrics.calculateROI(tenantId, dateFrom, dateTo, options);
|
||||||
|
return createMetricValue(result.roi.value * 100, { unit: '%' });
|
||||||
|
}
|
||||||
|
case 'roe': {
|
||||||
|
const result = await this.enterpriseMetrics.calculateROE(tenantId, dateFrom, dateTo, options);
|
||||||
|
return createMetricValue(result.roe.value * 100, { unit: '%' });
|
||||||
|
}
|
||||||
|
case 'current_ratio': {
|
||||||
|
const result = await this.enterpriseMetrics.calculateCurrentRatio(tenantId, dateTo, options);
|
||||||
|
return createMetricValue(result.ratio.value, { precision: 2 });
|
||||||
|
}
|
||||||
|
case 'quick_ratio': {
|
||||||
|
const result = await this.enterpriseMetrics.calculateQuickRatio(tenantId, dateTo, options);
|
||||||
|
return createMetricValue(result.ratio.value, { precision: 2 });
|
||||||
|
}
|
||||||
|
case 'debt_ratio': {
|
||||||
|
const result = await this.enterpriseMetrics.calculateDebtRatio(tenantId, dateTo, options);
|
||||||
|
return createMetricValue(result.ratio.value * 100, { unit: '%' });
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown metric type: ${metric}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build metric comparison object
|
||||||
|
*/
|
||||||
|
private buildComparison(current: number, previous: number): MetricComparison {
|
||||||
|
const change = current - previous;
|
||||||
|
const changePercentage = previous !== 0 ? (change / previous) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
current,
|
||||||
|
previous,
|
||||||
|
change,
|
||||||
|
changePercentage,
|
||||||
|
trend: change > 0 ? 'up' : change < 0 ? 'down' : 'stable',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate trend from a series of values
|
||||||
|
*/
|
||||||
|
private calculateTrend(values: number[]): MetricHistory['trend'] {
|
||||||
|
if (values.length < 2) {
|
||||||
|
return {
|
||||||
|
direction: 'stable',
|
||||||
|
averageChange: 0,
|
||||||
|
volatility: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes: number[] = [];
|
||||||
|
for (let i = 1; i < values.length; i++) {
|
||||||
|
changes.push(values[i] - values[i - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const averageChange = changes.reduce((sum, c) => sum + c, 0) / changes.length;
|
||||||
|
|
||||||
|
// Calculate volatility (standard deviation of changes)
|
||||||
|
const squaredDiffs = changes.map(c => Math.pow(c - averageChange, 2));
|
||||||
|
const volatility = Math.sqrt(squaredDiffs.reduce((sum, d) => sum + d, 0) / changes.length);
|
||||||
|
|
||||||
|
let direction: 'up' | 'down' | 'stable';
|
||||||
|
if (averageChange > 0) {
|
||||||
|
direction = 'up';
|
||||||
|
} else if (averageChange < 0) {
|
||||||
|
direction = 'down';
|
||||||
|
} else {
|
||||||
|
direction = 'stable';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
direction,
|
||||||
|
averageChange,
|
||||||
|
volatility,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate comparison summary
|
||||||
|
*/
|
||||||
|
private generateComparisonSummary(comparisons: MetricComparisonReport['metrics']): string {
|
||||||
|
const improvements = comparisons.filter(c => c.trend === 'up');
|
||||||
|
const declines = comparisons.filter(c => c.trend === 'down');
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (improvements.length > 0) {
|
||||||
|
parts.push(`${improvements.length} metricas mejoraron`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (declines.length > 0) {
|
||||||
|
parts.push(`${declines.length} metricas empeoraron`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return 'Sin cambios significativos entre los periodos.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight most significant changes
|
||||||
|
const sorted = [...comparisons].sort((a, b) =>
|
||||||
|
Math.abs(b.changePercentage) - Math.abs(a.changePercentage)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sorted.length > 0 && Math.abs(sorted[0].changePercentage) > 10) {
|
||||||
|
const most = sorted[0];
|
||||||
|
const direction = most.trend === 'up' ? 'aumento' : 'disminuyo';
|
||||||
|
parts.push(
|
||||||
|
`Cambio mas significativo: ${most.metric} ${direction} ${Math.abs(most.changePercentage).toFixed(1)}%`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('. ') + '.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factory functions
|
||||||
|
let metricsServiceInstance: MetricsService | null = null;
|
||||||
|
|
||||||
|
export function getMetricsService(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
redis?: Redis,
|
||||||
|
options?: MetricsServiceOptions
|
||||||
|
): MetricsService {
|
||||||
|
if (!metricsServiceInstance) {
|
||||||
|
metricsServiceInstance = new MetricsService(db, redis, options);
|
||||||
|
}
|
||||||
|
return metricsServiceInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMetricsService(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
redis?: Redis,
|
||||||
|
options?: MetricsServiceOptions
|
||||||
|
): MetricsService {
|
||||||
|
return new MetricsService(db, redis, options);
|
||||||
|
}
|
||||||
734
apps/api/src/services/metrics/metrics.types.ts
Normal file
734
apps/api/src/services/metrics/metrics.types.ts
Normal file
@@ -0,0 +1,734 @@
|
|||||||
|
/**
|
||||||
|
* Metrics Engine Types
|
||||||
|
*
|
||||||
|
* Type definitions for the Horux Strategy metrics system.
|
||||||
|
* Supports Core, Startup, and Enterprise metrics with multi-tenant support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Time Period Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type PeriodType =
|
||||||
|
| 'daily'
|
||||||
|
| 'weekly'
|
||||||
|
| 'monthly'
|
||||||
|
| 'quarterly'
|
||||||
|
| 'yearly'
|
||||||
|
| 'custom';
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
dateFrom: Date;
|
||||||
|
dateTo: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricPeriod {
|
||||||
|
type: PeriodType;
|
||||||
|
year: number;
|
||||||
|
month?: number; // 1-12
|
||||||
|
quarter?: number; // 1-4
|
||||||
|
week?: number; // 1-53
|
||||||
|
day?: number; // 1-31
|
||||||
|
dateRange?: DateRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeriodComparison {
|
||||||
|
currentPeriod: MetricPeriod;
|
||||||
|
previousPeriod: MetricPeriod;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metric Categories
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type MetricCategory =
|
||||||
|
| 'core'
|
||||||
|
| 'startup'
|
||||||
|
| 'enterprise';
|
||||||
|
|
||||||
|
export type CoreMetricType =
|
||||||
|
| 'revenue'
|
||||||
|
| 'expenses'
|
||||||
|
| 'gross_profit'
|
||||||
|
| 'net_profit'
|
||||||
|
| 'cash_flow'
|
||||||
|
| 'accounts_receivable'
|
||||||
|
| 'accounts_payable'
|
||||||
|
| 'aging_receivable'
|
||||||
|
| 'aging_payable'
|
||||||
|
| 'vat_position';
|
||||||
|
|
||||||
|
export type StartupMetricType =
|
||||||
|
| 'mrr'
|
||||||
|
| 'arr'
|
||||||
|
| 'churn_rate'
|
||||||
|
| 'cac'
|
||||||
|
| 'ltv'
|
||||||
|
| 'ltv_cac_ratio'
|
||||||
|
| 'runway'
|
||||||
|
| 'burn_rate';
|
||||||
|
|
||||||
|
export type EnterpriseMetricType =
|
||||||
|
| 'ebitda'
|
||||||
|
| 'roi'
|
||||||
|
| 'roe'
|
||||||
|
| 'current_ratio'
|
||||||
|
| 'quick_ratio'
|
||||||
|
| 'debt_ratio';
|
||||||
|
|
||||||
|
export type MetricType = CoreMetricType | StartupMetricType | EnterpriseMetricType;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metric Values
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface MonetaryValue {
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PercentageValue {
|
||||||
|
value: number;
|
||||||
|
formatted: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RatioValue {
|
||||||
|
value: number;
|
||||||
|
numerator: number;
|
||||||
|
denominator: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricValue {
|
||||||
|
raw: number;
|
||||||
|
formatted: string;
|
||||||
|
currency?: string;
|
||||||
|
unit?: string;
|
||||||
|
precision?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Core Metric Results
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface RevenueResult {
|
||||||
|
totalRevenue: MonetaryValue;
|
||||||
|
byCategory: Array<{
|
||||||
|
categoryId: string;
|
||||||
|
categoryName: string;
|
||||||
|
amount: MonetaryValue;
|
||||||
|
percentage: number;
|
||||||
|
}>;
|
||||||
|
byProduct?: Array<{
|
||||||
|
productId: string;
|
||||||
|
productName: string;
|
||||||
|
amount: MonetaryValue;
|
||||||
|
quantity: number;
|
||||||
|
}>;
|
||||||
|
invoiceCount: number;
|
||||||
|
averageInvoiceValue: MonetaryValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpensesResult {
|
||||||
|
totalExpenses: MonetaryValue;
|
||||||
|
byCategory: Array<{
|
||||||
|
categoryId: string;
|
||||||
|
categoryName: string;
|
||||||
|
amount: MonetaryValue;
|
||||||
|
percentage: number;
|
||||||
|
}>;
|
||||||
|
fixedExpenses: MonetaryValue;
|
||||||
|
variableExpenses: MonetaryValue;
|
||||||
|
expenseCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfitResult {
|
||||||
|
profit: MonetaryValue;
|
||||||
|
revenue: MonetaryValue;
|
||||||
|
costs: MonetaryValue;
|
||||||
|
margin: PercentageValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashFlowResult {
|
||||||
|
netCashFlow: MonetaryValue;
|
||||||
|
operatingActivities: MonetaryValue;
|
||||||
|
investingActivities: MonetaryValue;
|
||||||
|
financingActivities: MonetaryValue;
|
||||||
|
openingBalance: MonetaryValue;
|
||||||
|
closingBalance: MonetaryValue;
|
||||||
|
breakdown: Array<{
|
||||||
|
date: Date;
|
||||||
|
inflow: number;
|
||||||
|
outflow: number;
|
||||||
|
netFlow: number;
|
||||||
|
balance: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountsReceivableResult {
|
||||||
|
totalReceivable: MonetaryValue;
|
||||||
|
current: MonetaryValue;
|
||||||
|
overdue: MonetaryValue;
|
||||||
|
overduePercentage: PercentageValue;
|
||||||
|
customerCount: number;
|
||||||
|
invoiceCount: number;
|
||||||
|
averageDaysOutstanding: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountsPayableResult {
|
||||||
|
totalPayable: MonetaryValue;
|
||||||
|
current: MonetaryValue;
|
||||||
|
overdue: MonetaryValue;
|
||||||
|
overduePercentage: PercentageValue;
|
||||||
|
supplierCount: number;
|
||||||
|
invoiceCount: number;
|
||||||
|
averageDaysPayable: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgingBucket =
|
||||||
|
| 'current'
|
||||||
|
| '1-30'
|
||||||
|
| '31-60'
|
||||||
|
| '61-90'
|
||||||
|
| '90+';
|
||||||
|
|
||||||
|
export interface AgingBucketData {
|
||||||
|
bucket: AgingBucket;
|
||||||
|
label: string;
|
||||||
|
amount: MonetaryValue;
|
||||||
|
count: number;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgingReportResult {
|
||||||
|
type: 'receivable' | 'payable';
|
||||||
|
asOfDate: Date;
|
||||||
|
totalAmount: MonetaryValue;
|
||||||
|
buckets: AgingBucketData[];
|
||||||
|
details: Array<{
|
||||||
|
entityId: string;
|
||||||
|
entityName: string;
|
||||||
|
buckets: Record<AgingBucket, number>;
|
||||||
|
total: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VATPositionResult {
|
||||||
|
vatCollected: MonetaryValue;
|
||||||
|
vatPaid: MonetaryValue;
|
||||||
|
netPosition: MonetaryValue;
|
||||||
|
isPayable: boolean;
|
||||||
|
breakdown: Array<{
|
||||||
|
rate: number;
|
||||||
|
collected: number;
|
||||||
|
paid: number;
|
||||||
|
net: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Startup Metric Results
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface MRRResult {
|
||||||
|
mrr: MonetaryValue;
|
||||||
|
newMRR: MonetaryValue;
|
||||||
|
expansionMRR: MonetaryValue;
|
||||||
|
contractionMRR: MonetaryValue;
|
||||||
|
churnedMRR: MonetaryValue;
|
||||||
|
netNewMRR: MonetaryValue;
|
||||||
|
customerCount: number;
|
||||||
|
arpu: MonetaryValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ARRResult {
|
||||||
|
arr: MonetaryValue;
|
||||||
|
mrr: MonetaryValue;
|
||||||
|
growthRate: PercentageValue;
|
||||||
|
projectedEndOfYear: MonetaryValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChurnRateResult {
|
||||||
|
churnRate: PercentageValue;
|
||||||
|
churnedCustomers: number;
|
||||||
|
totalCustomers: number;
|
||||||
|
churnedMRR: MonetaryValue;
|
||||||
|
revenueChurnRate: PercentageValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CACResult {
|
||||||
|
cac: MonetaryValue;
|
||||||
|
totalMarketingSpend: MonetaryValue;
|
||||||
|
totalSalesSpend: MonetaryValue;
|
||||||
|
newCustomers: number;
|
||||||
|
breakdown: {
|
||||||
|
marketing: MonetaryValue;
|
||||||
|
sales: MonetaryValue;
|
||||||
|
other: MonetaryValue;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LTVResult {
|
||||||
|
ltv: MonetaryValue;
|
||||||
|
averageCustomerLifespan: number; // In months
|
||||||
|
arpu: MonetaryValue;
|
||||||
|
grossMargin: PercentageValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LTVCACRatioResult {
|
||||||
|
ratio: RatioValue;
|
||||||
|
ltv: MonetaryValue;
|
||||||
|
cac: MonetaryValue;
|
||||||
|
isHealthy: boolean;
|
||||||
|
recommendation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunwayResult {
|
||||||
|
runwayMonths: number;
|
||||||
|
currentCash: MonetaryValue;
|
||||||
|
monthlyBurnRate: MonetaryValue;
|
||||||
|
projectedZeroDate: Date | null;
|
||||||
|
isHealthy: boolean;
|
||||||
|
recommendation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BurnRateResult {
|
||||||
|
grossBurnRate: MonetaryValue;
|
||||||
|
netBurnRate: MonetaryValue;
|
||||||
|
revenue: MonetaryValue;
|
||||||
|
expenses: MonetaryValue;
|
||||||
|
monthlyTrend: Array<{
|
||||||
|
month: string;
|
||||||
|
grossBurn: number;
|
||||||
|
netBurn: number;
|
||||||
|
revenue: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Enterprise Metric Results
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface EBITDAResult {
|
||||||
|
ebitda: MonetaryValue;
|
||||||
|
operatingIncome: MonetaryValue;
|
||||||
|
depreciation: MonetaryValue;
|
||||||
|
amortization: MonetaryValue;
|
||||||
|
margin: PercentageValue;
|
||||||
|
revenue: MonetaryValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ROIResult {
|
||||||
|
roi: PercentageValue;
|
||||||
|
netProfit: MonetaryValue;
|
||||||
|
totalInvestment: MonetaryValue;
|
||||||
|
annualized: PercentageValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ROEResult {
|
||||||
|
roe: PercentageValue;
|
||||||
|
netIncome: MonetaryValue;
|
||||||
|
shareholdersEquity: MonetaryValue;
|
||||||
|
annualized: PercentageValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentRatioResult {
|
||||||
|
ratio: RatioValue;
|
||||||
|
currentAssets: MonetaryValue;
|
||||||
|
currentLiabilities: MonetaryValue;
|
||||||
|
isHealthy: boolean;
|
||||||
|
interpretation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuickRatioResult {
|
||||||
|
ratio: RatioValue;
|
||||||
|
currentAssets: MonetaryValue;
|
||||||
|
inventory: MonetaryValue;
|
||||||
|
currentLiabilities: MonetaryValue;
|
||||||
|
isHealthy: boolean;
|
||||||
|
interpretation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DebtRatioResult {
|
||||||
|
ratio: RatioValue;
|
||||||
|
totalDebt: MonetaryValue;
|
||||||
|
totalAssets: MonetaryValue;
|
||||||
|
interpretation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Dashboard & Aggregated Results
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface DashboardMetrics {
|
||||||
|
period: MetricPeriod;
|
||||||
|
generatedAt: Date;
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
// Core
|
||||||
|
revenue: MetricValue;
|
||||||
|
expenses: MetricValue;
|
||||||
|
netProfit: MetricValue;
|
||||||
|
profitMargin: MetricValue;
|
||||||
|
cashFlow: MetricValue;
|
||||||
|
accountsReceivable: MetricValue;
|
||||||
|
accountsPayable: MetricValue;
|
||||||
|
|
||||||
|
// Comparison with previous period
|
||||||
|
comparison: {
|
||||||
|
revenue: MetricComparison;
|
||||||
|
expenses: MetricComparison;
|
||||||
|
netProfit: MetricComparison;
|
||||||
|
cashFlow: MetricComparison;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional startup/enterprise metrics
|
||||||
|
startup?: {
|
||||||
|
mrr: MetricValue;
|
||||||
|
arr: MetricValue;
|
||||||
|
churnRate: MetricValue;
|
||||||
|
runway: MetricValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
enterprise?: {
|
||||||
|
ebitda: MetricValue;
|
||||||
|
currentRatio: MetricValue;
|
||||||
|
quickRatio: MetricValue;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricComparison {
|
||||||
|
current: number;
|
||||||
|
previous: number;
|
||||||
|
change: number;
|
||||||
|
changePercentage: number;
|
||||||
|
trend: 'up' | 'down' | 'stable';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricHistory {
|
||||||
|
metric: MetricType;
|
||||||
|
periods: Array<{
|
||||||
|
period: MetricPeriod;
|
||||||
|
value: MetricValue;
|
||||||
|
timestamp: Date;
|
||||||
|
}>;
|
||||||
|
trend: {
|
||||||
|
direction: 'up' | 'down' | 'stable';
|
||||||
|
averageChange: number;
|
||||||
|
volatility: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricComparisonReport {
|
||||||
|
period1: MetricPeriod;
|
||||||
|
period2: MetricPeriod;
|
||||||
|
metrics: Array<{
|
||||||
|
metric: MetricType;
|
||||||
|
period1Value: MetricValue;
|
||||||
|
period2Value: MetricValue;
|
||||||
|
change: number;
|
||||||
|
changePercentage: number;
|
||||||
|
trend: 'up' | 'down' | 'stable';
|
||||||
|
}>;
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Anomaly Detection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AnomalyType =
|
||||||
|
| 'significant_variation'
|
||||||
|
| 'negative_trend'
|
||||||
|
| 'out_of_range'
|
||||||
|
| 'sudden_spike'
|
||||||
|
| 'sudden_drop';
|
||||||
|
|
||||||
|
export type AnomalySeverity = 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
|
||||||
|
export interface Anomaly {
|
||||||
|
id: string;
|
||||||
|
metric: MetricType;
|
||||||
|
type: AnomalyType;
|
||||||
|
severity: AnomalySeverity;
|
||||||
|
description: string;
|
||||||
|
detectedAt: Date;
|
||||||
|
period: MetricPeriod;
|
||||||
|
currentValue: number;
|
||||||
|
expectedValue: number;
|
||||||
|
deviation: number;
|
||||||
|
deviationPercentage: number;
|
||||||
|
recommendation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnomalyDetectionResult {
|
||||||
|
tenantId: string;
|
||||||
|
period: MetricPeriod;
|
||||||
|
analyzedAt: Date;
|
||||||
|
anomalies: Anomaly[];
|
||||||
|
healthScore: number; // 0-100
|
||||||
|
alertLevel: 'none' | 'watch' | 'warning' | 'critical';
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cache Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface CachedMetric {
|
||||||
|
key: string;
|
||||||
|
tenantId: string;
|
||||||
|
metric: MetricType;
|
||||||
|
period: MetricPeriod;
|
||||||
|
value: unknown;
|
||||||
|
calculatedAt: Date;
|
||||||
|
expiresAt: Date;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheConfig {
|
||||||
|
ttlSeconds: number;
|
||||||
|
staleWhileRevalidate: boolean;
|
||||||
|
warmupEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheStats {
|
||||||
|
hits: number;
|
||||||
|
misses: number;
|
||||||
|
hitRate: number;
|
||||||
|
totalEntries: number;
|
||||||
|
memoryUsage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Query Options
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface MetricQueryOptions {
|
||||||
|
tenantId: string;
|
||||||
|
schemaName?: string;
|
||||||
|
currency?: string;
|
||||||
|
includeDetails?: boolean;
|
||||||
|
useCache?: boolean;
|
||||||
|
forceRefresh?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utility Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type MetricResult<T extends MetricType> =
|
||||||
|
T extends 'revenue' ? RevenueResult :
|
||||||
|
T extends 'expenses' ? ExpensesResult :
|
||||||
|
T extends 'gross_profit' ? ProfitResult :
|
||||||
|
T extends 'net_profit' ? ProfitResult :
|
||||||
|
T extends 'cash_flow' ? CashFlowResult :
|
||||||
|
T extends 'accounts_receivable' ? AccountsReceivableResult :
|
||||||
|
T extends 'accounts_payable' ? AccountsPayableResult :
|
||||||
|
T extends 'aging_receivable' ? AgingReportResult :
|
||||||
|
T extends 'aging_payable' ? AgingReportResult :
|
||||||
|
T extends 'vat_position' ? VATPositionResult :
|
||||||
|
T extends 'mrr' ? MRRResult :
|
||||||
|
T extends 'arr' ? ARRResult :
|
||||||
|
T extends 'churn_rate' ? ChurnRateResult :
|
||||||
|
T extends 'cac' ? CACResult :
|
||||||
|
T extends 'ltv' ? LTVResult :
|
||||||
|
T extends 'ltv_cac_ratio' ? LTVCACRatioResult :
|
||||||
|
T extends 'runway' ? RunwayResult :
|
||||||
|
T extends 'burn_rate' ? BurnRateResult :
|
||||||
|
T extends 'ebitda' ? EBITDAResult :
|
||||||
|
T extends 'roi' ? ROIResult :
|
||||||
|
T extends 'roe' ? ROEResult :
|
||||||
|
T extends 'current_ratio' ? CurrentRatioResult :
|
||||||
|
T extends 'quick_ratio' ? QuickRatioResult :
|
||||||
|
T extends 'debt_ratio' ? DebtRatioResult :
|
||||||
|
never;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Export Helper Functions for Type Formatting
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number, currency: string = 'MXN'): string {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPercentage(value: number, decimals: number = 2): string {
|
||||||
|
return `${(value * 100).toFixed(decimals)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNumber(value: number, decimals: number = 2): string {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMonetaryValue(amount: number, currency: string = 'MXN'): MonetaryValue {
|
||||||
|
return { amount, currency };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPercentageValue(value: number): PercentageValue {
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
formatted: formatPercentage(value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRatioValue(numerator: number, denominator: number): RatioValue {
|
||||||
|
return {
|
||||||
|
value: denominator !== 0 ? numerator / denominator : 0,
|
||||||
|
numerator,
|
||||||
|
denominator,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMetricValue(
|
||||||
|
raw: number,
|
||||||
|
options?: { currency?: string; unit?: string; precision?: number }
|
||||||
|
): MetricValue {
|
||||||
|
const precision = options?.precision ?? 2;
|
||||||
|
let formatted: string;
|
||||||
|
|
||||||
|
if (options?.currency) {
|
||||||
|
formatted = formatCurrency(raw, options.currency);
|
||||||
|
} else if (options?.unit === '%') {
|
||||||
|
formatted = formatPercentage(raw / 100, precision);
|
||||||
|
} else {
|
||||||
|
formatted = formatNumber(raw, precision);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
formatted,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Period Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function createPeriodFromDate(date: Date, type: PeriodType): MetricPeriod {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth() + 1;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'daily':
|
||||||
|
return { type, year, month, day: date.getDate() };
|
||||||
|
case 'weekly':
|
||||||
|
return { type, year, week: getWeekNumber(date) };
|
||||||
|
case 'monthly':
|
||||||
|
return { type, year, month };
|
||||||
|
case 'quarterly':
|
||||||
|
return { type, year, quarter: Math.ceil(month / 3) };
|
||||||
|
case 'yearly':
|
||||||
|
return { type, year };
|
||||||
|
default:
|
||||||
|
return { type: 'monthly', year, month };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPeriodDateRange(period: MetricPeriod): DateRange {
|
||||||
|
if (period.dateRange) {
|
||||||
|
return period.dateRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dateFrom: Date;
|
||||||
|
let dateTo: Date;
|
||||||
|
|
||||||
|
switch (period.type) {
|
||||||
|
case 'daily':
|
||||||
|
dateFrom = new Date(period.year, (period.month || 1) - 1, period.day || 1);
|
||||||
|
dateTo = new Date(period.year, (period.month || 1) - 1, period.day || 1, 23, 59, 59, 999);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'weekly':
|
||||||
|
const firstDayOfYear = new Date(period.year, 0, 1);
|
||||||
|
const daysOffset = (period.week || 1 - 1) * 7;
|
||||||
|
dateFrom = new Date(firstDayOfYear.getTime() + daysOffset * 24 * 60 * 60 * 1000);
|
||||||
|
dateTo = new Date(dateFrom.getTime() + 6 * 24 * 60 * 60 * 1000);
|
||||||
|
dateTo.setHours(23, 59, 59, 999);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'monthly':
|
||||||
|
dateFrom = new Date(period.year, (period.month || 1) - 1, 1);
|
||||||
|
dateTo = new Date(period.year, period.month || 1, 0, 23, 59, 59, 999);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'quarterly':
|
||||||
|
const quarterStartMonth = ((period.quarter || 1) - 1) * 3;
|
||||||
|
dateFrom = new Date(period.year, quarterStartMonth, 1);
|
||||||
|
dateTo = new Date(period.year, quarterStartMonth + 3, 0, 23, 59, 59, 999);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'yearly':
|
||||||
|
dateFrom = new Date(period.year, 0, 1);
|
||||||
|
dateTo = new Date(period.year, 11, 31, 23, 59, 59, 999);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
dateFrom = new Date(period.year, 0, 1);
|
||||||
|
dateTo = new Date(period.year, 11, 31, 23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dateFrom, dateTo };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPreviousPeriod(period: MetricPeriod): MetricPeriod {
|
||||||
|
switch (period.type) {
|
||||||
|
case 'daily':
|
||||||
|
const prevDay = new Date(period.year, (period.month || 1) - 1, (period.day || 1) - 1);
|
||||||
|
return createPeriodFromDate(prevDay, 'daily');
|
||||||
|
|
||||||
|
case 'weekly':
|
||||||
|
if ((period.week || 1) > 1) {
|
||||||
|
return { ...period, week: (period.week || 1) - 1 };
|
||||||
|
}
|
||||||
|
return { type: 'weekly', year: period.year - 1, week: 52 };
|
||||||
|
|
||||||
|
case 'monthly':
|
||||||
|
if ((period.month || 1) > 1) {
|
||||||
|
return { ...period, month: (period.month || 1) - 1 };
|
||||||
|
}
|
||||||
|
return { type: 'monthly', year: period.year - 1, month: 12 };
|
||||||
|
|
||||||
|
case 'quarterly':
|
||||||
|
if ((period.quarter || 1) > 1) {
|
||||||
|
return { ...period, quarter: (period.quarter || 1) - 1 };
|
||||||
|
}
|
||||||
|
return { type: 'quarterly', year: period.year - 1, quarter: 4 };
|
||||||
|
|
||||||
|
case 'yearly':
|
||||||
|
return { type: 'yearly', year: period.year - 1 };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { ...period, year: period.year - 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekNumber(date: Date): number {
|
||||||
|
const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
|
||||||
|
const pastDaysOfYear = (date.getTime() - firstDayOfYear.getTime()) / 86400000;
|
||||||
|
return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function periodToString(period: MetricPeriod): string {
|
||||||
|
switch (period.type) {
|
||||||
|
case 'daily':
|
||||||
|
return `${period.year}-${String(period.month).padStart(2, '0')}-${String(period.day).padStart(2, '0')}`;
|
||||||
|
case 'weekly':
|
||||||
|
return `${period.year}-W${String(period.week).padStart(2, '0')}`;
|
||||||
|
case 'monthly':
|
||||||
|
return `${period.year}-${String(period.month).padStart(2, '0')}`;
|
||||||
|
case 'quarterly':
|
||||||
|
return `${period.year}-Q${period.quarter}`;
|
||||||
|
case 'yearly':
|
||||||
|
return `${period.year}`;
|
||||||
|
default:
|
||||||
|
return `${period.year}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
678
apps/api/src/services/metrics/startup.metrics.ts
Normal file
678
apps/api/src/services/metrics/startup.metrics.ts
Normal file
@@ -0,0 +1,678 @@
|
|||||||
|
/**
|
||||||
|
* Startup Metrics Calculator
|
||||||
|
*
|
||||||
|
* SaaS and subscription-based business metrics:
|
||||||
|
* - MRR (Monthly Recurring Revenue)
|
||||||
|
* - ARR (Annual Recurring Revenue)
|
||||||
|
* - Churn Rate
|
||||||
|
* - CAC (Customer Acquisition Cost)
|
||||||
|
* - LTV (Lifetime Value)
|
||||||
|
* - LTV/CAC Ratio
|
||||||
|
* - Runway
|
||||||
|
* - Burn Rate
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DatabaseConnection, TenantContext } from '@horux/database';
|
||||||
|
import {
|
||||||
|
MRRResult,
|
||||||
|
ARRResult,
|
||||||
|
ChurnRateResult,
|
||||||
|
CACResult,
|
||||||
|
LTVResult,
|
||||||
|
LTVCACRatioResult,
|
||||||
|
RunwayResult,
|
||||||
|
BurnRateResult,
|
||||||
|
createMonetaryValue,
|
||||||
|
createPercentageValue,
|
||||||
|
createRatioValue,
|
||||||
|
MetricQueryOptions,
|
||||||
|
} from './metrics.types';
|
||||||
|
|
||||||
|
export class StartupMetricsCalculator {
|
||||||
|
private db: DatabaseConnection;
|
||||||
|
|
||||||
|
constructor(db: DatabaseConnection) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tenant context for queries
|
||||||
|
*/
|
||||||
|
private getTenantContext(tenantId: string, schemaName?: string): TenantContext {
|
||||||
|
return {
|
||||||
|
tenantId,
|
||||||
|
schemaName: schemaName || `tenant_${tenantId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate MRR (Monthly Recurring Revenue)
|
||||||
|
*/
|
||||||
|
async calculateMRR(
|
||||||
|
tenantId: string,
|
||||||
|
asOfDate: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<MRRResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
// Get first day of month
|
||||||
|
const monthStart = new Date(asOfDate.getFullYear(), asOfDate.getMonth(), 1);
|
||||||
|
const monthEnd = new Date(asOfDate.getFullYear(), asOfDate.getMonth() + 1, 0);
|
||||||
|
const prevMonthStart = new Date(asOfDate.getFullYear(), asOfDate.getMonth() - 1, 1);
|
||||||
|
const prevMonthEnd = new Date(asOfDate.getFullYear(), asOfDate.getMonth(), 0);
|
||||||
|
|
||||||
|
// Get current MRR from active subscriptions
|
||||||
|
const currentMRRQuery = await this.db.query<{
|
||||||
|
mrr: string;
|
||||||
|
customer_count: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN billing_period = 'monthly' THEN amount
|
||||||
|
WHEN billing_period = 'quarterly' THEN amount / 3
|
||||||
|
WHEN billing_period = 'yearly' THEN amount / 12
|
||||||
|
ELSE amount
|
||||||
|
END
|
||||||
|
), 0) as mrr,
|
||||||
|
COUNT(DISTINCT customer_id) as customer_count
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND start_date <= $1
|
||||||
|
AND (end_date IS NULL OR end_date > $1)`,
|
||||||
|
[asOfDate],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get new MRR (new subscriptions this month)
|
||||||
|
const newMRRQuery = await this.db.query<{ mrr: string }>(
|
||||||
|
`SELECT COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN billing_period = 'monthly' THEN amount
|
||||||
|
WHEN billing_period = 'quarterly' THEN amount / 3
|
||||||
|
WHEN billing_period = 'yearly' THEN amount / 12
|
||||||
|
ELSE amount
|
||||||
|
END
|
||||||
|
), 0) as mrr
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND start_date >= $1
|
||||||
|
AND start_date <= $2`,
|
||||||
|
[monthStart, monthEnd],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get expansion MRR (upgrades this month)
|
||||||
|
const expansionMRRQuery = await this.db.query<{ mrr: string }>(
|
||||||
|
`SELECT COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN new_billing_period = 'monthly' THEN new_amount - old_amount
|
||||||
|
WHEN new_billing_period = 'quarterly' THEN (new_amount - old_amount) / 3
|
||||||
|
WHEN new_billing_period = 'yearly' THEN (new_amount - old_amount) / 12
|
||||||
|
ELSE new_amount - old_amount
|
||||||
|
END
|
||||||
|
), 0) as mrr
|
||||||
|
FROM subscription_changes
|
||||||
|
WHERE change_type = 'upgrade'
|
||||||
|
AND change_date >= $1
|
||||||
|
AND change_date <= $2`,
|
||||||
|
[monthStart, monthEnd],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get contraction MRR (downgrades this month)
|
||||||
|
const contractionMRRQuery = await this.db.query<{ mrr: string }>(
|
||||||
|
`SELECT COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN new_billing_period = 'monthly' THEN old_amount - new_amount
|
||||||
|
WHEN new_billing_period = 'quarterly' THEN (old_amount - new_amount) / 3
|
||||||
|
WHEN new_billing_period = 'yearly' THEN (old_amount - new_amount) / 12
|
||||||
|
ELSE old_amount - new_amount
|
||||||
|
END
|
||||||
|
), 0) as mrr
|
||||||
|
FROM subscription_changes
|
||||||
|
WHERE change_type = 'downgrade'
|
||||||
|
AND change_date >= $1
|
||||||
|
AND change_date <= $2`,
|
||||||
|
[monthStart, monthEnd],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get churned MRR (cancellations this month)
|
||||||
|
const churnedMRRQuery = await this.db.query<{ mrr: string }>(
|
||||||
|
`SELECT COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN billing_period = 'monthly' THEN amount
|
||||||
|
WHEN billing_period = 'quarterly' THEN amount / 3
|
||||||
|
WHEN billing_period = 'yearly' THEN amount / 12
|
||||||
|
ELSE amount
|
||||||
|
END
|
||||||
|
), 0) as mrr
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE status = 'cancelled'
|
||||||
|
AND cancelled_at >= $1
|
||||||
|
AND cancelled_at <= $2`,
|
||||||
|
[monthStart, monthEnd],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
const mrr = parseFloat(currentMRRQuery.rows[0]?.mrr || '0');
|
||||||
|
const customerCount = parseInt(currentMRRQuery.rows[0]?.customer_count || '0');
|
||||||
|
const newMRR = parseFloat(newMRRQuery.rows[0]?.mrr || '0');
|
||||||
|
const expansionMRR = parseFloat(expansionMRRQuery.rows[0]?.mrr || '0');
|
||||||
|
const contractionMRR = parseFloat(contractionMRRQuery.rows[0]?.mrr || '0');
|
||||||
|
const churnedMRR = parseFloat(churnedMRRQuery.rows[0]?.mrr || '0');
|
||||||
|
const netNewMRR = newMRR + expansionMRR - contractionMRR - churnedMRR;
|
||||||
|
const arpu = customerCount > 0 ? mrr / customerCount : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mrr: createMonetaryValue(mrr, currency),
|
||||||
|
newMRR: createMonetaryValue(newMRR, currency),
|
||||||
|
expansionMRR: createMonetaryValue(expansionMRR, currency),
|
||||||
|
contractionMRR: createMonetaryValue(contractionMRR, currency),
|
||||||
|
churnedMRR: createMonetaryValue(churnedMRR, currency),
|
||||||
|
netNewMRR: createMonetaryValue(netNewMRR, currency),
|
||||||
|
customerCount,
|
||||||
|
arpu: createMonetaryValue(arpu, currency),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate ARR (Annual Recurring Revenue)
|
||||||
|
*/
|
||||||
|
async calculateARR(
|
||||||
|
tenantId: string,
|
||||||
|
asOfDate: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<ARRResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
// Get current MRR first
|
||||||
|
const mrrResult = await this.calculateMRR(tenantId, asOfDate, options);
|
||||||
|
const mrr = mrrResult.mrr.amount;
|
||||||
|
const arr = mrr * 12;
|
||||||
|
|
||||||
|
// Get MRR from 12 months ago to calculate growth rate
|
||||||
|
const yearAgo = new Date(asOfDate);
|
||||||
|
yearAgo.setFullYear(yearAgo.getFullYear() - 1);
|
||||||
|
|
||||||
|
const prevYearMRRQuery = await this.db.query<{ mrr: string }>(
|
||||||
|
`SELECT COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN billing_period = 'monthly' THEN amount
|
||||||
|
WHEN billing_period = 'quarterly' THEN amount / 3
|
||||||
|
WHEN billing_period = 'yearly' THEN amount / 12
|
||||||
|
ELSE amount
|
||||||
|
END
|
||||||
|
), 0) as mrr
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND start_date <= $1
|
||||||
|
AND (end_date IS NULL OR end_date > $1)`,
|
||||||
|
[yearAgo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
const prevMRR = parseFloat(prevYearMRRQuery.rows[0]?.mrr || '0');
|
||||||
|
const prevARR = prevMRR * 12;
|
||||||
|
const growthRate = prevARR > 0 ? (arr - prevARR) / prevARR : 0;
|
||||||
|
|
||||||
|
// Project end of year ARR based on current growth rate
|
||||||
|
const monthsRemaining = 12 - (asOfDate.getMonth() + 1);
|
||||||
|
const monthlyGrowthRate = prevMRR > 0 ? Math.pow(mrr / prevMRR, 1 / 12) - 1 : 0;
|
||||||
|
const projectedMRR = mrr * Math.pow(1 + monthlyGrowthRate, monthsRemaining);
|
||||||
|
const projectedEndOfYear = projectedMRR * 12;
|
||||||
|
|
||||||
|
return {
|
||||||
|
arr: createMonetaryValue(arr, currency),
|
||||||
|
mrr: createMonetaryValue(mrr, currency),
|
||||||
|
growthRate: createPercentageValue(growthRate),
|
||||||
|
projectedEndOfYear: createMonetaryValue(projectedEndOfYear, currency),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate churn rate
|
||||||
|
*/
|
||||||
|
async calculateChurnRate(
|
||||||
|
tenantId: string,
|
||||||
|
dateFrom: Date,
|
||||||
|
dateTo: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<ChurnRateResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
// Get customers at start of period
|
||||||
|
const startCustomersQuery = await this.db.query<{
|
||||||
|
count: string;
|
||||||
|
mrr: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(DISTINCT customer_id) as count,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN billing_period = 'monthly' THEN amount
|
||||||
|
WHEN billing_period = 'quarterly' THEN amount / 3
|
||||||
|
WHEN billing_period = 'yearly' THEN amount / 12
|
||||||
|
ELSE amount
|
||||||
|
END
|
||||||
|
), 0) as mrr
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND start_date <= $1
|
||||||
|
AND (end_date IS NULL OR end_date > $1)`,
|
||||||
|
[dateFrom],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get churned customers in period
|
||||||
|
const churnedQuery = await this.db.query<{
|
||||||
|
count: string;
|
||||||
|
mrr: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(DISTINCT customer_id) as count,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN billing_period = 'monthly' THEN amount
|
||||||
|
WHEN billing_period = 'quarterly' THEN amount / 3
|
||||||
|
WHEN billing_period = 'yearly' THEN amount / 12
|
||||||
|
ELSE amount
|
||||||
|
END
|
||||||
|
), 0) as mrr
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE status = 'cancelled'
|
||||||
|
AND cancelled_at >= $1
|
||||||
|
AND cancelled_at <= $2`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalCustomers = parseInt(startCustomersQuery.rows[0]?.count || '0');
|
||||||
|
const startMRR = parseFloat(startCustomersQuery.rows[0]?.mrr || '0');
|
||||||
|
const churnedCustomers = parseInt(churnedQuery.rows[0]?.count || '0');
|
||||||
|
const churnedMRR = parseFloat(churnedQuery.rows[0]?.mrr || '0');
|
||||||
|
|
||||||
|
const churnRate = totalCustomers > 0 ? churnedCustomers / totalCustomers : 0;
|
||||||
|
const revenueChurnRate = startMRR > 0 ? churnedMRR / startMRR : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
churnRate: createPercentageValue(churnRate),
|
||||||
|
churnedCustomers,
|
||||||
|
totalCustomers,
|
||||||
|
churnedMRR: createMonetaryValue(churnedMRR, currency),
|
||||||
|
revenueChurnRate: createPercentageValue(revenueChurnRate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate CAC (Customer Acquisition Cost)
|
||||||
|
*/
|
||||||
|
async calculateCAC(
|
||||||
|
tenantId: string,
|
||||||
|
dateFrom: Date,
|
||||||
|
dateTo: Date,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<CACResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
// Get marketing and sales expenses
|
||||||
|
const expensesQuery = await this.db.query<{
|
||||||
|
marketing: string;
|
||||||
|
sales: string;
|
||||||
|
other: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN c.type = 'marketing' THEN e.total_amount ELSE 0 END), 0) as marketing,
|
||||||
|
COALESCE(SUM(CASE WHEN c.type = 'sales' THEN e.total_amount ELSE 0 END), 0) as sales,
|
||||||
|
COALESCE(SUM(CASE WHEN c.type = 'acquisition' AND c.type NOT IN ('marketing', 'sales') THEN e.total_amount ELSE 0 END), 0) as other
|
||||||
|
FROM expenses e
|
||||||
|
JOIN categories c ON c.id = e.category_id
|
||||||
|
WHERE e.status = 'paid'
|
||||||
|
AND e.expense_date >= $1
|
||||||
|
AND e.expense_date <= $2
|
||||||
|
AND c.type IN ('marketing', 'sales', 'acquisition')`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get new customers acquired
|
||||||
|
const customersQuery = await this.db.query<{ count: string }>(
|
||||||
|
`SELECT COUNT(DISTINCT customer_id) as count
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE start_date >= $1
|
||||||
|
AND start_date <= $2
|
||||||
|
AND is_first_subscription = true`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
const marketing = parseFloat(expensesQuery.rows[0]?.marketing || '0');
|
||||||
|
const sales = parseFloat(expensesQuery.rows[0]?.sales || '0');
|
||||||
|
const other = parseFloat(expensesQuery.rows[0]?.other || '0');
|
||||||
|
const totalSpend = marketing + sales + other;
|
||||||
|
const newCustomers = parseInt(customersQuery.rows[0]?.count || '0');
|
||||||
|
const cac = newCustomers > 0 ? totalSpend / newCustomers : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cac: createMonetaryValue(cac, currency),
|
||||||
|
totalMarketingSpend: createMonetaryValue(marketing, currency),
|
||||||
|
totalSalesSpend: createMonetaryValue(sales, currency),
|
||||||
|
newCustomers,
|
||||||
|
breakdown: {
|
||||||
|
marketing: createMonetaryValue(marketing, currency),
|
||||||
|
sales: createMonetaryValue(sales, currency),
|
||||||
|
other: createMonetaryValue(other, currency),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate LTV (Customer Lifetime Value)
|
||||||
|
*/
|
||||||
|
async calculateLTV(
|
||||||
|
tenantId: string,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<LTVResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
// Get average customer lifespan in months
|
||||||
|
const lifespanQuery = await this.db.query<{
|
||||||
|
avg_lifespan_months: string;
|
||||||
|
}>(
|
||||||
|
`WITH customer_lifespans AS (
|
||||||
|
SELECT
|
||||||
|
customer_id,
|
||||||
|
MIN(start_date) as first_subscription,
|
||||||
|
CASE
|
||||||
|
WHEN MAX(cancelled_at) IS NOT NULL THEN MAX(cancelled_at)
|
||||||
|
ELSE CURRENT_DATE
|
||||||
|
END as last_active,
|
||||||
|
EXTRACT(MONTH FROM AGE(
|
||||||
|
CASE
|
||||||
|
WHEN MAX(cancelled_at) IS NOT NULL THEN MAX(cancelled_at)
|
||||||
|
ELSE CURRENT_DATE
|
||||||
|
END,
|
||||||
|
MIN(start_date)
|
||||||
|
)) + EXTRACT(YEAR FROM AGE(
|
||||||
|
CASE
|
||||||
|
WHEN MAX(cancelled_at) IS NOT NULL THEN MAX(cancelled_at)
|
||||||
|
ELSE CURRENT_DATE
|
||||||
|
END,
|
||||||
|
MIN(start_date)
|
||||||
|
)) * 12 as lifespan_months
|
||||||
|
FROM subscriptions
|
||||||
|
GROUP BY customer_id
|
||||||
|
)
|
||||||
|
SELECT COALESCE(AVG(lifespan_months), 12) as avg_lifespan_months
|
||||||
|
FROM customer_lifespans
|
||||||
|
WHERE lifespan_months > 0`,
|
||||||
|
[],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get current ARPU
|
||||||
|
const arpuQuery = await this.db.query<{
|
||||||
|
arpu: string;
|
||||||
|
}>(
|
||||||
|
`WITH active_subscriptions AS (
|
||||||
|
SELECT
|
||||||
|
customer_id,
|
||||||
|
SUM(
|
||||||
|
CASE
|
||||||
|
WHEN billing_period = 'monthly' THEN amount
|
||||||
|
WHEN billing_period = 'quarterly' THEN amount / 3
|
||||||
|
WHEN billing_period = 'yearly' THEN amount / 12
|
||||||
|
ELSE amount
|
||||||
|
END
|
||||||
|
) as monthly_revenue
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE status = 'active'
|
||||||
|
GROUP BY customer_id
|
||||||
|
)
|
||||||
|
SELECT COALESCE(AVG(monthly_revenue), 0) as arpu
|
||||||
|
FROM active_subscriptions`,
|
||||||
|
[],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get gross margin (assuming from settings or calculate from data)
|
||||||
|
const marginQuery = await this.db.query<{
|
||||||
|
gross_margin: string;
|
||||||
|
}>(
|
||||||
|
`WITH revenue AS (
|
||||||
|
SELECT COALESCE(SUM(total_amount), 0) as total
|
||||||
|
FROM invoices
|
||||||
|
WHERE status IN ('paid', 'partial')
|
||||||
|
AND issue_date >= CURRENT_DATE - INTERVAL '12 months'
|
||||||
|
),
|
||||||
|
cogs AS (
|
||||||
|
SELECT COALESCE(SUM(e.total_amount), 0) as total
|
||||||
|
FROM expenses e
|
||||||
|
JOIN categories c ON c.id = e.category_id
|
||||||
|
WHERE e.status = 'paid'
|
||||||
|
AND e.expense_date >= CURRENT_DATE - INTERVAL '12 months'
|
||||||
|
AND c.type = 'cogs'
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN (SELECT total FROM revenue) > 0
|
||||||
|
THEN ((SELECT total FROM revenue) - (SELECT total FROM cogs)) / (SELECT total FROM revenue)
|
||||||
|
ELSE 0.7
|
||||||
|
END as gross_margin`,
|
||||||
|
[],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
const avgLifespanMonths = parseFloat(lifespanQuery.rows[0]?.avg_lifespan_months || '12');
|
||||||
|
const arpu = parseFloat(arpuQuery.rows[0]?.arpu || '0');
|
||||||
|
const grossMargin = parseFloat(marginQuery.rows[0]?.gross_margin || '0.7');
|
||||||
|
const ltv = arpu * avgLifespanMonths * grossMargin;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ltv: createMonetaryValue(ltv, currency),
|
||||||
|
averageCustomerLifespan: avgLifespanMonths,
|
||||||
|
arpu: createMonetaryValue(arpu, currency),
|
||||||
|
grossMargin: createPercentageValue(grossMargin),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate LTV/CAC Ratio
|
||||||
|
*/
|
||||||
|
async calculateLTVCACRatio(
|
||||||
|
tenantId: string,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<LTVCACRatioResult> {
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
// Get LTV
|
||||||
|
const ltvResult = await this.calculateLTV(tenantId, options);
|
||||||
|
const ltv = ltvResult.ltv.amount;
|
||||||
|
|
||||||
|
// Get CAC for last 12 months
|
||||||
|
const dateFrom = new Date();
|
||||||
|
dateFrom.setMonth(dateFrom.getMonth() - 12);
|
||||||
|
const dateTo = new Date();
|
||||||
|
|
||||||
|
const cacResult = await this.calculateCAC(tenantId, dateFrom, dateTo, options);
|
||||||
|
const cac = cacResult.cac.amount;
|
||||||
|
|
||||||
|
const ratio = cac > 0 ? ltv / cac : 0;
|
||||||
|
const isHealthy = ratio >= 3;
|
||||||
|
|
||||||
|
let recommendation: string;
|
||||||
|
if (ratio >= 5) {
|
||||||
|
recommendation = 'Excelente ratio. Considera aumentar la inversion en adquisicion de clientes.';
|
||||||
|
} else if (ratio >= 3) {
|
||||||
|
recommendation = 'Ratio saludable. El negocio es sostenible a largo plazo.';
|
||||||
|
} else if (ratio >= 1) {
|
||||||
|
recommendation = 'Ratio bajo. Necesitas mejorar la retencion o reducir costos de adquisicion.';
|
||||||
|
} else {
|
||||||
|
recommendation = 'Ratio critico. Estas perdiendo dinero por cada cliente adquirido.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ratio: createRatioValue(ltv, cac),
|
||||||
|
ltv: createMonetaryValue(ltv, currency),
|
||||||
|
cac: createMonetaryValue(cac, currency),
|
||||||
|
isHealthy,
|
||||||
|
recommendation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate runway (months of cash remaining)
|
||||||
|
*/
|
||||||
|
async calculateRunway(
|
||||||
|
tenantId: string,
|
||||||
|
currentCash: number,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<RunwayResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
// Calculate average monthly burn rate from last 3 months
|
||||||
|
const burnRateResult = await this.calculateBurnRate(tenantId, 3, options);
|
||||||
|
const monthlyBurnRate = burnRateResult.netBurnRate.amount;
|
||||||
|
|
||||||
|
// Calculate runway
|
||||||
|
let runwayMonths: number;
|
||||||
|
let projectedZeroDate: Date | null = null;
|
||||||
|
let isHealthy: boolean;
|
||||||
|
let recommendation: string;
|
||||||
|
|
||||||
|
if (monthlyBurnRate <= 0) {
|
||||||
|
// Company is profitable or break-even
|
||||||
|
runwayMonths = Infinity;
|
||||||
|
isHealthy = true;
|
||||||
|
recommendation = 'La empresa es rentable. No hay runway que calcular.';
|
||||||
|
} else {
|
||||||
|
runwayMonths = currentCash / monthlyBurnRate;
|
||||||
|
projectedZeroDate = new Date();
|
||||||
|
projectedZeroDate.setMonth(projectedZeroDate.getMonth() + Math.floor(runwayMonths));
|
||||||
|
|
||||||
|
if (runwayMonths >= 18) {
|
||||||
|
isHealthy = true;
|
||||||
|
recommendation = 'Runway saludable. Tienes tiempo suficiente para crecer.';
|
||||||
|
} else if (runwayMonths >= 12) {
|
||||||
|
isHealthy = true;
|
||||||
|
recommendation = 'Runway adecuado. Considera empezar a planear tu proxima ronda o reducir gastos.';
|
||||||
|
} else if (runwayMonths >= 6) {
|
||||||
|
isHealthy = false;
|
||||||
|
recommendation = 'Runway limitado. Es urgente buscar financiamiento o reducir gastos significativamente.';
|
||||||
|
} else {
|
||||||
|
isHealthy = false;
|
||||||
|
recommendation = 'Runway critico. Accion inmediata requerida para sobrevivir.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
runwayMonths: runwayMonths === Infinity ? 999 : Math.round(runwayMonths * 10) / 10,
|
||||||
|
currentCash: createMonetaryValue(currentCash, currency),
|
||||||
|
monthlyBurnRate: createMonetaryValue(monthlyBurnRate, currency),
|
||||||
|
projectedZeroDate,
|
||||||
|
isHealthy,
|
||||||
|
recommendation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate burn rate
|
||||||
|
*/
|
||||||
|
async calculateBurnRate(
|
||||||
|
tenantId: string,
|
||||||
|
months: number = 3,
|
||||||
|
options?: MetricQueryOptions
|
||||||
|
): Promise<BurnRateResult> {
|
||||||
|
const tenant = this.getTenantContext(tenantId, options?.schemaName);
|
||||||
|
const currency = options?.currency || 'MXN';
|
||||||
|
|
||||||
|
const dateFrom = new Date();
|
||||||
|
dateFrom.setMonth(dateFrom.getMonth() - months);
|
||||||
|
const dateTo = new Date();
|
||||||
|
|
||||||
|
// Get monthly breakdown
|
||||||
|
const monthlyQuery = await this.db.query<{
|
||||||
|
month: string;
|
||||||
|
revenue: string;
|
||||||
|
expenses: string;
|
||||||
|
}>(
|
||||||
|
`WITH monthly_data AS (
|
||||||
|
SELECT
|
||||||
|
DATE_TRUNC('month', d.date) as month,
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(total_amount)
|
||||||
|
FROM invoices
|
||||||
|
WHERE status IN ('paid', 'partial')
|
||||||
|
AND DATE_TRUNC('month', issue_date) = DATE_TRUNC('month', d.date)
|
||||||
|
), 0) as revenue,
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(total_amount)
|
||||||
|
FROM expenses
|
||||||
|
WHERE status = 'paid'
|
||||||
|
AND DATE_TRUNC('month', expense_date) = DATE_TRUNC('month', d.date)
|
||||||
|
), 0) as expenses
|
||||||
|
FROM generate_series($1::date, $2::date, '1 month') as d(date)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(month, 'YYYY-MM') as month,
|
||||||
|
revenue,
|
||||||
|
expenses
|
||||||
|
FROM monthly_data
|
||||||
|
ORDER BY month`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
{ tenant }
|
||||||
|
);
|
||||||
|
|
||||||
|
let totalRevenue = 0;
|
||||||
|
let totalExpenses = 0;
|
||||||
|
|
||||||
|
const monthlyTrend = monthlyQuery.rows.map(row => {
|
||||||
|
const revenue = parseFloat(row.revenue);
|
||||||
|
const expenses = parseFloat(row.expenses);
|
||||||
|
totalRevenue += revenue;
|
||||||
|
totalExpenses += expenses;
|
||||||
|
|
||||||
|
return {
|
||||||
|
month: row.month,
|
||||||
|
revenue,
|
||||||
|
grossBurn: expenses,
|
||||||
|
netBurn: expenses - revenue,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const monthCount = monthlyTrend.length || 1;
|
||||||
|
const avgRevenue = totalRevenue / monthCount;
|
||||||
|
const avgExpenses = totalExpenses / monthCount;
|
||||||
|
const grossBurnRate = avgExpenses;
|
||||||
|
const netBurnRate = avgExpenses - avgRevenue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
grossBurnRate: createMonetaryValue(grossBurnRate, currency),
|
||||||
|
netBurnRate: createMonetaryValue(Math.max(0, netBurnRate), currency),
|
||||||
|
revenue: createMonetaryValue(avgRevenue, currency),
|
||||||
|
expenses: createMonetaryValue(avgExpenses, currency),
|
||||||
|
monthlyTrend,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton factory
|
||||||
|
let startupMetricsInstance: StartupMetricsCalculator | null = null;
|
||||||
|
|
||||||
|
export function getStartupMetricsCalculator(db: DatabaseConnection): StartupMetricsCalculator {
|
||||||
|
if (!startupMetricsInstance) {
|
||||||
|
startupMetricsInstance = new StartupMetricsCalculator(db);
|
||||||
|
}
|
||||||
|
return startupMetricsInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStartupMetricsCalculator(db: DatabaseConnection): StartupMetricsCalculator {
|
||||||
|
return new StartupMetricsCalculator(db);
|
||||||
|
}
|
||||||
1161
apps/api/src/services/sat/cfdi.parser.ts
Normal file
1161
apps/api/src/services/sat/cfdi.parser.ts
Normal file
File diff suppressed because it is too large
Load Diff
539
apps/api/src/services/sat/fiel.service.ts
Normal file
539
apps/api/src/services/sat/fiel.service.ts
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
/**
|
||||||
|
* FIEL Service
|
||||||
|
* Manejo de la Firma Electrónica Avanzada (FIEL) del SAT
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { CertificateInfo, FIELValidationResult, FIELError } from './sat.types.js';
|
||||||
|
|
||||||
|
// Constantes para el manejo de certificados
|
||||||
|
const CERTIFICATE_HEADER = '-----BEGIN CERTIFICATE-----';
|
||||||
|
const CERTIFICATE_FOOTER = '-----END CERTIFICATE-----';
|
||||||
|
const RSA_PRIVATE_KEY_HEADER = '-----BEGIN RSA PRIVATE KEY-----';
|
||||||
|
const RSA_PRIVATE_KEY_FOOTER = '-----END RSA PRIVATE KEY-----';
|
||||||
|
const ENCRYPTED_PRIVATE_KEY_HEADER = '-----BEGIN ENCRYPTED PRIVATE KEY-----';
|
||||||
|
const ENCRYPTED_PRIVATE_KEY_FOOTER = '-----END ENCRYPTED PRIVATE KEY-----';
|
||||||
|
|
||||||
|
// Algoritmos de encripción
|
||||||
|
const ENCRYPTION_ALGORITHM = 'aes-256-cbc';
|
||||||
|
const HASH_ALGORITHM = 'sha256';
|
||||||
|
const SIGNATURE_ALGORITHM = 'RSA-SHA256';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clase para el manejo de FIEL
|
||||||
|
*/
|
||||||
|
export class FIELService {
|
||||||
|
/**
|
||||||
|
* Valida un par de certificado y llave privada
|
||||||
|
*/
|
||||||
|
async validateFIEL(
|
||||||
|
cer: Buffer,
|
||||||
|
key: Buffer,
|
||||||
|
password: string
|
||||||
|
): Promise<FIELValidationResult> {
|
||||||
|
try {
|
||||||
|
// Obtener información del certificado
|
||||||
|
const certInfo = await this.getCertificateInfo(cer);
|
||||||
|
|
||||||
|
// Verificar que el certificado no esté expirado
|
||||||
|
if (!certInfo.isValid) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
certificateInfo: certInfo,
|
||||||
|
error: 'El certificado ha expirado o aún no es válido',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intentar descifrar la llave privada
|
||||||
|
let privateKey: crypto.KeyObject;
|
||||||
|
try {
|
||||||
|
privateKey = await this.decryptPrivateKey(key, password);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
certificateInfo: certInfo,
|
||||||
|
error: 'Contraseña incorrecta o llave privada inválida',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que la llave corresponda al certificado
|
||||||
|
const isMatch = await this.verifyKeyPair(cer, privateKey);
|
||||||
|
if (!isMatch) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
certificateInfo: certInfo,
|
||||||
|
error: 'La llave privada no corresponde al certificado',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
certificateInfo: certInfo,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: `Error al validar FIEL: ${errorMessage}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene información del certificado
|
||||||
|
*/
|
||||||
|
async getCertificateInfo(cer: Buffer): Promise<CertificateInfo> {
|
||||||
|
try {
|
||||||
|
// Convertir el certificado a formato PEM si es necesario
|
||||||
|
const pemCert = this.toPEM(cer, 'CERTIFICATE');
|
||||||
|
|
||||||
|
// Crear objeto X509Certificate
|
||||||
|
const x509 = new crypto.X509Certificate(pemCert);
|
||||||
|
|
||||||
|
// Extraer información del subject
|
||||||
|
const subjectParts = this.parseX509Name(x509.subject);
|
||||||
|
const issuerParts = this.parseX509Name(x509.issuer);
|
||||||
|
|
||||||
|
// Extraer RFC del subject (puede estar en diferentes campos)
|
||||||
|
let rfc = '';
|
||||||
|
let nombre = '';
|
||||||
|
let email = '';
|
||||||
|
|
||||||
|
// El RFC generalmente está en el campo serialNumber o en el UID
|
||||||
|
if (subjectParts.serialNumber) {
|
||||||
|
rfc = subjectParts.serialNumber;
|
||||||
|
} else if (subjectParts.UID) {
|
||||||
|
rfc = subjectParts.UID;
|
||||||
|
} else if (subjectParts['2.5.4.45']) {
|
||||||
|
// OID para uniqueIdentifier
|
||||||
|
rfc = subjectParts['2.5.4.45'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nombre del titular
|
||||||
|
nombre = subjectParts.CN || subjectParts.O || '';
|
||||||
|
|
||||||
|
// Email
|
||||||
|
email = subjectParts.emailAddress || '';
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const validFrom = new Date(x509.validFrom);
|
||||||
|
const validTo = new Date(x509.validTo);
|
||||||
|
|
||||||
|
return {
|
||||||
|
serialNumber: x509.serialNumber,
|
||||||
|
subject: {
|
||||||
|
rfc: this.cleanRFC(rfc),
|
||||||
|
nombre,
|
||||||
|
email: email || undefined,
|
||||||
|
},
|
||||||
|
issuer: {
|
||||||
|
cn: issuerParts.CN || '',
|
||||||
|
o: issuerParts.O || '',
|
||||||
|
},
|
||||||
|
validFrom,
|
||||||
|
validTo,
|
||||||
|
isValid: now >= validFrom && now <= validTo,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new FIELError(
|
||||||
|
`Error al leer certificado: ${error instanceof Error ? error.message : 'Error desconocido'}`,
|
||||||
|
'INVALID_CERTIFICATE'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encripta una llave privada con una contraseña
|
||||||
|
*/
|
||||||
|
async encryptPrivateKey(key: Buffer, password: string): Promise<Buffer> {
|
||||||
|
try {
|
||||||
|
// Primero intentar descifrar la llave si viene encriptada
|
||||||
|
// o usarla directamente si no lo está
|
||||||
|
let privateKeyPem: string;
|
||||||
|
|
||||||
|
if (this.isEncryptedKey(key)) {
|
||||||
|
// La llave ya está encriptada, necesitamos la contraseña original
|
||||||
|
// para poder re-encriptarla
|
||||||
|
throw new FIELError(
|
||||||
|
'La llave ya está encriptada. Proporcione la llave sin encriptar.',
|
||||||
|
'INVALID_KEY'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir a PEM si es necesario
|
||||||
|
privateKeyPem = this.toPEM(key, 'RSA PRIVATE KEY');
|
||||||
|
|
||||||
|
// Crear el objeto de llave
|
||||||
|
const keyObject = crypto.createPrivateKey({
|
||||||
|
key: privateKeyPem,
|
||||||
|
format: 'pem',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Encriptar la llave con la contraseña
|
||||||
|
const encryptedKey = keyObject.export({
|
||||||
|
type: 'pkcs8',
|
||||||
|
format: 'pem',
|
||||||
|
cipher: 'aes-256-cbc',
|
||||||
|
passphrase: password,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Buffer.from(encryptedKey as string);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FIELError) throw error;
|
||||||
|
throw new FIELError(
|
||||||
|
`Error al encriptar llave privada: ${error instanceof Error ? error.message : 'Error desconocido'}`,
|
||||||
|
'INVALID_KEY'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Firma datos con la llave privada encriptada
|
||||||
|
*/
|
||||||
|
async signRequest(data: string, encryptedKey: Buffer, password: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
// Descifrar la llave privada
|
||||||
|
const privateKey = await this.decryptPrivateKey(encryptedKey, password);
|
||||||
|
|
||||||
|
// Firmar los datos
|
||||||
|
const sign = crypto.createSign(SIGNATURE_ALGORITHM);
|
||||||
|
sign.update(data);
|
||||||
|
sign.end();
|
||||||
|
|
||||||
|
const signature = sign.sign(privateKey);
|
||||||
|
return signature.toString('base64');
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FIELError) throw error;
|
||||||
|
throw new FIELError(
|
||||||
|
`Error al firmar datos: ${error instanceof Error ? error.message : 'Error desconocido'}`,
|
||||||
|
'PASSWORD_ERROR'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Firma datos con la llave privada y contraseña
|
||||||
|
*/
|
||||||
|
async signWithFIEL(
|
||||||
|
data: string,
|
||||||
|
key: Buffer,
|
||||||
|
password: string
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
const privateKey = await this.decryptPrivateKey(key, password);
|
||||||
|
|
||||||
|
const sign = crypto.createSign(SIGNATURE_ALGORITHM);
|
||||||
|
sign.update(data);
|
||||||
|
sign.end();
|
||||||
|
|
||||||
|
const signature = sign.sign(privateKey);
|
||||||
|
return signature.toString('base64');
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FIELError) throw error;
|
||||||
|
throw new FIELError(
|
||||||
|
`Error al firmar con FIEL: ${error instanceof Error ? error.message : 'Error desconocido'}`,
|
||||||
|
'PASSWORD_ERROR'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera el token de autenticación para el web service del SAT
|
||||||
|
*/
|
||||||
|
async generateAuthToken(
|
||||||
|
cer: Buffer,
|
||||||
|
key: Buffer,
|
||||||
|
password: string,
|
||||||
|
timestamp: Date = new Date()
|
||||||
|
): Promise<string> {
|
||||||
|
// Obtener información del certificado
|
||||||
|
const certInfo = await this.getCertificateInfo(cer);
|
||||||
|
|
||||||
|
// Crear el XML de autenticación
|
||||||
|
const created = timestamp.toISOString();
|
||||||
|
const expires = new Date(timestamp.getTime() + 5 * 60 * 1000).toISOString(); // 5 minutos
|
||||||
|
|
||||||
|
const tokenXml = `
|
||||||
|
<u:Timestamp xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" u:Id="_0">
|
||||||
|
<u:Created>${created}</u:Created>
|
||||||
|
<u:Expires>${expires}</u:Expires>
|
||||||
|
</u:Timestamp>`.trim();
|
||||||
|
|
||||||
|
// Calcular el digest del timestamp
|
||||||
|
const digest = crypto.createHash('sha1').update(tokenXml).digest('base64');
|
||||||
|
|
||||||
|
// Crear el SignedInfo
|
||||||
|
const signedInfo = `
|
||||||
|
<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||||
|
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
||||||
|
<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
|
||||||
|
<Reference URI="#_0">
|
||||||
|
<Transforms>
|
||||||
|
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
||||||
|
</Transforms>
|
||||||
|
<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
|
||||||
|
<DigestValue>${digest}</DigestValue>
|
||||||
|
</Reference>
|
||||||
|
</SignedInfo>`.trim();
|
||||||
|
|
||||||
|
// Firmar el SignedInfo
|
||||||
|
const privateKey = await this.decryptPrivateKey(key, password);
|
||||||
|
const sign = crypto.createSign('RSA-SHA1');
|
||||||
|
sign.update(signedInfo);
|
||||||
|
sign.end();
|
||||||
|
const signature = sign.sign(privateKey).toString('base64');
|
||||||
|
|
||||||
|
// Obtener el certificado en base64
|
||||||
|
const certBase64 = this.getCertificateBase64(cer);
|
||||||
|
|
||||||
|
// Construir el token completo
|
||||||
|
const securityToken = `
|
||||||
|
<o:Security xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" s:mustUnderstand="1">
|
||||||
|
${tokenXml}
|
||||||
|
<o:BinarySecurityToken u:Id="X509Token" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">${certBase64}</o:BinarySecurityToken>
|
||||||
|
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||||
|
${signedInfo}
|
||||||
|
<SignatureValue>${signature}</SignatureValue>
|
||||||
|
<KeyInfo>
|
||||||
|
<o:SecurityTokenReference>
|
||||||
|
<o:Reference URI="#X509Token" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3"/>
|
||||||
|
</o:SecurityTokenReference>
|
||||||
|
</KeyInfo>
|
||||||
|
</Signature>
|
||||||
|
</o:Security>`.trim();
|
||||||
|
|
||||||
|
return securityToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el número de certificado
|
||||||
|
*/
|
||||||
|
getCertificateNumber(cer: Buffer): string {
|
||||||
|
const certInfo = this.getCertificateInfoSync(cer);
|
||||||
|
// El número de certificado es el serial number formateado
|
||||||
|
return this.formatSerialNumber(certInfo.serialNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene información del certificado de forma síncrona
|
||||||
|
*/
|
||||||
|
private getCertificateInfoSync(cer: Buffer): { serialNumber: string } {
|
||||||
|
const pemCert = this.toPEM(cer, 'CERTIFICATE');
|
||||||
|
const x509 = new crypto.X509Certificate(pemCert);
|
||||||
|
return { serialNumber: x509.serialNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea el número serial del certificado
|
||||||
|
*/
|
||||||
|
private formatSerialNumber(serialNumber: string): string {
|
||||||
|
// El serial number viene en hexadecimal, hay que convertirlo
|
||||||
|
// El formato del SAT es tomar los caracteres pares del hex
|
||||||
|
let formatted = '';
|
||||||
|
for (let i = 0; i < serialNumber.length; i += 2) {
|
||||||
|
const hexPair = serialNumber.substring(i, i + 2);
|
||||||
|
const charCode = parseInt(hexPair, 16);
|
||||||
|
if (charCode >= 48 && charCode <= 57) {
|
||||||
|
// Es un dígito (0-9)
|
||||||
|
formatted += String.fromCharCode(charCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return formatted || serialNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Descifra una llave privada con contraseña
|
||||||
|
*/
|
||||||
|
private async decryptPrivateKey(key: Buffer, password: string): Promise<crypto.KeyObject> {
|
||||||
|
try {
|
||||||
|
// Determinar el formato de la llave
|
||||||
|
let keyPem: string;
|
||||||
|
|
||||||
|
if (this.isEncryptedKey(key)) {
|
||||||
|
// La llave está encriptada en formato PKCS#8
|
||||||
|
keyPem = this.toPEM(key, 'ENCRYPTED PRIVATE KEY');
|
||||||
|
} else if (this.isDERFormat(key)) {
|
||||||
|
// La llave está en formato DER (archivo .key del SAT)
|
||||||
|
// Intentar como PKCS#8 encriptado
|
||||||
|
keyPem = this.toPEM(key, 'ENCRYPTED PRIVATE KEY');
|
||||||
|
} else {
|
||||||
|
// Asumir que es PEM
|
||||||
|
keyPem = key.toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear el objeto de llave
|
||||||
|
return crypto.createPrivateKey({
|
||||||
|
key: keyPem,
|
||||||
|
format: 'pem',
|
||||||
|
passphrase: password,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new FIELError(
|
||||||
|
'No se pudo descifrar la llave privada. Verifique la contraseña.',
|
||||||
|
'PASSWORD_ERROR'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica que la llave privada corresponda al certificado
|
||||||
|
*/
|
||||||
|
private async verifyKeyPair(cer: Buffer, privateKey: crypto.KeyObject): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const pemCert = this.toPEM(cer, 'CERTIFICATE');
|
||||||
|
const x509 = new crypto.X509Certificate(pemCert);
|
||||||
|
const publicKey = x509.publicKey;
|
||||||
|
|
||||||
|
// Crear datos de prueba
|
||||||
|
const testData = 'test-data-for-verification';
|
||||||
|
|
||||||
|
// Firmar con la llave privada
|
||||||
|
const sign = crypto.createSign(SIGNATURE_ALGORITHM);
|
||||||
|
sign.update(testData);
|
||||||
|
const signature = sign.sign(privateKey);
|
||||||
|
|
||||||
|
// Verificar con la llave pública del certificado
|
||||||
|
const verify = crypto.createVerify(SIGNATURE_ALGORITHM);
|
||||||
|
verify.update(testData);
|
||||||
|
|
||||||
|
return verify.verify(publicKey, signature);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si una llave está encriptada
|
||||||
|
*/
|
||||||
|
private isEncryptedKey(key: Buffer): boolean {
|
||||||
|
const keyStr = key.toString('utf-8');
|
||||||
|
return keyStr.includes('ENCRYPTED') || keyStr.includes('Proc-Type: 4,ENCRYPTED');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si el buffer está en formato DER
|
||||||
|
*/
|
||||||
|
private isDERFormat(buffer: Buffer): boolean {
|
||||||
|
// DER comienza con una secuencia (0x30)
|
||||||
|
return buffer[0] === 0x30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte un buffer a formato PEM
|
||||||
|
*/
|
||||||
|
private toPEM(buffer: Buffer, type: string): string {
|
||||||
|
const content = buffer.toString('utf-8').trim();
|
||||||
|
|
||||||
|
// Si ya es PEM, devolverlo
|
||||||
|
if (content.includes('-----BEGIN')) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si es DER (binario), convertir a base64
|
||||||
|
const base64 = buffer.toString('base64');
|
||||||
|
|
||||||
|
// Formatear en líneas de 64 caracteres
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let i = 0; i < base64.length; i += 64) {
|
||||||
|
lines.push(base64.substring(i, i + 64));
|
||||||
|
}
|
||||||
|
|
||||||
|
return `-----BEGIN ${type}-----\n${lines.join('\n')}\n-----END ${type}-----`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el certificado en base64
|
||||||
|
*/
|
||||||
|
private getCertificateBase64(cer: Buffer): string {
|
||||||
|
const pemCert = this.toPEM(cer, 'CERTIFICATE');
|
||||||
|
|
||||||
|
// Extraer solo el contenido base64 sin los headers
|
||||||
|
return pemCert
|
||||||
|
.replace(CERTIFICATE_HEADER, '')
|
||||||
|
.replace(CERTIFICATE_FOOTER, '')
|
||||||
|
.replace(/\s/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsea un nombre X.509 (subject o issuer)
|
||||||
|
*/
|
||||||
|
private parseX509Name(name: string): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
|
||||||
|
// El nombre viene en formato "CN=value\nO=value\n..."
|
||||||
|
const parts = name.split('\n');
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const [key, ...valueParts] = part.split('=');
|
||||||
|
if (key && valueParts.length > 0) {
|
||||||
|
result[key.trim()] = valueParts.join('=').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia un RFC (quita espacios y caracteres especiales)
|
||||||
|
*/
|
||||||
|
private cleanRFC(rfc: string): string {
|
||||||
|
return rfc.replace(/[^A-Za-z0-9&]/g, '').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera un digest SHA-256 de los datos
|
||||||
|
*/
|
||||||
|
generateDigest(data: string): string {
|
||||||
|
return crypto.createHash(HASH_ALGORITHM).update(data).digest('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica una firma
|
||||||
|
*/
|
||||||
|
async verifySignature(
|
||||||
|
data: string,
|
||||||
|
signature: string,
|
||||||
|
cer: Buffer
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const pemCert = this.toPEM(cer, 'CERTIFICATE');
|
||||||
|
const x509 = new crypto.X509Certificate(pemCert);
|
||||||
|
const publicKey = x509.publicKey;
|
||||||
|
|
||||||
|
const verify = crypto.createVerify(SIGNATURE_ALGORITHM);
|
||||||
|
verify.update(data);
|
||||||
|
|
||||||
|
return verify.verify(publicKey, Buffer.from(signature, 'base64'));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exportar instancia singleton
|
||||||
|
export const fielService = new FIELService();
|
||||||
|
|
||||||
|
// Exportar funciones helper
|
||||||
|
export async function validateFIEL(
|
||||||
|
cer: Buffer,
|
||||||
|
key: Buffer,
|
||||||
|
password: string
|
||||||
|
): Promise<FIELValidationResult> {
|
||||||
|
return fielService.validateFIEL(cer, key, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptPrivateKey(key: Buffer, password: string): Promise<Buffer> {
|
||||||
|
return fielService.encryptPrivateKey(key, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signRequest(
|
||||||
|
data: string,
|
||||||
|
encryptedKey: Buffer,
|
||||||
|
password: string
|
||||||
|
): Promise<string> {
|
||||||
|
return fielService.signRequest(data, encryptedKey, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCertificateInfo(cer: Buffer): Promise<CertificateInfo> {
|
||||||
|
return fielService.getCertificateInfo(cer);
|
||||||
|
}
|
||||||
803
apps/api/src/services/sat/sat.client.ts
Normal file
803
apps/api/src/services/sat/sat.client.ts
Normal file
@@ -0,0 +1,803 @@
|
|||||||
|
/**
|
||||||
|
* SAT Web Service Client
|
||||||
|
* Cliente para comunicarse con los servicios web del SAT para descarga masiva de CFDIs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as https from 'https';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { URL } from 'url';
|
||||||
|
import {
|
||||||
|
AuthResponse,
|
||||||
|
SolicitudDescarga,
|
||||||
|
SolicitudDescargaResponse,
|
||||||
|
VerificacionDescargaResponse,
|
||||||
|
DescargaPaqueteResponse,
|
||||||
|
TipoSolicitud,
|
||||||
|
TipoComprobante,
|
||||||
|
SATError,
|
||||||
|
SATAuthError,
|
||||||
|
EstadoSolicitud,
|
||||||
|
} from './sat.types.js';
|
||||||
|
import { FIELService } from './fiel.service.js';
|
||||||
|
|
||||||
|
// URLs de los servicios del SAT
|
||||||
|
const SAT_URLS = {
|
||||||
|
production: {
|
||||||
|
autenticacion: 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc',
|
||||||
|
solicitud: 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitudDescargaService.svc',
|
||||||
|
verificacion: 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc',
|
||||||
|
descarga: 'https://cfdidescargamasaborrasolicitud.clouda.sat.gob.mx/DescargaMasivaService.svc',
|
||||||
|
},
|
||||||
|
sandbox: {
|
||||||
|
autenticacion: 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc',
|
||||||
|
solicitud: 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitudDescargaService.svc',
|
||||||
|
verificacion: 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc',
|
||||||
|
descarga: 'https://cfdidescargamasaborrasolicitud.clouda.sat.gob.mx/DescargaMasivaService.svc',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Namespaces SOAP
|
||||||
|
const NAMESPACES = {
|
||||||
|
soap: 'http://schemas.xmlsoap.org/soap/envelope/',
|
||||||
|
wsse: 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd',
|
||||||
|
wsu: 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd',
|
||||||
|
des: 'http://DescargaMasivaTerceros.sat.gob.mx',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Acciones SOAP
|
||||||
|
const SOAP_ACTIONS = {
|
||||||
|
autenticacion: 'http://DescargaMasivaTerceros.gob.mx/IAutenticacion/Autentica',
|
||||||
|
solicitud: 'http://DescargaMasivaTerceros.sat.gob.mx/ISolicitudDescargaService/SolicitaDescarga',
|
||||||
|
verificacion: 'http://DescargaMasivaTerceros.sat.gob.mx/IVerificaSolicitudDescargaService/VerificaSolicitudDescarga',
|
||||||
|
descarga: 'http://DescargaMasivaTerceros.sat.gob.mx/IDescargaMasivaService/Descargar',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opciones de configuración del cliente SAT
|
||||||
|
*/
|
||||||
|
export interface SATClientConfig {
|
||||||
|
rfc: string;
|
||||||
|
certificado: Buffer;
|
||||||
|
llavePrivada: Buffer;
|
||||||
|
password: string;
|
||||||
|
sandbox?: boolean;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cliente para el Web Service de Descarga Masiva del SAT
|
||||||
|
*/
|
||||||
|
export class SATClient {
|
||||||
|
private config: SATClientConfig;
|
||||||
|
private fielService: FIELService;
|
||||||
|
private urls: typeof SAT_URLS.production;
|
||||||
|
private authToken: string | null = null;
|
||||||
|
private authExpires: Date | null = null;
|
||||||
|
|
||||||
|
constructor(config: SATClientConfig) {
|
||||||
|
this.config = {
|
||||||
|
timeout: 60000,
|
||||||
|
sandbox: false,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
this.fielService = new FIELService();
|
||||||
|
this.urls = this.config.sandbox ? SAT_URLS.sandbox : SAT_URLS.production;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autentica con el SAT y obtiene el token de sesión
|
||||||
|
*/
|
||||||
|
async authenticate(): Promise<AuthResponse> {
|
||||||
|
try {
|
||||||
|
// Verificar si ya tenemos un token válido
|
||||||
|
if (this.authToken && this.authExpires && this.authExpires > new Date()) {
|
||||||
|
return {
|
||||||
|
token: this.authToken,
|
||||||
|
expiresAt: this.authExpires,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generar el timestamp y UUID para la solicitud
|
||||||
|
const uuid = crypto.randomUUID();
|
||||||
|
const timestamp = new Date();
|
||||||
|
const created = timestamp.toISOString();
|
||||||
|
const expires = new Date(timestamp.getTime() + 5 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
// Crear el Timestamp para firmar
|
||||||
|
const timestampXml = this.createTimestampXml(created, expires, uuid);
|
||||||
|
|
||||||
|
// Calcular el digest del timestamp
|
||||||
|
const canonicalTimestamp = this.canonicalize(timestampXml);
|
||||||
|
const digest = crypto.createHash('sha1').update(canonicalTimestamp).digest('base64');
|
||||||
|
|
||||||
|
// Crear el SignedInfo
|
||||||
|
const signedInfo = this.createSignedInfo(digest, uuid);
|
||||||
|
|
||||||
|
// Firmar el SignedInfo
|
||||||
|
const signature = await this.fielService.signWithFIEL(
|
||||||
|
this.canonicalize(signedInfo),
|
||||||
|
this.config.llavePrivada,
|
||||||
|
this.config.password
|
||||||
|
);
|
||||||
|
|
||||||
|
// Obtener el certificado en base64
|
||||||
|
const certBase64 = this.getCertificateBase64();
|
||||||
|
const certNumber = this.fielService.getCertificateNumber(this.config.certificado);
|
||||||
|
|
||||||
|
// Construir el SOAP envelope
|
||||||
|
const soapEnvelope = this.buildAuthenticationEnvelope(
|
||||||
|
timestampXml,
|
||||||
|
signedInfo,
|
||||||
|
signature,
|
||||||
|
certBase64,
|
||||||
|
uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enviar la solicitud
|
||||||
|
const response = await this.sendSoapRequest(
|
||||||
|
this.urls.autenticacion,
|
||||||
|
SOAP_ACTIONS.autenticacion,
|
||||||
|
soapEnvelope
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parsear la respuesta
|
||||||
|
const token = this.extractAuthToken(response);
|
||||||
|
if (!token) {
|
||||||
|
throw new SATAuthError('No se pudo obtener el token de autenticación');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guardar el token
|
||||||
|
this.authToken = token;
|
||||||
|
this.authExpires = new Date(Date.now() + 5 * 60 * 1000); // 5 minutos
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: this.authToken,
|
||||||
|
expiresAt: this.authExpires,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SATError) throw error;
|
||||||
|
throw new SATAuthError(
|
||||||
|
`Error de autenticación: ${error instanceof Error ? error.message : 'Error desconocido'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solicita la descarga de CFDIs
|
||||||
|
*/
|
||||||
|
async requestDownload(
|
||||||
|
dateFrom: Date,
|
||||||
|
dateTo: Date,
|
||||||
|
type: 'emitidos' | 'recibidos',
|
||||||
|
options?: {
|
||||||
|
tipoComprobante?: TipoComprobante;
|
||||||
|
tipoSolicitud?: TipoSolicitud;
|
||||||
|
complemento?: string;
|
||||||
|
estadoComprobante?: '0' | '1';
|
||||||
|
rfcACuentaTerceros?: string;
|
||||||
|
}
|
||||||
|
): Promise<SolicitudDescargaResponse> {
|
||||||
|
// Asegurar que estamos autenticados
|
||||||
|
await this.authenticate();
|
||||||
|
|
||||||
|
const rfcEmisor = type === 'emitidos' ? this.config.rfc : undefined;
|
||||||
|
const rfcReceptor = type === 'recibidos' ? this.config.rfc : undefined;
|
||||||
|
|
||||||
|
// Formatear fechas
|
||||||
|
const fechaInicio = this.formatDateForSAT(dateFrom);
|
||||||
|
const fechaFin = this.formatDateForSAT(dateTo);
|
||||||
|
|
||||||
|
// Construir el XML de solicitud
|
||||||
|
const solicitudXml = this.buildSolicitudXml({
|
||||||
|
rfcSolicitante: this.config.rfc,
|
||||||
|
fechaInicio,
|
||||||
|
fechaFin,
|
||||||
|
tipoSolicitud: options?.tipoSolicitud || 'CFDI',
|
||||||
|
tipoComprobante: options?.tipoComprobante,
|
||||||
|
rfcEmisor,
|
||||||
|
rfcReceptor,
|
||||||
|
complemento: options?.complemento,
|
||||||
|
estadoComprobante: options?.estadoComprobante,
|
||||||
|
rfcACuentaTerceros: options?.rfcACuentaTerceros,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Firmar la solicitud
|
||||||
|
const signature = await this.fielService.signWithFIEL(
|
||||||
|
this.canonicalize(solicitudXml),
|
||||||
|
this.config.llavePrivada,
|
||||||
|
this.config.password
|
||||||
|
);
|
||||||
|
|
||||||
|
// Construir el SOAP envelope
|
||||||
|
const soapEnvelope = this.buildSolicitudEnvelope(
|
||||||
|
solicitudXml,
|
||||||
|
signature,
|
||||||
|
this.getCertificateBase64()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enviar la solicitud
|
||||||
|
const response = await this.sendSoapRequest(
|
||||||
|
this.urls.solicitud,
|
||||||
|
SOAP_ACTIONS.solicitud,
|
||||||
|
soapEnvelope
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parsear la respuesta
|
||||||
|
return this.parseSolicitudResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica el estado de una solicitud de descarga
|
||||||
|
*/
|
||||||
|
async checkDownloadStatus(requestId: string): Promise<VerificacionDescargaResponse> {
|
||||||
|
// Asegurar que estamos autenticados
|
||||||
|
await this.authenticate();
|
||||||
|
|
||||||
|
// Construir el XML de verificación
|
||||||
|
const verificacionXml = `<des:VerificaSolicitudDescarga xmlns:des="${NAMESPACES.des}">
|
||||||
|
<des:solicitud IdSolicitud="${requestId}" RfcSolicitante="${this.config.rfc}"/>
|
||||||
|
</des:VerificaSolicitudDescarga>`;
|
||||||
|
|
||||||
|
// Firmar la verificación
|
||||||
|
const solicitudAttr = `IdSolicitud="${requestId}" RfcSolicitante="${this.config.rfc}"`;
|
||||||
|
const signature = await this.fielService.signWithFIEL(
|
||||||
|
solicitudAttr,
|
||||||
|
this.config.llavePrivada,
|
||||||
|
this.config.password
|
||||||
|
);
|
||||||
|
|
||||||
|
// Construir el SOAP envelope
|
||||||
|
const soapEnvelope = this.buildVerificacionEnvelope(
|
||||||
|
requestId,
|
||||||
|
signature,
|
||||||
|
this.getCertificateBase64()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enviar la solicitud
|
||||||
|
const response = await this.sendSoapRequest(
|
||||||
|
this.urls.verificacion,
|
||||||
|
SOAP_ACTIONS.verificacion,
|
||||||
|
soapEnvelope
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parsear la respuesta
|
||||||
|
return this.parseVerificacionResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Descarga un paquete de CFDIs
|
||||||
|
*/
|
||||||
|
async downloadPackage(packageId: string): Promise<DescargaPaqueteResponse> {
|
||||||
|
// Asegurar que estamos autenticados
|
||||||
|
await this.authenticate();
|
||||||
|
|
||||||
|
// Construir el XML de descarga
|
||||||
|
const peticionAttr = `IdPaquete="${packageId}" RfcSolicitante="${this.config.rfc}"`;
|
||||||
|
const signature = await this.fielService.signWithFIEL(
|
||||||
|
peticionAttr,
|
||||||
|
this.config.llavePrivada,
|
||||||
|
this.config.password
|
||||||
|
);
|
||||||
|
|
||||||
|
// Construir el SOAP envelope
|
||||||
|
const soapEnvelope = this.buildDescargaEnvelope(
|
||||||
|
packageId,
|
||||||
|
signature,
|
||||||
|
this.getCertificateBase64()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enviar la solicitud
|
||||||
|
const response = await this.sendSoapRequest(
|
||||||
|
this.urls.descarga,
|
||||||
|
SOAP_ACTIONS.descarga,
|
||||||
|
soapEnvelope
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parsear la respuesta
|
||||||
|
return this.parseDescargaResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proceso completo de descarga: solicitar, esperar y descargar
|
||||||
|
*/
|
||||||
|
async downloadCFDIs(
|
||||||
|
dateFrom: Date,
|
||||||
|
dateTo: Date,
|
||||||
|
type: 'emitidos' | 'recibidos',
|
||||||
|
options?: {
|
||||||
|
tipoComprobante?: TipoComprobante;
|
||||||
|
maxWaitTime?: number; // milisegundos
|
||||||
|
pollInterval?: number; // milisegundos
|
||||||
|
onStatusChange?: (status: EstadoSolicitud, message: string) => void;
|
||||||
|
}
|
||||||
|
): Promise<Buffer[]> {
|
||||||
|
const maxWaitTime = options?.maxWaitTime || 10 * 60 * 1000; // 10 minutos
|
||||||
|
const pollInterval = options?.pollInterval || 30 * 1000; // 30 segundos
|
||||||
|
|
||||||
|
// 1. Solicitar la descarga
|
||||||
|
const solicitud = await this.requestDownload(dateFrom, dateTo, type, options);
|
||||||
|
|
||||||
|
if (solicitud.codEstatus !== '5000') {
|
||||||
|
throw new SATError(
|
||||||
|
`Error en solicitud de descarga: ${solicitud.mensaje}`,
|
||||||
|
solicitud.codEstatus,
|
||||||
|
solicitud.mensaje
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
options?.onStatusChange?.('1', 'Solicitud aceptada');
|
||||||
|
|
||||||
|
// 2. Esperar y verificar el estado
|
||||||
|
const startTime = Date.now();
|
||||||
|
let lastStatus: VerificacionDescargaResponse | null = null;
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWaitTime) {
|
||||||
|
// Esperar antes de verificar
|
||||||
|
await this.sleep(pollInterval);
|
||||||
|
|
||||||
|
// Verificar estado
|
||||||
|
lastStatus = await this.checkDownloadStatus(solicitud.idSolicitud);
|
||||||
|
options?.onStatusChange?.(lastStatus.estadoSolicitud, lastStatus.mensaje);
|
||||||
|
|
||||||
|
// Verificar si ya terminó
|
||||||
|
if (lastStatus.estadoSolicitud === '3') {
|
||||||
|
// Terminada
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastStatus.estadoSolicitud === '4' || lastStatus.estadoSolicitud === '5') {
|
||||||
|
// Error o Rechazada
|
||||||
|
throw new SATError(
|
||||||
|
`Error en descarga: ${lastStatus.mensaje}`,
|
||||||
|
lastStatus.codEstatus,
|
||||||
|
lastStatus.mensaje
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastStatus || lastStatus.estadoSolicitud !== '3') {
|
||||||
|
throw new SATError(
|
||||||
|
'Tiempo de espera agotado para la descarga',
|
||||||
|
'TIMEOUT',
|
||||||
|
'La solicitud no se completó en el tiempo esperado'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Descargar los paquetes
|
||||||
|
const paquetes: Buffer[] = [];
|
||||||
|
|
||||||
|
for (const paqueteId of lastStatus.paquetes) {
|
||||||
|
const descarga = await this.downloadPackage(paqueteId);
|
||||||
|
|
||||||
|
if (descarga.codEstatus !== '5000') {
|
||||||
|
throw new SATError(
|
||||||
|
`Error al descargar paquete: ${descarga.mensaje}`,
|
||||||
|
descarga.codEstatus,
|
||||||
|
descarga.mensaje
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
paquetes.push(descarga.paquete);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paquetes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Métodos privados de construcción de SOAP
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea el XML del Timestamp
|
||||||
|
*/
|
||||||
|
private createTimestampXml(created: string, expires: string, uuid: string): string {
|
||||||
|
return `<u:Timestamp xmlns:u="${NAMESPACES.wsu}" u:Id="_0">
|
||||||
|
<u:Created>${created}</u:Created>
|
||||||
|
<u:Expires>${expires}</u:Expires>
|
||||||
|
</u:Timestamp>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea el SignedInfo para la firma
|
||||||
|
*/
|
||||||
|
private createSignedInfo(digest: string, uuid: string): string {
|
||||||
|
return `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||||
|
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
||||||
|
<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
|
||||||
|
<Reference URI="#_0">
|
||||||
|
<Transforms>
|
||||||
|
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
||||||
|
</Transforms>
|
||||||
|
<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
|
||||||
|
<DigestValue>${digest}</DigestValue>
|
||||||
|
</Reference>
|
||||||
|
</SignedInfo>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye el envelope SOAP para autenticación
|
||||||
|
*/
|
||||||
|
private buildAuthenticationEnvelope(
|
||||||
|
timestampXml: string,
|
||||||
|
signedInfo: string,
|
||||||
|
signature: string,
|
||||||
|
certBase64: string,
|
||||||
|
uuid: string
|
||||||
|
): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<s:Envelope xmlns:s="${NAMESPACES.soap}" xmlns:u="${NAMESPACES.wsu}">
|
||||||
|
<s:Header>
|
||||||
|
<o:Security xmlns:o="${NAMESPACES.wsse}" s:mustUnderstand="1">
|
||||||
|
${timestampXml}
|
||||||
|
<o:BinarySecurityToken u:Id="X509Token" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">${certBase64}</o:BinarySecurityToken>
|
||||||
|
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||||
|
${signedInfo}
|
||||||
|
<SignatureValue>${signature}</SignatureValue>
|
||||||
|
<KeyInfo>
|
||||||
|
<o:SecurityTokenReference>
|
||||||
|
<o:Reference URI="#X509Token" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3"/>
|
||||||
|
</o:SecurityTokenReference>
|
||||||
|
</KeyInfo>
|
||||||
|
</Signature>
|
||||||
|
</o:Security>
|
||||||
|
</s:Header>
|
||||||
|
<s:Body>
|
||||||
|
<Autentica xmlns="http://DescargaMasivaTerceros.gob.mx"/>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye el XML de solicitud de descarga
|
||||||
|
*/
|
||||||
|
private buildSolicitudXml(params: {
|
||||||
|
rfcSolicitante: string;
|
||||||
|
fechaInicio: string;
|
||||||
|
fechaFin: string;
|
||||||
|
tipoSolicitud: string;
|
||||||
|
tipoComprobante?: TipoComprobante;
|
||||||
|
rfcEmisor?: string;
|
||||||
|
rfcReceptor?: string;
|
||||||
|
complemento?: string;
|
||||||
|
estadoComprobante?: string;
|
||||||
|
rfcACuentaTerceros?: string;
|
||||||
|
}): string {
|
||||||
|
let attrs = `RfcSolicitante="${params.rfcSolicitante}"`;
|
||||||
|
attrs += ` FechaInicial="${params.fechaInicio}"`;
|
||||||
|
attrs += ` FechaFinal="${params.fechaFin}"`;
|
||||||
|
attrs += ` TipoSolicitud="${params.tipoSolicitud}"`;
|
||||||
|
|
||||||
|
if (params.tipoComprobante) {
|
||||||
|
attrs += ` TipoComprobante="${params.tipoComprobante}"`;
|
||||||
|
}
|
||||||
|
if (params.rfcEmisor) {
|
||||||
|
attrs += ` RfcEmisor="${params.rfcEmisor}"`;
|
||||||
|
}
|
||||||
|
if (params.rfcReceptor) {
|
||||||
|
attrs += ` RfcReceptor="${params.rfcReceptor}"`;
|
||||||
|
}
|
||||||
|
if (params.complemento) {
|
||||||
|
attrs += ` Complemento="${params.complemento}"`;
|
||||||
|
}
|
||||||
|
if (params.estadoComprobante) {
|
||||||
|
attrs += ` EstadoComprobante="${params.estadoComprobante}"`;
|
||||||
|
}
|
||||||
|
if (params.rfcACuentaTerceros) {
|
||||||
|
attrs += ` RfcACuentaTerceros="${params.rfcACuentaTerceros}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<des:SolicitaDescarga xmlns:des="${NAMESPACES.des}">
|
||||||
|
<des:solicitud ${attrs}/>
|
||||||
|
</des:SolicitaDescarga>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye el envelope SOAP para solicitud de descarga
|
||||||
|
*/
|
||||||
|
private buildSolicitudEnvelope(
|
||||||
|
solicitudXml: string,
|
||||||
|
signature: string,
|
||||||
|
certBase64: string
|
||||||
|
): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<s:Envelope xmlns:s="${NAMESPACES.soap}" xmlns:des="${NAMESPACES.des}" xmlns:xd="http://www.w3.org/2000/09/xmldsig#">
|
||||||
|
<s:Header/>
|
||||||
|
<s:Body>
|
||||||
|
<des:SolicitaDescarga>
|
||||||
|
<des:solicitud>
|
||||||
|
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||||
|
<SignedInfo>
|
||||||
|
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
||||||
|
<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
|
||||||
|
<Reference URI="">
|
||||||
|
<Transforms>
|
||||||
|
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
||||||
|
</Transforms>
|
||||||
|
<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
|
||||||
|
<DigestValue></DigestValue>
|
||||||
|
</Reference>
|
||||||
|
</SignedInfo>
|
||||||
|
<SignatureValue>${signature}</SignatureValue>
|
||||||
|
<KeyInfo>
|
||||||
|
<X509Data>
|
||||||
|
<X509Certificate>${certBase64}</X509Certificate>
|
||||||
|
</X509Data>
|
||||||
|
</KeyInfo>
|
||||||
|
</Signature>
|
||||||
|
</des:solicitud>
|
||||||
|
</des:SolicitaDescarga>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye el envelope SOAP para verificación
|
||||||
|
*/
|
||||||
|
private buildVerificacionEnvelope(
|
||||||
|
requestId: string,
|
||||||
|
signature: string,
|
||||||
|
certBase64: string
|
||||||
|
): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<s:Envelope xmlns:s="${NAMESPACES.soap}" xmlns:des="${NAMESPACES.des}" xmlns:xd="http://www.w3.org/2000/09/xmldsig#">
|
||||||
|
<s:Header/>
|
||||||
|
<s:Body>
|
||||||
|
<des:VerificaSolicitudDescarga>
|
||||||
|
<des:solicitud IdSolicitud="${requestId}" RfcSolicitante="${this.config.rfc}">
|
||||||
|
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||||
|
<SignedInfo>
|
||||||
|
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
||||||
|
<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
|
||||||
|
<Reference URI="">
|
||||||
|
<Transforms>
|
||||||
|
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
||||||
|
</Transforms>
|
||||||
|
<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
|
||||||
|
<DigestValue></DigestValue>
|
||||||
|
</Reference>
|
||||||
|
</SignedInfo>
|
||||||
|
<SignatureValue>${signature}</SignatureValue>
|
||||||
|
<KeyInfo>
|
||||||
|
<X509Data>
|
||||||
|
<X509Certificate>${certBase64}</X509Certificate>
|
||||||
|
</X509Data>
|
||||||
|
</KeyInfo>
|
||||||
|
</Signature>
|
||||||
|
</des:solicitud>
|
||||||
|
</des:VerificaSolicitudDescarga>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye el envelope SOAP para descarga
|
||||||
|
*/
|
||||||
|
private buildDescargaEnvelope(
|
||||||
|
packageId: string,
|
||||||
|
signature: string,
|
||||||
|
certBase64: string
|
||||||
|
): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<s:Envelope xmlns:s="${NAMESPACES.soap}" xmlns:des="${NAMESPACES.des}" xmlns:xd="http://www.w3.org/2000/09/xmldsig#">
|
||||||
|
<s:Header/>
|
||||||
|
<s:Body>
|
||||||
|
<des:PeticionDescargaMasivaTercerosEntrada>
|
||||||
|
<des:peticionDescarga IdPaquete="${packageId}" RfcSolicitante="${this.config.rfc}">
|
||||||
|
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||||
|
<SignedInfo>
|
||||||
|
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
||||||
|
<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
|
||||||
|
<Reference URI="">
|
||||||
|
<Transforms>
|
||||||
|
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
||||||
|
</Transforms>
|
||||||
|
<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
|
||||||
|
<DigestValue></DigestValue>
|
||||||
|
</Reference>
|
||||||
|
</SignedInfo>
|
||||||
|
<SignatureValue>${signature}</SignatureValue>
|
||||||
|
<KeyInfo>
|
||||||
|
<X509Data>
|
||||||
|
<X509Certificate>${certBase64}</X509Certificate>
|
||||||
|
</X509Data>
|
||||||
|
</KeyInfo>
|
||||||
|
</Signature>
|
||||||
|
</des:peticionDescarga>
|
||||||
|
</des:PeticionDescargaMasivaTercerosEntrada>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Métodos de comunicación HTTP
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envía una solicitud SOAP
|
||||||
|
*/
|
||||||
|
private async sendSoapRequest(
|
||||||
|
url: string,
|
||||||
|
soapAction: string,
|
||||||
|
body: string
|
||||||
|
): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
|
||||||
|
const options: https.RequestOptions = {
|
||||||
|
hostname: parsedUrl.hostname,
|
||||||
|
port: parsedUrl.port || 443,
|
||||||
|
path: parsedUrl.pathname,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/xml; charset=utf-8',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
'SOAPAction': soapAction,
|
||||||
|
'Authorization': this.authToken ? `WRAP access_token="${this.authToken}"` : '',
|
||||||
|
},
|
||||||
|
timeout: this.config.timeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
resolve(data);
|
||||||
|
} else {
|
||||||
|
reject(
|
||||||
|
new SATError(
|
||||||
|
`Error HTTP ${res.statusCode}: ${res.statusMessage}`,
|
||||||
|
String(res.statusCode),
|
||||||
|
data
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
reject(new SATError(`Error de conexión: ${error.message}`, 'CONNECTION_ERROR'));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new SATError('Timeout de conexión', 'TIMEOUT'));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Métodos de parsing de respuestas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrae el token de autenticación de la respuesta
|
||||||
|
*/
|
||||||
|
private extractAuthToken(response: string): string | null {
|
||||||
|
// Buscar el token en la respuesta SOAP
|
||||||
|
const tokenMatch = response.match(/<AutenticaResult>([^<]+)<\/AutenticaResult>/);
|
||||||
|
return tokenMatch ? tokenMatch[1] || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsea la respuesta de solicitud de descarga
|
||||||
|
*/
|
||||||
|
private parseSolicitudResponse(response: string): SolicitudDescargaResponse {
|
||||||
|
const idMatch = response.match(/IdSolicitud="([^"]+)"/);
|
||||||
|
const codMatch = response.match(/CodEstatus="([^"]+)"/);
|
||||||
|
const msgMatch = response.match(/Mensaje="([^"]+)"/);
|
||||||
|
|
||||||
|
return {
|
||||||
|
idSolicitud: idMatch ? idMatch[1] || '' : '',
|
||||||
|
codEstatus: codMatch ? codMatch[1] || '' : '',
|
||||||
|
mensaje: msgMatch ? msgMatch[1] || '' : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsea la respuesta de verificación
|
||||||
|
*/
|
||||||
|
private parseVerificacionResponse(response: string): VerificacionDescargaResponse {
|
||||||
|
const codMatch = response.match(/CodEstatus="([^"]+)"/);
|
||||||
|
const estadoMatch = response.match(/EstadoSolicitud="([^"]+)"/);
|
||||||
|
const codEstadoMatch = response.match(/CodigoEstadoSolicitud="([^"]+)"/);
|
||||||
|
const numCFDIsMatch = response.match(/NumeroCFDIs="([^"]+)"/);
|
||||||
|
const msgMatch = response.match(/Mensaje="([^"]+)"/);
|
||||||
|
|
||||||
|
// Extraer los IDs de paquetes
|
||||||
|
const paquetes: string[] = [];
|
||||||
|
const paqueteMatches = response.matchAll(/IdPaquete="([^"]+)"/g);
|
||||||
|
for (const match of paqueteMatches) {
|
||||||
|
if (match[1]) paquetes.push(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
codEstatus: codMatch ? codMatch[1] || '' : '',
|
||||||
|
estadoSolicitud: (estadoMatch ? estadoMatch[1] || '1' : '1') as EstadoSolicitud,
|
||||||
|
codigoEstadoSolicitud: codEstadoMatch ? codEstadoMatch[1] || '' : '',
|
||||||
|
numeroCFDIs: numCFDIsMatch ? parseInt(numCFDIsMatch[1] || '0', 10) : 0,
|
||||||
|
mensaje: msgMatch ? msgMatch[1] || '' : '',
|
||||||
|
paquetes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsea la respuesta de descarga
|
||||||
|
*/
|
||||||
|
private parseDescargaResponse(response: string): DescargaPaqueteResponse {
|
||||||
|
const codMatch = response.match(/CodEstatus="([^"]+)"/);
|
||||||
|
const msgMatch = response.match(/Mensaje="([^"]+)"/);
|
||||||
|
const paqueteMatch = response.match(/<Paquete>([^<]+)<\/Paquete>/);
|
||||||
|
|
||||||
|
const paqueteBase64 = paqueteMatch ? paqueteMatch[1] || '' : '';
|
||||||
|
const paquete = paqueteBase64 ? Buffer.from(paqueteBase64, 'base64') : Buffer.alloc(0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
codEstatus: codMatch ? codMatch[1] || '' : '',
|
||||||
|
mensaje: msgMatch ? msgMatch[1] || '' : '',
|
||||||
|
paquete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utilidades
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el certificado en base64
|
||||||
|
*/
|
||||||
|
private getCertificateBase64(): string {
|
||||||
|
const content = this.config.certificado.toString('utf-8').trim();
|
||||||
|
|
||||||
|
// Si ya tiene headers PEM, extraer solo el contenido
|
||||||
|
if (content.includes('-----BEGIN')) {
|
||||||
|
return content
|
||||||
|
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
||||||
|
.replace(/-----END CERTIFICATE-----/, '')
|
||||||
|
.replace(/\s/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si es binario (DER), convertir a base64
|
||||||
|
return this.config.certificado.toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicaliza XML (simplificado - C14N exclusivo)
|
||||||
|
*/
|
||||||
|
private canonicalize(xml: string): string {
|
||||||
|
// Implementación simplificada de canonicalización
|
||||||
|
return xml
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
.replace(/\r/g, '\n')
|
||||||
|
.replace(/>\s+</g, '><')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea una fecha para el SAT (ISO 8601)
|
||||||
|
*/
|
||||||
|
private formatDateForSAT(date: Date): string {
|
||||||
|
return date.toISOString().replace(/\.\d{3}Z$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Espera un tiempo determinado
|
||||||
|
*/
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una instancia del cliente SAT
|
||||||
|
*/
|
||||||
|
export function createSATClient(config: SATClientConfig): SATClient {
|
||||||
|
return new SATClient(config);
|
||||||
|
}
|
||||||
871
apps/api/src/services/sat/sat.service.ts
Normal file
871
apps/api/src/services/sat/sat.service.ts
Normal file
@@ -0,0 +1,871 @@
|
|||||||
|
/**
|
||||||
|
* SAT Service
|
||||||
|
* Servicio de alto nivel para sincronización de CFDIs con el SAT
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as zlib from 'zlib';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import {
|
||||||
|
CFDI,
|
||||||
|
CFDIParsed,
|
||||||
|
SyncResult,
|
||||||
|
SyncOptions,
|
||||||
|
TipoComprobante,
|
||||||
|
SATError,
|
||||||
|
} from './sat.types.js';
|
||||||
|
import { CFDIParser, parseCFDI, extractBasicInfo } from './cfdi.parser.js';
|
||||||
|
import { SATClient, SATClientConfig, createSATClient } from './sat.client.js';
|
||||||
|
|
||||||
|
const unzip = promisify(zlib.unzip);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuración del servicio SAT
|
||||||
|
*/
|
||||||
|
export interface SATServiceConfig {
|
||||||
|
/** Pool de conexiones a la base de datos */
|
||||||
|
dbPool: Pool;
|
||||||
|
/** Configuración del cliente SAT (por tenant) */
|
||||||
|
getSATClientConfig: (tenantId: string) => Promise<SATClientConfig>;
|
||||||
|
/** Tabla donde se guardan los CFDIs */
|
||||||
|
cfdiTable?: string;
|
||||||
|
/** Esquema de la base de datos */
|
||||||
|
dbSchema?: string;
|
||||||
|
/** Logger personalizado */
|
||||||
|
logger?: {
|
||||||
|
info: (message: string, meta?: Record<string, unknown>) => void;
|
||||||
|
error: (message: string, meta?: Record<string, unknown>) => void;
|
||||||
|
warn: (message: string, meta?: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resultado del procesamiento de un CFDI
|
||||||
|
*/
|
||||||
|
interface CFDIProcessResult {
|
||||||
|
success: boolean;
|
||||||
|
uuid?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servicio principal para sincronización con el SAT
|
||||||
|
*/
|
||||||
|
export class SATService {
|
||||||
|
private config: Required<SATServiceConfig>;
|
||||||
|
private parser: CFDIParser;
|
||||||
|
private clients: Map<string, SATClient> = new Map();
|
||||||
|
|
||||||
|
constructor(config: SATServiceConfig) {
|
||||||
|
this.config = {
|
||||||
|
cfdiTable: 'cfdis',
|
||||||
|
dbSchema: 'public',
|
||||||
|
logger: {
|
||||||
|
info: console.log,
|
||||||
|
error: console.error,
|
||||||
|
warn: console.warn,
|
||||||
|
},
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
this.parser = new CFDIParser();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincroniza CFDIs de un tenant para un rango de fechas
|
||||||
|
*/
|
||||||
|
async syncCFDIs(options: SyncOptions): Promise<SyncResult> {
|
||||||
|
const { tenantId, dateFrom, dateTo, tipoComprobante, tipoSolicitud } = options;
|
||||||
|
|
||||||
|
this.config.logger.info('Iniciando sincronización de CFDIs', {
|
||||||
|
tenantId,
|
||||||
|
dateFrom: dateFrom.toISOString(),
|
||||||
|
dateTo: dateTo.toISOString(),
|
||||||
|
tipoComprobante,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: SyncResult = {
|
||||||
|
success: false,
|
||||||
|
totalProcessed: 0,
|
||||||
|
totalSaved: 0,
|
||||||
|
totalErrors: 0,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Obtener el cliente SAT para este tenant
|
||||||
|
const client = await this.getSATClient(tenantId);
|
||||||
|
|
||||||
|
// Descargar CFDIs emitidos
|
||||||
|
if (!tipoComprobante || tipoComprobante === 'emitidos') {
|
||||||
|
const emitidosResult = await this.downloadAndProcess(
|
||||||
|
client,
|
||||||
|
tenantId,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
'emitidos',
|
||||||
|
tipoSolicitud
|
||||||
|
);
|
||||||
|
result.totalProcessed += emitidosResult.totalProcessed;
|
||||||
|
result.totalSaved += emitidosResult.totalSaved;
|
||||||
|
result.totalErrors += emitidosResult.totalErrors;
|
||||||
|
result.errors.push(...emitidosResult.errors);
|
||||||
|
result.requestId = emitidosResult.requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Descargar CFDIs recibidos
|
||||||
|
if (!tipoComprobante || tipoComprobante === 'recibidos') {
|
||||||
|
const recibidosResult = await this.downloadAndProcess(
|
||||||
|
client,
|
||||||
|
tenantId,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
'recibidos',
|
||||||
|
tipoSolicitud
|
||||||
|
);
|
||||||
|
result.totalProcessed += recibidosResult.totalProcessed;
|
||||||
|
result.totalSaved += recibidosResult.totalSaved;
|
||||||
|
result.totalErrors += recibidosResult.totalErrors;
|
||||||
|
result.errors.push(...recibidosResult.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success = result.totalErrors === 0;
|
||||||
|
|
||||||
|
this.config.logger.info('Sincronización completada', {
|
||||||
|
tenantId,
|
||||||
|
totalProcessed: result.totalProcessed,
|
||||||
|
totalSaved: result.totalSaved,
|
||||||
|
totalErrors: result.totalErrors,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
|
||||||
|
this.config.logger.error('Error en sincronización', {
|
||||||
|
tenantId,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.errors.push({ error: errorMessage });
|
||||||
|
result.totalErrors++;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Descarga y procesa CFDIs de un tipo específico
|
||||||
|
*/
|
||||||
|
private async downloadAndProcess(
|
||||||
|
client: SATClient,
|
||||||
|
tenantId: string,
|
||||||
|
dateFrom: Date,
|
||||||
|
dateTo: Date,
|
||||||
|
type: 'emitidos' | 'recibidos',
|
||||||
|
tipoSolicitud?: 'CFDI' | 'Metadata'
|
||||||
|
): Promise<SyncResult> {
|
||||||
|
const result: SyncResult = {
|
||||||
|
success: false,
|
||||||
|
totalProcessed: 0,
|
||||||
|
totalSaved: 0,
|
||||||
|
totalErrors: 0,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.config.logger.info(`Descargando CFDIs ${type}`, {
|
||||||
|
tenantId,
|
||||||
|
dateFrom: dateFrom.toISOString(),
|
||||||
|
dateTo: dateTo.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Descargar paquetes del SAT
|
||||||
|
const paquetes = await client.downloadCFDIs(dateFrom, dateTo, type, {
|
||||||
|
tipoSolicitud,
|
||||||
|
onStatusChange: (status, message) => {
|
||||||
|
this.config.logger.info(`Estado de descarga: ${status}`, { message, tenantId });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Procesar cada paquete
|
||||||
|
for (const paquete of paquetes) {
|
||||||
|
const paqueteResult = await this.processCFDIPackage(paquete, tenantId, type);
|
||||||
|
result.totalProcessed += paqueteResult.totalProcessed;
|
||||||
|
result.totalSaved += paqueteResult.totalSaved;
|
||||||
|
result.totalErrors += paqueteResult.totalErrors;
|
||||||
|
result.errors.push(...paqueteResult.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success = true;
|
||||||
|
result.paquetes = paquetes.map((_, i) => `paquete_${i + 1}`);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
|
||||||
|
this.config.logger.error(`Error descargando CFDIs ${type}`, {
|
||||||
|
tenantId,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.errors.push({ error: errorMessage });
|
||||||
|
result.totalErrors++;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Procesa un paquete ZIP de CFDIs
|
||||||
|
*/
|
||||||
|
async processCFDIPackage(
|
||||||
|
zipBuffer: Buffer,
|
||||||
|
tenantId: string,
|
||||||
|
tipo: 'emitidos' | 'recibidos' = 'recibidos'
|
||||||
|
): Promise<SyncResult> {
|
||||||
|
const result: SyncResult = {
|
||||||
|
success: false,
|
||||||
|
totalProcessed: 0,
|
||||||
|
totalSaved: 0,
|
||||||
|
totalErrors: 0,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extraer los XMLs del ZIP
|
||||||
|
const xmlFiles = await this.extractZip(zipBuffer);
|
||||||
|
|
||||||
|
this.config.logger.info('Procesando paquete de CFDIs', {
|
||||||
|
tenantId,
|
||||||
|
totalFiles: xmlFiles.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parsear y guardar cada CFDI
|
||||||
|
const cfdis: CFDIParsed[] = [];
|
||||||
|
|
||||||
|
for (const xmlContent of xmlFiles) {
|
||||||
|
result.totalProcessed++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cfdiParsed = this.parser.parseCFDI(xmlContent);
|
||||||
|
cfdis.push(cfdiParsed);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Error de parsing';
|
||||||
|
|
||||||
|
// Intentar extraer el UUID para el log
|
||||||
|
const basicInfo = extractBasicInfo(xmlContent);
|
||||||
|
|
||||||
|
result.errors.push({
|
||||||
|
uuid: basicInfo.uuid || undefined,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
result.totalErrors++;
|
||||||
|
|
||||||
|
this.config.logger.warn('Error parseando CFDI', {
|
||||||
|
uuid: basicInfo.uuid,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guardar los CFDIs en la base de datos
|
||||||
|
if (cfdis.length > 0) {
|
||||||
|
const saveResult = await this.saveCFDIsToDB(cfdis, tenantId, tipo);
|
||||||
|
result.totalSaved = saveResult.saved;
|
||||||
|
result.totalErrors += saveResult.errors;
|
||||||
|
result.errors.push(...saveResult.errorDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success = result.totalErrors === 0;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
|
||||||
|
this.config.logger.error('Error procesando paquete', {
|
||||||
|
tenantId,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.errors.push({ error: errorMessage });
|
||||||
|
result.totalErrors++;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guarda los CFDIs en la base de datos
|
||||||
|
*/
|
||||||
|
async saveCFDIsToDB(
|
||||||
|
cfdis: CFDIParsed[],
|
||||||
|
tenantId: string,
|
||||||
|
tipo: 'emitidos' | 'recibidos'
|
||||||
|
): Promise<{ saved: number; errors: number; errorDetails: Array<{ uuid?: string; error: string }> }> {
|
||||||
|
const result = {
|
||||||
|
saved: 0,
|
||||||
|
errors: 0,
|
||||||
|
errorDetails: [] as Array<{ uuid?: string; error: string }>,
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = await this.config.dbPool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
for (const cfdiParsed of cfdis) {
|
||||||
|
try {
|
||||||
|
await this.upsertCFDI(client, cfdiParsed, tenantId, tipo);
|
||||||
|
result.saved++;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Error de BD';
|
||||||
|
result.errors++;
|
||||||
|
result.errorDetails.push({
|
||||||
|
uuid: cfdiParsed.uuid,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.config.logger.warn('Error guardando CFDI', {
|
||||||
|
uuid: cfdiParsed.uuid,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserta o actualiza un CFDI en la base de datos
|
||||||
|
*/
|
||||||
|
private async upsertCFDI(
|
||||||
|
client: ReturnType<Pool['connect']> extends Promise<infer T> ? T : never,
|
||||||
|
cfdiParsed: CFDIParsed,
|
||||||
|
tenantId: string,
|
||||||
|
tipo: 'emitidos' | 'recibidos'
|
||||||
|
): Promise<void> {
|
||||||
|
const { cfdi, xml, uuid, fechaTimbrado, rfcEmisor, rfcReceptor, total, tipoComprobante } =
|
||||||
|
cfdiParsed;
|
||||||
|
|
||||||
|
const table = `${this.config.dbSchema}.${this.config.cfdiTable}`;
|
||||||
|
|
||||||
|
// Extraer datos adicionales del CFDI
|
||||||
|
const serie = cfdi.Serie || null;
|
||||||
|
const folio = cfdi.Folio || null;
|
||||||
|
const fecha = new Date(cfdi.Fecha);
|
||||||
|
const formaPago = cfdi.FormaPago || null;
|
||||||
|
const metodoPago = cfdi.MetodoPago || null;
|
||||||
|
const moneda = cfdi.Moneda;
|
||||||
|
const tipoCambio = cfdi.TipoCambio || 1;
|
||||||
|
const subtotal = cfdi.SubTotal;
|
||||||
|
const descuento = cfdi.Descuento || 0;
|
||||||
|
const lugarExpedicion = cfdi.LugarExpedicion;
|
||||||
|
|
||||||
|
// Impuestos
|
||||||
|
const totalImpuestosTrasladados = cfdi.Impuestos?.TotalImpuestosTrasladados || 0;
|
||||||
|
const totalImpuestosRetenidos = cfdi.Impuestos?.TotalImpuestosRetenidos || 0;
|
||||||
|
|
||||||
|
// Datos del emisor
|
||||||
|
const nombreEmisor = cfdi.Emisor.Nombre;
|
||||||
|
const regimenFiscalEmisor = cfdi.Emisor.RegimenFiscal;
|
||||||
|
|
||||||
|
// Datos del receptor
|
||||||
|
const nombreReceptor = cfdi.Receptor.Nombre;
|
||||||
|
const regimenFiscalReceptor = cfdi.Receptor.RegimenFiscalReceptor;
|
||||||
|
const usoCfdi = cfdi.Receptor.UsoCFDI;
|
||||||
|
const domicilioFiscalReceptor = cfdi.Receptor.DomicilioFiscalReceptor;
|
||||||
|
|
||||||
|
// Complementos
|
||||||
|
const tienePago = !!cfdi.Complemento?.Pagos;
|
||||||
|
const tieneNomina = !!cfdi.Complemento?.Nomina;
|
||||||
|
|
||||||
|
// Datos del timbre
|
||||||
|
const rfcProvCertif = cfdi.Complemento?.TimbreFiscalDigital?.RfcProvCertif || null;
|
||||||
|
const noCertificadoSAT = cfdi.Complemento?.TimbreFiscalDigital?.NoCertificadoSAT || null;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO ${table} (
|
||||||
|
tenant_id,
|
||||||
|
uuid,
|
||||||
|
tipo,
|
||||||
|
version,
|
||||||
|
serie,
|
||||||
|
folio,
|
||||||
|
fecha,
|
||||||
|
fecha_timbrado,
|
||||||
|
tipo_comprobante,
|
||||||
|
forma_pago,
|
||||||
|
metodo_pago,
|
||||||
|
moneda,
|
||||||
|
tipo_cambio,
|
||||||
|
subtotal,
|
||||||
|
descuento,
|
||||||
|
total,
|
||||||
|
lugar_expedicion,
|
||||||
|
total_impuestos_trasladados,
|
||||||
|
total_impuestos_retenidos,
|
||||||
|
rfc_emisor,
|
||||||
|
nombre_emisor,
|
||||||
|
regimen_fiscal_emisor,
|
||||||
|
rfc_receptor,
|
||||||
|
nombre_receptor,
|
||||||
|
regimen_fiscal_receptor,
|
||||||
|
uso_cfdi,
|
||||||
|
domicilio_fiscal_receptor,
|
||||||
|
tiene_pago,
|
||||||
|
tiene_nomina,
|
||||||
|
rfc_prov_certif,
|
||||||
|
no_certificado_sat,
|
||||||
|
xml,
|
||||||
|
conceptos,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||||
|
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20,
|
||||||
|
$21, $22, $23, $24, $25, $26, $27, $28, $29, $30,
|
||||||
|
$31, $32, $33, NOW(), NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT (tenant_id, uuid) DO UPDATE SET
|
||||||
|
tipo = EXCLUDED.tipo,
|
||||||
|
version = EXCLUDED.version,
|
||||||
|
serie = EXCLUDED.serie,
|
||||||
|
folio = EXCLUDED.folio,
|
||||||
|
fecha = EXCLUDED.fecha,
|
||||||
|
fecha_timbrado = EXCLUDED.fecha_timbrado,
|
||||||
|
tipo_comprobante = EXCLUDED.tipo_comprobante,
|
||||||
|
forma_pago = EXCLUDED.forma_pago,
|
||||||
|
metodo_pago = EXCLUDED.metodo_pago,
|
||||||
|
moneda = EXCLUDED.moneda,
|
||||||
|
tipo_cambio = EXCLUDED.tipo_cambio,
|
||||||
|
subtotal = EXCLUDED.subtotal,
|
||||||
|
descuento = EXCLUDED.descuento,
|
||||||
|
total = EXCLUDED.total,
|
||||||
|
lugar_expedicion = EXCLUDED.lugar_expedicion,
|
||||||
|
total_impuestos_trasladados = EXCLUDED.total_impuestos_trasladados,
|
||||||
|
total_impuestos_retenidos = EXCLUDED.total_impuestos_retenidos,
|
||||||
|
rfc_emisor = EXCLUDED.rfc_emisor,
|
||||||
|
nombre_emisor = EXCLUDED.nombre_emisor,
|
||||||
|
regimen_fiscal_emisor = EXCLUDED.regimen_fiscal_emisor,
|
||||||
|
rfc_receptor = EXCLUDED.rfc_receptor,
|
||||||
|
nombre_receptor = EXCLUDED.nombre_receptor,
|
||||||
|
regimen_fiscal_receptor = EXCLUDED.regimen_fiscal_receptor,
|
||||||
|
uso_cfdi = EXCLUDED.uso_cfdi,
|
||||||
|
domicilio_fiscal_receptor = EXCLUDED.domicilio_fiscal_receptor,
|
||||||
|
tiene_pago = EXCLUDED.tiene_pago,
|
||||||
|
tiene_nomina = EXCLUDED.tiene_nomina,
|
||||||
|
rfc_prov_certif = EXCLUDED.rfc_prov_certif,
|
||||||
|
no_certificado_sat = EXCLUDED.no_certificado_sat,
|
||||||
|
xml = EXCLUDED.xml,
|
||||||
|
conceptos = EXCLUDED.conceptos,
|
||||||
|
updated_at = NOW()
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Serializar conceptos como JSON
|
||||||
|
const conceptosJson = JSON.stringify(
|
||||||
|
cfdi.Conceptos.map((c) => ({
|
||||||
|
claveProdServ: c.ClaveProdServ,
|
||||||
|
noIdentificacion: c.NoIdentificacion,
|
||||||
|
cantidad: c.Cantidad,
|
||||||
|
claveUnidad: c.ClaveUnidad,
|
||||||
|
unidad: c.Unidad,
|
||||||
|
descripcion: c.Descripcion,
|
||||||
|
valorUnitario: c.ValorUnitario,
|
||||||
|
importe: c.Importe,
|
||||||
|
descuento: c.Descuento,
|
||||||
|
objetoImp: c.ObjetoImp,
|
||||||
|
impuestos: c.Impuestos,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(query, [
|
||||||
|
tenantId,
|
||||||
|
uuid,
|
||||||
|
tipo,
|
||||||
|
cfdi.Version,
|
||||||
|
serie,
|
||||||
|
folio,
|
||||||
|
fecha,
|
||||||
|
fechaTimbrado,
|
||||||
|
tipoComprobante,
|
||||||
|
formaPago,
|
||||||
|
metodoPago,
|
||||||
|
moneda,
|
||||||
|
tipoCambio,
|
||||||
|
subtotal,
|
||||||
|
descuento,
|
||||||
|
total,
|
||||||
|
lugarExpedicion,
|
||||||
|
totalImpuestosTrasladados,
|
||||||
|
totalImpuestosRetenidos,
|
||||||
|
rfcEmisor,
|
||||||
|
nombreEmisor,
|
||||||
|
regimenFiscalEmisor,
|
||||||
|
rfcReceptor,
|
||||||
|
nombreReceptor,
|
||||||
|
regimenFiscalReceptor,
|
||||||
|
usoCfdi,
|
||||||
|
domicilioFiscalReceptor,
|
||||||
|
tienePago,
|
||||||
|
tieneNomina,
|
||||||
|
rfcProvCertif,
|
||||||
|
noCertificadoSAT,
|
||||||
|
xml,
|
||||||
|
conceptosJson,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrae los archivos XML de un ZIP
|
||||||
|
*/
|
||||||
|
private async extractZip(zipBuffer: Buffer): Promise<string[]> {
|
||||||
|
const xmlFiles: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// El paquete del SAT viene como un ZIP
|
||||||
|
// Primero intentamos descomprimir si está comprimido con gzip
|
||||||
|
let uncompressedBuffer: Buffer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
uncompressedBuffer = await unzip(zipBuffer);
|
||||||
|
} catch {
|
||||||
|
// Si no es gzip, usar el buffer original
|
||||||
|
uncompressedBuffer = zipBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsear el ZIP manualmente (formato básico)
|
||||||
|
// Los archivos ZIP tienen una estructura específica
|
||||||
|
const files = this.parseZipBuffer(uncompressedBuffer);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.name.toLowerCase().endsWith('.xml')) {
|
||||||
|
xmlFiles.push(file.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return xmlFiles;
|
||||||
|
} catch (error) {
|
||||||
|
this.config.logger.error('Error extrayendo ZIP', {
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido',
|
||||||
|
});
|
||||||
|
throw new SATError(
|
||||||
|
'Error al extraer paquete ZIP',
|
||||||
|
'ZIP_ERROR',
|
||||||
|
error instanceof Error ? error.message : undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsea un buffer ZIP y extrae los archivos
|
||||||
|
*/
|
||||||
|
private parseZipBuffer(buffer: Buffer): Array<{ name: string; content: string }> {
|
||||||
|
const files: Array<{ name: string; content: string }> = [];
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
const view = buffer;
|
||||||
|
|
||||||
|
while (offset < buffer.length - 4) {
|
||||||
|
// Buscar la firma del Local File Header (0x04034b50)
|
||||||
|
const signature = view.readUInt32LE(offset);
|
||||||
|
|
||||||
|
if (signature !== 0x04034b50) {
|
||||||
|
// No es un Local File Header, podría ser el Central Directory
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leer el Local File Header
|
||||||
|
const compressionMethod = view.readUInt16LE(offset + 8);
|
||||||
|
const compressedSize = view.readUInt32LE(offset + 18);
|
||||||
|
const uncompressedSize = view.readUInt32LE(offset + 22);
|
||||||
|
const fileNameLength = view.readUInt16LE(offset + 26);
|
||||||
|
const extraFieldLength = view.readUInt16LE(offset + 28);
|
||||||
|
|
||||||
|
// Nombre del archivo
|
||||||
|
const fileNameStart = offset + 30;
|
||||||
|
const fileName = buffer.subarray(fileNameStart, fileNameStart + fileNameLength).toString('utf-8');
|
||||||
|
|
||||||
|
// Datos del archivo
|
||||||
|
const dataStart = fileNameStart + fileNameLength + extraFieldLength;
|
||||||
|
const dataEnd = dataStart + compressedSize;
|
||||||
|
const fileData = buffer.subarray(dataStart, dataEnd);
|
||||||
|
|
||||||
|
// Descomprimir si es necesario
|
||||||
|
let content: string;
|
||||||
|
|
||||||
|
if (compressionMethod === 0) {
|
||||||
|
// Sin compresión
|
||||||
|
content = fileData.toString('utf-8');
|
||||||
|
} else if (compressionMethod === 8) {
|
||||||
|
// Deflate
|
||||||
|
try {
|
||||||
|
const inflated = zlib.inflateRawSync(fileData);
|
||||||
|
content = inflated.toString('utf-8');
|
||||||
|
} catch {
|
||||||
|
// Si falla la descompresión, intentar como texto plano
|
||||||
|
content = fileData.toString('utf-8');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Método de compresión no soportado
|
||||||
|
this.config.logger.warn(`Método de compresión no soportado: ${compressionMethod}`, {
|
||||||
|
fileName,
|
||||||
|
});
|
||||||
|
content = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
files.push({ name: fileName, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avanzar al siguiente archivo
|
||||||
|
offset = dataEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene o crea un cliente SAT para un tenant
|
||||||
|
*/
|
||||||
|
private async getSATClient(tenantId: string): Promise<SATClient> {
|
||||||
|
if (!this.clients.has(tenantId)) {
|
||||||
|
const config = await this.config.getSATClientConfig(tenantId);
|
||||||
|
this.clients.set(tenantId, createSATClient(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.clients.get(tenantId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un CFDI por UUID
|
||||||
|
*/
|
||||||
|
async getCFDIByUUID(tenantId: string, uuid: string): Promise<CFDIParsed | null> {
|
||||||
|
const table = `${this.config.dbSchema}.${this.config.cfdiTable}`;
|
||||||
|
|
||||||
|
const result = await this.config.dbPool.query(
|
||||||
|
`SELECT xml FROM ${table} WHERE tenant_id = $1 AND uuid = $2`,
|
||||||
|
[tenantId, uuid]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = result.rows[0] as { xml: string };
|
||||||
|
return parseCFDI(row.xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca CFDIs por criterios
|
||||||
|
*/
|
||||||
|
async searchCFDIs(
|
||||||
|
tenantId: string,
|
||||||
|
criteria: {
|
||||||
|
tipo?: 'emitidos' | 'recibidos';
|
||||||
|
tipoComprobante?: TipoComprobante;
|
||||||
|
rfcEmisor?: string;
|
||||||
|
rfcReceptor?: string;
|
||||||
|
fechaDesde?: Date;
|
||||||
|
fechaHasta?: Date;
|
||||||
|
montoMinimo?: number;
|
||||||
|
montoMaximo?: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
): Promise<{ cfdis: Array<Partial<CFDI> & { uuid: string }>; total: number }> {
|
||||||
|
const table = `${this.config.dbSchema}.${this.config.cfdiTable}`;
|
||||||
|
const conditions: string[] = ['tenant_id = $1'];
|
||||||
|
const params: unknown[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (criteria.tipo) {
|
||||||
|
conditions.push(`tipo = $${paramIndex++}`);
|
||||||
|
params.push(criteria.tipo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.tipoComprobante) {
|
||||||
|
conditions.push(`tipo_comprobante = $${paramIndex++}`);
|
||||||
|
params.push(criteria.tipoComprobante);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.rfcEmisor) {
|
||||||
|
conditions.push(`rfc_emisor = $${paramIndex++}`);
|
||||||
|
params.push(criteria.rfcEmisor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.rfcReceptor) {
|
||||||
|
conditions.push(`rfc_receptor = $${paramIndex++}`);
|
||||||
|
params.push(criteria.rfcReceptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.fechaDesde) {
|
||||||
|
conditions.push(`fecha >= $${paramIndex++}`);
|
||||||
|
params.push(criteria.fechaDesde);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.fechaHasta) {
|
||||||
|
conditions.push(`fecha <= $${paramIndex++}`);
|
||||||
|
params.push(criteria.fechaHasta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.montoMinimo !== undefined) {
|
||||||
|
conditions.push(`total >= $${paramIndex++}`);
|
||||||
|
params.push(criteria.montoMinimo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.montoMaximo !== undefined) {
|
||||||
|
conditions.push(`total <= $${paramIndex++}`);
|
||||||
|
params.push(criteria.montoMaximo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(' AND ');
|
||||||
|
const limit = criteria.limit || 100;
|
||||||
|
const offset = criteria.offset || 0;
|
||||||
|
|
||||||
|
// Contar total
|
||||||
|
const countResult = await this.config.dbPool.query(
|
||||||
|
`SELECT COUNT(*) as total FROM ${table} WHERE ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const total = parseInt((countResult.rows[0] as { total: string }).total, 10);
|
||||||
|
|
||||||
|
// Obtener CFDIs
|
||||||
|
const dataResult = await this.config.dbPool.query(
|
||||||
|
`SELECT
|
||||||
|
uuid,
|
||||||
|
tipo,
|
||||||
|
version,
|
||||||
|
serie,
|
||||||
|
folio,
|
||||||
|
fecha,
|
||||||
|
fecha_timbrado,
|
||||||
|
tipo_comprobante,
|
||||||
|
forma_pago,
|
||||||
|
metodo_pago,
|
||||||
|
moneda,
|
||||||
|
tipo_cambio,
|
||||||
|
subtotal,
|
||||||
|
descuento,
|
||||||
|
total,
|
||||||
|
rfc_emisor,
|
||||||
|
nombre_emisor,
|
||||||
|
rfc_receptor,
|
||||||
|
nombre_receptor,
|
||||||
|
uso_cfdi,
|
||||||
|
tiene_pago,
|
||||||
|
tiene_nomina
|
||||||
|
FROM ${table}
|
||||||
|
WHERE ${whereClause}
|
||||||
|
ORDER BY fecha DESC
|
||||||
|
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
|
||||||
|
[...params, limit, offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cfdis: dataResult.rows as Array<Partial<CFDI> & { uuid: string }>,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene estadísticas de CFDIs
|
||||||
|
*/
|
||||||
|
async getCFDIStats(
|
||||||
|
tenantId: string,
|
||||||
|
fechaDesde?: Date,
|
||||||
|
fechaHasta?: Date
|
||||||
|
): Promise<{
|
||||||
|
totalEmitidos: number;
|
||||||
|
totalRecibidos: number;
|
||||||
|
montoEmitidos: number;
|
||||||
|
montoRecibidos: number;
|
||||||
|
porTipoComprobante: Record<TipoComprobante, number>;
|
||||||
|
}> {
|
||||||
|
const table = `${this.config.dbSchema}.${this.config.cfdiTable}`;
|
||||||
|
let dateCondition = '';
|
||||||
|
const params: unknown[] = [tenantId];
|
||||||
|
|
||||||
|
if (fechaDesde) {
|
||||||
|
params.push(fechaDesde);
|
||||||
|
dateCondition += ` AND fecha >= $${params.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fechaHasta) {
|
||||||
|
params.push(fechaHasta);
|
||||||
|
dateCondition += ` AND fecha <= $${params.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
tipo,
|
||||||
|
tipo_comprobante,
|
||||||
|
COUNT(*) as cantidad,
|
||||||
|
COALESCE(SUM(total), 0) as monto
|
||||||
|
FROM ${table}
|
||||||
|
WHERE tenant_id = $1 ${dateCondition}
|
||||||
|
GROUP BY tipo, tipo_comprobante
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.config.dbPool.query(query, params);
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
totalEmitidos: 0,
|
||||||
|
totalRecibidos: 0,
|
||||||
|
montoEmitidos: 0,
|
||||||
|
montoRecibidos: 0,
|
||||||
|
porTipoComprobante: {
|
||||||
|
I: 0,
|
||||||
|
E: 0,
|
||||||
|
T: 0,
|
||||||
|
N: 0,
|
||||||
|
P: 0,
|
||||||
|
} as Record<TipoComprobante, number>,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of result.rows as Array<{
|
||||||
|
tipo: string;
|
||||||
|
tipo_comprobante: TipoComprobante;
|
||||||
|
cantidad: string;
|
||||||
|
monto: string;
|
||||||
|
}>) {
|
||||||
|
const cantidad = parseInt(row.cantidad, 10);
|
||||||
|
const monto = parseFloat(row.monto);
|
||||||
|
|
||||||
|
if (row.tipo === 'emitidos') {
|
||||||
|
stats.totalEmitidos += cantidad;
|
||||||
|
stats.montoEmitidos += monto;
|
||||||
|
} else {
|
||||||
|
stats.totalRecibidos += cantidad;
|
||||||
|
stats.montoRecibidos += monto;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.porTipoComprobante[row.tipo_comprobante] =
|
||||||
|
(stats.porTipoComprobante[row.tipo_comprobante] || 0) + cantidad;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia la caché de clientes SAT
|
||||||
|
*/
|
||||||
|
clearClientCache(): void {
|
||||||
|
this.clients.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalida el cliente de un tenant específico
|
||||||
|
*/
|
||||||
|
invalidateClient(tenantId: string): void {
|
||||||
|
this.clients.delete(tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una instancia del servicio SAT
|
||||||
|
*/
|
||||||
|
export function createSATService(config: SATServiceConfig): SATService {
|
||||||
|
return new SATService(config);
|
||||||
|
}
|
||||||
817
apps/api/src/services/sat/sat.types.ts
Normal file
817
apps/api/src/services/sat/sat.types.ts
Normal file
@@ -0,0 +1,817 @@
|
|||||||
|
/**
|
||||||
|
* SAT CFDI 4.0 Types
|
||||||
|
* Tipos completos para la integración con el SAT de México
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tipos de Comprobante
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de comprobante fiscal según SAT
|
||||||
|
* I - Ingreso
|
||||||
|
* E - Egreso
|
||||||
|
* T - Traslado
|
||||||
|
* N - Nómina
|
||||||
|
* P - Pago
|
||||||
|
*/
|
||||||
|
export type TipoComprobante = 'I' | 'E' | 'T' | 'N' | 'P';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de relación entre CFDIs
|
||||||
|
*/
|
||||||
|
export type TipoRelacion =
|
||||||
|
| '01' // Nota de crédito de los documentos relacionados
|
||||||
|
| '02' // Nota de débito de los documentos relacionados
|
||||||
|
| '03' // Devolución de mercancía sobre facturas o traslados previos
|
||||||
|
| '04' // Sustitución de los CFDI previos
|
||||||
|
| '05' // Traslados de mercancías facturados previamente
|
||||||
|
| '06' // Factura generada por los traslados previos
|
||||||
|
| '07' // CFDI por aplicación de anticipo
|
||||||
|
| '08' // Factura generada por pagos en parcialidades
|
||||||
|
| '09'; // Factura generada por pagos diferidos
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formas de pago
|
||||||
|
*/
|
||||||
|
export type FormaPago =
|
||||||
|
| '01' // Efectivo
|
||||||
|
| '02' // Cheque nominativo
|
||||||
|
| '03' // Transferencia electrónica de fondos
|
||||||
|
| '04' // Tarjeta de crédito
|
||||||
|
| '05' // Monedero electrónico
|
||||||
|
| '06' // Dinero electrónico
|
||||||
|
| '08' // Vales de despensa
|
||||||
|
| '12' // Dación en pago
|
||||||
|
| '13' // Pago por subrogación
|
||||||
|
| '14' // Pago por consignación
|
||||||
|
| '15' // Condonación
|
||||||
|
| '17' // Compensación
|
||||||
|
| '23' // Novación
|
||||||
|
| '24' // Confusión
|
||||||
|
| '25' // Remisión de deuda
|
||||||
|
| '26' // Prescripción o caducidad
|
||||||
|
| '27' // A satisfacción del acreedor
|
||||||
|
| '28' // Tarjeta de débito
|
||||||
|
| '29' // Tarjeta de servicios
|
||||||
|
| '30' // Aplicación de anticipos
|
||||||
|
| '31' // Intermediario pagos
|
||||||
|
| '99'; // Por definir
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Métodos de pago
|
||||||
|
*/
|
||||||
|
export type MetodoPago = 'PUE' | 'PPD';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uso del CFDI
|
||||||
|
*/
|
||||||
|
export type UsoCFDI =
|
||||||
|
| 'G01' // Adquisición de mercancías
|
||||||
|
| 'G02' // Devoluciones, descuentos o bonificaciones
|
||||||
|
| 'G03' // Gastos en general
|
||||||
|
| 'I01' // Construcciones
|
||||||
|
| 'I02' // Mobiliario y equipo de oficina por inversiones
|
||||||
|
| 'I03' // Equipo de transporte
|
||||||
|
| 'I04' // Equipo de cómputo y accesorios
|
||||||
|
| 'I05' // Dados, troqueles, moldes, matrices y herramental
|
||||||
|
| 'I06' // Comunicaciones telefónicas
|
||||||
|
| 'I07' // Comunicaciones satelitales
|
||||||
|
| 'I08' // Otra maquinaria y equipo
|
||||||
|
| 'D01' // Honorarios médicos, dentales y gastos hospitalarios
|
||||||
|
| 'D02' // Gastos médicos por incapacidad o discapacidad
|
||||||
|
| 'D03' // Gastos funerales
|
||||||
|
| 'D04' // Donativos
|
||||||
|
| 'D05' // Intereses reales efectivamente pagados por créditos hipotecarios
|
||||||
|
| 'D06' // Aportaciones voluntarias al SAR
|
||||||
|
| 'D07' // Primas por seguros de gastos médicos
|
||||||
|
| 'D08' // Gastos de transportación escolar obligatoria
|
||||||
|
| 'D09' // Depósitos en cuentas para el ahorro, primas de pensiones
|
||||||
|
| 'D10' // Pagos por servicios educativos
|
||||||
|
| 'S01' // Sin efectos fiscales
|
||||||
|
| 'CP01' // Pagos
|
||||||
|
| 'CN01'; // Nómina
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Régimen fiscal
|
||||||
|
*/
|
||||||
|
export type RegimenFiscal =
|
||||||
|
| '601' // General de Ley Personas Morales
|
||||||
|
| '603' // Personas Morales con Fines no Lucrativos
|
||||||
|
| '605' // Sueldos y Salarios e Ingresos Asimilados a Salarios
|
||||||
|
| '606' // Arrendamiento
|
||||||
|
| '607' // Régimen de Enajenación o Adquisición de Bienes
|
||||||
|
| '608' // Demás ingresos
|
||||||
|
| '609' // Consolidación
|
||||||
|
| '610' // Residentes en el Extranjero sin Establecimiento Permanente en México
|
||||||
|
| '611' // Ingresos por Dividendos (socios y accionistas)
|
||||||
|
| '612' // Personas Físicas con Actividades Empresariales y Profesionales
|
||||||
|
| '614' // Ingresos por intereses
|
||||||
|
| '615' // Régimen de los ingresos por obtención de premios
|
||||||
|
| '616' // Sin obligaciones fiscales
|
||||||
|
| '620' // Sociedades Cooperativas de Producción que optan por diferir sus ingresos
|
||||||
|
| '621' // Incorporación Fiscal
|
||||||
|
| '622' // Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras
|
||||||
|
| '623' // Opcional para Grupos de Sociedades
|
||||||
|
| '624' // Coordinados
|
||||||
|
| '625' // Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas
|
||||||
|
| '626'; // Régimen Simplificado de Confianza
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de impuesto
|
||||||
|
*/
|
||||||
|
export type TipoImpuesto =
|
||||||
|
| '001' // ISR
|
||||||
|
| '002' // IVA
|
||||||
|
| '003'; // IEPS
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de factor
|
||||||
|
*/
|
||||||
|
export type TipoFactor = 'Tasa' | 'Cuota' | 'Exento';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Objeto de impuesto
|
||||||
|
*/
|
||||||
|
export type ObjetoImp =
|
||||||
|
| '01' // No objeto de impuesto
|
||||||
|
| '02' // Sí objeto de impuesto
|
||||||
|
| '03' // Sí objeto del impuesto y no obligado al desglose
|
||||||
|
| '04'; // Sí objeto del impuesto y no causa impuesto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exportación
|
||||||
|
*/
|
||||||
|
export type Exportacion =
|
||||||
|
| '01' // No aplica
|
||||||
|
| '02' // Definitiva
|
||||||
|
| '03' // Temporal
|
||||||
|
| '04'; // Definitiva con clave distinta a A1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moneda
|
||||||
|
*/
|
||||||
|
export type Moneda = 'MXN' | 'USD' | 'EUR' | string;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Interfaces principales
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Información del emisor del CFDI
|
||||||
|
*/
|
||||||
|
export interface Emisor {
|
||||||
|
Rfc: string;
|
||||||
|
Nombre: string;
|
||||||
|
RegimenFiscal: RegimenFiscal;
|
||||||
|
FacAtrAdquirente?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Información del receptor del CFDI
|
||||||
|
*/
|
||||||
|
export interface Receptor {
|
||||||
|
Rfc: string;
|
||||||
|
Nombre: string;
|
||||||
|
DomicilioFiscalReceptor: string;
|
||||||
|
RegimenFiscalReceptor: RegimenFiscal;
|
||||||
|
UsoCFDI: UsoCFDI;
|
||||||
|
ResidenciaFiscal?: string;
|
||||||
|
NumRegIdTrib?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Impuesto trasladado
|
||||||
|
*/
|
||||||
|
export interface Traslado {
|
||||||
|
Base: number;
|
||||||
|
Impuesto: TipoImpuesto;
|
||||||
|
TipoFactor: TipoFactor;
|
||||||
|
TasaOCuota?: number;
|
||||||
|
Importe?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Impuesto retenido
|
||||||
|
*/
|
||||||
|
export interface Retencion {
|
||||||
|
Base: number;
|
||||||
|
Impuesto: TipoImpuesto;
|
||||||
|
TipoFactor: TipoFactor;
|
||||||
|
TasaOCuota: number;
|
||||||
|
Importe: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Impuestos de un concepto
|
||||||
|
*/
|
||||||
|
export interface ImpuestosConcepto {
|
||||||
|
Traslados?: Traslado[];
|
||||||
|
Retenciones?: Retencion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Información aduanera
|
||||||
|
*/
|
||||||
|
export interface InformacionAduanera {
|
||||||
|
NumeroPedimento: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cuenta predial
|
||||||
|
*/
|
||||||
|
export interface CuentaPredial {
|
||||||
|
Numero: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parte de un concepto
|
||||||
|
*/
|
||||||
|
export interface Parte {
|
||||||
|
ClaveProdServ: string;
|
||||||
|
NoIdentificacion?: string;
|
||||||
|
Cantidad: number;
|
||||||
|
Unidad?: string;
|
||||||
|
Descripcion: string;
|
||||||
|
ValorUnitario?: number;
|
||||||
|
Importe?: number;
|
||||||
|
InformacionAduanera?: InformacionAduanera[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ACuentaTerceros
|
||||||
|
*/
|
||||||
|
export interface ACuentaTerceros {
|
||||||
|
RfcACuentaTerceros: string;
|
||||||
|
NombreACuentaTerceros: string;
|
||||||
|
RegimenFiscalACuentaTerceros: RegimenFiscal;
|
||||||
|
DomicilioFiscalACuentaTerceros: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concepto del CFDI
|
||||||
|
*/
|
||||||
|
export interface Concepto {
|
||||||
|
ClaveProdServ: string;
|
||||||
|
NoIdentificacion?: string;
|
||||||
|
Cantidad: number;
|
||||||
|
ClaveUnidad: string;
|
||||||
|
Unidad?: string;
|
||||||
|
Descripcion: string;
|
||||||
|
ValorUnitario: number;
|
||||||
|
Importe: number;
|
||||||
|
Descuento?: number;
|
||||||
|
ObjetoImp: ObjetoImp;
|
||||||
|
Impuestos?: ImpuestosConcepto;
|
||||||
|
ACuentaTerceros?: ACuentaTerceros;
|
||||||
|
InformacionAduanera?: InformacionAduanera[];
|
||||||
|
CuentaPredial?: CuentaPredial[];
|
||||||
|
Parte?: Parte[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Impuestos del comprobante
|
||||||
|
*/
|
||||||
|
export interface Impuestos {
|
||||||
|
TotalImpuestosRetenidos?: number;
|
||||||
|
TotalImpuestosTrasladados?: number;
|
||||||
|
Retenciones?: Array<{
|
||||||
|
Impuesto: TipoImpuesto;
|
||||||
|
Importe: number;
|
||||||
|
}>;
|
||||||
|
Traslados?: Array<{
|
||||||
|
Base: number;
|
||||||
|
Impuesto: TipoImpuesto;
|
||||||
|
TipoFactor: TipoFactor;
|
||||||
|
TasaOCuota?: number;
|
||||||
|
Importe?: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CFDI relacionado
|
||||||
|
*/
|
||||||
|
export interface CfdiRelacionado {
|
||||||
|
UUID: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CFDIs relacionados
|
||||||
|
*/
|
||||||
|
export interface CfdiRelacionados {
|
||||||
|
TipoRelacion: TipoRelacion;
|
||||||
|
CfdiRelacionado: CfdiRelacionado[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Complemento de Pago
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Documento relacionado en un pago
|
||||||
|
*/
|
||||||
|
export interface DoctoRelacionado {
|
||||||
|
IdDocumento: string;
|
||||||
|
Serie?: string;
|
||||||
|
Folio?: string;
|
||||||
|
MonedaDR: Moneda;
|
||||||
|
EquivalenciaDR?: number;
|
||||||
|
NumParcialidad?: number;
|
||||||
|
ImpSaldoAnt?: number;
|
||||||
|
ImpPagado?: number;
|
||||||
|
ImpSaldoInsoluto?: number;
|
||||||
|
ObjetoImpDR?: ObjetoImp;
|
||||||
|
ImpuestosDR?: {
|
||||||
|
RetencionesDR?: Array<{
|
||||||
|
BaseDR: number;
|
||||||
|
ImpuestoDR: TipoImpuesto;
|
||||||
|
TipoFactorDR: TipoFactor;
|
||||||
|
TasaOCuotaDR: number;
|
||||||
|
ImporteDR: number;
|
||||||
|
}>;
|
||||||
|
TrasladosDR?: Array<{
|
||||||
|
BaseDR: number;
|
||||||
|
ImpuestoDR: TipoImpuesto;
|
||||||
|
TipoFactorDR: TipoFactor;
|
||||||
|
TasaOCuotaDR?: number;
|
||||||
|
ImporteDR?: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pago individual
|
||||||
|
*/
|
||||||
|
export interface Pago {
|
||||||
|
FechaPago: string;
|
||||||
|
FormaDePagoP: FormaPago;
|
||||||
|
MonedaP: Moneda;
|
||||||
|
TipoCambioP?: number;
|
||||||
|
Monto: number;
|
||||||
|
NumOperacion?: string;
|
||||||
|
RfcEmisorCtaOrd?: string;
|
||||||
|
NomBancoOrdExt?: string;
|
||||||
|
CtaOrdenante?: string;
|
||||||
|
RfcEmisorCtaBen?: string;
|
||||||
|
CtaBeneficiario?: string;
|
||||||
|
TipoCadPago?: '01';
|
||||||
|
CertPago?: string;
|
||||||
|
CadPago?: string;
|
||||||
|
SelloPago?: string;
|
||||||
|
DoctoRelacionado: DoctoRelacionado[];
|
||||||
|
ImpuestosP?: {
|
||||||
|
RetencionesP?: Array<{
|
||||||
|
ImpuestoP: TipoImpuesto;
|
||||||
|
ImporteP: number;
|
||||||
|
}>;
|
||||||
|
TrasladosP?: Array<{
|
||||||
|
BaseP: number;
|
||||||
|
ImpuestoP: TipoImpuesto;
|
||||||
|
TipoFactorP: TipoFactor;
|
||||||
|
TasaOCuotaP?: number;
|
||||||
|
ImporteP?: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Totales del complemento de pagos
|
||||||
|
*/
|
||||||
|
export interface TotalesPago {
|
||||||
|
TotalRetencionesIVA?: number;
|
||||||
|
TotalRetencionesISR?: number;
|
||||||
|
TotalRetencionesIEPS?: number;
|
||||||
|
TotalTrasladosBaseIVA16?: number;
|
||||||
|
TotalTrasladosImpuestoIVA16?: number;
|
||||||
|
TotalTrasladosBaseIVA8?: number;
|
||||||
|
TotalTrasladosImpuestoIVA8?: number;
|
||||||
|
TotalTrasladosBaseIVA0?: number;
|
||||||
|
TotalTrasladosImpuestoIVA0?: number;
|
||||||
|
TotalTrasladosBaseIVAExento?: number;
|
||||||
|
MontoTotalPagos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complemento de Pagos 2.0
|
||||||
|
*/
|
||||||
|
export interface ComplementoPago {
|
||||||
|
Version: '2.0';
|
||||||
|
Totales: TotalesPago;
|
||||||
|
Pago: Pago[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Complemento de Nómina
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Percepción de nómina
|
||||||
|
*/
|
||||||
|
export interface Percepcion {
|
||||||
|
TipoPercepcion: string;
|
||||||
|
Clave: string;
|
||||||
|
Concepto: string;
|
||||||
|
ImporteGravado: number;
|
||||||
|
ImporteExento: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deducción de nómina
|
||||||
|
*/
|
||||||
|
export interface Deduccion {
|
||||||
|
TipoDeduccion: string;
|
||||||
|
Clave: string;
|
||||||
|
Concepto: string;
|
||||||
|
Importe: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Otro pago de nómina
|
||||||
|
*/
|
||||||
|
export interface OtroPago {
|
||||||
|
TipoOtroPago: string;
|
||||||
|
Clave: string;
|
||||||
|
Concepto: string;
|
||||||
|
Importe: number;
|
||||||
|
SubsidioAlEmpleo?: {
|
||||||
|
SubsidioCausado: number;
|
||||||
|
};
|
||||||
|
CompensacionSaldosAFavor?: {
|
||||||
|
SaldoAFavor: number;
|
||||||
|
Ano: number;
|
||||||
|
RemanenteSalFav: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incapacidad
|
||||||
|
*/
|
||||||
|
export interface Incapacidad {
|
||||||
|
DiasIncapacidad: number;
|
||||||
|
TipoIncapacidad: string;
|
||||||
|
ImporteMonetario?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horas extra
|
||||||
|
*/
|
||||||
|
export interface HorasExtra {
|
||||||
|
Dias: number;
|
||||||
|
TipoHoras: 'Dobles' | 'Triples';
|
||||||
|
HorasExtra: number;
|
||||||
|
ImportePagado: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receptor de nómina
|
||||||
|
*/
|
||||||
|
export interface ReceptorNomina {
|
||||||
|
Curp: string;
|
||||||
|
NumSeguridadSocial?: string;
|
||||||
|
FechaInicioRelLaboral?: string;
|
||||||
|
Antiguedad?: string;
|
||||||
|
TipoContrato: string;
|
||||||
|
Sindicalizado?: 'Sí' | 'No';
|
||||||
|
TipoJornada?: string;
|
||||||
|
TipoRegimen: string;
|
||||||
|
NumEmpleado: string;
|
||||||
|
Departamento?: string;
|
||||||
|
Puesto?: string;
|
||||||
|
RiesgoContratado?: string;
|
||||||
|
PeriodicidadPago: string;
|
||||||
|
Banco?: string;
|
||||||
|
CuentaBancaria?: string;
|
||||||
|
SalarioBaseCotApor?: number;
|
||||||
|
SalarioDiarioIntegrado?: number;
|
||||||
|
ClaveEntFed: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emisor de nómina
|
||||||
|
*/
|
||||||
|
export interface EmisorNomina {
|
||||||
|
Curp?: string;
|
||||||
|
RegistroPatronal?: string;
|
||||||
|
RfcPatronOrigen?: string;
|
||||||
|
EntidadSNCF?: {
|
||||||
|
OrigenRecurso: string;
|
||||||
|
MontoRecursoPropio?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complemento de Nómina 1.2
|
||||||
|
*/
|
||||||
|
export interface ComplementoNomina {
|
||||||
|
Version: '1.2';
|
||||||
|
TipoNomina: 'O' | 'E';
|
||||||
|
FechaPago: string;
|
||||||
|
FechaInicialPago: string;
|
||||||
|
FechaFinalPago: string;
|
||||||
|
NumDiasPagados: number;
|
||||||
|
TotalPercepciones?: number;
|
||||||
|
TotalDeducciones?: number;
|
||||||
|
TotalOtrosPagos?: number;
|
||||||
|
Emisor?: EmisorNomina;
|
||||||
|
Receptor: ReceptorNomina;
|
||||||
|
Percepciones?: {
|
||||||
|
TotalSueldos?: number;
|
||||||
|
TotalSeparacionIndemnizacion?: number;
|
||||||
|
TotalJubilacionPensionRetiro?: number;
|
||||||
|
TotalGravado: number;
|
||||||
|
TotalExento: number;
|
||||||
|
Percepcion: Percepcion[];
|
||||||
|
};
|
||||||
|
Deducciones?: {
|
||||||
|
TotalOtrasDeducciones?: number;
|
||||||
|
TotalImpuestosRetenidos?: number;
|
||||||
|
Deduccion: Deduccion[];
|
||||||
|
};
|
||||||
|
OtrosPagos?: {
|
||||||
|
OtroPago: OtroPago[];
|
||||||
|
};
|
||||||
|
Incapacidades?: {
|
||||||
|
Incapacidad: Incapacidad[];
|
||||||
|
};
|
||||||
|
HorasExtras?: {
|
||||||
|
HorasExtra: HorasExtra[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Timbre Fiscal Digital
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timbre Fiscal Digital
|
||||||
|
*/
|
||||||
|
export interface TimbreFiscalDigital {
|
||||||
|
Version: '1.1';
|
||||||
|
UUID: string;
|
||||||
|
FechaTimbrado: string;
|
||||||
|
RfcProvCertif: string;
|
||||||
|
Leyenda?: string;
|
||||||
|
SelloCFD: string;
|
||||||
|
NoCertificadoSAT: string;
|
||||||
|
SelloSAT: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CFDI Completo
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estructura completa del CFDI 4.0
|
||||||
|
*/
|
||||||
|
export interface CFDI {
|
||||||
|
// Atributos requeridos
|
||||||
|
Version: '4.0';
|
||||||
|
Serie?: string;
|
||||||
|
Folio?: string;
|
||||||
|
Fecha: string;
|
||||||
|
Sello: string;
|
||||||
|
FormaPago?: FormaPago;
|
||||||
|
NoCertificado: string;
|
||||||
|
Certificado: string;
|
||||||
|
CondicionesDePago?: string;
|
||||||
|
SubTotal: number;
|
||||||
|
Descuento?: number;
|
||||||
|
Moneda: Moneda;
|
||||||
|
TipoCambio?: number;
|
||||||
|
Total: number;
|
||||||
|
TipoDeComprobante: TipoComprobante;
|
||||||
|
Exportacion: Exportacion;
|
||||||
|
MetodoPago?: MetodoPago;
|
||||||
|
LugarExpedicion: string;
|
||||||
|
Confirmacion?: string;
|
||||||
|
|
||||||
|
// Nodos hijos
|
||||||
|
InformacionGlobal?: {
|
||||||
|
Periodicidad: string;
|
||||||
|
Meses: string;
|
||||||
|
Ano: number;
|
||||||
|
};
|
||||||
|
CfdiRelacionados?: CfdiRelacionados[];
|
||||||
|
Emisor: Emisor;
|
||||||
|
Receptor: Receptor;
|
||||||
|
Conceptos: Concepto[];
|
||||||
|
Impuestos?: Impuestos;
|
||||||
|
|
||||||
|
// Complementos
|
||||||
|
Complemento?: {
|
||||||
|
TimbreFiscalDigital?: TimbreFiscalDigital;
|
||||||
|
Pagos?: ComplementoPago;
|
||||||
|
Nomina?: ComplementoNomina;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Addenda (opcional)
|
||||||
|
Addenda?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tipos para el servicio SAT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de solicitud de descarga
|
||||||
|
*/
|
||||||
|
export type TipoSolicitud = 'CFDI' | 'Metadata';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de comprobante para descarga
|
||||||
|
*/
|
||||||
|
export type TipoComprobanteDescarga = 'emitidos' | 'recibidos';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de la solicitud de descarga
|
||||||
|
*/
|
||||||
|
export type EstadoSolicitud =
|
||||||
|
| '1' // Aceptada
|
||||||
|
| '2' // En proceso
|
||||||
|
| '3' // Terminada
|
||||||
|
| '4' // Error
|
||||||
|
| '5'; // Rechazada
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Códigos de estado del SAT
|
||||||
|
*/
|
||||||
|
export interface CodigoEstadoSAT {
|
||||||
|
codigo: string;
|
||||||
|
mensaje: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Respuesta de autenticación
|
||||||
|
*/
|
||||||
|
export interface AuthResponse {
|
||||||
|
token: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solicitud de descarga
|
||||||
|
*/
|
||||||
|
export interface SolicitudDescarga {
|
||||||
|
rfcSolicitante: string;
|
||||||
|
fechaInicio: Date;
|
||||||
|
fechaFin: Date;
|
||||||
|
tipoSolicitud: TipoSolicitud;
|
||||||
|
tipoComprobante?: TipoComprobante;
|
||||||
|
rfcEmisor?: string;
|
||||||
|
rfcReceptor?: string;
|
||||||
|
complemento?: string;
|
||||||
|
estadoComprobante?: '0' | '1'; // 0: Cancelado, 1: Vigente
|
||||||
|
rfcACuentaTerceros?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Respuesta de solicitud de descarga
|
||||||
|
*/
|
||||||
|
export interface SolicitudDescargaResponse {
|
||||||
|
idSolicitud: string;
|
||||||
|
codEstatus: string;
|
||||||
|
mensaje: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Respuesta de verificación de descarga
|
||||||
|
*/
|
||||||
|
export interface VerificacionDescargaResponse {
|
||||||
|
codEstatus: string;
|
||||||
|
estadoSolicitud: EstadoSolicitud;
|
||||||
|
codigoEstadoSolicitud: string;
|
||||||
|
numeroCFDIs: number;
|
||||||
|
mensaje: string;
|
||||||
|
paquetes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Respuesta de descarga de paquete
|
||||||
|
*/
|
||||||
|
export interface DescargaPaqueteResponse {
|
||||||
|
codEstatus: string;
|
||||||
|
mensaje: string;
|
||||||
|
paquete: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Información del certificado
|
||||||
|
*/
|
||||||
|
export interface CertificateInfo {
|
||||||
|
serialNumber: string;
|
||||||
|
subject: {
|
||||||
|
rfc: string;
|
||||||
|
nombre: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
issuer: {
|
||||||
|
cn: string;
|
||||||
|
o: string;
|
||||||
|
};
|
||||||
|
validFrom: Date;
|
||||||
|
validTo: Date;
|
||||||
|
isValid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resultado de validación de FIEL
|
||||||
|
*/
|
||||||
|
export interface FIELValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
certificateInfo?: CertificateInfo;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CFDI parseado con metadata
|
||||||
|
*/
|
||||||
|
export interface CFDIParsed {
|
||||||
|
cfdi: CFDI;
|
||||||
|
xml: string;
|
||||||
|
uuid: string;
|
||||||
|
fechaTimbrado: Date;
|
||||||
|
rfcEmisor: string;
|
||||||
|
rfcReceptor: string;
|
||||||
|
total: number;
|
||||||
|
tipoComprobante: TipoComprobante;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resultado de sincronización
|
||||||
|
*/
|
||||||
|
export interface SyncResult {
|
||||||
|
success: boolean;
|
||||||
|
totalProcessed: number;
|
||||||
|
totalSaved: number;
|
||||||
|
totalErrors: number;
|
||||||
|
errors: Array<{
|
||||||
|
uuid?: string;
|
||||||
|
error: string;
|
||||||
|
}>;
|
||||||
|
requestId?: string;
|
||||||
|
paquetes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opciones de sincronización
|
||||||
|
*/
|
||||||
|
export interface SyncOptions {
|
||||||
|
tenantId: string;
|
||||||
|
dateFrom: Date;
|
||||||
|
dateTo: Date;
|
||||||
|
tipoComprobante?: TipoComprobanteDescarga;
|
||||||
|
tipoSolicitud?: TipoSolicitud;
|
||||||
|
complemento?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Errores específicos del SAT
|
||||||
|
*/
|
||||||
|
export class SATError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly codigo: string,
|
||||||
|
public readonly detalles?: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SATError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error de autenticación con SAT
|
||||||
|
*/
|
||||||
|
export class SATAuthError extends SATError {
|
||||||
|
constructor(message: string, codigo: string = 'AUTH_ERROR') {
|
||||||
|
super(message, codigo);
|
||||||
|
this.name = 'SATAuthError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error de FIEL
|
||||||
|
*/
|
||||||
|
export class FIELError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly tipo: 'INVALID_CERTIFICATE' | 'INVALID_KEY' | 'PASSWORD_ERROR' | 'EXPIRED' | 'MISMATCH'
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'FIELError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error de parsing de CFDI
|
||||||
|
*/
|
||||||
|
export class CFDIParseError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly campo?: string,
|
||||||
|
public readonly valor?: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'CFDIParseError';
|
||||||
|
}
|
||||||
|
}
|
||||||
271
apps/api/src/types/index.ts
Normal file
271
apps/api/src/types/index.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// User & Auth Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
password_hash: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
role: UserRole;
|
||||||
|
tenant_id: string;
|
||||||
|
is_active: boolean;
|
||||||
|
email_verified: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
last_login_at: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserRole = 'owner' | 'admin' | 'member' | 'viewer';
|
||||||
|
|
||||||
|
export interface Tenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
schema_name: string;
|
||||||
|
plan_id: string;
|
||||||
|
is_active: boolean;
|
||||||
|
settings: TenantSettings;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantSettings {
|
||||||
|
timezone: string;
|
||||||
|
currency: string;
|
||||||
|
fiscal_year_start_month: number;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
refresh_token_hash: string;
|
||||||
|
user_agent: string | null;
|
||||||
|
ip_address: string | null;
|
||||||
|
expires_at: Date;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// JWT Token Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface AccessTokenPayload {
|
||||||
|
sub: string; // user_id
|
||||||
|
email: string;
|
||||||
|
role: UserRole;
|
||||||
|
tenant_id: string;
|
||||||
|
schema_name: string;
|
||||||
|
type: 'access';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshTokenPayload {
|
||||||
|
sub: string; // user_id
|
||||||
|
session_id: string;
|
||||||
|
type: 'refresh';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenPair {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Request Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface AuthenticatedRequest {
|
||||||
|
user: AccessTokenPayload;
|
||||||
|
tenantSchema: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API Response Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ApiResponse<T = unknown> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: ApiError;
|
||||||
|
meta?: ResponseMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
stack?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseMeta {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
total?: number;
|
||||||
|
timestamp: string;
|
||||||
|
requestId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const RegisterSchema = z.object({
|
||||||
|
email: z.string().email('Email invalido'),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, 'La contrasena debe tener al menos 8 caracteres')
|
||||||
|
.regex(
|
||||||
|
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
|
||||||
|
'La contrasena debe contener al menos una mayuscula, una minuscula y un numero'
|
||||||
|
),
|
||||||
|
firstName: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'),
|
||||||
|
lastName: z.string().min(2, 'El apellido debe tener al menos 2 caracteres'),
|
||||||
|
companyName: z.string().min(2, 'El nombre de la empresa debe tener al menos 2 caracteres'),
|
||||||
|
rfc: z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/,
|
||||||
|
'RFC invalido. Formato esperado: XAXX010101XXX'
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const LoginSchema = z.object({
|
||||||
|
email: z.string().email('Email invalido'),
|
||||||
|
password: z.string().min(1, 'La contrasena es requerida'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RefreshTokenSchema = z.object({
|
||||||
|
refreshToken: z.string().min(1, 'Refresh token es requerido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ResetPasswordRequestSchema = z.object({
|
||||||
|
email: z.string().email('Email invalido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ResetPasswordSchema = z.object({
|
||||||
|
token: z.string().min(1, 'Token es requerido'),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, 'La contrasena debe tener al menos 8 caracteres')
|
||||||
|
.regex(
|
||||||
|
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
|
||||||
|
'La contrasena debe contener al menos una mayuscula, una minuscula y un numero'
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ChangePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1, 'Contrasena actual es requerida'),
|
||||||
|
newPassword: z
|
||||||
|
.string()
|
||||||
|
.min(8, 'La nueva contrasena debe tener al menos 8 caracteres')
|
||||||
|
.regex(
|
||||||
|
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
|
||||||
|
'La contrasena debe contener al menos una mayuscula, una minuscula y un numero'
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Inferred Types from Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type RegisterInput = z.infer<typeof RegisterSchema>;
|
||||||
|
export type LoginInput = z.infer<typeof LoginSchema>;
|
||||||
|
export type RefreshTokenInput = z.infer<typeof RefreshTokenSchema>;
|
||||||
|
export type ResetPasswordRequestInput = z.infer<typeof ResetPasswordRequestSchema>;
|
||||||
|
export type ResetPasswordInput = z.infer<typeof ResetPasswordSchema>;
|
||||||
|
export type ChangePasswordInput = z.infer<typeof ChangePasswordSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class AppError extends Error {
|
||||||
|
public readonly code: string;
|
||||||
|
public readonly statusCode: number;
|
||||||
|
public readonly isOperational: boolean;
|
||||||
|
public readonly details?: Record<string, unknown>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code: string,
|
||||||
|
statusCode: number = 500,
|
||||||
|
isOperational: boolean = true,
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.isOperational = isOperational;
|
||||||
|
this.details = details;
|
||||||
|
Object.setPrototypeOf(this, AppError.prototype);
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidationError extends AppError {
|
||||||
|
constructor(message: string, details?: Record<string, unknown>) {
|
||||||
|
super(message, 'VALIDATION_ERROR', 400, true, details);
|
||||||
|
Object.setPrototypeOf(this, ValidationError.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthenticationError extends AppError {
|
||||||
|
constructor(message: string = 'No autorizado') {
|
||||||
|
super(message, 'AUTHENTICATION_ERROR', 401, true);
|
||||||
|
Object.setPrototypeOf(this, AuthenticationError.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthorizationError extends AppError {
|
||||||
|
constructor(message: string = 'Acceso denegado') {
|
||||||
|
super(message, 'AUTHORIZATION_ERROR', 403, true);
|
||||||
|
Object.setPrototypeOf(this, AuthorizationError.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFoundError extends AppError {
|
||||||
|
constructor(resource: string = 'Recurso') {
|
||||||
|
super(`${resource} no encontrado`, 'NOT_FOUND', 404, true);
|
||||||
|
Object.setPrototypeOf(this, NotFoundError.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConflictError extends AppError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message, 'CONFLICT', 409, true);
|
||||||
|
Object.setPrototypeOf(this, ConflictError.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RateLimitError extends AppError {
|
||||||
|
constructor(message: string = 'Demasiadas solicitudes') {
|
||||||
|
super(message, 'RATE_LIMIT_EXCEEDED', 429, true);
|
||||||
|
Object.setPrototypeOf(this, RateLimitError.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DatabaseError extends AppError {
|
||||||
|
constructor(message: string = 'Error de base de datos') {
|
||||||
|
super(message, 'DATABASE_ERROR', 500, false);
|
||||||
|
Object.setPrototypeOf(this, DatabaseError.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExternalServiceError extends AppError {
|
||||||
|
constructor(service: string, message?: string) {
|
||||||
|
super(
|
||||||
|
message || `Error al comunicarse con ${service}`,
|
||||||
|
'EXTERNAL_SERVICE_ERROR',
|
||||||
|
502,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
Object.setPrototypeOf(this, ExternalServiceError.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
apps/api/src/utils/asyncHandler.ts
Normal file
55
apps/api/src/utils/asyncHandler.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for async route handlers that automatically catches errors
|
||||||
|
* and passes them to the next middleware (error handler).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* router.get('/users', asyncHandler(async (req, res) => {
|
||||||
|
* const users = await userService.getAll();
|
||||||
|
* res.json(users);
|
||||||
|
* }));
|
||||||
|
*/
|
||||||
|
export const asyncHandler = <T = void>(
|
||||||
|
fn: (req: Request, res: Response, next: NextFunction) => Promise<T>
|
||||||
|
): RequestHandler => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for async route handlers that includes typed request body
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* router.post('/users', asyncHandlerTyped<CreateUserInput>(async (req, res) => {
|
||||||
|
* const user = await userService.create(req.body);
|
||||||
|
* res.json(user);
|
||||||
|
* }));
|
||||||
|
*/
|
||||||
|
export const asyncHandlerTyped = <TBody = unknown, TParams = unknown, TQuery = unknown>(
|
||||||
|
fn: (
|
||||||
|
req: Request<TParams, unknown, TBody, TQuery>,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) => Promise<void>
|
||||||
|
): RequestHandler => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
Promise.resolve(fn(req as Request<TParams, unknown, TBody, TQuery>, res, next)).catch(next);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps multiple middleware functions and ensures errors are properly caught
|
||||||
|
*/
|
||||||
|
export const asyncMiddleware = (
|
||||||
|
...middlewares: Array<(req: Request, res: Response, next: NextFunction) => Promise<void> | void>
|
||||||
|
): RequestHandler[] => {
|
||||||
|
return middlewares.map((middleware) => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
Promise.resolve(middleware(req, res, next)).catch(next);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default asyncHandler;
|
||||||
3
apps/api/src/utils/index.ts
Normal file
3
apps/api/src/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Utils exports
|
||||||
|
export { logger, httpLogger, createContextLogger, auditLog } from './logger.js';
|
||||||
|
export { asyncHandler, asyncHandlerTyped, asyncMiddleware } from './asyncHandler.js';
|
||||||
147
apps/api/src/utils/logger.ts
Normal file
147
apps/api/src/utils/logger.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import winston from 'winston';
|
||||||
|
import { config } from '../config/index.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Custom Log Formats
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const { combine, timestamp, printf, colorize, errors, json } = winston.format;
|
||||||
|
|
||||||
|
// Simple format for development
|
||||||
|
const simpleFormat = printf(({ level, message, timestamp, ...metadata }) => {
|
||||||
|
let msg = `${timestamp} [${level}]: ${message}`;
|
||||||
|
|
||||||
|
if (Object.keys(metadata).length > 0) {
|
||||||
|
// Filter out Symbol properties and internal winston properties
|
||||||
|
const filteredMeta = Object.entries(metadata).reduce((acc, [key, value]) => {
|
||||||
|
if (!key.startsWith('Symbol') && key !== 'splat') {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, unknown>);
|
||||||
|
|
||||||
|
if (Object.keys(filteredMeta).length > 0) {
|
||||||
|
msg += ` ${JSON.stringify(filteredMeta)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
});
|
||||||
|
|
||||||
|
// JSON format for production
|
||||||
|
const jsonFormat = combine(
|
||||||
|
timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
|
||||||
|
errors({ stack: true }),
|
||||||
|
json()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Development format with colors
|
||||||
|
const devFormat = combine(
|
||||||
|
colorize({ all: true }),
|
||||||
|
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||||
|
errors({ stack: true }),
|
||||||
|
simpleFormat
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Create Logger Instance
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const transports: winston.transport[] = [
|
||||||
|
new winston.transports.Console({
|
||||||
|
format: config.logging.format === 'json' ? jsonFormat : devFormat,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add file transports in production
|
||||||
|
if (config.isProduction) {
|
||||||
|
transports.push(
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: 'logs/error.log',
|
||||||
|
level: 'error',
|
||||||
|
format: jsonFormat,
|
||||||
|
maxsize: 5242880, // 5MB
|
||||||
|
maxFiles: 5,
|
||||||
|
}),
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: 'logs/combined.log',
|
||||||
|
format: jsonFormat,
|
||||||
|
maxsize: 5242880, // 5MB
|
||||||
|
maxFiles: 5,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = winston.createLogger({
|
||||||
|
level: config.logging.level,
|
||||||
|
defaultMeta: {
|
||||||
|
service: 'horux-api',
|
||||||
|
version: process.env.npm_package_version || '0.1.0',
|
||||||
|
},
|
||||||
|
transports,
|
||||||
|
exitOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HTTP Request Logger
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const httpLogger = {
|
||||||
|
write: (message: string) => {
|
||||||
|
logger.http(message.trim());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utility Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface LogContext {
|
||||||
|
requestId?: string;
|
||||||
|
userId?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
action?: string;
|
||||||
|
resource?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createContextLogger = (context: LogContext) => {
|
||||||
|
return {
|
||||||
|
error: (message: string, meta?: Record<string, unknown>) =>
|
||||||
|
logger.error(message, { ...context, ...meta }),
|
||||||
|
warn: (message: string, meta?: Record<string, unknown>) =>
|
||||||
|
logger.warn(message, { ...context, ...meta }),
|
||||||
|
info: (message: string, meta?: Record<string, unknown>) =>
|
||||||
|
logger.info(message, { ...context, ...meta }),
|
||||||
|
http: (message: string, meta?: Record<string, unknown>) =>
|
||||||
|
logger.http(message, { ...context, ...meta }),
|
||||||
|
verbose: (message: string, meta?: Record<string, unknown>) =>
|
||||||
|
logger.verbose(message, { ...context, ...meta }),
|
||||||
|
debug: (message: string, meta?: Record<string, unknown>) =>
|
||||||
|
logger.debug(message, { ...context, ...meta }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Audit Logger for Security Events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const auditLog = (
|
||||||
|
action: string,
|
||||||
|
userId: string | null,
|
||||||
|
tenantId: string | null,
|
||||||
|
details: Record<string, unknown>,
|
||||||
|
success: boolean = true
|
||||||
|
) => {
|
||||||
|
logger.info('AUDIT', {
|
||||||
|
type: 'audit',
|
||||||
|
action,
|
||||||
|
userId,
|
||||||
|
tenantId,
|
||||||
|
success,
|
||||||
|
details,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default logger;
|
||||||
34
apps/api/tsconfig.json
Normal file
34
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"exactOptionalPropertyTypes": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"useUnknownInCatchVariables": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"baseUrl": "./src",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
},
|
||||||
|
"typeRoots": ["./node_modules/@types", "./src/types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
40
apps/web/next.config.js
Normal file
40
apps/web/next.config.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
transpilePackages: ['@horux/shared', '@horux/ui'],
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: '2mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: '*.githubusercontent.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'avatars.githubusercontent.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async redirects() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/api/:path*',
|
||||||
|
headers: [
|
||||||
|
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
|
||||||
|
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||||
|
{ key: 'Access-Control-Allow-Methods', value: 'GET,DELETE,PATCH,POST,PUT' },
|
||||||
|
{ key: 'Access-Control-Allow-Headers', value: 'Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, Authorization' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
40
apps/web/package.json
Normal file
40
apps/web/package.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "@horux/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Horux Strategy Frontend",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"lint:fix": "next lint --fix",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"clean": "rm -rf .next node_modules"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@horux/shared": "workspace:*",
|
||||||
|
"@horux/ui": "workspace:*",
|
||||||
|
"next": "14.1.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"zustand": "^4.5.0",
|
||||||
|
"recharts": "^2.10.4",
|
||||||
|
"lucide-react": "^0.312.0",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"tailwind-merge": "^2.2.0",
|
||||||
|
"date-fns": "^3.3.0",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"@types/react": "^18.2.48",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"autoprefixer": "^10.4.17",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-config-next": "14.1.0",
|
||||||
|
"postcss": "^8.4.33",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
156
apps/web/src/app/(auth)/layout.tsx
Normal file
156
apps/web/src/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth Layout
|
||||||
|
*
|
||||||
|
* Layout para las paginas de autenticacion (login, register).
|
||||||
|
* Redirige a dashboard si el usuario ya esta autenticado.
|
||||||
|
*/
|
||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAuthenticated, isInitialized, checkAuth } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
}, [checkAuth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitialized && isAuthenticated) {
|
||||||
|
router.replace('/dashboard');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, isInitialized, router]);
|
||||||
|
|
||||||
|
// Si esta autenticado, no renderizar nada mientras redirige
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex">
|
||||||
|
{/* Left Panel - Branding */}
|
||||||
|
<div className="hidden lg:flex lg:w-1/2 xl:w-2/5 bg-horux-gradient-dark relative overflow-hidden">
|
||||||
|
{/* Background Pattern */}
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative z-10 flex flex-col justify-between p-8 lg:p-12">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link href="/" className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-white/10 backdrop-blur flex items-center justify-center">
|
||||||
|
<span className="text-2xl font-bold text-white">H</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-white">Horux Strategy</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-4xl xl:text-5xl font-bold text-white leading-tight">
|
||||||
|
Trading Algoritmico
|
||||||
|
<br />
|
||||||
|
<span className="text-primary-300">Inteligente</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-primary-100/80 max-w-md">
|
||||||
|
Automatiza tus estrategias de inversion con inteligencia artificial
|
||||||
|
y maximiza tus ganancias en el mercado de criptomonedas.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<div className="space-y-4 pt-4">
|
||||||
|
<FeatureItem
|
||||||
|
title="Estrategias Automatizadas"
|
||||||
|
description="Ejecuta operaciones 24/7 sin intervencion manual"
|
||||||
|
/>
|
||||||
|
<FeatureItem
|
||||||
|
title="Analisis en Tiempo Real"
|
||||||
|
description="Monitorea el mercado y toma decisiones informadas"
|
||||||
|
/>
|
||||||
|
<FeatureItem
|
||||||
|
title="Gestion de Riesgo"
|
||||||
|
description="Protege tu capital con limites inteligentes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="text-primary-200/60 text-sm">
|
||||||
|
© {new Date().getFullYear()} Horux Strategy. Todos los derechos reservados.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decorative Elements */}
|
||||||
|
<div className="absolute -bottom-32 -right-32 w-96 h-96 rounded-full bg-primary-500/20 blur-3xl" />
|
||||||
|
<div className="absolute -top-32 -left-32 w-96 h-96 rounded-full bg-primary-400/10 blur-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel - Auth Form */}
|
||||||
|
<div className="flex-1 flex items-center justify-center p-4 sm:p-6 lg:p-8 bg-slate-50 dark:bg-slate-950">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Mobile Logo */}
|
||||||
|
<div className="lg:hidden mb-8 text-center">
|
||||||
|
<Link href="/" className="inline-flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-horux-gradient flex items-center justify-center">
|
||||||
|
<span className="text-xl font-bold text-white">H</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-slate-900 dark:text-white">
|
||||||
|
Horux Strategy
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature Item Component
|
||||||
|
*/
|
||||||
|
function FeatureItem({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary-400/20 flex items-center justify-center mt-0.5">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-primary-300"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-medium">{title}</h3>
|
||||||
|
<p className="text-primary-200/70 text-sm">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
apps/web/src/app/(auth)/login/page.tsx
Normal file
257
apps/web/src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Mail, Lock, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login Page
|
||||||
|
*
|
||||||
|
* Pagina de inicio de sesion con formulario de email y password.
|
||||||
|
*/
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { login, isLoading, error, clearError } = useAuthStore();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
remember: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [validationErrors, setValidationErrors] = useState<{
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
// Handle input change
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value, type, checked } = e.target;
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: type === 'checkbox' ? checked : value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear validation error on change
|
||||||
|
if (validationErrors[name as keyof typeof validationErrors]) {
|
||||||
|
setValidationErrors((prev) => ({ ...prev, [name]: undefined }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear API error on change
|
||||||
|
if (error) {
|
||||||
|
clearError();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
const validate = (): boolean => {
|
||||||
|
const errors: typeof validationErrors = {};
|
||||||
|
|
||||||
|
if (!formData.email) {
|
||||||
|
errors.email = 'El email es requerido';
|
||||||
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||||
|
errors.email = 'Email invalido';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.password) {
|
||||||
|
errors.password = 'La contrasena es requerida';
|
||||||
|
} else if (formData.password.length < 6) {
|
||||||
|
errors.password = 'La contrasena debe tener al menos 6 caracteres';
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidationErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle submit
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login({
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
remember: formData.remember,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch {
|
||||||
|
// Error is handled by the store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<h1 className="text-2xl lg:text-3xl font-bold text-slate-900 dark:text-white">
|
||||||
|
Bienvenido de nuevo
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600 dark:text-slate-400">
|
||||||
|
Ingresa tus credenciales para acceder a tu cuenta
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-lg bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800">
|
||||||
|
<AlertCircle className="h-5 w-5 text-error-600 dark:text-error-400 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{/* Email */}
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
label="Email"
|
||||||
|
placeholder="tu@email.com"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={validationErrors.email}
|
||||||
|
leftIcon={<Mail className="h-5 w-5" />}
|
||||||
|
autoComplete="email"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
label="Contrasena"
|
||||||
|
placeholder="Tu contrasena"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={validationErrors.password}
|
||||||
|
leftIcon={<Lock className="h-5 w-5" />}
|
||||||
|
autoComplete="current-password"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Remember & Forgot */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="remember"
|
||||||
|
checked={formData.remember}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-primary-600 focus:ring-primary-500"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Recordarme
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/forgot-password"
|
||||||
|
className="text-sm text-primary-600 dark:text-primary-400 hover:underline"
|
||||||
|
>
|
||||||
|
Olvidaste tu contrasena?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
size="lg"
|
||||||
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
|
Iniciar Sesion
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-slate-200 dark:border-slate-700" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-4 bg-slate-50 dark:bg-slate-950 text-slate-500">
|
||||||
|
O continua con
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Social Login */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<GoogleIcon className="h-5 w-5" />
|
||||||
|
Google
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<GithubIcon className="h-5 w-5" />
|
||||||
|
GitHub
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Register Link */}
|
||||||
|
<p className="text-center text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
No tienes una cuenta?{' '}
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="font-medium text-primary-600 dark:text-primary-400 hover:underline"
|
||||||
|
>
|
||||||
|
Registrate gratis
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Icon
|
||||||
|
*/
|
||||||
|
function GoogleIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Github Icon
|
||||||
|
*/
|
||||||
|
function GithubIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
392
apps/web/src/app/(auth)/register/page.tsx
Normal file
392
apps/web/src/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Mail, Lock, User, AlertCircle, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register Page
|
||||||
|
*
|
||||||
|
* Pagina de registro con formulario de nombre, email y password.
|
||||||
|
*/
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { register, isLoading, error, clearError } = useAuthStore();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
acceptTerms: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [validationErrors, setValidationErrors] = useState<{
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
confirmPassword?: string;
|
||||||
|
acceptTerms?: string;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
// Password strength
|
||||||
|
const getPasswordStrength = (password: string): {
|
||||||
|
score: number;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
} => {
|
||||||
|
let score = 0;
|
||||||
|
if (password.length >= 8) score++;
|
||||||
|
if (password.length >= 12) score++;
|
||||||
|
if (/[A-Z]/.test(password)) score++;
|
||||||
|
if (/[0-9]/.test(password)) score++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(password)) score++;
|
||||||
|
|
||||||
|
if (score <= 1) return { score, label: 'Muy debil', color: 'bg-error-500' };
|
||||||
|
if (score === 2) return { score, label: 'Debil', color: 'bg-warning-500' };
|
||||||
|
if (score === 3) return { score, label: 'Media', color: 'bg-warning-400' };
|
||||||
|
if (score === 4) return { score, label: 'Fuerte', color: 'bg-success-400' };
|
||||||
|
return { score, label: 'Muy fuerte', color: 'bg-success-500' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordStrength = getPasswordStrength(formData.password);
|
||||||
|
|
||||||
|
// Handle input change
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value, type, checked } = e.target;
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: type === 'checkbox' ? checked : value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear validation error on change
|
||||||
|
if (validationErrors[name as keyof typeof validationErrors]) {
|
||||||
|
setValidationErrors((prev) => ({ ...prev, [name]: undefined }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear API error on change
|
||||||
|
if (error) {
|
||||||
|
clearError();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
const validate = (): boolean => {
|
||||||
|
const errors: typeof validationErrors = {};
|
||||||
|
|
||||||
|
if (!formData.name) {
|
||||||
|
errors.name = 'El nombre es requerido';
|
||||||
|
} else if (formData.name.length < 2) {
|
||||||
|
errors.name = 'El nombre debe tener al menos 2 caracteres';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.email) {
|
||||||
|
errors.email = 'El email es requerido';
|
||||||
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||||
|
errors.email = 'Email invalido';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.password) {
|
||||||
|
errors.password = 'La contrasena es requerida';
|
||||||
|
} else if (formData.password.length < 8) {
|
||||||
|
errors.password = 'La contrasena debe tener al menos 8 caracteres';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.confirmPassword) {
|
||||||
|
errors.confirmPassword = 'Confirma tu contrasena';
|
||||||
|
} else if (formData.password !== formData.confirmPassword) {
|
||||||
|
errors.confirmPassword = 'Las contrasenas no coinciden';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.acceptTerms) {
|
||||||
|
errors.acceptTerms = 'Debes aceptar los terminos y condiciones';
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidationErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle submit
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register({
|
||||||
|
name: formData.name,
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch {
|
||||||
|
// Error is handled by the store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Password requirements
|
||||||
|
const requirements = [
|
||||||
|
{ label: 'Al menos 8 caracteres', met: formData.password.length >= 8 },
|
||||||
|
{ label: 'Una letra mayuscula', met: /[A-Z]/.test(formData.password) },
|
||||||
|
{ label: 'Un numero', met: /[0-9]/.test(formData.password) },
|
||||||
|
{ label: 'Un caracter especial', met: /[^A-Za-z0-9]/.test(formData.password) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<h1 className="text-2xl lg:text-3xl font-bold text-slate-900 dark:text-white">
|
||||||
|
Crear cuenta
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600 dark:text-slate-400">
|
||||||
|
Comienza tu viaje en el trading algoritmico
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-lg bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800">
|
||||||
|
<AlertCircle className="h-5 w-5 text-error-600 dark:text-error-400 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{/* Name */}
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
label="Nombre completo"
|
||||||
|
placeholder="Tu nombre"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={validationErrors.name}
|
||||||
|
leftIcon={<User className="h-5 w-5" />}
|
||||||
|
autoComplete="name"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
label="Email"
|
||||||
|
placeholder="tu@email.com"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={validationErrors.email}
|
||||||
|
leftIcon={<Mail className="h-5 w-5" />}
|
||||||
|
autoComplete="email"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
label="Contrasena"
|
||||||
|
placeholder="Crea una contrasena segura"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={validationErrors.password}
|
||||||
|
leftIcon={<Lock className="h-5 w-5" />}
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Password Strength */}
|
||||||
|
{formData.password && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-1 flex-1 rounded-full transition-all ${
|
||||||
|
i <= passwordStrength.score
|
||||||
|
? passwordStrength.color
|
||||||
|
: 'bg-slate-200 dark:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Seguridad: <span className="font-medium">{passwordStrength.label}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Requirements */}
|
||||||
|
{formData.password && (
|
||||||
|
<ul className="grid grid-cols-2 gap-2">
|
||||||
|
{requirements.map((req) => (
|
||||||
|
<li
|
||||||
|
key={req.label}
|
||||||
|
className={`flex items-center gap-2 text-xs ${
|
||||||
|
req.met
|
||||||
|
? 'text-success-600 dark:text-success-400'
|
||||||
|
: 'text-slate-400 dark:text-slate-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{req.met ? (
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<div className="h-3 w-3 rounded-full border border-current" />
|
||||||
|
)}
|
||||||
|
{req.label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Password */}
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
label="Confirmar contrasena"
|
||||||
|
placeholder="Repite tu contrasena"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={validationErrors.confirmPassword}
|
||||||
|
leftIcon={<Lock className="h-5 w-5" />}
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={isLoading}
|
||||||
|
success={
|
||||||
|
formData.confirmPassword !== '' &&
|
||||||
|
formData.password === formData.confirmPassword
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Terms */}
|
||||||
|
<div>
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="acceptTerms"
|
||||||
|
checked={formData.acceptTerms}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="mt-0.5 w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-primary-600 focus:ring-primary-500"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Acepto los{' '}
|
||||||
|
<Link
|
||||||
|
href="/terms"
|
||||||
|
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||||
|
>
|
||||||
|
Terminos de Servicio
|
||||||
|
</Link>{' '}
|
||||||
|
y la{' '}
|
||||||
|
<Link
|
||||||
|
href="/privacy"
|
||||||
|
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||||
|
>
|
||||||
|
Politica de Privacidad
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{validationErrors.acceptTerms && (
|
||||||
|
<p className="mt-1.5 text-sm text-error-600 dark:text-error-400">
|
||||||
|
{validationErrors.acceptTerms}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<Button type="submit" fullWidth size="lg" isLoading={isLoading}>
|
||||||
|
Crear cuenta
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-slate-200 dark:border-slate-700" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-4 bg-slate-50 dark:bg-slate-950 text-slate-500">
|
||||||
|
O registrate con
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Social Register */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<GoogleIcon className="h-5 w-5" />
|
||||||
|
Google
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<GithubIcon className="h-5 w-5" />
|
||||||
|
GitHub
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Link */}
|
||||||
|
<p className="text-center text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Ya tienes una cuenta?{' '}
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="font-medium text-primary-600 dark:text-primary-400 hover:underline"
|
||||||
|
>
|
||||||
|
Inicia sesion
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Icon
|
||||||
|
*/
|
||||||
|
function GoogleIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Github Icon
|
||||||
|
*/
|
||||||
|
function GithubIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
788
apps/web/src/app/(dashboard)/cfdis/page.tsx
Normal file
788
apps/web/src/app/(dashboard)/cfdis/page.tsx
Normal file
@@ -0,0 +1,788 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Download,
|
||||||
|
RefreshCw,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Eye,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
XCircle,
|
||||||
|
AlertCircle,
|
||||||
|
X,
|
||||||
|
ExternalLink,
|
||||||
|
Building2,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/utils';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type CFDIType = 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago';
|
||||||
|
type CFDIStatus = 'vigente' | 'cancelado' | 'pendiente_cancelacion';
|
||||||
|
type PaymentStatus = 'pagado' | 'parcial' | 'pendiente' | 'vencido';
|
||||||
|
type CFDITab = 'emitidos' | 'recibidos' | 'complementos';
|
||||||
|
|
||||||
|
interface CFDIConcept {
|
||||||
|
claveProdServ: string;
|
||||||
|
cantidad: number;
|
||||||
|
claveUnidad: string;
|
||||||
|
unidad: string;
|
||||||
|
descripcion: string;
|
||||||
|
valorUnitario: number;
|
||||||
|
importe: number;
|
||||||
|
descuento?: number;
|
||||||
|
impuestos?: {
|
||||||
|
traslados?: { impuesto: string; tasa: number; importe: number }[];
|
||||||
|
retenciones?: { impuesto: string; tasa: number; importe: number }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CFDI {
|
||||||
|
id: string;
|
||||||
|
uuid: string;
|
||||||
|
serie?: string;
|
||||||
|
folio?: string;
|
||||||
|
fecha: string;
|
||||||
|
fechaTimbrado: string;
|
||||||
|
tipo: CFDIType;
|
||||||
|
tipoComprobante: string;
|
||||||
|
metodoPago?: string;
|
||||||
|
formaPago?: string;
|
||||||
|
condicionesPago?: string;
|
||||||
|
status: CFDIStatus;
|
||||||
|
paymentStatus: PaymentStatus;
|
||||||
|
emisor: {
|
||||||
|
rfc: string;
|
||||||
|
nombre: string;
|
||||||
|
regimenFiscal: string;
|
||||||
|
};
|
||||||
|
receptor: {
|
||||||
|
rfc: string;
|
||||||
|
nombre: string;
|
||||||
|
usoCFDI: string;
|
||||||
|
};
|
||||||
|
conceptos: CFDIConcept[];
|
||||||
|
subtotal: number;
|
||||||
|
descuento?: number;
|
||||||
|
impuestos: {
|
||||||
|
totalTraslados: number;
|
||||||
|
totalRetenciones: number;
|
||||||
|
};
|
||||||
|
total: number;
|
||||||
|
moneda: string;
|
||||||
|
tipoCambio?: number;
|
||||||
|
complementos?: string[];
|
||||||
|
relacionados?: { tipoRelacion: string; uuid: string }[];
|
||||||
|
pagosRelacionados?: { id: string; uuid: string; fecha: string; monto: number }[];
|
||||||
|
montoPagado: number;
|
||||||
|
montoPendiente: number;
|
||||||
|
xmlUrl?: string;
|
||||||
|
pdfUrl?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Filters {
|
||||||
|
search: string;
|
||||||
|
tipo: CFDIType | 'all';
|
||||||
|
status: CFDIStatus | 'all';
|
||||||
|
paymentStatus: PaymentStatus | 'all';
|
||||||
|
rfc: string;
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mock Data
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const generateMockCFDIs = (): CFDI[] => {
|
||||||
|
const emisores = [
|
||||||
|
{ rfc: 'EMP123456789', nombre: 'Mi Empresa S.A. de C.V.', regimenFiscal: '601' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const receptores = [
|
||||||
|
{ rfc: 'CLI987654321', nombre: 'Cliente Uno S.A.', usoCFDI: 'G03' },
|
||||||
|
{ rfc: 'PRO456789123', nombre: 'Proveedor Alpha', usoCFDI: 'G01' },
|
||||||
|
{ rfc: 'SER789123456', nombre: 'Servicios Beta S.A.', usoCFDI: 'G03' },
|
||||||
|
{ rfc: 'TEC321654987', nombre: 'Tech Solutions', usoCFDI: 'G01' },
|
||||||
|
{ rfc: 'DIS654987321', nombre: 'Distribuidora Nacional', usoCFDI: 'G03' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const cfdis: CFDI[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 80; i++) {
|
||||||
|
const isEmitted = Math.random() > 0.4;
|
||||||
|
const tipo: CFDIType = (['ingreso', 'egreso', 'pago'] as CFDIType[])[Math.floor(Math.random() * 3)];
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - Math.floor(Math.random() * 90));
|
||||||
|
const total = Math.floor(Math.random() * 100000) + 1000;
|
||||||
|
const pagado = Math.random() > 0.3 ? (Math.random() > 0.5 ? total : Math.floor(total * Math.random())) : 0;
|
||||||
|
|
||||||
|
const receptor = receptores[Math.floor(Math.random() * receptores.length)];
|
||||||
|
|
||||||
|
let paymentStatus: PaymentStatus = 'pendiente';
|
||||||
|
if (pagado >= total) paymentStatus = 'pagado';
|
||||||
|
else if (pagado > 0) paymentStatus = 'parcial';
|
||||||
|
else if (date < new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)) paymentStatus = 'vencido';
|
||||||
|
|
||||||
|
cfdis.push({
|
||||||
|
id: `cfdi-${i + 1}`,
|
||||||
|
uuid: `${Math.random().toString(36).substr(2, 8)}-${Math.random().toString(36).substr(2, 4)}-${Math.random().toString(36).substr(2, 4)}-${Math.random().toString(36).substr(2, 4)}-${Math.random().toString(36).substr(2, 12)}`.toUpperCase(),
|
||||||
|
serie: 'A',
|
||||||
|
folio: String(1000 + i),
|
||||||
|
fecha: date.toISOString(),
|
||||||
|
fechaTimbrado: date.toISOString(),
|
||||||
|
tipo,
|
||||||
|
tipoComprobante: tipo === 'ingreso' ? 'I' : tipo === 'egreso' ? 'E' : 'P',
|
||||||
|
metodoPago: ['PUE', 'PPD'][Math.floor(Math.random() * 2)],
|
||||||
|
formaPago: ['01', '03', '04', '28'][Math.floor(Math.random() * 4)],
|
||||||
|
status: Math.random() > 0.1 ? 'vigente' : 'cancelado',
|
||||||
|
paymentStatus,
|
||||||
|
emisor: isEmitted ? emisores[0] : { ...receptor, regimenFiscal: '601' },
|
||||||
|
receptor: isEmitted ? receptor : emisores[0],
|
||||||
|
conceptos: [
|
||||||
|
{
|
||||||
|
claveProdServ: '84111506',
|
||||||
|
cantidad: 1,
|
||||||
|
claveUnidad: 'E48',
|
||||||
|
unidad: 'Servicio',
|
||||||
|
descripcion: `Servicio profesional ${i + 1}`,
|
||||||
|
valorUnitario: total / 1.16,
|
||||||
|
importe: total / 1.16,
|
||||||
|
impuestos: {
|
||||||
|
traslados: [{ impuesto: '002', tasa: 0.16, importe: (total / 1.16) * 0.16 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subtotal: total / 1.16,
|
||||||
|
impuestos: {
|
||||||
|
totalTraslados: (total / 1.16) * 0.16,
|
||||||
|
totalRetenciones: 0,
|
||||||
|
},
|
||||||
|
total,
|
||||||
|
moneda: 'MXN',
|
||||||
|
montoPagado: pagado,
|
||||||
|
montoPendiente: total - pagado,
|
||||||
|
pagosRelacionados: pagado > 0 && tipo !== 'pago' ? [
|
||||||
|
{ id: `pago-${i}`, uuid: `pago-uuid-${i}`, fecha: date.toISOString(), monto: pagado },
|
||||||
|
] : undefined,
|
||||||
|
xmlUrl: `/api/cfdis/${i}/xml`,
|
||||||
|
pdfUrl: `/api/cfdis/${i}/pdf`,
|
||||||
|
createdAt: date.toISOString(),
|
||||||
|
updatedAt: date.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfdis.sort((a, b) => new Date(b.fecha).getTime() - new Date(a.fecha).getTime());
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Loading Skeleton
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function CFDISkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-28" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-64" />
|
||||||
|
</div>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="px-4 py-4 border-b border-gray-100 dark:border-gray-700 flex gap-4">
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded flex-1" />
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24" />
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Payment Status Badge
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface PaymentBadgeProps {
|
||||||
|
status: PaymentStatus;
|
||||||
|
montoPagado: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaymentBadge({ status, montoPagado, total }: PaymentBadgeProps) {
|
||||||
|
const configs: Record<PaymentStatus, { icon: React.ReactNode; label: string; classes: string }> = {
|
||||||
|
pagado: {
|
||||||
|
icon: <CheckCircle className="w-3.5 h-3.5" />,
|
||||||
|
label: 'Pagado',
|
||||||
|
classes: 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400',
|
||||||
|
},
|
||||||
|
parcial: {
|
||||||
|
icon: <Clock className="w-3.5 h-3.5" />,
|
||||||
|
label: `${Math.round((montoPagado / total) * 100)}% Pagado`,
|
||||||
|
classes: 'bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-400',
|
||||||
|
},
|
||||||
|
pendiente: {
|
||||||
|
icon: <Clock className="w-3.5 h-3.5" />,
|
||||||
|
label: 'Pendiente',
|
||||||
|
classes: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||||
|
},
|
||||||
|
vencido: {
|
||||||
|
icon: <AlertCircle className="w-3.5 h-3.5" />,
|
||||||
|
label: 'Vencido',
|
||||||
|
classes: 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = configs[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn('inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full', config.classes)}>
|
||||||
|
{config.icon}
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Filter Panel
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface FilterPanelProps {
|
||||||
|
filters: Filters;
|
||||||
|
onChange: (filters: Filters) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterPanel({ filters, onChange, onClose, isOpen }: FilterPanelProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-4 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white">Filtros</h3>
|
||||||
|
<button onClick={onClose} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tipo</label>
|
||||||
|
<select
|
||||||
|
value={filters.tipo}
|
||||||
|
onChange={(e) => onChange({ ...filters, tipo: e.target.value as Filters['tipo'] })}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">Todos</option>
|
||||||
|
<option value="ingreso">Ingreso</option>
|
||||||
|
<option value="egreso">Egreso</option>
|
||||||
|
<option value="pago">Pago</option>
|
||||||
|
<option value="traslado">Traslado</option>
|
||||||
|
<option value="nomina">Nomina</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estado CFDI</label>
|
||||||
|
<select
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(e) => onChange({ ...filters, status: e.target.value as Filters['status'] })}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">Todos</option>
|
||||||
|
<option value="vigente">Vigente</option>
|
||||||
|
<option value="cancelado">Cancelado</option>
|
||||||
|
<option value="pendiente_cancelacion">Pendiente Cancelacion</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estado de Pago</label>
|
||||||
|
<select
|
||||||
|
value={filters.paymentStatus}
|
||||||
|
onChange={(e) => onChange({ ...filters, paymentStatus: e.target.value as Filters['paymentStatus'] })}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">Todos</option>
|
||||||
|
<option value="pagado">Pagado</option>
|
||||||
|
<option value="parcial">Parcial</option>
|
||||||
|
<option value="pendiente">Pendiente</option>
|
||||||
|
<option value="vencido">Vencido</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">RFC</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por RFC..."
|
||||||
|
value={filters.rfc}
|
||||||
|
onChange={(e) => onChange({ ...filters, rfc: e.target.value.toUpperCase() })}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Desde</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateFrom}
|
||||||
|
onChange={(e) => onChange({ ...filters, dateFrom: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hasta</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateTo}
|
||||||
|
onChange={(e) => onChange({ ...filters, dateTo: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
|
search: '',
|
||||||
|
tipo: 'all',
|
||||||
|
status: 'all',
|
||||||
|
paymentStatus: 'all',
|
||||||
|
rfc: '',
|
||||||
|
dateFrom: '',
|
||||||
|
dateTo: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
Limpiar filtros
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className="px-4 py-2 bg-primary-500 text-white rounded-lg text-sm hover:bg-primary-600">
|
||||||
|
Aplicar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Page Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function CFDIsPage() {
|
||||||
|
const [cfdis, setCfdis] = useState<CFDI[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<CFDITab>('emitidos');
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [filters, setFilters] = useState<Filters>({
|
||||||
|
search: '',
|
||||||
|
tipo: 'all',
|
||||||
|
status: 'all',
|
||||||
|
paymentStatus: 'all',
|
||||||
|
rfc: '',
|
||||||
|
dateFrom: '',
|
||||||
|
dateTo: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const limit = 20;
|
||||||
|
const myRFC = 'EMP123456789';
|
||||||
|
|
||||||
|
const fetchCFDIs = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||||
|
setCfdis(generateMockCFDIs());
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al cargar CFDIs');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCFDIs();
|
||||||
|
}, [fetchCFDIs]);
|
||||||
|
|
||||||
|
// Filter CFDIs by tab and filters
|
||||||
|
const filteredCFDIs = useMemo(() => {
|
||||||
|
return cfdis.filter((cfdi) => {
|
||||||
|
// Tab filter
|
||||||
|
if (activeTab === 'emitidos' && cfdi.emisor.rfc !== myRFC) return false;
|
||||||
|
if (activeTab === 'recibidos' && cfdi.receptor.rfc !== myRFC) return false;
|
||||||
|
if (activeTab === 'complementos' && cfdi.tipo !== 'pago') return false;
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
if (filters.search) {
|
||||||
|
const searchLower = filters.search.toLowerCase();
|
||||||
|
if (
|
||||||
|
!cfdi.uuid.toLowerCase().includes(searchLower) &&
|
||||||
|
!cfdi.emisor.nombre.toLowerCase().includes(searchLower) &&
|
||||||
|
!cfdi.receptor.nombre.toLowerCase().includes(searchLower) &&
|
||||||
|
!cfdi.folio?.toLowerCase().includes(searchLower)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other filters
|
||||||
|
if (filters.tipo !== 'all' && cfdi.tipo !== filters.tipo) return false;
|
||||||
|
if (filters.status !== 'all' && cfdi.status !== filters.status) return false;
|
||||||
|
if (filters.paymentStatus !== 'all' && cfdi.paymentStatus !== filters.paymentStatus) return false;
|
||||||
|
if (filters.rfc) {
|
||||||
|
if (!cfdi.emisor.rfc.includes(filters.rfc) && !cfdi.receptor.rfc.includes(filters.rfc)) return false;
|
||||||
|
}
|
||||||
|
if (filters.dateFrom && cfdi.fecha < filters.dateFrom) return false;
|
||||||
|
if (filters.dateTo && cfdi.fecha > filters.dateTo) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [cfdis, activeTab, filters, myRFC]);
|
||||||
|
|
||||||
|
const paginatedCFDIs = useMemo(() => {
|
||||||
|
const start = (page - 1) * limit;
|
||||||
|
return filteredCFDIs.slice(start, start + limit);
|
||||||
|
}, [filteredCFDIs, page]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredCFDIs.length / limit);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const total = filteredCFDIs.reduce((sum, c) => sum + c.total, 0);
|
||||||
|
const pagado = filteredCFDIs.reduce((sum, c) => sum + c.montoPagado, 0);
|
||||||
|
const pendiente = filteredCFDIs.reduce((sum, c) => sum + c.montoPendiente, 0);
|
||||||
|
return { total, pagado, pendiente, count: filteredCFDIs.length };
|
||||||
|
}, [filteredCFDIs]);
|
||||||
|
|
||||||
|
const tipoLabels: Record<CFDIType, string> = {
|
||||||
|
ingreso: 'Ingreso',
|
||||||
|
egreso: 'Egreso',
|
||||||
|
traslado: 'Traslado',
|
||||||
|
nomina: 'Nomina',
|
||||||
|
pago: 'Pago',
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewCFDI = (cfdi: CFDI) => {
|
||||||
|
window.location.href = `/cfdis/${cfdi.id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<CFDISkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800 rounded-lg p-6 text-center">
|
||||||
|
<p className="text-error-700 dark:text-error-400 mb-4">{error}</p>
|
||||||
|
<button onClick={fetchCFDIs} className="px-4 py-2 bg-error-600 text-white rounded-lg hover:bg-error-700">
|
||||||
|
Reintentar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">CFDIs</h1>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Gestion de comprobantes fiscales digitales
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={fetchCFDIs}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Sincronizar SAT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
{([
|
||||||
|
{ value: 'emitidos', label: 'Emitidos' },
|
||||||
|
{ value: 'recibidos', label: 'Recibidos' },
|
||||||
|
{ value: 'complementos', label: 'Complementos de Pago' },
|
||||||
|
] as { value: CFDITab; label: string }[]).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab(tab.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||||
|
activeTab === tab.value
|
||||||
|
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||||
|
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||||
|
<FileText className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Total CFDIs</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-white">{formatNumber(summary.count)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
|
||||||
|
<DollarSign className="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Monto Total</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-white">{formatCurrency(summary.total)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||||||
|
<CheckCircle className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Pagado</p>
|
||||||
|
<p className="text-xl font-bold text-success-600 dark:text-success-400">{formatCurrency(summary.pagado)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-warning-100 dark:bg-warning-900/30 rounded-lg">
|
||||||
|
<Clock className="w-5 h-5 text-warning-600 dark:text-warning-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Pendiente</p>
|
||||||
|
<p className="text-xl font-bold text-warning-600 dark:text-warning-400">{formatCurrency(summary.pendiente)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por UUID, folio, nombre..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-4 py-2 border rounded-lg transition-colors',
|
||||||
|
showFilters
|
||||||
|
? 'bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-700 text-primary-700 dark:text-primary-400'
|
||||||
|
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Filter className="w-4 h-4" />
|
||||||
|
Filtros
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Panel */}
|
||||||
|
<FilterPanel filters={filters} onChange={setFilters} onClose={() => setShowFilters(false)} isOpen={showFilters} />
|
||||||
|
|
||||||
|
{/* CFDIs Table */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Folio / UUID
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Fecha
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{activeTab === 'emitidos' ? 'Receptor' : 'Emisor'}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Tipo
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Total
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Estado CFDI
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Estado Pago
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Acciones
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{paginatedCFDIs.map((cfdi) => {
|
||||||
|
const contacto = activeTab === 'emitidos' ? cfdi.receptor : cfdi.emisor;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={cfdi.id}
|
||||||
|
onClick={() => handleViewCFDI(cfdi)}
|
||||||
|
className="hover:bg-gray-50 dark:hover:bg-gray-900/50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{cfdi.serie}-{cfdi.folio}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||||
|
{cfdi.uuid.slice(0, 8)}...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||||
|
{formatDate(cfdi.fecha)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-900 dark:text-white">{contacto.nombre}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{contacto.rfc}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex px-2 py-0.5 text-xs font-medium rounded-full',
|
||||||
|
cfdi.tipo === 'ingreso' && 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400',
|
||||||
|
cfdi.tipo === 'egreso' && 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400',
|
||||||
|
cfdi.tipo === 'pago' && 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400',
|
||||||
|
cfdi.tipo === 'traslado' && 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||||
|
cfdi.tipo === 'nomina' && 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tipoLabels[cfdi.tipo]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{formatCurrency(cfdi.total, cfdi.moneda)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full',
|
||||||
|
cfdi.status === 'vigente' && 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400',
|
||||||
|
cfdi.status === 'cancelado' && 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400',
|
||||||
|
cfdi.status === 'pendiente_cancelacion' && 'bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cfdi.status === 'vigente' && <CheckCircle className="w-3 h-3" />}
|
||||||
|
{cfdi.status === 'cancelado' && <XCircle className="w-3 h-3" />}
|
||||||
|
{cfdi.status === 'pendiente_cancelacion' && <Clock className="w-3 h-3" />}
|
||||||
|
{cfdi.status === 'vigente' ? 'Vigente' : cfdi.status === 'cancelado' ? 'Cancelado' : 'Pend. Cancel.'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<PaymentBadge status={cfdi.paymentStatus} montoPagado={cfdi.montoPagado} total={cfdi.total} />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleViewCFDI(cfdi);
|
||||||
|
}}
|
||||||
|
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||||
|
title="Ver detalle"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={cfdi.xmlUrl}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||||
|
title="Descargar XML"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 text-gray-500" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Mostrando {(page - 1) * limit + 1} a {Math.min(page * limit, filteredCFDIs.length)} de{' '}
|
||||||
|
{filteredCFDIs.length} CFDIs
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Pagina {page} de {totalPages || 1}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages || totalPages === 0}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
441
apps/web/src/app/(dashboard)/dashboard/page.tsx
Normal file
441
apps/web/src/app/(dashboard)/dashboard/page.tsx
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Card, CardHeader, CardContent, StatsCard } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { cn, formatCurrency, formatPercentage, formatNumber } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Wallet,
|
||||||
|
Activity,
|
||||||
|
BarChart3,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownRight,
|
||||||
|
Bot,
|
||||||
|
Target,
|
||||||
|
Clock,
|
||||||
|
AlertTriangle,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard Page
|
||||||
|
*
|
||||||
|
* Pagina principal del dashboard con KPIs, grafico de portfolio,
|
||||||
|
* estrategias activas y trades recientes.
|
||||||
|
*/
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl lg:text-3xl font-bold text-slate-900 dark:text-white">
|
||||||
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-slate-500 dark:text-slate-400">
|
||||||
|
Bienvenido de nuevo. Aqui esta el resumen de tu portfolio.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" leftIcon={<RefreshCw className="h-4 w-4" />}>
|
||||||
|
Actualizar
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" leftIcon={<Plus className="h-4 w-4" />}>
|
||||||
|
Nueva Estrategia
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
|
||||||
|
<StatsCard
|
||||||
|
title="Balance Total"
|
||||||
|
value={formatCurrency(125847.32)}
|
||||||
|
change={{ value: 12.5, label: 'vs mes anterior' }}
|
||||||
|
trend="up"
|
||||||
|
icon={<Wallet className="h-6 w-6" />}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="Ganancia Hoy"
|
||||||
|
value={formatCurrency(2340.18)}
|
||||||
|
change={{ value: 8.2, label: 'vs ayer' }}
|
||||||
|
trend="up"
|
||||||
|
icon={<TrendingUp className="h-6 w-6" />}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="Trades Activos"
|
||||||
|
value="12"
|
||||||
|
change={{ value: -2, label: 'vs ayer' }}
|
||||||
|
trend="down"
|
||||||
|
icon={<Activity className="h-6 w-6" />}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="Win Rate"
|
||||||
|
value="68.5%"
|
||||||
|
change={{ value: 3.2, label: 'vs semana anterior' }}
|
||||||
|
trend="up"
|
||||||
|
icon={<Target className="h-6 w-6" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Grid */}
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
|
{/* Portfolio Chart - Takes 2 columns */}
|
||||||
|
<Card className="xl:col-span-2">
|
||||||
|
<CardHeader
|
||||||
|
title="Rendimiento del Portfolio"
|
||||||
|
subtitle="Ultimos 30 dias"
|
||||||
|
action={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button className="px-3 py-1 text-xs font-medium rounded-lg bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
||||||
|
1M
|
||||||
|
</button>
|
||||||
|
<button className="px-3 py-1 text-xs font-medium rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||||
|
3M
|
||||||
|
</button>
|
||||||
|
<button className="px-3 py-1 text-xs font-medium rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||||
|
6M
|
||||||
|
</button>
|
||||||
|
<button className="px-3 py-1 text-xs font-medium rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||||
|
1A
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
{/* Chart Placeholder */}
|
||||||
|
<div className="h-64 lg:h-80 flex items-center justify-center bg-slate-50 dark:bg-slate-800/50 rounded-lg border-2 border-dashed border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="text-center">
|
||||||
|
<BarChart3 className="h-12 w-12 mx-auto text-slate-300 dark:text-slate-600" />
|
||||||
|
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Grafico de rendimiento
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
Conecta con Recharts para visualizacion
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart Stats */}
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||||
|
<div className="text-center p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Maximo</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
{formatCurrency(132450.00)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Minimo</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
{formatCurrency(98320.00)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Promedio</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
{formatCurrency(115385.00)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Active Strategies */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Estrategias Activas"
|
||||||
|
subtitle="3 de 5 ejecutando"
|
||||||
|
action={
|
||||||
|
<Button variant="ghost" size="xs">
|
||||||
|
Ver todas
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{strategies.map((strategy) => (
|
||||||
|
<StrategyItem key={strategy.id} strategy={strategy} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Second Row */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Recent Trades */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Trades Recientes"
|
||||||
|
subtitle="Ultimas 24 horas"
|
||||||
|
action={
|
||||||
|
<Button variant="ghost" size="xs">
|
||||||
|
Ver historial
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{recentTrades.map((trade) => (
|
||||||
|
<TradeItem key={trade.id} trade={trade} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Market Overview */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Resumen del Mercado"
|
||||||
|
subtitle="Precios en tiempo real"
|
||||||
|
action={
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-success-600 dark:text-success-400">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-success-500 animate-pulse" />
|
||||||
|
En vivo
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{marketData.map((market) => (
|
||||||
|
<MarketItem key={market.symbol} market={market} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alerts Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Alertas y Notificaciones"
|
||||||
|
action={
|
||||||
|
<Button variant="ghost" size="xs">
|
||||||
|
Configurar alertas
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<AlertItem key={alert.id} alert={alert} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Mock Data
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface Strategy {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
status: 'running' | 'paused' | 'stopped';
|
||||||
|
profit: number;
|
||||||
|
trades: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategies: Strategy[] = [
|
||||||
|
{ id: '1', name: 'Grid BTC/USDT', type: 'Grid Trading', status: 'running', profit: 12.5, trades: 45 },
|
||||||
|
{ id: '2', name: 'DCA ETH', type: 'DCA', status: 'running', profit: 8.2, trades: 12 },
|
||||||
|
{ id: '3', name: 'Scalping SOL', type: 'Scalping', status: 'paused', profit: -2.1, trades: 128 },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Trade {
|
||||||
|
id: string;
|
||||||
|
pair: string;
|
||||||
|
type: 'buy' | 'sell';
|
||||||
|
amount: number;
|
||||||
|
price: number;
|
||||||
|
profit?: number;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentTrades: Trade[] = [
|
||||||
|
{ id: '1', pair: 'BTC/USDT', type: 'buy', amount: 0.05, price: 43250, time: '10:32' },
|
||||||
|
{ id: '2', pair: 'ETH/USDT', type: 'sell', amount: 1.2, price: 2280, profit: 45.20, time: '10:15' },
|
||||||
|
{ id: '3', pair: 'SOL/USDT', type: 'buy', amount: 10, price: 98.5, time: '09:58' },
|
||||||
|
{ id: '4', pair: 'BTC/USDT', type: 'sell', amount: 0.08, price: 43180, profit: 120.50, time: '09:45' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Market {
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
change: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const marketData: Market[] = [
|
||||||
|
{ symbol: 'BTC', name: 'Bitcoin', price: 43250.00, change: 2.34 },
|
||||||
|
{ symbol: 'ETH', name: 'Ethereum', price: 2280.50, change: 1.82 },
|
||||||
|
{ symbol: 'SOL', name: 'Solana', price: 98.45, change: -0.54 },
|
||||||
|
{ symbol: 'BNB', name: 'BNB', price: 312.80, change: 0.92 },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Alert {
|
||||||
|
id: string;
|
||||||
|
type: 'warning' | 'info' | 'success';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alerts: Alert[] = [
|
||||||
|
{ id: '1', type: 'warning', title: 'Stop Loss cercano', message: 'BTC/USDT esta a 2% del stop loss', time: '5 min' },
|
||||||
|
{ id: '2', type: 'success', title: 'Take Profit alcanzado', message: 'ETH/USDT cerro con +3.5%', time: '15 min' },
|
||||||
|
{ id: '3', type: 'info', title: 'Nueva señal', message: 'SOL/USDT señal de compra detectada', time: '30 min' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Sub-components
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
function StrategyItem({ strategy }: { strategy: Strategy }) {
|
||||||
|
const statusStyles = {
|
||||||
|
running: 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400',
|
||||||
|
paused: 'bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-400',
|
||||||
|
stopped: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
|
||||||
|
<Bot className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900 dark:text-white">{strategy.name}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">{strategy.type}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className={cn(
|
||||||
|
'font-semibold',
|
||||||
|
strategy.profit >= 0 ? 'text-success-600 dark:text-success-400' : 'text-error-600 dark:text-error-400'
|
||||||
|
)}>
|
||||||
|
{formatPercentage(strategy.profit)}
|
||||||
|
</p>
|
||||||
|
<span className={cn('text-xs px-2 py-0.5 rounded-full', statusStyles[strategy.status])}>
|
||||||
|
{strategy.status === 'running' ? 'Activo' : strategy.status === 'paused' ? 'Pausado' : 'Detenido'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TradeItem({ trade }: { trade: Trade }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
'w-8 h-8 rounded-full flex items-center justify-center',
|
||||||
|
trade.type === 'buy'
|
||||||
|
? 'bg-success-100 dark:bg-success-900/30'
|
||||||
|
: 'bg-error-100 dark:bg-error-900/30'
|
||||||
|
)}>
|
||||||
|
{trade.type === 'buy' ? (
|
||||||
|
<ArrowDownRight className="h-4 w-4 text-success-600 dark:text-success-400" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpRight className="h-4 w-4 text-error-600 dark:text-error-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900 dark:text-white">{trade.pair}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{trade.type === 'buy' ? 'Compra' : 'Venta'} - {trade.amount} @ {formatCurrency(trade.price)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
{trade.profit !== undefined ? (
|
||||||
|
<p className={cn(
|
||||||
|
'font-semibold',
|
||||||
|
trade.profit >= 0 ? 'text-success-600 dark:text-success-400' : 'text-error-600 dark:text-error-400'
|
||||||
|
)}>
|
||||||
|
{trade.profit >= 0 ? '+' : ''}{formatCurrency(trade.profit)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Abierto</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500">{trade.time}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarketItem({ market }: { market: Market }) {
|
||||||
|
const isPositive = market.change >= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center font-bold text-slate-700 dark:text-slate-300">
|
||||||
|
{market.symbol.slice(0, 1)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900 dark:text-white">{market.symbol}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">{market.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-semibold text-slate-900 dark:text-white">
|
||||||
|
{formatCurrency(market.price)}
|
||||||
|
</p>
|
||||||
|
<p className={cn(
|
||||||
|
'text-sm flex items-center gap-1 justify-end',
|
||||||
|
isPositive ? 'text-success-600 dark:text-success-400' : 'text-error-600 dark:text-error-400'
|
||||||
|
)}>
|
||||||
|
{isPositive ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||||
|
{formatPercentage(market.change)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertItem({ alert }: { alert: Alert }) {
|
||||||
|
const typeStyles = {
|
||||||
|
warning: {
|
||||||
|
bg: 'bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800',
|
||||||
|
icon: 'text-warning-600 dark:text-warning-400',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
bg: 'bg-success-50 dark:bg-success-900/20 border-success-200 dark:border-success-800',
|
||||||
|
icon: 'text-success-600 dark:text-success-400',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
bg: 'bg-primary-50 dark:bg-primary-900/20 border-primary-200 dark:border-primary-800',
|
||||||
|
icon: 'text-primary-600 dark:text-primary-400',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
warning: <AlertTriangle className="h-5 w-5" />,
|
||||||
|
success: <Target className="h-5 w-5" />,
|
||||||
|
info: <Activity className="h-5 w-5" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('p-4 rounded-lg border', typeStyles[alert.type].bg)}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className={typeStyles[alert.type].icon}>{icons[alert.type]}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-slate-900 dark:text-white">{alert.title}</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">{alert.message}</p>
|
||||||
|
<p className="mt-2 text-xs text-slate-400 dark:text-slate-500 flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
Hace {alert.time}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
apps/web/src/app/(dashboard)/layout.tsx
Normal file
99
apps/web/src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { Sidebar } from '@/components/layout/Sidebar';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard Layout
|
||||||
|
*
|
||||||
|
* Layout principal para las paginas del dashboard.
|
||||||
|
* Incluye sidebar, header y manejo de responsive.
|
||||||
|
*/
|
||||||
|
export default function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAuthenticated, isInitialized, checkAuth, isLoading } = useAuthStore();
|
||||||
|
const { sidebarCollapsed, isMobile, setIsMobile } = useUIStore();
|
||||||
|
|
||||||
|
// Check auth on mount
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
}, [checkAuth]);
|
||||||
|
|
||||||
|
// Redirect to login if not authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitialized && !isAuthenticated) {
|
||||||
|
router.replace('/login');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, isInitialized, router]);
|
||||||
|
|
||||||
|
// Handle responsive
|
||||||
|
useEffect(() => {
|
||||||
|
const checkMobile = () => {
|
||||||
|
setIsMobile(window.innerWidth < 1024);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check on mount
|
||||||
|
checkMobile();
|
||||||
|
|
||||||
|
// Listen for resize
|
||||||
|
window.addEventListener('resize', checkMobile);
|
||||||
|
return () => window.removeEventListener('resize', checkMobile);
|
||||||
|
}, [setIsMobile]);
|
||||||
|
|
||||||
|
// Show loading while checking auth
|
||||||
|
if (!isInitialized || isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-950">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="relative w-16 h-16 mx-auto mb-4">
|
||||||
|
<div className="absolute inset-0 rounded-xl bg-horux-gradient animate-pulse" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-2xl font-bold text-white">H</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400 text-sm animate-pulse">Cargando...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't render if not authenticated
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'transition-all duration-300',
|
||||||
|
// Margin left based on sidebar state
|
||||||
|
isMobile ? 'ml-0' : (sidebarCollapsed ? 'ml-20' : 'ml-64')
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
{/* Page Content */}
|
||||||
|
<main className="pt-16 min-h-screen">
|
||||||
|
<div className="p-4 lg:p-6 xl:p-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
769
apps/web/src/app/(dashboard)/metricas/[code]/page.tsx
Normal file
769
apps/web/src/app/(dashboard)/metricas/[code]/page.tsx
Normal file
@@ -0,0 +1,769 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
ReferenceLine,
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownRight,
|
||||||
|
Calendar,
|
||||||
|
Download,
|
||||||
|
RefreshCw,
|
||||||
|
Target,
|
||||||
|
Info,
|
||||||
|
ChevronDown,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn, formatCurrency, formatNumber, formatPercentage, formatDate } from '@/lib/utils';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface MetricValue {
|
||||||
|
date: string;
|
||||||
|
value: number;
|
||||||
|
previousValue?: number;
|
||||||
|
change?: number;
|
||||||
|
changePercent?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricDetail {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
formula?: string;
|
||||||
|
value: number;
|
||||||
|
previousValue: number;
|
||||||
|
change: number;
|
||||||
|
changePercent: number;
|
||||||
|
format: 'currency' | 'number' | 'percentage';
|
||||||
|
category: 'core' | 'startup' | 'enterprise';
|
||||||
|
trend: 'up' | 'down' | 'neutral';
|
||||||
|
target?: number;
|
||||||
|
benchmark?: number;
|
||||||
|
history: MetricValue[];
|
||||||
|
periodComparison: {
|
||||||
|
period: string;
|
||||||
|
current: number;
|
||||||
|
previous: number;
|
||||||
|
change: number;
|
||||||
|
changePercent: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartType = 'line' | 'area' | 'bar';
|
||||||
|
type PeriodType = '7d' | '30d' | '90d' | '12m' | 'ytd' | 'all';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mock Data
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const generateMockData = (code: string): MetricDetail => {
|
||||||
|
const isRevenue = ['MRR', 'ARR', 'REVENUE', 'LTV', 'ARPU', 'NET_INCOME'].includes(code);
|
||||||
|
const isCost = ['CAC', 'EXPENSES', 'BURN_RATE'].includes(code);
|
||||||
|
const isPercentage = ['GROSS_MARGIN', 'CHURN', 'NRR', 'DAU_MAU'].includes(code);
|
||||||
|
const isCount = ['ACTIVE_CUSTOMERS', 'NPS', 'SUPPORT_TICKETS', 'RESOLUTION_TIME', 'LTV_CAC'].includes(code);
|
||||||
|
|
||||||
|
const baseValue = isRevenue ? 125000 : isCost ? 2500 : isPercentage ? 72.5 : 342;
|
||||||
|
const variance = baseValue * 0.1;
|
||||||
|
|
||||||
|
const history: MetricValue[] = Array.from({ length: 365 }, (_, i) => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - (364 - i));
|
||||||
|
const trendFactor = i / 365;
|
||||||
|
const seasonalFactor = Math.sin(i / 30) * 0.05;
|
||||||
|
const randomFactor = (Math.random() - 0.5) * 0.1;
|
||||||
|
const value = baseValue * (0.7 + trendFactor * 0.6 + seasonalFactor + randomFactor);
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: date.toISOString().split('T')[0],
|
||||||
|
value: Math.round(value * 100) / 100,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add comparison data
|
||||||
|
history.forEach((item, index) => {
|
||||||
|
if (index >= 30) {
|
||||||
|
item.previousValue = history[index - 30].value;
|
||||||
|
item.change = item.value - item.previousValue;
|
||||||
|
item.changePercent = (item.change / item.previousValue) * 100;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const metricNames: Record<string, { name: string; description: string; formula?: string }> = {
|
||||||
|
MRR: {
|
||||||
|
name: 'Monthly Recurring Revenue',
|
||||||
|
description: 'Ingresos recurrentes mensuales provenientes de suscripciones activas',
|
||||||
|
formula: 'Suma de todas las suscripciones activas mensuales',
|
||||||
|
},
|
||||||
|
ARR: {
|
||||||
|
name: 'Annual Recurring Revenue',
|
||||||
|
description: 'Ingresos recurrentes anuales (MRR x 12)',
|
||||||
|
formula: 'MRR x 12',
|
||||||
|
},
|
||||||
|
REVENUE: {
|
||||||
|
name: 'Ingresos Totales',
|
||||||
|
description: 'Total de ingresos del periodo incluyendo one-time y recurrentes',
|
||||||
|
},
|
||||||
|
EXPENSES: {
|
||||||
|
name: 'Gastos Totales',
|
||||||
|
description: 'Total de gastos operativos del periodo',
|
||||||
|
},
|
||||||
|
GROSS_MARGIN: {
|
||||||
|
name: 'Margen Bruto',
|
||||||
|
description: 'Porcentaje de ingresos despues de costos directos',
|
||||||
|
formula: '(Ingresos - Costos Directos) / Ingresos x 100',
|
||||||
|
},
|
||||||
|
NET_INCOME: {
|
||||||
|
name: 'Utilidad Neta',
|
||||||
|
description: 'Ingresos totales menos todos los gastos',
|
||||||
|
formula: 'Ingresos Totales - Gastos Totales',
|
||||||
|
},
|
||||||
|
CAC: {
|
||||||
|
name: 'Customer Acquisition Cost',
|
||||||
|
description: 'Costo promedio para adquirir un nuevo cliente',
|
||||||
|
formula: 'Gastos de Marketing y Ventas / Nuevos Clientes',
|
||||||
|
},
|
||||||
|
LTV: {
|
||||||
|
name: 'Customer Lifetime Value',
|
||||||
|
description: 'Valor total esperado de un cliente durante toda su relacion',
|
||||||
|
formula: 'ARPU / Churn Rate',
|
||||||
|
},
|
||||||
|
LTV_CAC: {
|
||||||
|
name: 'LTV/CAC Ratio',
|
||||||
|
description: 'Ratio que indica el retorno de inversion en adquisicion',
|
||||||
|
formula: 'LTV / CAC',
|
||||||
|
},
|
||||||
|
CHURN: {
|
||||||
|
name: 'Churn Rate',
|
||||||
|
description: 'Porcentaje mensual de clientes que cancelan',
|
||||||
|
formula: 'Clientes Cancelados / Clientes Inicio de Periodo x 100',
|
||||||
|
},
|
||||||
|
NRR: {
|
||||||
|
name: 'Net Revenue Retention',
|
||||||
|
description: 'Retencion de ingresos incluyendo expansiones',
|
||||||
|
formula: '(MRR Inicio + Expansion - Contraction - Churn) / MRR Inicio x 100',
|
||||||
|
},
|
||||||
|
BURN_RATE: {
|
||||||
|
name: 'Burn Rate',
|
||||||
|
description: 'Tasa mensual de consumo de capital',
|
||||||
|
formula: 'Gastos Mensuales - Ingresos Mensuales',
|
||||||
|
},
|
||||||
|
ACTIVE_CUSTOMERS: {
|
||||||
|
name: 'Clientes Activos',
|
||||||
|
description: 'Numero de clientes con suscripcion activa',
|
||||||
|
},
|
||||||
|
ARPU: {
|
||||||
|
name: 'Average Revenue Per User',
|
||||||
|
description: 'Ingreso promedio mensual por cliente',
|
||||||
|
formula: 'MRR / Clientes Activos',
|
||||||
|
},
|
||||||
|
NPS: {
|
||||||
|
name: 'Net Promoter Score',
|
||||||
|
description: 'Indice de satisfaccion y lealtad del cliente',
|
||||||
|
formula: '% Promotores - % Detractores',
|
||||||
|
},
|
||||||
|
DAU_MAU: {
|
||||||
|
name: 'DAU/MAU Ratio',
|
||||||
|
description: 'Engagement de usuarios activos',
|
||||||
|
formula: 'Usuarios Activos Diarios / Usuarios Activos Mensuales',
|
||||||
|
},
|
||||||
|
SUPPORT_TICKETS: {
|
||||||
|
name: 'Tickets de Soporte',
|
||||||
|
description: 'Cantidad de tickets abiertos en el periodo',
|
||||||
|
},
|
||||||
|
RESOLUTION_TIME: {
|
||||||
|
name: 'Tiempo de Resolucion',
|
||||||
|
description: 'Tiempo promedio para resolver un ticket (horas)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const info = metricNames[code] || { name: code, description: '' };
|
||||||
|
const currentValue = history[history.length - 1].value;
|
||||||
|
const previousValue = history[history.length - 31]?.value || currentValue * 0.95;
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
name: info.name,
|
||||||
|
description: info.description,
|
||||||
|
formula: info.formula,
|
||||||
|
value: currentValue,
|
||||||
|
previousValue,
|
||||||
|
change: currentValue - previousValue,
|
||||||
|
changePercent: ((currentValue - previousValue) / previousValue) * 100,
|
||||||
|
format: isPercentage ? 'percentage' : isCount ? 'number' : 'currency',
|
||||||
|
category: ['MRR', 'ARR', 'REVENUE', 'EXPENSES', 'GROSS_MARGIN', 'NET_INCOME'].includes(code)
|
||||||
|
? 'core'
|
||||||
|
: ['CAC', 'LTV', 'LTV_CAC', 'CHURN', 'NRR', 'BURN_RATE'].includes(code)
|
||||||
|
? 'startup'
|
||||||
|
: 'enterprise',
|
||||||
|
trend: currentValue > previousValue ? 'up' : 'down',
|
||||||
|
target: currentValue * 1.2,
|
||||||
|
benchmark: currentValue * 0.9,
|
||||||
|
history,
|
||||||
|
periodComparison: [
|
||||||
|
{
|
||||||
|
period: 'Esta semana',
|
||||||
|
current: currentValue,
|
||||||
|
previous: currentValue * 0.97,
|
||||||
|
change: currentValue * 0.03,
|
||||||
|
changePercent: 3.09,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'Este mes',
|
||||||
|
current: currentValue,
|
||||||
|
previous: previousValue,
|
||||||
|
change: currentValue - previousValue,
|
||||||
|
changePercent: ((currentValue - previousValue) / previousValue) * 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'Este trimestre',
|
||||||
|
current: currentValue,
|
||||||
|
previous: currentValue * 0.88,
|
||||||
|
change: currentValue * 0.12,
|
||||||
|
changePercent: 13.64,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'Este ano',
|
||||||
|
current: currentValue,
|
||||||
|
previous: currentValue * 0.65,
|
||||||
|
change: currentValue * 0.35,
|
||||||
|
changePercent: 53.85,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Loading Skeleton
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function MetricDetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-6 p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-10 w-10 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-2" />
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-72" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value Card */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-36 mb-4" />
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-24" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-4" />
|
||||||
|
<div className="h-80 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function MetricDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const code = params.code as string;
|
||||||
|
|
||||||
|
const [metric, setMetric] = useState<MetricDetail | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [period, setPeriod] = useState<PeriodType>('30d');
|
||||||
|
const [chartType, setChartType] = useState<ChartType>('area');
|
||||||
|
const [showComparison, setShowComparison] = useState(true);
|
||||||
|
|
||||||
|
const fetchMetricDetail = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// In production, this would be an API call
|
||||||
|
// const response = await api.get<MetricDetail>(`/metrics/${code}`, { params: { period } });
|
||||||
|
// setMetric(response.data ?? null);
|
||||||
|
|
||||||
|
// Mock data for development
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||||
|
setMetric(generateMockData(code.toUpperCase()));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al cargar metrica');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [code, period]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMetricDetail();
|
||||||
|
}, [fetchMetricDetail]);
|
||||||
|
|
||||||
|
const formatValue = (value: number) => {
|
||||||
|
if (!metric) return value.toString();
|
||||||
|
switch (metric.format) {
|
||||||
|
case 'currency':
|
||||||
|
return formatCurrency(value);
|
||||||
|
case 'percentage':
|
||||||
|
return `${value.toFixed(2)}%`;
|
||||||
|
case 'number':
|
||||||
|
default:
|
||||||
|
return formatNumber(value, value % 1 === 0 ? 0 : 2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFilteredHistory = () => {
|
||||||
|
if (!metric) return [];
|
||||||
|
const now = new Date();
|
||||||
|
const history = metric.history;
|
||||||
|
|
||||||
|
switch (period) {
|
||||||
|
case '7d':
|
||||||
|
return history.slice(-7);
|
||||||
|
case '30d':
|
||||||
|
return history.slice(-30);
|
||||||
|
case '90d':
|
||||||
|
return history.slice(-90);
|
||||||
|
case '12m':
|
||||||
|
return history.slice(-365);
|
||||||
|
case 'ytd':
|
||||||
|
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
||||||
|
return history.filter((h) => new Date(h.date) >= startOfYear);
|
||||||
|
case 'all':
|
||||||
|
default:
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (!metric) return;
|
||||||
|
const data = getFilteredHistory();
|
||||||
|
const csv = [
|
||||||
|
['Fecha', 'Valor', 'Valor Anterior', 'Cambio', 'Cambio %'].join(','),
|
||||||
|
...data.map((row) =>
|
||||||
|
[row.date, row.value, row.previousValue || '', row.change || '', row.changePercent || ''].join(',')
|
||||||
|
),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${metric.code}_${period}.csv`;
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <MetricDetailSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !metric) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800 rounded-lg p-6 text-center">
|
||||||
|
<p className="text-error-700 dark:text-error-400 mb-4">{error || 'Metrica no encontrada'}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Volver
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInverseTrend = ['CAC', 'CHURN', 'BURN_RATE', 'EXPENSES', 'SUPPORT_TICKETS', 'RESOLUTION_TIME'].includes(
|
||||||
|
metric.code
|
||||||
|
);
|
||||||
|
const isGoodTrend = isInverseTrend ? metric.change < 0 : metric.change > 0;
|
||||||
|
const filteredHistory = getFilteredHistory();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{metric.name}</h1>
|
||||||
|
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded">
|
||||||
|
{metric.code}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mt-1">{metric.description}</p>
|
||||||
|
{metric.formula && (
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
<span className="font-medium">Formula:</span> {metric.formula}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Exportar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={fetchMetricDetail}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value Card */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<div className="lg:col-span-2 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Valor Actual</p>
|
||||||
|
<p className="text-4xl font-bold text-gray-900 dark:text-white">{formatValue(metric.value)}</p>
|
||||||
|
<div className="flex items-center gap-3 mt-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 px-2 py-1 rounded-full text-sm font-medium',
|
||||||
|
isGoodTrend
|
||||||
|
? 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400'
|
||||||
|
: 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isGoodTrend ? <ArrowUpRight className="w-4 h-4" /> : <ArrowDownRight className="w-4 h-4" />}
|
||||||
|
{formatPercentage(Math.abs(metric.changePercent))}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">vs periodo anterior</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isGoodTrend ? (
|
||||||
|
<TrendingUp className="w-12 h-12 text-success-500" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="w-12 h-12 text-error-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target Progress */}
|
||||||
|
{metric.target && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Target className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">Meta</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white">{formatValue(metric.target)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full rounded-full transition-all',
|
||||||
|
metric.value >= metric.target
|
||||||
|
? 'bg-success-500'
|
||||||
|
: metric.value >= metric.target * 0.75
|
||||||
|
? 'bg-primary-500'
|
||||||
|
: metric.value >= metric.target * 0.5
|
||||||
|
? 'bg-warning-500'
|
||||||
|
: 'bg-error-500'
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.min(100, (metric.value / metric.target) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{((metric.value / metric.target) * 100).toFixed(1)}% completado
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period Comparison */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-4">Comparativos</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{metric.periodComparison.map((comp) => (
|
||||||
|
<div key={comp.period} className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">{comp.period}</span>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">{formatValue(comp.current)}</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-xs',
|
||||||
|
comp.changePercent >= 0 ? 'text-success-600 dark:text-success-400' : 'text-error-600 dark:text-error-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatPercentage(comp.changePercent)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart Controls */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(['7d', '30d', '90d', '12m', 'ytd', 'all'] as PeriodType[]).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm font-medium rounded-md transition-all',
|
||||||
|
period === p
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p === 'ytd' ? 'YTD' : p === 'all' ? 'Todo' : p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(['area', 'line', 'bar'] as ChartType[]).map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={() => setChartType(type)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm font-medium rounded-md capitalize transition-all',
|
||||||
|
chartType === type
|
||||||
|
? 'bg-gray-900 dark:bg-white text-white dark:text-gray-900'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Chart */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Historial</h3>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showComparison}
|
||||||
|
onChange={(e) => setShowComparison(e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-primary-500 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
Mostrar periodo anterior
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="h-96">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
{chartType === 'bar' ? (
|
||||||
|
<BarChart data={filteredHistory}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.2} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke="#6b7280"
|
||||||
|
tick={{ fill: '#6b7280', fontSize: 12 }}
|
||||||
|
tickFormatter={(value) => formatDate(value, { day: 'numeric', month: 'short' })}
|
||||||
|
/>
|
||||||
|
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} tickFormatter={(v) => formatValue(v)} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#1f2937',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
formatter={(value: number) => [formatValue(value), 'Valor']}
|
||||||
|
labelFormatter={(label) => formatDate(label)}
|
||||||
|
/>
|
||||||
|
{showComparison && <Bar dataKey="previousValue" name="Periodo Anterior" fill="#6b7280" opacity={0.5} />}
|
||||||
|
<Bar dataKey="value" name="Valor" fill="#0c8ce8" />
|
||||||
|
{metric.target && <ReferenceLine y={metric.target} stroke="#10b981" strokeDasharray="5 5" label="Meta" />}
|
||||||
|
</BarChart>
|
||||||
|
) : chartType === 'line' ? (
|
||||||
|
<LineChart data={filteredHistory}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.2} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke="#6b7280"
|
||||||
|
tick={{ fill: '#6b7280', fontSize: 12 }}
|
||||||
|
tickFormatter={(value) => formatDate(value, { day: 'numeric', month: 'short' })}
|
||||||
|
/>
|
||||||
|
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} tickFormatter={(v) => formatValue(v)} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#1f2937',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
formatter={(value: number) => [formatValue(value), 'Valor']}
|
||||||
|
labelFormatter={(label) => formatDate(label)}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
{showComparison && (
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="previousValue"
|
||||||
|
name="Periodo Anterior"
|
||||||
|
stroke="#6b7280"
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Line type="monotone" dataKey="value" name="Valor" stroke="#0c8ce8" strokeWidth={2} dot={false} activeDot={{ r: 4 }} />
|
||||||
|
{metric.target && <ReferenceLine y={metric.target} stroke="#10b981" strokeDasharray="5 5" label="Meta" />}
|
||||||
|
</LineChart>
|
||||||
|
) : (
|
||||||
|
<AreaChart data={filteredHistory}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#0c8ce8" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="#0c8ce8" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="colorPrevious" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#6b7280" stopOpacity={0.2} />
|
||||||
|
<stop offset="95%" stopColor="#6b7280" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.2} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke="#6b7280"
|
||||||
|
tick={{ fill: '#6b7280', fontSize: 12 }}
|
||||||
|
tickFormatter={(value) => formatDate(value, { day: 'numeric', month: 'short' })}
|
||||||
|
/>
|
||||||
|
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} tickFormatter={(v) => formatValue(v)} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#1f2937',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
formatter={(value: number) => [formatValue(value), 'Valor']}
|
||||||
|
labelFormatter={(label) => formatDate(label)}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
{showComparison && (
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="previousValue"
|
||||||
|
name="Periodo Anterior"
|
||||||
|
stroke="#6b7280"
|
||||||
|
fill="url(#colorPrevious)"
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Area type="monotone" dataKey="value" name="Valor" stroke="#0c8ce8" fill="url(#colorValue)" strokeWidth={2} />
|
||||||
|
{metric.target && <ReferenceLine y={metric.target} stroke="#10b981" strokeDasharray="5 5" label="Meta" />}
|
||||||
|
</AreaChart>
|
||||||
|
)}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data Table */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Valores por Periodo</h3>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Fecha
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Valor
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Anterior
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Cambio
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Cambio %
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{filteredHistory
|
||||||
|
.slice(-30)
|
||||||
|
.reverse()
|
||||||
|
.map((row) => (
|
||||||
|
<tr key={row.date} className="hover:bg-gray-50 dark:hover:bg-gray-900/50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||||
|
{formatDate(row.date)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-right font-medium text-gray-900 dark:text-white">
|
||||||
|
{formatValue(row.value)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">
|
||||||
|
{row.previousValue ? formatValue(row.previousValue) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-right">
|
||||||
|
{row.change !== undefined ? (
|
||||||
|
<span className={row.change >= 0 ? 'text-success-600' : 'text-error-600'}>
|
||||||
|
{row.change >= 0 ? '+' : ''}
|
||||||
|
{formatValue(row.change)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-right">
|
||||||
|
{row.changePercent !== undefined ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
row.changePercent >= 0
|
||||||
|
? 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400'
|
||||||
|
: 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatPercentage(row.changePercent)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
824
apps/web/src/app/(dashboard)/metricas/page.tsx
Normal file
824
apps/web/src/app/(dashboard)/metricas/page.tsx
Normal file
@@ -0,0 +1,824 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Users,
|
||||||
|
DollarSign,
|
||||||
|
Activity,
|
||||||
|
Calendar,
|
||||||
|
RefreshCw,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownRight,
|
||||||
|
Target,
|
||||||
|
Zap,
|
||||||
|
Building2,
|
||||||
|
ChevronRight,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn, formatCurrency, formatNumber, formatPercentage } from '@/lib/utils';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface Metric {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
value: number;
|
||||||
|
previousValue: number;
|
||||||
|
change: number;
|
||||||
|
changePercent: number;
|
||||||
|
format: 'currency' | 'number' | 'percentage';
|
||||||
|
category: 'core' | 'startup' | 'enterprise';
|
||||||
|
trend: 'up' | 'down' | 'neutral';
|
||||||
|
target?: number;
|
||||||
|
history: { date: string; value: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricsResponse {
|
||||||
|
metrics: Metric[];
|
||||||
|
period: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetricCategory = 'core' | 'startup' | 'enterprise';
|
||||||
|
type PeriodType = '7d' | '30d' | '90d' | '12m' | 'ytd';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mock Data for Development
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const mockMetrics: Metric[] = [
|
||||||
|
// Core Metrics
|
||||||
|
{
|
||||||
|
code: 'MRR',
|
||||||
|
name: 'Monthly Recurring Revenue',
|
||||||
|
description: 'Ingresos recurrentes mensuales',
|
||||||
|
value: 125000,
|
||||||
|
previousValue: 118000,
|
||||||
|
change: 7000,
|
||||||
|
changePercent: 5.93,
|
||||||
|
format: 'currency',
|
||||||
|
category: 'core',
|
||||||
|
trend: 'up',
|
||||||
|
target: 150000,
|
||||||
|
history: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
value: 80000 + i * 5000 + Math.random() * 3000,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ARR',
|
||||||
|
name: 'Annual Recurring Revenue',
|
||||||
|
description: 'Ingresos recurrentes anuales',
|
||||||
|
value: 1500000,
|
||||||
|
previousValue: 1416000,
|
||||||
|
change: 84000,
|
||||||
|
changePercent: 5.93,
|
||||||
|
format: 'currency',
|
||||||
|
category: 'core',
|
||||||
|
trend: 'up',
|
||||||
|
target: 1800000,
|
||||||
|
history: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
value: 960000 + i * 60000 + Math.random() * 36000,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'REVENUE',
|
||||||
|
name: 'Ingresos Totales',
|
||||||
|
description: 'Ingresos totales del periodo',
|
||||||
|
value: 245000,
|
||||||
|
previousValue: 230000,
|
||||||
|
change: 15000,
|
||||||
|
changePercent: 6.52,
|
||||||
|
format: 'currency',
|
||||||
|
category: 'core',
|
||||||
|
trend: 'up',
|
||||||
|
history: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
value: 180000 + i * 8000 + Math.random() * 10000,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'EXPENSES',
|
||||||
|
name: 'Gastos Totales',
|
||||||
|
description: 'Gastos totales del periodo',
|
||||||
|
value: 180000,
|
||||||
|
previousValue: 175000,
|
||||||
|
change: 5000,
|
||||||
|
changePercent: 2.86,
|
||||||
|
format: 'currency',
|
||||||
|
category: 'core',
|
||||||
|
trend: 'down',
|
||||||
|
history: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
value: 150000 + i * 3000 + Math.random() * 5000,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'GROSS_MARGIN',
|
||||||
|
name: 'Margen Bruto',
|
||||||
|
description: 'Porcentaje de margen bruto',
|
||||||
|
value: 72.5,
|
||||||
|
previousValue: 70.2,
|
||||||
|
change: 2.3,
|
||||||
|
changePercent: 3.28,
|
||||||
|
format: 'percentage',
|
||||||
|
category: 'core',
|
||||||
|
trend: 'up',
|
||||||
|
target: 75,
|
||||||
|
history: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
value: 65 + i * 0.8 + Math.random() * 2,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'NET_INCOME',
|
||||||
|
name: 'Utilidad Neta',
|
||||||
|
description: 'Ingresos menos gastos',
|
||||||
|
value: 65000,
|
||||||
|
previousValue: 55000,
|
||||||
|
change: 10000,
|
||||||
|
changePercent: 18.18,
|
||||||
|
format: 'currency',
|
||||||
|
category: 'core',
|
||||||
|
trend: 'up',
|
||||||
|
history: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
value: 30000 + i * 4000 + Math.random() * 5000,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
// Startup Metrics
|
||||||
|
{
|
||||||
|
code: 'CAC',
|
||||||
|
name: 'Customer Acquisition Cost',
|
||||||
|
description: 'Costo de adquisicion por cliente',
|
||||||
|
value: 2500,
|
||||||
|
previousValue: 2800,
|
||||||
|
change: -300,
|
||||||
|
changePercent: -10.71,
|
||||||
|
format: 'currency',
|
||||||
|
category: 'startup',
|
||||||
|
trend: 'up',
|
||||||
|
target: 2000,
|
||||||
|
history: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
value: 3500 - i * 100 + Math.random() * 200,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'LTV',
|
||||||
|
name: 'Customer Lifetime Value',
|
||||||
|
description: 'Valor de vida del cliente',
|
||||||
|
value: 15000,
|
||||||
|
previousValue: 13500,
|
||||||
|
change: 1500,
|
||||||
|
changePercent: 11.11,
|
||||||
|
format: 'currency',
|
||||||
|
category: 'startup',
|
||||||
|
trend: 'up',
|
||||||
|
history: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
value: 10000 + i * 500 + Math.random() * 500,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'LTV_CAC',
|
||||||
|
name: 'LTV/CAC Ratio',
|
||||||
|
description: 'Ratio de LTV sobre CAC',
|
||||||
|
value: 6.0,
|
||||||
|
previousValue: 4.8,
|
||||||
|
change: 1.2,
|
||||||
|
changePercent: 25,
|
||||||
|
format: 'number',
|
||||||
|
category: 'startup',
|
||||||
|
trend: 'up',
|
||||||
|
target: 5,
|
||||||
|
history: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
value: 3 + i * 0.3 + Math.random() * 0.5,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'CHURN',
|
||||||
|
name: 'Churn Rate',
|
||||||
|
description: 'Tasa de cancelacion mensual',
|
||||||
|
value: 2.5,
|
||||||
|
previousValue: 3.2,
|
||||||
|
change: -0.7,
|
||||||
|
changePercent: -21.88,
|
||||||
|
format: 'percentage',
|
||||||
|
category: 'startup',
|
||||||
|
trend: 'up',
|
||||||
|
target: 2,
|
||||||
|
history: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
value: 5 - i * 0.2 + Math.random() * 0.5,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'NRR',
|
||||||
|
name: 'Net Revenue Retention',
|
||||||
|
description: 'Retencion neta de ingresos',
|
||||||
|
value: 115,
|
||||||
|
previousValue: 110,
|
||||||
|
change: 5,
|
||||||
|
changePercent: 4.55,
|
||||||
|
format: 'percentage',
|
||||||
|
category: 'startup',
|
||||||
|
trend: 'up',
|
||||||
|
target: 120,
|
||||||
|
history: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
value: 100 + i * 1.5 + Math.random() * 3,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'BURN_RATE',
|
||||||
|
name: 'Burn Rate',
|
||||||
|
description: 'Tasa de quema mensual',
|
||||||
|
value: 45000,
|
||||||
|
previousValue: 52000,
|
||||||
|
change: -7000,
|
||||||
|
changePercent: -13.46,
|
||||||
|
format: 'currency',
|
||||||
|
category: 'startup',
|
||||||
|
trend: 'up',
|
||||||
|
history: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
value: 70000 - i * 3000 + Math.random() * 5000,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
// Enterprise Metrics
|
||||||
|
{
|
||||||
|
code: 'ACTIVE_CUSTOMERS',
|
||||||
|
name: 'Clientes Activos',
|
||||||
|
description: 'Numero de clientes activos',
|
||||||
|
value: 342,
|
||||||
|
previousValue: 315,
|
||||||
|
change: 27,
|
||||||
|
changePercent: 8.57,
|
||||||
|
format: 'number',
|
||||||
|
category: 'enterprise',
|
||||||
|
trend: 'up',
|
||||||
|
target: 400,
|
||||||
|
history: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
value: 250 + i * 10 + Math.floor(Math.random() * 10),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ARPU',
|
||||||
|
name: 'Average Revenue Per User',
|
||||||
|
description: 'Ingreso promedio por usuario',
|
||||||
|
value: 365,
|
||||||
|
previousValue: 350,
|
||||||
|
change: 15,
|
||||||
|
changePercent: 4.29,
|
||||||
|
format: 'currency',
|
||||||
|
category: 'enterprise',
|
||||||
|
trend: 'up',
|
||||||
|
history: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
value: 300 + i * 8 + Math.random() * 15,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'NPS',
|
||||||
|
name: 'Net Promoter Score',
|
||||||
|
description: 'Indice de promotores neto',
|
||||||
|
value: 72,
|
||||||
|
previousValue: 68,
|
||||||
|
change: 4,
|
||||||
|
changePercent: 5.88,
|
||||||
|
format: 'number',
|
||||||
|
category: 'enterprise',
|
||||||
|
trend: 'up',
|
||||||
|
target: 80,
|
||||||
|
history: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
value: 55 + i * 2 + Math.floor(Math.random() * 5),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'DAU_MAU',
|
||||||
|
name: 'DAU/MAU Ratio',
|
||||||
|
description: 'Ratio de usuarios activos diarios vs mensuales',
|
||||||
|
value: 0.45,
|
||||||
|
previousValue: 0.42,
|
||||||
|
change: 0.03,
|
||||||
|
changePercent: 7.14,
|
||||||
|
format: 'percentage',
|
||||||
|
category: 'enterprise',
|
||||||
|
trend: 'up',
|
||||||
|
target: 0.5,
|
||||||
|
history: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
value: 0.35 + i * 0.01 + Math.random() * 0.02,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'SUPPORT_TICKETS',
|
||||||
|
name: 'Tickets de Soporte',
|
||||||
|
description: 'Tickets abiertos este periodo',
|
||||||
|
value: 89,
|
||||||
|
previousValue: 120,
|
||||||
|
change: -31,
|
||||||
|
changePercent: -25.83,
|
||||||
|
format: 'number',
|
||||||
|
category: 'enterprise',
|
||||||
|
trend: 'up',
|
||||||
|
history: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
value: 150 - i * 5 + Math.floor(Math.random() * 20),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'RESOLUTION_TIME',
|
||||||
|
name: 'Tiempo de Resolucion',
|
||||||
|
description: 'Tiempo promedio de resolucion en horas',
|
||||||
|
value: 4.2,
|
||||||
|
previousValue: 5.8,
|
||||||
|
change: -1.6,
|
||||||
|
changePercent: -27.59,
|
||||||
|
format: 'number',
|
||||||
|
category: 'enterprise',
|
||||||
|
trend: 'up',
|
||||||
|
target: 4,
|
||||||
|
history: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
value: 8 - i * 0.4 + Math.random() * 1,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Loading Skeleton
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function MetricsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
{/* Header skeleton */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48" />
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-36" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs skeleton */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-28" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cards skeleton */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6"
|
||||||
|
>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2" />
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4" />
|
||||||
|
<div className="h-20 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metric Card Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
metric: Metric;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({ metric, onClick }: MetricCardProps) {
|
||||||
|
const formatValue = (value: number, format: Metric['format']) => {
|
||||||
|
switch (format) {
|
||||||
|
case 'currency':
|
||||||
|
return formatCurrency(value);
|
||||||
|
case 'percentage':
|
||||||
|
return `${value.toFixed(1)}%`;
|
||||||
|
case 'number':
|
||||||
|
default:
|
||||||
|
return formatNumber(value, value % 1 === 0 ? 0 : 2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPositiveTrend = metric.trend === 'up';
|
||||||
|
const TrendIcon = isPositiveTrend ? TrendingUp : TrendingDown;
|
||||||
|
|
||||||
|
// For metrics where lower is better (CAC, Churn, etc.)
|
||||||
|
const isInverseTrend = ['CAC', 'CHURN', 'BURN_RATE', 'EXPENSES', 'SUPPORT_TICKETS', 'RESOLUTION_TIME'].includes(metric.code);
|
||||||
|
const isGoodTrend = isInverseTrend ? metric.change < 0 : metric.change > 0;
|
||||||
|
|
||||||
|
const progressPercent = metric.target
|
||||||
|
? Math.min(100, (metric.value / metric.target) * 100)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700',
|
||||||
|
'p-6 hover:shadow-lg transition-all cursor-pointer hover:border-primary-300 dark:hover:border-primary-600',
|
||||||
|
'group'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{metric.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||||
|
{metric.code}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-5 h-5 text-gray-400 group-hover:text-primary-500 transition-colors" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end justify-between mb-4">
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{formatValue(metric.value, metric.format)}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium',
|
||||||
|
isGoodTrend
|
||||||
|
? 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400'
|
||||||
|
: 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isGoodTrend ? (
|
||||||
|
<ArrowUpRight className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowDownRight className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
{formatPercentage(Math.abs(metric.changePercent))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mini Chart */}
|
||||||
|
<div className="h-16 mb-3">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={metric.history.slice(-7)}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`gradient-${metric.code}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stopColor={isGoodTrend ? '#10b981' : '#ef4444'}
|
||||||
|
stopOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stopColor={isGoodTrend ? '#10b981' : '#ef4444'}
|
||||||
|
stopOpacity={0}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke={isGoodTrend ? '#10b981' : '#ef4444'}
|
||||||
|
strokeWidth={2}
|
||||||
|
fill={`url(#gradient-${metric.code})`}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target Progress */}
|
||||||
|
{progressPercent !== null && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Meta</span>
|
||||||
|
<span className="text-gray-600 dark:text-gray-300">
|
||||||
|
{formatValue(metric.target!, metric.format)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full rounded-full transition-all',
|
||||||
|
progressPercent >= 100
|
||||||
|
? 'bg-success-500'
|
||||||
|
: progressPercent >= 75
|
||||||
|
? 'bg-primary-500'
|
||||||
|
: progressPercent >= 50
|
||||||
|
? 'bg-warning-500'
|
||||||
|
: 'bg-error-500'
|
||||||
|
)}
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Trend Chart Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface TrendChartProps {
|
||||||
|
metrics: Metric[];
|
||||||
|
selectedMetrics: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendChart({ metrics, selectedMetrics }: TrendChartProps) {
|
||||||
|
const selectedData = metrics.filter((m) => selectedMetrics.includes(m.code));
|
||||||
|
|
||||||
|
if (selectedData.length === 0) return null;
|
||||||
|
|
||||||
|
// Combine data for multiple metrics
|
||||||
|
const chartData = selectedData[0].history.map((point, index) => {
|
||||||
|
const dataPoint: Record<string, unknown> = { date: point.date };
|
||||||
|
selectedData.forEach((metric) => {
|
||||||
|
dataPoint[metric.code] = metric.history[index]?.value ?? 0;
|
||||||
|
});
|
||||||
|
return dataPoint;
|
||||||
|
});
|
||||||
|
|
||||||
|
const colors = ['#0c8ce8', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Tendencias
|
||||||
|
</h3>
|
||||||
|
<div className="h-80">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.2} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke="#6b7280"
|
||||||
|
tick={{ fill: '#6b7280', fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#1f2937',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
{selectedData.map((metric, index) => (
|
||||||
|
<Line
|
||||||
|
key={metric.code}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={metric.code}
|
||||||
|
name={metric.name}
|
||||||
|
stroke={colors[index % colors.length]}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Period Selector
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface PeriodSelectorProps {
|
||||||
|
value: PeriodType;
|
||||||
|
onChange: (period: PeriodType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
|
||||||
|
const periods: { value: PeriodType; label: string }[] = [
|
||||||
|
{ value: '7d', label: '7 dias' },
|
||||||
|
{ value: '30d', label: '30 dias' },
|
||||||
|
{ value: '90d', label: '90 dias' },
|
||||||
|
{ value: '12m', label: '12 meses' },
|
||||||
|
{ value: 'ytd', label: 'YTD' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 p-1 rounded-lg">
|
||||||
|
{periods.map((period) => (
|
||||||
|
<button
|
||||||
|
key={period.value}
|
||||||
|
onClick={() => onChange(period.value)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm font-medium rounded-md transition-all',
|
||||||
|
value === period.value
|
||||||
|
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{period.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Category Tab
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface CategoryTabsProps {
|
||||||
|
value: MetricCategory;
|
||||||
|
onChange: (category: MetricCategory) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryTabs({ value, onChange }: CategoryTabsProps) {
|
||||||
|
const categories: { value: MetricCategory; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ value: 'core', label: 'Core', icon: <Activity className="w-4 h-4" /> },
|
||||||
|
{ value: 'startup', label: 'Startup', icon: <Zap className="w-4 h-4" /> },
|
||||||
|
{ value: 'enterprise', label: 'Enterprise', icon: <Building2 className="w-4 h-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category.value}
|
||||||
|
onClick={() => onChange(category.value)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all',
|
||||||
|
value === category.value
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{category.icon}
|
||||||
|
{category.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Page Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function MetricasPage() {
|
||||||
|
const [metrics, setMetrics] = useState<Metric[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [category, setCategory] = useState<MetricCategory>('core');
|
||||||
|
const [period, setPeriod] = useState<PeriodType>('30d');
|
||||||
|
const [selectedForTrend, setSelectedForTrend] = useState<string[]>(['MRR', 'ARR']);
|
||||||
|
|
||||||
|
const fetchMetrics = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// In production, this would be an API call
|
||||||
|
// const response = await api.get<MetricsResponse>('/metrics', { params: { period } });
|
||||||
|
// setMetrics(response.data?.metrics ?? []);
|
||||||
|
|
||||||
|
// Mock data for development
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
setMetrics(mockMetrics);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al cargar metricas');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [period]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMetrics();
|
||||||
|
}, [fetchMetrics]);
|
||||||
|
|
||||||
|
const filteredMetrics = metrics.filter((m) => m.category === category);
|
||||||
|
|
||||||
|
const handleMetricClick = (metric: Metric) => {
|
||||||
|
// Navigate to detail page
|
||||||
|
window.location.href = `/metricas/${metric.code}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTrendSelection = (code: string) => {
|
||||||
|
setSelectedForTrend((prev) =>
|
||||||
|
prev.includes(code)
|
||||||
|
? prev.filter((c) => c !== code)
|
||||||
|
: prev.length < 4
|
||||||
|
? [...prev, code]
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<MetricsSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800 rounded-lg p-6 text-center">
|
||||||
|
<p className="text-error-700 dark:text-error-400 mb-4">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchMetrics}
|
||||||
|
className="px-4 py-2 bg-error-600 text-white rounded-lg hover:bg-error-700 transition-colors"
|
||||||
|
>
|
||||||
|
Reintentar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Metricas</h1>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Analiza el rendimiento de tu negocio
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<PeriodSelector value={period} onChange={setPeriod} />
|
||||||
|
<button
|
||||||
|
onClick={fetchMetrics}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
title="Actualizar"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Tabs */}
|
||||||
|
<CategoryTabs value={category} onChange={setCategory} />
|
||||||
|
|
||||||
|
{/* Metrics Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredMetrics.map((metric) => (
|
||||||
|
<MetricCard
|
||||||
|
key={metric.code}
|
||||||
|
metric={metric}
|
||||||
|
onClick={() => handleMetricClick(metric)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trend Selection */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||||
|
Selecciona hasta 4 metricas para comparar en el grafico de tendencias:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{metrics.map((metric) => (
|
||||||
|
<button
|
||||||
|
key={metric.code}
|
||||||
|
onClick={() => toggleTrendSelection(metric.code)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm font-medium rounded-full transition-all',
|
||||||
|
selectedForTrend.includes(metric.code)
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:border-primary-300 dark:hover:border-primary-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{metric.code}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trend Chart */}
|
||||||
|
<TrendChart metrics={metrics} selectedMetrics={selectedForTrend} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
912
apps/web/src/app/(dashboard)/transacciones/page.tsx
Normal file
912
apps/web/src/app/(dashboard)/transacciones/page.tsx
Normal file
@@ -0,0 +1,912 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownRight,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Download,
|
||||||
|
Plus,
|
||||||
|
MoreVertical,
|
||||||
|
Calendar,
|
||||||
|
X,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Eye,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
FileText,
|
||||||
|
Building2,
|
||||||
|
Tag,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/utils';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type TransactionType = 'income' | 'expense' | 'transfer';
|
||||||
|
type TransactionStatus = 'pending' | 'completed' | 'cancelled' | 'reconciled';
|
||||||
|
type PaymentMethod = 'cash' | 'bank_transfer' | 'credit_card' | 'debit_card' | 'check' | 'other';
|
||||||
|
|
||||||
|
interface Transaction {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
type: TransactionType;
|
||||||
|
category: string;
|
||||||
|
subcategory?: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
status: TransactionStatus;
|
||||||
|
paymentMethod: PaymentMethod;
|
||||||
|
contact?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
rfc?: string;
|
||||||
|
type: 'customer' | 'supplier';
|
||||||
|
};
|
||||||
|
cfdiId?: string;
|
||||||
|
cfdiUuid?: string;
|
||||||
|
bankAccountId?: string;
|
||||||
|
bankAccountName?: string;
|
||||||
|
reference?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
attachments?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TransactionsResponse {
|
||||||
|
transactions: Transaction[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
summary: {
|
||||||
|
totalIncome: number;
|
||||||
|
totalExpenses: number;
|
||||||
|
netAmount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Filters {
|
||||||
|
search: string;
|
||||||
|
type: TransactionType | 'all';
|
||||||
|
status: TransactionStatus | 'all';
|
||||||
|
category: string;
|
||||||
|
contactId: string;
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
paymentMethod: PaymentMethod | 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mock Data
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const categories = {
|
||||||
|
income: ['Ventas', 'Servicios', 'Comisiones', 'Intereses', 'Otros Ingresos'],
|
||||||
|
expense: ['Nomina', 'Servicios Profesionales', 'Renta', 'Servicios', 'Suministros', 'Marketing', 'Viajes', 'Otros Gastos'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateMockTransactions = (): Transaction[] => {
|
||||||
|
const contacts = [
|
||||||
|
{ id: '1', name: 'Empresa ABC S.A. de C.V.', rfc: 'EAB123456789', type: 'customer' as const },
|
||||||
|
{ id: '2', name: 'Servicios XYZ S.A.', rfc: 'SXY987654321', type: 'supplier' as const },
|
||||||
|
{ id: '3', name: 'Consultores Asociados', rfc: 'CON456789123', type: 'supplier' as const },
|
||||||
|
{ id: '4', name: 'Tech Solutions MX', rfc: 'TSM789123456', type: 'customer' as const },
|
||||||
|
{ id: '5', name: 'Distribuidora Nacional', rfc: 'DNA321654987', type: 'customer' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
const transactions: Transaction[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const type: TransactionType = Math.random() > 0.4 ? 'income' : 'expense';
|
||||||
|
const categoryList = type === 'income' ? categories.income : categories.expense;
|
||||||
|
const contact = contacts[Math.floor(Math.random() * contacts.length)];
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - Math.floor(Math.random() * 90));
|
||||||
|
|
||||||
|
transactions.push({
|
||||||
|
id: `txn-${i + 1}`,
|
||||||
|
date: date.toISOString().split('T')[0],
|
||||||
|
type,
|
||||||
|
category: categoryList[Math.floor(Math.random() * categoryList.length)],
|
||||||
|
description: type === 'income' ? `Factura ${1000 + i}` : `Pago ${2000 + i}`,
|
||||||
|
amount: Math.floor(Math.random() * 50000) + 1000,
|
||||||
|
currency: 'MXN',
|
||||||
|
status: (['pending', 'completed', 'reconciled'] as TransactionStatus[])[Math.floor(Math.random() * 3)],
|
||||||
|
paymentMethod: (['bank_transfer', 'credit_card', 'cash', 'check'] as PaymentMethod[])[Math.floor(Math.random() * 4)],
|
||||||
|
contact: Math.random() > 0.2 ? contact : undefined,
|
||||||
|
cfdiId: Math.random() > 0.5 ? `cfdi-${i}` : undefined,
|
||||||
|
cfdiUuid: Math.random() > 0.5 ? `${crypto.randomUUID?.() || `uuid-${i}`}` : undefined,
|
||||||
|
bankAccountId: 'bank-1',
|
||||||
|
bankAccountName: 'Cuenta Principal BBVA',
|
||||||
|
reference: `REF-${1000 + i}`,
|
||||||
|
notes: Math.random() > 0.7 ? 'Nota de la transaccion' : undefined,
|
||||||
|
tags: Math.random() > 0.5 ? ['recurrente', 'prioritario'].slice(0, Math.floor(Math.random() * 2) + 1) : undefined,
|
||||||
|
attachments: Math.floor(Math.random() * 3),
|
||||||
|
createdAt: date.toISOString(),
|
||||||
|
updatedAt: date.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactions.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Loading Skeleton
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function TransactionsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2" />
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-32" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Table skeleton */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-64" />
|
||||||
|
</div>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="px-4 py-4 border-b border-gray-100 dark:border-gray-700 flex gap-4">
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded flex-1" />
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24" />
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32" />
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Transaction Detail Modal
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface TransactionModalProps {
|
||||||
|
transaction: Transaction | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransactionModal({ transaction, isOpen, onClose }: TransactionModalProps) {
|
||||||
|
if (!isOpen || !transaction) return null;
|
||||||
|
|
||||||
|
const typeLabels: Record<TransactionType, string> = {
|
||||||
|
income: 'Ingreso',
|
||||||
|
expense: 'Gasto',
|
||||||
|
transfer: 'Transferencia',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<TransactionStatus, string> = {
|
||||||
|
pending: 'Pendiente',
|
||||||
|
completed: 'Completado',
|
||||||
|
cancelled: 'Cancelado',
|
||||||
|
reconciled: 'Conciliado',
|
||||||
|
};
|
||||||
|
|
||||||
|
const paymentLabels: Record<PaymentMethod, string> = {
|
||||||
|
cash: 'Efectivo',
|
||||||
|
bank_transfer: 'Transferencia',
|
||||||
|
credit_card: 'Tarjeta de Credito',
|
||||||
|
debit_card: 'Tarjeta de Debito',
|
||||||
|
check: 'Cheque',
|
||||||
|
other: 'Otro',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4">
|
||||||
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-2 rounded-lg',
|
||||||
|
transaction.type === 'income'
|
||||||
|
? 'bg-success-100 dark:bg-success-900/30'
|
||||||
|
: 'bg-error-100 dark:bg-error-900/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{transaction.type === 'income' ? (
|
||||||
|
<ArrowDownRight className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpRight className="w-5 h-5 text-error-600 dark:text-error-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{transaction.description}</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">{transaction.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Amount */}
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-4xl font-bold',
|
||||||
|
transaction.type === 'income' ? 'text-success-600 dark:text-success-400' : 'text-error-600 dark:text-error-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{transaction.type === 'income' ? '+' : '-'}
|
||||||
|
{formatCurrency(transaction.amount, transaction.currency)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{formatDate(transaction.date)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tipo</label>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white mt-1">{typeLabels[transaction.type]}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Estado</label>
|
||||||
|
<p className="mt-1">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex px-2 py-0.5 text-xs font-medium rounded-full',
|
||||||
|
transaction.status === 'completed' && 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400',
|
||||||
|
transaction.status === 'pending' && 'bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-400',
|
||||||
|
transaction.status === 'cancelled' && 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400',
|
||||||
|
transaction.status === 'reconciled' && 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusLabels[transaction.status]}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Categoria</label>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white mt-1">{transaction.category}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Metodo de Pago</label>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white mt-1">{paymentLabels[transaction.paymentMethod]}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact */}
|
||||||
|
{transaction.contact && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Contacto</label>
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
<div className="p-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||||
|
<Building2 className="w-5 h-5 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">{transaction.contact.name}</p>
|
||||||
|
{transaction.contact.rfc && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{transaction.contact.rfc}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CFDI */}
|
||||||
|
{transaction.cfdiUuid && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">CFDI Relacionado</label>
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
<div className="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
|
||||||
|
<FileText className="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-mono text-gray-900 dark:text-white">{transaction.cfdiUuid}</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`/cfdis/${transaction.cfdiId}`}
|
||||||
|
className="text-sm text-primary-600 dark:text-primary-400 hover:underline"
|
||||||
|
>
|
||||||
|
Ver CFDI
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bank Account */}
|
||||||
|
{transaction.bankAccountName && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Cuenta Bancaria</label>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white mt-1">{transaction.bankAccountName}</p>
|
||||||
|
{transaction.reference && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Ref: {transaction.reference}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{transaction.tags && transaction.tags.length > 0 && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Etiquetas</label>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{transaction.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-xs rounded-full"
|
||||||
|
>
|
||||||
|
<Tag className="w-3 h-3" />
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{transaction.notes && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Notas</label>
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300 mt-1">{transaction.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="sticky bottom-0 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 px-6 py-4 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</button>
|
||||||
|
<button className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600">
|
||||||
|
<Edit className="w-4 h-4 inline mr-2" />
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Filter Panel
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface FilterPanelProps {
|
||||||
|
filters: Filters;
|
||||||
|
onChange: (filters: Filters) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterPanel({ filters, onChange, onClose, isOpen }: FilterPanelProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const allCategories = [...categories.income, ...categories.expense];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-4 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white">Filtros</h3>
|
||||||
|
<button onClick={onClose} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{/* Type */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tipo</label>
|
||||||
|
<select
|
||||||
|
value={filters.type}
|
||||||
|
onChange={(e) => onChange({ ...filters, type: e.target.value as Filters['type'] })}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">Todos</option>
|
||||||
|
<option value="income">Ingresos</option>
|
||||||
|
<option value="expense">Gastos</option>
|
||||||
|
<option value="transfer">Transferencias</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estado</label>
|
||||||
|
<select
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(e) => onChange({ ...filters, status: e.target.value as Filters['status'] })}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">Todos</option>
|
||||||
|
<option value="pending">Pendiente</option>
|
||||||
|
<option value="completed">Completado</option>
|
||||||
|
<option value="reconciled">Conciliado</option>
|
||||||
|
<option value="cancelled">Cancelado</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Categoria</label>
|
||||||
|
<select
|
||||||
|
value={filters.category}
|
||||||
|
onChange={(e) => onChange({ ...filters, category: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Todas</option>
|
||||||
|
{allCategories.map((cat) => (
|
||||||
|
<option key={cat} value={cat}>
|
||||||
|
{cat}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Method */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Metodo de Pago</label>
|
||||||
|
<select
|
||||||
|
value={filters.paymentMethod}
|
||||||
|
onChange={(e) => onChange({ ...filters, paymentMethod: e.target.value as Filters['paymentMethod'] })}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">Todos</option>
|
||||||
|
<option value="bank_transfer">Transferencia</option>
|
||||||
|
<option value="cash">Efectivo</option>
|
||||||
|
<option value="credit_card">Tarjeta de Credito</option>
|
||||||
|
<option value="debit_card">Tarjeta de Debito</option>
|
||||||
|
<option value="check">Cheque</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date From */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Desde</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateFrom}
|
||||||
|
onChange={(e) => onChange({ ...filters, dateFrom: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date To */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hasta</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateTo}
|
||||||
|
onChange={(e) => onChange({ ...filters, dateTo: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
|
search: '',
|
||||||
|
type: 'all',
|
||||||
|
status: 'all',
|
||||||
|
category: '',
|
||||||
|
contactId: '',
|
||||||
|
dateFrom: '',
|
||||||
|
dateTo: '',
|
||||||
|
paymentMethod: 'all',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
Limpiar filtros
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className="px-4 py-2 bg-primary-500 text-white rounded-lg text-sm hover:bg-primary-600">
|
||||||
|
Aplicar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Page Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function TransaccionesPage() {
|
||||||
|
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedTransaction, setSelectedTransaction] = useState<Transaction | null>(null);
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [filters, setFilters] = useState<Filters>({
|
||||||
|
search: '',
|
||||||
|
type: 'all',
|
||||||
|
status: 'all',
|
||||||
|
category: '',
|
||||||
|
contactId: '',
|
||||||
|
dateFrom: '',
|
||||||
|
dateTo: '',
|
||||||
|
paymentMethod: 'all',
|
||||||
|
});
|
||||||
|
|
||||||
|
const limit = 20;
|
||||||
|
|
||||||
|
const fetchTransactions = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// In production, this would be an API call
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||||
|
setTransactions(generateMockTransactions());
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al cargar transacciones');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTransactions();
|
||||||
|
}, [fetchTransactions]);
|
||||||
|
|
||||||
|
// Filter and paginate transactions
|
||||||
|
const filteredTransactions = useMemo(() => {
|
||||||
|
return transactions.filter((t) => {
|
||||||
|
if (filters.search) {
|
||||||
|
const searchLower = filters.search.toLowerCase();
|
||||||
|
if (
|
||||||
|
!t.description.toLowerCase().includes(searchLower) &&
|
||||||
|
!t.contact?.name.toLowerCase().includes(searchLower) &&
|
||||||
|
!t.category.toLowerCase().includes(searchLower)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filters.type !== 'all' && t.type !== filters.type) return false;
|
||||||
|
if (filters.status !== 'all' && t.status !== filters.status) return false;
|
||||||
|
if (filters.category && t.category !== filters.category) return false;
|
||||||
|
if (filters.paymentMethod !== 'all' && t.paymentMethod !== filters.paymentMethod) return false;
|
||||||
|
if (filters.dateFrom && t.date < filters.dateFrom) return false;
|
||||||
|
if (filters.dateTo && t.date > filters.dateTo) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [transactions, filters]);
|
||||||
|
|
||||||
|
const paginatedTransactions = useMemo(() => {
|
||||||
|
const start = (page - 1) * limit;
|
||||||
|
return filteredTransactions.slice(start, start + limit);
|
||||||
|
}, [filteredTransactions, page]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredTransactions.length / limit);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const income = filteredTransactions.filter((t) => t.type === 'income').reduce((sum, t) => sum + t.amount, 0);
|
||||||
|
const expenses = filteredTransactions.filter((t) => t.type === 'expense').reduce((sum, t) => sum + t.amount, 0);
|
||||||
|
return {
|
||||||
|
income,
|
||||||
|
expenses,
|
||||||
|
net: income - expenses,
|
||||||
|
};
|
||||||
|
}, [filteredTransactions]);
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const csv = [
|
||||||
|
['Fecha', 'Tipo', 'Descripcion', 'Categoria', 'Monto', 'Estado', 'Contacto'].join(','),
|
||||||
|
...filteredTransactions.map((t) =>
|
||||||
|
[t.date, t.type, t.description, t.category, t.amount, t.status, t.contact?.name || ''].join(',')
|
||||||
|
),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `transacciones_${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<TransactionsSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800 rounded-lg p-6 text-center">
|
||||||
|
<p className="text-error-700 dark:text-error-400 mb-4">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchTransactions}
|
||||||
|
className="px-4 py-2 bg-error-600 text-white rounded-lg hover:bg-error-700"
|
||||||
|
>
|
||||||
|
Reintentar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Transacciones</h1>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{formatNumber(filteredTransactions.length)} transacciones
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Exportar
|
||||||
|
</button>
|
||||||
|
<button className="flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Nueva Transaccion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||||||
|
<ArrowDownRight className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Ingresos</p>
|
||||||
|
<p className="text-xl font-bold text-success-600 dark:text-success-400">{formatCurrency(summary.income)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-error-100 dark:bg-error-900/30 rounded-lg">
|
||||||
|
<ArrowUpRight className="w-5 h-5 text-error-600 dark:text-error-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Gastos</p>
|
||||||
|
<p className="text-xl font-bold text-error-600 dark:text-error-400">{formatCurrency(summary.expenses)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn('p-2 rounded-lg', summary.net >= 0 ? 'bg-primary-100 dark:bg-primary-900/30' : 'bg-error-100 dark:bg-error-900/30')}>
|
||||||
|
<Activity className="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Neto</p>
|
||||||
|
<p className={cn('text-xl font-bold', summary.net >= 0 ? 'text-primary-600 dark:text-primary-400' : 'text-error-600 dark:text-error-400')}>
|
||||||
|
{formatCurrency(summary.net)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar transacciones..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-4 py-2 border rounded-lg transition-colors',
|
||||||
|
showFilters
|
||||||
|
? 'bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-700 text-primary-700 dark:text-primary-400'
|
||||||
|
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Filter className="w-4 h-4" />
|
||||||
|
Filtros
|
||||||
|
{Object.values(filters).filter((v) => v && v !== 'all').length > 1 && (
|
||||||
|
<span className="ml-1 px-1.5 py-0.5 bg-primary-500 text-white text-xs rounded-full">
|
||||||
|
{Object.values(filters).filter((v) => v && v !== 'all').length - 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Panel */}
|
||||||
|
<FilterPanel filters={filters} onChange={setFilters} onClose={() => setShowFilters(false)} isOpen={showFilters} />
|
||||||
|
|
||||||
|
{/* Transactions Table */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Fecha
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Descripcion
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Categoria
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Contacto
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Monto
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Estado
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Acciones
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{paginatedTransactions.map((transaction) => (
|
||||||
|
<tr
|
||||||
|
key={transaction.id}
|
||||||
|
onClick={() => setSelectedTransaction(transaction)}
|
||||||
|
className="hover:bg-gray-50 dark:hover:bg-gray-900/50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||||
|
{formatDate(transaction.date)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-1.5 rounded-lg',
|
||||||
|
transaction.type === 'income'
|
||||||
|
? 'bg-success-100 dark:bg-success-900/30'
|
||||||
|
: 'bg-error-100 dark:bg-error-900/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{transaction.type === 'income' ? (
|
||||||
|
<ArrowDownRight className="w-4 h-4 text-success-600 dark:text-success-400" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpRight className="w-4 h-4 text-error-600 dark:text-error-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">{transaction.description}</p>
|
||||||
|
{transaction.cfdiUuid && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||||
|
CFDI: {transaction.cfdiUuid.slice(0, 8)}...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{transaction.category}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{transaction.contact ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-900 dark:text-white">{transaction.contact.name}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{transaction.contact.rfc}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-400">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium',
|
||||||
|
transaction.type === 'income'
|
||||||
|
? 'text-success-600 dark:text-success-400'
|
||||||
|
: 'text-error-600 dark:text-error-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{transaction.type === 'income' ? '+' : '-'}
|
||||||
|
{formatCurrency(transaction.amount, transaction.currency)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex px-2 py-0.5 text-xs font-medium rounded-full',
|
||||||
|
transaction.status === 'completed' && 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400',
|
||||||
|
transaction.status === 'pending' && 'bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-400',
|
||||||
|
transaction.status === 'cancelled' && 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400',
|
||||||
|
transaction.status === 'reconciled' && 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{transaction.status === 'completed' && 'Completado'}
|
||||||
|
{transaction.status === 'pending' && 'Pendiente'}
|
||||||
|
{transaction.status === 'cancelled' && 'Cancelado'}
|
||||||
|
{transaction.status === 'reconciled' && 'Conciliado'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedTransaction(transaction);
|
||||||
|
}}
|
||||||
|
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Mostrando {(page - 1) * limit + 1} a {Math.min(page * limit, filteredTransactions.length)} de{' '}
|
||||||
|
{filteredTransactions.length} transacciones
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Pagina {page} de {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transaction Detail Modal */}
|
||||||
|
<TransactionModal
|
||||||
|
transaction={selectedTransaction}
|
||||||
|
isOpen={!!selectedTransaction}
|
||||||
|
onClose={() => setSelectedTransaction(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need to import Activity for the summary card
|
||||||
|
import { Activity } from 'lucide-react';
|
||||||
374
apps/web/src/app/globals.css
Normal file
374
apps/web/src/app/globals.css
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Base Styles
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
/* Root variables */
|
||||||
|
:root {
|
||||||
|
--background: 255 255 255;
|
||||||
|
--foreground: 15 23 42;
|
||||||
|
--primary: 12 140 232;
|
||||||
|
--primary-foreground: 255 255 255;
|
||||||
|
--muted: 241 245 249;
|
||||||
|
--muted-foreground: 100 116 139;
|
||||||
|
--accent: 241 245 249;
|
||||||
|
--accent-foreground: 15 23 42;
|
||||||
|
--border: 226 232 240;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 10 15 26;
|
||||||
|
--foreground: 241 245 249;
|
||||||
|
--primary: 54 167 247;
|
||||||
|
--primary-foreground: 255 255 255;
|
||||||
|
--muted: 30 41 59;
|
||||||
|
--muted-foreground: 148 163 184;
|
||||||
|
--accent: 30 41 59;
|
||||||
|
--accent-foreground: 241 245 249;
|
||||||
|
--border: 51 65 85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base HTML styles */
|
||||||
|
html {
|
||||||
|
@apply antialiased;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100;
|
||||||
|
@apply min-h-screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
@apply font-semibold tracking-tight;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { @apply text-4xl lg:text-5xl; }
|
||||||
|
h2 { @apply text-3xl lg:text-4xl; }
|
||||||
|
h3 { @apply text-2xl lg:text-3xl; }
|
||||||
|
h4 { @apply text-xl lg:text-2xl; }
|
||||||
|
h5 { @apply text-lg lg:text-xl; }
|
||||||
|
h6 { @apply text-base lg:text-lg; }
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
a {
|
||||||
|
@apply transition-colors duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus styles */
|
||||||
|
*:focus-visible {
|
||||||
|
@apply outline-none ring-2 ring-primary-500 ring-offset-2 ring-offset-white dark:ring-offset-slate-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection */
|
||||||
|
::selection {
|
||||||
|
@apply bg-primary-500/20 text-primary-900 dark:text-primary-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
@apply w-2 h-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-slate-300 dark:bg-slate-700 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-slate-400 dark:bg-slate-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox scrollbar */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgb(148 163 184) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark * {
|
||||||
|
scrollbar-color: rgb(51 65 85) transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Component Styles
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Container */
|
||||||
|
.container-app {
|
||||||
|
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page wrapper */
|
||||||
|
.page-wrapper {
|
||||||
|
@apply min-h-screen flex flex-col;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card styles */
|
||||||
|
.card {
|
||||||
|
@apply bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover {
|
||||||
|
@apply card transition-all duration-200 hover:border-primary-300 hover:shadow-md dark:hover:border-primary-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form styles */
|
||||||
|
.form-group {
|
||||||
|
@apply space-y-1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
@apply block text-sm font-medium text-slate-700 dark:text-slate-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
@apply w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600;
|
||||||
|
@apply bg-white dark:bg-slate-800 text-slate-900 dark:text-white;
|
||||||
|
@apply placeholder:text-slate-400 dark:placeholder:text-slate-500;
|
||||||
|
@apply focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20;
|
||||||
|
@apply transition-all duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
@apply text-sm text-error-600 dark:text-error-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge */
|
||||||
|
.badge {
|
||||||
|
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-primary {
|
||||||
|
@apply badge bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
@apply badge bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
@apply badge bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-error {
|
||||||
|
@apply badge bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status dot */
|
||||||
|
.status-dot {
|
||||||
|
@apply w-2 h-2 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-online {
|
||||||
|
@apply status-dot bg-success-500 animate-pulse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-offline {
|
||||||
|
@apply status-dot bg-slate-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warning {
|
||||||
|
@apply status-dot bg-warning-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient text */
|
||||||
|
.gradient-text {
|
||||||
|
@apply bg-gradient-to-r from-primary-600 to-primary-400 bg-clip-text text-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass effect */
|
||||||
|
.glass {
|
||||||
|
@apply bg-white/70 dark:bg-slate-900/70 backdrop-blur-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton loading */
|
||||||
|
.skeleton {
|
||||||
|
@apply bg-slate-200 dark:bg-slate-700 animate-pulse rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Divider */
|
||||||
|
.divider {
|
||||||
|
@apply border-t border-slate-200 dark:border-slate-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar */
|
||||||
|
.avatar {
|
||||||
|
@apply rounded-full bg-primary-100 dark:bg-primary-900/50 flex items-center justify-center;
|
||||||
|
@apply text-primary-700 dark:text-primary-300 font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-sm { @apply w-8 h-8 text-sm; }
|
||||||
|
.avatar-md { @apply w-10 h-10 text-base; }
|
||||||
|
.avatar-lg { @apply w-12 h-12 text-lg; }
|
||||||
|
.avatar-xl { @apply w-16 h-16 text-xl; }
|
||||||
|
|
||||||
|
/* Trading specific */
|
||||||
|
.price-up {
|
||||||
|
@apply text-success-600 dark:text-success-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-down {
|
||||||
|
@apply text-error-600 dark:text-error-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-neutral {
|
||||||
|
@apply text-slate-600 dark:text-slate-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Utility Styles
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Hide scrollbar */
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Truncate multiline */
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-clamp-3 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Safe area padding */
|
||||||
|
.safe-top {
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
}
|
||||||
|
|
||||||
|
.safe-bottom {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation delays */
|
||||||
|
.animation-delay-100 { animation-delay: 100ms; }
|
||||||
|
.animation-delay-200 { animation-delay: 200ms; }
|
||||||
|
.animation-delay-300 { animation-delay: 300ms; }
|
||||||
|
.animation-delay-400 { animation-delay: 400ms; }
|
||||||
|
.animation-delay-500 { animation-delay: 500ms; }
|
||||||
|
|
||||||
|
/* Glow effects */
|
||||||
|
.glow-primary {
|
||||||
|
box-shadow: 0 0 20px rgba(12, 140, 232, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-success {
|
||||||
|
box-shadow: 0 0 20px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-error {
|
||||||
|
box-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text gradient */
|
||||||
|
.text-gradient-primary {
|
||||||
|
@apply bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Backdrop blur variants */
|
||||||
|
.backdrop-blur-xs {
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Animation Keyframes
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradient {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float {
|
||||||
|
animation: float 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-shimmer {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0) 0%,
|
||||||
|
rgba(255, 255, 255, 0.2) 50%,
|
||||||
|
rgba(255, 255, 255, 0) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-gradient {
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: gradient 4s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Print Styles
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-white text-black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply border border-slate-300;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
144
apps/web/src/app/layout.tsx
Normal file
144
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
import { Inter, JetBrains_Mono } from 'next/font/google';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
// Fonts
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ['latin'],
|
||||||
|
display: 'swap',
|
||||||
|
variable: '--font-inter',
|
||||||
|
});
|
||||||
|
|
||||||
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
|
subsets: ['latin'],
|
||||||
|
display: 'swap',
|
||||||
|
variable: '--font-mono',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
default: 'Horux Strategy - Trading Algoritmico',
|
||||||
|
template: '%s | Horux Strategy',
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
'Plataforma de trading algoritmico para automatizar tus estrategias de inversion con inteligencia artificial.',
|
||||||
|
keywords: [
|
||||||
|
'trading',
|
||||||
|
'algoritmico',
|
||||||
|
'criptomonedas',
|
||||||
|
'bitcoin',
|
||||||
|
'estrategias',
|
||||||
|
'automatizacion',
|
||||||
|
'inversion',
|
||||||
|
],
|
||||||
|
authors: [{ name: 'Horux Team' }],
|
||||||
|
creator: 'Horux',
|
||||||
|
publisher: 'Horux',
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
type: 'website',
|
||||||
|
locale: 'es_ES',
|
||||||
|
url: 'https://horux.io',
|
||||||
|
siteName: 'Horux Strategy',
|
||||||
|
title: 'Horux Strategy - Trading Algoritmico',
|
||||||
|
description:
|
||||||
|
'Plataforma de trading algoritmico para automatizar tus estrategias de inversion.',
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: '/og-image.png',
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: 'Horux Strategy',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Horux Strategy',
|
||||||
|
description: 'Plataforma de trading algoritmico',
|
||||||
|
images: ['/og-image.png'],
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: '/favicon.ico',
|
||||||
|
shortcut: '/favicon-16x16.png',
|
||||||
|
apple: '/apple-touch-icon.png',
|
||||||
|
},
|
||||||
|
manifest: '/manifest.json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Viewport
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: [
|
||||||
|
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
||||||
|
{ media: '(prefers-color-scheme: dark)', color: '#0a0f1a' },
|
||||||
|
],
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root Layout
|
||||||
|
*
|
||||||
|
* Layout principal de la aplicacion que envuelve todas las paginas.
|
||||||
|
* Incluye providers globales, fonts y meta tags.
|
||||||
|
*/
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="es"
|
||||||
|
className={`${inter.variable} ${jetbrainsMono.variable}`}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
{/* Preconnect to external resources */}
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link
|
||||||
|
rel="preconnect"
|
||||||
|
href="https://fonts.gstatic.com"
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className="font-sans antialiased">
|
||||||
|
{/* Theme Script - Prevent flash */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var stored = localStorage.getItem('horux-ui-storage');
|
||||||
|
var theme = 'dark';
|
||||||
|
if (stored) {
|
||||||
|
var parsed = JSON.parse(stored);
|
||||||
|
theme = parsed.state?.theme || 'dark';
|
||||||
|
}
|
||||||
|
if (theme === 'system') {
|
||||||
|
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
document.documentElement.classList.add(theme);
|
||||||
|
} catch (e) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="min-h-screen">{children}</main>
|
||||||
|
|
||||||
|
{/* Portal containers for modals/toasts */}
|
||||||
|
<div id="modal-root" />
|
||||||
|
<div id="toast-root" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
apps/web/src/app/page.tsx
Normal file
51
apps/web/src/app/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Landing Page
|
||||||
|
*
|
||||||
|
* Pagina principal que redirige a login o dashboard
|
||||||
|
* dependiendo del estado de autenticacion.
|
||||||
|
*/
|
||||||
|
export default function HomePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAuthenticated, isInitialized, checkAuth } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
await checkAuth();
|
||||||
|
};
|
||||||
|
init();
|
||||||
|
}, [checkAuth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitialized) {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
router.replace('/dashboard');
|
||||||
|
} else {
|
||||||
|
router.replace('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, isInitialized, router]);
|
||||||
|
|
||||||
|
// Loading state mientras se verifica la autenticacion
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-950">
|
||||||
|
<div className="text-center">
|
||||||
|
{/* Logo animado */}
|
||||||
|
<div className="relative w-20 h-20 mx-auto mb-6">
|
||||||
|
<div className="absolute inset-0 rounded-2xl bg-horux-gradient animate-pulse" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-4xl font-bold text-white">H</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading text */}
|
||||||
|
<p className="text-slate-400 text-sm animate-pulse">Cargando...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
345
apps/web/src/components/layout/Header.tsx
Normal file
345
apps/web/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { cn, getInitials } from '@/lib/utils';
|
||||||
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
|
import { useUIStore, useTheme } from '@/stores/ui.store';
|
||||||
|
import {
|
||||||
|
Menu,
|
||||||
|
Bell,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
Search,
|
||||||
|
User,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
ChevronDown,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notificacion mock
|
||||||
|
*/
|
||||||
|
interface Notification {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
type: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
timestamp: Date;
|
||||||
|
read: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notificaciones de ejemplo
|
||||||
|
*/
|
||||||
|
const mockNotifications: Notification[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Trade ejecutado',
|
||||||
|
message: 'BTC/USDT compra a $43,250',
|
||||||
|
type: 'success',
|
||||||
|
timestamp: new Date(Date.now() - 1000 * 60 * 5),
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Stop Loss activado',
|
||||||
|
message: 'ETH/USDT posicion cerrada',
|
||||||
|
type: 'warning',
|
||||||
|
timestamp: new Date(Date.now() - 1000 * 60 * 30),
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: 'Nueva estrategia disponible',
|
||||||
|
message: 'Grid Trading actualizado',
|
||||||
|
type: 'info',
|
||||||
|
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2),
|
||||||
|
read: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente Header
|
||||||
|
*
|
||||||
|
* Header principal con búsqueda, notificaciones, tema y menú de usuario.
|
||||||
|
*/
|
||||||
|
export const Header: React.FC = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
const { sidebarCollapsed, isMobile, setSidebarOpen } = useUIStore();
|
||||||
|
const { isDark, toggleTheme } = useTheme();
|
||||||
|
|
||||||
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
|
const [showNotifications, setShowNotifications] = useState(false);
|
||||||
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||||
|
const [notifications] = useState<Notification[]>(mockNotifications);
|
||||||
|
|
||||||
|
const notificationRef = useRef<HTMLDivElement>(null);
|
||||||
|
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Cerrar menus al hacer click fuera
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (notificationRef.current && !notificationRef.current.contains(event.target as Node)) {
|
||||||
|
setShowNotifications(false);
|
||||||
|
}
|
||||||
|
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
|
||||||
|
setShowUserMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle logout
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
router.push('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unread count
|
||||||
|
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
'fixed top-0 right-0 z-40',
|
||||||
|
'h-16 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md',
|
||||||
|
'border-b border-slate-200 dark:border-slate-700',
|
||||||
|
'transition-all duration-300',
|
||||||
|
// Ajustar ancho segun sidebar
|
||||||
|
isMobile ? 'left-0' : (sidebarCollapsed ? 'left-20' : 'left-64')
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between h-full px-4 lg:px-6">
|
||||||
|
{/* Left Section */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Mobile menu button */}
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
className="p-2 rounded-lg text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
|
||||||
|
aria-label="Abrir menu"
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
{showSearch ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar..."
|
||||||
|
className="w-64 px-4 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSearch(false)}
|
||||||
|
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSearch(true)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Buscar</span>
|
||||||
|
<kbd className="hidden lg:inline-flex items-center gap-1 px-2 py-0.5 text-xs font-mono bg-slate-100 dark:bg-slate-700 rounded">
|
||||||
|
<span className="text-xs">Ctrl</span>K
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Section */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="p-2 rounded-lg text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<div ref={notificationRef} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNotifications(!showNotifications)}
|
||||||
|
className="relative p-2 rounded-lg text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
aria-label="Notificaciones"
|
||||||
|
>
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute top-1 right-1 w-4 h-4 flex items-center justify-center text-xs font-bold text-white bg-error-500 rounded-full">
|
||||||
|
{unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Notifications Dropdown */}
|
||||||
|
{showNotifications && (
|
||||||
|
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden z-50">
|
||||||
|
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">
|
||||||
|
Notificaciones
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{notifications.length > 0 ? (
|
||||||
|
notifications.map((notification) => (
|
||||||
|
<div
|
||||||
|
key={notification.id}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-3 border-b border-slate-100 dark:border-slate-700 last:border-0',
|
||||||
|
'hover:bg-slate-50 dark:hover:bg-slate-700/50 cursor-pointer transition-colors',
|
||||||
|
!notification.read && 'bg-primary-50/50 dark:bg-primary-900/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 mt-2 rounded-full flex-shrink-0',
|
||||||
|
notification.type === 'success' && 'bg-success-500',
|
||||||
|
notification.type === 'error' && 'bg-error-500',
|
||||||
|
notification.type === 'warning' && 'bg-warning-500',
|
||||||
|
notification.type === 'info' && 'bg-primary-500'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-900 dark:text-white">
|
||||||
|
{notification.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 truncate">
|
||||||
|
{notification.message}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
{formatTimeAgo(notification.timestamp)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-center text-slate-500 dark:text-slate-400">
|
||||||
|
No hay notificaciones
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-3 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<Link
|
||||||
|
href="/notifications"
|
||||||
|
className="block text-center text-sm text-primary-600 dark:text-primary-400 hover:underline"
|
||||||
|
onClick={() => setShowNotifications(false)}
|
||||||
|
>
|
||||||
|
Ver todas
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Menu */}
|
||||||
|
<div ref={userMenuRef} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||||
|
className="flex items-center gap-3 p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900/50 flex items-center justify-center text-primary-700 dark:text-primary-300 font-semibold text-sm">
|
||||||
|
{user?.avatar ? (
|
||||||
|
<img
|
||||||
|
src={user.avatar}
|
||||||
|
alt={user.name}
|
||||||
|
className="w-full h-full rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
getInitials(user?.name || 'Usuario')
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block text-left">
|
||||||
|
<p className="text-sm font-medium text-slate-900 dark:text-white">
|
||||||
|
{user?.name || 'Usuario'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{user?.role || 'trader'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="hidden md:block h-4 w-4 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* User Dropdown */}
|
||||||
|
{showUserMenu && (
|
||||||
|
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden z-50">
|
||||||
|
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<p className="text-sm font-medium text-slate-900 dark:text-white">
|
||||||
|
{user?.name || 'Usuario'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 truncate">
|
||||||
|
{user?.email || 'usuario@ejemplo.com'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="py-2">
|
||||||
|
<Link
|
||||||
|
href="/profile"
|
||||||
|
className="flex items-center gap-3 px-4 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700/50"
|
||||||
|
onClick={() => setShowUserMenu(false)}
|
||||||
|
>
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
Mi Perfil
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="flex items-center gap-3 px-4 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700/50"
|
||||||
|
onClick={() => setShowUserMenu(false)}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Configuracion
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="py-2 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center gap-3 w-full px-4 py-2 text-sm text-error-600 dark:text-error-400 hover:bg-error-50 dark:hover:bg-error-900/20"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Cerrar sesion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea tiempo relativo
|
||||||
|
*/
|
||||||
|
function formatTimeAgo(date: Date): string {
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
const minutes = Math.floor(diff / (1000 * 60));
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (minutes < 1) return 'Ahora';
|
||||||
|
if (minutes < 60) return `Hace ${minutes} min`;
|
||||||
|
if (hours < 24) return `Hace ${hours}h`;
|
||||||
|
return `Hace ${days}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header;
|
||||||
306
apps/web/src/components/layout/Sidebar.tsx
Normal file
306
apps/web/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
LineChart,
|
||||||
|
Wallet,
|
||||||
|
History,
|
||||||
|
Settings,
|
||||||
|
HelpCircle,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
TrendingUp,
|
||||||
|
Bot,
|
||||||
|
Shield,
|
||||||
|
Bell,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Item de navegación
|
||||||
|
*/
|
||||||
|
interface NavItem {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
badge?: string | number;
|
||||||
|
children?: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grupos de navegación
|
||||||
|
*/
|
||||||
|
interface NavGroup {
|
||||||
|
label?: string;
|
||||||
|
items: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navegación principal
|
||||||
|
*/
|
||||||
|
const navigation: NavGroup[] = [
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Dashboard',
|
||||||
|
href: '/dashboard',
|
||||||
|
icon: <LayoutDashboard className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Trading',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Estrategias',
|
||||||
|
href: '/strategies',
|
||||||
|
icon: <Bot className="h-5 w-5" />,
|
||||||
|
badge: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Portfolio',
|
||||||
|
href: '/portfolio',
|
||||||
|
icon: <Wallet className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Mercados',
|
||||||
|
href: '/markets',
|
||||||
|
icon: <TrendingUp className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Historial',
|
||||||
|
href: '/history',
|
||||||
|
icon: <History className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Analisis',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Performance',
|
||||||
|
href: '/analytics',
|
||||||
|
icon: <LineChart className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Riesgo',
|
||||||
|
href: '/risk',
|
||||||
|
icon: <Shield className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sistema',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Notificaciones',
|
||||||
|
href: '/notifications',
|
||||||
|
icon: <Bell className="h-5 w-5" />,
|
||||||
|
badge: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Configuracion',
|
||||||
|
href: '/settings',
|
||||||
|
icon: <Settings className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Ayuda',
|
||||||
|
href: '/help',
|
||||||
|
icon: <HelpCircle className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente NavLink
|
||||||
|
*/
|
||||||
|
interface NavLinkProps {
|
||||||
|
item: NavItem;
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavLink: React.FC<NavLinkProps> = ({ item, collapsed }) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-3 py-2.5 rounded-lg',
|
||||||
|
'text-sm font-medium transition-all duration-200',
|
||||||
|
'group relative',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700/50 dark:hover:text-white',
|
||||||
|
collapsed && 'justify-center px-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Active Indicator */}
|
||||||
|
{isActive && (
|
||||||
|
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-primary-600 dark:bg-primary-400 rounded-r-full" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex-shrink-0 transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'text-primary-600 dark:text-primary-400'
|
||||||
|
: 'text-slate-400 group-hover:text-slate-600 dark:text-slate-500 dark:group-hover:text-slate-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="flex-1 truncate">{item.label}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Badge */}
|
||||||
|
{item.badge && !collapsed && (
|
||||||
|
<span className="flex-shrink-0 px-2 py-0.5 text-xs font-semibold rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/50 dark:text-primary-300">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tooltip when collapsed */}
|
||||||
|
{collapsed && (
|
||||||
|
<span className="absolute left-full ml-2 px-2 py-1 text-sm font-medium text-white bg-slate-900 dark:bg-slate-700 rounded opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
|
||||||
|
{item.label}
|
||||||
|
{item.badge && (
|
||||||
|
<span className="ml-2 px-1.5 py-0.5 text-xs rounded-full bg-primary-500 text-white">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente Sidebar
|
||||||
|
*
|
||||||
|
* Barra lateral de navegación con soporte para colapsado y grupos de navegación.
|
||||||
|
*/
|
||||||
|
export const Sidebar: React.FC = () => {
|
||||||
|
const { sidebarOpen, sidebarCollapsed, toggleSidebarCollapsed, isMobile, setSidebarOpen } = useUIStore();
|
||||||
|
|
||||||
|
// En mobile, cerrar al hacer click en overlay
|
||||||
|
const handleOverlayClick = () => {
|
||||||
|
if (isMobile) {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Overlay mobile */}
|
||||||
|
{isMobile && sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'fixed top-0 left-0 z-50 h-full',
|
||||||
|
'bg-white dark:bg-slate-900',
|
||||||
|
'border-r border-slate-200 dark:border-slate-700',
|
||||||
|
'flex flex-col',
|
||||||
|
'transition-all duration-300 ease-in-out',
|
||||||
|
// Width
|
||||||
|
sidebarCollapsed ? 'w-20' : 'w-64',
|
||||||
|
// Mobile
|
||||||
|
isMobile && !sidebarOpen && '-translate-x-full',
|
||||||
|
isMobile && sidebarOpen && 'translate-x-0',
|
||||||
|
// Desktop
|
||||||
|
!isMobile && 'translate-x-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={cn(
|
||||||
|
'flex items-center h-16 px-4',
|
||||||
|
'border-b border-slate-200 dark:border-slate-700',
|
||||||
|
sidebarCollapsed && 'justify-center'
|
||||||
|
)}>
|
||||||
|
{/* Logo */}
|
||||||
|
<Link href="/dashboard" className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-horux-gradient flex items-center justify-center">
|
||||||
|
<span className="text-white font-bold text-xl">H</span>
|
||||||
|
</div>
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<span className="text-xl font-bold text-slate-900 dark:text-white">
|
||||||
|
Horux
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-6">
|
||||||
|
{navigation.map((group, idx) => (
|
||||||
|
<div key={idx}>
|
||||||
|
{/* Group Label */}
|
||||||
|
{group.label && !sidebarCollapsed && (
|
||||||
|
<h3 className="px-3 mb-2 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
|
||||||
|
{group.label}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Separator when collapsed */}
|
||||||
|
{group.label && sidebarCollapsed && (
|
||||||
|
<div className="my-2 border-t border-slate-200 dark:border-slate-700" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<NavLink key={item.href} item={item} collapsed={sidebarCollapsed} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer - Collapse Button */}
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="p-3 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<button
|
||||||
|
onClick={toggleSidebarCollapsed}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 w-full px-3 py-2.5 rounded-lg',
|
||||||
|
'text-sm font-medium text-slate-600 dark:text-slate-400',
|
||||||
|
'hover:bg-slate-100 dark:hover:bg-slate-700/50',
|
||||||
|
'transition-all duration-200',
|
||||||
|
sidebarCollapsed && 'justify-center'
|
||||||
|
)}
|
||||||
|
aria-label={sidebarCollapsed ? 'Expandir sidebar' : 'Colapsar sidebar'}
|
||||||
|
>
|
||||||
|
{sidebarCollapsed ? (
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
<span>Colapsar</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
9
apps/web/src/components/layout/index.ts
Normal file
9
apps/web/src/components/layout/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Layout Components Barrel Export
|
||||||
|
*
|
||||||
|
* Re-exporta todos los componentes de layout.
|
||||||
|
* Ejemplo: import { Sidebar, Header } from '@/components/layout';
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { Sidebar } from './Sidebar';
|
||||||
|
export { Header } from './Header';
|
||||||
215
apps/web/src/components/ui/Button.tsx
Normal file
215
apps/web/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { forwardRef, ButtonHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variantes del botón
|
||||||
|
*/
|
||||||
|
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tamaños del botón
|
||||||
|
*/
|
||||||
|
type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props del componente Button
|
||||||
|
*/
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
isLoading?: boolean;
|
||||||
|
leftIcon?: React.ReactNode;
|
||||||
|
rightIcon?: React.ReactNode;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estilos base del botón
|
||||||
|
*/
|
||||||
|
const baseStyles = `
|
||||||
|
inline-flex items-center justify-center
|
||||||
|
font-medium rounded-lg
|
||||||
|
transition-all duration-200 ease-in-out
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none
|
||||||
|
active:scale-[0.98]
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estilos por variante
|
||||||
|
*/
|
||||||
|
const variantStyles: Record<ButtonVariant, string> = {
|
||||||
|
primary: `
|
||||||
|
bg-primary-600 text-white
|
||||||
|
hover:bg-primary-700
|
||||||
|
focus:ring-primary-500
|
||||||
|
dark:bg-primary-500 dark:hover:bg-primary-600
|
||||||
|
`,
|
||||||
|
secondary: `
|
||||||
|
bg-slate-100 text-slate-900
|
||||||
|
hover:bg-slate-200
|
||||||
|
focus:ring-slate-500
|
||||||
|
dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-600
|
||||||
|
`,
|
||||||
|
outline: `
|
||||||
|
border-2 border-primary-600 text-primary-600
|
||||||
|
hover:bg-primary-50
|
||||||
|
focus:ring-primary-500
|
||||||
|
dark:border-primary-400 dark:text-primary-400 dark:hover:bg-primary-950
|
||||||
|
`,
|
||||||
|
ghost: `
|
||||||
|
text-slate-700
|
||||||
|
hover:bg-slate-100
|
||||||
|
focus:ring-slate-500
|
||||||
|
dark:text-slate-300 dark:hover:bg-slate-800
|
||||||
|
`,
|
||||||
|
danger: `
|
||||||
|
bg-error-600 text-white
|
||||||
|
hover:bg-error-700
|
||||||
|
focus:ring-error-500
|
||||||
|
dark:bg-error-500 dark:hover:bg-error-600
|
||||||
|
`,
|
||||||
|
success: `
|
||||||
|
bg-success-600 text-white
|
||||||
|
hover:bg-success-700
|
||||||
|
focus:ring-success-500
|
||||||
|
dark:bg-success-500 dark:hover:bg-success-600
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estilos por tamaño
|
||||||
|
*/
|
||||||
|
const sizeStyles: Record<ButtonSize, string> = {
|
||||||
|
xs: 'text-xs px-2.5 py-1.5 gap-1',
|
||||||
|
sm: 'text-sm px-3 py-2 gap-1.5',
|
||||||
|
md: 'text-sm px-4 py-2.5 gap-2',
|
||||||
|
lg: 'text-base px-5 py-3 gap-2',
|
||||||
|
xl: 'text-lg px-6 py-3.5 gap-2.5',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente Spinner para loading
|
||||||
|
*/
|
||||||
|
const Spinner: React.FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<svg
|
||||||
|
className={cn('animate-spin', className)}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente Button
|
||||||
|
*
|
||||||
|
* Botón reutilizable con múltiples variantes y tamaños.
|
||||||
|
* Soporta estados de loading, iconos y ancho completo.
|
||||||
|
*/
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
isLoading = false,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
fullWidth = false,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
// Determinar tamaño del spinner
|
||||||
|
const spinnerSize = {
|
||||||
|
xs: 'h-3 w-3',
|
||||||
|
sm: 'h-4 w-4',
|
||||||
|
md: 'h-4 w-4',
|
||||||
|
lg: 'h-5 w-5',
|
||||||
|
xl: 'h-5 w-5',
|
||||||
|
}[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
baseStyles,
|
||||||
|
variantStyles[variant],
|
||||||
|
sizeStyles[size],
|
||||||
|
fullWidth && 'w-full',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Spinner className={spinnerSize} />
|
||||||
|
<span>Cargando...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{leftIcon && <span className="flex-shrink-0">{leftIcon}</span>}
|
||||||
|
{children}
|
||||||
|
{rightIcon && <span className="flex-shrink-0">{rightIcon}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Botón de icono (solo icono, sin texto)
|
||||||
|
*/
|
||||||
|
interface IconButtonProps extends Omit<ButtonProps, 'leftIcon' | 'rightIcon' | 'children'> {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
'aria-label': string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||||
|
({ className, size = 'md', icon, ...props }, ref) => {
|
||||||
|
const iconSizeStyles: Record<ButtonSize, string> = {
|
||||||
|
xs: 'p-1.5',
|
||||||
|
sm: 'p-2',
|
||||||
|
md: 'p-2.5',
|
||||||
|
lg: 'p-3',
|
||||||
|
xl: 'p-3.5',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
className={cn(iconSizeStyles[size], className)}
|
||||||
|
size={size}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
IconButton.displayName = 'IconButton';
|
||||||
|
|
||||||
|
export default Button;
|
||||||
256
apps/web/src/components/ui/Card.tsx
Normal file
256
apps/web/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { forwardRef, HTMLAttributes } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variantes del Card
|
||||||
|
*/
|
||||||
|
type CardVariant = 'default' | 'bordered' | 'elevated' | 'gradient';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props del componente Card
|
||||||
|
*/
|
||||||
|
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
variant?: CardVariant;
|
||||||
|
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||||
|
hoverable?: boolean;
|
||||||
|
clickable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estilos base del card
|
||||||
|
*/
|
||||||
|
const baseStyles = `
|
||||||
|
rounded-xl bg-white
|
||||||
|
dark:bg-slate-800
|
||||||
|
transition-all duration-200
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estilos por variante
|
||||||
|
*/
|
||||||
|
const variantStyles: Record<CardVariant, string> = {
|
||||||
|
default: `
|
||||||
|
border border-slate-200
|
||||||
|
dark:border-slate-700
|
||||||
|
`,
|
||||||
|
bordered: `
|
||||||
|
border-2 border-slate-300
|
||||||
|
dark:border-slate-600
|
||||||
|
`,
|
||||||
|
elevated: `
|
||||||
|
shadow-lg shadow-slate-200/50
|
||||||
|
dark:shadow-slate-900/50
|
||||||
|
border border-slate-100
|
||||||
|
dark:border-slate-700
|
||||||
|
`,
|
||||||
|
gradient: `
|
||||||
|
border border-transparent
|
||||||
|
bg-gradient-to-br from-white to-slate-50
|
||||||
|
dark:from-slate-800 dark:to-slate-900
|
||||||
|
shadow-lg shadow-slate-200/50
|
||||||
|
dark:shadow-slate-900/50
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estilos de padding
|
||||||
|
*/
|
||||||
|
const paddingStyles: Record<string, string> = {
|
||||||
|
none: '',
|
||||||
|
sm: 'p-4',
|
||||||
|
md: 'p-6',
|
||||||
|
lg: 'p-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente Card
|
||||||
|
*
|
||||||
|
* Contenedor reutilizable con múltiples variantes y estados.
|
||||||
|
*/
|
||||||
|
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
padding = 'md',
|
||||||
|
hoverable = false,
|
||||||
|
clickable = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
baseStyles,
|
||||||
|
variantStyles[variant],
|
||||||
|
paddingStyles[padding],
|
||||||
|
hoverable && 'hover:border-primary-300 hover:shadow-md dark:hover:border-primary-600',
|
||||||
|
clickable && 'cursor-pointer active:scale-[0.99]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role={clickable ? 'button' : undefined}
|
||||||
|
tabIndex={clickable ? 0 : undefined}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card Header
|
||||||
|
*/
|
||||||
|
interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
|
||||||
|
({ className, title, subtitle, action, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex items-start justify-between gap-4 mb-4', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{(title || subtitle) ? (
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{title && (
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white truncate">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
{subtitle && (
|
||||||
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
{action && <div className="flex-shrink-0">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card Content
|
||||||
|
*/
|
||||||
|
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card Footer
|
||||||
|
*/
|
||||||
|
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'mt-4 pt-4 border-t border-slate-200 dark:border-slate-700',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stats Card - Card especializado para mostrar estadísticas
|
||||||
|
*/
|
||||||
|
interface StatsCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
change?: {
|
||||||
|
value: number;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
trend?: 'up' | 'down' | 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatsCard = forwardRef<HTMLDivElement, StatsCardProps>(
|
||||||
|
({ className, title, value, change, icon, trend, ...props }, ref) => {
|
||||||
|
const trendColors = {
|
||||||
|
up: 'text-success-600 dark:text-success-400',
|
||||||
|
down: 'text-error-600 dark:text-error-400',
|
||||||
|
neutral: 'text-slate-500 dark:text-slate-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const trendBgColors = {
|
||||||
|
up: 'bg-success-50 dark:bg-success-900/30',
|
||||||
|
down: 'bg-error-50 dark:bg-error-900/30',
|
||||||
|
neutral: 'bg-slate-100 dark:bg-slate-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card ref={ref} className={cn('', className)} {...props}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
{change && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
|
||||||
|
trendBgColors[trend || 'neutral'],
|
||||||
|
trendColors[trend || 'neutral']
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{change.value >= 0 ? '+' : ''}{change.value}%
|
||||||
|
</span>
|
||||||
|
{change.label && (
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{change.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{icon && (
|
||||||
|
<div className="flex-shrink-0 p-3 rounded-lg bg-primary-50 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
StatsCard.displayName = 'StatsCard';
|
||||||
|
|
||||||
|
export default Card;
|
||||||
266
apps/web/src/components/ui/Input.tsx
Normal file
266
apps/web/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { forwardRef, InputHTMLAttributes, useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Eye, EyeOff, AlertCircle, CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tamaños del input
|
||||||
|
*/
|
||||||
|
type InputSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props del componente Input
|
||||||
|
*/
|
||||||
|
interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
success?: boolean;
|
||||||
|
size?: InputSize;
|
||||||
|
leftIcon?: React.ReactNode;
|
||||||
|
rightIcon?: React.ReactNode;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estilos base del input
|
||||||
|
*/
|
||||||
|
const baseStyles = `
|
||||||
|
w-full rounded-lg border
|
||||||
|
transition-all duration-200 ease-in-out
|
||||||
|
focus:outline-none focus:ring-2
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-slate-50
|
||||||
|
dark:disabled:bg-slate-900
|
||||||
|
placeholder:text-slate-400 dark:placeholder:text-slate-500
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estilos por estado
|
||||||
|
*/
|
||||||
|
const stateStyles = {
|
||||||
|
default: `
|
||||||
|
border-slate-300 bg-white text-slate-900
|
||||||
|
hover:border-slate-400
|
||||||
|
focus:border-primary-500 focus:ring-primary-500/20
|
||||||
|
dark:border-slate-600 dark:bg-slate-800 dark:text-white
|
||||||
|
dark:hover:border-slate-500
|
||||||
|
dark:focus:border-primary-400 dark:focus:ring-primary-400/20
|
||||||
|
`,
|
||||||
|
error: `
|
||||||
|
border-error-500 bg-white text-slate-900
|
||||||
|
hover:border-error-600
|
||||||
|
focus:border-error-500 focus:ring-error-500/20
|
||||||
|
dark:border-error-400 dark:bg-slate-800 dark:text-white
|
||||||
|
dark:hover:border-error-300
|
||||||
|
dark:focus:border-error-400 dark:focus:ring-error-400/20
|
||||||
|
`,
|
||||||
|
success: `
|
||||||
|
border-success-500 bg-white text-slate-900
|
||||||
|
hover:border-success-600
|
||||||
|
focus:border-success-500 focus:ring-success-500/20
|
||||||
|
dark:border-success-400 dark:bg-slate-800 dark:text-white
|
||||||
|
dark:hover:border-success-300
|
||||||
|
dark:focus:border-success-400 dark:focus:ring-success-400/20
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estilos por tamaño
|
||||||
|
*/
|
||||||
|
const sizeStyles: Record<InputSize, string> = {
|
||||||
|
sm: 'px-3 py-2 text-sm',
|
||||||
|
md: 'px-4 py-2.5 text-sm',
|
||||||
|
lg: 'px-4 py-3 text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estilos del label
|
||||||
|
*/
|
||||||
|
const labelStyles = `
|
||||||
|
block text-sm font-medium text-slate-700
|
||||||
|
dark:text-slate-200 mb-1.5
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente Input
|
||||||
|
*
|
||||||
|
* Input reutilizable con soporte para label, error, hint,
|
||||||
|
* iconos y diferentes tamaños.
|
||||||
|
*/
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
hint,
|
||||||
|
success,
|
||||||
|
size = 'md',
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
fullWidth = true,
|
||||||
|
type = 'text',
|
||||||
|
id,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
// Determinar el estado actual
|
||||||
|
const state = error ? 'error' : success ? 'success' : 'default';
|
||||||
|
|
||||||
|
// Determinar si es password y toggle visibility
|
||||||
|
const isPassword = type === 'password';
|
||||||
|
const inputType = isPassword && showPassword ? 'text' : type;
|
||||||
|
|
||||||
|
// Calcular padding extra para iconos
|
||||||
|
const paddingLeft = leftIcon ? 'pl-10' : '';
|
||||||
|
const paddingRight = rightIcon || isPassword ? 'pr-10' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(!fullWidth && 'inline-block', fullWidth && 'w-full')}>
|
||||||
|
{/* Label */}
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={inputId} className={labelStyles}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input Container */}
|
||||||
|
<div className="relative">
|
||||||
|
{/* Left Icon */}
|
||||||
|
{leftIcon && (
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500">
|
||||||
|
{leftIcon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
id={inputId}
|
||||||
|
type={inputType}
|
||||||
|
className={cn(
|
||||||
|
baseStyles,
|
||||||
|
stateStyles[state],
|
||||||
|
sizeStyles[size],
|
||||||
|
paddingLeft,
|
||||||
|
paddingRight,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right Icon or Password Toggle or State Icon */}
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||||
|
{isPassword ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300 transition-colors"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : error ? (
|
||||||
|
<AlertCircle className="h-4 w-4 text-error-500" />
|
||||||
|
) : success ? (
|
||||||
|
<CheckCircle className="h-4 w-4 text-success-500" />
|
||||||
|
) : rightIcon ? (
|
||||||
|
<span className="text-slate-400 dark:text-slate-500">{rightIcon}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<p
|
||||||
|
id={`${inputId}-error`}
|
||||||
|
className="mt-1.5 text-sm text-error-600 dark:text-error-400"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hint */}
|
||||||
|
{hint && !error && (
|
||||||
|
<p
|
||||||
|
id={`${inputId}-hint`}
|
||||||
|
className="mt-1.5 text-sm text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente TextArea
|
||||||
|
*/
|
||||||
|
interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||||
|
({ className, label, error, hint, fullWidth = true, id, ...props }, ref) => {
|
||||||
|
const inputId = id || `textarea-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const state = error ? 'error' : 'default';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(!fullWidth && 'inline-block', fullWidth && 'w-full')}>
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={inputId} className={labelStyles}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
id={inputId}
|
||||||
|
className={cn(
|
||||||
|
baseStyles,
|
||||||
|
stateStyles[state],
|
||||||
|
'px-4 py-2.5 text-sm min-h-[100px] resize-y',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-error-600 dark:text-error-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hint && !error && (
|
||||||
|
<p id={`${inputId}-hint`} className="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TextArea.displayName = 'TextArea';
|
||||||
|
|
||||||
|
export default Input;
|
||||||
15
apps/web/src/components/ui/index.ts
Normal file
15
apps/web/src/components/ui/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* UI Components Barrel Export
|
||||||
|
*
|
||||||
|
* Re-exporta todos los componentes UI para facilitar imports.
|
||||||
|
* Ejemplo: import { Button, Input, Card } from '@/components/ui';
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { Button, IconButton } from './Button';
|
||||||
|
export type { } from './Button';
|
||||||
|
|
||||||
|
export { Input, TextArea } from './Input';
|
||||||
|
export type { } from './Input';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardContent, CardFooter, StatsCard } from './Card';
|
||||||
|
export type { } from './Card';
|
||||||
212
apps/web/src/lib/api.ts
Normal file
212
apps/web/src/lib/api.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* Cliente API para comunicación con el backend
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
interface ApiResponse<T = unknown> {
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions extends Omit<RequestInit, 'body'> {
|
||||||
|
body?: unknown;
|
||||||
|
params?: Record<string, string | number | boolean | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
data?: unknown;
|
||||||
|
|
||||||
|
constructor(message: string, status: number, data?: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el token de autenticación del storage
|
||||||
|
*/
|
||||||
|
function getAuthToken(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('horux-auth-storage');
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
return parsed?.state?.token || null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye la URL con query params
|
||||||
|
*/
|
||||||
|
function buildUrl(endpoint: string, params?: Record<string, string | number | boolean | undefined>): string {
|
||||||
|
const url = new URL(endpoint.startsWith('http') ? endpoint : `${API_BASE_URL}${endpoint}`);
|
||||||
|
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
url.searchParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cliente fetch base con manejo de errores y auth
|
||||||
|
*/
|
||||||
|
async function fetchApi<T>(endpoint: string, options: RequestOptions = {}): Promise<ApiResponse<T>> {
|
||||||
|
const { body, params, headers: customHeaders, ...restOptions } = options;
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...customHeaders,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: RequestInit = {
|
||||||
|
...restOptions,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
config.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = buildUrl(endpoint, params);
|
||||||
|
const response = await fetch(url, config);
|
||||||
|
|
||||||
|
let data: T | undefined;
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
data = await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = (data as { message?: string })?.message ||
|
||||||
|
(data as { error?: string })?.error ||
|
||||||
|
`Error ${response.status}`;
|
||||||
|
throw new ApiError(errorMessage, response.status, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
status: response.status,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError(
|
||||||
|
error instanceof Error ? error.message : 'Error de conexión',
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Client con métodos HTTP
|
||||||
|
*/
|
||||||
|
export const api = {
|
||||||
|
get<T>(endpoint: string, options?: RequestOptions): Promise<ApiResponse<T>> {
|
||||||
|
return fetchApi<T>(endpoint, { ...options, method: 'GET' });
|
||||||
|
},
|
||||||
|
|
||||||
|
post<T>(endpoint: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>> {
|
||||||
|
return fetchApi<T>(endpoint, { ...options, method: 'POST', body });
|
||||||
|
},
|
||||||
|
|
||||||
|
put<T>(endpoint: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>> {
|
||||||
|
return fetchApi<T>(endpoint, { ...options, method: 'PUT', body });
|
||||||
|
},
|
||||||
|
|
||||||
|
patch<T>(endpoint: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>> {
|
||||||
|
return fetchApi<T>(endpoint, { ...options, method: 'PATCH', body });
|
||||||
|
},
|
||||||
|
|
||||||
|
delete<T>(endpoint: string, options?: RequestOptions): Promise<ApiResponse<T>> {
|
||||||
|
return fetchApi<T>(endpoint, { ...options, method: 'DELETE' });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoints tipados para la API
|
||||||
|
*/
|
||||||
|
export const endpoints = {
|
||||||
|
// Auth
|
||||||
|
auth: {
|
||||||
|
login: '/auth/login',
|
||||||
|
register: '/auth/register',
|
||||||
|
logout: '/auth/logout',
|
||||||
|
me: '/auth/me',
|
||||||
|
refresh: '/auth/refresh',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Users
|
||||||
|
users: {
|
||||||
|
base: '/users',
|
||||||
|
byId: (id: string) => `/users/${id}`,
|
||||||
|
profile: '/users/profile',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Strategies
|
||||||
|
strategies: {
|
||||||
|
base: '/strategies',
|
||||||
|
byId: (id: string) => `/strategies/${id}`,
|
||||||
|
activate: (id: string) => `/strategies/${id}/activate`,
|
||||||
|
deactivate: (id: string) => `/strategies/${id}/deactivate`,
|
||||||
|
backtest: (id: string) => `/strategies/${id}/backtest`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Trades
|
||||||
|
trades: {
|
||||||
|
base: '/trades',
|
||||||
|
byId: (id: string) => `/trades/${id}`,
|
||||||
|
active: '/trades/active',
|
||||||
|
history: '/trades/history',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Portfolio
|
||||||
|
portfolio: {
|
||||||
|
base: '/portfolio',
|
||||||
|
balance: '/portfolio/balance',
|
||||||
|
positions: '/portfolio/positions',
|
||||||
|
performance: '/portfolio/performance',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Market Data
|
||||||
|
market: {
|
||||||
|
prices: '/market/prices',
|
||||||
|
ticker: (symbol: string) => `/market/ticker/${symbol}`,
|
||||||
|
candles: (symbol: string) => `/market/candles/${symbol}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Analytics
|
||||||
|
analytics: {
|
||||||
|
dashboard: '/analytics/dashboard',
|
||||||
|
performance: '/analytics/performance',
|
||||||
|
risk: '/analytics/risk',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export { ApiError };
|
||||||
|
export type { ApiResponse, RequestOptions };
|
||||||
125
apps/web/src/lib/utils.ts
Normal file
125
apps/web/src/lib/utils.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combina clases de Tailwind de manera inteligente
|
||||||
|
* Usa clsx para condicionales y twMerge para resolver conflictos
|
||||||
|
*/
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea una fecha a string legible
|
||||||
|
*/
|
||||||
|
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
|
return d.toLocaleDateString('es-ES', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea un número como moneda
|
||||||
|
*/
|
||||||
|
export function formatCurrency(amount: number, currency: string = 'USD'): string {
|
||||||
|
return new Intl.NumberFormat('es-ES', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea un número con separadores de miles
|
||||||
|
*/
|
||||||
|
export function formatNumber(num: number, decimals: number = 0): string {
|
||||||
|
return new Intl.NumberFormat('es-ES', {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
}).format(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea un porcentaje
|
||||||
|
*/
|
||||||
|
export function formatPercentage(value: number, decimals: number = 2): string {
|
||||||
|
return `${value >= 0 ? '+' : ''}${value.toFixed(decimals)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera un ID único
|
||||||
|
*/
|
||||||
|
export function generateId(): string {
|
||||||
|
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce function
|
||||||
|
*/
|
||||||
|
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||||
|
func: T,
|
||||||
|
wait: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => func(...args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trunca un texto con ellipsis
|
||||||
|
*/
|
||||||
|
export function truncate(str: string, length: number): string {
|
||||||
|
if (str.length <= length) return str;
|
||||||
|
return str.slice(0, length) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capitaliza la primera letra
|
||||||
|
*/
|
||||||
|
export function capitalize(str: string): string {
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene las iniciales de un nombre
|
||||||
|
*/
|
||||||
|
export function getInitials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si estamos en el cliente
|
||||||
|
*/
|
||||||
|
export const isClient = typeof window !== 'undefined';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si estamos en producción
|
||||||
|
*/
|
||||||
|
export const isProd = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep helper para async/await
|
||||||
|
*/
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp un número entre min y max
|
||||||
|
*/
|
||||||
|
export function clamp(num: number, min: number, max: number): number {
|
||||||
|
return Math.min(Math.max(num, min), max);
|
||||||
|
}
|
||||||
287
apps/web/src/stores/auth.store.ts
Normal file
287
apps/web/src/stores/auth.store.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
import { api, endpoints, ApiError } from '@/lib/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos para el usuario
|
||||||
|
*/
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
avatar?: string;
|
||||||
|
role: 'admin' | 'trader' | 'viewer';
|
||||||
|
createdAt: string;
|
||||||
|
lastLoginAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Credenciales de login
|
||||||
|
*/
|
||||||
|
interface LoginCredentials {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
remember?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datos de registro
|
||||||
|
*/
|
||||||
|
interface RegisterData {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Respuesta de autenticación
|
||||||
|
*/
|
||||||
|
interface AuthResponse {
|
||||||
|
user: User;
|
||||||
|
token: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado del store de autenticación
|
||||||
|
*/
|
||||||
|
interface AuthState {
|
||||||
|
// Estado
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
isInitialized: boolean;
|
||||||
|
|
||||||
|
// Acciones
|
||||||
|
login: (credentials: LoginCredentials) => Promise<void>;
|
||||||
|
register: (data: RegisterData) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
refreshSession: () => Promise<void>;
|
||||||
|
checkAuth: () => Promise<void>;
|
||||||
|
updateUser: (data: Partial<User>) => void;
|
||||||
|
clearError: () => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store de autenticación con persistencia
|
||||||
|
*/
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// Estado inicial
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
isInitialized: false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iniciar sesión
|
||||||
|
*/
|
||||||
|
login: async (credentials: LoginCredentials) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<AuthResponse>(endpoints.auth.login, credentials);
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
set({
|
||||||
|
user: data.user,
|
||||||
|
token: data.token,
|
||||||
|
refreshToken: data.refreshToken || null,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof ApiError
|
||||||
|
? error.message
|
||||||
|
: 'Error al iniciar sesión';
|
||||||
|
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registrar nuevo usuario
|
||||||
|
*/
|
||||||
|
register: async (data: RegisterData) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: authData } = await api.post<AuthResponse>(endpoints.auth.register, data);
|
||||||
|
|
||||||
|
if (authData) {
|
||||||
|
set({
|
||||||
|
user: authData.user,
|
||||||
|
token: authData.token,
|
||||||
|
refreshToken: authData.refreshToken || null,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof ApiError
|
||||||
|
? error.message
|
||||||
|
: 'Error al registrar usuario';
|
||||||
|
|
||||||
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cerrar sesión
|
||||||
|
*/
|
||||||
|
logout: async () => {
|
||||||
|
const { token } = get();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (token) {
|
||||||
|
await api.post(endpoints.auth.logout);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignorar errores de logout
|
||||||
|
} finally {
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refrescar sesión con refresh token
|
||||||
|
*/
|
||||||
|
refreshSession: async () => {
|
||||||
|
const { refreshToken } = get();
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error('No refresh token available');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<AuthResponse>(endpoints.auth.refresh, {
|
||||||
|
refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
set({
|
||||||
|
token: data.token,
|
||||||
|
refreshToken: data.refreshToken || refreshToken,
|
||||||
|
user: data.user,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Si falla el refresh, cerrar sesión
|
||||||
|
get().logout();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar autenticación actual
|
||||||
|
*/
|
||||||
|
checkAuth: async () => {
|
||||||
|
const { token } = get();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
set({ isInitialized: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<User>(endpoints.auth.me);
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
set({
|
||||||
|
user: data,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
isInitialized: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Token inválido, limpiar estado
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
isInitialized: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar datos del usuario
|
||||||
|
*/
|
||||||
|
updateUser: (data: Partial<User>) => {
|
||||||
|
const { user } = get();
|
||||||
|
if (user) {
|
||||||
|
set({ user: { ...user, ...data } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpiar error
|
||||||
|
*/
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Establecer loading
|
||||||
|
*/
|
||||||
|
setLoading: (loading: boolean) => set({ isLoading: loading }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'horux-auth-storage',
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
partialize: (state) => ({
|
||||||
|
user: state.user,
|
||||||
|
token: state.token,
|
||||||
|
refreshToken: state.refreshToken,
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selector para verificar si el usuario es admin
|
||||||
|
*/
|
||||||
|
export const selectIsAdmin = (state: AuthState): boolean =>
|
||||||
|
state.user?.role === 'admin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selector para verificar si el usuario puede hacer trading
|
||||||
|
*/
|
||||||
|
export const selectCanTrade = (state: AuthState): boolean =>
|
||||||
|
state.user?.role === 'admin' || state.user?.role === 'trader';
|
||||||
297
apps/web/src/stores/ui.store.ts
Normal file
297
apps/web/src/stores/ui.store.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de tema
|
||||||
|
*/
|
||||||
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de notificación
|
||||||
|
*/
|
||||||
|
interface Notification {
|
||||||
|
id: string;
|
||||||
|
type: 'success' | 'error' | 'warning' | 'info';
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
duration?: number;
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal state
|
||||||
|
*/
|
||||||
|
interface ModalState {
|
||||||
|
isOpen: boolean;
|
||||||
|
type: string | null;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado del store de UI
|
||||||
|
*/
|
||||||
|
interface UIState {
|
||||||
|
// Sidebar
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
sidebarCollapsed: boolean;
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
theme: Theme;
|
||||||
|
resolvedTheme: 'light' | 'dark';
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
notifications: Notification[];
|
||||||
|
|
||||||
|
// Modal
|
||||||
|
modal: ModalState;
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
globalLoading: boolean;
|
||||||
|
loadingMessage: string | null;
|
||||||
|
|
||||||
|
// Mobile
|
||||||
|
isMobile: boolean;
|
||||||
|
|
||||||
|
// Acciones Sidebar
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
setSidebarOpen: (open: boolean) => void;
|
||||||
|
toggleSidebarCollapsed: () => void;
|
||||||
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||||
|
|
||||||
|
// Acciones Theme
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
|
||||||
|
// Acciones Notifications
|
||||||
|
addNotification: (notification: Omit<Notification, 'id'>) => void;
|
||||||
|
removeNotification: (id: string) => void;
|
||||||
|
clearNotifications: () => void;
|
||||||
|
|
||||||
|
// Acciones Modal
|
||||||
|
openModal: (type: string, data?: unknown) => void;
|
||||||
|
closeModal: () => void;
|
||||||
|
|
||||||
|
// Acciones Loading
|
||||||
|
setGlobalLoading: (loading: boolean, message?: string) => void;
|
||||||
|
|
||||||
|
// Acciones Mobile
|
||||||
|
setIsMobile: (isMobile: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera un ID único para notificaciones
|
||||||
|
*/
|
||||||
|
function generateNotificationId(): string {
|
||||||
|
return `notification-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecta el tema del sistema
|
||||||
|
*/
|
||||||
|
function getSystemTheme(): 'light' | 'dark' {
|
||||||
|
if (typeof window === 'undefined') return 'dark';
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store de UI con persistencia parcial
|
||||||
|
*/
|
||||||
|
export const useUIStore = create<UIState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// Estado inicial
|
||||||
|
sidebarOpen: true,
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
theme: 'dark',
|
||||||
|
resolvedTheme: 'dark',
|
||||||
|
notifications: [],
|
||||||
|
modal: {
|
||||||
|
isOpen: false,
|
||||||
|
type: null,
|
||||||
|
data: undefined,
|
||||||
|
},
|
||||||
|
globalLoading: false,
|
||||||
|
loadingMessage: null,
|
||||||
|
isMobile: false,
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
toggleSidebar: () => {
|
||||||
|
set((state) => ({ sidebarOpen: !state.sidebarOpen }));
|
||||||
|
},
|
||||||
|
|
||||||
|
setSidebarOpen: (open: boolean) => {
|
||||||
|
set({ sidebarOpen: open });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleSidebarCollapsed: () => {
|
||||||
|
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed }));
|
||||||
|
},
|
||||||
|
|
||||||
|
setSidebarCollapsed: (collapsed: boolean) => {
|
||||||
|
set({ sidebarCollapsed: collapsed });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
setTheme: (theme: Theme) => {
|
||||||
|
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
||||||
|
|
||||||
|
// Aplicar clase al documento
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
|
document.documentElement.classList.add(resolvedTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ theme, resolvedTheme });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleTheme: () => {
|
||||||
|
const { theme } = get();
|
||||||
|
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
||||||
|
get().setTheme(newTheme);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
addNotification: (notification) => {
|
||||||
|
const id = generateNotificationId();
|
||||||
|
const newNotification: Notification = {
|
||||||
|
...notification,
|
||||||
|
id,
|
||||||
|
duration: notification.duration ?? 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
notifications: [...state.notifications, newNotification],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Auto-remove after duration
|
||||||
|
if (newNotification.duration && newNotification.duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
get().removeNotification(id);
|
||||||
|
}, newNotification.duration);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeNotification: (id: string) => {
|
||||||
|
set((state) => ({
|
||||||
|
notifications: state.notifications.filter((n) => n.id !== id),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
clearNotifications: () => {
|
||||||
|
set({ notifications: [] });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Modal
|
||||||
|
openModal: (type: string, data?: unknown) => {
|
||||||
|
set({
|
||||||
|
modal: {
|
||||||
|
isOpen: true,
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
closeModal: () => {
|
||||||
|
set({
|
||||||
|
modal: {
|
||||||
|
isOpen: false,
|
||||||
|
type: null,
|
||||||
|
data: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
setGlobalLoading: (loading: boolean, message?: string) => {
|
||||||
|
set({
|
||||||
|
globalLoading: loading,
|
||||||
|
loadingMessage: loading ? (message || null) : null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mobile
|
||||||
|
setIsMobile: (isMobile: boolean) => {
|
||||||
|
set({ isMobile });
|
||||||
|
|
||||||
|
// Cerrar sidebar en mobile
|
||||||
|
if (isMobile) {
|
||||||
|
set({ sidebarOpen: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'horux-ui-storage',
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
partialize: (state) => ({
|
||||||
|
theme: state.theme,
|
||||||
|
sidebarCollapsed: state.sidebarCollapsed,
|
||||||
|
}),
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
// Aplicar tema al rehidratar
|
||||||
|
if (state) {
|
||||||
|
const resolvedTheme = state.theme === 'system' ? getSystemTheme() : state.theme;
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
|
document.documentElement.classList.add(resolvedTheme);
|
||||||
|
}
|
||||||
|
state.resolvedTheme = resolvedTheme;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook helper para notificaciones
|
||||||
|
*/
|
||||||
|
export const useNotifications = () => {
|
||||||
|
const { addNotification, removeNotification, clearNotifications, notifications } = useUIStore();
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications,
|
||||||
|
notify: addNotification,
|
||||||
|
remove: removeNotification,
|
||||||
|
clear: clearNotifications,
|
||||||
|
success: (title: string, message?: string) =>
|
||||||
|
addNotification({ type: 'success', title, message }),
|
||||||
|
error: (title: string, message?: string) =>
|
||||||
|
addNotification({ type: 'error', title, message }),
|
||||||
|
warning: (title: string, message?: string) =>
|
||||||
|
addNotification({ type: 'warning', title, message }),
|
||||||
|
info: (title: string, message?: string) =>
|
||||||
|
addNotification({ type: 'info', title, message }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook helper para modales
|
||||||
|
*/
|
||||||
|
export const useModal = () => {
|
||||||
|
const { modal, openModal, closeModal } = useUIStore();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...modal,
|
||||||
|
open: openModal,
|
||||||
|
close: closeModal,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook helper para el tema
|
||||||
|
*/
|
||||||
|
export const useTheme = () => {
|
||||||
|
const { theme, resolvedTheme, setTheme, toggleTheme } = useUIStore();
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
resolvedTheme,
|
||||||
|
setTheme,
|
||||||
|
toggleTheme,
|
||||||
|
isDark: resolvedTheme === 'dark',
|
||||||
|
isLight: resolvedTheme === 'light',
|
||||||
|
};
|
||||||
|
};
|
||||||
152
apps/web/tailwind.config.js
Normal file
152
apps/web/tailwind.config.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: 'class',
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// Horux Brand Colors
|
||||||
|
horux: {
|
||||||
|
50: '#f0f7ff',
|
||||||
|
100: '#e0effe',
|
||||||
|
200: '#b9dffd',
|
||||||
|
300: '#7cc5fb',
|
||||||
|
400: '#36a7f7',
|
||||||
|
500: '#0c8ce8',
|
||||||
|
600: '#006fc6',
|
||||||
|
700: '#0159a1',
|
||||||
|
800: '#064c85',
|
||||||
|
900: '#0b406e',
|
||||||
|
950: '#072849',
|
||||||
|
},
|
||||||
|
// Primary (usando horux como primario)
|
||||||
|
primary: {
|
||||||
|
50: '#f0f7ff',
|
||||||
|
100: '#e0effe',
|
||||||
|
200: '#b9dffd',
|
||||||
|
300: '#7cc5fb',
|
||||||
|
400: '#36a7f7',
|
||||||
|
500: '#0c8ce8',
|
||||||
|
600: '#006fc6',
|
||||||
|
700: '#0159a1',
|
||||||
|
800: '#064c85',
|
||||||
|
900: '#0b406e',
|
||||||
|
950: '#072849',
|
||||||
|
},
|
||||||
|
// Grises personalizados
|
||||||
|
slate: {
|
||||||
|
850: '#172033',
|
||||||
|
950: '#0a0f1a',
|
||||||
|
},
|
||||||
|
// Estados
|
||||||
|
success: {
|
||||||
|
50: '#ecfdf5',
|
||||||
|
100: '#d1fae5',
|
||||||
|
200: '#a7f3d0',
|
||||||
|
300: '#6ee7b7',
|
||||||
|
400: '#34d399',
|
||||||
|
500: '#10b981',
|
||||||
|
600: '#059669',
|
||||||
|
700: '#047857',
|
||||||
|
800: '#065f46',
|
||||||
|
900: '#064e3b',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
50: '#fffbeb',
|
||||||
|
100: '#fef3c7',
|
||||||
|
200: '#fde68a',
|
||||||
|
300: '#fcd34d',
|
||||||
|
400: '#fbbf24',
|
||||||
|
500: '#f59e0b',
|
||||||
|
600: '#d97706',
|
||||||
|
700: '#b45309',
|
||||||
|
800: '#92400e',
|
||||||
|
900: '#78350f',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
50: '#fef2f2',
|
||||||
|
100: '#fee2e2',
|
||||||
|
200: '#fecaca',
|
||||||
|
300: '#fca5a5',
|
||||||
|
400: '#f87171',
|
||||||
|
500: '#ef4444',
|
||||||
|
600: '#dc2626',
|
||||||
|
700: '#b91c1c',
|
||||||
|
800: '#991b1b',
|
||||||
|
900: '#7f1d1d',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
'2xs': ['0.625rem', { lineHeight: '0.875rem' }],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
'glow': '0 0 20px rgba(12, 140, 232, 0.3)',
|
||||||
|
'glow-lg': '0 0 40px rgba(12, 140, 232, 0.4)',
|
||||||
|
'inner-glow': 'inset 0 0 20px rgba(12, 140, 232, 0.1)',
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||||
|
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||||
|
'horux-gradient': 'linear-gradient(135deg, #0c8ce8 0%, #006fc6 50%, #0159a1 100%)',
|
||||||
|
'horux-gradient-dark': 'linear-gradient(135deg, #072849 0%, #0b406e 50%, #064c85 100%)',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.3s ease-in-out',
|
||||||
|
'fade-out': 'fadeOut 0.3s ease-in-out',
|
||||||
|
'slide-in': 'slideIn 0.3s ease-out',
|
||||||
|
'slide-out': 'slideOut 0.3s ease-in',
|
||||||
|
'scale-in': 'scaleIn 0.2s ease-out',
|
||||||
|
'spin-slow': 'spin 2s linear infinite',
|
||||||
|
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
|
'bounce-slow': 'bounce 2s infinite',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
fadeOut: {
|
||||||
|
'0%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0' },
|
||||||
|
},
|
||||||
|
slideIn: {
|
||||||
|
'0%': { transform: 'translateX(-100%)' },
|
||||||
|
'100%': { transform: 'translateX(0)' },
|
||||||
|
},
|
||||||
|
slideOut: {
|
||||||
|
'0%': { transform: 'translateX(0)' },
|
||||||
|
'100%': { transform: 'translateX(-100%)' },
|
||||||
|
},
|
||||||
|
scaleIn: {
|
||||||
|
'0%': { transform: 'scale(0.95)', opacity: '0' },
|
||||||
|
'100%': { transform: 'scale(1)', opacity: '1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
'4xl': '2rem',
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
'18': '4.5rem',
|
||||||
|
'88': '22rem',
|
||||||
|
'112': '28rem',
|
||||||
|
'128': '32rem',
|
||||||
|
},
|
||||||
|
zIndex: {
|
||||||
|
'60': '60',
|
||||||
|
'70': '70',
|
||||||
|
'80': '80',
|
||||||
|
'90': '90',
|
||||||
|
'100': '100',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
39
apps/web/tsconfig.json
Normal file
39
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@/components/*": ["./src/components/*"],
|
||||||
|
"@/lib/*": ["./src/lib/*"],
|
||||||
|
"@/stores/*": ["./src/stores/*"],
|
||||||
|
"@/hooks/*": ["./src/hooks/*"],
|
||||||
|
"@/types/*": ["./src/types/*"]
|
||||||
|
},
|
||||||
|
"target": "ES2017",
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"baseUrl": "."
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
145
docker-compose.yml
Normal file
145
docker-compose.yml
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Horux Strategy - Docker Compose para Desarrollo
|
||||||
|
# =============================================================================
|
||||||
|
# Este archivo define los servicios de infraestructura necesarios para el
|
||||||
|
# desarrollo local del proyecto Horux Strategy.
|
||||||
|
#
|
||||||
|
# Uso:
|
||||||
|
# docker-compose up -d # Levantar todos los servicios
|
||||||
|
# docker-compose down # Detener todos los servicios
|
||||||
|
# docker-compose logs -f # Ver logs en tiempo real
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PostgreSQL - Base de datos principal
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: horux-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-horux}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-horux_secret_2024}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-horux_strategy}
|
||||||
|
# Configuracion para mejor rendimiento en desarrollo
|
||||||
|
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=en_US.utf8 --lc-ctype=en_US.utf8"
|
||||||
|
volumes:
|
||||||
|
# Persistencia de datos
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
# Script de inicializacion
|
||||||
|
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-horux} -d ${POSTGRES_DB:-horux_strategy}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- horux-network
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Redis - Cache y colas de trabajo (BullMQ)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: horux-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
|
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- horux-network
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MinIO - Almacenamiento de objetos (compatible con S3)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Usado para almacenar reportes, adjuntos y exportaciones
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: horux-minio
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${MINIO_API_PORT:-9000}:9000" # API S3
|
||||||
|
- "${MINIO_CONSOLE_PORT:-9001}:9001" # Consola web
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-horux_minio}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-horux_minio_secret}
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 20s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- horux-network
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MinIO Setup - Inicializacion de buckets
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Este servicio se ejecuta una vez para crear los buckets necesarios
|
||||||
|
minio-setup:
|
||||||
|
image: minio/mc:latest
|
||||||
|
container_name: horux-minio-setup
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: /bin/sh
|
||||||
|
command: /scripts/init.sh
|
||||||
|
volumes:
|
||||||
|
- ./docker/minio/init.sh:/scripts/init.sh:ro
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-horux_minio}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-horux_minio_secret}
|
||||||
|
networks:
|
||||||
|
- horux-network
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mailhog - Servidor SMTP para testing de emails
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Captura todos los emails enviados y los muestra en una interfaz web
|
||||||
|
# NO envia emails reales - perfecto para desarrollo
|
||||||
|
mailhog:
|
||||||
|
image: mailhog/mailhog:latest
|
||||||
|
container_name: horux-mailhog
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${MAILHOG_SMTP_PORT:-1025}:1025" # SMTP
|
||||||
|
- "${MAILHOG_UI_PORT:-8025}:8025" # Interfaz web
|
||||||
|
networks:
|
||||||
|
- horux-network
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Volumenes persistentes
|
||||||
|
# =============================================================================
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
name: horux_postgres_data
|
||||||
|
redis_data:
|
||||||
|
driver: local
|
||||||
|
name: horux_redis_data
|
||||||
|
minio_data:
|
||||||
|
driver: local
|
||||||
|
name: horux_minio_data
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Red interna
|
||||||
|
# =============================================================================
|
||||||
|
networks:
|
||||||
|
horux-network:
|
||||||
|
driver: bridge
|
||||||
|
name: horux-network
|
||||||
82
docker/minio/init.sh
Normal file
82
docker/minio/init.sh
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# =============================================================================
|
||||||
|
# Horux Strategy - Script de Inicializacion MinIO
|
||||||
|
# =============================================================================
|
||||||
|
# Este script crea los buckets necesarios para el almacenamiento de archivos
|
||||||
|
# en MinIO (compatible con S3).
|
||||||
|
#
|
||||||
|
# Buckets creados:
|
||||||
|
# - horux-reports: Reportes financieros generados (PDF, Excel)
|
||||||
|
# - horux-attachments: Adjuntos de documentos (facturas, comprobantes)
|
||||||
|
# - horux-exports: Exportaciones de datos (CSV, JSON)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "============================================="
|
||||||
|
echo "Horux Strategy - Inicializando MinIO"
|
||||||
|
echo "============================================="
|
||||||
|
|
||||||
|
# Esperar a que MinIO este completamente listo
|
||||||
|
echo "[1/5] Esperando a que MinIO este listo..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Configurar el cliente mc para conectarse a MinIO
|
||||||
|
echo "[2/5] Configurando cliente MinIO..."
|
||||||
|
mc alias set horux http://minio:9000 "${MINIO_ROOT_USER}" "${MINIO_ROOT_PASSWORD}"
|
||||||
|
|
||||||
|
# Verificar conexion
|
||||||
|
echo "[3/5] Verificando conexion..."
|
||||||
|
mc admin info horux
|
||||||
|
|
||||||
|
# Crear buckets
|
||||||
|
echo "[4/5] Creando buckets..."
|
||||||
|
|
||||||
|
# Bucket para reportes financieros
|
||||||
|
# Almacena: PDFs de estados financieros, reportes de KPIs, dashboards exportados
|
||||||
|
if ! mc ls horux/horux-reports > /dev/null 2>&1; then
|
||||||
|
mc mb horux/horux-reports
|
||||||
|
echo " - Bucket 'horux-reports' creado"
|
||||||
|
else
|
||||||
|
echo " - Bucket 'horux-reports' ya existe"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bucket para adjuntos de documentos
|
||||||
|
# Almacena: Facturas escaneadas, XMLs del SAT, comprobantes bancarios
|
||||||
|
if ! mc ls horux/horux-attachments > /dev/null 2>&1; then
|
||||||
|
mc mb horux/horux-attachments
|
||||||
|
echo " - Bucket 'horux-attachments' creado"
|
||||||
|
else
|
||||||
|
echo " - Bucket 'horux-attachments' ya existe"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bucket para exportaciones de datos
|
||||||
|
# Almacena: Exports de datos en CSV/JSON, backups de configuracion
|
||||||
|
if ! mc ls horux/horux-exports > /dev/null 2>&1; then
|
||||||
|
mc mb horux/horux-exports
|
||||||
|
echo " - Bucket 'horux-exports' creado"
|
||||||
|
else
|
||||||
|
echo " - Bucket 'horux-exports' ya existe"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configurar politicas de acceso (opcional)
|
||||||
|
echo "[5/5] Configurando politicas de acceso..."
|
||||||
|
|
||||||
|
# Politica de retencion para reportes (30 dias minimo)
|
||||||
|
# mc ilm add horux/horux-reports --expire-days 365
|
||||||
|
|
||||||
|
# Los attachments pueden ser de solo lectura para usuarios
|
||||||
|
# mc anonymous set download horux/horux-attachments
|
||||||
|
|
||||||
|
echo "============================================="
|
||||||
|
echo "MinIO inicializado correctamente!"
|
||||||
|
echo "============================================="
|
||||||
|
echo "Buckets disponibles:"
|
||||||
|
mc ls horux
|
||||||
|
echo "============================================="
|
||||||
|
echo ""
|
||||||
|
echo "Acceso a la consola web:"
|
||||||
|
echo " URL: http://localhost:9001"
|
||||||
|
echo " Usuario: ${MINIO_ROOT_USER}"
|
||||||
|
echo " Password: ${MINIO_ROOT_PASSWORD}"
|
||||||
|
echo "============================================="
|
||||||
170
docker/postgres/init.sql
Normal file
170
docker/postgres/init.sql
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Horux Strategy - Script de Inicializacion PostgreSQL
|
||||||
|
-- =============================================================================
|
||||||
|
-- Este script se ejecuta automaticamente cuando el contenedor de PostgreSQL
|
||||||
|
-- se inicia por primera vez.
|
||||||
|
--
|
||||||
|
-- Crea:
|
||||||
|
-- - Base de datos horux_strategy
|
||||||
|
-- - Usuario de aplicacion con permisos limitados
|
||||||
|
-- - Extensiones necesarias
|
||||||
|
-- - Esquemas base
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Crear base de datos si no existe (la variable POSTGRES_DB ya la crea)
|
||||||
|
-- Este bloque es por si se necesita una base de datos adicional
|
||||||
|
-- CREATE DATABASE horux_strategy_test;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Extensiones necesarias
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- UUID: Generacion de identificadores unicos universales
|
||||||
|
-- Usado para IDs de entidades (usuarios, empresas, transacciones, etc.)
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- pgcrypto: Funciones criptograficas
|
||||||
|
-- Usado para hash de passwords, generacion de tokens seguros
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
-- pg_trgm: Busqueda por trigramas
|
||||||
|
-- Mejora las busquedas de texto parcial (LIKE, ILIKE)
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||||
|
|
||||||
|
-- btree_gin: Indices GIN para tipos de datos adicionales
|
||||||
|
-- Mejora consultas con multiples condiciones en indices compuestos
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "btree_gin";
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Usuario de aplicacion
|
||||||
|
-- =============================================================================
|
||||||
|
-- Creamos un usuario con permisos limitados para la aplicacion
|
||||||
|
-- Esto sigue el principio de menor privilegio
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Crear usuario de aplicacion si no existe
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'horux_app') THEN
|
||||||
|
CREATE USER horux_app WITH PASSWORD 'horux_app_secret';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Permisos en la base de datos
|
||||||
|
GRANT CONNECT ON DATABASE horux_strategy TO horux_app;
|
||||||
|
|
||||||
|
-- Permisos en el esquema public
|
||||||
|
GRANT USAGE ON SCHEMA public TO horux_app;
|
||||||
|
GRANT CREATE ON SCHEMA public TO horux_app;
|
||||||
|
|
||||||
|
-- Permisos por defecto para tablas futuras
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO horux_app;
|
||||||
|
|
||||||
|
-- Permisos por defecto para secuencias futuras
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCES TO horux_app;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Esquemas adicionales (opcional)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Esquema para datos de auditoria
|
||||||
|
CREATE SCHEMA IF NOT EXISTS audit;
|
||||||
|
GRANT USAGE ON SCHEMA audit TO horux_app;
|
||||||
|
GRANT CREATE ON SCHEMA audit TO horux_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA audit
|
||||||
|
GRANT SELECT, INSERT ON TABLES TO horux_app;
|
||||||
|
|
||||||
|
-- Esquema para reportes y vistas materializadas
|
||||||
|
CREATE SCHEMA IF NOT EXISTS reports;
|
||||||
|
GRANT USAGE ON SCHEMA reports TO horux_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA reports
|
||||||
|
GRANT SELECT ON TABLES TO horux_app;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Funciones de utilidad
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Funcion para generar UUIDs v4
|
||||||
|
CREATE OR REPLACE FUNCTION generate_uuid()
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN uuid_generate_v4();
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para actualizar timestamps automaticamente
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para registrar cambios en auditoria
|
||||||
|
CREATE OR REPLACE FUNCTION audit.log_changes()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
audit_table_name TEXT;
|
||||||
|
BEGIN
|
||||||
|
audit_table_name := TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME || '_audit';
|
||||||
|
|
||||||
|
IF TG_OP = 'DELETE' THEN
|
||||||
|
EXECUTE format(
|
||||||
|
'INSERT INTO audit.changes_log (table_name, operation, old_data, changed_at) VALUES ($1, $2, $3, CURRENT_TIMESTAMP)'
|
||||||
|
) USING TG_TABLE_NAME, TG_OP, row_to_json(OLD);
|
||||||
|
RETURN OLD;
|
||||||
|
ELSIF TG_OP = 'UPDATE' THEN
|
||||||
|
EXECUTE format(
|
||||||
|
'INSERT INTO audit.changes_log (table_name, operation, old_data, new_data, changed_at) VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)'
|
||||||
|
) USING TG_TABLE_NAME, TG_OP, row_to_json(OLD), row_to_json(NEW);
|
||||||
|
RETURN NEW;
|
||||||
|
ELSIF TG_OP = 'INSERT' THEN
|
||||||
|
EXECUTE format(
|
||||||
|
'INSERT INTO audit.changes_log (table_name, operation, new_data, changed_at) VALUES ($1, $2, $3, CURRENT_TIMESTAMP)'
|
||||||
|
) USING TG_TABLE_NAME, TG_OP, row_to_json(NEW);
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Tabla para log de cambios de auditoria
|
||||||
|
CREATE TABLE IF NOT EXISTS audit.changes_log (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
table_name VARCHAR(100) NOT NULL,
|
||||||
|
operation VARCHAR(10) NOT NULL,
|
||||||
|
old_data JSONB,
|
||||||
|
new_data JSONB,
|
||||||
|
changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
changed_by UUID -- Se puede llenar con el usuario de la sesion
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indice para busquedas rapidas por tabla y fecha
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_changes_table_date
|
||||||
|
ON audit.changes_log (table_name, changed_at DESC);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Mensaje de confirmacion
|
||||||
|
-- =============================================================================
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
RAISE NOTICE '===========================================';
|
||||||
|
RAISE NOTICE 'Horux Strategy - Base de datos inicializada';
|
||||||
|
RAISE NOTICE '===========================================';
|
||||||
|
RAISE NOTICE 'Extensiones instaladas:';
|
||||||
|
RAISE NOTICE ' - uuid-ossp';
|
||||||
|
RAISE NOTICE ' - pgcrypto';
|
||||||
|
RAISE NOTICE ' - pg_trgm';
|
||||||
|
RAISE NOTICE ' - btree_gin';
|
||||||
|
RAISE NOTICE 'Usuarios creados:';
|
||||||
|
RAISE NOTICE ' - horux_app (usuario de aplicacion)';
|
||||||
|
RAISE NOTICE 'Esquemas creados:';
|
||||||
|
RAISE NOTICE ' - public (tablas principales)';
|
||||||
|
RAISE NOTICE ' - audit (auditoria de cambios)';
|
||||||
|
RAISE NOTICE ' - reports (reportes y vistas)';
|
||||||
|
RAISE NOTICE '===========================================';
|
||||||
|
END
|
||||||
|
$$;
|
||||||
31
packages/database/package.json
Normal file
31
packages/database/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@horux/database",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Database schemas and migrations for Horux Strategy",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsc --watch",
|
||||||
|
"migrate": "node dist/migrate.js",
|
||||||
|
"migrate:dev": "tsx src/migrate.ts",
|
||||||
|
"seed": "tsx src/seed.ts",
|
||||||
|
"studio": "echo 'DB Studio not configured'",
|
||||||
|
"lint": "eslint src --ext .ts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"clean": "rm -rf dist node_modules"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@horux/shared": "workspace:*",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"dotenv": "^16.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"@types/pg": "^8.10.9",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
406
packages/database/src/connection.ts
Normal file
406
packages/database/src/connection.ts
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
/**
|
||||||
|
* PostgreSQL Connection Pool with Multi-Tenant Support
|
||||||
|
*
|
||||||
|
* Implements a connection pool manager that supports schema-based multi-tenancy.
|
||||||
|
* Each tenant has their own schema, and connections can be scoped to a specific tenant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool, PoolClient, PoolConfig, QueryResult, QueryResultRow } from 'pg';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
// Database configuration interface
|
||||||
|
export interface DatabaseConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
database: string;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
ssl?: boolean | { rejectUnauthorized: boolean };
|
||||||
|
max?: number;
|
||||||
|
idleTimeoutMillis?: number;
|
||||||
|
connectionTimeoutMillis?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant context for queries
|
||||||
|
export interface TenantContext {
|
||||||
|
tenantId: string;
|
||||||
|
schemaName: string;
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query options
|
||||||
|
export interface QueryOptions {
|
||||||
|
tenant?: TenantContext;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection pool events
|
||||||
|
export interface PoolEvents {
|
||||||
|
connect: (client: PoolClient) => void;
|
||||||
|
acquire: (client: PoolClient) => void;
|
||||||
|
release: (client: PoolClient) => void;
|
||||||
|
error: (error: Error, client: PoolClient) => void;
|
||||||
|
remove: (client: PoolClient) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton pool instance
|
||||||
|
let globalPool: Pool | null = null;
|
||||||
|
|
||||||
|
// Pool statistics
|
||||||
|
interface PoolStats {
|
||||||
|
totalConnections: number;
|
||||||
|
idleConnections: number;
|
||||||
|
waitingRequests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database connection manager with multi-tenant support
|
||||||
|
*/
|
||||||
|
export class DatabaseConnection extends EventEmitter {
|
||||||
|
private pool: Pool;
|
||||||
|
private config: DatabaseConfig;
|
||||||
|
private isConnected: boolean = false;
|
||||||
|
|
||||||
|
constructor(config?: DatabaseConfig) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.config = config || this.getConfigFromEnv();
|
||||||
|
|
||||||
|
const poolConfig: PoolConfig = {
|
||||||
|
host: this.config.host,
|
||||||
|
port: this.config.port,
|
||||||
|
database: this.config.database,
|
||||||
|
user: this.config.user,
|
||||||
|
password: this.config.password,
|
||||||
|
ssl: this.config.ssl,
|
||||||
|
max: this.config.max || 20,
|
||||||
|
idleTimeoutMillis: this.config.idleTimeoutMillis || 30000,
|
||||||
|
connectionTimeoutMillis: this.config.connectionTimeoutMillis || 10000,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pool = new Pool(poolConfig);
|
||||||
|
this.setupPoolEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get configuration from environment variables
|
||||||
|
*/
|
||||||
|
private getConfigFromEnv(): DatabaseConfig {
|
||||||
|
return {
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||||
|
database: process.env.DB_NAME || 'horux_strategy',
|
||||||
|
user: process.env.DB_USER || 'postgres',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
ssl: process.env.DB_SSL === 'true'
|
||||||
|
? { rejectUnauthorized: process.env.DB_SSL_REJECT_UNAUTHORIZED !== 'false' }
|
||||||
|
: false,
|
||||||
|
max: parseInt(process.env.DB_POOL_MAX || '20', 10),
|
||||||
|
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
|
||||||
|
connectionTimeoutMillis: parseInt(process.env.DB_CONNECTION_TIMEOUT || '10000', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup pool event handlers
|
||||||
|
*/
|
||||||
|
private setupPoolEvents(): void {
|
||||||
|
this.pool.on('connect', (client) => {
|
||||||
|
this.isConnected = true;
|
||||||
|
this.emit('connect', client);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pool.on('acquire', (client) => {
|
||||||
|
this.emit('acquire', client);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pool.on('release', (client) => {
|
||||||
|
this.emit('release', client);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pool.on('error', (err, client) => {
|
||||||
|
console.error('Unexpected pool error:', err);
|
||||||
|
this.emit('error', err, client);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pool.on('remove', (client) => {
|
||||||
|
this.emit('remove', client);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test database connection
|
||||||
|
*/
|
||||||
|
async connect(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
await client.query('SELECT 1');
|
||||||
|
client.release();
|
||||||
|
this.isConnected = true;
|
||||||
|
console.log('Database connection established successfully');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect to database:', error);
|
||||||
|
this.isConnected = false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close all connections in the pool
|
||||||
|
*/
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
await this.pool.end();
|
||||||
|
this.isConnected = false;
|
||||||
|
console.log('Database connection pool closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pool statistics
|
||||||
|
*/
|
||||||
|
getStats(): PoolStats {
|
||||||
|
return {
|
||||||
|
totalConnections: this.pool.totalCount,
|
||||||
|
idleConnections: this.pool.idleCount,
|
||||||
|
waitingRequests: this.pool.waitingCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if connected
|
||||||
|
*/
|
||||||
|
isPoolConnected(): boolean {
|
||||||
|
return this.isConnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query with optional tenant context
|
||||||
|
*/
|
||||||
|
async query<T extends QueryResultRow = QueryResultRow>(
|
||||||
|
text: string,
|
||||||
|
params?: unknown[],
|
||||||
|
options?: QueryOptions
|
||||||
|
): Promise<QueryResult<T>> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set search path for tenant if provided
|
||||||
|
if (options?.tenant) {
|
||||||
|
await client.query(`SET search_path TO ${this.escapeIdentifier(options.tenant.schemaName)}, public`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set query timeout if provided
|
||||||
|
if (options?.timeout) {
|
||||||
|
await client.query(`SET statement_timeout = ${options.timeout}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await client.query<T>(text, params);
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
// Reset search path and timeout before releasing
|
||||||
|
if (options?.tenant || options?.timeout) {
|
||||||
|
await client.query('RESET search_path; RESET statement_timeout;').catch(() => {});
|
||||||
|
}
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query in the public schema
|
||||||
|
*/
|
||||||
|
async queryPublic<T extends QueryResultRow = QueryResultRow>(
|
||||||
|
text: string,
|
||||||
|
params?: unknown[]
|
||||||
|
): Promise<QueryResult<T>> {
|
||||||
|
return this.query<T>(text, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query in a tenant schema
|
||||||
|
*/
|
||||||
|
async queryTenant<T extends QueryResultRow = QueryResultRow>(
|
||||||
|
tenant: TenantContext,
|
||||||
|
text: string,
|
||||||
|
params?: unknown[]
|
||||||
|
): Promise<QueryResult<T>> {
|
||||||
|
return this.query<T>(text, params, { tenant });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a client for transaction handling
|
||||||
|
*/
|
||||||
|
async getClient(): Promise<PoolClient> {
|
||||||
|
return this.pool.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a function within a transaction
|
||||||
|
*/
|
||||||
|
async transaction<T>(
|
||||||
|
fn: (client: PoolClient) => Promise<T>,
|
||||||
|
options?: QueryOptions
|
||||||
|
): Promise<T> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set search path for tenant if provided
|
||||||
|
if (options?.tenant) {
|
||||||
|
await client.query(`SET search_path TO ${this.escapeIdentifier(options.tenant.schemaName)}, public`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fn(client);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (options?.tenant) {
|
||||||
|
await client.query('RESET search_path').catch(() => {});
|
||||||
|
}
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a function within a tenant transaction
|
||||||
|
*/
|
||||||
|
async tenantTransaction<T>(
|
||||||
|
tenant: TenantContext,
|
||||||
|
fn: (client: PoolClient) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
return this.transaction(fn, { tenant });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape an identifier (schema name, table name, etc.)
|
||||||
|
*/
|
||||||
|
private escapeIdentifier(identifier: string): string {
|
||||||
|
// Validate identifier format (alphanumeric and underscores only)
|
||||||
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(identifier)) {
|
||||||
|
throw new Error(`Invalid identifier: ${identifier}`);
|
||||||
|
}
|
||||||
|
return `"${identifier}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a schema exists
|
||||||
|
*/
|
||||||
|
async schemaExists(schemaName: string): Promise<boolean> {
|
||||||
|
const result = await this.query<{ exists: boolean }>(
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = $1) as exists`,
|
||||||
|
[schemaName]
|
||||||
|
);
|
||||||
|
return result.rows[0]?.exists ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of all tenant schemas
|
||||||
|
*/
|
||||||
|
async getTenantSchemas(): Promise<string[]> {
|
||||||
|
const result = await this.query<{ schema_name: string }>(
|
||||||
|
`SELECT schema_name FROM information_schema.schemata
|
||||||
|
WHERE schema_name LIKE 'tenant_%'
|
||||||
|
ORDER BY schema_name`
|
||||||
|
);
|
||||||
|
return result.rows.map(row => row.schema_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the underlying pool (use with caution)
|
||||||
|
*/
|
||||||
|
getPool(): Pool {
|
||||||
|
return this.pool;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the global database connection
|
||||||
|
*/
|
||||||
|
export function getDatabase(config?: DatabaseConfig): DatabaseConnection {
|
||||||
|
if (!globalPool) {
|
||||||
|
const connection = new DatabaseConnection(config);
|
||||||
|
globalPool = connection.getPool();
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DatabaseConnection(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new database connection (non-singleton)
|
||||||
|
*/
|
||||||
|
export function createDatabase(config?: DatabaseConfig): DatabaseConnection {
|
||||||
|
return new DatabaseConnection(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant-scoped database client
|
||||||
|
* Provides a simplified interface for tenant-specific operations
|
||||||
|
*/
|
||||||
|
export class TenantDatabase {
|
||||||
|
private db: DatabaseConnection;
|
||||||
|
private tenant: TenantContext;
|
||||||
|
|
||||||
|
constructor(db: DatabaseConnection, tenant: TenantContext) {
|
||||||
|
this.db = db;
|
||||||
|
this.tenant = tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query in the tenant schema
|
||||||
|
*/
|
||||||
|
async query<T extends QueryResultRow = QueryResultRow>(
|
||||||
|
text: string,
|
||||||
|
params?: unknown[]
|
||||||
|
): Promise<QueryResult<T>> {
|
||||||
|
return this.db.queryTenant<T>(this.tenant, text, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a transaction in the tenant schema
|
||||||
|
*/
|
||||||
|
async transaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||||
|
return this.db.tenantTransaction(this.tenant, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tenant context
|
||||||
|
*/
|
||||||
|
getContext(): TenantContext {
|
||||||
|
return this.tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query the public schema (for cross-tenant operations)
|
||||||
|
*/
|
||||||
|
async queryPublic<T extends QueryResultRow = QueryResultRow>(
|
||||||
|
text: string,
|
||||||
|
params?: unknown[]
|
||||||
|
): Promise<QueryResult<T>> {
|
||||||
|
return this.db.queryPublic<T>(text, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a tenant-scoped database client
|
||||||
|
*/
|
||||||
|
export function createTenantDatabase(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
tenantId: string,
|
||||||
|
userId?: string
|
||||||
|
): TenantDatabase {
|
||||||
|
const tenant: TenantContext = {
|
||||||
|
tenantId,
|
||||||
|
schemaName: `tenant_${tenantId}`,
|
||||||
|
userId,
|
||||||
|
};
|
||||||
|
return new TenantDatabase(db, tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export types
|
||||||
|
export type { Pool, PoolClient, QueryResult, QueryResultRow };
|
||||||
676
packages/database/src/index.ts
Normal file
676
packages/database/src/index.ts
Normal file
@@ -0,0 +1,676 @@
|
|||||||
|
/**
|
||||||
|
* @horux/database
|
||||||
|
*
|
||||||
|
* Database package for Horux Strategy - CFO Digital para Empresas Mexicanas
|
||||||
|
*
|
||||||
|
* Provides:
|
||||||
|
* - PostgreSQL connection pool with multi-tenant support
|
||||||
|
* - Tenant schema management (create, delete, suspend)
|
||||||
|
* - Migration utilities
|
||||||
|
* - Type definitions for database entities
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Connection management
|
||||||
|
export {
|
||||||
|
DatabaseConnection,
|
||||||
|
TenantDatabase,
|
||||||
|
getDatabase,
|
||||||
|
createDatabase,
|
||||||
|
createTenantDatabase,
|
||||||
|
type DatabaseConfig,
|
||||||
|
type TenantContext,
|
||||||
|
type QueryOptions,
|
||||||
|
type Pool,
|
||||||
|
type PoolClient,
|
||||||
|
type QueryResult,
|
||||||
|
type QueryResultRow,
|
||||||
|
} from './connection.js';
|
||||||
|
|
||||||
|
// Tenant management
|
||||||
|
export {
|
||||||
|
createTenantSchema,
|
||||||
|
deleteTenantSchema,
|
||||||
|
suspendTenant,
|
||||||
|
reactivateTenant,
|
||||||
|
getTenant,
|
||||||
|
getTenantBySlug,
|
||||||
|
listTenants,
|
||||||
|
updateTenantSettings,
|
||||||
|
validateTenantAccess,
|
||||||
|
getSchemaName,
|
||||||
|
createTenantContext,
|
||||||
|
type CreateTenantOptions,
|
||||||
|
type TenantSettings,
|
||||||
|
type TenantInfo,
|
||||||
|
type TenantStatus,
|
||||||
|
} from './tenant.js';
|
||||||
|
|
||||||
|
// Migration utilities (for programmatic use)
|
||||||
|
export {
|
||||||
|
runMigrations,
|
||||||
|
printStatus as getMigrationStatus,
|
||||||
|
rollbackLast as rollbackMigration,
|
||||||
|
ensureDatabase,
|
||||||
|
loadMigrationFiles,
|
||||||
|
getExecutedMigrations,
|
||||||
|
ensureMigrationsTable,
|
||||||
|
type MigrationFile,
|
||||||
|
type MigrationRecord,
|
||||||
|
} from './migrate.js';
|
||||||
|
|
||||||
|
// Seed data exports
|
||||||
|
export {
|
||||||
|
PLANS,
|
||||||
|
SYSTEM_SETTINGS,
|
||||||
|
} from './seed.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Type Definitions for Database Entities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// User roles
|
||||||
|
export type UserRole = 'super_admin' | 'owner' | 'admin' | 'manager' | 'analyst' | 'viewer';
|
||||||
|
|
||||||
|
// Subscription status
|
||||||
|
export type SubscriptionStatus = 'active' | 'trial' | 'past_due' | 'cancelled' | 'suspended' | 'expired';
|
||||||
|
|
||||||
|
// Job status
|
||||||
|
export type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||||
|
|
||||||
|
// Transaction types
|
||||||
|
export type TransactionType = 'income' | 'expense' | 'transfer' | 'adjustment';
|
||||||
|
export type TransactionStatus = 'pending' | 'confirmed' | 'reconciled' | 'voided';
|
||||||
|
|
||||||
|
// CFDI types
|
||||||
|
export type CfdiStatus = 'active' | 'cancelled' | 'pending_cancellation';
|
||||||
|
export type CfdiType = 'I' | 'E' | 'T' | 'N' | 'P'; // Ingreso, Egreso, Traslado, Nomina, Pago
|
||||||
|
|
||||||
|
// Contact types
|
||||||
|
export type ContactType = 'customer' | 'supplier' | 'both' | 'employee';
|
||||||
|
|
||||||
|
// Category types
|
||||||
|
export type CategoryType = 'income' | 'expense' | 'cost' | 'other';
|
||||||
|
|
||||||
|
// Account types
|
||||||
|
export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense';
|
||||||
|
|
||||||
|
// Alert severity
|
||||||
|
export type AlertSeverity = 'info' | 'warning' | 'critical';
|
||||||
|
|
||||||
|
// Report status
|
||||||
|
export type ReportStatus = 'draft' | 'generating' | 'completed' | 'failed' | 'archived';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Entity Interfaces
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan entity
|
||||||
|
*/
|
||||||
|
export interface Plan {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
priceMonthly: number;
|
||||||
|
priceYearly: number;
|
||||||
|
maxUsers: number;
|
||||||
|
maxCfdisMonthly: number;
|
||||||
|
maxStorageMb: number;
|
||||||
|
maxApiCallsDaily: number;
|
||||||
|
maxReportsMonthly: number;
|
||||||
|
features: Record<string, boolean>;
|
||||||
|
hasSatSync: boolean;
|
||||||
|
hasBankSync: boolean;
|
||||||
|
hasAiInsights: boolean;
|
||||||
|
hasCustomReports: boolean;
|
||||||
|
hasApiAccess: boolean;
|
||||||
|
hasWhiteLabel: boolean;
|
||||||
|
hasPrioritySupport: boolean;
|
||||||
|
hasDedicatedAccountManager: boolean;
|
||||||
|
dataRetentionMonths: number;
|
||||||
|
displayOrder: number;
|
||||||
|
isActive: boolean;
|
||||||
|
isPopular: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User entity
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
defaultRole: UserRole;
|
||||||
|
isActive: boolean;
|
||||||
|
isEmailVerified: boolean;
|
||||||
|
emailVerifiedAt: Date | null;
|
||||||
|
twoFactorEnabled: boolean;
|
||||||
|
preferences: Record<string, unknown>;
|
||||||
|
timezone: string;
|
||||||
|
locale: string;
|
||||||
|
lastLoginAt: Date | null;
|
||||||
|
lastLoginIp: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant entity
|
||||||
|
*/
|
||||||
|
export interface Tenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
schemaName: string;
|
||||||
|
rfc: string | null;
|
||||||
|
razonSocial: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
ownerId: string;
|
||||||
|
planId: string;
|
||||||
|
status: TenantStatus;
|
||||||
|
settings: TenantSettings;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription entity
|
||||||
|
*/
|
||||||
|
export interface Subscription {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
planId: string;
|
||||||
|
status: SubscriptionStatus;
|
||||||
|
billingCycle: 'monthly' | 'yearly';
|
||||||
|
trialEndsAt: Date | null;
|
||||||
|
currentPeriodStart: Date;
|
||||||
|
currentPeriodEnd: Date;
|
||||||
|
cancelledAt: Date | null;
|
||||||
|
cancelAtPeriodEnd: boolean;
|
||||||
|
paymentProcessor: string | null;
|
||||||
|
externalSubscriptionId: string | null;
|
||||||
|
externalCustomerId: string | null;
|
||||||
|
priceCents: number;
|
||||||
|
currency: string;
|
||||||
|
usageCfdisCurrent: number;
|
||||||
|
usageStorageMbCurrent: number;
|
||||||
|
usageApiCallsCurrent: number;
|
||||||
|
usageResetAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User session entity
|
||||||
|
*/
|
||||||
|
export interface UserSession {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
tenantId: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
ipAddress: string | null;
|
||||||
|
deviceType: string | null;
|
||||||
|
deviceName: string | null;
|
||||||
|
locationCity: string | null;
|
||||||
|
locationCountry: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
expiresAt: Date;
|
||||||
|
refreshExpiresAt: Date | null;
|
||||||
|
lastActivityAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
revokedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background job entity
|
||||||
|
*/
|
||||||
|
export interface BackgroundJob {
|
||||||
|
id: string;
|
||||||
|
tenantId: string | null;
|
||||||
|
userId: string | null;
|
||||||
|
jobType: string;
|
||||||
|
jobName: string | null;
|
||||||
|
queue: string;
|
||||||
|
priority: number;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
status: JobStatus;
|
||||||
|
progress: number;
|
||||||
|
result: Record<string, unknown> | null;
|
||||||
|
errorMessage: string | null;
|
||||||
|
errorStack: string | null;
|
||||||
|
attempts: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
scheduledAt: Date;
|
||||||
|
startedAt: Date | null;
|
||||||
|
completedAt: Date | null;
|
||||||
|
timeoutSeconds: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API key entity
|
||||||
|
*/
|
||||||
|
export interface ApiKey {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
keyPrefix: string;
|
||||||
|
scopes: string[];
|
||||||
|
allowedIps: string[] | null;
|
||||||
|
allowedOrigins: string[] | null;
|
||||||
|
rateLimitPerMinute: number;
|
||||||
|
rateLimitPerDay: number;
|
||||||
|
lastUsedAt: Date | null;
|
||||||
|
usageCount: number;
|
||||||
|
isActive: boolean;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
createdBy: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
revokedAt: Date | null;
|
||||||
|
revokedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit log entry entity
|
||||||
|
*/
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
id: string;
|
||||||
|
tenantId: string | null;
|
||||||
|
userId: string | null;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string | null;
|
||||||
|
oldValues: Record<string, unknown> | null;
|
||||||
|
newValues: Record<string, unknown> | null;
|
||||||
|
details: Record<string, unknown> | null;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
requestId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tenant Schema Entity Interfaces
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SAT credentials entity (tenant schema)
|
||||||
|
*/
|
||||||
|
export interface SatCredentials {
|
||||||
|
id: string;
|
||||||
|
rfc: string;
|
||||||
|
cerSerialNumber: string | null;
|
||||||
|
cerIssuedAt: Date | null;
|
||||||
|
cerExpiresAt: Date | null;
|
||||||
|
cerIssuer: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
isValid: boolean;
|
||||||
|
lastValidatedAt: Date | null;
|
||||||
|
validationError: string | null;
|
||||||
|
syncEnabled: boolean;
|
||||||
|
syncFrequencyHours: number;
|
||||||
|
lastSyncAt: Date | null;
|
||||||
|
lastSyncStatus: string | null;
|
||||||
|
lastSyncError: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CFDI entity (tenant schema)
|
||||||
|
*/
|
||||||
|
export interface Cfdi {
|
||||||
|
id: string;
|
||||||
|
uuidFiscal: string;
|
||||||
|
serie: string | null;
|
||||||
|
folio: string | null;
|
||||||
|
tipoComprobante: CfdiType;
|
||||||
|
status: CfdiStatus;
|
||||||
|
fechaEmision: Date;
|
||||||
|
fechaTimbrado: Date | null;
|
||||||
|
fechaCancelacion: Date | null;
|
||||||
|
emisorRfc: string;
|
||||||
|
emisorNombre: string;
|
||||||
|
emisorRegimenFiscal: string;
|
||||||
|
receptorRfc: string;
|
||||||
|
receptorNombre: string;
|
||||||
|
receptorRegimenFiscal: string | null;
|
||||||
|
receptorDomicilioFiscal: string | null;
|
||||||
|
receptorUsoCfdi: string;
|
||||||
|
subtotal: number;
|
||||||
|
descuento: number;
|
||||||
|
total: number;
|
||||||
|
totalImpuestosTrasladados: number;
|
||||||
|
totalImpuestosRetenidos: number;
|
||||||
|
iva16: number;
|
||||||
|
iva8: number;
|
||||||
|
iva0: number;
|
||||||
|
ivaExento: number;
|
||||||
|
isrRetenido: number;
|
||||||
|
ivaRetenido: number;
|
||||||
|
moneda: string;
|
||||||
|
tipoCambio: number;
|
||||||
|
formaPago: string | null;
|
||||||
|
metodoPago: string | null;
|
||||||
|
condicionesPago: string | null;
|
||||||
|
cfdiRelacionados: Record<string, unknown> | null;
|
||||||
|
tipoRelacion: string | null;
|
||||||
|
conceptos: Record<string, unknown>[];
|
||||||
|
isEmitted: boolean;
|
||||||
|
categoryId: string | null;
|
||||||
|
contactId: string | null;
|
||||||
|
isReconciled: boolean;
|
||||||
|
reconciledAt: Date | null;
|
||||||
|
reconciledBy: string | null;
|
||||||
|
aiCategorySuggestion: string | null;
|
||||||
|
aiConfidenceScore: number | null;
|
||||||
|
source: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction entity (tenant schema)
|
||||||
|
*/
|
||||||
|
export interface Transaction {
|
||||||
|
id: string;
|
||||||
|
type: TransactionType;
|
||||||
|
status: TransactionStatus;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
exchangeRate: number;
|
||||||
|
amountMxn: number;
|
||||||
|
transactionDate: Date;
|
||||||
|
valueDate: Date | null;
|
||||||
|
recordedAt: Date;
|
||||||
|
description: string | null;
|
||||||
|
reference: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
categoryId: string | null;
|
||||||
|
accountId: string | null;
|
||||||
|
contactId: string | null;
|
||||||
|
cfdiId: string | null;
|
||||||
|
bankTransactionId: string | null;
|
||||||
|
bankAccountId: string | null;
|
||||||
|
bankDescription: string | null;
|
||||||
|
isRecurring: boolean;
|
||||||
|
recurringPattern: Record<string, unknown> | null;
|
||||||
|
parentTransactionId: string | null;
|
||||||
|
attachments: Record<string, unknown>[] | null;
|
||||||
|
tags: string[] | null;
|
||||||
|
isReconciled: boolean;
|
||||||
|
reconciledAt: Date | null;
|
||||||
|
reconciledBy: string | null;
|
||||||
|
requiresApproval: boolean;
|
||||||
|
approvedAt: Date | null;
|
||||||
|
approvedBy: string | null;
|
||||||
|
aiCategoryId: string | null;
|
||||||
|
aiConfidence: number | null;
|
||||||
|
aiNotes: string | null;
|
||||||
|
createdBy: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
voidedAt: Date | null;
|
||||||
|
voidedBy: string | null;
|
||||||
|
voidReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact entity (tenant schema)
|
||||||
|
*/
|
||||||
|
export interface Contact {
|
||||||
|
id: string;
|
||||||
|
type: ContactType;
|
||||||
|
name: string;
|
||||||
|
tradeName: string | null;
|
||||||
|
rfc: string | null;
|
||||||
|
regimenFiscal: string | null;
|
||||||
|
usoCfdiDefault: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
mobile: string | null;
|
||||||
|
website: string | null;
|
||||||
|
addressStreet: string | null;
|
||||||
|
addressInterior: string | null;
|
||||||
|
addressExterior: string | null;
|
||||||
|
addressNeighborhood: string | null;
|
||||||
|
addressCity: string | null;
|
||||||
|
addressMunicipality: string | null;
|
||||||
|
addressState: string | null;
|
||||||
|
addressZip: string | null;
|
||||||
|
addressCountry: string;
|
||||||
|
bankName: string | null;
|
||||||
|
bankAccount: string | null;
|
||||||
|
bankClabe: string | null;
|
||||||
|
creditDays: number;
|
||||||
|
creditLimit: number;
|
||||||
|
balanceReceivable: number;
|
||||||
|
balancePayable: number;
|
||||||
|
category: string | null;
|
||||||
|
tags: string[] | null;
|
||||||
|
isActive: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
createdBy: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category entity (tenant schema)
|
||||||
|
*/
|
||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
type: CategoryType;
|
||||||
|
parentId: string | null;
|
||||||
|
level: number;
|
||||||
|
path: string | null;
|
||||||
|
satKey: string | null;
|
||||||
|
budgetMonthly: number | null;
|
||||||
|
budgetYearly: number | null;
|
||||||
|
color: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
displayOrder: number;
|
||||||
|
isActive: boolean;
|
||||||
|
isSystem: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account entity (tenant schema)
|
||||||
|
*/
|
||||||
|
export interface Account {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
type: AccountType;
|
||||||
|
parentId: string | null;
|
||||||
|
level: number;
|
||||||
|
path: string | null;
|
||||||
|
satCode: string | null;
|
||||||
|
satNature: 'D' | 'A' | null;
|
||||||
|
balanceDebit: number;
|
||||||
|
balanceCredit: number;
|
||||||
|
balanceCurrent: number;
|
||||||
|
isActive: boolean;
|
||||||
|
isSystem: boolean;
|
||||||
|
allowsMovements: boolean;
|
||||||
|
displayOrder: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alert entity (tenant schema)
|
||||||
|
*/
|
||||||
|
export interface Alert {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
severity: AlertSeverity;
|
||||||
|
entityType: string | null;
|
||||||
|
entityId: string | null;
|
||||||
|
thresholdType: string | null;
|
||||||
|
thresholdValue: number | null;
|
||||||
|
currentValue: number | null;
|
||||||
|
actionUrl: string | null;
|
||||||
|
actionLabel: string | null;
|
||||||
|
actionData: Record<string, unknown> | null;
|
||||||
|
isRead: boolean;
|
||||||
|
isDismissed: boolean;
|
||||||
|
readAt: Date | null;
|
||||||
|
dismissedAt: Date | null;
|
||||||
|
dismissedBy: string | null;
|
||||||
|
isRecurring: boolean;
|
||||||
|
lastTriggeredAt: Date | null;
|
||||||
|
triggerCount: number;
|
||||||
|
autoResolved: boolean;
|
||||||
|
resolvedAt: Date | null;
|
||||||
|
resolvedBy: string | null;
|
||||||
|
resolutionNotes: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report entity (tenant schema)
|
||||||
|
*/
|
||||||
|
export interface Report {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
periodStart: Date;
|
||||||
|
periodEnd: Date;
|
||||||
|
comparisonPeriodStart: Date | null;
|
||||||
|
comparisonPeriodEnd: Date | null;
|
||||||
|
status: ReportStatus;
|
||||||
|
parameters: Record<string, unknown> | null;
|
||||||
|
data: Record<string, unknown> | null;
|
||||||
|
fileUrl: string | null;
|
||||||
|
fileFormat: string | null;
|
||||||
|
isScheduled: boolean;
|
||||||
|
scheduleCron: string | null;
|
||||||
|
nextScheduledAt: Date | null;
|
||||||
|
lastGeneratedAt: Date | null;
|
||||||
|
isShared: boolean;
|
||||||
|
sharedWith: string[] | null;
|
||||||
|
shareToken: string | null;
|
||||||
|
shareExpiresAt: Date | null;
|
||||||
|
generatedBy: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metric cache entry entity (tenant schema)
|
||||||
|
*/
|
||||||
|
export interface MetricCache {
|
||||||
|
id: string;
|
||||||
|
metricKey: string;
|
||||||
|
periodType: 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||||
|
periodStart: Date;
|
||||||
|
periodEnd: Date;
|
||||||
|
dimensionType: string | null;
|
||||||
|
dimensionId: string | null;
|
||||||
|
valueNumeric: number | null;
|
||||||
|
valueJson: Record<string, unknown> | null;
|
||||||
|
previousValue: number | null;
|
||||||
|
changePercent: number | null;
|
||||||
|
changeAbsolute: number | null;
|
||||||
|
computedAt: Date;
|
||||||
|
validUntil: Date | null;
|
||||||
|
isStale: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setting entity (tenant schema)
|
||||||
|
*/
|
||||||
|
export interface Setting {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
valueType: 'string' | 'integer' | 'boolean' | 'json';
|
||||||
|
category: string;
|
||||||
|
label: string | null;
|
||||||
|
description: string | null;
|
||||||
|
isSensitive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bank account entity (tenant schema)
|
||||||
|
*/
|
||||||
|
export interface BankAccount {
|
||||||
|
id: string;
|
||||||
|
bankName: string;
|
||||||
|
bankCode: string | null;
|
||||||
|
accountNumber: string | null;
|
||||||
|
clabe: string | null;
|
||||||
|
accountType: string | null;
|
||||||
|
alias: string | null;
|
||||||
|
currency: string;
|
||||||
|
balanceAvailable: number | null;
|
||||||
|
balanceCurrent: number | null;
|
||||||
|
balanceUpdatedAt: Date | null;
|
||||||
|
connectionProvider: string | null;
|
||||||
|
connectionId: string | null;
|
||||||
|
connectionStatus: string | null;
|
||||||
|
lastSyncAt: Date | null;
|
||||||
|
lastSyncError: string | null;
|
||||||
|
accountId: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Budget item entity (tenant schema)
|
||||||
|
*/
|
||||||
|
export interface BudgetItem {
|
||||||
|
id: string;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
categoryId: string | null;
|
||||||
|
accountId: string | null;
|
||||||
|
amountBudgeted: number;
|
||||||
|
amountActual: number;
|
||||||
|
amountVariance: number;
|
||||||
|
notes: string | null;
|
||||||
|
isLocked: boolean;
|
||||||
|
createdBy: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attachment entity (tenant schema)
|
||||||
|
*/
|
||||||
|
export interface Attachment {
|
||||||
|
id: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
fileName: string;
|
||||||
|
fileType: string | null;
|
||||||
|
fileSize: number | null;
|
||||||
|
fileUrl: string;
|
||||||
|
storageProvider: string;
|
||||||
|
storagePath: string | null;
|
||||||
|
uploadedBy: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
462
packages/database/src/migrate.ts
Normal file
462
packages/database/src/migrate.ts
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
/**
|
||||||
|
* Database Migration Script
|
||||||
|
*
|
||||||
|
* Executes SQL migrations against the database.
|
||||||
|
* Supports both public schema and tenant schema migrations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, readdirSync, existsSync } from 'fs';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { Pool, PoolClient } from 'pg';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
// Get directory path for ES modules
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// Migration tracking table
|
||||||
|
const MIGRATIONS_TABLE = '_migrations';
|
||||||
|
|
||||||
|
// Database configuration
|
||||||
|
interface DatabaseConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
database: string;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
ssl?: boolean | { rejectUnauthorized: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration record
|
||||||
|
interface MigrationRecord {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
executed_at: Date;
|
||||||
|
checksum: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration file
|
||||||
|
interface MigrationFile {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
checksum: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get database configuration from environment
|
||||||
|
*/
|
||||||
|
function getConfig(): DatabaseConfig {
|
||||||
|
return {
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||||
|
database: process.env.DB_NAME || 'horux_strategy',
|
||||||
|
user: process.env.DB_USER || 'postgres',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
ssl: process.env.DB_SSL === 'true'
|
||||||
|
? { rejectUnauthorized: process.env.DB_SSL_REJECT_UNAUTHORIZED !== 'false' }
|
||||||
|
: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate simple checksum for migration content
|
||||||
|
*/
|
||||||
|
function calculateChecksum(content: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < content.length; i++) {
|
||||||
|
const char = content.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + char;
|
||||||
|
hash = hash & hash; // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
return Math.abs(hash).toString(16).padStart(8, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create migrations tracking table if it doesn't exist
|
||||||
|
*/
|
||||||
|
async function ensureMigrationsTable(client: PoolClient): Promise<void> {
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
executed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
checksum VARCHAR(20) NOT NULL,
|
||||||
|
execution_time_ms INTEGER
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of executed migrations
|
||||||
|
*/
|
||||||
|
async function getExecutedMigrations(client: PoolClient): Promise<MigrationRecord[]> {
|
||||||
|
const result = await client.query<MigrationRecord>(
|
||||||
|
`SELECT id, name, executed_at, checksum FROM ${MIGRATIONS_TABLE} ORDER BY id`
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load migration files from directory
|
||||||
|
*/
|
||||||
|
function loadMigrationFiles(migrationsDir: string): MigrationFile[] {
|
||||||
|
if (!existsSync(migrationsDir)) {
|
||||||
|
console.log(`Migrations directory not found: ${migrationsDir}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = readdirSync(migrationsDir)
|
||||||
|
.filter(f => f.endsWith('.sql'))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
return files.map(file => {
|
||||||
|
const filePath = join(migrationsDir, file);
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
return {
|
||||||
|
name: file,
|
||||||
|
path: filePath,
|
||||||
|
content,
|
||||||
|
checksum: calculateChecksum(content),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a single migration
|
||||||
|
*/
|
||||||
|
async function executeMigration(
|
||||||
|
client: PoolClient,
|
||||||
|
migration: MigrationFile
|
||||||
|
): Promise<number> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
console.log(` Executing: ${migration.name}`);
|
||||||
|
|
||||||
|
// Execute the migration SQL
|
||||||
|
await client.query(migration.content);
|
||||||
|
|
||||||
|
// Record the migration
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO ${MIGRATIONS_TABLE} (name, checksum, execution_time_ms)
|
||||||
|
VALUES ($1, $2, $3)`,
|
||||||
|
[migration.name, migration.checksum, Date.now() - startTime]
|
||||||
|
);
|
||||||
|
|
||||||
|
const executionTime = Date.now() - startTime;
|
||||||
|
console.log(` Completed: ${migration.name} (${executionTime}ms)`);
|
||||||
|
|
||||||
|
return executionTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run pending migrations
|
||||||
|
*/
|
||||||
|
async function runMigrations(
|
||||||
|
pool: Pool,
|
||||||
|
migrationsDir: string,
|
||||||
|
options: { force?: boolean; dryRun?: boolean } = {}
|
||||||
|
): Promise<{ executed: string[]; skipped: string[]; errors: string[] }> {
|
||||||
|
const result = {
|
||||||
|
executed: [] as string[],
|
||||||
|
skipped: [] as string[],
|
||||||
|
errors: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure migrations table exists
|
||||||
|
await ensureMigrationsTable(client);
|
||||||
|
|
||||||
|
// Get executed migrations
|
||||||
|
const executed = await getExecutedMigrations(client);
|
||||||
|
const executedNames = new Set(executed.map(m => m.name));
|
||||||
|
const executedChecksums = new Map(executed.map(m => [m.name, m.checksum]));
|
||||||
|
|
||||||
|
// Load migration files
|
||||||
|
const migrations = loadMigrationFiles(migrationsDir);
|
||||||
|
|
||||||
|
if (migrations.length === 0) {
|
||||||
|
console.log('No migration files found.');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${migrations.length} migration file(s)`);
|
||||||
|
|
||||||
|
// Check for checksum mismatches
|
||||||
|
for (const migration of migrations) {
|
||||||
|
if (executedNames.has(migration.name)) {
|
||||||
|
const previousChecksum = executedChecksums.get(migration.name);
|
||||||
|
if (previousChecksum !== migration.checksum && !options.force) {
|
||||||
|
const errorMsg = `Checksum mismatch for ${migration.name}. Migration was modified after execution.`;
|
||||||
|
console.error(`ERROR: ${errorMsg}`);
|
||||||
|
result.errors.push(errorMsg);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute pending migrations
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const migration of migrations) {
|
||||||
|
if (executedNames.has(migration.name)) {
|
||||||
|
console.log(` Skipping: ${migration.name} (already executed)`);
|
||||||
|
result.skipped.push(migration.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.dryRun) {
|
||||||
|
console.log(` Would execute: ${migration.name}`);
|
||||||
|
result.executed.push(migration.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeMigration(client, migration);
|
||||||
|
result.executed.push(migration.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.dryRun) {
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} else {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.log('\nDry run complete. No changes were made.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print migration status
|
||||||
|
*/
|
||||||
|
async function printStatus(pool: Pool, migrationsDir: string): Promise<void> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureMigrationsTable(client);
|
||||||
|
|
||||||
|
const executed = await getExecutedMigrations(client);
|
||||||
|
const executedNames = new Set(executed.map(m => m.name));
|
||||||
|
|
||||||
|
const migrations = loadMigrationFiles(migrationsDir);
|
||||||
|
|
||||||
|
console.log('\nMigration Status:');
|
||||||
|
console.log('=================\n');
|
||||||
|
|
||||||
|
if (migrations.length === 0) {
|
||||||
|
console.log('No migration files found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const migration of migrations) {
|
||||||
|
const status = executedNames.has(migration.name) ? '[x]' : '[ ]';
|
||||||
|
const executedRecord = executed.find(e => e.name === migration.name);
|
||||||
|
const date = executedRecord
|
||||||
|
? executedRecord.executed_at.toISOString()
|
||||||
|
: 'pending';
|
||||||
|
console.log(`${status} ${migration.name} - ${date}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = migrations.filter(m => !executedNames.has(m.name));
|
||||||
|
console.log(`\nTotal: ${migrations.length}, Executed: ${executed.length}, Pending: ${pending.length}`);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback last migration
|
||||||
|
*/
|
||||||
|
async function rollbackLast(pool: Pool): Promise<void> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureMigrationsTable(client);
|
||||||
|
|
||||||
|
const result = await client.query<MigrationRecord>(
|
||||||
|
`SELECT id, name FROM ${MIGRATIONS_TABLE} ORDER BY id DESC LIMIT 1`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
console.log('No migrations to rollback.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastMigration = result.rows[0];
|
||||||
|
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete migration record
|
||||||
|
await client.query(`DELETE FROM ${MIGRATIONS_TABLE} WHERE id = $1`, [lastMigration.id]);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
console.log(`Rolled back: ${lastMigration.name}`);
|
||||||
|
console.log('\nNote: The SQL changes were NOT reversed. You need to manually undo the schema changes.');
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create database if it doesn't exist
|
||||||
|
*/
|
||||||
|
async function ensureDatabase(config: DatabaseConfig): Promise<void> {
|
||||||
|
// Connect to postgres database to create the target database
|
||||||
|
const adminPool = new Pool({
|
||||||
|
...config,
|
||||||
|
database: 'postgres',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await adminPool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if database exists
|
||||||
|
const result = await client.query(
|
||||||
|
'SELECT 1 FROM pg_database WHERE datname = $1',
|
||||||
|
[config.database]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
// Create database
|
||||||
|
console.log(`Creating database: ${config.database}`);
|
||||||
|
await client.query(`CREATE DATABASE "${config.database}"`);
|
||||||
|
console.log(`Database created: ${config.database}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await adminPool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main CLI
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const command = args[0] || 'up';
|
||||||
|
|
||||||
|
const config = getConfig();
|
||||||
|
const migrationsDir = join(__dirname, 'migrations');
|
||||||
|
|
||||||
|
console.log(`\nHorux Strategy - Database Migration Tool`);
|
||||||
|
console.log(`========================================`);
|
||||||
|
console.log(`Database: ${config.database}@${config.host}:${config.port}`);
|
||||||
|
console.log(`Migrations: ${migrationsDir}\n`);
|
||||||
|
|
||||||
|
// Ensure database exists
|
||||||
|
if (command !== 'status') {
|
||||||
|
try {
|
||||||
|
await ensureDatabase(config);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error ensuring database exists:', error);
|
||||||
|
// Continue anyway, might not have permission to create DB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool(config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (command) {
|
||||||
|
case 'up':
|
||||||
|
case 'migrate': {
|
||||||
|
const dryRun = args.includes('--dry-run');
|
||||||
|
const force = args.includes('--force');
|
||||||
|
|
||||||
|
console.log('Running migrations...');
|
||||||
|
if (dryRun) console.log('(Dry run mode)\n');
|
||||||
|
|
||||||
|
const result = await runMigrations(pool, migrationsDir, { dryRun, force });
|
||||||
|
|
||||||
|
console.log('\nSummary:');
|
||||||
|
console.log(` Executed: ${result.executed.length}`);
|
||||||
|
console.log(` Skipped: ${result.skipped.length}`);
|
||||||
|
console.log(` Errors: ${result.errors.length}`);
|
||||||
|
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'status': {
|
||||||
|
await printStatus(pool, migrationsDir);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'rollback': {
|
||||||
|
console.log('Rolling back last migration...\n');
|
||||||
|
await rollbackLast(pool);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'help':
|
||||||
|
default: {
|
||||||
|
console.log(`
|
||||||
|
Usage: migrate [command] [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
up, migrate Run pending migrations (default)
|
||||||
|
status Show migration status
|
||||||
|
rollback Rollback the last migration record
|
||||||
|
help Show this help message
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--dry-run Show what would be executed without making changes
|
||||||
|
--force Ignore checksum mismatches
|
||||||
|
|
||||||
|
Environment Variables:
|
||||||
|
DB_HOST Database host (default: localhost)
|
||||||
|
DB_PORT Database port (default: 5432)
|
||||||
|
DB_NAME Database name (default: horux_strategy)
|
||||||
|
DB_USER Database user (default: postgres)
|
||||||
|
DB_PASSWORD Database password
|
||||||
|
DB_SSL Enable SSL (default: false)
|
||||||
|
`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\nMigration failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run if called directly
|
||||||
|
main().catch(console.error);
|
||||||
|
|
||||||
|
// Export for programmatic use
|
||||||
|
export {
|
||||||
|
runMigrations,
|
||||||
|
printStatus,
|
||||||
|
rollbackLast,
|
||||||
|
ensureDatabase,
|
||||||
|
loadMigrationFiles,
|
||||||
|
getExecutedMigrations,
|
||||||
|
ensureMigrationsTable,
|
||||||
|
MigrationFile,
|
||||||
|
MigrationRecord,
|
||||||
|
};
|
||||||
658
packages/database/src/migrations/001_public_schema.sql
Normal file
658
packages/database/src/migrations/001_public_schema.sql
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Horux Strategy - Public Schema Migration
|
||||||
|
-- Version: 001
|
||||||
|
-- Description: Core tables for multi-tenant SaaS platform
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Enable required extensions
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- For text search
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- ENUM TYPES
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- User roles enum
|
||||||
|
CREATE TYPE user_role AS ENUM (
|
||||||
|
'super_admin', -- Platform administrator (Horux team)
|
||||||
|
'owner', -- Tenant owner (company owner)
|
||||||
|
'admin', -- Tenant administrator
|
||||||
|
'manager', -- Department manager
|
||||||
|
'analyst', -- Financial analyst (read + limited write)
|
||||||
|
'viewer' -- Read-only access
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Subscription status enum
|
||||||
|
CREATE TYPE subscription_status AS ENUM (
|
||||||
|
'active',
|
||||||
|
'trial',
|
||||||
|
'past_due',
|
||||||
|
'cancelled',
|
||||||
|
'suspended',
|
||||||
|
'expired'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tenant status enum
|
||||||
|
CREATE TYPE tenant_status AS ENUM (
|
||||||
|
'active',
|
||||||
|
'suspended',
|
||||||
|
'pending',
|
||||||
|
'deleted'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Job status enum
|
||||||
|
CREATE TYPE job_status AS ENUM (
|
||||||
|
'pending',
|
||||||
|
'running',
|
||||||
|
'completed',
|
||||||
|
'failed',
|
||||||
|
'cancelled'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- PLANS TABLE
|
||||||
|
-- Subscription plans available in the platform
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE plans (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Pricing (in MXN cents to avoid floating point issues)
|
||||||
|
price_monthly_cents INTEGER NOT NULL DEFAULT 0,
|
||||||
|
price_yearly_cents INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Limits
|
||||||
|
max_users INTEGER NOT NULL DEFAULT 1,
|
||||||
|
max_cfdis_monthly INTEGER NOT NULL DEFAULT 100,
|
||||||
|
max_storage_mb INTEGER NOT NULL DEFAULT 1024,
|
||||||
|
max_api_calls_daily INTEGER NOT NULL DEFAULT 1000,
|
||||||
|
max_reports_monthly INTEGER NOT NULL DEFAULT 10,
|
||||||
|
|
||||||
|
-- Features (JSON for flexibility)
|
||||||
|
features JSONB NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Feature flags
|
||||||
|
has_sat_sync BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
has_bank_sync BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
has_ai_insights BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
has_custom_reports BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
has_api_access BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
has_white_label BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
has_priority_support BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
has_dedicated_account_manager BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
-- Retention
|
||||||
|
data_retention_months INTEGER NOT NULL DEFAULT 12,
|
||||||
|
|
||||||
|
-- Display
|
||||||
|
display_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
is_popular BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create index for active plans
|
||||||
|
CREATE INDEX idx_plans_active ON plans(is_active, display_order);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TENANTS TABLE
|
||||||
|
-- Companies/organizations using the platform
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE tenants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- Basic info
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
schema_name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
|
||||||
|
-- Tax info (Mexican RFC)
|
||||||
|
rfc VARCHAR(13),
|
||||||
|
razon_social VARCHAR(500),
|
||||||
|
|
||||||
|
-- Contact
|
||||||
|
email VARCHAR(255),
|
||||||
|
phone VARCHAR(50),
|
||||||
|
|
||||||
|
-- Address
|
||||||
|
address_street VARCHAR(500),
|
||||||
|
address_city VARCHAR(100),
|
||||||
|
address_state VARCHAR(100),
|
||||||
|
address_zip VARCHAR(10),
|
||||||
|
address_country VARCHAR(2) DEFAULT 'MX',
|
||||||
|
|
||||||
|
-- Branding
|
||||||
|
logo_url VARCHAR(500),
|
||||||
|
primary_color VARCHAR(7),
|
||||||
|
|
||||||
|
-- Owner and plan
|
||||||
|
owner_id UUID NOT NULL,
|
||||||
|
plan_id VARCHAR(50) NOT NULL REFERENCES plans(id),
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status tenant_status NOT NULL DEFAULT 'pending',
|
||||||
|
|
||||||
|
-- Settings (JSON for flexibility)
|
||||||
|
settings JSONB NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for tenants
|
||||||
|
CREATE INDEX idx_tenants_slug ON tenants(slug);
|
||||||
|
CREATE INDEX idx_tenants_rfc ON tenants(rfc) WHERE rfc IS NOT NULL;
|
||||||
|
CREATE INDEX idx_tenants_owner ON tenants(owner_id);
|
||||||
|
CREATE INDEX idx_tenants_plan ON tenants(plan_id);
|
||||||
|
CREATE INDEX idx_tenants_status ON tenants(status);
|
||||||
|
CREATE INDEX idx_tenants_created ON tenants(created_at);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- USERS TABLE
|
||||||
|
-- Platform users (can belong to multiple tenants via user_tenants)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- Authentication
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255),
|
||||||
|
|
||||||
|
-- Profile
|
||||||
|
first_name VARCHAR(100),
|
||||||
|
last_name VARCHAR(100),
|
||||||
|
phone VARCHAR(50),
|
||||||
|
avatar_url VARCHAR(500),
|
||||||
|
|
||||||
|
-- Default role (for super_admin only)
|
||||||
|
default_role user_role NOT NULL DEFAULT 'viewer',
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
is_email_verified BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
email_verified_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Security
|
||||||
|
failed_login_attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
locked_until TIMESTAMP WITH TIME ZONE,
|
||||||
|
password_changed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
must_change_password BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
-- Two-factor authentication
|
||||||
|
two_factor_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
two_factor_secret VARCHAR(255),
|
||||||
|
two_factor_recovery_codes TEXT[],
|
||||||
|
|
||||||
|
-- Preferences
|
||||||
|
preferences JSONB NOT NULL DEFAULT '{}',
|
||||||
|
timezone VARCHAR(50) DEFAULT 'America/Mexico_City',
|
||||||
|
locale VARCHAR(10) DEFAULT 'es-MX',
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
last_login_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
last_login_ip INET,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for users
|
||||||
|
CREATE INDEX idx_users_email ON users(email);
|
||||||
|
CREATE INDEX idx_users_active ON users(is_active);
|
||||||
|
CREATE INDEX idx_users_default_role ON users(default_role) WHERE default_role = 'super_admin';
|
||||||
|
CREATE INDEX idx_users_last_login ON users(last_login_at);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- USER_TENANTS TABLE
|
||||||
|
-- Association between users and tenants with role
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE user_tenants (
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Role within this tenant
|
||||||
|
role user_role NOT NULL DEFAULT 'viewer',
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
-- Invitation
|
||||||
|
invited_by UUID REFERENCES users(id),
|
||||||
|
invited_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
accepted_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
PRIMARY KEY (user_id, tenant_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for user_tenants
|
||||||
|
CREATE INDEX idx_user_tenants_tenant ON user_tenants(tenant_id);
|
||||||
|
CREATE INDEX idx_user_tenants_user ON user_tenants(user_id);
|
||||||
|
CREATE INDEX idx_user_tenants_role ON user_tenants(role);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- USER_SESSIONS TABLE
|
||||||
|
-- Active user sessions for authentication
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE user_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Session token (hashed)
|
||||||
|
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
refresh_token_hash VARCHAR(255),
|
||||||
|
|
||||||
|
-- Device info
|
||||||
|
user_agent TEXT,
|
||||||
|
ip_address INET,
|
||||||
|
device_type VARCHAR(50),
|
||||||
|
device_name VARCHAR(255),
|
||||||
|
|
||||||
|
-- Location (approximate)
|
||||||
|
location_city VARCHAR(100),
|
||||||
|
location_country VARCHAR(2),
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
refresh_expires_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
last_activity_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
revoked_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for user_sessions
|
||||||
|
CREATE INDEX idx_user_sessions_user ON user_sessions(user_id);
|
||||||
|
CREATE INDEX idx_user_sessions_tenant ON user_sessions(tenant_id);
|
||||||
|
CREATE INDEX idx_user_sessions_token ON user_sessions(token_hash);
|
||||||
|
CREATE INDEX idx_user_sessions_expires ON user_sessions(expires_at) WHERE is_active = true;
|
||||||
|
CREATE INDEX idx_user_sessions_active ON user_sessions(user_id, is_active) WHERE is_active = true;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SUBSCRIPTIONS TABLE
|
||||||
|
-- Tenant subscription management
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE subscriptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
plan_id VARCHAR(50) NOT NULL REFERENCES plans(id),
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status subscription_status NOT NULL DEFAULT 'trial',
|
||||||
|
|
||||||
|
-- Billing cycle
|
||||||
|
billing_cycle VARCHAR(20) NOT NULL DEFAULT 'monthly', -- monthly, yearly
|
||||||
|
|
||||||
|
-- Dates
|
||||||
|
trial_ends_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
current_period_start TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
current_period_end TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
cancelled_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
-- Payment info (Stripe or other processor)
|
||||||
|
payment_processor VARCHAR(50),
|
||||||
|
external_subscription_id VARCHAR(255),
|
||||||
|
external_customer_id VARCHAR(255),
|
||||||
|
|
||||||
|
-- Pricing at subscription time
|
||||||
|
price_cents INTEGER NOT NULL,
|
||||||
|
currency VARCHAR(3) NOT NULL DEFAULT 'MXN',
|
||||||
|
|
||||||
|
-- Usage tracking
|
||||||
|
usage_cfdis_current INTEGER NOT NULL DEFAULT 0,
|
||||||
|
usage_storage_mb_current DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
usage_api_calls_current INTEGER NOT NULL DEFAULT 0,
|
||||||
|
usage_reset_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for subscriptions
|
||||||
|
CREATE INDEX idx_subscriptions_tenant ON subscriptions(tenant_id);
|
||||||
|
CREATE INDEX idx_subscriptions_plan ON subscriptions(plan_id);
|
||||||
|
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
|
||||||
|
CREATE INDEX idx_subscriptions_period_end ON subscriptions(current_period_end);
|
||||||
|
CREATE INDEX idx_subscriptions_external ON subscriptions(external_subscription_id) WHERE external_subscription_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- AUDIT_LOG TABLE
|
||||||
|
-- Comprehensive audit trail for compliance
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- Context
|
||||||
|
tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Action info
|
||||||
|
action VARCHAR(100) NOT NULL,
|
||||||
|
entity_type VARCHAR(100) NOT NULL,
|
||||||
|
entity_id VARCHAR(255),
|
||||||
|
|
||||||
|
-- Change tracking
|
||||||
|
old_values JSONB,
|
||||||
|
new_values JSONB,
|
||||||
|
details JSONB,
|
||||||
|
|
||||||
|
-- Request context
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
request_id VARCHAR(100),
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for audit_log (optimized for queries)
|
||||||
|
CREATE INDEX idx_audit_log_tenant ON audit_log(tenant_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_audit_log_user ON audit_log(user_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_audit_log_action ON audit_log(action, created_at DESC);
|
||||||
|
CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_audit_log_created ON audit_log(created_at DESC);
|
||||||
|
|
||||||
|
-- Partition audit_log by month for better performance (optional, can be enabled later)
|
||||||
|
-- This is a placeholder comment - actual partitioning would require more setup
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- BACKGROUND_JOBS TABLE
|
||||||
|
-- Async job queue for long-running tasks
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE background_jobs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- Context
|
||||||
|
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Job info
|
||||||
|
job_type VARCHAR(100) NOT NULL,
|
||||||
|
job_name VARCHAR(255),
|
||||||
|
queue VARCHAR(50) NOT NULL DEFAULT 'default',
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Payload
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status job_status NOT NULL DEFAULT 'pending',
|
||||||
|
progress INTEGER DEFAULT 0, -- 0-100
|
||||||
|
|
||||||
|
-- Results
|
||||||
|
result JSONB,
|
||||||
|
error_message TEXT,
|
||||||
|
error_stack TEXT,
|
||||||
|
|
||||||
|
-- Retry logic
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||||
|
|
||||||
|
-- Scheduling
|
||||||
|
scheduled_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
started_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
completed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Timeout
|
||||||
|
timeout_seconds INTEGER DEFAULT 3600, -- 1 hour default
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for background_jobs
|
||||||
|
CREATE INDEX idx_background_jobs_tenant ON background_jobs(tenant_id);
|
||||||
|
CREATE INDEX idx_background_jobs_status ON background_jobs(status, scheduled_at) WHERE status IN ('pending', 'running');
|
||||||
|
CREATE INDEX idx_background_jobs_queue ON background_jobs(queue, priority DESC, scheduled_at) WHERE status = 'pending';
|
||||||
|
CREATE INDEX idx_background_jobs_type ON background_jobs(job_type, status);
|
||||||
|
CREATE INDEX idx_background_jobs_scheduled ON background_jobs(scheduled_at) WHERE status = 'pending';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- API_KEYS TABLE
|
||||||
|
-- API keys for external integrations
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Key info
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- The key (only prefix stored for display, full hash for verification)
|
||||||
|
key_prefix VARCHAR(10) NOT NULL, -- First 8 chars for identification
|
||||||
|
key_hash VARCHAR(255) NOT NULL UNIQUE, -- SHA-256 hash of full key
|
||||||
|
|
||||||
|
-- Permissions (scopes)
|
||||||
|
scopes TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Restrictions
|
||||||
|
allowed_ips INET[],
|
||||||
|
allowed_origins TEXT[],
|
||||||
|
|
||||||
|
-- Rate limiting
|
||||||
|
rate_limit_per_minute INTEGER DEFAULT 60,
|
||||||
|
rate_limit_per_day INTEGER DEFAULT 10000,
|
||||||
|
|
||||||
|
-- Usage tracking
|
||||||
|
last_used_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
usage_count BIGINT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
-- Expiration
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
revoked_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
revoked_by UUID REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for api_keys
|
||||||
|
CREATE INDEX idx_api_keys_tenant ON api_keys(tenant_id);
|
||||||
|
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash) WHERE is_active = true;
|
||||||
|
CREATE INDEX idx_api_keys_prefix ON api_keys(key_prefix);
|
||||||
|
CREATE INDEX idx_api_keys_active ON api_keys(tenant_id, is_active) WHERE is_active = true;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- PASSWORD_RESET_TOKENS TABLE
|
||||||
|
-- Tokens for password reset functionality
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE password_reset_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
used_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for password_reset_tokens
|
||||||
|
CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens(user_id);
|
||||||
|
CREATE INDEX idx_password_reset_tokens_token ON password_reset_tokens(token_hash) WHERE used_at IS NULL;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- EMAIL_VERIFICATION_TOKENS TABLE
|
||||||
|
-- Tokens for email verification
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE email_verification_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
verified_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for email_verification_tokens
|
||||||
|
CREATE INDEX idx_email_verification_tokens_user ON email_verification_tokens(user_id);
|
||||||
|
CREATE INDEX idx_email_verification_tokens_token ON email_verification_tokens(token_hash) WHERE verified_at IS NULL;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- INVITATION_TOKENS TABLE
|
||||||
|
-- Tokens for user invitations to tenants
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE invitation_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
role user_role NOT NULL DEFAULT 'viewer',
|
||||||
|
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
invited_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
accepted_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for invitation_tokens
|
||||||
|
CREATE INDEX idx_invitation_tokens_tenant ON invitation_tokens(tenant_id);
|
||||||
|
CREATE INDEX idx_invitation_tokens_email ON invitation_tokens(email);
|
||||||
|
CREATE INDEX idx_invitation_tokens_token ON invitation_tokens(token_hash) WHERE accepted_at IS NULL;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SYSTEM_SETTINGS TABLE
|
||||||
|
-- Global platform settings
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE system_settings (
|
||||||
|
key VARCHAR(100) PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
value_type VARCHAR(20) NOT NULL DEFAULT 'string', -- string, integer, boolean, json
|
||||||
|
category VARCHAR(50) NOT NULL DEFAULT 'general',
|
||||||
|
description TEXT,
|
||||||
|
is_sensitive BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- NOTIFICATIONS TABLE
|
||||||
|
-- System and user notifications
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Notification content
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
data JSONB,
|
||||||
|
|
||||||
|
-- Action
|
||||||
|
action_url VARCHAR(500),
|
||||||
|
action_label VARCHAR(100),
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_read BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
read_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Delivery
|
||||||
|
channels TEXT[] NOT NULL DEFAULT '{"in_app"}', -- in_app, email, push
|
||||||
|
email_sent_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
push_sent_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for notifications
|
||||||
|
CREATE INDEX idx_notifications_user ON notifications(user_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_notifications_unread ON notifications(user_id, is_read, created_at DESC) WHERE is_read = false;
|
||||||
|
CREATE INDEX idx_notifications_tenant ON notifications(tenant_id, created_at DESC);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- FUNCTIONS AND TRIGGERS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Function to update updated_at timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
-- Apply update_updated_at trigger to all relevant tables
|
||||||
|
CREATE TRIGGER update_plans_updated_at BEFORE UPDATE ON plans
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_tenants_updated_at BEFORE UPDATE ON tenants
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_user_tenants_updated_at BEFORE UPDATE ON user_tenants
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_subscriptions_updated_at BEFORE UPDATE ON subscriptions
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_background_jobs_updated_at BEFORE UPDATE ON background_jobs
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_api_keys_updated_at BEFORE UPDATE ON api_keys
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_system_settings_updated_at BEFORE UPDATE ON system_settings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- ROW LEVEL SECURITY (RLS) POLICIES
|
||||||
|
-- Enable RLS for multi-tenant security
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Note: RLS policies would be configured here in production
|
||||||
|
-- For now, security is handled at the application layer with schema-based isolation
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- COMMENTS FOR DOCUMENTATION
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
COMMENT ON TABLE plans IS 'Subscription plans available in the platform';
|
||||||
|
COMMENT ON TABLE tenants IS 'Companies/organizations using the platform (each gets their own schema)';
|
||||||
|
COMMENT ON TABLE users IS 'Platform users (authentication and profile)';
|
||||||
|
COMMENT ON TABLE user_tenants IS 'Many-to-many relationship between users and tenants with role';
|
||||||
|
COMMENT ON TABLE user_sessions IS 'Active user sessions for JWT-based authentication';
|
||||||
|
COMMENT ON TABLE subscriptions IS 'Tenant subscription and billing information';
|
||||||
|
COMMENT ON TABLE audit_log IS 'Comprehensive audit trail for security and compliance';
|
||||||
|
COMMENT ON TABLE background_jobs IS 'Async job queue for long-running tasks (SAT sync, reports, etc.)';
|
||||||
|
COMMENT ON TABLE api_keys IS 'API keys for external integrations and third-party access';
|
||||||
|
COMMENT ON TABLE notifications IS 'In-app and push notifications for users';
|
||||||
889
packages/database/src/migrations/002_tenant_schema.sql
Normal file
889
packages/database/src/migrations/002_tenant_schema.sql
Normal file
@@ -0,0 +1,889 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Horux Strategy - Tenant Schema Template
|
||||||
|
-- Version: 002
|
||||||
|
-- Description: Tables created for each tenant in their own schema
|
||||||
|
-- Note: ${SCHEMA_NAME} will be replaced with the actual schema name
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- ENUM TYPES (tenant-specific)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Transaction type
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'transaction_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||||
|
CREATE TYPE transaction_type AS ENUM ('income', 'expense', 'transfer', 'adjustment');
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Transaction status
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'transaction_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||||
|
CREATE TYPE transaction_status AS ENUM ('pending', 'confirmed', 'reconciled', 'voided');
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- CFDI status
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'cfdi_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||||
|
CREATE TYPE cfdi_status AS ENUM ('active', 'cancelled', 'pending_cancellation');
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- CFDI type (comprobante)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'cfdi_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||||
|
CREATE TYPE cfdi_type AS ENUM ('I', 'E', 'T', 'N', 'P'); -- Ingreso, Egreso, Traslado, Nomina, Pago
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Contact type
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'contact_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||||
|
CREATE TYPE contact_type AS ENUM ('customer', 'supplier', 'both', 'employee');
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Category type
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'category_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||||
|
CREATE TYPE category_type AS ENUM ('income', 'expense', 'cost', 'other');
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Account type
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'account_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||||
|
CREATE TYPE account_type AS ENUM ('asset', 'liability', 'equity', 'revenue', 'expense');
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Alert severity
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'alert_severity' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||||
|
CREATE TYPE alert_severity AS ENUM ('info', 'warning', 'critical');
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Report status
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'report_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||||
|
CREATE TYPE report_status AS ENUM ('draft', 'generating', 'completed', 'failed', 'archived');
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SAT_CREDENTIALS TABLE
|
||||||
|
-- Encrypted FIEL (e.firma) credentials for SAT integration
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE sat_credentials (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- RFC associated with credentials
|
||||||
|
rfc VARCHAR(13) NOT NULL UNIQUE,
|
||||||
|
|
||||||
|
-- FIEL Components (encrypted with AES-256)
|
||||||
|
-- The actual encryption key is stored securely in environment variables
|
||||||
|
cer_file_encrypted BYTEA NOT NULL, -- .cer file content (encrypted)
|
||||||
|
key_file_encrypted BYTEA NOT NULL, -- .key file content (encrypted)
|
||||||
|
password_encrypted BYTEA NOT NULL, -- FIEL password (encrypted)
|
||||||
|
|
||||||
|
-- Certificate metadata
|
||||||
|
cer_serial_number VARCHAR(50),
|
||||||
|
cer_issued_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
cer_expires_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
cer_issuer VARCHAR(255),
|
||||||
|
|
||||||
|
-- CIEC credentials (optional, for portal access)
|
||||||
|
ciec_password_encrypted BYTEA,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
is_valid BOOLEAN NOT NULL DEFAULT false, -- Set after validation
|
||||||
|
last_validated_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
validation_error TEXT,
|
||||||
|
|
||||||
|
-- SAT sync settings
|
||||||
|
sync_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
sync_frequency_hours INTEGER DEFAULT 24,
|
||||||
|
last_sync_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
last_sync_status VARCHAR(50),
|
||||||
|
last_sync_error TEXT,
|
||||||
|
|
||||||
|
-- Encryption metadata
|
||||||
|
encryption_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
encryption_iv BYTEA, -- Initialization vector
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_by UUID NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for sat_credentials
|
||||||
|
CREATE INDEX idx_sat_credentials_rfc ON sat_credentials(rfc);
|
||||||
|
CREATE INDEX idx_sat_credentials_active ON sat_credentials(is_active, is_valid);
|
||||||
|
CREATE INDEX idx_sat_credentials_sync ON sat_credentials(sync_enabled, last_sync_at) WHERE sync_enabled = true;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- CFDIS TABLE
|
||||||
|
-- CFDI 4.0 compliant invoice storage
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE cfdis (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- CFDI Identifiers
|
||||||
|
uuid_fiscal UUID NOT NULL UNIQUE, -- UUID from SAT (timbre fiscal)
|
||||||
|
serie VARCHAR(25),
|
||||||
|
folio VARCHAR(40),
|
||||||
|
|
||||||
|
-- Type and status
|
||||||
|
tipo_comprobante cfdi_type NOT NULL, -- I, E, T, N, P
|
||||||
|
status cfdi_status NOT NULL DEFAULT 'active',
|
||||||
|
|
||||||
|
-- Dates
|
||||||
|
fecha_emision TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
fecha_timbrado TIMESTAMP WITH TIME ZONE,
|
||||||
|
fecha_cancelacion TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Emisor (seller)
|
||||||
|
emisor_rfc VARCHAR(13) NOT NULL,
|
||||||
|
emisor_nombre VARCHAR(500) NOT NULL,
|
||||||
|
emisor_regimen_fiscal VARCHAR(3) NOT NULL, -- SAT catalog code
|
||||||
|
|
||||||
|
-- Receptor (buyer)
|
||||||
|
receptor_rfc VARCHAR(13) NOT NULL,
|
||||||
|
receptor_nombre VARCHAR(500) NOT NULL,
|
||||||
|
receptor_regimen_fiscal VARCHAR(3),
|
||||||
|
receptor_domicilio_fiscal VARCHAR(5), -- CP
|
||||||
|
receptor_uso_cfdi VARCHAR(4) NOT NULL, -- SAT catalog code
|
||||||
|
|
||||||
|
-- Amounts
|
||||||
|
subtotal DECIMAL(18,2) NOT NULL,
|
||||||
|
descuento DECIMAL(18,2) DEFAULT 0,
|
||||||
|
total DECIMAL(18,2) NOT NULL,
|
||||||
|
|
||||||
|
-- Tax breakdown
|
||||||
|
total_impuestos_trasladados DECIMAL(18,2) DEFAULT 0,
|
||||||
|
total_impuestos_retenidos DECIMAL(18,2) DEFAULT 0,
|
||||||
|
|
||||||
|
-- IVA specific
|
||||||
|
iva_16 DECIMAL(18,2) DEFAULT 0, -- IVA 16%
|
||||||
|
iva_8 DECIMAL(18,2) DEFAULT 0, -- IVA 8% (frontera)
|
||||||
|
iva_0 DECIMAL(18,2) DEFAULT 0, -- IVA 0%
|
||||||
|
iva_exento DECIMAL(18,2) DEFAULT 0, -- IVA exento
|
||||||
|
|
||||||
|
-- ISR retention
|
||||||
|
isr_retenido DECIMAL(18,2) DEFAULT 0,
|
||||||
|
iva_retenido DECIMAL(18,2) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Currency
|
||||||
|
moneda VARCHAR(3) NOT NULL DEFAULT 'MXN',
|
||||||
|
tipo_cambio DECIMAL(18,6) DEFAULT 1,
|
||||||
|
|
||||||
|
-- Payment info
|
||||||
|
forma_pago VARCHAR(2), -- SAT catalog
|
||||||
|
metodo_pago VARCHAR(3), -- PUE, PPD
|
||||||
|
condiciones_pago VARCHAR(255),
|
||||||
|
|
||||||
|
-- Related documents
|
||||||
|
cfdi_relacionados JSONB, -- Array of related CFDI UUIDs
|
||||||
|
tipo_relacion VARCHAR(2), -- SAT catalog
|
||||||
|
|
||||||
|
-- Concepts/items (denormalized for quick access)
|
||||||
|
conceptos JSONB NOT NULL, -- Array of line items
|
||||||
|
|
||||||
|
-- Full XML storage
|
||||||
|
xml_content TEXT, -- Original XML
|
||||||
|
xml_hash VARCHAR(64), -- SHA-256 of XML
|
||||||
|
|
||||||
|
-- Digital stamps
|
||||||
|
sello_cfdi TEXT, -- Digital signature
|
||||||
|
sello_sat TEXT, -- SAT signature
|
||||||
|
certificado_sat VARCHAR(50),
|
||||||
|
cadena_original_tfd TEXT,
|
||||||
|
|
||||||
|
-- Direction (for the tenant)
|
||||||
|
is_emitted BOOLEAN NOT NULL, -- true = we issued it, false = we received it
|
||||||
|
|
||||||
|
-- Categorization
|
||||||
|
category_id UUID,
|
||||||
|
contact_id UUID,
|
||||||
|
|
||||||
|
-- Reconciliation
|
||||||
|
is_reconciled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
reconciled_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
reconciled_by UUID,
|
||||||
|
|
||||||
|
-- AI-generated insights
|
||||||
|
ai_category_suggestion VARCHAR(100),
|
||||||
|
ai_confidence_score DECIMAL(5,4),
|
||||||
|
|
||||||
|
-- Source
|
||||||
|
source VARCHAR(50) NOT NULL DEFAULT 'sat_sync', -- sat_sync, manual, api
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for cfdis (optimized for reporting queries)
|
||||||
|
CREATE INDEX idx_cfdis_uuid_fiscal ON cfdis(uuid_fiscal);
|
||||||
|
CREATE INDEX idx_cfdis_fecha_emision ON cfdis(fecha_emision DESC);
|
||||||
|
CREATE INDEX idx_cfdis_emisor_rfc ON cfdis(emisor_rfc);
|
||||||
|
CREATE INDEX idx_cfdis_receptor_rfc ON cfdis(receptor_rfc);
|
||||||
|
CREATE INDEX idx_cfdis_tipo ON cfdis(tipo_comprobante);
|
||||||
|
CREATE INDEX idx_cfdis_status ON cfdis(status);
|
||||||
|
CREATE INDEX idx_cfdis_is_emitted ON cfdis(is_emitted);
|
||||||
|
CREATE INDEX idx_cfdis_category ON cfdis(category_id) WHERE category_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_cfdis_contact ON cfdis(contact_id) WHERE contact_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_cfdis_reconciled ON cfdis(is_reconciled, fecha_emision DESC) WHERE is_reconciled = false;
|
||||||
|
|
||||||
|
-- Composite indexes for common queries
|
||||||
|
CREATE INDEX idx_cfdis_emitted_date ON cfdis(is_emitted, fecha_emision DESC);
|
||||||
|
CREATE INDEX idx_cfdis_type_date ON cfdis(tipo_comprobante, fecha_emision DESC);
|
||||||
|
CREATE INDEX idx_cfdis_month_report ON cfdis(DATE_TRUNC('month', fecha_emision), tipo_comprobante, is_emitted);
|
||||||
|
|
||||||
|
-- Full-text search index
|
||||||
|
CREATE INDEX idx_cfdis_search ON cfdis USING gin(to_tsvector('spanish', emisor_nombre || ' ' || receptor_nombre || ' ' || COALESCE(serie, '') || ' ' || COALESCE(folio, '')));
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TRANSACTIONS TABLE
|
||||||
|
-- Unified financial transaction model
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE transactions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- Transaction info
|
||||||
|
type transaction_type NOT NULL,
|
||||||
|
status transaction_status NOT NULL DEFAULT 'pending',
|
||||||
|
|
||||||
|
-- Amount
|
||||||
|
amount DECIMAL(18,2) NOT NULL,
|
||||||
|
currency VARCHAR(3) NOT NULL DEFAULT 'MXN',
|
||||||
|
exchange_rate DECIMAL(18,6) DEFAULT 1,
|
||||||
|
amount_mxn DECIMAL(18,2) NOT NULL, -- Always in MXN for reporting
|
||||||
|
|
||||||
|
-- Dates
|
||||||
|
transaction_date DATE NOT NULL,
|
||||||
|
value_date DATE, -- Settlement date
|
||||||
|
recorded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Description
|
||||||
|
description TEXT,
|
||||||
|
reference VARCHAR(255),
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
-- Categorization
|
||||||
|
category_id UUID,
|
||||||
|
account_id UUID,
|
||||||
|
contact_id UUID,
|
||||||
|
|
||||||
|
-- Related documents
|
||||||
|
cfdi_id UUID REFERENCES cfdis(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Bank reconciliation
|
||||||
|
bank_transaction_id VARCHAR(255),
|
||||||
|
bank_account_id VARCHAR(100),
|
||||||
|
bank_description TEXT,
|
||||||
|
|
||||||
|
-- Recurring
|
||||||
|
is_recurring BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
recurring_pattern JSONB, -- Frequency, end date, etc.
|
||||||
|
parent_transaction_id UUID REFERENCES transactions(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Attachments
|
||||||
|
attachments JSONB, -- Array of file references
|
||||||
|
|
||||||
|
-- Tags for custom classification
|
||||||
|
tags TEXT[],
|
||||||
|
|
||||||
|
-- Reconciliation status
|
||||||
|
is_reconciled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
reconciled_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
reconciled_by UUID,
|
||||||
|
|
||||||
|
-- Approval workflow (for larger amounts)
|
||||||
|
requires_approval BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
approved_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
approved_by UUID,
|
||||||
|
|
||||||
|
-- AI categorization
|
||||||
|
ai_category_id UUID,
|
||||||
|
ai_confidence DECIMAL(5,4),
|
||||||
|
ai_notes TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_by UUID,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
voided_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
voided_by UUID,
|
||||||
|
void_reason TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for transactions
|
||||||
|
CREATE INDEX idx_transactions_date ON transactions(transaction_date DESC);
|
||||||
|
CREATE INDEX idx_transactions_type ON transactions(type);
|
||||||
|
CREATE INDEX idx_transactions_status ON transactions(status);
|
||||||
|
CREATE INDEX idx_transactions_category ON transactions(category_id);
|
||||||
|
CREATE INDEX idx_transactions_account ON transactions(account_id);
|
||||||
|
CREATE INDEX idx_transactions_contact ON transactions(contact_id);
|
||||||
|
CREATE INDEX idx_transactions_cfdi ON transactions(cfdi_id) WHERE cfdi_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_transactions_reconciled ON transactions(is_reconciled, transaction_date DESC) WHERE is_reconciled = false;
|
||||||
|
|
||||||
|
-- Composite indexes for reporting
|
||||||
|
CREATE INDEX idx_transactions_monthly ON transactions(DATE_TRUNC('month', transaction_date), type, status);
|
||||||
|
CREATE INDEX idx_transactions_category_date ON transactions(category_id, transaction_date DESC) WHERE category_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- CONTACTS TABLE
|
||||||
|
-- Customers and suppliers
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE contacts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- Type
|
||||||
|
type contact_type NOT NULL,
|
||||||
|
|
||||||
|
-- Basic info
|
||||||
|
name VARCHAR(500) NOT NULL,
|
||||||
|
trade_name VARCHAR(500), -- Nombre comercial
|
||||||
|
|
||||||
|
-- Tax info (RFC)
|
||||||
|
rfc VARCHAR(13),
|
||||||
|
regimen_fiscal VARCHAR(3), -- SAT catalog
|
||||||
|
uso_cfdi_default VARCHAR(4), -- Default uso CFDI
|
||||||
|
|
||||||
|
-- Contact info
|
||||||
|
email VARCHAR(255),
|
||||||
|
phone VARCHAR(50),
|
||||||
|
mobile VARCHAR(50),
|
||||||
|
website VARCHAR(255),
|
||||||
|
|
||||||
|
-- Address
|
||||||
|
address_street VARCHAR(500),
|
||||||
|
address_interior VARCHAR(50),
|
||||||
|
address_exterior VARCHAR(50),
|
||||||
|
address_neighborhood VARCHAR(200), -- Colonia
|
||||||
|
address_city VARCHAR(100),
|
||||||
|
address_municipality VARCHAR(100), -- Municipio/Delegacion
|
||||||
|
address_state VARCHAR(100),
|
||||||
|
address_zip VARCHAR(5),
|
||||||
|
address_country VARCHAR(2) DEFAULT 'MX',
|
||||||
|
|
||||||
|
-- Bank info
|
||||||
|
bank_name VARCHAR(100),
|
||||||
|
bank_account VARCHAR(20),
|
||||||
|
bank_clabe VARCHAR(18),
|
||||||
|
|
||||||
|
-- Credit terms
|
||||||
|
credit_days INTEGER DEFAULT 0,
|
||||||
|
credit_limit DECIMAL(18,2) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Balances (denormalized for performance)
|
||||||
|
balance_receivable DECIMAL(18,2) DEFAULT 0,
|
||||||
|
balance_payable DECIMAL(18,2) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Classification
|
||||||
|
category VARCHAR(100),
|
||||||
|
tags TEXT[],
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
-- Notes
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_by UUID,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for contacts
|
||||||
|
CREATE INDEX idx_contacts_type ON contacts(type);
|
||||||
|
CREATE INDEX idx_contacts_rfc ON contacts(rfc) WHERE rfc IS NOT NULL;
|
||||||
|
CREATE INDEX idx_contacts_name ON contacts(name);
|
||||||
|
CREATE INDEX idx_contacts_active ON contacts(is_active);
|
||||||
|
CREATE INDEX idx_contacts_search ON contacts USING gin(to_tsvector('spanish', name || ' ' || COALESCE(trade_name, '') || ' ' || COALESCE(rfc, '')));
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- CATEGORIES TABLE
|
||||||
|
-- Transaction/expense categories
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE categories (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- Identification
|
||||||
|
code VARCHAR(20) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Type
|
||||||
|
type category_type NOT NULL,
|
||||||
|
|
||||||
|
-- Hierarchy
|
||||||
|
parent_id UUID REFERENCES categories(id) ON DELETE SET NULL,
|
||||||
|
level INTEGER NOT NULL DEFAULT 0,
|
||||||
|
path TEXT, -- Materialized path for hierarchy
|
||||||
|
|
||||||
|
-- SAT mapping
|
||||||
|
sat_key VARCHAR(10), -- Clave producto/servicio SAT
|
||||||
|
|
||||||
|
-- Budget
|
||||||
|
budget_monthly DECIMAL(18,2),
|
||||||
|
budget_yearly DECIMAL(18,2),
|
||||||
|
|
||||||
|
-- Display
|
||||||
|
color VARCHAR(7), -- Hex color
|
||||||
|
icon VARCHAR(50),
|
||||||
|
display_order INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
is_system BOOLEAN NOT NULL DEFAULT false, -- Prevent deletion of system categories
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for categories
|
||||||
|
CREATE INDEX idx_categories_code ON categories(code);
|
||||||
|
CREATE INDEX idx_categories_type ON categories(type);
|
||||||
|
CREATE INDEX idx_categories_parent ON categories(parent_id);
|
||||||
|
CREATE INDEX idx_categories_active ON categories(is_active);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- ACCOUNTS TABLE
|
||||||
|
-- Chart of accounts (catalogo de cuentas)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE accounts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- Identification
|
||||||
|
code VARCHAR(20) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Type
|
||||||
|
type account_type NOT NULL,
|
||||||
|
|
||||||
|
-- Hierarchy
|
||||||
|
parent_id UUID REFERENCES accounts(id) ON DELETE SET NULL,
|
||||||
|
level INTEGER NOT NULL DEFAULT 0,
|
||||||
|
path TEXT, -- Materialized path
|
||||||
|
|
||||||
|
-- SAT mapping (for Contabilidad Electronica)
|
||||||
|
sat_code VARCHAR(20), -- Codigo agrupador SAT
|
||||||
|
sat_nature VARCHAR(1), -- D = Deudora, A = Acreedora
|
||||||
|
|
||||||
|
-- Balances (denormalized)
|
||||||
|
balance_debit DECIMAL(18,2) DEFAULT 0,
|
||||||
|
balance_credit DECIMAL(18,2) DEFAULT 0,
|
||||||
|
balance_current DECIMAL(18,2) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
is_system BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
allows_movements BOOLEAN NOT NULL DEFAULT true, -- Can have direct transactions
|
||||||
|
|
||||||
|
-- Display
|
||||||
|
display_order INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for accounts
|
||||||
|
CREATE INDEX idx_accounts_code ON accounts(code);
|
||||||
|
CREATE INDEX idx_accounts_type ON accounts(type);
|
||||||
|
CREATE INDEX idx_accounts_parent ON accounts(parent_id);
|
||||||
|
CREATE INDEX idx_accounts_active ON accounts(is_active);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- METRICS_CACHE TABLE
|
||||||
|
-- Pre-computed metrics for dashboard performance
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE metrics_cache (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- Metric identification
|
||||||
|
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,
|
||||||
|
|
||||||
|
-- Dimension (optional filtering)
|
||||||
|
dimension_type VARCHAR(50), -- category, contact, account, etc.
|
||||||
|
dimension_id UUID,
|
||||||
|
|
||||||
|
-- Values
|
||||||
|
value_numeric DECIMAL(18,4),
|
||||||
|
value_json JSONB,
|
||||||
|
|
||||||
|
-- Comparison
|
||||||
|
previous_value DECIMAL(18,4),
|
||||||
|
change_percent DECIMAL(8,4),
|
||||||
|
change_absolute DECIMAL(18,4),
|
||||||
|
|
||||||
|
-- Validity
|
||||||
|
computed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
valid_until TIMESTAMP WITH TIME ZONE,
|
||||||
|
is_stale BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
-- Unique constraint on metric + period + dimension
|
||||||
|
UNIQUE(metric_key, period_type, period_start, dimension_type, dimension_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for metrics_cache
|
||||||
|
CREATE INDEX idx_metrics_cache_key ON metrics_cache(metric_key);
|
||||||
|
CREATE INDEX idx_metrics_cache_period ON metrics_cache(period_type, period_start DESC);
|
||||||
|
CREATE INDEX idx_metrics_cache_dimension ON metrics_cache(dimension_type, dimension_id) WHERE dimension_type IS NOT NULL;
|
||||||
|
CREATE INDEX idx_metrics_cache_stale ON metrics_cache(is_stale) WHERE is_stale = true;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- ALERTS TABLE
|
||||||
|
-- Financial alerts and notifications
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE alerts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- Alert info
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
severity alert_severity NOT NULL DEFAULT 'info',
|
||||||
|
|
||||||
|
-- Related entity
|
||||||
|
entity_type VARCHAR(50),
|
||||||
|
entity_id UUID,
|
||||||
|
|
||||||
|
-- Threshold that triggered the alert
|
||||||
|
threshold_type VARCHAR(50),
|
||||||
|
threshold_value DECIMAL(18,4),
|
||||||
|
current_value DECIMAL(18,4),
|
||||||
|
|
||||||
|
-- Actions
|
||||||
|
action_url VARCHAR(500),
|
||||||
|
action_label VARCHAR(100),
|
||||||
|
action_data JSONB,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_read BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
is_dismissed BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
read_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
dismissed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
dismissed_by UUID,
|
||||||
|
|
||||||
|
-- Recurrence
|
||||||
|
is_recurring BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
last_triggered_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
trigger_count INTEGER DEFAULT 1,
|
||||||
|
|
||||||
|
-- Auto-resolve
|
||||||
|
auto_resolved BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
resolved_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
resolved_by UUID,
|
||||||
|
resolution_notes TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for alerts
|
||||||
|
CREATE INDEX idx_alerts_type ON alerts(type);
|
||||||
|
CREATE INDEX idx_alerts_severity ON alerts(severity);
|
||||||
|
CREATE INDEX idx_alerts_unread ON alerts(is_read, created_at DESC) WHERE is_read = false;
|
||||||
|
CREATE INDEX idx_alerts_entity ON alerts(entity_type, entity_id) WHERE entity_type IS NOT NULL;
|
||||||
|
CREATE INDEX idx_alerts_active ON alerts(is_dismissed, created_at DESC) WHERE is_dismissed = false;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- REPORTS TABLE
|
||||||
|
-- Generated reports
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- Report info
|
||||||
|
type VARCHAR(100) NOT NULL, -- balance_general, estado_resultados, flujo_efectivo, etc.
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Period
|
||||||
|
period_start DATE NOT NULL,
|
||||||
|
period_end DATE NOT NULL,
|
||||||
|
comparison_period_start DATE,
|
||||||
|
comparison_period_end DATE,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status report_status NOT NULL DEFAULT 'draft',
|
||||||
|
|
||||||
|
-- Parameters used to generate
|
||||||
|
parameters JSONB,
|
||||||
|
|
||||||
|
-- Output
|
||||||
|
data JSONB, -- Report data
|
||||||
|
file_url VARCHAR(500), -- PDF/Excel URL
|
||||||
|
file_format VARCHAR(10), -- pdf, xlsx, csv
|
||||||
|
|
||||||
|
-- Scheduling
|
||||||
|
is_scheduled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
schedule_cron VARCHAR(50),
|
||||||
|
next_scheduled_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
last_generated_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Sharing
|
||||||
|
is_shared BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
shared_with UUID[],
|
||||||
|
share_token VARCHAR(100),
|
||||||
|
share_expires_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
generated_by UUID,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for reports
|
||||||
|
CREATE INDEX idx_reports_type ON reports(type);
|
||||||
|
CREATE INDEX idx_reports_status ON reports(status);
|
||||||
|
CREATE INDEX idx_reports_period ON reports(period_start, period_end);
|
||||||
|
CREATE INDEX idx_reports_scheduled ON reports(is_scheduled, next_scheduled_at) WHERE is_scheduled = true;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SETTINGS TABLE
|
||||||
|
-- Tenant-specific settings
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE settings (
|
||||||
|
key VARCHAR(100) PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
value_type VARCHAR(20) NOT NULL DEFAULT 'string', -- string, integer, boolean, json
|
||||||
|
category VARCHAR(50) NOT NULL DEFAULT 'general',
|
||||||
|
label VARCHAR(200),
|
||||||
|
description TEXT,
|
||||||
|
is_sensitive BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- BANK_ACCOUNTS TABLE
|
||||||
|
-- Connected bank accounts
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE bank_accounts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- Bank info
|
||||||
|
bank_name VARCHAR(100) NOT NULL,
|
||||||
|
bank_code VARCHAR(10), -- SPEI bank code
|
||||||
|
|
||||||
|
-- Account info
|
||||||
|
account_number VARCHAR(20),
|
||||||
|
clabe VARCHAR(18),
|
||||||
|
account_type VARCHAR(50), -- checking, savings, credit
|
||||||
|
|
||||||
|
-- Display
|
||||||
|
alias VARCHAR(100),
|
||||||
|
currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
|
||||||
|
-- Balance (cached from bank sync)
|
||||||
|
balance_available DECIMAL(18,2),
|
||||||
|
balance_current DECIMAL(18,2),
|
||||||
|
balance_updated_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Connection
|
||||||
|
connection_provider VARCHAR(50), -- belvo, finerio, manual
|
||||||
|
connection_id VARCHAR(255),
|
||||||
|
connection_status VARCHAR(50),
|
||||||
|
last_sync_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
last_sync_error TEXT,
|
||||||
|
|
||||||
|
-- Categorization
|
||||||
|
account_id UUID REFERENCES accounts(id), -- Link to chart of accounts
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for bank_accounts
|
||||||
|
CREATE INDEX idx_bank_accounts_active ON bank_accounts(is_active);
|
||||||
|
CREATE INDEX idx_bank_accounts_connection ON bank_accounts(connection_provider, connection_id) WHERE connection_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- BUDGET_ITEMS TABLE
|
||||||
|
-- Budget planning
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE budget_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- Period
|
||||||
|
year INTEGER NOT NULL,
|
||||||
|
month INTEGER NOT NULL, -- 1-12, or 0 for yearly
|
||||||
|
|
||||||
|
-- Category
|
||||||
|
category_id UUID REFERENCES categories(id) ON DELETE CASCADE,
|
||||||
|
account_id UUID REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Amounts
|
||||||
|
amount_budgeted DECIMAL(18,2) NOT NULL,
|
||||||
|
amount_actual DECIMAL(18,2) DEFAULT 0,
|
||||||
|
amount_variance DECIMAL(18,2) GENERATED ALWAYS AS (amount_actual - amount_budgeted) STORED,
|
||||||
|
|
||||||
|
-- Notes
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_locked BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_by UUID,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- One budget per category/account per period
|
||||||
|
UNIQUE(year, month, category_id),
|
||||||
|
UNIQUE(year, month, account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for budget_items
|
||||||
|
CREATE INDEX idx_budget_items_period ON budget_items(year, month);
|
||||||
|
CREATE INDEX idx_budget_items_category ON budget_items(category_id);
|
||||||
|
CREATE INDEX idx_budget_items_account ON budget_items(account_id);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- ATTACHMENTS TABLE
|
||||||
|
-- File attachments for various entities
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE attachments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- Related entity
|
||||||
|
entity_type VARCHAR(50) NOT NULL, -- cfdi, transaction, contact, etc.
|
||||||
|
entity_id UUID NOT NULL,
|
||||||
|
|
||||||
|
-- File info
|
||||||
|
file_name VARCHAR(255) NOT NULL,
|
||||||
|
file_type VARCHAR(100),
|
||||||
|
file_size INTEGER,
|
||||||
|
file_url VARCHAR(500) NOT NULL,
|
||||||
|
|
||||||
|
-- Storage
|
||||||
|
storage_provider VARCHAR(50) DEFAULT 'local', -- local, s3, gcs
|
||||||
|
storage_path VARCHAR(500),
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
uploaded_by UUID,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for attachments
|
||||||
|
CREATE INDEX idx_attachments_entity ON attachments(entity_type, entity_id);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TRIGGERS FOR TENANT SCHEMA
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Update updated_at timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
-- Apply triggers
|
||||||
|
CREATE TRIGGER update_sat_credentials_updated_at BEFORE UPDATE ON sat_credentials
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_cfdis_updated_at BEFORE UPDATE ON cfdis
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_transactions_updated_at BEFORE UPDATE ON transactions
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_contacts_updated_at BEFORE UPDATE ON contacts
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_categories_updated_at BEFORE UPDATE ON categories
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_accounts_updated_at BEFORE UPDATE ON accounts
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_reports_updated_at BEFORE UPDATE ON reports
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_settings_updated_at BEFORE UPDATE ON settings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_bank_accounts_updated_at BEFORE UPDATE ON bank_accounts
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_budget_items_updated_at BEFORE UPDATE ON budget_items
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- FOREIGN KEY CONSTRAINTS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Add foreign keys after all tables are created
|
||||||
|
ALTER TABLE cfdis ADD CONSTRAINT fk_cfdis_category
|
||||||
|
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE cfdis ADD CONSTRAINT fk_cfdis_contact
|
||||||
|
FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE transactions ADD CONSTRAINT fk_transactions_category
|
||||||
|
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE transactions ADD CONSTRAINT fk_transactions_account
|
||||||
|
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE transactions ADD CONSTRAINT fk_transactions_contact
|
||||||
|
FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- COMMENTS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
COMMENT ON TABLE sat_credentials IS 'Encrypted SAT FIEL credentials for each tenant';
|
||||||
|
COMMENT ON TABLE cfdis IS 'CFDI 4.0 invoices (emitted and received)';
|
||||||
|
COMMENT ON TABLE transactions IS 'Unified financial transactions';
|
||||||
|
COMMENT ON TABLE contacts IS 'Customers and suppliers';
|
||||||
|
COMMENT ON TABLE categories IS 'Transaction categorization';
|
||||||
|
COMMENT ON TABLE accounts IS 'Chart of accounts (catalogo de cuentas)';
|
||||||
|
COMMENT ON TABLE metrics_cache IS 'Pre-computed metrics for dashboard';
|
||||||
|
COMMENT ON TABLE alerts IS 'Financial alerts and notifications';
|
||||||
|
COMMENT ON TABLE reports IS 'Generated financial reports';
|
||||||
|
COMMENT ON TABLE settings IS 'Tenant-specific configuration';
|
||||||
521
packages/database/src/seed.ts
Normal file
521
packages/database/src/seed.ts
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
/**
|
||||||
|
* Database Seed Script
|
||||||
|
*
|
||||||
|
* Populates the database with initial data:
|
||||||
|
* - Subscription plans
|
||||||
|
* - System settings
|
||||||
|
* - Default super admin user
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool, PoolClient } from 'pg';
|
||||||
|
import { randomBytes, createHash } from 'crypto';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
// Database configuration
|
||||||
|
interface DatabaseConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
database: string;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
ssl?: boolean | { rejectUnauthorized: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get database configuration from environment
|
||||||
|
*/
|
||||||
|
function getConfig(): DatabaseConfig {
|
||||||
|
return {
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||||
|
database: process.env.DB_NAME || 'horux_strategy',
|
||||||
|
user: process.env.DB_USER || 'postgres',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
ssl: process.env.DB_SSL === 'true'
|
||||||
|
? { rejectUnauthorized: process.env.DB_SSL_REJECT_UNAUTHORIZED !== 'false' }
|
||||||
|
: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash password using SHA-256 (in production, use bcrypt)
|
||||||
|
* This is a placeholder - actual implementation should use bcrypt
|
||||||
|
*/
|
||||||
|
function hashPassword(password: string): string {
|
||||||
|
// In production, use bcrypt with proper salt rounds
|
||||||
|
// This is just for seeding purposes
|
||||||
|
const salt = randomBytes(16).toString('hex');
|
||||||
|
const hash = createHash('sha256').update(password + salt).digest('hex');
|
||||||
|
return `sha256:${salt}:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription plans data
|
||||||
|
*/
|
||||||
|
const PLANS = [
|
||||||
|
{
|
||||||
|
id: 'startup',
|
||||||
|
name: 'Startup',
|
||||||
|
description: 'Para emprendedores y negocios en crecimiento',
|
||||||
|
price_monthly_cents: 49900, // $499 MXN
|
||||||
|
price_yearly_cents: 479900, // $4,799 MXN (20% off)
|
||||||
|
max_users: 2,
|
||||||
|
max_cfdis_monthly: 100,
|
||||||
|
max_storage_mb: 1024, // 1 GB
|
||||||
|
max_api_calls_daily: 500,
|
||||||
|
max_reports_monthly: 5,
|
||||||
|
features: {
|
||||||
|
dashboard: true,
|
||||||
|
cfdi_management: true,
|
||||||
|
basic_reports: true,
|
||||||
|
email_support: true,
|
||||||
|
},
|
||||||
|
has_sat_sync: true,
|
||||||
|
has_bank_sync: false,
|
||||||
|
has_ai_insights: false,
|
||||||
|
has_custom_reports: false,
|
||||||
|
has_api_access: false,
|
||||||
|
has_white_label: false,
|
||||||
|
has_priority_support: false,
|
||||||
|
has_dedicated_account_manager: false,
|
||||||
|
data_retention_months: 12,
|
||||||
|
display_order: 1,
|
||||||
|
is_popular: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pyme',
|
||||||
|
name: 'PyME',
|
||||||
|
description: 'Para pequenas y medianas empresas',
|
||||||
|
price_monthly_cents: 99900, // $999 MXN
|
||||||
|
price_yearly_cents: 959900, // $9,599 MXN (20% off)
|
||||||
|
max_users: 5,
|
||||||
|
max_cfdis_monthly: 500,
|
||||||
|
max_storage_mb: 5120, // 5 GB
|
||||||
|
max_api_calls_daily: 2000,
|
||||||
|
max_reports_monthly: 20,
|
||||||
|
features: {
|
||||||
|
dashboard: true,
|
||||||
|
cfdi_management: true,
|
||||||
|
basic_reports: true,
|
||||||
|
advanced_reports: true,
|
||||||
|
email_support: true,
|
||||||
|
chat_support: true,
|
||||||
|
bank_reconciliation: true,
|
||||||
|
},
|
||||||
|
has_sat_sync: true,
|
||||||
|
has_bank_sync: true,
|
||||||
|
has_ai_insights: true,
|
||||||
|
has_custom_reports: false,
|
||||||
|
has_api_access: false,
|
||||||
|
has_white_label: false,
|
||||||
|
has_priority_support: false,
|
||||||
|
has_dedicated_account_manager: false,
|
||||||
|
data_retention_months: 24,
|
||||||
|
display_order: 2,
|
||||||
|
is_popular: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'enterprise',
|
||||||
|
name: 'Enterprise',
|
||||||
|
description: 'Para empresas en expansion con necesidades avanzadas',
|
||||||
|
price_monthly_cents: 249900, // $2,499 MXN
|
||||||
|
price_yearly_cents: 2399900, // $23,999 MXN (20% off)
|
||||||
|
max_users: 15,
|
||||||
|
max_cfdis_monthly: 2000,
|
||||||
|
max_storage_mb: 20480, // 20 GB
|
||||||
|
max_api_calls_daily: 10000,
|
||||||
|
max_reports_monthly: 100,
|
||||||
|
features: {
|
||||||
|
dashboard: true,
|
||||||
|
cfdi_management: true,
|
||||||
|
basic_reports: true,
|
||||||
|
advanced_reports: true,
|
||||||
|
custom_reports: true,
|
||||||
|
email_support: true,
|
||||||
|
chat_support: true,
|
||||||
|
phone_support: true,
|
||||||
|
bank_reconciliation: true,
|
||||||
|
multi_currency: true,
|
||||||
|
budget_planning: true,
|
||||||
|
api_access: true,
|
||||||
|
},
|
||||||
|
has_sat_sync: true,
|
||||||
|
has_bank_sync: true,
|
||||||
|
has_ai_insights: true,
|
||||||
|
has_custom_reports: true,
|
||||||
|
has_api_access: true,
|
||||||
|
has_white_label: false,
|
||||||
|
has_priority_support: true,
|
||||||
|
has_dedicated_account_manager: false,
|
||||||
|
data_retention_months: 60,
|
||||||
|
display_order: 3,
|
||||||
|
is_popular: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'corporativo',
|
||||||
|
name: 'Corporativo',
|
||||||
|
description: 'Solucion completa para grandes corporaciones',
|
||||||
|
price_monthly_cents: 499900, // $4,999 MXN
|
||||||
|
price_yearly_cents: 4799900, // $47,999 MXN (20% off)
|
||||||
|
max_users: 50,
|
||||||
|
max_cfdis_monthly: 10000,
|
||||||
|
max_storage_mb: 102400, // 100 GB
|
||||||
|
max_api_calls_daily: 50000,
|
||||||
|
max_reports_monthly: -1, // Unlimited
|
||||||
|
features: {
|
||||||
|
dashboard: true,
|
||||||
|
cfdi_management: true,
|
||||||
|
basic_reports: true,
|
||||||
|
advanced_reports: true,
|
||||||
|
custom_reports: true,
|
||||||
|
email_support: true,
|
||||||
|
chat_support: true,
|
||||||
|
phone_support: true,
|
||||||
|
bank_reconciliation: true,
|
||||||
|
multi_currency: true,
|
||||||
|
budget_planning: true,
|
||||||
|
api_access: true,
|
||||||
|
white_label: true,
|
||||||
|
sso: true,
|
||||||
|
audit_logs: true,
|
||||||
|
custom_integrations: true,
|
||||||
|
dedicated_infrastructure: true,
|
||||||
|
},
|
||||||
|
has_sat_sync: true,
|
||||||
|
has_bank_sync: true,
|
||||||
|
has_ai_insights: true,
|
||||||
|
has_custom_reports: true,
|
||||||
|
has_api_access: true,
|
||||||
|
has_white_label: true,
|
||||||
|
has_priority_support: true,
|
||||||
|
has_dedicated_account_manager: true,
|
||||||
|
data_retention_months: 120, // 10 years
|
||||||
|
display_order: 4,
|
||||||
|
is_popular: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System settings data
|
||||||
|
*/
|
||||||
|
const SYSTEM_SETTINGS = [
|
||||||
|
// General
|
||||||
|
{ key: 'app_name', value: 'Horux Strategy', category: 'general', value_type: 'string' },
|
||||||
|
{ key: 'app_version', value: '0.1.0', category: 'general', value_type: 'string' },
|
||||||
|
{ key: 'app_environment', value: 'development', category: 'general', value_type: 'string' },
|
||||||
|
{ key: 'default_timezone', value: 'America/Mexico_City', category: 'general', value_type: 'string' },
|
||||||
|
{ key: 'default_locale', value: 'es-MX', category: 'general', value_type: 'string' },
|
||||||
|
{ key: 'default_currency', value: 'MXN', category: 'general', value_type: 'string' },
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
{ key: 'session_duration_hours', value: '24', category: 'auth', value_type: 'integer' },
|
||||||
|
{ key: 'refresh_token_duration_days', value: '30', category: 'auth', value_type: 'integer' },
|
||||||
|
{ key: 'max_login_attempts', value: '5', category: 'auth', value_type: 'integer' },
|
||||||
|
{ key: 'lockout_duration_minutes', value: '30', category: 'auth', value_type: 'integer' },
|
||||||
|
{ key: 'password_min_length', value: '8', category: 'auth', value_type: 'integer' },
|
||||||
|
{ key: 'require_2fa_for_admin', value: 'true', category: 'auth', value_type: 'boolean' },
|
||||||
|
|
||||||
|
// Email
|
||||||
|
{ key: 'email_from_name', value: 'Horux Strategy', category: 'email', value_type: 'string' },
|
||||||
|
{ key: 'email_from_address', value: 'noreply@horuxstrategy.com', category: 'email', value_type: 'string' },
|
||||||
|
|
||||||
|
// SAT Integration
|
||||||
|
{ key: 'sat_api_base_url', value: 'https://cfdidescargamasiva.clouda.sat.gob.mx', category: 'sat', value_type: 'string' },
|
||||||
|
{ key: 'sat_sync_batch_size', value: '100', category: 'sat', value_type: 'integer' },
|
||||||
|
{ key: 'sat_sync_max_retries', value: '3', category: 'sat', value_type: 'integer' },
|
||||||
|
|
||||||
|
// Jobs
|
||||||
|
{ key: 'job_cleanup_days', value: '30', category: 'jobs', value_type: 'integer' },
|
||||||
|
{ key: 'job_max_concurrent', value: '5', category: 'jobs', value_type: 'integer' },
|
||||||
|
|
||||||
|
// Maintenance
|
||||||
|
{ key: 'maintenance_mode', value: 'false', category: 'maintenance', value_type: 'boolean' },
|
||||||
|
{ key: 'maintenance_message', value: '', category: 'maintenance', value_type: 'string' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed plans
|
||||||
|
*/
|
||||||
|
async function seedPlans(client: PoolClient): Promise<void> {
|
||||||
|
console.log('Seeding subscription plans...');
|
||||||
|
|
||||||
|
for (const plan of PLANS) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO plans (
|
||||||
|
id, name, description,
|
||||||
|
price_monthly_cents, price_yearly_cents,
|
||||||
|
max_users, max_cfdis_monthly, max_storage_mb, max_api_calls_daily, max_reports_monthly,
|
||||||
|
features,
|
||||||
|
has_sat_sync, has_bank_sync, has_ai_insights, has_custom_reports,
|
||||||
|
has_api_access, has_white_label, has_priority_support, has_dedicated_account_manager,
|
||||||
|
data_retention_months, display_order, is_popular
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
price_monthly_cents = EXCLUDED.price_monthly_cents,
|
||||||
|
price_yearly_cents = EXCLUDED.price_yearly_cents,
|
||||||
|
max_users = EXCLUDED.max_users,
|
||||||
|
max_cfdis_monthly = EXCLUDED.max_cfdis_monthly,
|
||||||
|
max_storage_mb = EXCLUDED.max_storage_mb,
|
||||||
|
max_api_calls_daily = EXCLUDED.max_api_calls_daily,
|
||||||
|
max_reports_monthly = EXCLUDED.max_reports_monthly,
|
||||||
|
features = EXCLUDED.features,
|
||||||
|
has_sat_sync = EXCLUDED.has_sat_sync,
|
||||||
|
has_bank_sync = EXCLUDED.has_bank_sync,
|
||||||
|
has_ai_insights = EXCLUDED.has_ai_insights,
|
||||||
|
has_custom_reports = EXCLUDED.has_custom_reports,
|
||||||
|
has_api_access = EXCLUDED.has_api_access,
|
||||||
|
has_white_label = EXCLUDED.has_white_label,
|
||||||
|
has_priority_support = EXCLUDED.has_priority_support,
|
||||||
|
has_dedicated_account_manager = EXCLUDED.has_dedicated_account_manager,
|
||||||
|
data_retention_months = EXCLUDED.data_retention_months,
|
||||||
|
display_order = EXCLUDED.display_order,
|
||||||
|
is_popular = EXCLUDED.is_popular,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[
|
||||||
|
plan.id,
|
||||||
|
plan.name,
|
||||||
|
plan.description,
|
||||||
|
plan.price_monthly_cents,
|
||||||
|
plan.price_yearly_cents,
|
||||||
|
plan.max_users,
|
||||||
|
plan.max_cfdis_monthly,
|
||||||
|
plan.max_storage_mb,
|
||||||
|
plan.max_api_calls_daily,
|
||||||
|
plan.max_reports_monthly,
|
||||||
|
JSON.stringify(plan.features),
|
||||||
|
plan.has_sat_sync,
|
||||||
|
plan.has_bank_sync,
|
||||||
|
plan.has_ai_insights,
|
||||||
|
plan.has_custom_reports,
|
||||||
|
plan.has_api_access,
|
||||||
|
plan.has_white_label,
|
||||||
|
plan.has_priority_support,
|
||||||
|
plan.has_dedicated_account_manager,
|
||||||
|
plan.data_retention_months,
|
||||||
|
plan.display_order,
|
||||||
|
plan.is_popular,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
console.log(` - Plan: ${plan.name} ($${(plan.price_monthly_cents / 100).toFixed(2)} MXN/mes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Total: ${PLANS.length} plans seeded\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed system settings
|
||||||
|
*/
|
||||||
|
async function seedSystemSettings(client: PoolClient): Promise<void> {
|
||||||
|
console.log('Seeding system settings...');
|
||||||
|
|
||||||
|
for (const setting of SYSTEM_SETTINGS) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO system_settings (key, value, value_type, category)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
value_type = EXCLUDED.value_type,
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[setting.key, setting.value, setting.value_type, setting.category]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Total: ${SYSTEM_SETTINGS.length} settings seeded\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed default super admin user
|
||||||
|
*/
|
||||||
|
async function seedSuperAdmin(client: PoolClient): Promise<void> {
|
||||||
|
console.log('Seeding super admin user...');
|
||||||
|
|
||||||
|
const email = process.env.SUPER_ADMIN_EMAIL || 'admin@horuxstrategy.com';
|
||||||
|
const password = process.env.SUPER_ADMIN_PASSWORD || 'HoruxAdmin2024!';
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existing = await client.query(
|
||||||
|
'SELECT id FROM users WHERE email = $1',
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
console.log(` - Super admin already exists: ${email}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create super admin
|
||||||
|
const passwordHash = hashPassword(password);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO users (
|
||||||
|
email, password_hash, first_name, last_name,
|
||||||
|
default_role, is_active, is_email_verified, email_verified_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, 'Super', 'Admin',
|
||||||
|
'super_admin', true, true, NOW()
|
||||||
|
)`,
|
||||||
|
[email, passwordHash]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(` - Created super admin: ${email}`);
|
||||||
|
console.log(` - Default password: ${password}`);
|
||||||
|
console.log(' - IMPORTANT: Change the password immediately!\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed demo tenant (optional)
|
||||||
|
*/
|
||||||
|
async function seedDemoTenant(client: PoolClient): Promise<void> {
|
||||||
|
if (process.env.SEED_DEMO_TENANT !== 'true') {
|
||||||
|
console.log('Skipping demo tenant (set SEED_DEMO_TENANT=true to enable)\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Seeding demo tenant...');
|
||||||
|
|
||||||
|
// Get super admin user
|
||||||
|
const adminResult = await client.query<{ id: string }>(
|
||||||
|
"SELECT id FROM users WHERE default_role = 'super_admin' LIMIT 1"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (adminResult.rows.length === 0) {
|
||||||
|
console.log(' - No super admin found, skipping demo tenant\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminId = adminResult.rows[0].id;
|
||||||
|
|
||||||
|
// Check if demo tenant exists
|
||||||
|
const existingTenant = await client.query(
|
||||||
|
"SELECT id FROM tenants WHERE slug = 'demo-company'"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingTenant.rows.length > 0) {
|
||||||
|
console.log(' - Demo tenant already exists\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create demo tenant
|
||||||
|
const tenantResult = await client.query<{ id: string }>(
|
||||||
|
`INSERT INTO tenants (
|
||||||
|
name, slug, schema_name, rfc, razon_social,
|
||||||
|
email, phone, owner_id, plan_id, status, settings
|
||||||
|
) VALUES (
|
||||||
|
'Empresa Demo S.A. de C.V.',
|
||||||
|
'demo-company',
|
||||||
|
'tenant_demo',
|
||||||
|
'XAXX010101000',
|
||||||
|
'Empresa Demo S.A. de C.V.',
|
||||||
|
'demo@horuxstrategy.com',
|
||||||
|
'+52 55 1234 5678',
|
||||||
|
$1,
|
||||||
|
'pyme',
|
||||||
|
'active',
|
||||||
|
$2
|
||||||
|
) RETURNING id`,
|
||||||
|
[
|
||||||
|
adminId,
|
||||||
|
JSON.stringify({
|
||||||
|
timezone: 'America/Mexico_City',
|
||||||
|
currency: 'MXN',
|
||||||
|
language: 'es-MX',
|
||||||
|
fiscalYearStart: 1,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tenantId = tenantResult.rows[0].id;
|
||||||
|
|
||||||
|
// Add admin to tenant
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO user_tenants (user_id, tenant_id, role, is_active, accepted_at)
|
||||||
|
VALUES ($1, $2, 'owner', true, NOW())`,
|
||||||
|
[adminId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create subscription
|
||||||
|
const trialEnds = new Date();
|
||||||
|
trialEnds.setDate(trialEnds.getDate() + 14); // 14-day trial
|
||||||
|
|
||||||
|
const periodEnd = new Date();
|
||||||
|
periodEnd.setMonth(periodEnd.getMonth() + 1);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO subscriptions (
|
||||||
|
tenant_id, plan_id, status, billing_cycle,
|
||||||
|
trial_ends_at, current_period_end, price_cents
|
||||||
|
) VALUES (
|
||||||
|
$1, 'pyme', 'trial', 'monthly',
|
||||||
|
$2, $3, 99900
|
||||||
|
)`,
|
||||||
|
[tenantId, trialEnds, periodEnd]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(` - Created demo tenant: Empresa Demo S.A. de C.V.`);
|
||||||
|
console.log(` - Tenant ID: ${tenantId}`);
|
||||||
|
console.log(` - Schema: tenant_demo`);
|
||||||
|
console.log(' - Note: Tenant schema needs to be created separately using createTenantSchema()\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main seed function
|
||||||
|
*/
|
||||||
|
async function seed(): Promise<void> {
|
||||||
|
const config = getConfig();
|
||||||
|
|
||||||
|
console.log('\nHorux Strategy - Database Seed');
|
||||||
|
console.log('==============================');
|
||||||
|
console.log(`Database: ${config.database}@${config.host}:${config.port}\n`);
|
||||||
|
|
||||||
|
const pool = new Pool(config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Run seeders
|
||||||
|
await seedPlans(client);
|
||||||
|
await seedSystemSettings(client);
|
||||||
|
await seedSuperAdmin(client);
|
||||||
|
await seedDemoTenant(client);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
console.log('Seed completed successfully!\n');
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Seed failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run if called directly
|
||||||
|
seed().catch(console.error);
|
||||||
|
|
||||||
|
// Export for programmatic use
|
||||||
|
export {
|
||||||
|
seed,
|
||||||
|
seedPlans,
|
||||||
|
seedSystemSettings,
|
||||||
|
seedSuperAdmin,
|
||||||
|
seedDemoTenant,
|
||||||
|
PLANS,
|
||||||
|
SYSTEM_SETTINGS,
|
||||||
|
};
|
||||||
619
packages/database/src/tenant.ts
Normal file
619
packages/database/src/tenant.ts
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
/**
|
||||||
|
* Tenant Schema Management
|
||||||
|
*
|
||||||
|
* Functions for creating, deleting, and managing tenant schemas.
|
||||||
|
* Each tenant gets their own PostgreSQL schema with isolated data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { DatabaseConnection, TenantContext } from './connection.js';
|
||||||
|
import { PoolClient } from 'pg';
|
||||||
|
|
||||||
|
// Get directory path for ES modules
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// Tenant creation options
|
||||||
|
export interface CreateTenantOptions {
|
||||||
|
tenantId: string;
|
||||||
|
companyName: string;
|
||||||
|
ownerId: string;
|
||||||
|
planId?: string;
|
||||||
|
settings?: TenantSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant settings
|
||||||
|
export interface TenantSettings {
|
||||||
|
timezone?: string;
|
||||||
|
currency?: string;
|
||||||
|
language?: string;
|
||||||
|
fiscalYearStart?: number; // Month (1-12)
|
||||||
|
dateFormat?: string;
|
||||||
|
numberFormat?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default tenant settings for Mexican companies
|
||||||
|
const DEFAULT_TENANT_SETTINGS: TenantSettings = {
|
||||||
|
timezone: 'America/Mexico_City',
|
||||||
|
currency: 'MXN',
|
||||||
|
language: 'es-MX',
|
||||||
|
fiscalYearStart: 1, // January
|
||||||
|
dateFormat: 'DD/MM/YYYY',
|
||||||
|
numberFormat: 'es-MX',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tenant info
|
||||||
|
export interface TenantInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
schemaName: string;
|
||||||
|
status: TenantStatus;
|
||||||
|
planId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant status
|
||||||
|
export type TenantStatus = 'active' | 'suspended' | 'pending' | 'deleted';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate schema name from tenant ID
|
||||||
|
*/
|
||||||
|
export function getSchemaName(tenantId: string): string {
|
||||||
|
// Validate tenant ID format (UUID or alphanumeric)
|
||||||
|
if (!/^[a-zA-Z0-9-_]+$/.test(tenantId)) {
|
||||||
|
throw new Error(`Invalid tenant ID format: ${tenantId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace hyphens with underscores for valid schema name
|
||||||
|
const safeTenantId = tenantId.replace(/-/g, '_');
|
||||||
|
return `tenant_${safeTenantId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create tenant context
|
||||||
|
*/
|
||||||
|
export function createTenantContext(tenantId: string, userId?: string): TenantContext {
|
||||||
|
return {
|
||||||
|
tenantId,
|
||||||
|
schemaName: getSchemaName(tenantId),
|
||||||
|
userId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the tenant schema migration SQL
|
||||||
|
*/
|
||||||
|
function getTenantSchemaSql(): string {
|
||||||
|
const migrationPath = join(__dirname, 'migrations', '002_tenant_schema.sql');
|
||||||
|
return readFileSync(migrationPath, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new tenant schema with all required tables
|
||||||
|
*/
|
||||||
|
export async function createTenantSchema(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
options: CreateTenantOptions
|
||||||
|
): Promise<TenantInfo> {
|
||||||
|
const { tenantId, companyName, ownerId, planId, settings } = options;
|
||||||
|
const schemaName = getSchemaName(tenantId);
|
||||||
|
const slug = companyName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
|
||||||
|
// Merge settings with defaults
|
||||||
|
const finalSettings = { ...DEFAULT_TENANT_SETTINGS, ...settings };
|
||||||
|
|
||||||
|
// Check if schema already exists
|
||||||
|
const exists = await db.schemaExists(schemaName);
|
||||||
|
if (exists) {
|
||||||
|
throw new Error(`Tenant schema already exists: ${schemaName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start transaction
|
||||||
|
const result = await db.transaction<TenantInfo>(async (client: PoolClient) => {
|
||||||
|
// 1. Create tenant record in public schema
|
||||||
|
const tenantResult = await client.query<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
schema_name: string;
|
||||||
|
status: TenantStatus;
|
||||||
|
plan_id: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}>(
|
||||||
|
`INSERT INTO public.tenants (id, name, slug, schema_name, owner_id, plan_id, status, settings)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7)
|
||||||
|
RETURNING id, name, slug, schema_name, status, plan_id, created_at, updated_at`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
companyName,
|
||||||
|
slug,
|
||||||
|
schemaName,
|
||||||
|
ownerId,
|
||||||
|
planId || 'startup',
|
||||||
|
JSON.stringify(finalSettings),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tenant = tenantResult.rows[0];
|
||||||
|
|
||||||
|
// 2. Create the schema
|
||||||
|
await client.query(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
|
||||||
|
|
||||||
|
// 3. Set search path to the new schema
|
||||||
|
await client.query(`SET search_path TO "${schemaName}", public`);
|
||||||
|
|
||||||
|
// 4. Execute tenant schema migration
|
||||||
|
const tenantSchemaSql = getTenantSchemaSql();
|
||||||
|
|
||||||
|
// Replace schema placeholder with actual schema name
|
||||||
|
const schemaSql = tenantSchemaSql.replace(/\$\{SCHEMA_NAME\}/g, schemaName);
|
||||||
|
|
||||||
|
await client.query(schemaSql);
|
||||||
|
|
||||||
|
// 5. Insert default settings
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO settings (key, value, category)
|
||||||
|
VALUES
|
||||||
|
('timezone', $1, 'general'),
|
||||||
|
('currency', $2, 'general'),
|
||||||
|
('language', $3, 'general'),
|
||||||
|
('fiscal_year_start', $4, 'accounting'),
|
||||||
|
('date_format', $5, 'display'),
|
||||||
|
('number_format', $6, 'display')`,
|
||||||
|
[
|
||||||
|
finalSettings.timezone,
|
||||||
|
finalSettings.currency,
|
||||||
|
finalSettings.language,
|
||||||
|
String(finalSettings.fiscalYearStart),
|
||||||
|
finalSettings.dateFormat,
|
||||||
|
finalSettings.numberFormat,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. Insert default categories
|
||||||
|
await insertDefaultCategories(client);
|
||||||
|
|
||||||
|
// 7. Insert default accounts (chart of accounts)
|
||||||
|
await insertDefaultAccounts(client);
|
||||||
|
|
||||||
|
// 8. Log the creation in audit log
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO public.audit_log (tenant_id, user_id, action, entity_type, entity_id, details)
|
||||||
|
VALUES ($1, $2, 'tenant.created', 'tenant', $1, $3)`,
|
||||||
|
[tenantId, ownerId, JSON.stringify({ company_name: companyName, schema_name: schemaName })]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset search path
|
||||||
|
await client.query('RESET search_path');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: tenant.id,
|
||||||
|
name: tenant.name,
|
||||||
|
slug: tenant.slug,
|
||||||
|
schemaName: tenant.schema_name,
|
||||||
|
status: tenant.status,
|
||||||
|
planId: tenant.plan_id,
|
||||||
|
createdAt: tenant.created_at,
|
||||||
|
updatedAt: tenant.updated_at,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert default expense/income categories
|
||||||
|
*/
|
||||||
|
async function insertDefaultCategories(client: PoolClient): Promise<void> {
|
||||||
|
// Mexican-specific expense categories
|
||||||
|
const categories = [
|
||||||
|
// Income categories
|
||||||
|
{ code: 'ING-001', name: 'Ventas de productos', type: 'income', sat_key: '84111506' },
|
||||||
|
{ code: 'ING-002', name: 'Servicios profesionales', type: 'income', sat_key: '80111601' },
|
||||||
|
{ code: 'ING-003', name: 'Comisiones', type: 'income', sat_key: '84111502' },
|
||||||
|
{ code: 'ING-004', name: 'Intereses ganados', type: 'income', sat_key: '84111503' },
|
||||||
|
{ code: 'ING-005', name: 'Otros ingresos', type: 'income', sat_key: '84111599' },
|
||||||
|
|
||||||
|
// Expense categories - Operating
|
||||||
|
{ code: 'GAS-001', name: 'Sueldos y salarios', type: 'expense', sat_key: '80111501' },
|
||||||
|
{ code: 'GAS-002', name: 'Servicios profesionales', type: 'expense', sat_key: '80111601' },
|
||||||
|
{ code: 'GAS-003', name: 'Arrendamiento', type: 'expense', sat_key: '80131501' },
|
||||||
|
{ code: 'GAS-004', name: 'Servicios de luz', type: 'expense', sat_key: '83101801' },
|
||||||
|
{ code: 'GAS-005', name: 'Servicios de agua', type: 'expense', sat_key: '83101802' },
|
||||||
|
{ code: 'GAS-006', name: 'Telecomunicaciones', type: 'expense', sat_key: '83111501' },
|
||||||
|
{ code: 'GAS-007', name: 'Combustibles', type: 'expense', sat_key: '15101506' },
|
||||||
|
{ code: 'GAS-008', name: 'Mantenimiento', type: 'expense', sat_key: '72101507' },
|
||||||
|
{ code: 'GAS-009', name: 'Papeleria y utiles', type: 'expense', sat_key: '44121600' },
|
||||||
|
{ code: 'GAS-010', name: 'Seguros y fianzas', type: 'expense', sat_key: '84131501' },
|
||||||
|
|
||||||
|
// Expense categories - Administrative
|
||||||
|
{ code: 'ADM-001', name: 'Honorarios contables', type: 'expense', sat_key: '80111604' },
|
||||||
|
{ code: 'ADM-002', name: 'Honorarios legales', type: 'expense', sat_key: '80111607' },
|
||||||
|
{ code: 'ADM-003', name: 'Capacitacion', type: 'expense', sat_key: '86101701' },
|
||||||
|
{ code: 'ADM-004', name: 'Publicidad', type: 'expense', sat_key: '80141600' },
|
||||||
|
{ code: 'ADM-005', name: 'Viajes y viaticos', type: 'expense', sat_key: '90101800' },
|
||||||
|
|
||||||
|
// Expense categories - Financial
|
||||||
|
{ code: 'FIN-001', name: 'Comisiones bancarias', type: 'expense', sat_key: '84111502' },
|
||||||
|
{ code: 'FIN-002', name: 'Intereses pagados', type: 'expense', sat_key: '84111503' },
|
||||||
|
|
||||||
|
// Cost categories
|
||||||
|
{ code: 'COS-001', name: 'Costo de ventas', type: 'cost', sat_key: '84111506' },
|
||||||
|
{ code: 'COS-002', name: 'Materia prima', type: 'cost', sat_key: '11000000' },
|
||||||
|
{ code: 'COS-003', name: 'Mano de obra directa', type: 'cost', sat_key: '80111501' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const cat of categories) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO categories (code, name, type, sat_key, is_system)
|
||||||
|
VALUES ($1, $2, $3, $4, true)`,
|
||||||
|
[cat.code, cat.name, cat.type, cat.sat_key]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert default chart of accounts (Mexican CUCA standard)
|
||||||
|
*/
|
||||||
|
async function insertDefaultAccounts(client: PoolClient): Promise<void> {
|
||||||
|
// Mexican Chart of Accounts (simplified)
|
||||||
|
const accounts = [
|
||||||
|
// Assets (1xxx)
|
||||||
|
{ code: '1000', name: 'Activo', type: 'asset', parent: null },
|
||||||
|
{ code: '1100', name: 'Activo Circulante', type: 'asset', parent: '1000' },
|
||||||
|
{ code: '1101', name: 'Caja', type: 'asset', parent: '1100' },
|
||||||
|
{ code: '1102', name: 'Bancos', type: 'asset', parent: '1100' },
|
||||||
|
{ code: '1103', name: 'Inversiones temporales', type: 'asset', parent: '1100' },
|
||||||
|
{ code: '1110', name: 'Clientes', type: 'asset', parent: '1100' },
|
||||||
|
{ code: '1120', name: 'Documentos por cobrar', type: 'asset', parent: '1100' },
|
||||||
|
{ code: '1130', name: 'Deudores diversos', type: 'asset', parent: '1100' },
|
||||||
|
{ code: '1140', name: 'Inventarios', type: 'asset', parent: '1100' },
|
||||||
|
{ code: '1150', name: 'Anticipo a proveedores', type: 'asset', parent: '1100' },
|
||||||
|
{ code: '1160', name: 'IVA acreditable', type: 'asset', parent: '1100' },
|
||||||
|
{ code: '1200', name: 'Activo Fijo', type: 'asset', parent: '1000' },
|
||||||
|
{ code: '1201', name: 'Terrenos', type: 'asset', parent: '1200' },
|
||||||
|
{ code: '1202', name: 'Edificios', type: 'asset', parent: '1200' },
|
||||||
|
{ code: '1203', name: 'Maquinaria y equipo', type: 'asset', parent: '1200' },
|
||||||
|
{ code: '1204', name: 'Equipo de transporte', type: 'asset', parent: '1200' },
|
||||||
|
{ code: '1205', name: 'Equipo de computo', type: 'asset', parent: '1200' },
|
||||||
|
{ code: '1206', name: 'Mobiliario y equipo', type: 'asset', parent: '1200' },
|
||||||
|
{ code: '1210', name: 'Depreciacion acumulada', type: 'asset', parent: '1200' },
|
||||||
|
|
||||||
|
// Liabilities (2xxx)
|
||||||
|
{ code: '2000', name: 'Pasivo', type: 'liability', parent: null },
|
||||||
|
{ code: '2100', name: 'Pasivo Corto Plazo', type: 'liability', parent: '2000' },
|
||||||
|
{ code: '2101', name: 'Proveedores', type: 'liability', parent: '2100' },
|
||||||
|
{ code: '2102', name: 'Acreedores diversos', type: 'liability', parent: '2100' },
|
||||||
|
{ code: '2103', name: 'Documentos por pagar', type: 'liability', parent: '2100' },
|
||||||
|
{ code: '2110', name: 'Impuestos por pagar', type: 'liability', parent: '2100' },
|
||||||
|
{ code: '2111', name: 'IVA por pagar', type: 'liability', parent: '2110' },
|
||||||
|
{ code: '2112', name: 'ISR por pagar', type: 'liability', parent: '2110' },
|
||||||
|
{ code: '2113', name: 'Retenciones por pagar', type: 'liability', parent: '2110' },
|
||||||
|
{ code: '2120', name: 'Anticipo de clientes', type: 'liability', parent: '2100' },
|
||||||
|
{ code: '2200', name: 'Pasivo Largo Plazo', type: 'liability', parent: '2000' },
|
||||||
|
{ code: '2201', name: 'Prestamos bancarios LP', type: 'liability', parent: '2200' },
|
||||||
|
{ code: '2202', name: 'Hipotecas por pagar', type: 'liability', parent: '2200' },
|
||||||
|
|
||||||
|
// Equity (3xxx)
|
||||||
|
{ code: '3000', name: 'Capital Contable', type: 'equity', parent: null },
|
||||||
|
{ code: '3100', name: 'Capital Social', type: 'equity', parent: '3000' },
|
||||||
|
{ code: '3200', name: 'Reserva legal', type: 'equity', parent: '3000' },
|
||||||
|
{ code: '3300', name: 'Resultados acumulados', type: 'equity', parent: '3000' },
|
||||||
|
{ code: '3400', name: 'Resultado del ejercicio', type: 'equity', parent: '3000' },
|
||||||
|
|
||||||
|
// Revenue (4xxx)
|
||||||
|
{ code: '4000', name: 'Ingresos', type: 'revenue', parent: null },
|
||||||
|
{ code: '4100', name: 'Ventas', type: 'revenue', parent: '4000' },
|
||||||
|
{ code: '4101', name: 'Ventas nacionales', type: 'revenue', parent: '4100' },
|
||||||
|
{ code: '4102', name: 'Ventas de exportacion', type: 'revenue', parent: '4100' },
|
||||||
|
{ code: '4200', name: 'Productos financieros', type: 'revenue', parent: '4000' },
|
||||||
|
{ code: '4300', name: 'Otros ingresos', type: 'revenue', parent: '4000' },
|
||||||
|
|
||||||
|
// Expenses (5xxx-6xxx)
|
||||||
|
{ code: '5000', name: 'Costo de Ventas', type: 'expense', parent: null },
|
||||||
|
{ code: '5100', name: 'Costo de lo vendido', type: 'expense', parent: '5000' },
|
||||||
|
{ code: '6000', name: 'Gastos de Operacion', type: 'expense', parent: null },
|
||||||
|
{ code: '6100', name: 'Gastos de venta', type: 'expense', parent: '6000' },
|
||||||
|
{ code: '6200', name: 'Gastos de administracion', type: 'expense', parent: '6000' },
|
||||||
|
{ code: '6300', name: 'Gastos financieros', type: 'expense', parent: '6000' },
|
||||||
|
{ code: '6400', name: 'Otros gastos', type: 'expense', parent: '6000' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// First pass: insert accounts without parent references
|
||||||
|
for (const account of accounts) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO accounts (code, name, type, is_system)
|
||||||
|
VALUES ($1, $2, $3, true)`,
|
||||||
|
[account.code, account.name, account.type]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: update parent references
|
||||||
|
for (const account of accounts) {
|
||||||
|
if (account.parent) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE accounts SET parent_id = (SELECT id FROM accounts WHERE code = $1)
|
||||||
|
WHERE code = $2`,
|
||||||
|
[account.parent, account.code]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a tenant schema and all its data
|
||||||
|
* WARNING: This is destructive and cannot be undone!
|
||||||
|
*/
|
||||||
|
export async function deleteTenantSchema(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
tenantId: string,
|
||||||
|
deletedBy: string,
|
||||||
|
hardDelete: boolean = false
|
||||||
|
): Promise<void> {
|
||||||
|
const schemaName = getSchemaName(tenantId);
|
||||||
|
|
||||||
|
await db.transaction(async (client: PoolClient) => {
|
||||||
|
// Verify tenant exists
|
||||||
|
const tenantResult = await client.query<{ status: TenantStatus }>(
|
||||||
|
'SELECT status FROM public.tenants WHERE id = $1',
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tenantResult.rows.length === 0) {
|
||||||
|
throw new Error(`Tenant not found: ${tenantId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hardDelete) {
|
||||||
|
// Drop the schema and all its objects
|
||||||
|
await client.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
|
||||||
|
|
||||||
|
// Delete tenant record
|
||||||
|
await client.query('DELETE FROM public.tenants WHERE id = $1', [tenantId]);
|
||||||
|
} else {
|
||||||
|
// Soft delete: mark as deleted but keep data
|
||||||
|
await client.query(
|
||||||
|
`UPDATE public.tenants SET status = 'deleted', updated_at = NOW() WHERE id = $1`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the deletion
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO public.audit_log (tenant_id, user_id, action, entity_type, entity_id, details)
|
||||||
|
VALUES ($1, $2, 'tenant.deleted', 'tenant', $1, $3)`,
|
||||||
|
[tenantId, deletedBy, JSON.stringify({ hard_delete: hardDelete, schema_name: schemaName })]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspend a tenant (deactivate but don't delete)
|
||||||
|
*/
|
||||||
|
export async function suspendTenant(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
tenantId: string,
|
||||||
|
suspendedBy: string,
|
||||||
|
reason?: string
|
||||||
|
): Promise<void> {
|
||||||
|
await db.transaction(async (client: PoolClient) => {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE public.tenants SET status = 'suspended', updated_at = NOW() WHERE id = $1`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO public.audit_log (tenant_id, user_id, action, entity_type, entity_id, details)
|
||||||
|
VALUES ($1, $2, 'tenant.suspended', 'tenant', $1, $3)`,
|
||||||
|
[tenantId, suspendedBy, JSON.stringify({ reason })]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactivate a suspended tenant
|
||||||
|
*/
|
||||||
|
export async function reactivateTenant(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
tenantId: string,
|
||||||
|
reactivatedBy: string
|
||||||
|
): Promise<void> {
|
||||||
|
await db.transaction(async (client: PoolClient) => {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE public.tenants SET status = 'active', updated_at = NOW() WHERE id = $1`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO public.audit_log (tenant_id, user_id, action, entity_type, entity_id, details)
|
||||||
|
VALUES ($1, $2, 'tenant.reactivated', 'tenant', $1, $3)`,
|
||||||
|
[tenantId, reactivatedBy, JSON.stringify({})]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tenant information
|
||||||
|
*/
|
||||||
|
export async function getTenant(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<TenantInfo | null> {
|
||||||
|
const result = await db.query<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
schema_name: string;
|
||||||
|
status: TenantStatus;
|
||||||
|
plan_id: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}>(
|
||||||
|
`SELECT id, name, slug, schema_name, status, plan_id, created_at, updated_at
|
||||||
|
FROM public.tenants WHERE id = $1`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = result.rows[0];
|
||||||
|
return {
|
||||||
|
id: tenant.id,
|
||||||
|
name: tenant.name,
|
||||||
|
slug: tenant.slug,
|
||||||
|
schemaName: tenant.schema_name,
|
||||||
|
status: tenant.status,
|
||||||
|
planId: tenant.plan_id,
|
||||||
|
createdAt: tenant.created_at,
|
||||||
|
updatedAt: tenant.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all tenants
|
||||||
|
*/
|
||||||
|
export async function listTenants(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
status?: TenantStatus
|
||||||
|
): Promise<TenantInfo[]> {
|
||||||
|
let query = `
|
||||||
|
SELECT id, name, slug, schema_name, status, plan_id, created_at, updated_at
|
||||||
|
FROM public.tenants
|
||||||
|
`;
|
||||||
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
query += ' WHERE status = $1';
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY created_at DESC';
|
||||||
|
|
||||||
|
const result = await db.query<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
schema_name: string;
|
||||||
|
status: TenantStatus;
|
||||||
|
plan_id: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}>(query, params);
|
||||||
|
|
||||||
|
return result.rows.map(tenant => ({
|
||||||
|
id: tenant.id,
|
||||||
|
name: tenant.name,
|
||||||
|
slug: tenant.slug,
|
||||||
|
schemaName: tenant.schema_name,
|
||||||
|
status: tenant.status,
|
||||||
|
planId: tenant.plan_id,
|
||||||
|
createdAt: tenant.created_at,
|
||||||
|
updatedAt: tenant.updated_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update tenant settings
|
||||||
|
*/
|
||||||
|
export async function updateTenantSettings(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
tenantId: string,
|
||||||
|
settings: Partial<TenantSettings>,
|
||||||
|
updatedBy: string
|
||||||
|
): Promise<void> {
|
||||||
|
const tenant = await getTenant(db, tenantId);
|
||||||
|
if (!tenant) {
|
||||||
|
throw new Error(`Tenant not found: ${tenantId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (client: PoolClient) => {
|
||||||
|
// Update settings in tenant schema
|
||||||
|
await client.query(`SET search_path TO "${tenant.schemaName}", public`);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO settings (key, value, category)
|
||||||
|
VALUES ($1, $2, 'general')
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
|
||||||
|
[snakeKey, String(value)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('RESET search_path');
|
||||||
|
|
||||||
|
// Log the update
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO public.audit_log (tenant_id, user_id, action, entity_type, entity_id, details)
|
||||||
|
VALUES ($1, $2, 'tenant.settings_updated', 'tenant', $1, $3)`,
|
||||||
|
[tenantId, updatedBy, JSON.stringify(settings)]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tenant by slug
|
||||||
|
*/
|
||||||
|
export async function getTenantBySlug(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
slug: string
|
||||||
|
): Promise<TenantInfo | null> {
|
||||||
|
const result = await db.query<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
schema_name: string;
|
||||||
|
status: TenantStatus;
|
||||||
|
plan_id: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}>(
|
||||||
|
`SELECT id, name, slug, schema_name, status, plan_id, created_at, updated_at
|
||||||
|
FROM public.tenants WHERE slug = $1`,
|
||||||
|
[slug]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = result.rows[0];
|
||||||
|
return {
|
||||||
|
id: tenant.id,
|
||||||
|
name: tenant.name,
|
||||||
|
slug: tenant.slug,
|
||||||
|
schemaName: tenant.schema_name,
|
||||||
|
status: tenant.status,
|
||||||
|
planId: tenant.plan_id,
|
||||||
|
createdAt: tenant.created_at,
|
||||||
|
updatedAt: tenant.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate tenant access
|
||||||
|
* Returns true if the tenant exists and is active
|
||||||
|
*/
|
||||||
|
export async function validateTenantAccess(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await db.query<{ status: TenantStatus }>(
|
||||||
|
'SELECT status FROM public.tenants WHERE id = $1',
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.rows[0].status === 'active';
|
||||||
|
}
|
||||||
27
packages/database/tsconfig.json
Normal file
27
packages/database/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"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,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"isolatedModules": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||||
|
}
|
||||||
23
packages/shared/package.json
Normal file
23
packages/shared/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@horux/shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Shared types and utilities for Horux Strategy",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsc --watch",
|
||||||
|
"lint": "eslint src --ext .ts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"clean": "rm -rf dist node_modules"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
630
packages/shared/src/constants/index.ts
Normal file
630
packages/shared/src/constants/index.ts
Normal file
@@ -0,0 +1,630 @@
|
|||||||
|
/**
|
||||||
|
* Constants for Horux Strategy
|
||||||
|
* Roles, permissions, document states, and error codes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { UserRole, UserPermission } from '../types/auth';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Roles & Permissions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available user roles
|
||||||
|
*/
|
||||||
|
export const USER_ROLES = {
|
||||||
|
SUPER_ADMIN: 'super_admin' as const,
|
||||||
|
TENANT_ADMIN: 'tenant_admin' as const,
|
||||||
|
ACCOUNTANT: 'accountant' as const,
|
||||||
|
ASSISTANT: 'assistant' as const,
|
||||||
|
VIEWER: 'viewer' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role display names in Spanish
|
||||||
|
*/
|
||||||
|
export const ROLE_NAMES: Record<UserRole, string> = {
|
||||||
|
super_admin: 'Super Administrador',
|
||||||
|
tenant_admin: 'Administrador',
|
||||||
|
accountant: 'Contador',
|
||||||
|
assistant: 'Asistente',
|
||||||
|
viewer: 'Solo Lectura',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role descriptions
|
||||||
|
*/
|
||||||
|
export const ROLE_DESCRIPTIONS: Record<UserRole, string> = {
|
||||||
|
super_admin: 'Acceso completo al sistema y todas las empresas',
|
||||||
|
tenant_admin: 'Administración completa de la empresa',
|
||||||
|
accountant: 'Acceso completo a funciones contables y financieras',
|
||||||
|
assistant: 'Acceso limitado para captura de información',
|
||||||
|
viewer: 'Solo puede visualizar información, sin editar',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available resources for permissions
|
||||||
|
*/
|
||||||
|
export const RESOURCES = {
|
||||||
|
TRANSACTIONS: 'transactions',
|
||||||
|
INVOICES: 'invoices',
|
||||||
|
CONTACTS: 'contacts',
|
||||||
|
ACCOUNTS: 'accounts',
|
||||||
|
CATEGORIES: 'categories',
|
||||||
|
REPORTS: 'reports',
|
||||||
|
SETTINGS: 'settings',
|
||||||
|
USERS: 'users',
|
||||||
|
BILLING: 'billing',
|
||||||
|
INTEGRATIONS: 'integrations',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default permissions per role
|
||||||
|
*/
|
||||||
|
export const DEFAULT_ROLE_PERMISSIONS: Record<UserRole, UserPermission[]> = {
|
||||||
|
super_admin: [
|
||||||
|
{ resource: '*', actions: ['create', 'read', 'update', 'delete'] },
|
||||||
|
],
|
||||||
|
tenant_admin: [
|
||||||
|
{ resource: 'transactions', actions: ['create', 'read', 'update', 'delete'] },
|
||||||
|
{ resource: 'invoices', actions: ['create', 'read', 'update', 'delete'] },
|
||||||
|
{ resource: 'contacts', actions: ['create', 'read', 'update', 'delete'] },
|
||||||
|
{ resource: 'accounts', actions: ['create', 'read', 'update', 'delete'] },
|
||||||
|
{ resource: 'categories', actions: ['create', 'read', 'update', 'delete'] },
|
||||||
|
{ resource: 'reports', actions: ['create', 'read', 'update', 'delete'] },
|
||||||
|
{ resource: 'settings', actions: ['create', 'read', 'update', 'delete'] },
|
||||||
|
{ resource: 'users', actions: ['create', 'read', 'update', 'delete'] },
|
||||||
|
{ resource: 'billing', actions: ['create', 'read', 'update', 'delete'] },
|
||||||
|
{ resource: 'integrations', actions: ['create', 'read', 'update', 'delete'] },
|
||||||
|
],
|
||||||
|
accountant: [
|
||||||
|
{ resource: 'transactions', actions: ['create', 'read', 'update', 'delete'] },
|
||||||
|
{ resource: 'invoices', actions: ['create', 'read', 'update', 'delete'] },
|
||||||
|
{ resource: 'contacts', actions: ['create', 'read', 'update'] },
|
||||||
|
{ resource: 'accounts', actions: ['create', 'read', 'update'] },
|
||||||
|
{ resource: 'categories', actions: ['create', 'read', 'update'] },
|
||||||
|
{ resource: 'reports', actions: ['create', 'read'] },
|
||||||
|
{ resource: 'settings', actions: ['read'] },
|
||||||
|
{ resource: 'integrations', actions: ['read'] },
|
||||||
|
],
|
||||||
|
assistant: [
|
||||||
|
{ resource: 'transactions', actions: ['create', 'read', 'update'] },
|
||||||
|
{ resource: 'invoices', actions: ['create', 'read'] },
|
||||||
|
{ resource: 'contacts', actions: ['create', 'read'] },
|
||||||
|
{ resource: 'accounts', actions: ['read'] },
|
||||||
|
{ resource: 'categories', actions: ['read'] },
|
||||||
|
{ resource: 'reports', actions: ['read'] },
|
||||||
|
],
|
||||||
|
viewer: [
|
||||||
|
{ resource: 'transactions', actions: ['read'] },
|
||||||
|
{ resource: 'invoices', actions: ['read'] },
|
||||||
|
{ resource: 'contacts', actions: ['read'] },
|
||||||
|
{ resource: 'accounts', actions: ['read'] },
|
||||||
|
{ resource: 'categories', actions: ['read'] },
|
||||||
|
{ resource: 'reports', actions: ['read'] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Document States
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction statuses
|
||||||
|
*/
|
||||||
|
export const TRANSACTION_STATUS = {
|
||||||
|
PENDING: 'pending',
|
||||||
|
CLEARED: 'cleared',
|
||||||
|
RECONCILED: 'reconciled',
|
||||||
|
VOIDED: 'voided',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const TRANSACTION_STATUS_NAMES: Record<string, string> = {
|
||||||
|
pending: 'Pendiente',
|
||||||
|
cleared: 'Procesado',
|
||||||
|
reconciled: 'Conciliado',
|
||||||
|
voided: 'Anulado',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TRANSACTION_STATUS_COLORS: Record<string, string> = {
|
||||||
|
pending: 'yellow',
|
||||||
|
cleared: 'blue',
|
||||||
|
reconciled: 'green',
|
||||||
|
voided: 'gray',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CFDI statuses
|
||||||
|
*/
|
||||||
|
export const CFDI_STATUS = {
|
||||||
|
DRAFT: 'draft',
|
||||||
|
PENDING: 'pending',
|
||||||
|
STAMPED: 'stamped',
|
||||||
|
SENT: 'sent',
|
||||||
|
PAID: 'paid',
|
||||||
|
PARTIAL_PAID: 'partial_paid',
|
||||||
|
CANCELLED: 'cancelled',
|
||||||
|
CANCELLATION_PENDING: 'cancellation_pending',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const CFDI_STATUS_NAMES: Record<string, string> = {
|
||||||
|
draft: 'Borrador',
|
||||||
|
pending: 'Pendiente de Timbrar',
|
||||||
|
stamped: 'Timbrado',
|
||||||
|
sent: 'Enviado',
|
||||||
|
paid: 'Pagado',
|
||||||
|
partial_paid: 'Pago Parcial',
|
||||||
|
cancelled: 'Cancelado',
|
||||||
|
cancellation_pending: 'Cancelación Pendiente',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CFDI_STATUS_COLORS: Record<string, string> = {
|
||||||
|
draft: 'gray',
|
||||||
|
pending: 'yellow',
|
||||||
|
stamped: 'blue',
|
||||||
|
sent: 'indigo',
|
||||||
|
paid: 'green',
|
||||||
|
partial_paid: 'orange',
|
||||||
|
cancelled: 'red',
|
||||||
|
cancellation_pending: 'pink',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CFDI types
|
||||||
|
*/
|
||||||
|
export const CFDI_TYPES = {
|
||||||
|
INGRESO: 'I',
|
||||||
|
EGRESO: 'E',
|
||||||
|
TRASLADO: 'T',
|
||||||
|
NOMINA: 'N',
|
||||||
|
PAGO: 'P',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const CFDI_TYPE_NAMES: Record<string, string> = {
|
||||||
|
I: 'Ingreso',
|
||||||
|
E: 'Egreso',
|
||||||
|
T: 'Traslado',
|
||||||
|
N: 'Nómina',
|
||||||
|
P: 'Pago',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CFDI Usage codes (Uso del CFDI)
|
||||||
|
*/
|
||||||
|
export const CFDI_USAGE_CODES: Record<string, string> = {
|
||||||
|
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',
|
||||||
|
D10: 'Pagos por servicios educativos (colegiaturas)',
|
||||||
|
S01: 'Sin efectos fiscales',
|
||||||
|
CP01: 'Pagos',
|
||||||
|
CN01: 'Nómina',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payment forms (Forma de pago SAT)
|
||||||
|
*/
|
||||||
|
export const PAYMENT_FORMS: Record<string, string> = {
|
||||||
|
'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',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CFDI Cancellation reasons
|
||||||
|
*/
|
||||||
|
export const CFDI_CANCELLATION_REASONS: Record<string, string> = {
|
||||||
|
'01': 'Comprobante emitido con errores con relación',
|
||||||
|
'02': 'Comprobante emitido con errores sin relación',
|
||||||
|
'03': 'No se llevó a cabo la operación',
|
||||||
|
'04': 'Operación nominativa relacionada en una factura global',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant statuses
|
||||||
|
*/
|
||||||
|
export const TENANT_STATUS = {
|
||||||
|
PENDING: 'pending',
|
||||||
|
ACTIVE: 'active',
|
||||||
|
SUSPENDED: 'suspended',
|
||||||
|
CANCELLED: 'cancelled',
|
||||||
|
TRIAL: 'trial',
|
||||||
|
EXPIRED: 'expired',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const TENANT_STATUS_NAMES: Record<string, string> = {
|
||||||
|
pending: 'Pendiente',
|
||||||
|
active: 'Activo',
|
||||||
|
suspended: 'Suspendido',
|
||||||
|
cancelled: 'Cancelado',
|
||||||
|
trial: 'Prueba',
|
||||||
|
expired: 'Expirado',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription statuses
|
||||||
|
*/
|
||||||
|
export const SUBSCRIPTION_STATUS = {
|
||||||
|
TRIALING: 'trialing',
|
||||||
|
ACTIVE: 'active',
|
||||||
|
PAST_DUE: 'past_due',
|
||||||
|
CANCELED: 'canceled',
|
||||||
|
UNPAID: 'unpaid',
|
||||||
|
PAUSED: 'paused',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const SUBSCRIPTION_STATUS_NAMES: Record<string, string> = {
|
||||||
|
trialing: 'Período de Prueba',
|
||||||
|
active: 'Activa',
|
||||||
|
past_due: 'Pago Atrasado',
|
||||||
|
canceled: 'Cancelada',
|
||||||
|
unpaid: 'Sin Pagar',
|
||||||
|
paused: 'Pausada',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Codes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const ERROR_CODES = {
|
||||||
|
// Authentication errors (1xxx)
|
||||||
|
AUTH_INVALID_CREDENTIALS: 'AUTH_001',
|
||||||
|
AUTH_TOKEN_EXPIRED: 'AUTH_002',
|
||||||
|
AUTH_TOKEN_INVALID: 'AUTH_003',
|
||||||
|
AUTH_REFRESH_TOKEN_EXPIRED: 'AUTH_004',
|
||||||
|
AUTH_USER_NOT_FOUND: 'AUTH_005',
|
||||||
|
AUTH_USER_DISABLED: 'AUTH_006',
|
||||||
|
AUTH_EMAIL_NOT_VERIFIED: 'AUTH_007',
|
||||||
|
AUTH_TWO_FACTOR_REQUIRED: 'AUTH_008',
|
||||||
|
AUTH_TWO_FACTOR_INVALID: 'AUTH_009',
|
||||||
|
AUTH_SESSION_EXPIRED: 'AUTH_010',
|
||||||
|
AUTH_PASSWORD_INCORRECT: 'AUTH_011',
|
||||||
|
AUTH_PASSWORD_WEAK: 'AUTH_012',
|
||||||
|
AUTH_EMAIL_ALREADY_EXISTS: 'AUTH_013',
|
||||||
|
AUTH_INVITATION_EXPIRED: 'AUTH_014',
|
||||||
|
AUTH_INVITATION_INVALID: 'AUTH_015',
|
||||||
|
|
||||||
|
// Authorization errors (2xxx)
|
||||||
|
AUTHZ_FORBIDDEN: 'AUTHZ_001',
|
||||||
|
AUTHZ_INSUFFICIENT_PERMISSIONS: 'AUTHZ_002',
|
||||||
|
AUTHZ_RESOURCE_NOT_ACCESSIBLE: 'AUTHZ_003',
|
||||||
|
AUTHZ_TENANT_MISMATCH: 'AUTHZ_004',
|
||||||
|
|
||||||
|
// Validation errors (3xxx)
|
||||||
|
VALIDATION_FAILED: 'VAL_001',
|
||||||
|
VALIDATION_REQUIRED_FIELD: 'VAL_002',
|
||||||
|
VALIDATION_INVALID_FORMAT: 'VAL_003',
|
||||||
|
VALIDATION_INVALID_VALUE: 'VAL_004',
|
||||||
|
VALIDATION_TOO_LONG: 'VAL_005',
|
||||||
|
VALIDATION_TOO_SHORT: 'VAL_006',
|
||||||
|
VALIDATION_OUT_OF_RANGE: 'VAL_007',
|
||||||
|
VALIDATION_DUPLICATE: 'VAL_008',
|
||||||
|
|
||||||
|
// Resource errors (4xxx)
|
||||||
|
RESOURCE_NOT_FOUND: 'RES_001',
|
||||||
|
RESOURCE_ALREADY_EXISTS: 'RES_002',
|
||||||
|
RESOURCE_CONFLICT: 'RES_003',
|
||||||
|
RESOURCE_LOCKED: 'RES_004',
|
||||||
|
RESOURCE_DELETED: 'RES_005',
|
||||||
|
|
||||||
|
// Business logic errors (5xxx)
|
||||||
|
BUSINESS_INVALID_OPERATION: 'BIZ_001',
|
||||||
|
BUSINESS_INSUFFICIENT_BALANCE: 'BIZ_002',
|
||||||
|
BUSINESS_LIMIT_EXCEEDED: 'BIZ_003',
|
||||||
|
BUSINESS_INVALID_STATE: 'BIZ_004',
|
||||||
|
BUSINESS_DEPENDENCY_ERROR: 'BIZ_005',
|
||||||
|
|
||||||
|
// CFDI errors (6xxx)
|
||||||
|
CFDI_STAMPING_FAILED: 'CFDI_001',
|
||||||
|
CFDI_CANCELLATION_FAILED: 'CFDI_002',
|
||||||
|
CFDI_INVALID_RFC: 'CFDI_003',
|
||||||
|
CFDI_INVALID_POSTAL_CODE: 'CFDI_004',
|
||||||
|
CFDI_ALREADY_STAMPED: 'CFDI_005',
|
||||||
|
CFDI_ALREADY_CANCELLED: 'CFDI_006',
|
||||||
|
CFDI_NOT_FOUND_SAT: 'CFDI_007',
|
||||||
|
CFDI_XML_INVALID: 'CFDI_008',
|
||||||
|
CFDI_CERTIFICATE_EXPIRED: 'CFDI_009',
|
||||||
|
CFDI_PAC_ERROR: 'CFDI_010',
|
||||||
|
|
||||||
|
// Subscription/Billing errors (7xxx)
|
||||||
|
BILLING_PAYMENT_FAILED: 'BILL_001',
|
||||||
|
BILLING_CARD_DECLINED: 'BILL_002',
|
||||||
|
BILLING_SUBSCRIPTION_EXPIRED: 'BILL_003',
|
||||||
|
BILLING_PLAN_NOT_AVAILABLE: 'BILL_004',
|
||||||
|
BILLING_PROMO_CODE_INVALID: 'BILL_005',
|
||||||
|
BILLING_PROMO_CODE_EXPIRED: 'BILL_006',
|
||||||
|
BILLING_DOWNGRADE_NOT_ALLOWED: 'BILL_007',
|
||||||
|
|
||||||
|
// Integration errors (8xxx)
|
||||||
|
INTEGRATION_CONNECTION_FAILED: 'INT_001',
|
||||||
|
INTEGRATION_AUTH_FAILED: 'INT_002',
|
||||||
|
INTEGRATION_SYNC_FAILED: 'INT_003',
|
||||||
|
INTEGRATION_NOT_CONFIGURED: 'INT_004',
|
||||||
|
INTEGRATION_RATE_LIMITED: 'INT_005',
|
||||||
|
|
||||||
|
// System errors (9xxx)
|
||||||
|
SYSTEM_INTERNAL_ERROR: 'SYS_001',
|
||||||
|
SYSTEM_SERVICE_UNAVAILABLE: 'SYS_002',
|
||||||
|
SYSTEM_TIMEOUT: 'SYS_003',
|
||||||
|
SYSTEM_MAINTENANCE: 'SYS_004',
|
||||||
|
SYSTEM_RATE_LIMITED: 'SYS_005',
|
||||||
|
SYSTEM_STORAGE_FULL: 'SYS_006',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error messages in Spanish
|
||||||
|
*/
|
||||||
|
export const ERROR_MESSAGES: Record<ErrorCode, string> = {
|
||||||
|
// Auth
|
||||||
|
AUTH_001: 'Credenciales inválidas',
|
||||||
|
AUTH_002: 'La sesión ha expirado',
|
||||||
|
AUTH_003: 'Token de acceso inválido',
|
||||||
|
AUTH_004: 'Token de actualización expirado',
|
||||||
|
AUTH_005: 'Usuario no encontrado',
|
||||||
|
AUTH_006: 'Usuario deshabilitado',
|
||||||
|
AUTH_007: 'Correo electrónico no verificado',
|
||||||
|
AUTH_008: 'Se requiere autenticación de dos factores',
|
||||||
|
AUTH_009: 'Código de verificación inválido',
|
||||||
|
AUTH_010: 'La sesión ha expirado',
|
||||||
|
AUTH_011: 'Contraseña incorrecta',
|
||||||
|
AUTH_012: 'La contraseña no cumple los requisitos de seguridad',
|
||||||
|
AUTH_013: 'El correo electrónico ya está registrado',
|
||||||
|
AUTH_014: 'La invitación ha expirado',
|
||||||
|
AUTH_015: 'Invitación inválida',
|
||||||
|
|
||||||
|
// Authz
|
||||||
|
AUTHZ_001: 'No tienes permiso para realizar esta acción',
|
||||||
|
AUTHZ_002: 'Permisos insuficientes',
|
||||||
|
AUTHZ_003: 'No tienes acceso a este recurso',
|
||||||
|
AUTHZ_004: 'No tienes acceso a esta empresa',
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
VAL_001: 'Error de validación',
|
||||||
|
VAL_002: 'Campo requerido',
|
||||||
|
VAL_003: 'Formato inválido',
|
||||||
|
VAL_004: 'Valor inválido',
|
||||||
|
VAL_005: 'El valor es demasiado largo',
|
||||||
|
VAL_006: 'El valor es demasiado corto',
|
||||||
|
VAL_007: 'Valor fuera de rango',
|
||||||
|
VAL_008: 'El valor ya existe',
|
||||||
|
|
||||||
|
// Resource
|
||||||
|
RES_001: 'Recurso no encontrado',
|
||||||
|
RES_002: 'El recurso ya existe',
|
||||||
|
RES_003: 'Conflicto de recursos',
|
||||||
|
RES_004: 'El recurso está bloqueado',
|
||||||
|
RES_005: 'El recurso ha sido eliminado',
|
||||||
|
|
||||||
|
// Business
|
||||||
|
BIZ_001: 'Operación no válida',
|
||||||
|
BIZ_002: 'Saldo insuficiente',
|
||||||
|
BIZ_003: 'Límite excedido',
|
||||||
|
BIZ_004: 'Estado no válido para esta operación',
|
||||||
|
BIZ_005: 'Error de dependencia',
|
||||||
|
|
||||||
|
// CFDI
|
||||||
|
CFDI_001: 'Error al timbrar el CFDI',
|
||||||
|
CFDI_002: 'Error al cancelar el CFDI',
|
||||||
|
CFDI_003: 'RFC inválido',
|
||||||
|
CFDI_004: 'Código postal inválido',
|
||||||
|
CFDI_005: 'El CFDI ya está timbrado',
|
||||||
|
CFDI_006: 'El CFDI ya está cancelado',
|
||||||
|
CFDI_007: 'CFDI no encontrado en el SAT',
|
||||||
|
CFDI_008: 'XML inválido',
|
||||||
|
CFDI_009: 'Certificado expirado',
|
||||||
|
CFDI_010: 'Error del proveedor de certificación',
|
||||||
|
|
||||||
|
// Billing
|
||||||
|
BILL_001: 'Error en el pago',
|
||||||
|
BILL_002: 'Tarjeta rechazada',
|
||||||
|
BILL_003: 'Suscripción expirada',
|
||||||
|
BILL_004: 'Plan no disponible',
|
||||||
|
BILL_005: 'Código promocional inválido',
|
||||||
|
BILL_006: 'Código promocional expirado',
|
||||||
|
BILL_007: 'No es posible cambiar a un plan inferior',
|
||||||
|
|
||||||
|
// Integration
|
||||||
|
INT_001: 'Error de conexión con el servicio externo',
|
||||||
|
INT_002: 'Error de autenticación con el servicio externo',
|
||||||
|
INT_003: 'Error de sincronización',
|
||||||
|
INT_004: 'Integración no configurada',
|
||||||
|
INT_005: 'Límite de solicitudes excedido',
|
||||||
|
|
||||||
|
// System
|
||||||
|
SYS_001: 'Error interno del servidor',
|
||||||
|
SYS_002: 'Servicio no disponible',
|
||||||
|
SYS_003: 'Tiempo de espera agotado',
|
||||||
|
SYS_004: 'Sistema en mantenimiento',
|
||||||
|
SYS_005: 'Demasiadas solicitudes, intenta más tarde',
|
||||||
|
SYS_006: 'Almacenamiento lleno',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Fiscal Regimes (Mexico SAT)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const FISCAL_REGIMES: Record<string, string> = {
|
||||||
|
'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',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mexican States
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const MEXICAN_STATES: Record<string, string> = {
|
||||||
|
AGU: 'Aguascalientes',
|
||||||
|
BCN: 'Baja California',
|
||||||
|
BCS: 'Baja California Sur',
|
||||||
|
CAM: 'Campeche',
|
||||||
|
CHP: 'Chiapas',
|
||||||
|
CHH: 'Chihuahua',
|
||||||
|
COA: 'Coahuila',
|
||||||
|
COL: 'Colima',
|
||||||
|
CMX: 'Ciudad de México',
|
||||||
|
DUR: 'Durango',
|
||||||
|
GUA: 'Guanajuato',
|
||||||
|
GRO: 'Guerrero',
|
||||||
|
HID: 'Hidalgo',
|
||||||
|
JAL: 'Jalisco',
|
||||||
|
MEX: 'Estado de México',
|
||||||
|
MIC: 'Michoacán',
|
||||||
|
MOR: 'Morelos',
|
||||||
|
NAY: 'Nayarit',
|
||||||
|
NLE: 'Nuevo León',
|
||||||
|
OAX: 'Oaxaca',
|
||||||
|
PUE: 'Puebla',
|
||||||
|
QUE: 'Querétaro',
|
||||||
|
ROO: 'Quintana Roo',
|
||||||
|
SLP: 'San Luis Potosí',
|
||||||
|
SIN: 'Sinaloa',
|
||||||
|
SON: 'Sonora',
|
||||||
|
TAB: 'Tabasco',
|
||||||
|
TAM: 'Tamaulipas',
|
||||||
|
TLA: 'Tlaxcala',
|
||||||
|
VER: 'Veracruz',
|
||||||
|
YUC: 'Yucatán',
|
||||||
|
ZAC: 'Zacatecas',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Currencies
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const CURRENCIES: Record<string, { name: string; symbol: string; decimals: number }> = {
|
||||||
|
MXN: { name: 'Peso Mexicano', symbol: '$', decimals: 2 },
|
||||||
|
USD: { name: 'Dólar Estadounidense', symbol: 'US$', decimals: 2 },
|
||||||
|
EUR: { name: 'Euro', symbol: '€', decimals: 2 },
|
||||||
|
CAD: { name: 'Dólar Canadiense', symbol: 'CA$', decimals: 2 },
|
||||||
|
GBP: { name: 'Libra Esterlina', symbol: '£', decimals: 2 },
|
||||||
|
JPY: { name: 'Yen Japonés', symbol: '¥', decimals: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_CURRENCY = 'MXN';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Date & Time
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const DEFAULT_TIMEZONE = 'America/Mexico_City';
|
||||||
|
export const DEFAULT_LOCALE = 'es-MX';
|
||||||
|
export const DEFAULT_DATE_FORMAT = 'dd/MM/yyyy';
|
||||||
|
export const DEFAULT_TIME_FORMAT = 'HH:mm';
|
||||||
|
export const DEFAULT_DATETIME_FORMAT = 'dd/MM/yyyy HH:mm';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Limits
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const LIMITS = {
|
||||||
|
// Pagination
|
||||||
|
DEFAULT_PAGE_SIZE: 20,
|
||||||
|
MAX_PAGE_SIZE: 100,
|
||||||
|
|
||||||
|
// File uploads
|
||||||
|
MAX_FILE_SIZE_MB: 10,
|
||||||
|
MAX_ATTACHMENT_SIZE_MB: 25,
|
||||||
|
ALLOWED_FILE_TYPES: ['pdf', 'xml', 'jpg', 'jpeg', 'png', 'xlsx', 'csv'],
|
||||||
|
|
||||||
|
// Text fields
|
||||||
|
MAX_DESCRIPTION_LENGTH: 500,
|
||||||
|
MAX_NOTES_LENGTH: 2000,
|
||||||
|
MAX_NAME_LENGTH: 200,
|
||||||
|
|
||||||
|
// Lists
|
||||||
|
MAX_TAGS: 10,
|
||||||
|
MAX_BATCH_SIZE: 100,
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
MAX_API_REQUESTS_PER_MINUTE: 60,
|
||||||
|
MAX_LOGIN_ATTEMPTS: 5,
|
||||||
|
LOGIN_LOCKOUT_MINUTES: 15,
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
ACCESS_TOKEN_EXPIRY_MINUTES: 15,
|
||||||
|
REFRESH_TOKEN_EXPIRY_DAYS: 7,
|
||||||
|
SESSION_TIMEOUT_MINUTES: 60,
|
||||||
|
|
||||||
|
// Passwords
|
||||||
|
MIN_PASSWORD_LENGTH: 8,
|
||||||
|
MAX_PASSWORD_LENGTH: 128,
|
||||||
|
PASSWORD_HISTORY_COUNT: 5,
|
||||||
|
|
||||||
|
// Export/Import
|
||||||
|
MAX_EXPORT_ROWS: 50000,
|
||||||
|
MAX_IMPORT_ROWS: 10000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Regular Expressions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const REGEX = {
|
||||||
|
RFC: /^([A-ZÑ&]{3,4})(\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])([A-Z\d]{2})([A\d])$/,
|
||||||
|
CURP: /^[A-Z]{4}\d{6}[HM][A-Z]{5}[A-Z\d]\d$/,
|
||||||
|
CLABE: /^\d{18}$/,
|
||||||
|
POSTAL_CODE_MX: /^\d{5}$/,
|
||||||
|
PHONE_MX: /^(\+52)?[\s.-]?\(?[0-9]{2,3}\)?[\s.-]?[0-9]{3,4}[\s.-]?[0-9]{4}$/,
|
||||||
|
UUID: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/,
|
||||||
|
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||||
|
SLUG: /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
||||||
|
HEX_COLOR: /^#[0-9A-Fa-f]{6}$/,
|
||||||
|
SAT_PRODUCT_CODE: /^\d{8}$/,
|
||||||
|
SAT_UNIT_CODE: /^[A-Z0-9]{2,3}$/,
|
||||||
|
};
|
||||||
362
packages/shared/src/schemas/auth.schema.ts
Normal file
362
packages/shared/src/schemas/auth.schema.ts
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
/**
|
||||||
|
* Authentication Validation Schemas
|
||||||
|
* Zod schemas for auth-related data validation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Common Validators
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email validation
|
||||||
|
*/
|
||||||
|
export const emailSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El correo electrónico es requerido')
|
||||||
|
.email('El correo electrónico no es válido')
|
||||||
|
.max(255, 'El correo electrónico es demasiado largo')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password validation with Mexican-friendly messages
|
||||||
|
*/
|
||||||
|
export const passwordSchema = z
|
||||||
|
.string()
|
||||||
|
.min(8, 'La contraseña debe tener al menos 8 caracteres')
|
||||||
|
.max(128, 'La contraseña es demasiado larga')
|
||||||
|
.regex(/[A-Z]/, 'La contraseña debe contener al menos una mayúscula')
|
||||||
|
.regex(/[a-z]/, 'La contraseña debe contener al menos una minúscula')
|
||||||
|
.regex(/[0-9]/, 'La contraseña debe contener al menos un número')
|
||||||
|
.regex(
|
||||||
|
/[^A-Za-z0-9]/,
|
||||||
|
'La contraseña debe contener al menos un carácter especial'
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple password (for login, without complexity requirements)
|
||||||
|
*/
|
||||||
|
export const simplePasswordSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1, 'La contraseña es requerida')
|
||||||
|
.max(128, 'La contraseña es demasiado larga');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User role enum
|
||||||
|
*/
|
||||||
|
export const userRoleSchema = z.enum([
|
||||||
|
'super_admin',
|
||||||
|
'tenant_admin',
|
||||||
|
'accountant',
|
||||||
|
'assistant',
|
||||||
|
'viewer',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone number (Mexican format)
|
||||||
|
*/
|
||||||
|
export const phoneSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^(\+52)?[\s.-]?\(?[0-9]{2,3}\)?[\s.-]?[0-9]{3,4}[\s.-]?[0-9]{4}$/,
|
||||||
|
'El número de teléfono no es válido'
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.or(z.literal(''));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name validation
|
||||||
|
*/
|
||||||
|
export const nameSchema = z
|
||||||
|
.string()
|
||||||
|
.min(2, 'El nombre debe tener al menos 2 caracteres')
|
||||||
|
.max(100, 'El nombre es demasiado largo')
|
||||||
|
.regex(
|
||||||
|
/^[a-zA-ZáéíóúüñÁÉÍÓÚÜÑ\s'-]+$/,
|
||||||
|
'El nombre contiene caracteres no válidos'
|
||||||
|
)
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Login Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const loginRequestSchema = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
password: simplePasswordSchema,
|
||||||
|
rememberMe: z.boolean().optional().default(false),
|
||||||
|
tenantSlug: z
|
||||||
|
.string()
|
||||||
|
.min(2, 'El identificador de empresa es muy corto')
|
||||||
|
.max(50, 'El identificador de empresa es muy largo')
|
||||||
|
.regex(
|
||||||
|
/^[a-z0-9-]+$/,
|
||||||
|
'El identificador solo puede contener letras minúsculas, números y guiones'
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LoginRequestInput = z.infer<typeof loginRequestSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Register Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const registerRequestSchema = z
|
||||||
|
.object({
|
||||||
|
email: emailSchema,
|
||||||
|
password: passwordSchema,
|
||||||
|
confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'),
|
||||||
|
firstName: nameSchema,
|
||||||
|
lastName: nameSchema,
|
||||||
|
phone: phoneSchema,
|
||||||
|
tenantName: z
|
||||||
|
.string()
|
||||||
|
.min(2, 'El nombre de empresa debe tener al menos 2 caracteres')
|
||||||
|
.max(100, 'El nombre de empresa es demasiado largo')
|
||||||
|
.optional(),
|
||||||
|
inviteCode: z
|
||||||
|
.string()
|
||||||
|
.length(32, 'El código de invitación no es válido')
|
||||||
|
.optional(),
|
||||||
|
acceptTerms: z.literal(true, {
|
||||||
|
errorMap: () => ({
|
||||||
|
message: 'Debes aceptar los términos y condiciones',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: 'Las contraseñas no coinciden',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => data.tenantName || data.inviteCode,
|
||||||
|
{
|
||||||
|
message: 'Debes proporcionar un nombre de empresa o código de invitación',
|
||||||
|
path: ['tenantName'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type RegisterRequestInput = z.infer<typeof registerRequestSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Password Reset Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const forgotPasswordRequestSchema = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ForgotPasswordRequestInput = z.infer<typeof forgotPasswordRequestSchema>;
|
||||||
|
|
||||||
|
export const resetPasswordRequestSchema = z
|
||||||
|
.object({
|
||||||
|
token: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El token es requerido')
|
||||||
|
.length(64, 'El token no es válido'),
|
||||||
|
password: passwordSchema,
|
||||||
|
confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: 'Las contraseñas no coinciden',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResetPasswordRequestInput = z.infer<typeof resetPasswordRequestSchema>;
|
||||||
|
|
||||||
|
export const changePasswordRequestSchema = z
|
||||||
|
.object({
|
||||||
|
currentPassword: simplePasswordSchema,
|
||||||
|
newPassword: passwordSchema,
|
||||||
|
confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'),
|
||||||
|
})
|
||||||
|
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||||
|
message: 'Las contraseñas no coinciden',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
})
|
||||||
|
.refine((data) => data.currentPassword !== data.newPassword, {
|
||||||
|
message: 'La nueva contraseña debe ser diferente a la actual',
|
||||||
|
path: ['newPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ChangePasswordRequestInput = z.infer<typeof changePasswordRequestSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Email Verification
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const verifyEmailRequestSchema = z.object({
|
||||||
|
token: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El token es requerido')
|
||||||
|
.length(64, 'El token no es válido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type VerifyEmailRequestInput = z.infer<typeof verifyEmailRequestSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Refresh Token
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const refreshTokenRequestSchema = z.object({
|
||||||
|
refreshToken: z.string().min(1, 'El token de actualización es requerido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RefreshTokenRequestInput = z.infer<typeof refreshTokenRequestSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// User Profile Update
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const updateProfileSchema = z.object({
|
||||||
|
firstName: nameSchema.optional(),
|
||||||
|
lastName: nameSchema.optional(),
|
||||||
|
phone: phoneSchema,
|
||||||
|
timezone: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'La zona horaria es requerida')
|
||||||
|
.max(50, 'La zona horaria no es válida')
|
||||||
|
.optional(),
|
||||||
|
locale: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-z]{2}(-[A-Z]{2})?$/, 'El idioma no es válido')
|
||||||
|
.optional(),
|
||||||
|
avatar: z.string().url('La URL del avatar no es válida').optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// User Invitation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const inviteUserRequestSchema = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
role: userRoleSchema,
|
||||||
|
message: z
|
||||||
|
.string()
|
||||||
|
.max(500, 'El mensaje es demasiado largo')
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InviteUserRequestInput = z.infer<typeof inviteUserRequestSchema>;
|
||||||
|
|
||||||
|
export const acceptInvitationSchema = z
|
||||||
|
.object({
|
||||||
|
token: z.string().min(1, 'El token es requerido'),
|
||||||
|
firstName: nameSchema,
|
||||||
|
lastName: nameSchema,
|
||||||
|
password: passwordSchema,
|
||||||
|
confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: 'Las contraseñas no coinciden',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AcceptInvitationInput = z.infer<typeof acceptInvitationSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Two-Factor Authentication
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const twoFactorVerifySchema = z.object({
|
||||||
|
code: z
|
||||||
|
.string()
|
||||||
|
.length(6, 'El código debe tener 6 dígitos')
|
||||||
|
.regex(/^\d+$/, 'El código solo debe contener números'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TwoFactorVerifyInput = z.infer<typeof twoFactorVerifySchema>;
|
||||||
|
|
||||||
|
export const twoFactorLoginSchema = z.object({
|
||||||
|
tempToken: z.string().min(1, 'El token temporal es requerido'),
|
||||||
|
code: z
|
||||||
|
.string()
|
||||||
|
.length(6, 'El código debe tener 6 dígitos')
|
||||||
|
.regex(/^\d+$/, 'El código solo debe contener números'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TwoFactorLoginInput = z.infer<typeof twoFactorLoginSchema>;
|
||||||
|
|
||||||
|
export const twoFactorBackupCodeSchema = z.object({
|
||||||
|
backupCode: z
|
||||||
|
.string()
|
||||||
|
.length(10, 'El código de respaldo debe tener 10 caracteres')
|
||||||
|
.regex(/^[A-Z0-9]+$/, 'El código de respaldo no es válido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TwoFactorBackupCodeInput = z.infer<typeof twoFactorBackupCodeSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Session Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const revokeSessionSchema = z.object({
|
||||||
|
sessionId: z.string().uuid('El ID de sesión no es válido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RevokeSessionInput = z.infer<typeof revokeSessionSchema>;
|
||||||
|
|
||||||
|
export const revokeAllSessionsSchema = z.object({
|
||||||
|
exceptCurrent: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RevokeAllSessionsInput = z.infer<typeof revokeAllSessionsSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// User Management (Admin)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const createUserSchema = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
firstName: nameSchema,
|
||||||
|
lastName: nameSchema,
|
||||||
|
role: userRoleSchema,
|
||||||
|
phone: phoneSchema,
|
||||||
|
sendInvitation: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateUserInput = z.infer<typeof createUserSchema>;
|
||||||
|
|
||||||
|
export const updateUserSchema = z.object({
|
||||||
|
firstName: nameSchema.optional(),
|
||||||
|
lastName: nameSchema.optional(),
|
||||||
|
role: userRoleSchema.optional(),
|
||||||
|
phone: phoneSchema,
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
|
||||||
|
|
||||||
|
export const userFilterSchema = z.object({
|
||||||
|
search: z.string().max(100).optional(),
|
||||||
|
role: userRoleSchema.optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
sortBy: z.enum(['createdAt', 'email', 'firstName', 'lastName', 'role']).default('createdAt'),
|
||||||
|
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UserFilterInput = z.infer<typeof userFilterSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Permission Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const permissionSchema = z.object({
|
||||||
|
resource: z.string().min(1).max(50),
|
||||||
|
actions: z.array(z.enum(['create', 'read', 'update', 'delete'])).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rolePermissionsSchema = z.object({
|
||||||
|
role: userRoleSchema,
|
||||||
|
permissions: z.array(permissionSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PermissionInput = z.infer<typeof permissionSchema>;
|
||||||
|
export type RolePermissionsInput = z.infer<typeof rolePermissionsSchema>;
|
||||||
730
packages/shared/src/schemas/financial.schema.ts
Normal file
730
packages/shared/src/schemas/financial.schema.ts
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
/**
|
||||||
|
* Financial Validation Schemas
|
||||||
|
* Zod schemas for transactions, CFDI, contacts, accounts, and categories
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Common Validators
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC validation (Mexican tax ID)
|
||||||
|
*/
|
||||||
|
export const rfcSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^([A-ZÑ&]{3,4})(\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])([A-Z\d]{2})([A\d])$/,
|
||||||
|
'El RFC no tiene un formato válido'
|
||||||
|
)
|
||||||
|
.toUpperCase()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CURP validation
|
||||||
|
*/
|
||||||
|
export const curpSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^[A-Z]{4}\d{6}[HM][A-Z]{5}[A-Z\d]\d$/,
|
||||||
|
'El CURP no tiene un formato válido'
|
||||||
|
)
|
||||||
|
.toUpperCase()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLABE validation (18 digits)
|
||||||
|
*/
|
||||||
|
export const clabeSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{18}$/, 'La CLABE debe tener 18 dígitos');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Money amount validation
|
||||||
|
*/
|
||||||
|
export const moneySchema = z
|
||||||
|
.number()
|
||||||
|
.multipleOf(0.01, 'El monto debe tener máximo 2 decimales');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Positive money amount
|
||||||
|
*/
|
||||||
|
export const positiveMoneySchema = moneySchema
|
||||||
|
.positive('El monto debe ser mayor a cero');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-negative money amount
|
||||||
|
*/
|
||||||
|
export const nonNegativeMoneySchema = moneySchema
|
||||||
|
.nonnegative('El monto no puede ser negativo');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currency code
|
||||||
|
*/
|
||||||
|
export const currencySchema = z
|
||||||
|
.string()
|
||||||
|
.length(3, 'El código de moneda debe tener 3 letras')
|
||||||
|
.toUpperCase()
|
||||||
|
.default('MXN');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UUID validation
|
||||||
|
*/
|
||||||
|
export const uuidSchema = z.string().uuid('El ID no es válido');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Enums
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const transactionTypeSchema = z.enum([
|
||||||
|
'income',
|
||||||
|
'expense',
|
||||||
|
'transfer',
|
||||||
|
'adjustment',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const transactionStatusSchema = z.enum([
|
||||||
|
'pending',
|
||||||
|
'cleared',
|
||||||
|
'reconciled',
|
||||||
|
'voided',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const paymentMethodSchema = z.enum([
|
||||||
|
'cash',
|
||||||
|
'bank_transfer',
|
||||||
|
'credit_card',
|
||||||
|
'debit_card',
|
||||||
|
'check',
|
||||||
|
'digital_wallet',
|
||||||
|
'other',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const cfdiTypeSchema = z.enum(['I', 'E', 'T', 'N', 'P']);
|
||||||
|
|
||||||
|
export const cfdiStatusSchema = z.enum([
|
||||||
|
'draft',
|
||||||
|
'pending',
|
||||||
|
'stamped',
|
||||||
|
'sent',
|
||||||
|
'paid',
|
||||||
|
'partial_paid',
|
||||||
|
'cancelled',
|
||||||
|
'cancellation_pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const cfdiUsageSchema = z.enum([
|
||||||
|
'G01', 'G02', 'G03',
|
||||||
|
'I01', 'I02', 'I03', 'I04', 'I05', 'I06', 'I07', 'I08',
|
||||||
|
'D01', 'D02', 'D03', 'D04', 'D05', 'D06', 'D07', 'D08', 'D09', 'D10',
|
||||||
|
'S01', 'CP01', 'CN01',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const paymentFormSchema = z.enum([
|
||||||
|
'01', '02', '03', '04', '05', '06', '08', '12', '13', '14', '15',
|
||||||
|
'17', '23', '24', '25', '26', '27', '28', '29', '30', '31', '99',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const paymentMethodCFDISchema = z.enum(['PUE', 'PPD']);
|
||||||
|
|
||||||
|
export const contactTypeSchema = z.enum([
|
||||||
|
'customer',
|
||||||
|
'supplier',
|
||||||
|
'both',
|
||||||
|
'employee',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const categoryTypeSchema = z.enum(['income', 'expense']);
|
||||||
|
|
||||||
|
export const accountTypeSchema = z.enum([
|
||||||
|
'bank',
|
||||||
|
'cash',
|
||||||
|
'credit_card',
|
||||||
|
'loan',
|
||||||
|
'investment',
|
||||||
|
'other',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const accountSubtypeSchema = z.enum([
|
||||||
|
'checking',
|
||||||
|
'savings',
|
||||||
|
'money_market',
|
||||||
|
'cd',
|
||||||
|
'credit',
|
||||||
|
'line_of_credit',
|
||||||
|
'mortgage',
|
||||||
|
'auto_loan',
|
||||||
|
'personal_loan',
|
||||||
|
'brokerage',
|
||||||
|
'retirement',
|
||||||
|
'other',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Transaction Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const createTransactionSchema = z.object({
|
||||||
|
type: transactionTypeSchema,
|
||||||
|
amount: positiveMoneySchema,
|
||||||
|
currency: currencySchema,
|
||||||
|
exchangeRate: z.number().positive().optional(),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'La descripción es requerida')
|
||||||
|
.max(500, 'La descripción es demasiado larga')
|
||||||
|
.trim(),
|
||||||
|
reference: z.string().max(100).optional(),
|
||||||
|
notes: z.string().max(2000).optional(),
|
||||||
|
date: z.coerce.date(),
|
||||||
|
valueDate: z.coerce.date().optional(),
|
||||||
|
accountId: uuidSchema,
|
||||||
|
destinationAccountId: uuidSchema.optional(),
|
||||||
|
categoryId: uuidSchema.optional(),
|
||||||
|
contactId: uuidSchema.optional(),
|
||||||
|
cfdiId: uuidSchema.optional(),
|
||||||
|
paymentMethod: paymentMethodSchema.optional(),
|
||||||
|
paymentReference: z.string().max(100).optional(),
|
||||||
|
tags: z.array(z.string().max(50)).max(10).default([]),
|
||||||
|
isRecurring: z.boolean().default(false),
|
||||||
|
recurringRuleId: uuidSchema.optional(),
|
||||||
|
}).refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.type === 'transfer') {
|
||||||
|
return !!data.destinationAccountId;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'La cuenta destino es requerida para transferencias',
|
||||||
|
path: ['destinationAccountId'],
|
||||||
|
}
|
||||||
|
).refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.type === 'transfer' && data.destinationAccountId) {
|
||||||
|
return data.accountId !== data.destinationAccountId;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'La cuenta origen y destino no pueden ser la misma',
|
||||||
|
path: ['destinationAccountId'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type CreateTransactionInput = z.infer<typeof createTransactionSchema>;
|
||||||
|
|
||||||
|
export const updateTransactionSchema = z.object({
|
||||||
|
amount: positiveMoneySchema.optional(),
|
||||||
|
description: z.string().min(1).max(500).trim().optional(),
|
||||||
|
reference: z.string().max(100).optional().nullable(),
|
||||||
|
notes: z.string().max(2000).optional().nullable(),
|
||||||
|
date: z.coerce.date().optional(),
|
||||||
|
valueDate: z.coerce.date().optional().nullable(),
|
||||||
|
categoryId: uuidSchema.optional().nullable(),
|
||||||
|
contactId: uuidSchema.optional().nullable(),
|
||||||
|
paymentMethod: paymentMethodSchema.optional().nullable(),
|
||||||
|
paymentReference: z.string().max(100).optional().nullable(),
|
||||||
|
tags: z.array(z.string().max(50)).max(10).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateTransactionInput = z.infer<typeof updateTransactionSchema>;
|
||||||
|
|
||||||
|
export const transactionFilterSchema = z.object({
|
||||||
|
type: z.array(transactionTypeSchema).optional(),
|
||||||
|
status: z.array(transactionStatusSchema).optional(),
|
||||||
|
accountId: uuidSchema.optional(),
|
||||||
|
categoryId: uuidSchema.optional(),
|
||||||
|
contactId: uuidSchema.optional(),
|
||||||
|
dateFrom: z.coerce.date().optional(),
|
||||||
|
dateTo: z.coerce.date().optional(),
|
||||||
|
amountMin: nonNegativeMoneySchema.optional(),
|
||||||
|
amountMax: nonNegativeMoneySchema.optional(),
|
||||||
|
search: z.string().max(100).optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
sortBy: z.enum(['date', 'amount', 'description', 'createdAt']).default('date'),
|
||||||
|
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TransactionFilterInput = z.infer<typeof transactionFilterSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CFDI Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const cfdiTaxSchema = z.object({
|
||||||
|
type: z.enum(['transferred', 'withheld']),
|
||||||
|
tax: z.enum(['IVA', 'ISR', 'IEPS']),
|
||||||
|
factor: z.enum(['Tasa', 'Cuota', 'Exento']),
|
||||||
|
rate: z.number().min(0).max(1),
|
||||||
|
base: positiveMoneySchema,
|
||||||
|
amount: nonNegativeMoneySchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cfdiItemSchema = z.object({
|
||||||
|
productCode: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{8}$/, 'La clave del producto debe tener 8 dígitos'),
|
||||||
|
unitCode: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[A-Z0-9]{2,3}$/, 'La clave de unidad no es válida'),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'La descripción es requerida')
|
||||||
|
.max(1000, 'La descripción es demasiado larga'),
|
||||||
|
quantity: z.number().positive('La cantidad debe ser mayor a cero'),
|
||||||
|
unitPrice: positiveMoneySchema,
|
||||||
|
discount: nonNegativeMoneySchema.optional().default(0),
|
||||||
|
identificationNumber: z.string().max(100).optional(),
|
||||||
|
unit: z.string().max(50).optional(),
|
||||||
|
taxes: z.array(cfdiTaxSchema).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cfdiRelationSchema = z.object({
|
||||||
|
type: z.enum(['01', '02', '03', '04', '05', '06', '07', '08', '09']),
|
||||||
|
uuid: z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/,
|
||||||
|
'El UUID no tiene un formato válido'
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createCFDISchema = z.object({
|
||||||
|
type: cfdiTypeSchema,
|
||||||
|
series: z
|
||||||
|
.string()
|
||||||
|
.max(25, 'La serie es demasiado larga')
|
||||||
|
.regex(/^[A-Z0-9]*$/, 'La serie solo puede contener letras y números')
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
// Receiver
|
||||||
|
receiverRfc: rfcSchema,
|
||||||
|
receiverName: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El nombre del receptor es requerido')
|
||||||
|
.max(300, 'El nombre del receptor es demasiado largo'),
|
||||||
|
receiverFiscalRegime: z.string().min(3).max(3).optional(),
|
||||||
|
receiverPostalCode: z.string().regex(/^\d{5}$/, 'El código postal debe tener 5 dígitos'),
|
||||||
|
receiverUsage: cfdiUsageSchema,
|
||||||
|
receiverEmail: z.string().email('El correo electrónico no es válido').optional(),
|
||||||
|
|
||||||
|
// Items
|
||||||
|
items: z
|
||||||
|
.array(cfdiItemSchema)
|
||||||
|
.min(1, 'Debe haber al menos un concepto'),
|
||||||
|
|
||||||
|
// Payment
|
||||||
|
paymentForm: paymentFormSchema,
|
||||||
|
paymentMethod: paymentMethodCFDISchema,
|
||||||
|
paymentConditions: z.string().max(1000).optional(),
|
||||||
|
|
||||||
|
// Currency
|
||||||
|
currency: currencySchema,
|
||||||
|
exchangeRate: z.number().positive().optional(),
|
||||||
|
|
||||||
|
// Related CFDIs
|
||||||
|
relatedCfdis: z.array(cfdiRelationSchema).optional(),
|
||||||
|
|
||||||
|
// Contact
|
||||||
|
contactId: uuidSchema.optional(),
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
issueDate: z.coerce.date().optional(),
|
||||||
|
expirationDate: z.coerce.date().optional(),
|
||||||
|
}).refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.currency !== 'MXN') {
|
||||||
|
return !!data.exchangeRate;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'El tipo de cambio es requerido para monedas diferentes a MXN',
|
||||||
|
path: ['exchangeRate'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type CreateCFDIInput = z.infer<typeof createCFDISchema>;
|
||||||
|
|
||||||
|
export const updateCFDIDraftSchema = z.object({
|
||||||
|
receiverRfc: rfcSchema.optional(),
|
||||||
|
receiverName: z.string().min(1).max(300).optional(),
|
||||||
|
receiverFiscalRegime: z.string().min(3).max(3).optional().nullable(),
|
||||||
|
receiverPostalCode: z.string().regex(/^\d{5}$/).optional(),
|
||||||
|
receiverUsage: cfdiUsageSchema.optional(),
|
||||||
|
receiverEmail: z.string().email().optional().nullable(),
|
||||||
|
items: z.array(cfdiItemSchema).min(1).optional(),
|
||||||
|
paymentForm: paymentFormSchema.optional(),
|
||||||
|
paymentMethod: paymentMethodCFDISchema.optional(),
|
||||||
|
paymentConditions: z.string().max(1000).optional().nullable(),
|
||||||
|
expirationDate: z.coerce.date().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateCFDIDraftInput = z.infer<typeof updateCFDIDraftSchema>;
|
||||||
|
|
||||||
|
export const cfdiFilterSchema = z.object({
|
||||||
|
type: z.array(cfdiTypeSchema).optional(),
|
||||||
|
status: z.array(cfdiStatusSchema).optional(),
|
||||||
|
contactId: uuidSchema.optional(),
|
||||||
|
receiverRfc: z.string().optional(),
|
||||||
|
dateFrom: z.coerce.date().optional(),
|
||||||
|
dateTo: z.coerce.date().optional(),
|
||||||
|
amountMin: nonNegativeMoneySchema.optional(),
|
||||||
|
amountMax: nonNegativeMoneySchema.optional(),
|
||||||
|
search: z.string().max(100).optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
sortBy: z.enum(['issueDate', 'total', 'folio', 'createdAt']).default('issueDate'),
|
||||||
|
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CFDIFilterInput = z.infer<typeof cfdiFilterSchema>;
|
||||||
|
|
||||||
|
export const cancelCFDISchema = z.object({
|
||||||
|
reason: z.enum(['01', '02', '03', '04'], {
|
||||||
|
errorMap: () => ({ message: 'El motivo de cancelación no es válido' }),
|
||||||
|
}),
|
||||||
|
substitutedByUuid: z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
}).refine(
|
||||||
|
(data) => {
|
||||||
|
// If reason is 01 (substitution), substitutedByUuid is required
|
||||||
|
if (data.reason === '01') {
|
||||||
|
return !!data.substitutedByUuid;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'El UUID del CFDI sustituto es requerido para el motivo 01',
|
||||||
|
path: ['substitutedByUuid'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type CancelCFDIInput = z.infer<typeof cancelCFDISchema>;
|
||||||
|
|
||||||
|
export const registerPaymentSchema = z.object({
|
||||||
|
cfdiId: uuidSchema,
|
||||||
|
amount: positiveMoneySchema,
|
||||||
|
paymentDate: z.coerce.date(),
|
||||||
|
paymentForm: paymentFormSchema,
|
||||||
|
transactionId: uuidSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RegisterPaymentInput = z.infer<typeof registerPaymentSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Contact Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const contactAddressSchema = z.object({
|
||||||
|
street: z.string().min(1).max(200),
|
||||||
|
exteriorNumber: z.string().min(1).max(20),
|
||||||
|
interiorNumber: z.string().max(20).optional().or(z.literal('')),
|
||||||
|
neighborhood: z.string().min(1).max(100),
|
||||||
|
city: z.string().min(1).max(100),
|
||||||
|
state: z.string().min(1).max(100),
|
||||||
|
country: z.string().min(1).max(100).default('México'),
|
||||||
|
postalCode: z.string().regex(/^\d{5}$/),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const contactBankAccountSchema = z.object({
|
||||||
|
bankName: z.string().min(1).max(100),
|
||||||
|
accountNumber: z.string().min(1).max(20),
|
||||||
|
clabe: clabeSchema.optional(),
|
||||||
|
accountHolder: z.string().min(1).max(200),
|
||||||
|
currency: currencySchema,
|
||||||
|
isDefault: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createContactSchema = z.object({
|
||||||
|
type: contactTypeSchema,
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El nombre es requerido')
|
||||||
|
.max(200, 'El nombre es demasiado largo')
|
||||||
|
.trim(),
|
||||||
|
displayName: z.string().max(200).optional(),
|
||||||
|
rfc: rfcSchema.optional(),
|
||||||
|
curp: curpSchema.optional(),
|
||||||
|
email: z.string().email('El correo electrónico no es válido').optional(),
|
||||||
|
phone: z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^(\+52)?[\s.-]?\(?[0-9]{2,3}\)?[\s.-]?[0-9]{3,4}[\s.-]?[0-9]{4}$/
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
mobile: z.string().optional(),
|
||||||
|
website: z.string().url().optional(),
|
||||||
|
fiscalRegime: z.string().min(3).max(3).optional(),
|
||||||
|
fiscalName: z.string().max(300).optional(),
|
||||||
|
cfdiUsage: cfdiUsageSchema.optional(),
|
||||||
|
address: contactAddressSchema.optional(),
|
||||||
|
creditLimit: nonNegativeMoneySchema.optional(),
|
||||||
|
creditDays: z.number().int().min(0).max(365).optional(),
|
||||||
|
bankAccounts: z.array(contactBankAccountSchema).optional(),
|
||||||
|
tags: z.array(z.string().max(50)).max(10).default([]),
|
||||||
|
groupId: uuidSchema.optional(),
|
||||||
|
notes: z.string().max(2000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateContactInput = z.infer<typeof createContactSchema>;
|
||||||
|
|
||||||
|
export const updateContactSchema = z.object({
|
||||||
|
type: contactTypeSchema.optional(),
|
||||||
|
name: z.string().min(1).max(200).trim().optional(),
|
||||||
|
displayName: z.string().max(200).optional().nullable(),
|
||||||
|
rfc: rfcSchema.optional().nullable(),
|
||||||
|
curp: curpSchema.optional().nullable(),
|
||||||
|
email: z.string().email().optional().nullable(),
|
||||||
|
phone: z.string().optional().nullable(),
|
||||||
|
mobile: z.string().optional().nullable(),
|
||||||
|
website: z.string().url().optional().nullable(),
|
||||||
|
fiscalRegime: z.string().min(3).max(3).optional().nullable(),
|
||||||
|
fiscalName: z.string().max(300).optional().nullable(),
|
||||||
|
cfdiUsage: cfdiUsageSchema.optional().nullable(),
|
||||||
|
address: contactAddressSchema.optional().nullable(),
|
||||||
|
creditLimit: nonNegativeMoneySchema.optional().nullable(),
|
||||||
|
creditDays: z.number().int().min(0).max(365).optional().nullable(),
|
||||||
|
bankAccounts: z.array(contactBankAccountSchema).optional(),
|
||||||
|
tags: z.array(z.string().max(50)).max(10).optional(),
|
||||||
|
groupId: uuidSchema.optional().nullable(),
|
||||||
|
notes: z.string().max(2000).optional().nullable(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateContactInput = z.infer<typeof updateContactSchema>;
|
||||||
|
|
||||||
|
export const contactFilterSchema = z.object({
|
||||||
|
type: z.array(contactTypeSchema).optional(),
|
||||||
|
search: z.string().max(100).optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
hasBalance: z.boolean().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
sortBy: z.enum(['name', 'balance', 'createdAt']).default('name'),
|
||||||
|
sortOrder: z.enum(['asc', 'desc']).default('asc'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ContactFilterInput = z.infer<typeof contactFilterSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Category Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const createCategorySchema = z.object({
|
||||||
|
type: categoryTypeSchema,
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El nombre es requerido')
|
||||||
|
.max(100, 'El nombre es demasiado largo')
|
||||||
|
.trim(),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
code: z.string().max(20).optional(),
|
||||||
|
color: z
|
||||||
|
.string()
|
||||||
|
.regex(/^#[0-9A-Fa-f]{6}$/)
|
||||||
|
.optional(),
|
||||||
|
icon: z.string().max(50).optional(),
|
||||||
|
parentId: uuidSchema.optional(),
|
||||||
|
satCode: z.string().max(10).optional(),
|
||||||
|
sortOrder: z.number().int().min(0).default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateCategoryInput = z.infer<typeof createCategorySchema>;
|
||||||
|
|
||||||
|
export const updateCategorySchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).trim().optional(),
|
||||||
|
description: z.string().max(500).optional().nullable(),
|
||||||
|
code: z.string().max(20).optional().nullable(),
|
||||||
|
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional().nullable(),
|
||||||
|
icon: z.string().max(50).optional().nullable(),
|
||||||
|
parentId: uuidSchema.optional().nullable(),
|
||||||
|
satCode: z.string().max(10).optional().nullable(),
|
||||||
|
sortOrder: z.number().int().min(0).optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateCategoryInput = z.infer<typeof updateCategorySchema>;
|
||||||
|
|
||||||
|
export const categoryFilterSchema = z.object({
|
||||||
|
type: categoryTypeSchema.optional(),
|
||||||
|
search: z.string().max(100).optional(),
|
||||||
|
parentId: uuidSchema.optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
includeSystem: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CategoryFilterInput = z.infer<typeof categoryFilterSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Account Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const createAccountSchema = z.object({
|
||||||
|
type: accountTypeSchema,
|
||||||
|
subtype: accountSubtypeSchema.optional(),
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El nombre es requerido')
|
||||||
|
.max(100, 'El nombre es demasiado largo')
|
||||||
|
.trim(),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
accountNumber: z.string().max(30).optional(),
|
||||||
|
currency: currencySchema,
|
||||||
|
bankName: z.string().max(100).optional(),
|
||||||
|
bankBranch: z.string().max(100).optional(),
|
||||||
|
clabe: clabeSchema.optional(),
|
||||||
|
swiftCode: z.string().max(11).optional(),
|
||||||
|
currentBalance: moneySchema.default(0),
|
||||||
|
creditLimit: nonNegativeMoneySchema.optional(),
|
||||||
|
isDefault: z.boolean().default(false),
|
||||||
|
isReconcilable: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateAccountInput = z.infer<typeof createAccountSchema>;
|
||||||
|
|
||||||
|
export const updateAccountSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).trim().optional(),
|
||||||
|
description: z.string().max(500).optional().nullable(),
|
||||||
|
accountNumber: z.string().max(30).optional().nullable(),
|
||||||
|
bankName: z.string().max(100).optional().nullable(),
|
||||||
|
bankBranch: z.string().max(100).optional().nullable(),
|
||||||
|
clabe: clabeSchema.optional().nullable(),
|
||||||
|
swiftCode: z.string().max(11).optional().nullable(),
|
||||||
|
creditLimit: nonNegativeMoneySchema.optional().nullable(),
|
||||||
|
isDefault: z.boolean().optional(),
|
||||||
|
isReconcilable: z.boolean().optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateAccountInput = z.infer<typeof updateAccountSchema>;
|
||||||
|
|
||||||
|
export const accountFilterSchema = z.object({
|
||||||
|
type: z.array(accountTypeSchema).optional(),
|
||||||
|
search: z.string().max(100).optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
currency: currencySchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AccountFilterInput = z.infer<typeof accountFilterSchema>;
|
||||||
|
|
||||||
|
export const adjustBalanceSchema = z.object({
|
||||||
|
newBalance: moneySchema,
|
||||||
|
reason: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El motivo es requerido')
|
||||||
|
.max(500, 'El motivo es demasiado largo'),
|
||||||
|
date: z.coerce.date().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AdjustBalanceInput = z.infer<typeof adjustBalanceSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Recurring Rule Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const recurringFrequencySchema = z.enum([
|
||||||
|
'daily',
|
||||||
|
'weekly',
|
||||||
|
'biweekly',
|
||||||
|
'monthly',
|
||||||
|
'quarterly',
|
||||||
|
'yearly',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const createRecurringRuleSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).trim(),
|
||||||
|
type: transactionTypeSchema,
|
||||||
|
frequency: recurringFrequencySchema,
|
||||||
|
interval: z.number().int().min(1).max(12).default(1),
|
||||||
|
startDate: z.coerce.date(),
|
||||||
|
endDate: z.coerce.date().optional(),
|
||||||
|
amount: positiveMoneySchema,
|
||||||
|
description: z.string().min(1).max(500).trim(),
|
||||||
|
accountId: uuidSchema,
|
||||||
|
categoryId: uuidSchema.optional(),
|
||||||
|
contactId: uuidSchema.optional(),
|
||||||
|
maxOccurrences: z.number().int().positive().optional(),
|
||||||
|
}).refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.endDate) {
|
||||||
|
return data.endDate > data.startDate;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'La fecha de fin debe ser posterior a la fecha de inicio',
|
||||||
|
path: ['endDate'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type CreateRecurringRuleInput = z.infer<typeof createRecurringRuleSchema>;
|
||||||
|
|
||||||
|
export const updateRecurringRuleSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).trim().optional(),
|
||||||
|
frequency: recurringFrequencySchema.optional(),
|
||||||
|
interval: z.number().int().min(1).max(12).optional(),
|
||||||
|
endDate: z.coerce.date().optional().nullable(),
|
||||||
|
amount: positiveMoneySchema.optional(),
|
||||||
|
description: z.string().min(1).max(500).trim().optional(),
|
||||||
|
categoryId: uuidSchema.optional().nullable(),
|
||||||
|
contactId: uuidSchema.optional().nullable(),
|
||||||
|
maxOccurrences: z.number().int().positive().optional().nullable(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateRecurringRuleInput = z.infer<typeof updateRecurringRuleSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Bank Statement Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const uploadBankStatementSchema = z.object({
|
||||||
|
accountId: uuidSchema,
|
||||||
|
file: z.string().min(1, 'El archivo es requerido'),
|
||||||
|
format: z.enum(['ofx', 'csv', 'xlsx']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UploadBankStatementInput = z.infer<typeof uploadBankStatementSchema>;
|
||||||
|
|
||||||
|
export const matchTransactionSchema = z.object({
|
||||||
|
statementLineId: uuidSchema,
|
||||||
|
transactionId: uuidSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MatchTransactionInput = z.infer<typeof matchTransactionSchema>;
|
||||||
|
|
||||||
|
export const createFromStatementLineSchema = z.object({
|
||||||
|
statementLineId: uuidSchema,
|
||||||
|
categoryId: uuidSchema.optional(),
|
||||||
|
contactId: uuidSchema.optional(),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateFromStatementLineInput = z.infer<typeof createFromStatementLineSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Bulk Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const bulkCategorizeSchema = z.object({
|
||||||
|
transactionIds: z.array(uuidSchema).min(1, 'Selecciona al menos una transacción'),
|
||||||
|
categoryId: uuidSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BulkCategorizeInput = z.infer<typeof bulkCategorizeSchema>;
|
||||||
|
|
||||||
|
export const bulkDeleteSchema = z.object({
|
||||||
|
ids: z.array(uuidSchema).min(1, 'Selecciona al menos un elemento'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BulkDeleteInput = z.infer<typeof bulkDeleteSchema>;
|
||||||
12
packages/shared/src/schemas/index.ts
Normal file
12
packages/shared/src/schemas/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Schemas Index - Re-export all validation schemas
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Authentication schemas
|
||||||
|
export * from './auth.schema';
|
||||||
|
|
||||||
|
// Tenant & subscription schemas
|
||||||
|
export * from './tenant.schema';
|
||||||
|
|
||||||
|
// Financial schemas
|
||||||
|
export * from './financial.schema';
|
||||||
509
packages/shared/src/schemas/tenant.schema.ts
Normal file
509
packages/shared/src/schemas/tenant.schema.ts
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
/**
|
||||||
|
* Tenant Validation Schemas
|
||||||
|
* Zod schemas for tenant, subscription, and billing validation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Common Validators
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC validation (Mexican tax ID)
|
||||||
|
*/
|
||||||
|
export const rfcSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^([A-ZÑ&]{3,4})(\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])([A-Z\d]{2})([A\d])$/,
|
||||||
|
'El RFC no tiene un formato válido'
|
||||||
|
)
|
||||||
|
.toUpperCase()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slug validation for URLs
|
||||||
|
*/
|
||||||
|
export const slugSchema = z
|
||||||
|
.string()
|
||||||
|
.min(2, 'El identificador debe tener al menos 2 caracteres')
|
||||||
|
.max(50, 'El identificador es demasiado largo')
|
||||||
|
.regex(
|
||||||
|
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
||||||
|
'El identificador solo puede contener letras minúsculas, números y guiones'
|
||||||
|
)
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant status enum
|
||||||
|
*/
|
||||||
|
export const tenantStatusSchema = z.enum([
|
||||||
|
'pending',
|
||||||
|
'active',
|
||||||
|
'suspended',
|
||||||
|
'cancelled',
|
||||||
|
'trial',
|
||||||
|
'expired',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription status enum
|
||||||
|
*/
|
||||||
|
export const subscriptionStatusSchema = z.enum([
|
||||||
|
'trialing',
|
||||||
|
'active',
|
||||||
|
'past_due',
|
||||||
|
'canceled',
|
||||||
|
'unpaid',
|
||||||
|
'paused',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Billing cycle enum
|
||||||
|
*/
|
||||||
|
export const billingCycleSchema = z.enum(['monthly', 'annual']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan tier enum
|
||||||
|
*/
|
||||||
|
export const planTierSchema = z.enum(['free', 'starter', 'professional', 'enterprise']);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Address Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const addressSchema = z.object({
|
||||||
|
street: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'La calle es requerida')
|
||||||
|
.max(200, 'La calle es demasiado larga'),
|
||||||
|
exteriorNumber: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El número exterior es requerido')
|
||||||
|
.max(20, 'El número exterior es demasiado largo'),
|
||||||
|
interiorNumber: z
|
||||||
|
.string()
|
||||||
|
.max(20, 'El número interior es demasiado largo')
|
||||||
|
.optional()
|
||||||
|
.or(z.literal('')),
|
||||||
|
neighborhood: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'La colonia es requerida')
|
||||||
|
.max(100, 'La colonia es demasiado larga'),
|
||||||
|
city: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'La ciudad es requerida')
|
||||||
|
.max(100, 'La ciudad es demasiado larga'),
|
||||||
|
state: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El estado es requerido')
|
||||||
|
.max(100, 'El estado es demasiado largo'),
|
||||||
|
country: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El país es requerido')
|
||||||
|
.max(100, 'El país es demasiado largo')
|
||||||
|
.default('México'),
|
||||||
|
postalCode: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{5}$/, 'El código postal debe tener 5 dígitos'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AddressInput = z.infer<typeof addressSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tenant Creation Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const createTenantSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(2, 'El nombre debe tener al menos 2 caracteres')
|
||||||
|
.max(100, 'El nombre es demasiado largo')
|
||||||
|
.trim(),
|
||||||
|
slug: slugSchema,
|
||||||
|
legalName: z
|
||||||
|
.string()
|
||||||
|
.min(2, 'La razón social debe tener al menos 2 caracteres')
|
||||||
|
.max(200, 'La razón social es demasiado larga')
|
||||||
|
.optional(),
|
||||||
|
rfc: rfcSchema.optional(),
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.email('El correo electrónico no es válido')
|
||||||
|
.max(255),
|
||||||
|
phone: z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^(\+52)?[\s.-]?\(?[0-9]{2,3}\)?[\s.-]?[0-9]{3,4}[\s.-]?[0-9]{4}$/,
|
||||||
|
'El número de teléfono no es válido'
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
website: z.string().url('La URL del sitio web no es válida').optional(),
|
||||||
|
planId: z.string().uuid('El ID del plan no es válido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateTenantInput = z.infer<typeof createTenantSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tenant Update Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const updateTenantSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(2, 'El nombre debe tener al menos 2 caracteres')
|
||||||
|
.max(100, 'El nombre es demasiado largo')
|
||||||
|
.trim()
|
||||||
|
.optional(),
|
||||||
|
legalName: z
|
||||||
|
.string()
|
||||||
|
.min(2, 'La razón social debe tener al menos 2 caracteres')
|
||||||
|
.max(200, 'La razón social es demasiado larga')
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
rfc: rfcSchema.optional().nullable(),
|
||||||
|
fiscalRegime: z
|
||||||
|
.string()
|
||||||
|
.min(3, 'El régimen fiscal no es válido')
|
||||||
|
.max(10, 'El régimen fiscal no es válido')
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
fiscalAddress: addressSchema.optional().nullable(),
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.email('El correo electrónico no es válido')
|
||||||
|
.max(255)
|
||||||
|
.optional(),
|
||||||
|
phone: z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^(\+52)?[\s.-]?\(?[0-9]{2,3}\)?[\s.-]?[0-9]{3,4}[\s.-]?[0-9]{4}$/,
|
||||||
|
'El número de teléfono no es válido'
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
website: z.string().url('La URL del sitio web no es válida').optional().nullable(),
|
||||||
|
logo: z.string().url('La URL del logo no es válida').optional().nullable(),
|
||||||
|
primaryColor: z
|
||||||
|
.string()
|
||||||
|
.regex(/^#[0-9A-Fa-f]{6}$/, 'El color primario no es válido')
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
secondaryColor: z
|
||||||
|
.string()
|
||||||
|
.regex(/^#[0-9A-Fa-f]{6}$/, 'El color secundario no es válido')
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateTenantInput = z.infer<typeof updateTenantSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tenant Settings Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const tenantSettingsSchema = z.object({
|
||||||
|
// General
|
||||||
|
timezone: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'La zona horaria es requerida')
|
||||||
|
.max(50)
|
||||||
|
.default('America/Mexico_City'),
|
||||||
|
locale: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-z]{2}(-[A-Z]{2})?$/)
|
||||||
|
.default('es-MX'),
|
||||||
|
currency: z
|
||||||
|
.string()
|
||||||
|
.length(3, 'La moneda debe ser un código de 3 letras')
|
||||||
|
.toUpperCase()
|
||||||
|
.default('MXN'),
|
||||||
|
fiscalYearStart: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(12)
|
||||||
|
.default(1),
|
||||||
|
|
||||||
|
// Invoicing
|
||||||
|
defaultPaymentTerms: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.max(365)
|
||||||
|
.default(30),
|
||||||
|
invoicePrefix: z
|
||||||
|
.string()
|
||||||
|
.max(10, 'El prefijo es demasiado largo')
|
||||||
|
.regex(/^[A-Z0-9]*$/, 'El prefijo solo puede contener letras mayúsculas y números')
|
||||||
|
.default(''),
|
||||||
|
invoiceNextNumber: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.default(1),
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
emailNotifications: z.boolean().default(true),
|
||||||
|
invoiceReminders: z.boolean().default(true),
|
||||||
|
paymentReminders: z.boolean().default(true),
|
||||||
|
|
||||||
|
// Security
|
||||||
|
sessionTimeout: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(5)
|
||||||
|
.max(1440)
|
||||||
|
.default(60),
|
||||||
|
requireTwoFactor: z.boolean().default(false),
|
||||||
|
allowedIPs: z
|
||||||
|
.array(
|
||||||
|
z.string().ip({ message: 'La dirección IP no es válida' })
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
// Integrations
|
||||||
|
satIntegration: z.boolean().default(false),
|
||||||
|
bankingIntegration: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TenantSettingsInput = z.infer<typeof tenantSettingsSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Plan Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const planFeaturesSchema = z.object({
|
||||||
|
// Modules
|
||||||
|
invoicing: z.boolean(),
|
||||||
|
expenses: z.boolean(),
|
||||||
|
bankReconciliation: z.boolean(),
|
||||||
|
reports: z.boolean(),
|
||||||
|
budgets: z.boolean(),
|
||||||
|
forecasting: z.boolean(),
|
||||||
|
multiCurrency: z.boolean(),
|
||||||
|
|
||||||
|
// CFDI
|
||||||
|
cfdiGeneration: z.boolean(),
|
||||||
|
cfdiCancellation: z.boolean(),
|
||||||
|
cfdiAddenda: z.boolean(),
|
||||||
|
massInvoicing: z.boolean(),
|
||||||
|
|
||||||
|
// Integrations
|
||||||
|
satIntegration: z.boolean(),
|
||||||
|
bankIntegration: z.boolean(),
|
||||||
|
erpIntegration: z.boolean(),
|
||||||
|
apiAccess: z.boolean(),
|
||||||
|
webhooks: z.boolean(),
|
||||||
|
|
||||||
|
// Collaboration
|
||||||
|
multiUser: z.boolean(),
|
||||||
|
customRoles: z.boolean(),
|
||||||
|
auditLog: z.boolean(),
|
||||||
|
comments: z.boolean(),
|
||||||
|
|
||||||
|
// Support
|
||||||
|
emailSupport: z.boolean(),
|
||||||
|
chatSupport: z.boolean(),
|
||||||
|
phoneSupport: z.boolean(),
|
||||||
|
prioritySupport: z.boolean(),
|
||||||
|
dedicatedManager: z.boolean(),
|
||||||
|
|
||||||
|
// Extras
|
||||||
|
customBranding: z.boolean(),
|
||||||
|
whiteLabel: z.boolean(),
|
||||||
|
dataExport: z.boolean(),
|
||||||
|
advancedReports: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const planLimitsSchema = z.object({
|
||||||
|
maxUsers: z.number().int().positive(),
|
||||||
|
maxTransactionsPerMonth: z.number().int().positive(),
|
||||||
|
maxInvoicesPerMonth: z.number().int().positive(),
|
||||||
|
maxContacts: z.number().int().positive(),
|
||||||
|
maxBankAccounts: z.number().int().positive(),
|
||||||
|
storageMB: z.number().int().positive(),
|
||||||
|
apiRequestsPerDay: z.number().int().positive(),
|
||||||
|
retentionDays: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const planPricingSchema = z.object({
|
||||||
|
monthlyPrice: z.number().nonnegative(),
|
||||||
|
annualPrice: z.number().nonnegative(),
|
||||||
|
currency: z.string().length(3).default('MXN'),
|
||||||
|
trialDays: z.number().int().nonnegative().default(14),
|
||||||
|
setupFee: z.number().nonnegative().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createPlanSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
tier: planTierSchema,
|
||||||
|
description: z.string().max(500),
|
||||||
|
features: planFeaturesSchema,
|
||||||
|
limits: planLimitsSchema,
|
||||||
|
pricing: planPricingSchema,
|
||||||
|
isActive: z.boolean().default(true),
|
||||||
|
isPopular: z.boolean().default(false),
|
||||||
|
sortOrder: z.number().int().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreatePlanInput = z.infer<typeof createPlanSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Subscription Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const createSubscriptionSchema = z.object({
|
||||||
|
tenantId: z.string().uuid('El ID del tenant no es válido'),
|
||||||
|
planId: z.string().uuid('El ID del plan no es válido'),
|
||||||
|
billingCycle: billingCycleSchema,
|
||||||
|
paymentMethodId: z.string().optional(),
|
||||||
|
promoCode: z.string().max(50).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateSubscriptionInput = z.infer<typeof createSubscriptionSchema>;
|
||||||
|
|
||||||
|
export const updateSubscriptionSchema = z.object({
|
||||||
|
planId: z.string().uuid('El ID del plan no es válido').optional(),
|
||||||
|
billingCycle: billingCycleSchema.optional(),
|
||||||
|
cancelAtPeriodEnd: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateSubscriptionInput = z.infer<typeof updateSubscriptionSchema>;
|
||||||
|
|
||||||
|
export const cancelSubscriptionSchema = z.object({
|
||||||
|
reason: z
|
||||||
|
.string()
|
||||||
|
.max(500, 'La razón es demasiado larga')
|
||||||
|
.optional(),
|
||||||
|
feedback: z
|
||||||
|
.string()
|
||||||
|
.max(1000, 'La retroalimentación es demasiado larga')
|
||||||
|
.optional(),
|
||||||
|
immediate: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CancelSubscriptionInput = z.infer<typeof cancelSubscriptionSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Payment Method Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const paymentMethodTypeSchema = z.enum([
|
||||||
|
'card',
|
||||||
|
'bank_transfer',
|
||||||
|
'oxxo',
|
||||||
|
'spei',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const addPaymentMethodSchema = z.object({
|
||||||
|
type: paymentMethodTypeSchema,
|
||||||
|
token: z.string().min(1, 'El token es requerido'),
|
||||||
|
setAsDefault: z.boolean().default(false),
|
||||||
|
billingAddress: addressSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AddPaymentMethodInput = z.infer<typeof addPaymentMethodSchema>;
|
||||||
|
|
||||||
|
export const updatePaymentMethodSchema = z.object({
|
||||||
|
setAsDefault: z.boolean().optional(),
|
||||||
|
billingAddress: addressSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdatePaymentMethodInput = z.infer<typeof updatePaymentMethodSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Promo Code Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const promoCodeSchema = z.object({
|
||||||
|
code: z
|
||||||
|
.string()
|
||||||
|
.min(3, 'El código es muy corto')
|
||||||
|
.max(50, 'El código es muy largo')
|
||||||
|
.toUpperCase()
|
||||||
|
.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PromoCodeInput = z.infer<typeof promoCodeSchema>;
|
||||||
|
|
||||||
|
export const createPromoCodeSchema = z.object({
|
||||||
|
code: z
|
||||||
|
.string()
|
||||||
|
.min(3)
|
||||||
|
.max(50)
|
||||||
|
.regex(/^[A-Z0-9_-]+$/, 'El código solo puede contener letras, números, guiones y guiones bajos')
|
||||||
|
.toUpperCase(),
|
||||||
|
discountType: z.enum(['percentage', 'fixed']),
|
||||||
|
discountValue: z.number().positive(),
|
||||||
|
maxRedemptions: z.number().int().positive().optional(),
|
||||||
|
validFrom: z.coerce.date(),
|
||||||
|
validUntil: z.coerce.date(),
|
||||||
|
applicablePlans: z.array(z.string().uuid()).optional(),
|
||||||
|
minBillingCycles: z.number().int().positive().optional(),
|
||||||
|
}).refine(
|
||||||
|
(data) => data.validUntil > data.validFrom,
|
||||||
|
{
|
||||||
|
message: 'La fecha de fin debe ser posterior a la fecha de inicio',
|
||||||
|
path: ['validUntil'],
|
||||||
|
}
|
||||||
|
).refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.discountType === 'percentage') {
|
||||||
|
return data.discountValue <= 100;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'El porcentaje de descuento no puede ser mayor a 100',
|
||||||
|
path: ['discountValue'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type CreatePromoCodeInput = z.infer<typeof createPromoCodeSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Invoice Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const invoiceFilterSchema = z.object({
|
||||||
|
status: z.enum(['draft', 'open', 'paid', 'void', 'uncollectible']).optional(),
|
||||||
|
dateFrom: z.coerce.date().optional(),
|
||||||
|
dateTo: z.coerce.date().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InvoiceFilterInput = z.infer<typeof invoiceFilterSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Usage Query Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const usageQuerySchema = z.object({
|
||||||
|
period: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'El período debe tener formato YYYY-MM')
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UsageQueryInput = z.infer<typeof usageQuerySchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tenant Filter Schema (Admin)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const tenantFilterSchema = z.object({
|
||||||
|
search: z.string().max(100).optional(),
|
||||||
|
status: tenantStatusSchema.optional(),
|
||||||
|
planId: z.string().uuid().optional(),
|
||||||
|
dateFrom: z.coerce.date().optional(),
|
||||||
|
dateTo: z.coerce.date().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
sortBy: z.enum(['createdAt', 'name', 'status']).default('createdAt'),
|
||||||
|
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TenantFilterInput = z.infer<typeof tenantFilterSchema>;
|
||||||
264
packages/shared/src/types/auth.ts
Normal file
264
packages/shared/src/types/auth.ts
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
/**
|
||||||
|
* Authentication Types for Horux Strategy
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// User Roles
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type UserRole =
|
||||||
|
| 'super_admin' // Administrador del sistema completo
|
||||||
|
| 'tenant_admin' // Administrador del tenant
|
||||||
|
| 'accountant' // Contador con acceso completo a finanzas
|
||||||
|
| 'assistant' // Asistente con acceso limitado
|
||||||
|
| 'viewer'; // Solo lectura
|
||||||
|
|
||||||
|
export interface UserPermission {
|
||||||
|
resource: string;
|
||||||
|
actions: ('create' | 'read' | 'update' | 'delete')[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RolePermissions {
|
||||||
|
role: UserRole;
|
||||||
|
permissions: UserPermission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// User
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
fullName: string;
|
||||||
|
role: UserRole;
|
||||||
|
permissions: UserPermission[];
|
||||||
|
avatar?: string;
|
||||||
|
phone?: string;
|
||||||
|
timezone: string;
|
||||||
|
locale: string;
|
||||||
|
isActive: boolean;
|
||||||
|
isEmailVerified: boolean;
|
||||||
|
lastLoginAt?: Date;
|
||||||
|
passwordChangedAt?: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
fullName: string;
|
||||||
|
role: UserRole;
|
||||||
|
avatar?: string;
|
||||||
|
phone?: string;
|
||||||
|
timezone: string;
|
||||||
|
locale: string;
|
||||||
|
tenant: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// User Session
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface UserSession {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
tenantId: string;
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
userAgent?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
isValid: boolean;
|
||||||
|
expiresAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
lastActivityAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveSession {
|
||||||
|
id: string;
|
||||||
|
userAgent?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
location?: string;
|
||||||
|
lastActivityAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
isCurrent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Authentication Requests & Responses
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
rememberMe?: boolean;
|
||||||
|
tenantSlug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
user: UserProfile;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
tokenType: 'Bearer';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
phone?: string;
|
||||||
|
tenantName?: string;
|
||||||
|
inviteCode?: string;
|
||||||
|
acceptTerms: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterResponse {
|
||||||
|
user: UserProfile;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
tokenType: 'Bearer';
|
||||||
|
requiresEmailVerification: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshTokenRequest {
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshTokenResponse {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
tokenType: 'Bearer';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForgotPasswordRequest {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResetPasswordRequest {
|
||||||
|
token: string;
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangePasswordRequest {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyEmailRequest {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Token Payload
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface TokenPayload {
|
||||||
|
sub: string; // User ID
|
||||||
|
email: string;
|
||||||
|
tenantId: string;
|
||||||
|
role: UserRole;
|
||||||
|
permissions: string[];
|
||||||
|
sessionId: string;
|
||||||
|
iat: number; // Issued at
|
||||||
|
exp: number; // Expiration
|
||||||
|
iss: string; // Issuer
|
||||||
|
aud: string; // Audience
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshTokenPayload {
|
||||||
|
sub: string; // User ID
|
||||||
|
sessionId: string;
|
||||||
|
tokenFamily: string; // Para detección de reuso
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
iss: string;
|
||||||
|
aud: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Invitation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface UserInvitation {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
email: string;
|
||||||
|
role: UserRole;
|
||||||
|
invitedBy: string;
|
||||||
|
token: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
acceptedAt?: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteUserRequest {
|
||||||
|
email: string;
|
||||||
|
role: UserRole;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Two-Factor Authentication
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface TwoFactorSetup {
|
||||||
|
secret: string;
|
||||||
|
qrCodeUrl: string;
|
||||||
|
backupCodes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorVerifyRequest {
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorLoginRequest {
|
||||||
|
tempToken: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Audit Log
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface AuthAuditLog {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
tenantId: string;
|
||||||
|
action: AuthAction;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
success: boolean;
|
||||||
|
failureReason?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthAction =
|
||||||
|
| 'login'
|
||||||
|
| 'logout'
|
||||||
|
| 'register'
|
||||||
|
| 'password_reset_request'
|
||||||
|
| 'password_reset_complete'
|
||||||
|
| 'password_change'
|
||||||
|
| 'email_verification'
|
||||||
|
| 'two_factor_enable'
|
||||||
|
| 'two_factor_disable'
|
||||||
|
| 'session_revoke'
|
||||||
|
| 'token_refresh';
|
||||||
634
packages/shared/src/types/financial.ts
Normal file
634
packages/shared/src/types/financial.ts
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
/**
|
||||||
|
* Financial Types for Horux Strategy
|
||||||
|
* Core financial entities for Mexican accounting
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Transaction Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type TransactionType =
|
||||||
|
| 'income' // Ingreso
|
||||||
|
| 'expense' // Egreso
|
||||||
|
| 'transfer' // Transferencia entre cuentas
|
||||||
|
| 'adjustment'; // Ajuste contable
|
||||||
|
|
||||||
|
export type TransactionStatus =
|
||||||
|
| 'pending' // Pendiente de procesar
|
||||||
|
| 'cleared' // Conciliado
|
||||||
|
| 'reconciled' // Conciliado con banco
|
||||||
|
| 'voided'; // Anulado
|
||||||
|
|
||||||
|
export type PaymentMethod =
|
||||||
|
| 'cash' // Efectivo
|
||||||
|
| 'bank_transfer' // Transferencia bancaria
|
||||||
|
| 'credit_card' // Tarjeta de crédito
|
||||||
|
| 'debit_card' // Tarjeta de débito
|
||||||
|
| 'check' // Cheque
|
||||||
|
| 'digital_wallet' // Wallet digital (SPEI, etc)
|
||||||
|
| 'other'; // Otro
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Transaction
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Transaction {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
type: TransactionType;
|
||||||
|
status: TransactionStatus;
|
||||||
|
|
||||||
|
// Monto
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
exchangeRate?: number;
|
||||||
|
amountInBaseCurrency: number;
|
||||||
|
|
||||||
|
// Detalles
|
||||||
|
description: string;
|
||||||
|
reference?: string;
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
// Fecha
|
||||||
|
date: Date;
|
||||||
|
valueDate?: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
accountId: string;
|
||||||
|
destinationAccountId?: string; // Para transferencias
|
||||||
|
categoryId?: string;
|
||||||
|
contactId?: string;
|
||||||
|
cfdiId?: string;
|
||||||
|
|
||||||
|
// Pago
|
||||||
|
paymentMethod?: PaymentMethod;
|
||||||
|
paymentReference?: string;
|
||||||
|
|
||||||
|
// Tags y metadata
|
||||||
|
tags: string[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
|
||||||
|
// Recurrencia
|
||||||
|
isRecurring: boolean;
|
||||||
|
recurringRuleId?: string;
|
||||||
|
|
||||||
|
// Conciliación
|
||||||
|
bankStatementId?: string;
|
||||||
|
reconciledAt?: Date;
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
deletedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransactionSummary {
|
||||||
|
id: string;
|
||||||
|
type: TransactionType;
|
||||||
|
status: TransactionStatus;
|
||||||
|
amount: number;
|
||||||
|
description: string;
|
||||||
|
date: Date;
|
||||||
|
categoryName?: string;
|
||||||
|
contactName?: string;
|
||||||
|
accountName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransactionFilters {
|
||||||
|
type?: TransactionType[];
|
||||||
|
status?: TransactionStatus[];
|
||||||
|
accountId?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
contactId?: string;
|
||||||
|
dateFrom?: Date;
|
||||||
|
dateTo?: Date;
|
||||||
|
amountMin?: number;
|
||||||
|
amountMax?: number;
|
||||||
|
search?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CFDI Types (Factura Electrónica México)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type CFDIType =
|
||||||
|
| 'I' // Ingreso
|
||||||
|
| 'E' // Egreso
|
||||||
|
| 'T' // Traslado
|
||||||
|
| 'N' // Nómina
|
||||||
|
| 'P'; // Pago
|
||||||
|
|
||||||
|
export type CFDIStatus =
|
||||||
|
| 'draft' // Borrador
|
||||||
|
| 'pending' // Pendiente de timbrar
|
||||||
|
| 'stamped' // Timbrado
|
||||||
|
| 'sent' // Enviado al cliente
|
||||||
|
| 'paid' // Pagado
|
||||||
|
| 'partial_paid' // Parcialmente pagado
|
||||||
|
| 'cancelled' // Cancelado
|
||||||
|
| 'cancellation_pending'; // Cancelación pendiente
|
||||||
|
|
||||||
|
export type CFDIUsage =
|
||||||
|
| 'G01' // Adquisición de mercancías
|
||||||
|
| 'G02' // Devoluciones, descuentos o bonificaciones
|
||||||
|
| 'G03' // Gastos en general
|
||||||
|
| 'I01' // Construcciones
|
||||||
|
| 'I02' // Mobiliario y equipo de oficina
|
||||||
|
| 'I03' // Equipo de transporte
|
||||||
|
| 'I04' // Equipo de cómputo
|
||||||
|
| 'I05' // Dados, troqueles, moldes
|
||||||
|
| 'I06' // Comunicaciones telefónicas
|
||||||
|
| 'I07' // Comunicaciones satelitales
|
||||||
|
| 'I08' // Otra maquinaria y equipo
|
||||||
|
| 'D01' // Honorarios médicos
|
||||||
|
| 'D02' // Gastos médicos por incapacidad
|
||||||
|
| 'D03' // Gastos funerales
|
||||||
|
| 'D04' // Donativos
|
||||||
|
| 'D05' // Intereses hipotecarios
|
||||||
|
| 'D06' // Aportaciones voluntarias SAR
|
||||||
|
| 'D07' // Primas seguros gastos médicos
|
||||||
|
| 'D08' // Gastos transportación escolar
|
||||||
|
| 'D09' // Depósitos ahorro
|
||||||
|
| 'D10' // Servicios educativos
|
||||||
|
| 'S01' // Sin efectos fiscales
|
||||||
|
| 'CP01' // Pagos
|
||||||
|
| 'CN01'; // Nómina
|
||||||
|
|
||||||
|
export type PaymentForm =
|
||||||
|
| '01' // Efectivo
|
||||||
|
| '02' // Cheque nominativo
|
||||||
|
| '03' // Transferencia electrónica
|
||||||
|
| '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
|
||||||
|
|
||||||
|
export type PaymentMethod_CFDI =
|
||||||
|
| 'PUE' // Pago en Una sola Exhibición
|
||||||
|
| 'PPD'; // Pago en Parcialidades o Diferido
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CFDI
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface CFDI {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
type: CFDIType;
|
||||||
|
status: CFDIStatus;
|
||||||
|
|
||||||
|
// Identificación
|
||||||
|
series?: string;
|
||||||
|
folio?: string;
|
||||||
|
uuid?: string;
|
||||||
|
|
||||||
|
// Emisor
|
||||||
|
issuerRfc: string;
|
||||||
|
issuerName: string;
|
||||||
|
issuerFiscalRegime: string;
|
||||||
|
issuerPostalCode: string;
|
||||||
|
|
||||||
|
// Receptor
|
||||||
|
receiverRfc: string;
|
||||||
|
receiverName: string;
|
||||||
|
receiverFiscalRegime?: string;
|
||||||
|
receiverPostalCode: string;
|
||||||
|
receiverUsage: CFDIUsage;
|
||||||
|
receiverEmail?: string;
|
||||||
|
|
||||||
|
// Montos
|
||||||
|
subtotal: number;
|
||||||
|
discount: number;
|
||||||
|
taxes: CFDITax[];
|
||||||
|
total: number;
|
||||||
|
currency: string;
|
||||||
|
exchangeRate?: number;
|
||||||
|
|
||||||
|
// Pago
|
||||||
|
paymentForm: PaymentForm;
|
||||||
|
paymentMethod: PaymentMethod_CFDI;
|
||||||
|
paymentConditions?: string;
|
||||||
|
|
||||||
|
// Conceptos
|
||||||
|
items: CFDIItem[];
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
relatedCfdis?: CFDIRelation[];
|
||||||
|
contactId?: string;
|
||||||
|
|
||||||
|
// Fechas
|
||||||
|
issueDate: Date;
|
||||||
|
certificationDate?: Date;
|
||||||
|
cancellationDate?: Date;
|
||||||
|
expirationDate?: Date;
|
||||||
|
|
||||||
|
// Certificación
|
||||||
|
certificateNumber?: string;
|
||||||
|
satCertificateNumber?: string;
|
||||||
|
digitalSignature?: string;
|
||||||
|
satSignature?: string;
|
||||||
|
|
||||||
|
// Archivos
|
||||||
|
xmlUrl?: string;
|
||||||
|
pdfUrl?: string;
|
||||||
|
|
||||||
|
// Cancelación
|
||||||
|
cancellationReason?: string;
|
||||||
|
substitutedByUuid?: string;
|
||||||
|
cancellationAcknowledgment?: string;
|
||||||
|
|
||||||
|
// Pago tracking
|
||||||
|
amountPaid: number;
|
||||||
|
balance: number;
|
||||||
|
lastPaymentDate?: Date;
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CFDIItem {
|
||||||
|
id: string;
|
||||||
|
productCode: string; // Clave del producto SAT
|
||||||
|
unitCode: string; // Clave de unidad SAT
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
discount?: number;
|
||||||
|
subtotal: number;
|
||||||
|
taxes: CFDIItemTax[];
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
// Opcional
|
||||||
|
identificationNumber?: string;
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CFDITax {
|
||||||
|
type: 'transferred' | 'withheld';
|
||||||
|
tax: 'IVA' | 'ISR' | 'IEPS';
|
||||||
|
factor: 'Tasa' | 'Cuota' | 'Exento';
|
||||||
|
rate: number;
|
||||||
|
base: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CFDIItemTax {
|
||||||
|
type: 'transferred' | 'withheld';
|
||||||
|
tax: 'IVA' | 'ISR' | 'IEPS';
|
||||||
|
factor: 'Tasa' | 'Cuota' | 'Exento';
|
||||||
|
rate: number;
|
||||||
|
base: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CFDIRelation {
|
||||||
|
type: CFDIRelationType;
|
||||||
|
uuid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CFDIRelationType =
|
||||||
|
| '01' // Nota de crédito
|
||||||
|
| '02' // Nota de débito
|
||||||
|
| '03' // Devolución de mercancía
|
||||||
|
| '04' // Sustitución de CFDI previos
|
||||||
|
| '05' // Traslados de mercancías facturadas
|
||||||
|
| '06' // Factura por traslados previos
|
||||||
|
| '07' // CFDI por aplicación de anticipo
|
||||||
|
| '08' // Factura por pagos en parcialidades
|
||||||
|
| '09'; // Factura por pagos diferidos
|
||||||
|
|
||||||
|
export interface CFDIPayment {
|
||||||
|
id: string;
|
||||||
|
cfdiId: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
exchangeRate?: number;
|
||||||
|
paymentDate: Date;
|
||||||
|
paymentForm: PaymentForm;
|
||||||
|
relatedCfdi: string;
|
||||||
|
previousBalance: number;
|
||||||
|
paidAmount: number;
|
||||||
|
remainingBalance: number;
|
||||||
|
transactionId?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Contact Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ContactType =
|
||||||
|
| 'customer' // Cliente
|
||||||
|
| 'supplier' // Proveedor
|
||||||
|
| 'both' // Ambos
|
||||||
|
| 'employee'; // Empleado
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Contact
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Contact {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
type: ContactType;
|
||||||
|
|
||||||
|
// Información básica
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
rfc?: string;
|
||||||
|
curp?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
website?: string;
|
||||||
|
|
||||||
|
// Fiscal
|
||||||
|
fiscalRegime?: string;
|
||||||
|
fiscalName?: string;
|
||||||
|
cfdiUsage?: CFDIUsage;
|
||||||
|
|
||||||
|
// Dirección
|
||||||
|
address?: ContactAddress;
|
||||||
|
|
||||||
|
// Crédito
|
||||||
|
creditLimit?: number;
|
||||||
|
creditDays?: number;
|
||||||
|
balance: number;
|
||||||
|
|
||||||
|
// Bancarios
|
||||||
|
bankAccounts?: ContactBankAccount[];
|
||||||
|
|
||||||
|
// Categorización
|
||||||
|
tags: string[];
|
||||||
|
groupId?: string;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
// Notas
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
deletedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactAddress {
|
||||||
|
street: string;
|
||||||
|
exteriorNumber: string;
|
||||||
|
interiorNumber?: string;
|
||||||
|
neighborhood: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
country: string;
|
||||||
|
postalCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactBankAccount {
|
||||||
|
id: string;
|
||||||
|
bankName: string;
|
||||||
|
accountNumber: string;
|
||||||
|
clabe?: string;
|
||||||
|
accountHolder: string;
|
||||||
|
currency: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactSummary {
|
||||||
|
id: string;
|
||||||
|
type: ContactType;
|
||||||
|
name: string;
|
||||||
|
rfc?: string;
|
||||||
|
email?: string;
|
||||||
|
balance: number;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Category
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type CategoryType = 'income' | 'expense';
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
type: CategoryType;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
code?: string;
|
||||||
|
color?: string;
|
||||||
|
icon?: string;
|
||||||
|
parentId?: string;
|
||||||
|
satCode?: string; // Código SAT para mapeo
|
||||||
|
isSystem: boolean; // Categoría del sistema (no editable)
|
||||||
|
isActive: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryTree extends Category {
|
||||||
|
children: CategoryTree[];
|
||||||
|
fullPath: string;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Account Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AccountType =
|
||||||
|
| 'bank' // Cuenta bancaria
|
||||||
|
| 'cash' // Caja/Efectivo
|
||||||
|
| 'credit_card' // Tarjeta de crédito
|
||||||
|
| 'loan' // Préstamo
|
||||||
|
| 'investment' // Inversión
|
||||||
|
| 'other'; // Otro
|
||||||
|
|
||||||
|
export type AccountSubtype =
|
||||||
|
| 'checking' // Cuenta de cheques
|
||||||
|
| 'savings' // Cuenta de ahorro
|
||||||
|
| 'money_market' // Mercado de dinero
|
||||||
|
| 'cd' // Certificado de depósito
|
||||||
|
| 'credit' // Crédito
|
||||||
|
| 'line_of_credit' // Línea de crédito
|
||||||
|
| 'mortgage' // Hipoteca
|
||||||
|
| 'auto_loan' // Préstamo auto
|
||||||
|
| 'personal_loan' // Préstamo personal
|
||||||
|
| 'brokerage' // Corretaje
|
||||||
|
| 'retirement' // Retiro
|
||||||
|
| 'other'; // Otro
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Account
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Account {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
type: AccountType;
|
||||||
|
subtype?: AccountSubtype;
|
||||||
|
|
||||||
|
// Información básica
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
accountNumber?: string;
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
// Banco
|
||||||
|
bankName?: string;
|
||||||
|
bankBranch?: string;
|
||||||
|
clabe?: string;
|
||||||
|
swiftCode?: string;
|
||||||
|
|
||||||
|
// Saldos
|
||||||
|
currentBalance: number;
|
||||||
|
availableBalance: number;
|
||||||
|
creditLimit?: number;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
isActive: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
|
isReconcilable: boolean;
|
||||||
|
|
||||||
|
// Sincronización
|
||||||
|
lastSyncAt?: Date;
|
||||||
|
connectionId?: string;
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountSummary {
|
||||||
|
id: string;
|
||||||
|
type: AccountType;
|
||||||
|
name: string;
|
||||||
|
bankName?: string;
|
||||||
|
currentBalance: number;
|
||||||
|
currency: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountBalance {
|
||||||
|
accountId: string;
|
||||||
|
date: Date;
|
||||||
|
balance: number;
|
||||||
|
availableBalance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Recurring Rules
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type RecurringFrequency =
|
||||||
|
| 'daily'
|
||||||
|
| 'weekly'
|
||||||
|
| 'biweekly'
|
||||||
|
| 'monthly'
|
||||||
|
| 'quarterly'
|
||||||
|
| 'yearly';
|
||||||
|
|
||||||
|
export interface RecurringRule {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
name: string;
|
||||||
|
type: TransactionType;
|
||||||
|
frequency: RecurringFrequency;
|
||||||
|
interval: number; // Cada N períodos
|
||||||
|
startDate: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
nextOccurrence: Date;
|
||||||
|
lastOccurrence?: Date;
|
||||||
|
|
||||||
|
// Template de transacción
|
||||||
|
amount: number;
|
||||||
|
description: string;
|
||||||
|
accountId: string;
|
||||||
|
categoryId?: string;
|
||||||
|
contactId?: string;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
isActive: boolean;
|
||||||
|
occurrenceCount: number;
|
||||||
|
maxOccurrences?: number;
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Bank Statement & Reconciliation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface BankStatement {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
accountId: string;
|
||||||
|
|
||||||
|
// Período
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
|
||||||
|
// Saldos
|
||||||
|
openingBalance: number;
|
||||||
|
closingBalance: number;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
status: 'pending' | 'in_progress' | 'completed';
|
||||||
|
reconciledAt?: Date;
|
||||||
|
reconciledBy?: string;
|
||||||
|
|
||||||
|
// Archivo
|
||||||
|
fileUrl?: string;
|
||||||
|
fileName?: string;
|
||||||
|
|
||||||
|
// Conteos
|
||||||
|
totalTransactions: number;
|
||||||
|
matchedTransactions: number;
|
||||||
|
unmatchedTransactions: number;
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankStatementLine {
|
||||||
|
id: string;
|
||||||
|
statementId: string;
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
reference?: string;
|
||||||
|
amount: number;
|
||||||
|
balance?: number;
|
||||||
|
type: 'debit' | 'credit';
|
||||||
|
|
||||||
|
// Matching
|
||||||
|
matchedTransactionId?: string;
|
||||||
|
matchConfidence?: number;
|
||||||
|
matchStatus: 'unmatched' | 'matched' | 'created' | 'ignored';
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
305
packages/shared/src/types/index.ts
Normal file
305
packages/shared/src/types/index.ts
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
/**
|
||||||
|
* Types Index - Re-export all types
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Authentication types
|
||||||
|
export * from './auth';
|
||||||
|
|
||||||
|
// Tenant & multi-tenancy types
|
||||||
|
export * from './tenant';
|
||||||
|
|
||||||
|
// Financial & accounting types
|
||||||
|
export * from './financial';
|
||||||
|
|
||||||
|
// Metrics & analytics types
|
||||||
|
export * from './metrics';
|
||||||
|
|
||||||
|
// Reports & alerts types
|
||||||
|
export * from './reports';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Common Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic API response wrapper
|
||||||
|
*/
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated API response
|
||||||
|
*/
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T[];
|
||||||
|
pagination: PaginationMeta;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination metadata
|
||||||
|
*/
|
||||||
|
export interface PaginationMeta {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPreviousPage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination request parameters
|
||||||
|
*/
|
||||||
|
export interface PaginationParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Error response
|
||||||
|
*/
|
||||||
|
export interface ApiError {
|
||||||
|
success: false;
|
||||||
|
error: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
field?: string;
|
||||||
|
stack?: string;
|
||||||
|
};
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation error details
|
||||||
|
*/
|
||||||
|
export interface ValidationError {
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
code: string;
|
||||||
|
value?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch operation result
|
||||||
|
*/
|
||||||
|
export interface BatchResult<T> {
|
||||||
|
success: boolean;
|
||||||
|
total: number;
|
||||||
|
succeeded: number;
|
||||||
|
failed: number;
|
||||||
|
results: BatchItemResult<T>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchItemResult<T> {
|
||||||
|
index: number;
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection for bulk operations
|
||||||
|
*/
|
||||||
|
export interface SelectionState {
|
||||||
|
selectedIds: string[];
|
||||||
|
selectAll: boolean;
|
||||||
|
excludedIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort configuration
|
||||||
|
*/
|
||||||
|
export interface SortConfig {
|
||||||
|
field: string;
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter configuration
|
||||||
|
*/
|
||||||
|
export interface FilterConfig {
|
||||||
|
field: string;
|
||||||
|
operator: FilterOperator;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FilterOperator =
|
||||||
|
| 'eq'
|
||||||
|
| 'neq'
|
||||||
|
| 'gt'
|
||||||
|
| 'gte'
|
||||||
|
| 'lt'
|
||||||
|
| 'lte'
|
||||||
|
| 'contains'
|
||||||
|
| 'starts_with'
|
||||||
|
| 'ends_with'
|
||||||
|
| 'in'
|
||||||
|
| 'not_in'
|
||||||
|
| 'between'
|
||||||
|
| 'is_null'
|
||||||
|
| 'is_not_null';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search parameters
|
||||||
|
*/
|
||||||
|
export interface SearchParams {
|
||||||
|
query: string;
|
||||||
|
fields?: string[];
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit information
|
||||||
|
*/
|
||||||
|
export interface AuditInfo {
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedBy?: string;
|
||||||
|
updatedAt: Date;
|
||||||
|
deletedBy?: string;
|
||||||
|
deletedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity with soft delete
|
||||||
|
*/
|
||||||
|
export interface SoftDeletable {
|
||||||
|
deletedAt?: Date;
|
||||||
|
deletedBy?: string;
|
||||||
|
isDeleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity with timestamps
|
||||||
|
*/
|
||||||
|
export interface Timestamped {
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base entity with common fields
|
||||||
|
*/
|
||||||
|
export interface BaseEntity extends Timestamped {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup option for dropdowns
|
||||||
|
*/
|
||||||
|
export interface LookupOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tree node for hierarchical data
|
||||||
|
*/
|
||||||
|
export interface TreeNode<T> {
|
||||||
|
data: T;
|
||||||
|
children: TreeNode<T>[];
|
||||||
|
parent?: TreeNode<T>;
|
||||||
|
level: number;
|
||||||
|
isExpanded?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File upload
|
||||||
|
*/
|
||||||
|
export interface FileUpload {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
originalName: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
uploadedBy: string;
|
||||||
|
uploadedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attachment
|
||||||
|
*/
|
||||||
|
export interface Attachment extends FileUpload {
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comment
|
||||||
|
*/
|
||||||
|
export interface Comment {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
content: string;
|
||||||
|
authorId: string;
|
||||||
|
authorName: string;
|
||||||
|
authorAvatar?: string;
|
||||||
|
parentId?: string;
|
||||||
|
replies?: Comment[];
|
||||||
|
attachments?: Attachment[];
|
||||||
|
isEdited: boolean;
|
||||||
|
editedAt?: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity log entry
|
||||||
|
*/
|
||||||
|
export interface ActivityLog {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
entityName?: string;
|
||||||
|
changes?: Record<string, { old: unknown; new: unknown }>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature flag
|
||||||
|
*/
|
||||||
|
export interface FeatureFlag {
|
||||||
|
key: string;
|
||||||
|
enabled: boolean;
|
||||||
|
description?: string;
|
||||||
|
rolloutPercentage?: number;
|
||||||
|
conditions?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App configuration
|
||||||
|
*/
|
||||||
|
export interface AppConfig {
|
||||||
|
environment: 'development' | 'staging' | 'production';
|
||||||
|
version: string;
|
||||||
|
apiUrl: string;
|
||||||
|
features: Record<string, boolean>;
|
||||||
|
limits: Record<string, number>;
|
||||||
|
}
|
||||||
490
packages/shared/src/types/metrics.ts
Normal file
490
packages/shared/src/types/metrics.ts
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
/**
|
||||||
|
* Metrics Types for Horux Strategy
|
||||||
|
* Analytics, KPIs and Dashboard data structures
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metric Period
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type MetricPeriod =
|
||||||
|
| 'today'
|
||||||
|
| 'yesterday'
|
||||||
|
| 'this_week'
|
||||||
|
| 'last_week'
|
||||||
|
| 'this_month'
|
||||||
|
| 'last_month'
|
||||||
|
| 'this_quarter'
|
||||||
|
| 'last_quarter'
|
||||||
|
| 'this_year'
|
||||||
|
| 'last_year'
|
||||||
|
| 'last_7_days'
|
||||||
|
| 'last_30_days'
|
||||||
|
| 'last_90_days'
|
||||||
|
| 'last_12_months'
|
||||||
|
| 'custom';
|
||||||
|
|
||||||
|
export type MetricGranularity =
|
||||||
|
| 'hour'
|
||||||
|
| 'day'
|
||||||
|
| 'week'
|
||||||
|
| 'month'
|
||||||
|
| 'quarter'
|
||||||
|
| 'year';
|
||||||
|
|
||||||
|
export type MetricAggregation =
|
||||||
|
| 'sum'
|
||||||
|
| 'avg'
|
||||||
|
| 'min'
|
||||||
|
| 'max'
|
||||||
|
| 'count'
|
||||||
|
| 'first'
|
||||||
|
| 'last';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Date Range
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
period?: MetricPeriod;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DateRangeComparison {
|
||||||
|
current: DateRange;
|
||||||
|
previous: DateRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metric
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Metric {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
category: MetricCategory;
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
// Tipo de dato
|
||||||
|
valueType: 'number' | 'currency' | 'percentage' | 'count';
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
// Configuración
|
||||||
|
aggregation: MetricAggregation;
|
||||||
|
isPositiveGood: boolean; // Para determinar color del cambio
|
||||||
|
|
||||||
|
// Objetivo
|
||||||
|
targetValue?: number;
|
||||||
|
warningThreshold?: number;
|
||||||
|
criticalThreshold?: number;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
isActive: boolean;
|
||||||
|
isSystem: boolean;
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MetricCategory =
|
||||||
|
| 'revenue'
|
||||||
|
| 'expenses'
|
||||||
|
| 'profit'
|
||||||
|
| 'cash_flow'
|
||||||
|
| 'receivables'
|
||||||
|
| 'payables'
|
||||||
|
| 'taxes'
|
||||||
|
| 'invoicing'
|
||||||
|
| 'operations';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metric Value
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface MetricValue {
|
||||||
|
metricId: string;
|
||||||
|
metricKey: string;
|
||||||
|
period: DateRange;
|
||||||
|
value: number;
|
||||||
|
formattedValue: string;
|
||||||
|
count?: number; // Número de elementos que componen el valor
|
||||||
|
breakdown?: MetricBreakdown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricBreakdown {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
percentage: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricTimeSeries {
|
||||||
|
metricId: string;
|
||||||
|
metricKey: string;
|
||||||
|
period: DateRange;
|
||||||
|
granularity: MetricGranularity;
|
||||||
|
dataPoints: MetricDataPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricDataPoint {
|
||||||
|
date: Date;
|
||||||
|
value: number;
|
||||||
|
formattedValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metric Comparison
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface MetricComparison {
|
||||||
|
metricId: string;
|
||||||
|
metricKey: string;
|
||||||
|
current: MetricValue;
|
||||||
|
previous: MetricValue;
|
||||||
|
change: MetricChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricChange {
|
||||||
|
absolute: number;
|
||||||
|
percentage: number;
|
||||||
|
direction: 'up' | 'down' | 'unchanged';
|
||||||
|
isPositive: boolean; // Basado en isPositiveGood del Metric
|
||||||
|
formattedAbsolute: string;
|
||||||
|
formattedPercentage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// KPI Card
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface KPICard {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
|
||||||
|
// Valor principal
|
||||||
|
value: number;
|
||||||
|
formattedValue: string;
|
||||||
|
valueType: 'number' | 'currency' | 'percentage' | 'count';
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
// Comparación
|
||||||
|
comparison?: MetricChange;
|
||||||
|
comparisonLabel?: string;
|
||||||
|
|
||||||
|
// Objetivo
|
||||||
|
target?: {
|
||||||
|
value: number;
|
||||||
|
formattedValue: string;
|
||||||
|
progress: number; // 0-100
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trend
|
||||||
|
trend?: {
|
||||||
|
direction: 'up' | 'down' | 'stable';
|
||||||
|
dataPoints: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Desglose
|
||||||
|
breakdown?: MetricBreakdown[];
|
||||||
|
|
||||||
|
// Acción
|
||||||
|
actionLabel?: string;
|
||||||
|
actionUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Dashboard Data
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface DashboardData {
|
||||||
|
tenantId: string;
|
||||||
|
period: DateRange;
|
||||||
|
comparisonPeriod?: DateRange;
|
||||||
|
generatedAt: Date;
|
||||||
|
|
||||||
|
// KPIs principales
|
||||||
|
kpis: DashboardKPIs;
|
||||||
|
|
||||||
|
// Resumen financiero
|
||||||
|
financialSummary: FinancialSummary;
|
||||||
|
|
||||||
|
// Flujo de efectivo
|
||||||
|
cashFlow: CashFlowData;
|
||||||
|
|
||||||
|
// Por cobrar y por pagar
|
||||||
|
receivables: ReceivablesData;
|
||||||
|
payables: PayablesData;
|
||||||
|
|
||||||
|
// Gráficas
|
||||||
|
revenueChart: MetricTimeSeries;
|
||||||
|
expenseChart: MetricTimeSeries;
|
||||||
|
profitChart: MetricTimeSeries;
|
||||||
|
|
||||||
|
// Top lists
|
||||||
|
topCustomers: TopListItem[];
|
||||||
|
topSuppliers: TopListItem[];
|
||||||
|
topCategories: TopListItem[];
|
||||||
|
|
||||||
|
// Alertas y pendientes
|
||||||
|
alerts: DashboardAlert[];
|
||||||
|
pendingItems: PendingItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardKPIs {
|
||||||
|
totalRevenue: KPICard;
|
||||||
|
totalExpenses: KPICard;
|
||||||
|
netProfit: KPICard;
|
||||||
|
profitMargin: KPICard;
|
||||||
|
cashBalance: KPICard;
|
||||||
|
accountsReceivable: KPICard;
|
||||||
|
accountsPayable: KPICard;
|
||||||
|
pendingInvoices: KPICard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinancialSummary {
|
||||||
|
period: DateRange;
|
||||||
|
|
||||||
|
// Ingresos
|
||||||
|
totalRevenue: number;
|
||||||
|
invoicedRevenue: number;
|
||||||
|
otherRevenue: number;
|
||||||
|
|
||||||
|
// Gastos
|
||||||
|
totalExpenses: number;
|
||||||
|
operatingExpenses: number;
|
||||||
|
costOfGoods: number;
|
||||||
|
otherExpenses: number;
|
||||||
|
|
||||||
|
// Impuestos
|
||||||
|
ivaCollected: number;
|
||||||
|
ivaPaid: number;
|
||||||
|
ivaBalance: number;
|
||||||
|
isrRetained: number;
|
||||||
|
|
||||||
|
// Resultado
|
||||||
|
grossProfit: number;
|
||||||
|
grossMargin: number;
|
||||||
|
netProfit: number;
|
||||||
|
netMargin: number;
|
||||||
|
|
||||||
|
// Comparación
|
||||||
|
comparison?: {
|
||||||
|
revenueChange: MetricChange;
|
||||||
|
expensesChange: MetricChange;
|
||||||
|
profitChange: MetricChange;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashFlowData {
|
||||||
|
period: DateRange;
|
||||||
|
|
||||||
|
// Saldos
|
||||||
|
openingBalance: number;
|
||||||
|
closingBalance: number;
|
||||||
|
netChange: number;
|
||||||
|
|
||||||
|
// Flujos
|
||||||
|
operatingCashFlow: number;
|
||||||
|
investingCashFlow: number;
|
||||||
|
financingCashFlow: number;
|
||||||
|
|
||||||
|
// Desglose operativo
|
||||||
|
cashFromCustomers: number;
|
||||||
|
cashToSuppliers: number;
|
||||||
|
cashToEmployees: number;
|
||||||
|
taxesPaid: number;
|
||||||
|
otherOperating: number;
|
||||||
|
|
||||||
|
// Proyección
|
||||||
|
projection?: CashFlowProjection[];
|
||||||
|
|
||||||
|
// Serie temporal
|
||||||
|
timeSeries: MetricDataPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashFlowProjection {
|
||||||
|
date: Date;
|
||||||
|
projectedBalance: number;
|
||||||
|
expectedInflows: number;
|
||||||
|
expectedOutflows: number;
|
||||||
|
confidence: 'high' | 'medium' | 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReceivablesData {
|
||||||
|
total: number;
|
||||||
|
current: number; // No vencido
|
||||||
|
overdue: number; // Vencido
|
||||||
|
overduePercentage: number;
|
||||||
|
|
||||||
|
// Por antigüedad
|
||||||
|
aging: AgingBucket[];
|
||||||
|
|
||||||
|
// Top deudores
|
||||||
|
topDebtors: {
|
||||||
|
contactId: string;
|
||||||
|
contactName: string;
|
||||||
|
amount: number;
|
||||||
|
oldestDueDate: Date;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
// Cobros esperados
|
||||||
|
expectedCollections: {
|
||||||
|
thisWeek: number;
|
||||||
|
thisMonth: number;
|
||||||
|
nextMonth: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PayablesData {
|
||||||
|
total: number;
|
||||||
|
current: number;
|
||||||
|
overdue: number;
|
||||||
|
overduePercentage: number;
|
||||||
|
|
||||||
|
// Por antigüedad
|
||||||
|
aging: AgingBucket[];
|
||||||
|
|
||||||
|
// Top acreedores
|
||||||
|
topCreditors: {
|
||||||
|
contactId: string;
|
||||||
|
contactName: string;
|
||||||
|
amount: number;
|
||||||
|
oldestDueDate: Date;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
// Pagos programados
|
||||||
|
scheduledPayments: {
|
||||||
|
thisWeek: number;
|
||||||
|
thisMonth: number;
|
||||||
|
nextMonth: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgingBucket {
|
||||||
|
label: string;
|
||||||
|
minDays: number;
|
||||||
|
maxDays?: number;
|
||||||
|
amount: number;
|
||||||
|
count: number;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopListItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
formattedValue: string;
|
||||||
|
percentage: number;
|
||||||
|
count?: number;
|
||||||
|
trend?: 'up' | 'down' | 'stable';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardAlert {
|
||||||
|
id: string;
|
||||||
|
type: AlertType;
|
||||||
|
severity: 'info' | 'warning' | 'error';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
actionUrl?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AlertType =
|
||||||
|
| 'overdue_invoice'
|
||||||
|
| 'overdue_payment'
|
||||||
|
| 'low_cash'
|
||||||
|
| 'high_expenses'
|
||||||
|
| 'tax_deadline'
|
||||||
|
| 'subscription_expiring'
|
||||||
|
| 'usage_limit'
|
||||||
|
| 'reconciliation_needed'
|
||||||
|
| 'pending_approval';
|
||||||
|
|
||||||
|
export interface PendingItem {
|
||||||
|
id: string;
|
||||||
|
type: PendingItemType;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
amount?: number;
|
||||||
|
dueDate?: Date;
|
||||||
|
priority: 'low' | 'medium' | 'high';
|
||||||
|
actionLabel: string;
|
||||||
|
actionUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PendingItemType =
|
||||||
|
| 'draft_invoice'
|
||||||
|
| 'pending_approval'
|
||||||
|
| 'unreconciled_transaction'
|
||||||
|
| 'uncategorized_expense'
|
||||||
|
| 'missing_document'
|
||||||
|
| 'overdue_task';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Widget Configuration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface DashboardWidget {
|
||||||
|
id: string;
|
||||||
|
type: WidgetType;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
|
||||||
|
// Configuración
|
||||||
|
config: WidgetConfig;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
isVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WidgetType =
|
||||||
|
| 'kpi'
|
||||||
|
| 'chart_line'
|
||||||
|
| 'chart_bar'
|
||||||
|
| 'chart_pie'
|
||||||
|
| 'chart_area'
|
||||||
|
| 'table'
|
||||||
|
| 'list'
|
||||||
|
| 'calendar'
|
||||||
|
| 'alerts';
|
||||||
|
|
||||||
|
export interface WidgetConfig {
|
||||||
|
metricKey?: string;
|
||||||
|
period?: MetricPeriod;
|
||||||
|
showComparison?: boolean;
|
||||||
|
showTarget?: boolean;
|
||||||
|
showTrend?: boolean;
|
||||||
|
showBreakdown?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
filters?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Dashboard Layout
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface DashboardLayout {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
userId?: string; // null = layout por defecto
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
widgets: DashboardWidget[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
578
packages/shared/src/types/reports.ts
Normal file
578
packages/shared/src/types/reports.ts
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
/**
|
||||||
|
* Report Types for Horux Strategy
|
||||||
|
* Reports, exports, and alert configurations
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Report Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ReportType =
|
||||||
|
// Financieros
|
||||||
|
| 'income_statement' // Estado de resultados
|
||||||
|
| 'balance_sheet' // Balance general
|
||||||
|
| 'cash_flow' // Flujo de efectivo
|
||||||
|
| 'trial_balance' // Balanza de comprobación
|
||||||
|
|
||||||
|
// Operativos
|
||||||
|
| 'accounts_receivable' // Cuentas por cobrar
|
||||||
|
| 'accounts_payable' // Cuentas por pagar
|
||||||
|
| 'aging_report' // Antigüedad de saldos
|
||||||
|
| 'transactions' // Movimientos
|
||||||
|
|
||||||
|
// Fiscales
|
||||||
|
| 'tax_summary' // Resumen de impuestos
|
||||||
|
| 'iva_report' // Reporte de IVA
|
||||||
|
| 'isr_report' // Reporte de ISR
|
||||||
|
| 'diot' // DIOT
|
||||||
|
|
||||||
|
// CFDI
|
||||||
|
| 'invoices_issued' // Facturas emitidas
|
||||||
|
| 'invoices_received' // Facturas recibidas
|
||||||
|
| 'cfdi_cancellations' // Cancelaciones
|
||||||
|
|
||||||
|
// Análisis
|
||||||
|
| 'expense_analysis' // Análisis de gastos
|
||||||
|
| 'revenue_analysis' // Análisis de ingresos
|
||||||
|
| 'category_analysis' // Análisis por categoría
|
||||||
|
| 'contact_analysis' // Análisis por contacto
|
||||||
|
|
||||||
|
// Custom
|
||||||
|
| 'custom';
|
||||||
|
|
||||||
|
export type ReportStatus =
|
||||||
|
| 'pending' // En cola
|
||||||
|
| 'processing' // Procesando
|
||||||
|
| 'completed' // Completado
|
||||||
|
| 'failed' // Error
|
||||||
|
| 'expired'; // Expirado (archivo eliminado)
|
||||||
|
|
||||||
|
export type ReportFormat =
|
||||||
|
| 'pdf'
|
||||||
|
| 'xlsx'
|
||||||
|
| 'csv'
|
||||||
|
| 'xml'
|
||||||
|
| 'json';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Report
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Report {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
type: ReportType;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
status: ReportStatus;
|
||||||
|
|
||||||
|
// Configuración
|
||||||
|
config: ReportConfig;
|
||||||
|
|
||||||
|
// Período
|
||||||
|
periodStart: Date;
|
||||||
|
periodEnd: Date;
|
||||||
|
|
||||||
|
// Formato
|
||||||
|
format: ReportFormat;
|
||||||
|
locale: string;
|
||||||
|
timezone: string;
|
||||||
|
|
||||||
|
// Archivo generado
|
||||||
|
fileUrl?: string;
|
||||||
|
fileName?: string;
|
||||||
|
fileSizeBytes?: number;
|
||||||
|
expiresAt?: Date;
|
||||||
|
|
||||||
|
// Procesamiento
|
||||||
|
startedAt?: Date;
|
||||||
|
completedAt?: Date;
|
||||||
|
error?: string;
|
||||||
|
progress?: number; // 0-100
|
||||||
|
|
||||||
|
// Metadatos
|
||||||
|
rowCount?: number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportConfig {
|
||||||
|
// Filtros generales
|
||||||
|
accountIds?: string[];
|
||||||
|
categoryIds?: string[];
|
||||||
|
contactIds?: string[];
|
||||||
|
transactionTypes?: string[];
|
||||||
|
|
||||||
|
// Agrupación
|
||||||
|
groupBy?: ReportGroupBy[];
|
||||||
|
sortBy?: ReportSortConfig[];
|
||||||
|
|
||||||
|
// Columnas
|
||||||
|
columns?: string[];
|
||||||
|
includeSubtotals?: boolean;
|
||||||
|
includeTotals?: boolean;
|
||||||
|
|
||||||
|
// Comparación
|
||||||
|
compareWithPreviousPeriod?: boolean;
|
||||||
|
comparisonPeriodStart?: Date;
|
||||||
|
comparisonPeriodEnd?: Date;
|
||||||
|
|
||||||
|
// Moneda
|
||||||
|
currency?: string;
|
||||||
|
showOriginalCurrency?: boolean;
|
||||||
|
|
||||||
|
// Formato específico
|
||||||
|
showZeroBalances?: boolean;
|
||||||
|
showInactiveAccounts?: boolean;
|
||||||
|
consolidateAccounts?: boolean;
|
||||||
|
|
||||||
|
// PDF específico
|
||||||
|
includeCharts?: boolean;
|
||||||
|
includeSummary?: boolean;
|
||||||
|
includeNotes?: boolean;
|
||||||
|
companyLogo?: boolean;
|
||||||
|
pageOrientation?: 'portrait' | 'landscape';
|
||||||
|
|
||||||
|
// Personalizado
|
||||||
|
customFilters?: Record<string, unknown>;
|
||||||
|
customOptions?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportGroupBy =
|
||||||
|
| 'date'
|
||||||
|
| 'week'
|
||||||
|
| 'month'
|
||||||
|
| 'quarter'
|
||||||
|
| 'year'
|
||||||
|
| 'account'
|
||||||
|
| 'category'
|
||||||
|
| 'contact'
|
||||||
|
| 'type'
|
||||||
|
| 'status';
|
||||||
|
|
||||||
|
export interface ReportSortConfig {
|
||||||
|
field: string;
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Report Template
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ReportTemplate {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
type: ReportType;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
config: ReportConfig;
|
||||||
|
isDefault: boolean;
|
||||||
|
isSystem: boolean;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Scheduled Report
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ScheduleFrequency =
|
||||||
|
| 'daily'
|
||||||
|
| 'weekly'
|
||||||
|
| 'biweekly'
|
||||||
|
| 'monthly'
|
||||||
|
| 'quarterly'
|
||||||
|
| 'yearly';
|
||||||
|
|
||||||
|
export interface ScheduledReport {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
templateId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
// Programación
|
||||||
|
frequency: ScheduleFrequency;
|
||||||
|
dayOfWeek?: number; // 0-6 (domingo-sábado)
|
||||||
|
dayOfMonth?: number; // 1-31
|
||||||
|
time: string; // HH:mm formato 24h
|
||||||
|
timezone: string;
|
||||||
|
|
||||||
|
// Período del reporte
|
||||||
|
periodType: 'previous' | 'current' | 'custom';
|
||||||
|
periodOffset?: number; // Para períodos anteriores
|
||||||
|
|
||||||
|
// Formato
|
||||||
|
format: ReportFormat;
|
||||||
|
|
||||||
|
// Entrega
|
||||||
|
deliveryMethod: DeliveryMethod[];
|
||||||
|
recipients: ReportRecipient[];
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
isActive: boolean;
|
||||||
|
lastRunAt?: Date;
|
||||||
|
nextRunAt: Date;
|
||||||
|
lastReportId?: string;
|
||||||
|
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeliveryMethod =
|
||||||
|
| 'email'
|
||||||
|
| 'download'
|
||||||
|
| 'webhook'
|
||||||
|
| 'storage';
|
||||||
|
|
||||||
|
export interface ReportRecipient {
|
||||||
|
type: 'user' | 'email';
|
||||||
|
userId?: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Report Execution
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ReportExecution {
|
||||||
|
id: string;
|
||||||
|
reportId?: string;
|
||||||
|
scheduledReportId?: string;
|
||||||
|
status: ReportStatus;
|
||||||
|
startedAt: Date;
|
||||||
|
completedAt?: Date;
|
||||||
|
durationMs?: number;
|
||||||
|
error?: string;
|
||||||
|
retryCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Alert Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AlertSeverity =
|
||||||
|
| 'info'
|
||||||
|
| 'warning'
|
||||||
|
| 'error'
|
||||||
|
| 'critical';
|
||||||
|
|
||||||
|
export type AlertChannel =
|
||||||
|
| 'in_app'
|
||||||
|
| 'email'
|
||||||
|
| 'sms'
|
||||||
|
| 'push'
|
||||||
|
| 'webhook';
|
||||||
|
|
||||||
|
export type AlertTriggerType =
|
||||||
|
// Financieros
|
||||||
|
| 'low_cash_balance'
|
||||||
|
| 'high_expenses'
|
||||||
|
| 'revenue_drop'
|
||||||
|
| 'profit_margin_low'
|
||||||
|
|
||||||
|
// Cobros y pagos
|
||||||
|
| 'invoice_overdue'
|
||||||
|
| 'payment_due'
|
||||||
|
| 'receivable_aging'
|
||||||
|
| 'payable_aging'
|
||||||
|
|
||||||
|
// Límites
|
||||||
|
| 'budget_exceeded'
|
||||||
|
| 'credit_limit_reached'
|
||||||
|
| 'usage_limit_warning'
|
||||||
|
|
||||||
|
// Operaciones
|
||||||
|
| 'reconciliation_discrepancy'
|
||||||
|
| 'duplicate_transaction'
|
||||||
|
| 'unusual_activity'
|
||||||
|
|
||||||
|
// Fiscales
|
||||||
|
| 'tax_deadline'
|
||||||
|
| 'cfdi_rejection'
|
||||||
|
| 'sat_notification'
|
||||||
|
|
||||||
|
// Sistema
|
||||||
|
| 'subscription_expiring'
|
||||||
|
| 'integration_error'
|
||||||
|
| 'backup_failed';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Alert
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Alert {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
ruleId?: string;
|
||||||
|
type: AlertTriggerType;
|
||||||
|
severity: AlertSeverity;
|
||||||
|
|
||||||
|
// Contenido
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
|
||||||
|
// Contexto
|
||||||
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
entityName?: string;
|
||||||
|
|
||||||
|
// Valores
|
||||||
|
currentValue?: number;
|
||||||
|
thresholdValue?: number;
|
||||||
|
|
||||||
|
// Acción
|
||||||
|
actionLabel?: string;
|
||||||
|
actionUrl?: string;
|
||||||
|
actionRequired: boolean;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
status: AlertStatus;
|
||||||
|
acknowledgedBy?: string;
|
||||||
|
acknowledgedAt?: Date;
|
||||||
|
resolvedBy?: string;
|
||||||
|
resolvedAt?: Date;
|
||||||
|
resolution?: string;
|
||||||
|
|
||||||
|
// Notificaciones
|
||||||
|
channels: AlertChannel[];
|
||||||
|
notifiedAt?: Date;
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AlertStatus =
|
||||||
|
| 'active'
|
||||||
|
| 'acknowledged'
|
||||||
|
| 'resolved'
|
||||||
|
| 'dismissed';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Alert Rule
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface AlertRule {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
type: AlertTriggerType;
|
||||||
|
severity: AlertSeverity;
|
||||||
|
|
||||||
|
// Condición
|
||||||
|
condition: AlertCondition;
|
||||||
|
|
||||||
|
// Mensaje
|
||||||
|
titleTemplate: string;
|
||||||
|
messageTemplate: string;
|
||||||
|
|
||||||
|
// Notificación
|
||||||
|
channels: AlertChannel[];
|
||||||
|
recipients: AlertRecipient[];
|
||||||
|
|
||||||
|
// Cooldown
|
||||||
|
cooldownMinutes: number; // Tiempo mínimo entre alertas
|
||||||
|
lastTriggeredAt?: Date;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertCondition {
|
||||||
|
metric: string;
|
||||||
|
operator: AlertOperator;
|
||||||
|
value: number;
|
||||||
|
unit?: string;
|
||||||
|
|
||||||
|
// Para condiciones compuestas
|
||||||
|
and?: AlertCondition[];
|
||||||
|
or?: AlertCondition[];
|
||||||
|
|
||||||
|
// Contexto
|
||||||
|
accountId?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
contactId?: string;
|
||||||
|
|
||||||
|
// Período de evaluación
|
||||||
|
evaluationPeriod?: string; // e.g., "1d", "7d", "30d"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AlertOperator =
|
||||||
|
| 'gt' // Mayor que
|
||||||
|
| 'gte' // Mayor o igual que
|
||||||
|
| 'lt' // Menor que
|
||||||
|
| 'lte' // Menor o igual que
|
||||||
|
| 'eq' // Igual a
|
||||||
|
| 'neq' // Diferente de
|
||||||
|
| 'between' // Entre (requiere value2)
|
||||||
|
| 'change_gt' // Cambio mayor que %
|
||||||
|
| 'change_lt'; // Cambio menor que %
|
||||||
|
|
||||||
|
export interface AlertRecipient {
|
||||||
|
type: 'user' | 'role' | 'email' | 'webhook';
|
||||||
|
userId?: string;
|
||||||
|
role?: string;
|
||||||
|
email?: string;
|
||||||
|
webhookUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Notification
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
alertId?: string;
|
||||||
|
|
||||||
|
// Contenido
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
|
||||||
|
// Acción
|
||||||
|
actionLabel?: string;
|
||||||
|
actionUrl?: string;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
isRead: boolean;
|
||||||
|
readAt?: Date;
|
||||||
|
|
||||||
|
// Metadatos
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
|
expiresAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationType =
|
||||||
|
| 'alert'
|
||||||
|
| 'report_ready'
|
||||||
|
| 'task_assigned'
|
||||||
|
| 'mention'
|
||||||
|
| 'system'
|
||||||
|
| 'update'
|
||||||
|
| 'reminder';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Export Job
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ExportJob {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
type: ExportType;
|
||||||
|
status: ReportStatus;
|
||||||
|
|
||||||
|
// Configuración
|
||||||
|
entityType: string;
|
||||||
|
filters?: Record<string, unknown>;
|
||||||
|
columns?: string[];
|
||||||
|
format: ReportFormat;
|
||||||
|
|
||||||
|
// Archivo
|
||||||
|
fileUrl?: string;
|
||||||
|
fileName?: string;
|
||||||
|
fileSizeBytes?: number;
|
||||||
|
rowCount?: number;
|
||||||
|
|
||||||
|
// Procesamiento
|
||||||
|
startedAt?: Date;
|
||||||
|
completedAt?: Date;
|
||||||
|
expiresAt?: Date;
|
||||||
|
error?: string;
|
||||||
|
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExportType =
|
||||||
|
| 'transactions'
|
||||||
|
| 'invoices'
|
||||||
|
| 'contacts'
|
||||||
|
| 'categories'
|
||||||
|
| 'accounts'
|
||||||
|
| 'reports'
|
||||||
|
| 'full_backup';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Import Job
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ImportJob {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
type: ImportType;
|
||||||
|
status: ImportStatus;
|
||||||
|
|
||||||
|
// Archivo
|
||||||
|
fileUrl: string;
|
||||||
|
fileName: string;
|
||||||
|
fileSizeBytes: number;
|
||||||
|
|
||||||
|
// Mapeo
|
||||||
|
mapping?: ImportMapping;
|
||||||
|
|
||||||
|
// Resultados
|
||||||
|
totalRows?: number;
|
||||||
|
processedRows?: number;
|
||||||
|
successRows?: number;
|
||||||
|
errorRows?: number;
|
||||||
|
errors?: ImportError[];
|
||||||
|
|
||||||
|
// Procesamiento
|
||||||
|
startedAt?: Date;
|
||||||
|
completedAt?: Date;
|
||||||
|
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportType =
|
||||||
|
| 'transactions'
|
||||||
|
| 'invoices'
|
||||||
|
| 'contacts'
|
||||||
|
| 'categories'
|
||||||
|
| 'bank_statement'
|
||||||
|
| 'cfdi_xml';
|
||||||
|
|
||||||
|
export type ImportStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'mapping'
|
||||||
|
| 'validating'
|
||||||
|
| 'processing'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed'
|
||||||
|
| 'cancelled';
|
||||||
|
|
||||||
|
export interface ImportMapping {
|
||||||
|
[targetField: string]: {
|
||||||
|
sourceField: string;
|
||||||
|
transform?: string;
|
||||||
|
defaultValue?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportError {
|
||||||
|
row: number;
|
||||||
|
field?: string;
|
||||||
|
value?: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
379
packages/shared/src/types/tenant.ts
Normal file
379
packages/shared/src/types/tenant.ts
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
/**
|
||||||
|
* Tenant Types for Horux Strategy
|
||||||
|
* Multi-tenancy support for SaaS architecture
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tenant Status
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type TenantStatus =
|
||||||
|
| 'pending' // Registro pendiente de aprobación
|
||||||
|
| 'active' // Tenant activo y operativo
|
||||||
|
| 'suspended' // Suspendido por falta de pago o violación
|
||||||
|
| 'cancelled' // Cancelado por el usuario
|
||||||
|
| 'trial' // En período de prueba
|
||||||
|
| 'expired'; // Período de prueba expirado
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tenant
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Tenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
legalName?: string;
|
||||||
|
rfc?: string;
|
||||||
|
status: TenantStatus;
|
||||||
|
planId: string;
|
||||||
|
subscriptionId?: string;
|
||||||
|
|
||||||
|
// Configuración fiscal México
|
||||||
|
fiscalRegime?: string;
|
||||||
|
fiscalAddress?: TenantAddress;
|
||||||
|
|
||||||
|
// Información de contacto
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
website?: string;
|
||||||
|
|
||||||
|
// Branding
|
||||||
|
logo?: string;
|
||||||
|
primaryColor?: string;
|
||||||
|
secondaryColor?: string;
|
||||||
|
|
||||||
|
// Configuración
|
||||||
|
settings: TenantSettings;
|
||||||
|
features: string[];
|
||||||
|
|
||||||
|
// Límites
|
||||||
|
maxUsers: number;
|
||||||
|
maxTransactionsPerMonth: number;
|
||||||
|
storageUsedMB: number;
|
||||||
|
storageLimitMB: number;
|
||||||
|
|
||||||
|
// Fechas
|
||||||
|
trialEndsAt?: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
deletedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantAddress {
|
||||||
|
street: string;
|
||||||
|
exteriorNumber: string;
|
||||||
|
interiorNumber?: string;
|
||||||
|
neighborhood: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
country: string;
|
||||||
|
postalCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantSettings {
|
||||||
|
// General
|
||||||
|
timezone: string;
|
||||||
|
locale: string;
|
||||||
|
currency: string;
|
||||||
|
fiscalYearStart: number; // Mes (1-12)
|
||||||
|
|
||||||
|
// Facturación
|
||||||
|
defaultPaymentTerms: number; // Días
|
||||||
|
invoicePrefix: string;
|
||||||
|
invoiceNextNumber: number;
|
||||||
|
|
||||||
|
// Notificaciones
|
||||||
|
emailNotifications: boolean;
|
||||||
|
invoiceReminders: boolean;
|
||||||
|
paymentReminders: boolean;
|
||||||
|
|
||||||
|
// Seguridad
|
||||||
|
sessionTimeout: number; // Minutos
|
||||||
|
requireTwoFactor: boolean;
|
||||||
|
allowedIPs?: string[];
|
||||||
|
|
||||||
|
// Integraciones
|
||||||
|
satIntegration: boolean;
|
||||||
|
bankingIntegration: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
logo?: string;
|
||||||
|
status: TenantStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Plan & Features
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type PlanTier = 'free' | 'starter' | 'professional' | 'enterprise';
|
||||||
|
|
||||||
|
export interface Plan {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tier: PlanTier;
|
||||||
|
description: string;
|
||||||
|
features: PlanFeatures;
|
||||||
|
limits: PlanLimits;
|
||||||
|
pricing: PlanPricing;
|
||||||
|
isActive: boolean;
|
||||||
|
isPopular: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanFeatures {
|
||||||
|
// Módulos
|
||||||
|
invoicing: boolean;
|
||||||
|
expenses: boolean;
|
||||||
|
bankReconciliation: boolean;
|
||||||
|
reports: boolean;
|
||||||
|
budgets: boolean;
|
||||||
|
forecasting: boolean;
|
||||||
|
multiCurrency: boolean;
|
||||||
|
|
||||||
|
// Facturación electrónica
|
||||||
|
cfdiGeneration: boolean;
|
||||||
|
cfdiCancellation: boolean;
|
||||||
|
cfdiAddenda: boolean;
|
||||||
|
massInvoicing: boolean;
|
||||||
|
|
||||||
|
// Integraciones
|
||||||
|
satIntegration: boolean;
|
||||||
|
bankIntegration: boolean;
|
||||||
|
erpIntegration: boolean;
|
||||||
|
apiAccess: boolean;
|
||||||
|
webhooks: boolean;
|
||||||
|
|
||||||
|
// Colaboración
|
||||||
|
multiUser: boolean;
|
||||||
|
customRoles: boolean;
|
||||||
|
auditLog: boolean;
|
||||||
|
comments: boolean;
|
||||||
|
|
||||||
|
// Soporte
|
||||||
|
emailSupport: boolean;
|
||||||
|
chatSupport: boolean;
|
||||||
|
phoneSupport: boolean;
|
||||||
|
prioritySupport: boolean;
|
||||||
|
dedicatedManager: boolean;
|
||||||
|
|
||||||
|
// Extras
|
||||||
|
customBranding: boolean;
|
||||||
|
whiteLabel: boolean;
|
||||||
|
dataExport: boolean;
|
||||||
|
advancedReports: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanLimits {
|
||||||
|
maxUsers: number;
|
||||||
|
maxTransactionsPerMonth: number;
|
||||||
|
maxInvoicesPerMonth: number;
|
||||||
|
maxContacts: number;
|
||||||
|
maxBankAccounts: number;
|
||||||
|
storageMB: number;
|
||||||
|
apiRequestsPerDay: number;
|
||||||
|
retentionDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanPricing {
|
||||||
|
monthlyPrice: number;
|
||||||
|
annualPrice: number;
|
||||||
|
currency: string;
|
||||||
|
trialDays: number;
|
||||||
|
setupFee?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Subscription
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type SubscriptionStatus =
|
||||||
|
| 'trialing' // En período de prueba
|
||||||
|
| 'active' // Suscripción activa
|
||||||
|
| 'past_due' // Pago atrasado
|
||||||
|
| 'canceled' // Cancelada
|
||||||
|
| 'unpaid' // Sin pagar
|
||||||
|
| 'paused'; // Pausada
|
||||||
|
|
||||||
|
export type BillingCycle = 'monthly' | 'annual';
|
||||||
|
|
||||||
|
export interface Subscription {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
planId: string;
|
||||||
|
status: SubscriptionStatus;
|
||||||
|
billingCycle: BillingCycle;
|
||||||
|
|
||||||
|
// Precios
|
||||||
|
pricePerCycle: number;
|
||||||
|
currency: string;
|
||||||
|
discount?: SubscriptionDiscount;
|
||||||
|
|
||||||
|
// Fechas
|
||||||
|
currentPeriodStart: Date;
|
||||||
|
currentPeriodEnd: Date;
|
||||||
|
trialStart?: Date;
|
||||||
|
trialEnd?: Date;
|
||||||
|
canceledAt?: Date;
|
||||||
|
cancelAtPeriodEnd: boolean;
|
||||||
|
|
||||||
|
// Pago
|
||||||
|
paymentMethodId?: string;
|
||||||
|
lastPaymentAt?: Date;
|
||||||
|
nextPaymentAt?: Date;
|
||||||
|
|
||||||
|
// Stripe/Pasarela
|
||||||
|
externalId?: string;
|
||||||
|
externalCustomerId?: string;
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionDiscount {
|
||||||
|
code: string;
|
||||||
|
type: 'percentage' | 'fixed';
|
||||||
|
value: number;
|
||||||
|
validUntil?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Usage & Billing
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface TenantUsage {
|
||||||
|
tenantId: string;
|
||||||
|
period: string; // YYYY-MM
|
||||||
|
|
||||||
|
// Conteos
|
||||||
|
activeUsers: number;
|
||||||
|
totalTransactions: number;
|
||||||
|
totalInvoices: number;
|
||||||
|
totalContacts: number;
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
documentsStorageMB: number;
|
||||||
|
attachmentsStorageMB: number;
|
||||||
|
totalStorageMB: number;
|
||||||
|
|
||||||
|
// API
|
||||||
|
apiRequests: number;
|
||||||
|
webhookDeliveries: number;
|
||||||
|
|
||||||
|
// Límites
|
||||||
|
limits: PlanLimits;
|
||||||
|
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Invoice {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
number: string;
|
||||||
|
status: InvoiceStatus;
|
||||||
|
|
||||||
|
// Montos
|
||||||
|
subtotal: number;
|
||||||
|
discount: number;
|
||||||
|
tax: number;
|
||||||
|
total: number;
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
// Detalles
|
||||||
|
items: InvoiceItem[];
|
||||||
|
|
||||||
|
// Fechas
|
||||||
|
periodStart: Date;
|
||||||
|
periodEnd: Date;
|
||||||
|
dueDate: Date;
|
||||||
|
paidAt?: Date;
|
||||||
|
|
||||||
|
// Pago
|
||||||
|
paymentMethod?: string;
|
||||||
|
paymentIntentId?: string;
|
||||||
|
|
||||||
|
// PDF
|
||||||
|
pdfUrl?: string;
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceStatus =
|
||||||
|
| 'draft'
|
||||||
|
| 'open'
|
||||||
|
| 'paid'
|
||||||
|
| 'void'
|
||||||
|
| 'uncollectible';
|
||||||
|
|
||||||
|
export interface InvoiceItem {
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Payment Method
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface PaymentMethod {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
type: PaymentMethodType;
|
||||||
|
isDefault: boolean;
|
||||||
|
|
||||||
|
// Card details (masked)
|
||||||
|
card?: {
|
||||||
|
brand: string;
|
||||||
|
last4: string;
|
||||||
|
expMonth: number;
|
||||||
|
expYear: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bank account (masked)
|
||||||
|
bankAccount?: {
|
||||||
|
bankName: string;
|
||||||
|
last4: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
billingAddress?: TenantAddress;
|
||||||
|
|
||||||
|
externalId?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaymentMethodType = 'card' | 'bank_transfer' | 'oxxo' | 'spei';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tenant Events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface TenantEvent {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
type: TenantEventType;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TenantEventType =
|
||||||
|
| 'tenant.created'
|
||||||
|
| 'tenant.updated'
|
||||||
|
| 'tenant.suspended'
|
||||||
|
| 'tenant.activated'
|
||||||
|
| 'tenant.deleted'
|
||||||
|
| 'subscription.created'
|
||||||
|
| 'subscription.updated'
|
||||||
|
| 'subscription.canceled'
|
||||||
|
| 'subscription.renewed'
|
||||||
|
| 'payment.succeeded'
|
||||||
|
| 'payment.failed'
|
||||||
|
| 'usage.limit_warning'
|
||||||
|
| 'usage.limit_reached';
|
||||||
658
packages/shared/src/utils/format.ts
Normal file
658
packages/shared/src/utils/format.ts
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
/**
|
||||||
|
* Formatting Utilities for Horux Strategy
|
||||||
|
* Currency, percentage, date, and other formatting functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_LOCALE, DEFAULT_TIMEZONE, CURRENCIES, DEFAULT_CURRENCY } from '../constants';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Currency Formatting
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface CurrencyFormatOptions {
|
||||||
|
currency?: string;
|
||||||
|
locale?: string;
|
||||||
|
showSymbol?: boolean;
|
||||||
|
showCode?: boolean;
|
||||||
|
minimumFractionDigits?: number;
|
||||||
|
maximumFractionDigits?: number;
|
||||||
|
signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a number as currency (default: Mexican Pesos)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatCurrency(1234.56) // "$1,234.56"
|
||||||
|
* formatCurrency(1234.56, { currency: 'USD' }) // "US$1,234.56"
|
||||||
|
* formatCurrency(-1234.56) // "-$1,234.56"
|
||||||
|
* formatCurrency(1234.56, { showCode: true }) // "$1,234.56 MXN"
|
||||||
|
*/
|
||||||
|
export function formatCurrency(
|
||||||
|
amount: number,
|
||||||
|
options: CurrencyFormatOptions = {}
|
||||||
|
): string {
|
||||||
|
const {
|
||||||
|
currency = DEFAULT_CURRENCY,
|
||||||
|
locale = DEFAULT_LOCALE,
|
||||||
|
showSymbol = true,
|
||||||
|
showCode = false,
|
||||||
|
minimumFractionDigits,
|
||||||
|
maximumFractionDigits,
|
||||||
|
signDisplay = 'auto',
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const currencyInfo = CURRENCIES[currency] || CURRENCIES[DEFAULT_CURRENCY];
|
||||||
|
const decimals = minimumFractionDigits ?? maximumFractionDigits ?? currencyInfo.decimals;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formatter = new Intl.NumberFormat(locale, {
|
||||||
|
style: showSymbol ? 'currency' : 'decimal',
|
||||||
|
currency: showSymbol ? currency : undefined,
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: maximumFractionDigits ?? decimals,
|
||||||
|
signDisplay,
|
||||||
|
});
|
||||||
|
|
||||||
|
let formatted = formatter.format(amount);
|
||||||
|
|
||||||
|
// Add currency code if requested
|
||||||
|
if (showCode) {
|
||||||
|
formatted = `${formatted} ${currency}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
} catch {
|
||||||
|
// Fallback formatting
|
||||||
|
const symbol = showSymbol ? currencyInfo.symbol : '';
|
||||||
|
const sign = amount < 0 ? '-' : '';
|
||||||
|
const absAmount = Math.abs(amount).toFixed(decimals);
|
||||||
|
const [intPart, decPart] = absAmount.split('.');
|
||||||
|
const formattedInt = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
|
||||||
|
let result = `${sign}${symbol}${formattedInt}`;
|
||||||
|
if (decPart) {
|
||||||
|
result += `.${decPart}`;
|
||||||
|
}
|
||||||
|
if (showCode) {
|
||||||
|
result += ` ${currency}`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format currency for display in compact form
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatCurrencyCompact(1234) // "$1.2K"
|
||||||
|
* formatCurrencyCompact(1234567) // "$1.2M"
|
||||||
|
*/
|
||||||
|
export function formatCurrencyCompact(
|
||||||
|
amount: number,
|
||||||
|
options: Omit<CurrencyFormatOptions, 'minimumFractionDigits' | 'maximumFractionDigits'> = {}
|
||||||
|
): string {
|
||||||
|
const {
|
||||||
|
currency = DEFAULT_CURRENCY,
|
||||||
|
locale = DEFAULT_LOCALE,
|
||||||
|
showSymbol = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formatter = new Intl.NumberFormat(locale, {
|
||||||
|
style: showSymbol ? 'currency' : 'decimal',
|
||||||
|
currency: showSymbol ? currency : undefined,
|
||||||
|
notation: 'compact',
|
||||||
|
compactDisplay: 'short',
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return formatter.format(amount);
|
||||||
|
} catch {
|
||||||
|
// Fallback
|
||||||
|
const currencyInfo = CURRENCIES[currency] || CURRENCIES[DEFAULT_CURRENCY];
|
||||||
|
const symbol = showSymbol ? currencyInfo.symbol : '';
|
||||||
|
const absAmount = Math.abs(amount);
|
||||||
|
const sign = amount < 0 ? '-' : '';
|
||||||
|
|
||||||
|
if (absAmount >= 1000000000) {
|
||||||
|
return `${sign}${symbol}${(absAmount / 1000000000).toFixed(1)}B`;
|
||||||
|
}
|
||||||
|
if (absAmount >= 1000000) {
|
||||||
|
return `${sign}${symbol}${(absAmount / 1000000).toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
if (absAmount >= 1000) {
|
||||||
|
return `${sign}${symbol}${(absAmount / 1000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return `${sign}${symbol}${absAmount.toFixed(0)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a currency string to number
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* parseCurrency("$1,234.56") // 1234.56
|
||||||
|
* parseCurrency("-$1,234.56") // -1234.56
|
||||||
|
*/
|
||||||
|
export function parseCurrency(value: string): number {
|
||||||
|
// Remove currency symbols, spaces, and thousand separators
|
||||||
|
const cleaned = value
|
||||||
|
.replace(/[^0-9.,-]/g, '')
|
||||||
|
.replace(/,/g, '');
|
||||||
|
|
||||||
|
const number = parseFloat(cleaned);
|
||||||
|
return isNaN(number) ? 0 : number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Percentage Formatting
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface PercentFormatOptions {
|
||||||
|
locale?: string;
|
||||||
|
minimumFractionDigits?: number;
|
||||||
|
maximumFractionDigits?: number;
|
||||||
|
signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never';
|
||||||
|
multiply?: boolean; // If true, multiply by 100 (e.g., 0.16 -> 16%)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a number as percentage
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatPercent(16.5) // "16.5%"
|
||||||
|
* formatPercent(0.165, { multiply: true }) // "16.5%"
|
||||||
|
* formatPercent(-5.2, { signDisplay: 'always' }) // "-5.2%"
|
||||||
|
*/
|
||||||
|
export function formatPercent(
|
||||||
|
value: number,
|
||||||
|
options: PercentFormatOptions = {}
|
||||||
|
): string {
|
||||||
|
const {
|
||||||
|
locale = DEFAULT_LOCALE,
|
||||||
|
minimumFractionDigits = 0,
|
||||||
|
maximumFractionDigits = 2,
|
||||||
|
signDisplay = 'auto',
|
||||||
|
multiply = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const displayValue = multiply ? value : value / 100;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formatter = new Intl.NumberFormat(locale, {
|
||||||
|
style: 'percent',
|
||||||
|
minimumFractionDigits,
|
||||||
|
maximumFractionDigits,
|
||||||
|
signDisplay,
|
||||||
|
});
|
||||||
|
|
||||||
|
return formatter.format(displayValue);
|
||||||
|
} catch {
|
||||||
|
// Fallback
|
||||||
|
const sign = signDisplay === 'always' && value > 0 ? '+' : '';
|
||||||
|
const actualValue = multiply ? value * 100 : value;
|
||||||
|
return `${sign}${actualValue.toFixed(maximumFractionDigits)}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a percentage change with color indicator
|
||||||
|
* Returns an object with formatted value and direction
|
||||||
|
*/
|
||||||
|
export function formatPercentChange(
|
||||||
|
value: number,
|
||||||
|
options: PercentFormatOptions = {}
|
||||||
|
): {
|
||||||
|
formatted: string;
|
||||||
|
direction: 'up' | 'down' | 'unchanged';
|
||||||
|
isPositive: boolean;
|
||||||
|
} {
|
||||||
|
const formatted = formatPercent(value, { ...options, signDisplay: 'exceptZero' });
|
||||||
|
const direction = value > 0 ? 'up' : value < 0 ? 'down' : 'unchanged';
|
||||||
|
|
||||||
|
return {
|
||||||
|
formatted,
|
||||||
|
direction,
|
||||||
|
isPositive: value > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Date Formatting
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface DateFormatOptions {
|
||||||
|
locale?: string;
|
||||||
|
timezone?: string;
|
||||||
|
format?: 'short' | 'medium' | 'long' | 'full' | 'relative' | 'iso';
|
||||||
|
includeTime?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatDate(new Date()) // "31/01/2024"
|
||||||
|
* formatDate(new Date(), { format: 'long' }) // "31 de enero de 2024"
|
||||||
|
* formatDate(new Date(), { includeTime: true }) // "31/01/2024 14:30"
|
||||||
|
*/
|
||||||
|
export function formatDate(
|
||||||
|
date: Date | string | number,
|
||||||
|
options: DateFormatOptions = {}
|
||||||
|
): string {
|
||||||
|
const {
|
||||||
|
locale = DEFAULT_LOCALE,
|
||||||
|
timezone = DEFAULT_TIMEZONE,
|
||||||
|
format = 'short',
|
||||||
|
includeTime = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const dateObj = date instanceof Date ? date : new Date(date);
|
||||||
|
|
||||||
|
if (isNaN(dateObj.getTime())) {
|
||||||
|
return 'Fecha inválida';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO format
|
||||||
|
if (format === 'iso') {
|
||||||
|
return dateObj.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative format
|
||||||
|
if (format === 'relative') {
|
||||||
|
return formatRelativeTime(dateObj, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dateStyle = format === 'short' ? 'short'
|
||||||
|
: format === 'medium' ? 'medium'
|
||||||
|
: format === 'long' ? 'long'
|
||||||
|
: 'full';
|
||||||
|
|
||||||
|
const formatter = new Intl.DateTimeFormat(locale, {
|
||||||
|
dateStyle,
|
||||||
|
timeStyle: includeTime ? 'short' : undefined,
|
||||||
|
timeZone: timezone,
|
||||||
|
});
|
||||||
|
|
||||||
|
return formatter.format(dateObj);
|
||||||
|
} catch {
|
||||||
|
// Fallback
|
||||||
|
const day = dateObj.getDate().toString().padStart(2, '0');
|
||||||
|
const month = (dateObj.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const year = dateObj.getFullYear();
|
||||||
|
let result = `${day}/${month}/${year}`;
|
||||||
|
|
||||||
|
if (includeTime) {
|
||||||
|
const hours = dateObj.getHours().toString().padStart(2, '0');
|
||||||
|
const minutes = dateObj.getMinutes().toString().padStart(2, '0');
|
||||||
|
result += ` ${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date as relative time (e.g., "hace 2 días")
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(
|
||||||
|
date: Date | string | number,
|
||||||
|
locale: string = DEFAULT_LOCALE
|
||||||
|
): string {
|
||||||
|
const dateObj = date instanceof Date ? date : new Date(date);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - dateObj.getTime();
|
||||||
|
const diffSeconds = Math.floor(diffMs / 1000);
|
||||||
|
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||||
|
const diffHours = Math.floor(diffMinutes / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
const diffWeeks = Math.floor(diffDays / 7);
|
||||||
|
const diffMonths = Math.floor(diffDays / 30);
|
||||||
|
const diffYears = Math.floor(diffDays / 365);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
||||||
|
|
||||||
|
if (Math.abs(diffSeconds) < 60) {
|
||||||
|
return rtf.format(-diffSeconds, 'second');
|
||||||
|
}
|
||||||
|
if (Math.abs(diffMinutes) < 60) {
|
||||||
|
return rtf.format(-diffMinutes, 'minute');
|
||||||
|
}
|
||||||
|
if (Math.abs(diffHours) < 24) {
|
||||||
|
return rtf.format(-diffHours, 'hour');
|
||||||
|
}
|
||||||
|
if (Math.abs(diffDays) < 7) {
|
||||||
|
return rtf.format(-diffDays, 'day');
|
||||||
|
}
|
||||||
|
if (Math.abs(diffWeeks) < 4) {
|
||||||
|
return rtf.format(-diffWeeks, 'week');
|
||||||
|
}
|
||||||
|
if (Math.abs(diffMonths) < 12) {
|
||||||
|
return rtf.format(-diffMonths, 'month');
|
||||||
|
}
|
||||||
|
return rtf.format(-diffYears, 'year');
|
||||||
|
} catch {
|
||||||
|
// Fallback for environments without Intl.RelativeTimeFormat
|
||||||
|
if (diffSeconds < 60) return 'hace un momento';
|
||||||
|
if (diffMinutes < 60) return `hace ${diffMinutes} minuto${diffMinutes !== 1 ? 's' : ''}`;
|
||||||
|
if (diffHours < 24) return `hace ${diffHours} hora${diffHours !== 1 ? 's' : ''}`;
|
||||||
|
if (diffDays < 7) return `hace ${diffDays} día${diffDays !== 1 ? 's' : ''}`;
|
||||||
|
if (diffWeeks < 4) return `hace ${diffWeeks} semana${diffWeeks !== 1 ? 's' : ''}`;
|
||||||
|
if (diffMonths < 12) return `hace ${diffMonths} mes${diffMonths !== 1 ? 'es' : ''}`;
|
||||||
|
return `hace ${diffYears} año${diffYears !== 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date range
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatDateRange(start, end) // "1 - 31 de enero de 2024"
|
||||||
|
*/
|
||||||
|
export function formatDateRange(
|
||||||
|
start: Date | string | number,
|
||||||
|
end: Date | string | number,
|
||||||
|
options: Omit<DateFormatOptions, 'includeTime'> = {}
|
||||||
|
): string {
|
||||||
|
const { locale = DEFAULT_LOCALE, timezone = DEFAULT_TIMEZONE } = options;
|
||||||
|
|
||||||
|
const startDate = start instanceof Date ? start : new Date(start);
|
||||||
|
const endDate = end instanceof Date ? end : new Date(end);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formatter = new Intl.DateTimeFormat(locale, {
|
||||||
|
dateStyle: 'long',
|
||||||
|
timeZone: timezone,
|
||||||
|
});
|
||||||
|
|
||||||
|
return formatter.formatRange(startDate, endDate);
|
||||||
|
} catch {
|
||||||
|
// Fallback
|
||||||
|
return `${formatDate(startDate, options)} - ${formatDate(endDate, options)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time only
|
||||||
|
*/
|
||||||
|
export function formatTime(
|
||||||
|
date: Date | string | number,
|
||||||
|
options: { locale?: string; timezone?: string; style?: 'short' | 'medium' } = {}
|
||||||
|
): string {
|
||||||
|
const { locale = DEFAULT_LOCALE, timezone = DEFAULT_TIMEZONE, style = 'short' } = options;
|
||||||
|
const dateObj = date instanceof Date ? date : new Date(date);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formatter = new Intl.DateTimeFormat(locale, {
|
||||||
|
timeStyle: style,
|
||||||
|
timeZone: timezone,
|
||||||
|
});
|
||||||
|
return formatter.format(dateObj);
|
||||||
|
} catch {
|
||||||
|
const hours = dateObj.getHours().toString().padStart(2, '0');
|
||||||
|
const minutes = dateObj.getMinutes().toString().padStart(2, '0');
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Number Formatting
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface NumberFormatOptions {
|
||||||
|
locale?: string;
|
||||||
|
minimumFractionDigits?: number;
|
||||||
|
maximumFractionDigits?: number;
|
||||||
|
notation?: 'standard' | 'scientific' | 'engineering' | 'compact';
|
||||||
|
signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a number with thousand separators
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatNumber(1234567.89) // "1,234,567.89"
|
||||||
|
* formatNumber(1234567, { notation: 'compact' }) // "1.2M"
|
||||||
|
*/
|
||||||
|
export function formatNumber(
|
||||||
|
value: number,
|
||||||
|
options: NumberFormatOptions = {}
|
||||||
|
): string {
|
||||||
|
const {
|
||||||
|
locale = DEFAULT_LOCALE,
|
||||||
|
minimumFractionDigits,
|
||||||
|
maximumFractionDigits,
|
||||||
|
notation = 'standard',
|
||||||
|
signDisplay = 'auto',
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formatter = new Intl.NumberFormat(locale, {
|
||||||
|
minimumFractionDigits,
|
||||||
|
maximumFractionDigits,
|
||||||
|
notation,
|
||||||
|
signDisplay,
|
||||||
|
});
|
||||||
|
return formatter.format(value);
|
||||||
|
} catch {
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a number in compact notation
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatCompactNumber(1234) // "1.2K"
|
||||||
|
* formatCompactNumber(1234567) // "1.2M"
|
||||||
|
*/
|
||||||
|
export function formatCompactNumber(
|
||||||
|
value: number,
|
||||||
|
options: Omit<NumberFormatOptions, 'notation'> = {}
|
||||||
|
): string {
|
||||||
|
return formatNumber(value, { ...options, notation: 'compact' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes to human readable size
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatBytes(1024) // "1 KB"
|
||||||
|
* formatBytes(1536) // "1.5 KB"
|
||||||
|
* formatBytes(1048576) // "1 MB"
|
||||||
|
*/
|
||||||
|
export function formatBytes(
|
||||||
|
bytes: number,
|
||||||
|
options: { locale?: string; decimals?: number } = {}
|
||||||
|
): string {
|
||||||
|
const { locale = DEFAULT_LOCALE, decimals = 1 } = options;
|
||||||
|
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
const value = bytes / Math.pow(k, i);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formatter = new Intl.NumberFormat(locale, {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
});
|
||||||
|
return `${formatter.format(value)} ${sizes[i]}`;
|
||||||
|
} catch {
|
||||||
|
return `${value.toFixed(decimals)} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Text Formatting
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate text with ellipsis
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* truncate("Hello World", 8) // "Hello..."
|
||||||
|
*/
|
||||||
|
export function truncate(text: string, maxLength: number, suffix = '...'): string {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
return text.slice(0, maxLength - suffix.length).trim() + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capitalize first letter
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* capitalize("hello world") // "Hello world"
|
||||||
|
*/
|
||||||
|
export function capitalize(text: string): string {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title case
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* titleCase("hello world") // "Hello World"
|
||||||
|
*/
|
||||||
|
export function titleCase(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.split(' ')
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format RFC for display (with spaces)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatRFC("XAXX010101000") // "XAXX 010101 000"
|
||||||
|
*/
|
||||||
|
export function formatRFC(rfc: string): string {
|
||||||
|
const cleaned = rfc.replace(/\s/g, '').toUpperCase();
|
||||||
|
if (cleaned.length === 12) {
|
||||||
|
// Persona física
|
||||||
|
return `${cleaned.slice(0, 4)} ${cleaned.slice(4, 10)} ${cleaned.slice(10)}`;
|
||||||
|
}
|
||||||
|
if (cleaned.length === 13) {
|
||||||
|
// Persona moral
|
||||||
|
return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 9)} ${cleaned.slice(9)}`;
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mask sensitive data
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* maskString("1234567890", 4) // "******7890"
|
||||||
|
* maskString("email@example.com", 3, { maskChar: '*', type: 'email' }) // "ema***@example.com"
|
||||||
|
*/
|
||||||
|
export function maskString(
|
||||||
|
value: string,
|
||||||
|
visibleChars: number = 4,
|
||||||
|
options: { maskChar?: string; position?: 'start' | 'end' } = {}
|
||||||
|
): string {
|
||||||
|
const { maskChar = '*', position = 'end' } = options;
|
||||||
|
|
||||||
|
if (value.length <= visibleChars) return value;
|
||||||
|
|
||||||
|
const maskLength = value.length - visibleChars;
|
||||||
|
const mask = maskChar.repeat(maskLength);
|
||||||
|
|
||||||
|
if (position === 'start') {
|
||||||
|
return value.slice(0, visibleChars) + mask;
|
||||||
|
}
|
||||||
|
return mask + value.slice(-visibleChars);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format CLABE for display
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatCLABE("123456789012345678") // "123 456 789012345678"
|
||||||
|
*/
|
||||||
|
export function formatCLABE(clabe: string): string {
|
||||||
|
const cleaned = clabe.replace(/\s/g, '');
|
||||||
|
if (cleaned.length !== 18) return clabe;
|
||||||
|
return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 6)} ${cleaned.slice(6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format phone number (Mexican format)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatPhone("5512345678") // "(55) 1234-5678"
|
||||||
|
*/
|
||||||
|
export function formatPhone(phone: string): string {
|
||||||
|
const cleaned = phone.replace(/\D/g, '');
|
||||||
|
|
||||||
|
if (cleaned.length === 10) {
|
||||||
|
return `(${cleaned.slice(0, 2)}) ${cleaned.slice(2, 6)}-${cleaned.slice(6)}`;
|
||||||
|
}
|
||||||
|
if (cleaned.length === 12 && cleaned.startsWith('52')) {
|
||||||
|
const national = cleaned.slice(2);
|
||||||
|
return `+52 (${national.slice(0, 2)}) ${national.slice(2, 6)}-${national.slice(6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Pluralization
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple Spanish pluralization
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* pluralize(1, 'factura', 'facturas') // "1 factura"
|
||||||
|
* pluralize(5, 'factura', 'facturas') // "5 facturas"
|
||||||
|
*/
|
||||||
|
export function pluralize(
|
||||||
|
count: number,
|
||||||
|
singular: string,
|
||||||
|
plural: string
|
||||||
|
): string {
|
||||||
|
const word = count === 1 ? singular : plural;
|
||||||
|
return `${formatNumber(count)} ${word}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a list of items with proper grammar
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatList(['a', 'b', 'c']) // "a, b y c"
|
||||||
|
* formatList(['a', 'b']) // "a y b"
|
||||||
|
* formatList(['a']) // "a"
|
||||||
|
*/
|
||||||
|
export function formatList(
|
||||||
|
items: string[],
|
||||||
|
options: { locale?: string; type?: 'conjunction' | 'disjunction' } = {}
|
||||||
|
): string {
|
||||||
|
const { locale = DEFAULT_LOCALE, type = 'conjunction' } = options;
|
||||||
|
|
||||||
|
if (items.length === 0) return '';
|
||||||
|
if (items.length === 1) return items[0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formatter = new Intl.ListFormat(locale, {
|
||||||
|
style: 'long',
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
return formatter.format(items);
|
||||||
|
} catch {
|
||||||
|
// Fallback
|
||||||
|
const connector = type === 'conjunction' ? 'y' : 'o';
|
||||||
|
if (items.length === 2) {
|
||||||
|
return `${items[0]} ${connector} ${items[1]}`;
|
||||||
|
}
|
||||||
|
return `${items.slice(0, -1).join(', ')} ${connector} ${items[items.length - 1]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
packages/ui/package.json
Normal file
32
packages/ui/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@horux/ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "UI components for Horux Strategy",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsc --watch",
|
||||||
|
"lint": "eslint src --ext .ts,.tsx",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"clean": "rm -rf dist node_modules"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"recharts": "^2.12.0",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"tailwind-merge": "^2.2.0",
|
||||||
|
"lucide-react": "^0.312.0",
|
||||||
|
"date-fns": "^3.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"@types/react": "^18.2.48",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
280
packages/ui/src/components/AlertBadge.tsx
Normal file
280
packages/ui/src/components/AlertBadge.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Info,
|
||||||
|
XCircle,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AlertSeverity = 'info' | 'success' | 'warning' | 'critical' | 'error';
|
||||||
|
|
||||||
|
export interface AlertBadgeProps {
|
||||||
|
/** The severity level of the alert */
|
||||||
|
severity: AlertSeverity;
|
||||||
|
/** Optional label text */
|
||||||
|
label?: string;
|
||||||
|
/** Size variant */
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
/** Show icon */
|
||||||
|
showIcon?: boolean;
|
||||||
|
/** Make the badge pulsate for critical alerts */
|
||||||
|
pulse?: boolean;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
className?: string;
|
||||||
|
/** Click handler */
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Severity Configuration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface SeverityConfig {
|
||||||
|
icon: LucideIcon;
|
||||||
|
bgColor: string;
|
||||||
|
textColor: string;
|
||||||
|
borderColor: string;
|
||||||
|
pulseColor: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const severityConfigs: Record<AlertSeverity, SeverityConfig> = {
|
||||||
|
info: {
|
||||||
|
icon: Info,
|
||||||
|
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
|
||||||
|
textColor: 'text-blue-700 dark:text-blue-300',
|
||||||
|
borderColor: 'border-blue-200 dark:border-blue-800',
|
||||||
|
pulseColor: 'bg-blue-400',
|
||||||
|
label: 'Info',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
icon: CheckCircle,
|
||||||
|
bgColor: 'bg-green-50 dark:bg-green-900/20',
|
||||||
|
textColor: 'text-green-700 dark:text-green-300',
|
||||||
|
borderColor: 'border-green-200 dark:border-green-800',
|
||||||
|
pulseColor: 'bg-green-400',
|
||||||
|
label: 'Bueno',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: AlertTriangle,
|
||||||
|
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||||
|
textColor: 'text-yellow-700 dark:text-yellow-300',
|
||||||
|
borderColor: 'border-yellow-200 dark:border-yellow-800',
|
||||||
|
pulseColor: 'bg-yellow-400',
|
||||||
|
label: 'Alerta',
|
||||||
|
},
|
||||||
|
critical: {
|
||||||
|
icon: XCircle,
|
||||||
|
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
||||||
|
textColor: 'text-red-700 dark:text-red-300',
|
||||||
|
borderColor: 'border-red-200 dark:border-red-800',
|
||||||
|
pulseColor: 'bg-red-400',
|
||||||
|
label: 'Critico',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: AlertCircle,
|
||||||
|
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
||||||
|
textColor: 'text-red-700 dark:text-red-300',
|
||||||
|
borderColor: 'border-red-200 dark:border-red-800',
|
||||||
|
pulseColor: 'bg-red-400',
|
||||||
|
label: 'Error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Size Configuration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const sizeConfigs = {
|
||||||
|
sm: {
|
||||||
|
padding: 'px-2 py-0.5',
|
||||||
|
text: 'text-xs',
|
||||||
|
iconSize: 12,
|
||||||
|
gap: 'gap-1',
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
padding: 'px-2.5 py-1',
|
||||||
|
text: 'text-sm',
|
||||||
|
iconSize: 14,
|
||||||
|
gap: 'gap-1.5',
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
padding: 'px-3 py-1.5',
|
||||||
|
text: 'text-base',
|
||||||
|
iconSize: 16,
|
||||||
|
gap: 'gap-2',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function AlertBadge({
|
||||||
|
severity,
|
||||||
|
label,
|
||||||
|
size = 'md',
|
||||||
|
showIcon = true,
|
||||||
|
pulse = false,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
}: AlertBadgeProps): React.ReactElement {
|
||||||
|
const config = severityConfigs[severity];
|
||||||
|
const sizeConfig = sizeConfigs[size];
|
||||||
|
const Icon = config.icon;
|
||||||
|
const displayLabel = label ?? config.label;
|
||||||
|
|
||||||
|
const isClickable = Boolean(onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full border font-medium',
|
||||||
|
config.bgColor,
|
||||||
|
config.textColor,
|
||||||
|
config.borderColor,
|
||||||
|
sizeConfig.padding,
|
||||||
|
sizeConfig.text,
|
||||||
|
sizeConfig.gap,
|
||||||
|
isClickable && 'cursor-pointer hover:opacity-80 transition-opacity',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
role={isClickable ? 'button' : undefined}
|
||||||
|
tabIndex={isClickable ? 0 : undefined}
|
||||||
|
>
|
||||||
|
{pulse && (severity === 'critical' || severity === 'error') && (
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
|
||||||
|
config.pulseColor
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-2 w-2 rounded-full',
|
||||||
|
config.pulseColor
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showIcon && !pulse && (
|
||||||
|
<Icon size={sizeConfig.iconSize} className="flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
{displayLabel && <span>{displayLabel}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Status Badge Variant (simpler dot + text)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface StatusBadgeProps {
|
||||||
|
status: 'active' | 'inactive' | 'pending' | 'error';
|
||||||
|
label?: string;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfigs = {
|
||||||
|
active: {
|
||||||
|
dotColor: 'bg-green-500',
|
||||||
|
textColor: 'text-green-700 dark:text-green-400',
|
||||||
|
label: 'Activo',
|
||||||
|
},
|
||||||
|
inactive: {
|
||||||
|
dotColor: 'bg-gray-400',
|
||||||
|
textColor: 'text-gray-600 dark:text-gray-400',
|
||||||
|
label: 'Inactivo',
|
||||||
|
},
|
||||||
|
pending: {
|
||||||
|
dotColor: 'bg-yellow-500',
|
||||||
|
textColor: 'text-yellow-700 dark:text-yellow-400',
|
||||||
|
label: 'Pendiente',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
dotColor: 'bg-red-500',
|
||||||
|
textColor: 'text-red-700 dark:text-red-400',
|
||||||
|
label: 'Error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({
|
||||||
|
status,
|
||||||
|
label,
|
||||||
|
size = 'md',
|
||||||
|
className,
|
||||||
|
}: StatusBadgeProps): React.ReactElement {
|
||||||
|
const config = statusConfigs[status];
|
||||||
|
const displayLabel = label ?? config.label;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2',
|
||||||
|
config.textColor,
|
||||||
|
size === 'sm' ? 'text-xs' : 'text-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'rounded-full',
|
||||||
|
config.dotColor,
|
||||||
|
size === 'sm' ? 'h-1.5 w-1.5' : 'h-2 w-2'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{displayLabel}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Notification Badge (for counts)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface NotificationBadgeProps {
|
||||||
|
count: number;
|
||||||
|
maxCount?: number;
|
||||||
|
severity?: 'default' | 'warning' | 'critical';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationBadge({
|
||||||
|
count,
|
||||||
|
maxCount = 99,
|
||||||
|
severity = 'default',
|
||||||
|
className,
|
||||||
|
}: NotificationBadgeProps): React.ReactElement | null {
|
||||||
|
if (count <= 0) return null;
|
||||||
|
|
||||||
|
const displayCount = count > maxCount ? `${maxCount}+` : count.toString();
|
||||||
|
|
||||||
|
const severityStyles = {
|
||||||
|
default: 'bg-blue-500 text-white',
|
||||||
|
warning: 'bg-yellow-500 text-white',
|
||||||
|
critical: 'bg-red-500 text-white',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center rounded-full text-xs font-bold',
|
||||||
|
'min-w-[20px] h-5 px-1.5',
|
||||||
|
severityStyles[severity],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{displayCount}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
699
packages/ui/src/components/DataTable.tsx
Normal file
699
packages/ui/src/components/DataTable.tsx
Normal file
@@ -0,0 +1,699 @@
|
|||||||
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronsUpDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronsRight,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
Filter,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
import { SkeletonTable } from './Skeleton';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type SortDirection = 'asc' | 'desc' | null;
|
||||||
|
|
||||||
|
export type ColumnAlign = 'left' | 'center' | 'right';
|
||||||
|
|
||||||
|
export interface ColumnDef<T> {
|
||||||
|
/** Unique column identifier */
|
||||||
|
id: string;
|
||||||
|
/** Column header label */
|
||||||
|
header: string;
|
||||||
|
/** Data accessor key or function */
|
||||||
|
accessorKey?: keyof T;
|
||||||
|
accessorFn?: (row: T) => unknown;
|
||||||
|
/** Custom cell renderer */
|
||||||
|
cell?: (value: unknown, row: T, rowIndex: number) => React.ReactNode;
|
||||||
|
/** Column alignment */
|
||||||
|
align?: ColumnAlign;
|
||||||
|
/** Whether column is sortable */
|
||||||
|
sortable?: boolean;
|
||||||
|
/** Whether column is filterable */
|
||||||
|
filterable?: boolean;
|
||||||
|
/** Column width */
|
||||||
|
width?: string | number;
|
||||||
|
/** Minimum column width */
|
||||||
|
minWidth?: string | number;
|
||||||
|
/** Whether to hide on mobile */
|
||||||
|
hideOnMobile?: boolean;
|
||||||
|
/** Custom sort function */
|
||||||
|
sortFn?: (a: T, b: T, direction: SortDirection) => number;
|
||||||
|
/** Custom filter function */
|
||||||
|
filterFn?: (row: T, filterValue: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationConfig {
|
||||||
|
/** Current page (1-indexed) */
|
||||||
|
page: number;
|
||||||
|
/** Items per page */
|
||||||
|
pageSize: number;
|
||||||
|
/** Total number of items (for server-side pagination) */
|
||||||
|
totalItems?: number;
|
||||||
|
/** Available page sizes */
|
||||||
|
pageSizeOptions?: number[];
|
||||||
|
/** Callback when page changes */
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
/** Callback when page size changes */
|
||||||
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataTableProps<T extends Record<string, unknown>> {
|
||||||
|
/** Column definitions */
|
||||||
|
columns: ColumnDef<T>[];
|
||||||
|
/** Table data */
|
||||||
|
data: T[];
|
||||||
|
/** Row key extractor */
|
||||||
|
getRowId?: (row: T, index: number) => string;
|
||||||
|
/** Pagination configuration */
|
||||||
|
pagination?: PaginationConfig;
|
||||||
|
/** Enable global search */
|
||||||
|
enableSearch?: boolean;
|
||||||
|
/** Search placeholder */
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
/** Enable column filters */
|
||||||
|
enableFilters?: boolean;
|
||||||
|
/** Default sort column */
|
||||||
|
defaultSortColumn?: string;
|
||||||
|
/** Default sort direction */
|
||||||
|
defaultSortDirection?: SortDirection;
|
||||||
|
/** Loading state */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Empty state message */
|
||||||
|
emptyMessage?: string;
|
||||||
|
/** Table title */
|
||||||
|
title?: string;
|
||||||
|
/** Table subtitle */
|
||||||
|
subtitle?: string;
|
||||||
|
/** Row click handler */
|
||||||
|
onRowClick?: (row: T, index: number) => void;
|
||||||
|
/** Selected rows (controlled) */
|
||||||
|
selectedRows?: Set<string>;
|
||||||
|
/** Row selection handler */
|
||||||
|
onRowSelect?: (rowId: string, selected: boolean) => void;
|
||||||
|
/** Enable row selection */
|
||||||
|
enableRowSelection?: boolean;
|
||||||
|
/** Striped rows */
|
||||||
|
striped?: boolean;
|
||||||
|
/** Hover effect on rows */
|
||||||
|
hoverable?: boolean;
|
||||||
|
/** Compact mode */
|
||||||
|
compact?: boolean;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utility Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function getCellValue<T>(row: T, column: ColumnDef<T>): unknown {
|
||||||
|
if (column.accessorFn) {
|
||||||
|
return column.accessorFn(row);
|
||||||
|
}
|
||||||
|
if (column.accessorKey) {
|
||||||
|
return row[column.accessorKey];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultSort<T>(
|
||||||
|
a: T,
|
||||||
|
b: T,
|
||||||
|
column: ColumnDef<T>,
|
||||||
|
direction: SortDirection
|
||||||
|
): number {
|
||||||
|
if (!direction) return 0;
|
||||||
|
|
||||||
|
const aVal = getCellValue(a, column);
|
||||||
|
const bVal = getCellValue(b, column);
|
||||||
|
|
||||||
|
let comparison = 0;
|
||||||
|
|
||||||
|
if (aVal === null || aVal === undefined) comparison = 1;
|
||||||
|
else if (bVal === null || bVal === undefined) comparison = -1;
|
||||||
|
else if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||||
|
comparison = aVal - bVal;
|
||||||
|
} else if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||||
|
comparison = aVal.localeCompare(bVal, 'es-MX');
|
||||||
|
} else if (aVal instanceof Date && bVal instanceof Date) {
|
||||||
|
comparison = aVal.getTime() - bVal.getTime();
|
||||||
|
} else {
|
||||||
|
comparison = String(aVal).localeCompare(String(bVal), 'es-MX');
|
||||||
|
}
|
||||||
|
|
||||||
|
return direction === 'asc' ? comparison : -comparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultFilter<T>(row: T, column: ColumnDef<T>, filterValue: string): boolean {
|
||||||
|
const value = getCellValue(row, column);
|
||||||
|
if (value === null || value === undefined) return false;
|
||||||
|
return String(value).toLowerCase().includes(filterValue.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sub-Components
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface SortIconProps {
|
||||||
|
direction: SortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortIcon({ direction }: SortIconProps): React.ReactElement {
|
||||||
|
if (direction === 'asc') {
|
||||||
|
return <ChevronUp size={14} className="text-blue-500" />;
|
||||||
|
}
|
||||||
|
if (direction === 'desc') {
|
||||||
|
return <ChevronDown size={14} className="text-blue-500" />;
|
||||||
|
}
|
||||||
|
return <ChevronsUpDown size={14} className="text-gray-400" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginationProps {
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
pageSizeOptions: number[];
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (pageSize: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pagination({
|
||||||
|
currentPage,
|
||||||
|
pageSize,
|
||||||
|
totalItems,
|
||||||
|
pageSizeOptions,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: PaginationProps): React.ReactElement {
|
||||||
|
const totalPages = Math.ceil(totalItems / pageSize);
|
||||||
|
const startItem = (currentPage - 1) * pageSize + 1;
|
||||||
|
const endItem = Math.min(currentPage * pageSize, totalItems);
|
||||||
|
|
||||||
|
const canGoPrev = currentPage > 1;
|
||||||
|
const canGoNext = currentPage < totalPages;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-4 py-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
{/* Page size selector */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<span>Mostrar</span>
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
|
className="rounded border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
{pageSizeOptions.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span>por pagina</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info and controls */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{startItem}-{endItem} de {totalItems}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(1)}
|
||||||
|
disabled={!canGoPrev}
|
||||||
|
className={cn(
|
||||||
|
'p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||||
|
!canGoPrev && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
aria-label="Primera pagina"
|
||||||
|
>
|
||||||
|
<ChevronsLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={!canGoPrev}
|
||||||
|
className={cn(
|
||||||
|
'p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||||
|
!canGoPrev && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
aria-label="Pagina anterior"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="px-3 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
className={cn(
|
||||||
|
'p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||||
|
!canGoNext && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
aria-label="Pagina siguiente"
|
||||||
|
>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(totalPages)}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
className={cn(
|
||||||
|
'p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||||
|
!canGoNext && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
aria-label="Ultima pagina"
|
||||||
|
>
|
||||||
|
<ChevronsRight size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function DataTable<T extends Record<string, unknown>>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
getRowId,
|
||||||
|
pagination,
|
||||||
|
enableSearch = false,
|
||||||
|
searchPlaceholder = 'Buscar...',
|
||||||
|
enableFilters = false,
|
||||||
|
defaultSortColumn,
|
||||||
|
defaultSortDirection = null,
|
||||||
|
isLoading = false,
|
||||||
|
emptyMessage = 'No hay datos disponibles',
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
onRowClick,
|
||||||
|
selectedRows,
|
||||||
|
onRowSelect,
|
||||||
|
enableRowSelection = false,
|
||||||
|
striped = false,
|
||||||
|
hoverable = true,
|
||||||
|
compact = false,
|
||||||
|
className,
|
||||||
|
}: DataTableProps<T>): React.ReactElement {
|
||||||
|
// State
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [sortColumn, setSortColumn] = useState<string | null>(defaultSortColumn ?? null);
|
||||||
|
const [sortDirection, setSortDirection] = useState<SortDirection>(defaultSortDirection);
|
||||||
|
const [columnFilters, setColumnFilters] = useState<Record<string, string>>({});
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
|
// Internal pagination state (for client-side pagination)
|
||||||
|
const [internalPage, setInternalPage] = useState(pagination?.page ?? 1);
|
||||||
|
const [internalPageSize, setInternalPageSize] = useState(pagination?.pageSize ?? 10);
|
||||||
|
|
||||||
|
// Effective pagination values
|
||||||
|
const currentPage = pagination?.page ?? internalPage;
|
||||||
|
const pageSize = pagination?.pageSize ?? internalPageSize;
|
||||||
|
const pageSizeOptions = pagination?.pageSizeOptions ?? [10, 25, 50, 100];
|
||||||
|
|
||||||
|
// Row ID helper
|
||||||
|
const getRowIdFn = useCallback(
|
||||||
|
(row: T, index: number): string => {
|
||||||
|
if (getRowId) return getRowId(row, index);
|
||||||
|
if ('id' in row) return String(row.id);
|
||||||
|
return String(index);
|
||||||
|
},
|
||||||
|
[getRowId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter and sort data
|
||||||
|
const processedData = useMemo(() => {
|
||||||
|
let result = [...data];
|
||||||
|
|
||||||
|
// Apply global search
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
result = result.filter((row) =>
|
||||||
|
columns.some((col) => {
|
||||||
|
const value = getCellValue(row, col);
|
||||||
|
return value !== null && String(value).toLowerCase().includes(query);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply column filters
|
||||||
|
if (Object.keys(columnFilters).length > 0) {
|
||||||
|
result = result.filter((row) =>
|
||||||
|
Object.entries(columnFilters).every(([colId, filterValue]) => {
|
||||||
|
if (!filterValue) return true;
|
||||||
|
const column = columns.find((c) => c.id === colId);
|
||||||
|
if (!column) return true;
|
||||||
|
if (column.filterFn) return column.filterFn(row, filterValue);
|
||||||
|
return defaultFilter(row, column, filterValue);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
if (sortColumn && sortDirection) {
|
||||||
|
const column = columns.find((c) => c.id === sortColumn);
|
||||||
|
if (column) {
|
||||||
|
result.sort((a, b) => {
|
||||||
|
if (column.sortFn) return column.sortFn(a, b, sortDirection);
|
||||||
|
return defaultSort(a, b, column, sortDirection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [data, columns, searchQuery, columnFilters, sortColumn, sortDirection]);
|
||||||
|
|
||||||
|
// Calculate total items
|
||||||
|
const totalItems = pagination?.totalItems ?? processedData.length;
|
||||||
|
|
||||||
|
// Apply pagination (client-side only if not server-side)
|
||||||
|
const paginatedData = useMemo(() => {
|
||||||
|
if (pagination?.totalItems !== undefined) {
|
||||||
|
// Server-side pagination - data is already paginated
|
||||||
|
return processedData;
|
||||||
|
}
|
||||||
|
// Client-side pagination
|
||||||
|
const start = (currentPage - 1) * pageSize;
|
||||||
|
return processedData.slice(start, start + pageSize);
|
||||||
|
}, [processedData, pagination?.totalItems, currentPage, pageSize]);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleSort = useCallback((columnId: string) => {
|
||||||
|
setSortColumn((prev) => {
|
||||||
|
if (prev !== columnId) {
|
||||||
|
setSortDirection('asc');
|
||||||
|
return columnId;
|
||||||
|
}
|
||||||
|
setSortDirection((dir) => {
|
||||||
|
if (dir === 'asc') return 'desc';
|
||||||
|
if (dir === 'desc') return null;
|
||||||
|
return 'asc';
|
||||||
|
});
|
||||||
|
return columnId;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(page: number) => {
|
||||||
|
if (pagination?.onPageChange) {
|
||||||
|
pagination.onPageChange(page);
|
||||||
|
} else {
|
||||||
|
setInternalPage(page);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pagination]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePageSizeChange = useCallback(
|
||||||
|
(size: number) => {
|
||||||
|
if (pagination?.onPageSizeChange) {
|
||||||
|
pagination.onPageSizeChange(size);
|
||||||
|
} else {
|
||||||
|
setInternalPageSize(size);
|
||||||
|
setInternalPage(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pagination]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleColumnFilterChange = useCallback((columnId: string, value: string) => {
|
||||||
|
setColumnFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[columnId]: value,
|
||||||
|
}));
|
||||||
|
setInternalPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearFilters = useCallback(() => {
|
||||||
|
setColumnFilters({});
|
||||||
|
setSearchQuery('');
|
||||||
|
setInternalPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasActiveFilters = searchQuery || Object.values(columnFilters).some(Boolean);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<SkeletonTable
|
||||||
|
rows={pageSize}
|
||||||
|
columns={columns.length}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
{(title || subtitle || enableSearch || enableFilters) && (
|
||||||
|
<div className="border-b border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
{/* Title */}
|
||||||
|
{(title || subtitle) && (
|
||||||
|
<div>
|
||||||
|
{title && (
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search and filters */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{enableSearch && (
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
size={16}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setInternalPage(1);
|
||||||
|
}}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className="w-full sm:w-64 rounded-md border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{enableFilters && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 rounded-md border px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
showFilters || hasActiveFilters
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400'
|
||||||
|
: 'border-gray-300 text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Filter size={16} />
|
||||||
|
<span>Filtros</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
<span>Limpiar</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column filters */}
|
||||||
|
{showFilters && enableFilters && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{columns
|
||||||
|
.filter((col) => col.filterable !== false)
|
||||||
|
.map((column) => (
|
||||||
|
<div key={column.id} className="flex-shrink-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={columnFilters[column.id] || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleColumnFilterChange(column.id, e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={column.header}
|
||||||
|
className="w-32 rounded border border-gray-300 bg-white px-2 py-1 text-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50">
|
||||||
|
{enableRowSelection && (
|
||||||
|
<th className="w-10 px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
onChange={(e) => {
|
||||||
|
paginatedData.forEach((row, index) => {
|
||||||
|
const rowId = getRowIdFn(row, index);
|
||||||
|
onRowSelect?.(rowId, e.target.checked);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th
|
||||||
|
key={column.id}
|
||||||
|
className={cn(
|
||||||
|
'px-4 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400',
|
||||||
|
compact ? 'py-2' : 'py-3',
|
||||||
|
column.align === 'center' && 'text-center',
|
||||||
|
column.align === 'right' && 'text-right',
|
||||||
|
column.hideOnMobile && 'hidden md:table-cell',
|
||||||
|
column.sortable !== false && 'cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-300'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: column.width,
|
||||||
|
minWidth: column.minWidth,
|
||||||
|
}}
|
||||||
|
onClick={() => column.sortable !== false && handleSort(column.id)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1',
|
||||||
|
column.align === 'center' && 'justify-center',
|
||||||
|
column.align === 'right' && 'justify-end'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{column.header}</span>
|
||||||
|
{column.sortable !== false && (
|
||||||
|
<SortIcon
|
||||||
|
direction={sortColumn === column.id ? sortDirection : null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{paginatedData.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={columns.length + (enableRowSelection ? 1 : 0)}
|
||||||
|
className="px-4 py-8 text-center text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{emptyMessage}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
paginatedData.map((row, rowIndex) => {
|
||||||
|
const rowId = getRowIdFn(row, rowIndex);
|
||||||
|
const isSelected = selectedRows?.has(rowId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={rowId}
|
||||||
|
className={cn(
|
||||||
|
'transition-colors',
|
||||||
|
striped && rowIndex % 2 === 1 && 'bg-gray-50 dark:bg-gray-800/30',
|
||||||
|
hoverable && 'hover:bg-gray-50 dark:hover:bg-gray-800/50',
|
||||||
|
onRowClick && 'cursor-pointer',
|
||||||
|
isSelected && 'bg-blue-50 dark:bg-blue-900/20'
|
||||||
|
)}
|
||||||
|
onClick={() => onRowClick?.(row, rowIndex)}
|
||||||
|
>
|
||||||
|
{enableRowSelection && (
|
||||||
|
<td className="w-10 px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRowSelect?.(rowId, e.target.checked);
|
||||||
|
}}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{columns.map((column) => {
|
||||||
|
const value = getCellValue(row, column);
|
||||||
|
const displayValue = column.cell
|
||||||
|
? column.cell(value, row, rowIndex)
|
||||||
|
: value !== null && value !== undefined
|
||||||
|
? String(value)
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={column.id}
|
||||||
|
className={cn(
|
||||||
|
'px-4 text-sm text-gray-900 dark:text-gray-100',
|
||||||
|
compact ? 'py-2' : 'py-3',
|
||||||
|
column.align === 'center' && 'text-center',
|
||||||
|
column.align === 'right' && 'text-right',
|
||||||
|
column.hideOnMobile && 'hidden md:table-cell'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: column.width,
|
||||||
|
minWidth: column.minWidth,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayValue}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{pagination !== undefined && totalItems > 0 && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalItems={totalItems}
|
||||||
|
pageSizeOptions={pageSizeOptions}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
400
packages/ui/src/components/KPICard.tsx
Normal file
400
packages/ui/src/components/KPICard.tsx
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
import { SkeletonKPICard } from './Skeleton';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ValueFormat = 'currency' | 'percent' | 'number' | 'compact';
|
||||||
|
|
||||||
|
export interface SparklineDataPoint {
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KPICardProps {
|
||||||
|
/** Title of the KPI */
|
||||||
|
title: string;
|
||||||
|
/** Current value */
|
||||||
|
value: number;
|
||||||
|
/** Previous period value for comparison */
|
||||||
|
previousValue?: number;
|
||||||
|
/** Format to display the value */
|
||||||
|
format?: ValueFormat;
|
||||||
|
/** Currency code for currency format (default: MXN) */
|
||||||
|
currency?: string;
|
||||||
|
/** Number of decimal places */
|
||||||
|
decimals?: number;
|
||||||
|
/** Optional prefix (e.g., "$") */
|
||||||
|
prefix?: string;
|
||||||
|
/** Optional suffix (e.g., "%", "users") */
|
||||||
|
suffix?: string;
|
||||||
|
/** Sparkline data points */
|
||||||
|
sparklineData?: SparklineDataPoint[];
|
||||||
|
/** Whether the card is loading */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Invert the color logic (lower is better) */
|
||||||
|
invertColors?: boolean;
|
||||||
|
/** Optional icon component */
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
className?: string;
|
||||||
|
/** Period label (e.g., "vs mes anterior") */
|
||||||
|
periodLabel?: string;
|
||||||
|
/** Click handler */
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Formatting Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function formatValue(
|
||||||
|
value: number,
|
||||||
|
format: ValueFormat,
|
||||||
|
options: {
|
||||||
|
currency?: string;
|
||||||
|
decimals?: number;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
} = {}
|
||||||
|
): string {
|
||||||
|
const { currency = 'MXN', decimals, prefix = '', suffix = '' } = options;
|
||||||
|
|
||||||
|
let formatted: string;
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'currency':
|
||||||
|
formatted = new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: decimals ?? 0,
|
||||||
|
maximumFractionDigits: decimals ?? 0,
|
||||||
|
}).format(value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'percent':
|
||||||
|
formatted = new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'percent',
|
||||||
|
minimumFractionDigits: decimals ?? 1,
|
||||||
|
maximumFractionDigits: decimals ?? 1,
|
||||||
|
}).format(value / 100);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'compact':
|
||||||
|
formatted = new Intl.NumberFormat('es-MX', {
|
||||||
|
notation: 'compact',
|
||||||
|
compactDisplay: 'short',
|
||||||
|
minimumFractionDigits: decimals ?? 1,
|
||||||
|
maximumFractionDigits: decimals ?? 1,
|
||||||
|
}).format(value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'number':
|
||||||
|
default:
|
||||||
|
formatted = new Intl.NumberFormat('es-MX', {
|
||||||
|
minimumFractionDigits: decimals ?? 0,
|
||||||
|
maximumFractionDigits: decimals ?? 2,
|
||||||
|
}).format(value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}${formatted}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateVariation(
|
||||||
|
current: number,
|
||||||
|
previous: number
|
||||||
|
): { percentage: number; direction: 'up' | 'down' | 'neutral' } {
|
||||||
|
if (previous === 0) {
|
||||||
|
return { percentage: 0, direction: 'neutral' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentage = ((current - previous) / Math.abs(previous)) * 100;
|
||||||
|
|
||||||
|
if (Math.abs(percentage) < 0.1) {
|
||||||
|
return { percentage: 0, direction: 'neutral' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
percentage,
|
||||||
|
direction: percentage > 0 ? 'up' : 'down',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mini Sparkline Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface SparklineProps {
|
||||||
|
data: SparklineDataPoint[];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
strokeColor?: string;
|
||||||
|
strokeWidth?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sparkline({
|
||||||
|
data,
|
||||||
|
width = 80,
|
||||||
|
height = 32,
|
||||||
|
strokeColor,
|
||||||
|
strokeWidth = 2,
|
||||||
|
className,
|
||||||
|
}: SparklineProps): React.ReactElement | null {
|
||||||
|
const pathD = useMemo(() => {
|
||||||
|
if (data.length < 2) return null;
|
||||||
|
|
||||||
|
const values = data.map((d) => d.value);
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const range = max - min || 1;
|
||||||
|
|
||||||
|
const padding = 2;
|
||||||
|
const chartWidth = width - padding * 2;
|
||||||
|
const chartHeight = height - padding * 2;
|
||||||
|
|
||||||
|
const points = values.map((value, index) => {
|
||||||
|
const x = padding + (index / (values.length - 1)) * chartWidth;
|
||||||
|
const y = padding + chartHeight - ((value - min) / range) * chartHeight;
|
||||||
|
return { x, y };
|
||||||
|
});
|
||||||
|
|
||||||
|
return points
|
||||||
|
.map((point, i) => `${i === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
|
||||||
|
.join(' ');
|
||||||
|
}, [data, width, height]);
|
||||||
|
|
||||||
|
if (!pathD) return null;
|
||||||
|
|
||||||
|
// Determine color based on trend
|
||||||
|
const trend = data[data.length - 1].value >= data[0].value;
|
||||||
|
const color = strokeColor ?? (trend ? '#10B981' : '#EF4444');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
className={cn('overflow-visible', className)}
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d={pathD}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Variation Badge Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface VariationBadgeProps {
|
||||||
|
percentage: number;
|
||||||
|
direction: 'up' | 'down' | 'neutral';
|
||||||
|
invertColors?: boolean;
|
||||||
|
periodLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VariationBadge({
|
||||||
|
percentage,
|
||||||
|
direction,
|
||||||
|
invertColors = false,
|
||||||
|
periodLabel,
|
||||||
|
}: VariationBadgeProps): React.ReactElement {
|
||||||
|
const isPositive = direction === 'up';
|
||||||
|
const isNeutral = direction === 'neutral';
|
||||||
|
|
||||||
|
// Determine if this change is "good" or "bad"
|
||||||
|
const isGood = invertColors ? !isPositive : isPositive;
|
||||||
|
|
||||||
|
const colorClasses = isNeutral
|
||||||
|
? 'text-gray-500 bg-gray-100 dark:bg-gray-700 dark:text-gray-400'
|
||||||
|
: isGood
|
||||||
|
? 'text-green-700 bg-green-100 dark:bg-green-900/30 dark:text-green-400'
|
||||||
|
: 'text-red-700 bg-red-100 dark:bg-red-900/30 dark:text-red-400';
|
||||||
|
|
||||||
|
const Icon = isNeutral ? Minus : isPositive ? TrendingUp : TrendingDown;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-sm font-medium',
|
||||||
|
colorClasses
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={14} className="flex-shrink-0" />
|
||||||
|
<span>{Math.abs(percentage).toFixed(1)}%</span>
|
||||||
|
</span>
|
||||||
|
{periodLabel && (
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{periodLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main KPICard Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function KPICard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
previousValue,
|
||||||
|
format = 'number',
|
||||||
|
currency = 'MXN',
|
||||||
|
decimals,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
sparklineData,
|
||||||
|
isLoading = false,
|
||||||
|
invertColors = false,
|
||||||
|
icon,
|
||||||
|
className,
|
||||||
|
periodLabel = 'vs periodo anterior',
|
||||||
|
onClick,
|
||||||
|
}: KPICardProps): React.ReactElement {
|
||||||
|
// Calculate variation if previous value is provided
|
||||||
|
const variation = useMemo(() => {
|
||||||
|
if (previousValue === undefined) return null;
|
||||||
|
return calculateVariation(value, previousValue);
|
||||||
|
}, [value, previousValue]);
|
||||||
|
|
||||||
|
// Format the display value
|
||||||
|
const formattedValue = useMemo(() => {
|
||||||
|
return formatValue(value, format, { currency, decimals, prefix, suffix });
|
||||||
|
}, [value, format, currency, decimals, prefix, suffix]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <SkeletonKPICard showSparkline={Boolean(sparklineData)} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isClickable = Boolean(onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-all',
|
||||||
|
'dark:border-gray-700 dark:bg-gray-800',
|
||||||
|
isClickable && 'cursor-pointer hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
role={isClickable ? 'button' : undefined}
|
||||||
|
tabIndex={isClickable ? 0 : undefined}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Title with optional icon */}
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
{icon && (
|
||||||
|
<span className="text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Value */}
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white mb-2 truncate">
|
||||||
|
{formattedValue}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Variation Badge */}
|
||||||
|
{variation && (
|
||||||
|
<VariationBadge
|
||||||
|
percentage={variation.percentage}
|
||||||
|
direction={variation.direction}
|
||||||
|
invertColors={invertColors}
|
||||||
|
periodLabel={periodLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sparkline */}
|
||||||
|
{sparklineData && sparklineData.length > 1 && (
|
||||||
|
<div className="ml-4 flex-shrink-0">
|
||||||
|
<Sparkline data={sparklineData} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Compact KPI Card Variant
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface CompactKPICardProps {
|
||||||
|
title: string;
|
||||||
|
value: number;
|
||||||
|
format?: ValueFormat;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
trend?: 'up' | 'down' | 'neutral';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompactKPICard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
format = 'number',
|
||||||
|
icon,
|
||||||
|
trend,
|
||||||
|
className,
|
||||||
|
}: CompactKPICardProps): React.ReactElement {
|
||||||
|
const formattedValue = formatValue(value, format, {});
|
||||||
|
|
||||||
|
const trendColors = {
|
||||||
|
up: 'text-green-500',
|
||||||
|
down: 'text-red-500',
|
||||||
|
neutral: 'text-gray-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-lg bg-gray-50 p-3 dark:bg-gray-800/50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex-shrink-0',
|
||||||
|
trend ? trendColors[trend] : 'text-gray-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900 dark:text-white truncate">
|
||||||
|
{formattedValue}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{trend && (
|
||||||
|
<div className={cn('flex-shrink-0', trendColors[trend])}>
|
||||||
|
{trend === 'up' && <TrendingUp size={16} />}
|
||||||
|
{trend === 'down' && <TrendingDown size={16} />}
|
||||||
|
{trend === 'neutral' && <Minus size={16} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
425
packages/ui/src/components/MetricCard.tsx
Normal file
425
packages/ui/src/components/MetricCard.tsx
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Minus,
|
||||||
|
ArrowRight,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
import { AlertBadge, type AlertSeverity } from './AlertBadge';
|
||||||
|
import { SkeletonCard } from './Skeleton';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type MetricStatus = 'good' | 'warning' | 'critical' | 'neutral';
|
||||||
|
export type MetricTrend = 'up' | 'down' | 'stable';
|
||||||
|
export type MetricFormat = 'currency' | 'percent' | 'number' | 'compact' | 'days';
|
||||||
|
|
||||||
|
export interface MetricValue {
|
||||||
|
current: number;
|
||||||
|
previous?: number;
|
||||||
|
target?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricPeriod {
|
||||||
|
label: string;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricComparison {
|
||||||
|
type: 'previous_period' | 'previous_year' | 'target' | 'budget';
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricCardProps {
|
||||||
|
/** Metric name/title */
|
||||||
|
title: string;
|
||||||
|
/** Description or subtitle */
|
||||||
|
description?: string;
|
||||||
|
/** Metric values */
|
||||||
|
metric: MetricValue;
|
||||||
|
/** Current period */
|
||||||
|
period?: MetricPeriod;
|
||||||
|
/** Comparison data */
|
||||||
|
comparison?: MetricComparison;
|
||||||
|
/** Value format */
|
||||||
|
format?: MetricFormat;
|
||||||
|
/** Currency code */
|
||||||
|
currency?: string;
|
||||||
|
/** Number of decimal places */
|
||||||
|
decimals?: number;
|
||||||
|
/** Status thresholds - automatically determines status */
|
||||||
|
thresholds?: {
|
||||||
|
good: number;
|
||||||
|
warning: number;
|
||||||
|
};
|
||||||
|
/** Override automatic status */
|
||||||
|
status?: MetricStatus;
|
||||||
|
/** Invert threshold logic (lower is better) */
|
||||||
|
invertThresholds?: boolean;
|
||||||
|
/** Icon to display */
|
||||||
|
icon?: LucideIcon;
|
||||||
|
/** Loading state */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Click handler */
|
||||||
|
onClick?: () => void;
|
||||||
|
/** Link to detailed view */
|
||||||
|
detailsLink?: string;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Formatting Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function formatMetricValue(
|
||||||
|
value: number,
|
||||||
|
format: MetricFormat,
|
||||||
|
options: { currency?: string; decimals?: number } = {}
|
||||||
|
): string {
|
||||||
|
const { currency = 'MXN', decimals } = options;
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'currency':
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: decimals ?? 0,
|
||||||
|
maximumFractionDigits: decimals ?? 0,
|
||||||
|
}).format(value);
|
||||||
|
|
||||||
|
case 'percent':
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'percent',
|
||||||
|
minimumFractionDigits: decimals ?? 1,
|
||||||
|
maximumFractionDigits: decimals ?? 1,
|
||||||
|
}).format(value / 100);
|
||||||
|
|
||||||
|
case 'compact':
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
notation: 'compact',
|
||||||
|
compactDisplay: 'short',
|
||||||
|
minimumFractionDigits: decimals ?? 1,
|
||||||
|
maximumFractionDigits: decimals ?? 1,
|
||||||
|
}).format(value);
|
||||||
|
|
||||||
|
case 'days':
|
||||||
|
return `${value.toFixed(decimals ?? 0)} dias`;
|
||||||
|
|
||||||
|
case 'number':
|
||||||
|
default:
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
minimumFractionDigits: decimals ?? 0,
|
||||||
|
maximumFractionDigits: decimals ?? 2,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateTrend(current: number, previous?: number): MetricTrend {
|
||||||
|
if (previous === undefined) return 'stable';
|
||||||
|
|
||||||
|
const change = ((current - previous) / Math.abs(previous || 1)) * 100;
|
||||||
|
|
||||||
|
if (Math.abs(change) < 1) return 'stable';
|
||||||
|
return change > 0 ? 'up' : 'down';
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateVariationPercent(current: number, previous: number): number {
|
||||||
|
if (previous === 0) return 0;
|
||||||
|
return ((current - previous) / Math.abs(previous)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function determineStatus(
|
||||||
|
value: number,
|
||||||
|
thresholds?: { good: number; warning: number },
|
||||||
|
invert: boolean = false
|
||||||
|
): MetricStatus {
|
||||||
|
if (!thresholds) return 'neutral';
|
||||||
|
|
||||||
|
if (invert) {
|
||||||
|
// Lower is better (e.g., DSO, costs)
|
||||||
|
if (value <= thresholds.good) return 'good';
|
||||||
|
if (value <= thresholds.warning) return 'warning';
|
||||||
|
return 'critical';
|
||||||
|
} else {
|
||||||
|
// Higher is better (e.g., revenue, margins)
|
||||||
|
if (value >= thresholds.good) return 'good';
|
||||||
|
if (value >= thresholds.warning) return 'warning';
|
||||||
|
return 'critical';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusToSeverity(status: MetricStatus): AlertSeverity {
|
||||||
|
switch (status) {
|
||||||
|
case 'good':
|
||||||
|
return 'success';
|
||||||
|
case 'warning':
|
||||||
|
return 'warning';
|
||||||
|
case 'critical':
|
||||||
|
return 'critical';
|
||||||
|
default:
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sub-components
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface TrendIndicatorProps {
|
||||||
|
trend: MetricTrend;
|
||||||
|
percentage: number;
|
||||||
|
invertColors?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendIndicator({
|
||||||
|
trend,
|
||||||
|
percentage,
|
||||||
|
invertColors = false,
|
||||||
|
}: TrendIndicatorProps): React.ReactElement {
|
||||||
|
const isPositive = trend === 'up';
|
||||||
|
const isNeutral = trend === 'stable';
|
||||||
|
const isGood = invertColors ? !isPositive : isPositive;
|
||||||
|
|
||||||
|
const colorClass = isNeutral
|
||||||
|
? 'text-gray-500'
|
||||||
|
: isGood
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: 'text-red-600 dark:text-red-400';
|
||||||
|
|
||||||
|
const Icon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : Minus;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-1 text-sm font-medium', colorClass)}>
|
||||||
|
<Icon size={16} />
|
||||||
|
<span>
|
||||||
|
{trend !== 'stable' && (trend === 'up' ? '+' : '')}
|
||||||
|
{percentage.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TargetProgressProps {
|
||||||
|
current: number;
|
||||||
|
target: number;
|
||||||
|
format: MetricFormat;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TargetProgress({
|
||||||
|
current,
|
||||||
|
target,
|
||||||
|
format,
|
||||||
|
currency,
|
||||||
|
}: TargetProgressProps): React.ReactElement {
|
||||||
|
const progress = Math.min((current / target) * 100, 100);
|
||||||
|
const isOnTrack = progress >= 80;
|
||||||
|
const isAhead = current >= target;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
<span>Objetivo: {formatMetricValue(target, format, { currency })}</span>
|
||||||
|
<span>{progress.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full rounded-full transition-all duration-500',
|
||||||
|
isAhead
|
||||||
|
? 'bg-green-500'
|
||||||
|
: isOnTrack
|
||||||
|
? 'bg-blue-500'
|
||||||
|
: 'bg-yellow-500'
|
||||||
|
)}
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main MetricCard Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function MetricCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
metric,
|
||||||
|
period,
|
||||||
|
comparison,
|
||||||
|
format = 'number',
|
||||||
|
currency = 'MXN',
|
||||||
|
decimals,
|
||||||
|
thresholds,
|
||||||
|
status: statusOverride,
|
||||||
|
invertThresholds = false,
|
||||||
|
icon: Icon,
|
||||||
|
isLoading = false,
|
||||||
|
onClick,
|
||||||
|
detailsLink,
|
||||||
|
className,
|
||||||
|
}: MetricCardProps): React.ReactElement {
|
||||||
|
// Calculate derived values
|
||||||
|
const trend = useMemo(
|
||||||
|
() => calculateTrend(metric.current, metric.previous),
|
||||||
|
[metric.current, metric.previous]
|
||||||
|
);
|
||||||
|
|
||||||
|
const variationPercent = useMemo(() => {
|
||||||
|
if (metric.previous === undefined) return 0;
|
||||||
|
return calculateVariationPercent(metric.current, metric.previous);
|
||||||
|
}, [metric.current, metric.previous]);
|
||||||
|
|
||||||
|
const status = useMemo(() => {
|
||||||
|
if (statusOverride) return statusOverride;
|
||||||
|
return determineStatus(metric.current, thresholds, invertThresholds);
|
||||||
|
}, [metric.current, thresholds, invertThresholds, statusOverride]);
|
||||||
|
|
||||||
|
const formattedValue = useMemo(
|
||||||
|
() => formatMetricValue(metric.current, format, { currency, decimals }),
|
||||||
|
[metric.current, format, currency, decimals]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <SkeletonCard className={className} showHeader lines={4} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isClickable = Boolean(onClick || detailsLink);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border border-gray-200 bg-white p-5 shadow-sm transition-all',
|
||||||
|
'dark:border-gray-700 dark:bg-gray-800',
|
||||||
|
isClickable &&
|
||||||
|
'cursor-pointer hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
role={isClickable ? 'button' : undefined}
|
||||||
|
tabIndex={isClickable ? 0 : undefined}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{Icon && (
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-700">
|
||||||
|
<Icon size={20} className="text-gray-600 dark:text-gray-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status !== 'neutral' && (
|
||||||
|
<AlertBadge
|
||||||
|
severity={statusToSeverity(status)}
|
||||||
|
size="sm"
|
||||||
|
label={status === 'good' ? 'Bueno' : status === 'warning' ? 'Alerta' : 'Critico'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Value */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{formattedValue}
|
||||||
|
</p>
|
||||||
|
{period && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{period.label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trend & Comparison */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{metric.previous !== undefined && (
|
||||||
|
<TrendIndicator
|
||||||
|
trend={trend}
|
||||||
|
percentage={variationPercent}
|
||||||
|
invertColors={invertThresholds}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{comparison && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatMetricValue(comparison.value, format, { currency })}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1">{comparison.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target Progress */}
|
||||||
|
{metric.target !== undefined && (
|
||||||
|
<TargetProgress
|
||||||
|
current={metric.current}
|
||||||
|
target={metric.target}
|
||||||
|
format={format}
|
||||||
|
currency={currency}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Details Link */}
|
||||||
|
{detailsLink && (
|
||||||
|
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<a
|
||||||
|
href={detailsLink}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
Ver detalles
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metric Card Grid Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface MetricCardGridProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
columns?: 2 | 3 | 4;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricCardGrid({
|
||||||
|
children,
|
||||||
|
columns = 3,
|
||||||
|
className,
|
||||||
|
}: MetricCardGridProps): React.ReactElement {
|
||||||
|
const gridCols = {
|
||||||
|
2: 'grid-cols-1 md:grid-cols-2',
|
||||||
|
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||||
|
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('grid gap-4', gridCols[columns], className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user