refactor: Configure subrepositorios for apps

This commit is contained in:
rckrdmrd 2026-01-04 07:05:07 -06:00
parent ef42f5353a
commit bfda089f4e
532 changed files with 67 additions and 131731 deletions

43
.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# =============================================================================
# SUBREPOSITORIOS - Tienen sus propios repositorios independientes
# Ver .gitmodules para referencias
# =============================================================================
apps/backend/
apps/frontend/
apps/database/
apps/ml-engine/
apps/data-service/
# Apps adicionales (sin subrepo aún)
apps/llm-agent/
apps/mt4-gateway/
apps/personal/
apps/trading-agents/
# Dependencias
node_modules/
# Build
dist/
build/
.next/
# Environment
.env
.env.local
!.env.example
# Logs
*.log
# IDE
.idea/
.vscode/
# OS
.DS_Store
# Python
__pycache__/
*.pyc
.venv/

24
.gitmodules vendored Normal file
View File

@ -0,0 +1,24 @@
# =============================================================================
# Subrepositorios de trading-platform
# Cada subproyecto tiene su propio repositorio para deployment independiente
# =============================================================================
[submodule "apps/backend"]
path = apps/backend
url = git@gitea-server:rckrdmrd/trading-platform-backend.git
[submodule "apps/frontend"]
path = apps/frontend
url = git@gitea-server:rckrdmrd/trading-platform-frontend.git
[submodule "apps/database"]
path = apps/database
url = git@gitea-server:rckrdmrd/trading-platform-database.git
[submodule "apps/ml-engine"]
path = apps/ml-engine
url = git@gitea-server:rckrdmrd/trading-platform-ml-engine.git
[submodule "apps/data-service"]
path = apps/data-service
url = git@gitea-server:rckrdmrd/trading-platform-data-service.git

View File

@ -1,159 +0,0 @@
# OrbiQuant IA - Backend Environment Variables
# ============================================================================
# App
# ============================================================================
NODE_ENV=development
PORT=3081
FRONTEND_URL=http://localhost:3080
API_URL=http://localhost:3081
# ============================================================================
# CORS
# ============================================================================
CORS_ORIGINS=http://localhost:3080,http://localhost:3081
# ============================================================================
# JWT
# ============================================================================
JWT_ACCESS_SECRET=your-access-secret-change-in-production-min-32-chars
JWT_REFRESH_SECRET=your-refresh-secret-change-in-production-min-32-chars
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
# ============================================================================
# Database (PostgreSQL)
# ============================================================================
DB_HOST=localhost
DB_PORT=5432
DB_NAME=orbiquant_platform
DB_USER=orbiquant_user
DB_PASSWORD=your-secure-password-here
DB_SSL=false
DB_POOL_MAX=20
DB_IDLE_TIMEOUT=30000
DB_CONNECTION_TIMEOUT=5000
# ============================================================================
# Redis
# ============================================================================
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# ============================================================================
# Stripe
# ============================================================================
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# ============================================================================
# ML Engine
# ============================================================================
ML_ENGINE_URL=http://localhost:3083
ML_ENGINE_API_KEY=
ML_ENGINE_TIMEOUT=30000
# ============================================================================
# Trading Agents
# ============================================================================
TRADING_AGENTS_URL=http://localhost:3086
TRADING_AGENTS_TIMEOUT=60000
# ============================================================================
# LLM Agent (Local Python Service)
# ============================================================================
LLM_AGENT_URL=http://localhost:3085
LLM_AGENT_TIMEOUT=120000
# ============================================================================
# LLM Services (Cloud APIs - Fallback)
# ============================================================================
# Anthropic (Claude)
ANTHROPIC_API_KEY=sk-ant-...
# OpenAI (optional, fallback)
OPENAI_API_KEY=sk-...
# LLM Configuration
LLM_PROVIDER=anthropic
LLM_MODEL=claude-3-5-sonnet-20241022
LLM_MAX_TOKENS=4096
LLM_TEMPERATURE=0.7
# ============================================================================
# Binance API (Market Data)
# ============================================================================
BINANCE_API_KEY=
BINANCE_SECRET_KEY=
BINANCE_TESTNET=true
# ============================================================================
# Rate Limiting
# ============================================================================
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX=100
# ============================================================================
# Email (SMTP)
# ============================================================================
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_SECURE=false
EMAIL_USER=your-email@gmail.com
EMAIL_PASSWORD=your-app-password
EMAIL_FROM=noreply@orbiquant.io
# ============================================================================
# Twilio (SMS/WhatsApp)
# ============================================================================
TWILIO_ACCOUNT_SID=your-twilio-account-sid
TWILIO_AUTH_TOKEN=your-twilio-auth-token
TWILIO_PHONE_NUMBER=+1234567890
TWILIO_WHATSAPP_NUMBER=+14155238886
TWILIO_VERIFY_SERVICE_SID=your-verify-service-sid
TWILIO_USE_VERIFY_SERVICE=true
# ============================================================================
# OAuth - Google
# ============================================================================
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-google-client-secret
GOOGLE_CALLBACK_URL=http://localhost:3081/api/v1/auth/google/callback
# ============================================================================
# OAuth - Facebook
# ============================================================================
FACEBOOK_CLIENT_ID=your-facebook-app-id
FACEBOOK_CLIENT_SECRET=your-facebook-app-secret
FACEBOOK_CALLBACK_URL=http://localhost:3081/api/v1/auth/facebook/callback
# ============================================================================
# OAuth - Twitter/X
# ============================================================================
TWITTER_CLIENT_ID=your-twitter-client-id
TWITTER_CLIENT_SECRET=your-twitter-client-secret
TWITTER_CALLBACK_URL=http://localhost:3081/api/v1/auth/twitter/callback
# ============================================================================
# OAuth - Apple Sign In
# ============================================================================
APPLE_CLIENT_ID=your-apple-service-id
APPLE_CLIENT_SECRET=your-apple-client-secret
APPLE_TEAM_ID=your-apple-team-id
APPLE_KEY_ID=your-apple-key-id
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
APPLE_CALLBACK_URL=http://localhost:3081/api/v1/auth/apple/callback
# ============================================================================
# OAuth - GitHub
# ============================================================================
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
GITHUB_CALLBACK_URL=http://localhost:3081/api/v1/auth/github/callback
# ============================================================================
# Logging
# ============================================================================
LOG_LEVEL=info

View File

@ -1,73 +0,0 @@
# =============================================================================
# OrbiQuant IA - Backend API
# Multi-stage Dockerfile for production deployment
# =============================================================================
# -----------------------------------------------------------------------------
# Stage 1: Dependencies
# -----------------------------------------------------------------------------
FROM node:20-alpine AS deps
WORKDIR /app
# Install dependencies for native modules
RUN apk add --no-cache libc6-compat python3 make g++
# Copy package files
COPY package*.json ./
# Install all dependencies (including dev for build)
RUN npm ci
# -----------------------------------------------------------------------------
# Stage 2: Builder
# -----------------------------------------------------------------------------
FROM node:20-alpine AS builder
WORKDIR /app
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build TypeScript
RUN npm run build
# Remove dev dependencies
RUN npm prune --production
# -----------------------------------------------------------------------------
# Stage 3: Production
# -----------------------------------------------------------------------------
FROM node:20-alpine AS runner
WORKDIR /app
# Create non-root user for security
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 orbiquant
# Set production environment
ENV NODE_ENV=production
ENV PORT=3000
# Copy necessary files
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
# Change ownership
RUN chown -R orbiquant:nodejs /app
# Switch to non-root user
USER orbiquant
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# Start application
CMD ["node", "dist/index.js"]

View File

@ -1,563 +0,0 @@
# WebSocket Implementation Report - OrbiQuant IA
**Fecha:** 2024-12-07
**Épica:** OQI-003 - Trading y Charts
**Tarea:** Implementar WebSocket Server para actualizaciones en tiempo real
**Estado:** ✅ COMPLETADO
---
## 1. RESUMEN EJECUTIVO
Se ha implementado exitosamente un sistema de WebSocket para actualizaciones de precios en tiempo real, integrando directamente con los streams de Binance WebSocket. El sistema reemplaza el polling por verdaderas actualizaciones en tiempo real mediante event-driven architecture.
### Mejoras Clave
- ✅ **Streaming en tiempo real** desde Binance (no polling)
- ✅ **Múltiples canales** de suscripción (price, ticker, klines, trades, depth)
- ✅ **Heartbeat/ping-pong** para mantener conexiones
- ✅ **Reconexión automática** en caso de desconexión
- ✅ **Cache de precios** para respuestas inmediatas
- ✅ **Gestión de memoria** (cleanup automático de clientes desconectados)
---
## 2. ARCHIVOS MODIFICADOS
### 2.1 `/apps/backend/src/core/websocket/trading-stream.service.ts`
**Cambios principales:**
1. **Integración directa con Binance WebSocket**
- Reemplazó polling (`setInterval`) por event listeners de Binance
- Agregó métodos: `startTickerStream()`, `startKlineStream()`, `startTradeStream()`, `startDepthStream()`
- Implementó manejo de eventos: `ticker`, `kline`, `trade`, `depth`
2. **Nuevos tipos de datos**
```typescript
export interface KlineData {
symbol: string;
interval: string;
time: number;
open: number;
high: number;
low: number;
close: number;
volume: number;
isFinal: boolean;
timestamp: Date;
}
```
3. **Nuevos canales soportados**
- `price:<symbol>` - Actualizaciones de precio
- `ticker:<symbol>` - Estadísticas 24h completas
- `klines:<symbol>:<interval>` - Datos de velas
- `trades:<symbol>` - Trades individuales
- `depth:<symbol>` - Order book depth
4. **Cache de precios**
- `priceCache: Map<string, QuoteData>` para respuestas instantáneas
- TTL de 5 segundos
5. **Referencias de streams de Binance**
- `binanceStreamRefs: Map<string, {...}>` para rastrear suscripciones activas
- Cleanup automático cuando no hay subscriptores
6. **Estadísticas mejoradas**
```typescript
getStats(): {
connectedClients: number;
activeChannels: string[];
quoteStreams: number;
signalStreams: number;
binanceStreams: number; // NUEVO
binanceActiveStreams: string[]; // NUEVO
priceCache: number; // NUEVO
}
```
### 2.2 `/apps/backend/src/core/websocket/index.ts`
**Cambio:**
- Exportó el nuevo tipo `KlineData` para uso en otros módulos
### 2.3 Archivos NO modificados (ya existían)
- `/apps/backend/src/core/websocket/websocket.server.ts` - Infraestructura base
- `/apps/backend/src/modules/trading/services/binance.service.ts` - Cliente de Binance
- `/apps/backend/src/index.ts` - Entry point (ya tenía WebSocket configurado)
---
## 3. ARCHIVOS CREADOS
### 3.1 Documentación
**`/apps/backend/WEBSOCKET_TESTING.md`** (13 KB)
- Guía completa de uso del WebSocket
- Ejemplos de todos los tipos de mensajes
- Tutoriales para diferentes clientes (wscat, websocat, browser, Python)
- Troubleshooting y mejores prácticas
### 3.2 Scripts de Testing
**`/apps/backend/test-websocket.js`** (4.3 KB)
- Cliente de prueba en Node.js
- Auto-subscribe a múltiples canales
- Output formateado y colorizado
- Auto-disconnect después de 60s
**`/apps/backend/test-websocket.html`** (14 KB)
- Dashboard interactivo en HTML
- UI visual para probar WebSocket
- Estadísticas en tiempo real
- Suscripción dinámica a canales
---
## 4. DEPENDENCIAS
### Instaladas (ya existían en package.json)
- ✅ `ws@8.18.0` - WebSocket library
- ✅ `@types/ws@8.5.13` - TypeScript types
### No se requirieron nuevas dependencias
---
## 5. ARQUITECTURA DEL SISTEMA
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend Client │
│ (Browser/Mobile App) │
└──────────────────────┬──────────────────────────────────────┘
│ ws://localhost:3000/ws
┌──────────────────────▼──────────────────────────────────────┐
│ OrbiQuant WebSocket Server │
│ (websocket.server.ts) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ - Manejo de conexiones │ │
│ │ - Autenticación (opcional) │ │
│ │ - Channel subscriptions │ │
│ │ - Heartbeat/ping-pong │ │
│ │ - Broadcast a clientes suscritos │ │
│ └──────────────────┬───────────────────────────────────┘ │
└─────────────────────┼──────────────────────────────────────┘
┌─────────────────────▼──────────────────────────────────────┐
│ Trading Stream Service │
│ (trading-stream.service.ts) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ - Gestión de subscripciones por canal │ │
│ │ - Event listeners de Binance │ │
│ │ - Cache de precios │ │
│ │ - Transformación de datos │ │
│ │ - Broadcast a clientes │ │
│ └──────────────────┬───────────────────────────────────┘ │
└─────────────────────┼──────────────────────────────────────┘
┌─────────────────────▼──────────────────────────────────────┐
│ Binance Service │
│ (binance.service.ts) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ - WebSocket client para Binance │ │
│ │ - Subscripción a streams: │ │
│ │ • ticker (@ticker) │ │
│ │ • klines (@kline_<interval>) │ │
│ │ • trades (@trade) │ │
│ │ • depth (@depth10@100ms) │ │
│ │ - Reconexión automática │ │
│ │ - Event emitter │ │
│ └──────────────────┬───────────────────────────────────┘ │
└─────────────────────┼──────────────────────────────────────┘
┌────────────▼────────────┐
│ Binance WebSocket │
│ wss://stream.binance │
│ .com:9443/ws │
└─────────────────────────┘
```
---
## 6. FLUJO DE DATOS
### 6.1 Cliente Subscribe a un Canal
```
1. Cliente → Server: {"type":"subscribe","channels":["price:BTCUSDT"]}
2. Server → tradingStreamService.handleSubscribe("price:BTCUSDT")
3. tradingStreamService → binanceService.subscribeTicker("BTCUSDT")
4. binanceService → Binance WS: Conecta a "btcusdt@ticker"
5. Server → Cliente: {"type":"subscribed","channel":"price:BTCUSDT"}
```
### 6.2 Recepción de Datos en Tiempo Real
```
1. Binance WS → binanceService: Ticker data
2. binanceService → EventEmitter.emit('ticker', data)
3. tradingStreamService: listener('ticker') recibe data
4. tradingStreamService: Transforma data a QuoteData
5. tradingStreamService: Actualiza priceCache
6. tradingStreamService → wsManager.broadcast("price:BTCUSDT", {...})
7. wsManager → Todos los clientes suscritos a "price:BTCUSDT"
```
### 6.3 Cleanup al Desuscribirse
```
1. Cliente → Server: {"type":"unsubscribe","channels":["price:BTCUSDT"]}
2. Server → tradingStreamService.handleUnsubscribe()
3. tradingStreamService: Verifica si hay otros suscritos
4. Si no hay suscritos → binanceService.unsubscribe("btcusdt@ticker")
5. binanceService: Cierra conexión WS con Binance
6. tradingStreamService: Limpia binanceStreamRefs
7. Server → Cliente: {"type":"unsubscribed","channel":"price:BTCUSDT"}
```
---
## 7. RESULTADOS DE BUILD Y TESTS
### 7.1 TypeScript Build
```bash
$ npm run build
> tsc
✅ Build exitoso - 0 errores
```
### 7.2 Type Checking
```bash
$ npm run typecheck
> tsc --noEmit
✅ Type checking exitoso - 0 errores
```
### 7.3 ESLint
```
⚠️ No se encontró configuración de ESLint
Nota: Esto no afecta la funcionalidad. Se puede configurar posteriormente.
```
---
## 8. CÓMO PROBAR EL WEBSOCKET
### 8.1 Iniciar el Backend
```bash
cd /home/isem/workspace/projects/trading-platform/apps/backend
npm run dev
```
### 8.2 Opción 1: Script Node.js (Consola)
```bash
node test-websocket.js
```
**Output esperado:**
```
OrbiQuant WebSocket Test Client
================================
⏳ Connecting to WebSocket server...
(Test will run for 60 seconds, or press Ctrl+C to stop)
✅ Connected to WebSocket server
URL: ws://localhost:3000/ws
📡 Subscribing to channels...
🔌 Server welcome message:
Client ID: ws_1701806400000_abc123def
Authenticated: false
Timestamp: 2024-12-06T12:00:00.000Z
✅ Subscribed to: price:BTCUSDT
✅ Subscribed to: ticker:ETHUSDT
✅ Subscribed to: klines:BTCUSDT:1m
[2.3s] 💰 PRICE UPDATE - BTCUSDT
Price: $97,523.45
24h Change: +2.47%
Volume: 12,345.67
[2.8s] 📊 TICKER UPDATE - ETHUSDT
Price: $3,650.00
Bid/Ask: $3,649.50 / $3,650.50
24h: +3.56%
High/Low: $3,700.00 / $3,500.00
[3.1s] 📈 KLINE UPDATE - BTCUSDT (1m)
O: $97500.0 H: $97600.0 L: $97400.0 C: $97523.45
Volume: 123.4500
Status: ⏳ Updating
```
### 8.3 Opción 2: Dashboard HTML (Browser)
1. Abrir en navegador: `test-websocket.html`
2. Click en "Connect"
3. Suscribirse a canales desde la UI
**Características:**
- ✅ UI visual interactiva
- ✅ Estadísticas en tiempo real
- ✅ Suscripción dinámica
- ✅ Log coloreado de mensajes
### 8.4 Opción 3: wscat
```bash
npm install -g wscat
wscat -c ws://localhost:3000/ws
> {"type":"subscribe","channels":["price:BTCUSDT"]}
< {"type":"subscribed","channel":"price:BTCUSDT","timestamp":"..."}
< {"type":"price","channel":"price:BTCUSDT","data":{...}}
```
### 8.5 Verificar Estadísticas del Servidor
```bash
curl http://localhost:3000/api/v1/ws/stats
```
**Response:**
```json
{
"success": true,
"data": {
"connectedClients": 2,
"activeChannels": ["price:BTCUSDT", "klines:ETHUSDT:1m"],
"quoteStreams": 0,
"signalStreams": 0,
"binanceStreams": 2,
"binanceActiveStreams": ["btcusdt@ticker", "ethusdt@kline_1m"],
"priceCache": 2
}
}
```
---
## 9. EJEMPLOS DE MENSAJES
### 9.1 Price Update (del spec)
```json
{
"type": "price",
"symbol": "BTCUSDT",
"data": {
"price": 97523.45,
"change24h": 2345.67,
"changePercent24h": 2.47,
"high24h": 98500.00,
"low24h": 95000.00,
"volume24h": 12345.67,
"timestamp": 1701806400000
}
}
```
### 9.2 Kline Update (del spec)
```json
{
"type": "kline",
"symbol": "BTCUSDT",
"interval": "1m",
"data": {
"time": 1701806400,
"open": 97500,
"high": 97600,
"low": 97400,
"close": 97523.45,
"volume": 123.45
}
}
```
### 9.3 Pong Response (del spec)
```json
{
"type": "pong",
"timestamp": 1701806400000
}
```
---
## 10. CRITERIOS DE ACEPTACIÓN
| Criterio | Estado | Notas |
|----------|--------|-------|
| WebSocket server escucha en `/ws` | ✅ CUMPLIDO | Configurado en `index.ts` |
| Clientes pueden suscribirse a precios de símbolos | ✅ CUMPLIDO | Canales: price, ticker, klines |
| Updates de precio se envían cada 1-2 segundos | ✅ CUMPLIDO | En tiempo real desde Binance |
| Heartbeat/ping-pong funciona | ✅ CUMPLIDO | Implementado en `websocket.server.ts` |
| Sin memory leaks (cleanup de clientes desconectados) | ✅ CUMPLIDO | Cleanup automático en `handleDisconnect()` |
| `npm run build` pasa sin errores | ✅ CUMPLIDO | Build exitoso |
| `npm run lint` pasa o solo warnings no críticos | ⚠️ PARCIAL | No hay config de ESLint (no crítico) |
---
## 11. PROBLEMAS ENCONTRADOS Y SOLUCIONES
### 11.1 Problema: Sistema ya tenía WebSocket implementado
**Solución:**
- No se crearon archivos nuevos desde cero
- Se mejoró el sistema existente agregando integración directa con Binance
- Se mantuvieron interfaces compatibles con el código existente
### 11.2 Problema: ESLint no configurado
**Solución:**
- No es crítico para funcionalidad
- TypeScript compiler y `tsc --noEmit` proporcionan validación suficiente
- Se puede configurar ESLint posteriormente si es necesario
### 11.3 Problema: Múltiples canales para el mismo propósito
**Solución:**
- Se mantuvieron canales compatibles hacia atrás (`quotes`)
- Se agregaron nuevos canales específicos (`price`, `ticker`, `klines`)
- Todos usan los mismos streams de Binance internamente
---
## 12. VENTAJAS DE LA IMPLEMENTACIÓN
### 12.1 Rendimiento
- ✅ **Latencia reducida**: Datos directos de Binance sin polling
- ✅ **Menos carga en servidor**: Event-driven vs polling cada 1s
- ✅ **Escalable**: Un stream de Binance sirve a múltiples clientes
### 12.2 Confiabilidad
- ✅ **Reconexión automática**: Binance service maneja desconexiones
- ✅ **Heartbeat**: Detecta conexiones muertas (30s interval)
- ✅ **Error handling**: Fallback a datos mock si Binance falla
### 12.3 Funcionalidad
- ✅ **Múltiples tipos de datos**: Price, ticker, klines, trades, depth
- ✅ **Múltiples intervalos**: Klines soporta 14 intervalos diferentes
- ✅ **Cache inteligente**: Respuestas inmediatas en nueva suscripción
### 12.4 Mantenibilidad
- ✅ **Código organizado**: Separación clara de responsabilidades
- ✅ **TypeScript**: Type safety completo
- ✅ **Documentación**: Guías completas y ejemplos
- ✅ **Testing**: Scripts de prueba incluidos
---
## 13. SIGUIENTES PASOS RECOMENDADOS
### 13.1 Integración Frontend
```typescript
// En el frontend (React/Vue/Angular)
const ws = new WebSocket('ws://localhost:3000/ws');
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'subscribe',
channels: ['price:BTCUSDT', 'klines:ETHUSDT:5m']
}));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'price') {
updatePriceDisplay(msg.data);
} else if (msg.type === 'kline') {
updateChart(msg.data);
}
};
```
### 13.2 Autenticación para Canales Privados
- Implementar JWT en query string: `ws://localhost:3000/ws?token=<JWT>`
- Ya soportado en `websocket.server.ts` (línea 82-92)
- Canales privados: `portfolio:`, `orders:`, `account:`
### 13.3 Rate Limiting
- Limitar número de subscripciones por cliente (ya hay `MAX_SYMBOLS_PER_CLIENT = 50`)
- Limitar frecuencia de mensajes
### 13.4 Monitoring
- Agregar métricas de Prometheus
- Dashboard de Grafana para WebSocket stats
- Alertas por desconexiones frecuentes
---
## 14. RECURSOS Y REFERENCIAS
### Documentación Creada
- `/apps/backend/WEBSOCKET_TESTING.md` - Guía completa de uso
- `/apps/backend/WEBSOCKET_IMPLEMENTATION_REPORT.md` - Este documento
### Scripts de Testing
- `/apps/backend/test-websocket.js` - Cliente CLI
- `/apps/backend/test-websocket.html` - Dashboard web
### Código Fuente Modificado
- `/apps/backend/src/core/websocket/trading-stream.service.ts`
- `/apps/backend/src/core/websocket/index.ts`
### APIs Externas
- Binance WebSocket Streams: https://binance-docs.github.io/apidocs/spot/en/#websocket-market-streams
- Binance API Documentation: https://binance-docs.github.io/apidocs/
---
## 15. CONTACTO Y SOPORTE
**Para problemas o preguntas:**
1. Revisar logs del backend: `npm run dev` (muestra logs en tiempo real)
2. Verificar estadísticas: `GET /api/v1/ws/stats`
3. Verificar salud del servidor: `GET /health`
4. Consultar `WEBSOCKET_TESTING.md` para troubleshooting
**Logs importantes a revisar:**
- `[WS]` - WebSocket server events
- `[TradingStream]` - Trading stream service events
- `[Binance WS]` - Binance WebSocket events
---
## 16. CONCLUSIÓN
✅ **Estado: COMPLETADO**
Se ha implementado exitosamente un sistema completo de WebSocket para actualizaciones en tiempo real, superando los requisitos originales:
**Requerimientos Originales:**
- ✅ WebSocket server para enviar actualizaciones de precios
- ✅ Soporte para canales de suscripción
- ✅ Integración con Binance WebSocket
**Extras Implementados:**
- ✅ Múltiples tipos de canales (price, ticker, klines, trades, depth)
- ✅ Cache de precios para respuestas inmediatas
- ✅ Documentación completa y scripts de testing
- ✅ Dashboard web interactivo
- ✅ Estadísticas en tiempo real
- ✅ Manejo robusto de errores y reconexión
El sistema está listo para producción y puede escalar para soportar cientos de conexiones simultáneas.
---
**Implementado por:** Backend-Agent (Claude Code)
**Fecha de finalización:** 2024-12-07
**Versión:** 1.0.0

View File

@ -1,648 +0,0 @@
# WebSocket Testing Guide - OrbiQuant Trading Platform
## Overview
The OrbiQuant backend now supports real-time market data via WebSocket, powered by direct integration with Binance WebSocket streams. This guide explains how to test and use the WebSocket API.
## WebSocket Endpoint
```
ws://localhost:3000/ws
```
For production:
```
wss://your-domain.com/ws
```
## Authentication (Optional)
To access private channels (portfolio, orders, user-specific data), include a JWT token in the query string:
```
ws://localhost:3000/ws?token=YOUR_JWT_TOKEN
```
Public market data channels do not require authentication.
## Message Format
### Client to Server
All messages must be valid JSON with a `type` field:
```json
{
"type": "subscribe",
"channels": ["price:BTCUSDT"]
}
```
### Server to Client
Server responses include a `type`, optional `channel`, and `timestamp`:
```json
{
"type": "price",
"channel": "price:BTCUSDT",
"data": {
"symbol": "BTCUSDT",
"price": 97523.45,
"change24h": 2345.67,
"changePercent24h": 2.47,
"high24h": 98500.00,
"low24h": 95000.00,
"volume24h": 12345.67,
"timestamp": 1701806400000
},
"timestamp": "2024-12-06T12:00:00.000Z"
}
```
## Available Channels
### 1. Price Updates (`price:<symbol>`)
Real-time price updates for a specific symbol (via Binance ticker stream).
**Subscribe:**
```json
{
"type": "subscribe",
"channels": ["price:BTCUSDT"]
}
```
**Data received:**
```json
{
"type": "price",
"channel": "price:BTCUSDT",
"data": {
"symbol": "BTCUSDT",
"price": 97523.45,
"change24h": 2345.67,
"changePercent24h": 2.47,
"high24h": 98500.00,
"low24h": 95000.00,
"volume24h": 12345.67,
"timestamp": 1701806400000
}
}
```
### 2. Ticker Updates (`ticker:<symbol>`)
Full 24h ticker statistics (bid, ask, volume, etc.).
**Subscribe:**
```json
{
"type": "subscribe",
"channels": ["ticker:ETHUSDT"]
}
```
**Data received:**
```json
{
"type": "ticker",
"channel": "ticker:ETHUSDT",
"data": {
"symbol": "ETHUSDT",
"price": 3650.00,
"bid": 3649.50,
"ask": 3650.50,
"volume": 123456.78,
"change": 125.50,
"changePercent": 3.56,
"high": 3700.00,
"low": 3500.00,
"open": 3524.50,
"previousClose": 3524.50,
"timestamp": "2024-12-06T12:00:00.000Z"
}
}
```
### 3. Klines/Candlesticks (`klines:<symbol>:<interval>`)
Real-time candlestick data at specified intervals.
**Intervals:** `1m`, `3m`, `5m`, `15m`, `30m`, `1h`, `2h`, `4h`, `6h`, `12h`, `1d`, `3d`, `1w`, `1M`
**Subscribe:**
```json
{
"type": "subscribe",
"channels": ["klines:BTCUSDT:1m"]
}
```
**Data received:**
```json
{
"type": "kline",
"channel": "klines:BTCUSDT:1m",
"data": {
"symbol": "BTCUSDT",
"interval": "1m",
"time": 1701806400000,
"open": 97500.00,
"high": 97600.00,
"low": 97400.00,
"close": 97523.45,
"volume": 123.45,
"isFinal": false,
"timestamp": "2024-12-06T12:00:00.000Z"
}
}
```
**Note:** `isFinal: true` indicates the candle is closed and will not change.
### 4. Trades (`trades:<symbol>`)
Individual trade executions as they happen.
**Subscribe:**
```json
{
"type": "subscribe",
"channels": ["trades:BTCUSDT"]
}
```
**Data received:**
```json
{
"type": "trade",
"channel": "trades:BTCUSDT",
"data": {
"symbol": "BTCUSDT",
"price": 97523.45,
"quantity": 0.5,
"side": "buy",
"timestamp": "2024-12-06T12:00:00.000Z"
}
}
```
### 5. Depth/Order Book (`depth:<symbol>`)
Order book depth updates (top 10 levels by default).
**Subscribe:**
```json
{
"type": "subscribe",
"channels": ["depth:BTCUSDT"]
}
```
**Data received:**
```json
{
"type": "depth",
"channel": "depth:BTCUSDT",
"data": {
"symbol": "BTCUSDT",
"bids": [
[97520.00, 1.5],
[97519.00, 2.3]
],
"asks": [
[97521.00, 0.8],
[97522.00, 1.2]
],
"timestamp": "2024-12-06T12:00:00.000Z"
}
}
```
### 6. ML Signals (`signals:<symbol>`)
AI-powered trading signals (requires backend ML service).
**Subscribe:**
```json
{
"type": "subscribe",
"channels": ["signals:BTCUSDT"]
}
```
## Client Messages
### Subscribe
```json
{
"type": "subscribe",
"channels": ["price:BTCUSDT", "ticker:ETHUSDT", "klines:SOLUSDT:5m"]
}
```
### Unsubscribe
```json
{
"type": "unsubscribe",
"channels": ["price:BTCUSDT"]
}
```
### Ping (Keepalive)
```json
{
"type": "ping"
}
```
**Response:**
```json
{
"type": "pong",
"timestamp": "2024-12-06T12:00:00.000Z"
}
```
## Testing Tools
### 1. Using `wscat` (Node.js)
Install:
```bash
npm install -g wscat
```
Connect:
```bash
wscat -c ws://localhost:3000/ws
```
Send messages:
```
> {"type":"subscribe","channels":["price:BTCUSDT"]}
< {"type":"subscribed","channel":"price:BTCUSDT","timestamp":"..."}
< {"type":"price","channel":"price:BTCUSDT","data":{...}}
```
### 2. Using `websocat` (Rust)
Install:
```bash
# macOS
brew install websocat
# Linux
cargo install websocat
```
Connect:
```bash
websocat ws://localhost:3000/ws
```
### 3. Using Browser JavaScript
```html
<!DOCTYPE html>
<html>
<head>
<title>WebSocket Test</title>
</head>
<body>
<h1>OrbiQuant WebSocket Test</h1>
<div id="output"></div>
<script>
const ws = new WebSocket('ws://localhost:3000/ws');
const output = document.getElementById('output');
ws.onopen = () => {
console.log('Connected to WebSocket');
output.innerHTML += '<p>Connected!</p>';
// Subscribe to price updates
ws.send(JSON.stringify({
type: 'subscribe',
channels: ['price:BTCUSDT', 'klines:ETHUSDT:1m']
}));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
console.log('Received:', msg);
output.innerHTML += `<p>${msg.type}: ${JSON.stringify(msg.data || {})}</p>`;
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
output.innerHTML += '<p>Error occurred</p>';
};
ws.onclose = () => {
console.log('Disconnected');
output.innerHTML += '<p>Disconnected</p>';
};
// Send ping every 30 seconds
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
</script>
</body>
</html>
```
### 4. Using Python
```python
import asyncio
import websockets
import json
async def test_websocket():
uri = "ws://localhost:3000/ws"
async with websockets.connect(uri) as websocket:
# Subscribe to channels
await websocket.send(json.dumps({
"type": "subscribe",
"channels": ["price:BTCUSDT", "klines:BTCUSDT:1m"]
}))
# Listen for messages
while True:
message = await websocket.recv()
data = json.loads(message)
print(f"Received: {data['type']}")
if 'data' in data:
print(f" Data: {data['data']}")
asyncio.run(test_websocket())
```
## WebSocket Stats Endpoint
Check WebSocket server statistics:
```bash
curl http://localhost:3000/api/v1/ws/stats
```
**Response:**
```json
{
"success": true,
"data": {
"connectedClients": 5,
"activeChannels": ["price:BTCUSDT", "klines:ETHUSDT:1m"],
"quoteStreams": 0,
"signalStreams": 2,
"binanceStreams": 3,
"binanceActiveStreams": ["btcusdt@ticker", "ethusdt@kline_1m"],
"priceCache": 2
}
}
```
## Common Symbols
- `BTCUSDT` - Bitcoin/USDT
- `ETHUSDT` - Ethereum/USDT
- `BNBUSDT` - Binance Coin/USDT
- `SOLUSDT` - Solana/USDT
- `XRPUSDT` - Ripple/USDT
- `DOGEUSDT` - Dogecoin/USDT
- `ADAUSDT` - Cardano/USDT
- `AVAXUSDT` - Avalanche/USDT
## Error Handling
### Connection Errors
If connection fails, check:
1. Backend server is running on port 3000
2. WebSocket path is `/ws`
3. No firewall blocking the connection
### Subscription Errors
```json
{
"type": "error",
"channel": "price:INVALID",
"data": {
"message": "Failed to fetch quote for INVALID"
}
}
```
### Authentication Errors
For private channels without valid token:
```json
{
"type": "error",
"channel": "portfolio:user123",
"data": {
"message": "Authentication required for this channel"
}
}
```
## Best Practices
1. **Heartbeat**: Send ping every 30 seconds to keep connection alive
2. **Reconnection**: Implement exponential backoff for reconnections
3. **Subscription Limit**: Don't subscribe to too many symbols at once (max 50 per client)
4. **Clean Disconnect**: Unsubscribe before closing connection
5. **Error Handling**: Always handle `error` messages from server
## Example: Complete Trading Dashboard
```javascript
class TradingDashboard {
constructor() {
this.ws = null;
this.subscriptions = new Set();
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
}
connect() {
this.ws = new WebSocket('ws://localhost:3000/ws');
this.ws.onopen = () => {
console.log('Connected to trading server');
this.reconnectAttempts = 0;
this.resubscribe();
};
this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
this.handleMessage(msg);
};
this.ws.onclose = () => {
console.log('Disconnected from server');
this.attemptReconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
// Heartbeat
setInterval(() => this.ping(), 30000);
}
subscribe(channel) {
this.subscriptions.add(channel);
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'subscribe',
channels: [channel]
}));
}
}
unsubscribe(channel) {
this.subscriptions.delete(channel);
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'unsubscribe',
channels: [channel]
}));
}
}
resubscribe() {
if (this.subscriptions.size > 0) {
this.ws.send(JSON.stringify({
type: 'subscribe',
channels: Array.from(this.subscriptions)
}));
}
}
ping() {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'ping' }));
}
}
attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnect attempts reached');
return;
}
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
setTimeout(() => this.connect(), delay);
}
handleMessage(msg) {
switch (msg.type) {
case 'connected':
console.log('Server says: connected', msg.data);
break;
case 'price':
this.updatePrice(msg.data);
break;
case 'kline':
this.updateChart(msg.data);
break;
case 'ticker':
this.updateTicker(msg.data);
break;
case 'trade':
this.addTrade(msg.data);
break;
case 'error':
console.error('Server error:', msg.data);
break;
case 'pong':
// Heartbeat acknowledged
break;
default:
console.log('Unknown message type:', msg.type);
}
}
updatePrice(data) {
console.log(`Price update: ${data.symbol} = $${data.price}`);
// Update UI
}
updateChart(data) {
console.log(`Kline update: ${data.symbol} ${data.interval}`);
// Update chart
}
updateTicker(data) {
console.log(`Ticker update: ${data.symbol}`);
// Update ticker display
}
addTrade(data) {
console.log(`New trade: ${data.symbol} ${data.side} ${data.quantity} @ ${data.price}`);
// Add to trades list
}
disconnect() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
}
// Usage
const dashboard = new TradingDashboard();
dashboard.connect();
dashboard.subscribe('price:BTCUSDT');
dashboard.subscribe('klines:ETHUSDT:1m');
dashboard.subscribe('trades:SOLUSDT');
```
## Troubleshooting
### No Data Received
1. Check if Binance API is accessible from your server
2. Verify symbol format (must be uppercase, e.g., `BTCUSDT`)
3. Check backend logs for connection errors
### High Latency
1. Ensure server is geographically close to Binance servers
2. Check network connection quality
3. Reduce number of subscriptions
### Disconnections
1. Implement heartbeat/ping mechanism
2. Check server logs for errors
3. Verify no rate limiting from Binance
## Support
For issues or questions:
- Check backend logs: `npm run dev` (shows real-time logs)
- WebSocket stats endpoint: `GET /api/v1/ws/stats`
- Server health: `GET /health`
---
**Last Updated:** December 6, 2024
**Backend Version:** 0.1.0
**WebSocket Protocol:** RFC 6455

View File

@ -1,29 +0,0 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import globals from 'globals';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ['dist/**', 'node_modules/**', 'coverage/**'],
},
{
files: ['**/*.ts'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
...globals.node,
...globals.jest,
},
},
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'@typescript-eslint/no-empty-function': 'off',
},
}
);

View File

@ -1,37 +0,0 @@
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: [
'**/__tests__/**/*.test.ts',
'**/__tests__/**/*.spec.ts',
'**/?(*.)+(spec|test).ts'
],
testPathIgnorePatterns: [
'/node_modules/',
'/__tests__/mocks/',
'/__tests__/setup.ts'
],
moduleFileExtensions: ['ts', 'js', 'json'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/*.spec.ts',
'!src/**/*.test.ts',
'!src/**/index.ts'
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
verbose: true,
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
transform: {
'^.+\\.ts$': ['ts-jest', {}]
},
setupFilesAfterEnv: ['<rootDir>/src/__tests__/setup.ts']
};
export default config;

File diff suppressed because it is too large Load Diff

View File

@ -1,90 +0,0 @@
{
"name": "@orbiquant/backend",
"version": "0.1.0",
"description": "OrbiQuant IA - Backend API",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"format": "prettier --write \"src/**/*.ts\"",
"test": "jest --passWithNoTests",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage --passWithNoTests",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.71.2",
"axios": "^1.6.2",
"bcryptjs": "^3.0.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"dotenv": "^16.4.7",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"express-validator": "^7.0.1",
"google-auth-library": "^9.4.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"nodemailer": "^7.0.11",
"openai": "^4.104.0",
"passport": "^0.7.0",
"passport-apple": "^2.0.2",
"passport-facebook": "^3.0.0",
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"passport-local": "^1.0.0",
"pg": "^8.11.3",
"qrcode": "^1.5.3",
"speakeasy": "^2.0.0",
"stripe": "^17.5.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"twilio": "^4.19.3",
"uuid": "^9.0.1",
"winston": "^3.11.0",
"ws": "^8.18.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/bcryptjs": "^2.4.6",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.5",
"@types/morgan": "^1.9.9",
"@types/node": "^20.10.4",
"@types/nodemailer": "^6.4.14",
"@types/passport": "^1.0.16",
"@types/passport-facebook": "^3.0.3",
"@types/passport-github2": "^1.2.9",
"@types/passport-google-oauth20": "^2.0.14",
"@types/passport-local": "^1.0.38",
"@types/pg": "^8.10.9",
"@types/qrcode": "^1.5.5",
"@types/speakeasy": "^2.0.10",
"@types/supertest": "^2.0.16",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"@types/uuid": "^9.0.7",
"@types/ws": "^8.5.13",
"eslint": "^9.17.0",
"globals": "^15.14.0",
"jest": "^30.0.0",
"prettier": "^3.1.1",
"supertest": "^6.3.3",
"ts-jest": "^29.3.0",
"tsx": "^4.6.2",
"typescript": "^5.3.3",
"typescript-eslint": "^8.18.0"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@ -1,54 +0,0 @@
# ==============================================================================
# SERVICE DESCRIPTOR - TRADING PLATFORM API
# ==============================================================================
version: "1.0.0"
service:
name: "trading-api"
display_name: "Trading Platform API"
description: "API para plataforma de trading"
type: "backend"
runtime: "node"
framework: "nestjs"
owner_agent: "NEXUS-BACKEND"
ports:
internal: 3040
registry_ref: "projects.trading.services.api"
protocol: "http"
database:
registry_ref: "trading"
role: "runtime"
modules:
market_data:
description: "Datos de mercado"
status: "planned"
alerts:
description: "Sistema de alertas"
status: "planned"
portfolio:
description: "Gestion de portafolio"
status: "planned"
docker:
networks:
- "trading_${ENV:-local}"
- "infra_shared"
labels:
traefik:
enable: true
rule: "Host(`api.trading.localhost`)"
healthcheck:
endpoint: "/health"
status:
phase: "planned"
version: "0.0.1"
completeness: 5
metadata:
created_at: "2025-12-18"
project: "trading-platform"

View File

@ -1,35 +0,0 @@
/**
* Jest 30 Migration Test
*
* This test verifies that Jest 30 is working correctly after migration.
* It tests new features and ensures deprecated methods are not being used.
*/
describe('Jest 30 Migration', () => {
test('should pass with Jest 30', () => {
expect(true).toBe(true);
});
test('should support modern Jest matchers', () => {
const mockFn = jest.fn();
mockFn('test');
// Using toHaveBeenCalled instead of deprecated toBeCalled
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledWith('test');
});
test('should work with async tests', async () => {
const promise = Promise.resolve('success');
await expect(promise).resolves.toBe('success');
});
test('should support mock functions', () => {
const mockCallback = jest.fn((x) => x * 2);
[1, 2, 3].forEach(mockCallback);
expect(mockCallback).toHaveBeenCalledTimes(3);
expect(mockCallback.mock.results[0].value).toBe(2);
});
});

View File

@ -1,101 +0,0 @@
/**
* Database Mock for Testing
*
* Provides mock implementations of database operations.
*/
import { QueryResult, PoolClient } from 'pg';
/**
* Mock database query results
*/
export const createMockQueryResult = <T>(rows: T[] = []): QueryResult<T> => ({
rows,
command: 'SELECT',
rowCount: rows.length,
oid: 0,
fields: [],
});
/**
* Mock PoolClient for transaction testing
*/
export const createMockPoolClient = (): jest.Mocked<PoolClient> => ({
query: jest.fn(),
release: jest.fn(),
connect: jest.fn(),
on: jest.fn(),
removeListener: jest.fn(),
emit: jest.fn(),
eventNames: jest.fn(),
listenerCount: jest.fn(),
listeners: jest.fn(),
off: jest.fn(),
addListener: jest.fn(),
once: jest.fn(),
prependListener: jest.fn(),
prependOnceListener: jest.fn(),
removeAllListeners: jest.fn(),
setMaxListeners: jest.fn(),
getMaxListeners: jest.fn(),
rawListeners: jest.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
/**
* Mock database instance
*/
export const mockDb = {
query: jest.fn(),
getClient: jest.fn(),
transaction: jest.fn(),
healthCheck: jest.fn(),
close: jest.fn(),
getPoolStatus: jest.fn(),
};
/**
* Setup database mock with default behaviors
*/
export const setupDatabaseMock = () => {
const mockClient = createMockPoolClient();
// Default implementations
mockDb.query.mockResolvedValue(createMockQueryResult([]));
mockDb.getClient.mockResolvedValue(mockClient);
mockDb.transaction.mockImplementation(async (callback) => {
return callback(mockClient);
});
mockDb.healthCheck.mockResolvedValue(true);
mockDb.getPoolStatus.mockReturnValue({
total: 10,
idle: 5,
waiting: 0,
});
// Mock client methods
mockClient.query.mockResolvedValue(createMockQueryResult([]));
return { mockDb, mockClient };
};
/**
* Reset all database mocks
*/
export const resetDatabaseMocks = () => {
mockDb.query.mockClear();
mockDb.getClient.mockClear();
mockDb.transaction.mockClear();
mockDb.healthCheck.mockClear();
mockDb.close.mockClear();
mockDb.getPoolStatus.mockClear();
};
// Export for use in test files
export { mockDb };
// Note: Tests should import mockDb and manually mock the database module
// in their test file using:
// jest.mock('path/to/database', () => ({
// db: mockDb,
// }));

View File

@ -1,79 +0,0 @@
/**
* Email Mock for Testing
*
* Provides mock implementations for nodemailer.
*/
/**
* Mock sent emails storage
*/
export const sentEmails: Array<{
from: string;
to: string;
subject: string;
html: string;
timestamp: Date;
}> = [];
/**
* Mock transporter
*/
export const mockTransporter = {
sendMail: jest.fn().mockImplementation((mailOptions) => {
sentEmails.push({
from: mailOptions.from,
to: mailOptions.to,
subject: mailOptions.subject,
html: mailOptions.html,
timestamp: new Date(),
});
return Promise.resolve({
messageId: `mock-message-${Date.now()}@example.com`,
accepted: [mailOptions.to],
rejected: [],
response: '250 Message accepted',
});
}),
verify: jest.fn().mockResolvedValue(true),
};
/**
* Mock nodemailer
*/
export const mockNodemailer = {
createTransport: jest.fn().mockReturnValue(mockTransporter),
};
/**
* Reset email mocks
*/
export const resetEmailMocks = () => {
sentEmails.length = 0;
mockTransporter.sendMail.mockClear();
mockTransporter.verify.mockClear();
mockNodemailer.createTransport.mockClear();
};
/**
* Get sent emails
*/
export const getSentEmails = () => sentEmails;
/**
* Find email by recipient
*/
export const findEmailByRecipient = (email: string) => {
return sentEmails.find((e) => e.to === email);
};
/**
* Find email by subject
*/
export const findEmailBySubject = (subject: string) => {
return sentEmails.find((e) => e.subject.includes(subject));
};
// Mock nodemailer module
jest.mock('nodemailer', () => mockNodemailer);

View File

@ -1,97 +0,0 @@
/**
* Redis Mock for Testing
*
* Provides mock implementations for Redis operations.
*/
/**
* In-memory store for testing Redis operations
*/
class MockRedisStore {
private store = new Map<string, { value: string; expiresAt: number | null }>();
async get(key: string): Promise<string | null> {
const entry = this.store.get(key);
if (!entry) return null;
if (entry.expiresAt && Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value;
}
async setex(key: string, seconds: number, value: string): Promise<string> {
this.store.set(key, {
value,
expiresAt: Date.now() + seconds * 1000,
});
return 'OK';
}
async set(key: string, value: string): Promise<string> {
this.store.set(key, {
value,
expiresAt: null,
});
return 'OK';
}
async del(key: string): Promise<number> {
const deleted = this.store.delete(key);
return deleted ? 1 : 0;
}
async exists(key: string): Promise<number> {
const entry = this.store.get(key);
if (!entry) return 0;
if (entry.expiresAt && Date.now() > entry.expiresAt) {
this.store.delete(key);
return 0;
}
return 1;
}
async ttl(key: string): Promise<number> {
const entry = this.store.get(key);
if (!entry) return -2;
if (!entry.expiresAt) return -1;
const remaining = Math.floor((entry.expiresAt - Date.now()) / 1000);
return remaining > 0 ? remaining : -2;
}
async flushall(): Promise<string> {
this.store.clear();
return 'OK';
}
async quit(): Promise<string> {
this.store.clear();
return 'OK';
}
clear() {
this.store.clear();
}
// For debugging
getStore() {
return this.store;
}
}
/**
* Export singleton instance
*/
export const mockRedisClient = new MockRedisStore();
/**
* Reset mock Redis store
*/
export const resetRedisMock = () => {
mockRedisClient.clear();
};

View File

@ -1,115 +0,0 @@
/**
* Jest Test Setup
*
* Global test configuration and environment setup for all test suites.
* This file runs before all tests.
*/
// Set test environment
process.env.NODE_ENV = 'test';
// Set test config values
process.env.JWT_ACCESS_SECRET = 'test-access-secret';
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
process.env.JWT_ACCESS_EXPIRES = '15m';
process.env.JWT_REFRESH_EXPIRES = '7d';
process.env.DB_HOST = 'localhost';
process.env.DB_PORT = '5432';
process.env.DB_NAME = 'test_db';
process.env.DB_USER = 'test_user';
process.env.DB_PASSWORD = 'test_password';
process.env.FRONTEND_URL = 'http://localhost:3000';
process.env.EMAIL_HOST = 'smtp.test.com';
process.env.EMAIL_PORT = '587';
process.env.EMAIL_FROM = 'test@test.com';
// Configure test timeouts
jest.setTimeout(10000); // 10 seconds
// Mock logger to prevent console spam during tests
jest.mock('../shared/utils/logger', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
// Global test utilities
global.testUtils = {
/**
* Generate a valid test email
*/
generateTestEmail: () => `test-${Date.now()}@example.com`,
/**
* Generate a strong test password
*/
generateTestPassword: () => 'TestPass123!',
/**
* Create a mock user object
*/
createMockUser: (overrides = {}) => ({
id: 'test-user-id',
email: 'test@example.com',
emailVerified: true,
phoneVerified: false,
primaryAuthProvider: 'email' as const,
totpEnabled: false,
role: 'investor' as const,
status: 'active' as const,
failedLoginAttempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
}),
/**
* Create a mock profile object
*/
createMockProfile: (overrides = {}) => ({
id: 'test-profile-id',
userId: 'test-user-id',
firstName: 'John',
lastName: 'Doe',
displayName: 'John Doe',
timezone: 'UTC',
language: 'en',
preferredCurrency: 'USD',
...overrides,
}),
/**
* Create a mock session object
*/
createMockSession: (overrides = {}) => ({
id: 'test-session-id',
userId: 'test-user-id',
refreshToken: 'mock-refresh-token',
userAgent: 'Mozilla/5.0',
ipAddress: '127.0.0.1',
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
lastActiveAt: new Date(),
...overrides,
}),
};
// Declare global TypeScript types
declare global {
// eslint-disable-next-line no-var
var testUtils: {
generateTestEmail: () => string;
generateTestPassword: () => string;
createMockUser: (overrides?: Record<string, unknown>) => Record<string, unknown>;
createMockProfile: (overrides?: Record<string, unknown>) => Record<string, unknown>;
createMockSession: (overrides?: Record<string, unknown>) => Record<string, unknown>;
};
}
// Clean up after all tests
afterAll(() => {
jest.clearAllMocks();
});

View File

@ -1,119 +0,0 @@
/**
* Application Configuration
*/
import dotenv from 'dotenv';
dotenv.config();
export const config = {
app: {
name: 'OrbiQuant IA',
version: '0.1.0',
env: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '3000', 10),
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
apiUrl: process.env.API_URL || 'http://localhost:3000',
},
cors: {
origins: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:5173'],
},
jwt: {
accessSecret: process.env.JWT_ACCESS_SECRET || 'your-access-secret-change-in-production',
refreshSecret: process.env.JWT_REFRESH_SECRET || 'your-refresh-secret-change-in-production',
accessExpiry: process.env.JWT_ACCESS_EXPIRES || '15m',
refreshExpiry: process.env.JWT_REFRESH_EXPIRES || '7d',
},
database: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
name: process.env.DB_NAME || 'orbiquant',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
ssl: process.env.DB_SSL === 'true',
poolMax: parseInt(process.env.DB_POOL_MAX || '20', 10),
idleTimeout: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
connectionTimeout: parseInt(process.env.DB_CONNECTION_TIMEOUT || '5000', 10),
},
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD,
},
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY || '',
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '',
},
mlEngine: {
baseUrl: process.env.ML_ENGINE_URL || 'http://localhost:8001',
timeout: parseInt(process.env.ML_ENGINE_TIMEOUT || '5000', 10),
},
rateLimit: {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10),
max: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
},
email: {
host: process.env.EMAIL_HOST || 'smtp.gmail.com',
port: parseInt(process.env.EMAIL_PORT || '587', 10),
secure: process.env.EMAIL_SECURE === 'true',
user: process.env.EMAIL_USER || '',
password: process.env.EMAIL_PASSWORD || '',
from: process.env.EMAIL_FROM || 'noreply@orbiquant.io',
},
twilio: {
accountSid: process.env.TWILIO_ACCOUNT_SID || '',
authToken: process.env.TWILIO_AUTH_TOKEN || '',
phoneNumber: process.env.TWILIO_PHONE_NUMBER || '',
whatsappNumber: process.env.TWILIO_WHATSAPP_NUMBER || '',
verifyServiceSid: process.env.TWILIO_VERIFY_SERVICE_SID || '',
useVerifyService: process.env.TWILIO_USE_VERIFY_SERVICE === 'true',
},
oauth: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID || '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
callbackUrl: process.env.GOOGLE_CALLBACK_URL || 'http://localhost:3000/api/auth/google/callback',
scope: ['openid', 'email', 'profile'],
},
facebook: {
clientId: process.env.FACEBOOK_CLIENT_ID || '',
clientSecret: process.env.FACEBOOK_CLIENT_SECRET || '',
callbackUrl: process.env.FACEBOOK_CALLBACK_URL || 'http://localhost:3000/api/auth/facebook/callback',
scope: ['email', 'public_profile'],
},
twitter: {
clientId: process.env.TWITTER_CLIENT_ID || '',
clientSecret: process.env.TWITTER_CLIENT_SECRET || '',
callbackUrl: process.env.TWITTER_CALLBACK_URL || 'http://localhost:3000/api/auth/twitter/callback',
scope: ['users.read', 'tweet.read', 'offline.access'],
},
apple: {
clientId: process.env.APPLE_CLIENT_ID || '',
clientSecret: process.env.APPLE_CLIENT_SECRET || '',
teamId: process.env.APPLE_TEAM_ID || '',
keyId: process.env.APPLE_KEY_ID || '',
privateKey: process.env.APPLE_PRIVATE_KEY || '',
callbackUrl: process.env.APPLE_CALLBACK_URL || 'http://localhost:3000/api/auth/apple/callback',
scope: ['name', 'email'],
},
github: {
clientId: process.env.GITHUB_CLIENT_ID || '',
clientSecret: process.env.GITHUB_CLIENT_SECRET || '',
callbackUrl: process.env.GITHUB_CALLBACK_URL || 'http://localhost:3000/api/auth/github/callback',
scope: ['read:user', 'user:email'],
},
},
};
export type Config = typeof config;

View File

@ -1,175 +0,0 @@
/**
* Swagger/OpenAPI Configuration for OrbiQuant IA Trading Platform
*/
import swaggerJSDoc from 'swagger-jsdoc';
import { Express } from 'express';
import swaggerUi from 'swagger-ui-express';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Swagger definition
const swaggerDefinition = {
openapi: '3.0.0',
info: {
title: 'OrbiQuant IA - Trading Platform API',
version: '1.0.0',
description: `
API para la plataforma OrbiQuant IA - Trading y análisis cuantitativo con ML e IA.
## Características principales
- Autenticación OAuth2 y JWT con 2FA
- Trading automatizado y análisis cuantitativo
- Integración con agentes ML/LLM (Python microservices)
- WebSocket para datos de mercado en tiempo real
- Sistema de pagos y suscripciones (Stripe)
- Gestión de portfolios y estrategias de inversión
## Autenticación
La mayoría de los endpoints requieren autenticación mediante Bearer Token (JWT).
Algunos endpoints administrativos requieren API Key.
`,
contact: {
name: 'OrbiQuant Support',
email: 'support@orbiquant.com',
url: 'https://orbiquant.com',
},
license: {
name: 'Proprietary',
},
},
servers: [
{
url: 'http://localhost:3000/api/v1',
description: 'Desarrollo local',
},
{
url: 'https://api.orbiquant.com/api/v1',
description: 'Producción',
},
],
tags: [
{ name: 'Auth', description: 'Autenticación y autorización (JWT, OAuth2, 2FA)' },
{ name: 'Users', description: 'Gestión de usuarios y perfiles' },
{ name: 'Education', description: 'Contenido educativo y cursos de trading' },
{ name: 'Trading', description: 'Operaciones de trading y gestión de órdenes' },
{ name: 'Investment', description: 'Gestión de inversiones y análisis de riesgo' },
{ name: 'Payments', description: 'Pagos, suscripciones y facturación (Stripe)' },
{ name: 'Portfolio', description: 'Gestión de portfolios y activos' },
{ name: 'ML', description: 'Machine Learning Engine - Predicciones y análisis' },
{ name: 'LLM', description: 'Large Language Model Agent - Asistente IA' },
{ name: 'Agents', description: 'Trading Agents automatizados' },
{ name: 'Admin', description: 'Administración del sistema' },
{ name: 'Health', description: 'Health checks y monitoreo' },
{ name: 'WebSocket', description: 'WebSocket endpoints y estadísticas' },
],
components: {
securitySchemes: {
BearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Token JWT obtenido del endpoint de login',
},
ApiKeyAuth: {
type: 'apiKey',
in: 'header',
name: 'X-API-Key',
description: 'API Key para autenticación de servicios externos',
},
},
schemas: {
Error: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: false,
},
error: {
type: 'string',
example: 'Error message',
},
statusCode: {
type: 'number',
example: 400,
},
},
},
SuccessResponse: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: true,
},
data: {
type: 'object',
},
message: {
type: 'string',
},
},
},
},
},
security: [
{
BearerAuth: [],
},
],
};
// Options for swagger-jsdoc
const options: swaggerJSDoc.Options = {
definition: swaggerDefinition,
// Path to the API routes for JSDoc comments
apis: [
path.join(__dirname, '../modules/**/*.routes.ts'),
path.join(__dirname, '../modules/**/*.routes.js'),
path.join(__dirname, '../docs/openapi.yaml'),
],
};
// Initialize swagger-jsdoc
const swaggerSpec = swaggerJSDoc(options);
/**
* Setup Swagger documentation for Express app
*/
export function setupSwagger(app: Express, prefix: string = '/api/v1') {
// Swagger UI options
const swaggerUiOptions = {
customCss: `
.swagger-ui .topbar { display: none }
.swagger-ui .info { margin: 50px 0; }
.swagger-ui .info .title { font-size: 36px; }
`,
customSiteTitle: 'OrbiQuant IA - API Documentation',
swaggerOptions: {
persistAuthorization: true,
displayRequestDuration: true,
filter: true,
tagsSorter: 'alpha',
operationsSorter: 'alpha',
},
};
// Serve Swagger UI
app.use(`${prefix}/docs`, swaggerUi.serve);
app.get(`${prefix}/docs`, swaggerUi.setup(swaggerSpec, swaggerUiOptions));
// Serve OpenAPI spec as JSON
app.get(`${prefix}/docs.json`, (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.send(swaggerSpec);
});
console.log(`📚 Swagger docs available at: http://localhost:${process.env.PORT || 3000}${prefix}/docs`);
console.log(`📄 OpenAPI spec JSON at: http://localhost:${process.env.PORT || 3000}${prefix}/docs.json`);
}
export { swaggerSpec };

View File

@ -1,172 +0,0 @@
/**
* HTTP Exception Filter
* Unified error handling for all API errors
*/
import { Request, Response, NextFunction } from 'express';
import { logger } from '../../shared/utils/logger';
import { HTTP_STATUS } from '../../shared/constants';
import type { ApiResponse, ApiError } from '../../shared/types';
// Custom error class
export class HttpException extends Error {
public readonly statusCode: number;
public readonly code: string;
public readonly field?: string;
public readonly details?: unknown;
constructor(
statusCode: number,
message: string,
code?: string,
field?: string,
details?: unknown
) {
super(message);
this.statusCode = statusCode;
this.code = code || this.getDefaultCode(statusCode);
this.field = field;
this.details = details;
this.name = 'HttpException';
// Maintains proper stack trace for where our error was thrown
Error.captureStackTrace(this, HttpException);
}
private getDefaultCode(statusCode: number): string {
const codes: Record<number, string> = {
[HTTP_STATUS.BAD_REQUEST]: 'BAD_REQUEST',
[HTTP_STATUS.UNAUTHORIZED]: 'UNAUTHORIZED',
[HTTP_STATUS.FORBIDDEN]: 'FORBIDDEN',
[HTTP_STATUS.NOT_FOUND]: 'NOT_FOUND',
[HTTP_STATUS.CONFLICT]: 'CONFLICT',
[HTTP_STATUS.UNPROCESSABLE_ENTITY]: 'VALIDATION_ERROR',
[HTTP_STATUS.TOO_MANY_REQUESTS]: 'RATE_LIMIT_EXCEEDED',
[HTTP_STATUS.INTERNAL_SERVER_ERROR]: 'INTERNAL_ERROR',
[HTTP_STATUS.SERVICE_UNAVAILABLE]: 'SERVICE_UNAVAILABLE',
};
return codes[statusCode] || 'UNKNOWN_ERROR';
}
}
// Specific exception classes
export class BadRequestException extends HttpException {
constructor(message: string, field?: string, details?: unknown) {
super(HTTP_STATUS.BAD_REQUEST, message, 'BAD_REQUEST', field, details);
}
}
export class UnauthorizedException extends HttpException {
constructor(message = 'Unauthorized', code = 'UNAUTHORIZED') {
super(HTTP_STATUS.UNAUTHORIZED, message, code);
}
}
export class ForbiddenException extends HttpException {
constructor(message = 'Forbidden') {
super(HTTP_STATUS.FORBIDDEN, message, 'FORBIDDEN');
}
}
export class NotFoundException extends HttpException {
constructor(resource = 'Resource') {
super(HTTP_STATUS.NOT_FOUND, `${resource} not found`, 'NOT_FOUND');
}
}
export class ConflictException extends HttpException {
constructor(message: string, field?: string) {
super(HTTP_STATUS.CONFLICT, message, 'CONFLICT', field);
}
}
export class ValidationException extends HttpException {
constructor(message: string, field?: string, details?: unknown) {
super(HTTP_STATUS.UNPROCESSABLE_ENTITY, message, 'VALIDATION_ERROR', field, details);
}
}
export class TooManyRequestsException extends HttpException {
constructor(message = 'Too many requests', retryAfter?: number) {
super(HTTP_STATUS.TOO_MANY_REQUESTS, message, 'RATE_LIMIT_EXCEEDED', undefined, { retryAfter });
}
}
// Global exception filter middleware
export function globalExceptionFilter(
error: Error | HttpException,
req: Request,
res: Response,
_next: NextFunction
): void {
const traceId = req.headers['x-request-id'] as string || crypto.randomUUID();
let statusCode: number = HTTP_STATUS.INTERNAL_SERVER_ERROR;
let apiError: ApiError = {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
};
if (error instanceof HttpException) {
statusCode = error.statusCode;
apiError = {
code: error.code,
message: error.message,
field: error.field,
details: error.details,
};
} else if (error.name === 'ValidationError') {
// Handle Zod/validator errors
statusCode = HTTP_STATUS.UNPROCESSABLE_ENTITY;
apiError = {
code: 'VALIDATION_ERROR',
message: error.message,
details: (error as unknown as Record<string, unknown>).errors,
};
} else if (error.name === 'JsonWebTokenError') {
statusCode = HTTP_STATUS.UNAUTHORIZED;
apiError = {
code: 'INVALID_TOKEN',
message: 'Invalid or malformed token',
};
} else if (error.name === 'TokenExpiredError') {
statusCode = HTTP_STATUS.UNAUTHORIZED;
apiError = {
code: 'TOKEN_EXPIRED',
message: 'Token has expired',
};
}
// Log error
const reqUser = (req as unknown as Record<string, unknown>).user;
const userId = reqUser ? (reqUser as Record<string, unknown>).id : undefined;
const logData = {
traceId,
method: req.method,
path: req.path,
statusCode,
errorCode: apiError.code,
message: apiError.message,
userId,
ip: req.ip,
};
if (statusCode >= 500) {
logger.error('Server error', { ...logData, stack: error.stack });
} else if (statusCode >= 400) {
logger.warn('Client error', logData);
}
// Send response
const response: ApiResponse = {
success: false,
error: apiError,
meta: {
traceId,
timestamp: new Date().toISOString(),
},
};
res.status(statusCode).json(response);
}

View File

@ -1,5 +0,0 @@
/**
* Filters - Barrel Export
*/
export * from './http-exception.filter';

View File

@ -1,237 +0,0 @@
/**
* Authentication Guards
* Middleware for protecting routes
*/
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { config } from '../../config';
import { db } from '../../shared/database';
import { UnauthorizedException, ForbiddenException } from '../filters/http-exception.filter';
import { logger } from '../../shared/utils/logger';
import type { AuthenticatedUser, UserRole } from '../../modules/auth/types/auth.types';
import { UserRoleEnum } from '../../modules/auth/types/auth.types';
// Authenticated request type - user is required and has all auth properties
export interface AuthenticatedRequest extends Request {
user: AuthenticatedUser;
}
interface JwtPayload {
sub: string;
email: string;
role: string;
sessionId?: string;
iat: number;
exp: number;
}
/**
* Require authentication
* Validates JWT token and attaches user to request
*/
export async function requireAuth(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('No token provided', 'NO_TOKEN');
}
const token = authHeader.substring(7);
// Verify token
const decoded = jwt.verify(token, config.jwt.accessSecret) as JwtPayload;
// Check if user exists and is active
const userResult = await db.query<{
id: string;
email: string;
role: string;
status: string;
}>(
'SELECT id, email, role, status FROM users WHERE id = $1',
[decoded.sub]
);
if (userResult.rows.length === 0) {
throw new UnauthorizedException('User not found', 'USER_NOT_FOUND');
}
const user = userResult.rows[0];
if (user.status !== 'active') {
throw new UnauthorizedException('Account is not active', 'ACCOUNT_INACTIVE');
}
// Attach user to request (partial user info from guard, full info loaded by middleware)
(req as AuthenticatedRequest).user = {
id: user.id,
email: user.email,
role: user.role as UserRole,
} as AuthenticatedUser;
next();
} catch (error) {
if (error instanceof UnauthorizedException) {
next(error);
} else if ((error as Error).name === 'JsonWebTokenError') {
next(new UnauthorizedException('Invalid token', 'INVALID_TOKEN'));
} else if ((error as Error).name === 'TokenExpiredError') {
next(new UnauthorizedException('Token expired', 'TOKEN_EXPIRED'));
} else {
logger.error('Auth guard error', { error: (error as Error).message });
next(new UnauthorizedException('Authentication failed', 'AUTH_FAILED'));
}
}
}
/**
* Optional authentication
* Attaches user if token is valid, but doesn't require it
*/
export async function optionalAuth(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return next();
}
const token = authHeader.substring(7);
const decoded = jwt.verify(token, config.jwt.accessSecret) as JwtPayload;
const userResult = await db.query<{
id: string;
email: string;
role: string;
status: string;
}>(
'SELECT id, email, role, status FROM users WHERE id = $1 AND status = $2',
[decoded.sub, 'active']
);
if (userResult.rows.length > 0) {
const user = userResult.rows[0];
(req as AuthenticatedRequest).user = {
id: user.id,
email: user.email,
role: user.role as UserRole,
} as AuthenticatedUser;
}
next();
} catch {
// Ignore errors for optional auth
next();
}
}
/**
* Require specific roles
* Must be used after requireAuth
*/
export function requireRoles(...roles: UserRoleEnum[]) {
return (req: Request, res: Response, next: NextFunction): void => {
const authReq = req as AuthenticatedRequest;
if (!authReq.user) {
return next(new UnauthorizedException('Authentication required'));
}
if (!roles.includes(authReq.user.role as UserRoleEnum)) {
return next(new ForbiddenException('Insufficient permissions'));
}
next();
};
}
/**
* Require admin role
*/
export const requireAdmin = requireRoles(UserRoleEnum.ADMIN, UserRoleEnum.SUPER_ADMIN);
/**
* Require instructor role
*/
export const requireInstructor = requireRoles(
UserRoleEnum.INSTRUCTOR,
UserRoleEnum.ADMIN,
UserRoleEnum.SUPER_ADMIN
);
/**
* Resource ownership guard
* Checks if user owns the resource or is admin
*/
export function requireOwnership(
resourceUserIdExtractor: (req: Request) => string | Promise<string>
) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const authReq = req as AuthenticatedRequest;
if (!authReq.user) {
return next(new UnauthorizedException('Authentication required'));
}
// Admins can access any resource
if ([UserRoleEnum.ADMIN, UserRoleEnum.SUPER_ADMIN].includes(authReq.user.role as UserRoleEnum)) {
return next();
}
const resourceUserId = await resourceUserIdExtractor(req);
if (authReq.user.id !== resourceUserId) {
return next(new ForbiddenException('You do not have access to this resource'));
}
next();
} catch (error) {
next(error);
}
};
}
/**
* Rate limit by user
* Applies stricter rate limiting per user
*/
export function userRateLimit(maxRequests: number, windowMs: number) {
const requests = new Map<string, { count: number; resetAt: number }>();
return (req: Request, res: Response, next: NextFunction): void => {
const authReq = req as AuthenticatedRequest;
const key = authReq.user?.id || req.ip || 'anonymous';
const now = Date.now();
let userData = requests.get(key);
if (!userData || now > userData.resetAt) {
userData = { count: 0, resetAt: now + windowMs };
requests.set(key, userData);
}
userData.count++;
if (userData.count > maxRequests) {
res.set('Retry-After', String(Math.ceil((userData.resetAt - now) / 1000)));
return next(
new ForbiddenException(
`Rate limit exceeded. Try again in ${Math.ceil((userData.resetAt - now) / 1000)} seconds`
)
);
}
next();
};
}

View File

@ -1,5 +0,0 @@
/**
* Guards - Barrel Export
*/
export * from './auth.guard';

View File

@ -1,5 +0,0 @@
/**
* Interceptors - Barrel Export
*/
export * from './transform-response.interceptor';

View File

@ -1,108 +0,0 @@
/**
* Transform Response Interceptor
* Wraps all successful responses in a standard format
*/
import { Request, Response, NextFunction } from 'express';
import type { ApiResponse } from '../../shared/types';
/**
* Middleware that wraps successful responses in ApiResponse format
* Usage: app.use(transformResponse);
*/
export function transformResponse(req: Request, res: Response, next: NextFunction): void {
// Store original json method
const originalJson = res.json.bind(res);
// Override json method
res.json = function (data: unknown): Response {
// Don't transform if already in ApiResponse format
if (data && typeof data === 'object' && 'success' in data) {
return originalJson(data);
}
// Don't transform error responses (handled by exception filter)
if (res.statusCode >= 400) {
return originalJson(data);
}
// Wrap successful response
const response: ApiResponse = {
success: true,
data,
meta: {
timestamp: new Date().toISOString(),
requestId: req.headers['x-request-id'] as string || undefined,
},
};
return originalJson(response);
};
next();
}
/**
* Async handler wrapper
* Catches async errors and passes them to error handler
*/
export function asyncHandler(
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);
};
}
/**
* Response helper methods
*/
export const responseHelpers = {
/**
* Send success response with data
*/
success<T>(res: Response, data: T, statusCode = 200): Response {
return res.status(statusCode).json(data);
},
/**
* Send created response
*/
created<T>(res: Response, data: T): Response {
return res.status(201).json(data);
},
/**
* Send no content response
*/
noContent(res: Response): Response {
return res.status(204).send();
},
/**
* Send paginated response
*/
paginated<T>(
res: Response,
data: T[],
pagination: {
page: number;
perPage: number;
total: number;
}
): Response {
const totalPages = Math.ceil(pagination.total / pagination.perPage);
return res.json({
data,
pagination: {
page: pagination.page,
perPage: pagination.perPage,
total: pagination.total,
totalPages,
hasNext: pagination.page < totalPages,
hasPrev: pagination.page > 1,
},
});
},
};

View File

@ -1,206 +0,0 @@
// ============================================================================
// OrbiQuant IA - Authentication Middleware
// ============================================================================
import { Request, Response, NextFunction } from 'express';
import { tokenService } from '../../modules/auth/services/token.service';
import { db } from '../../shared/database';
import type { User, Profile, AuthenticatedUser } from '../../modules/auth/types/auth.types';
// Extend Express Request type
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
user?: AuthenticatedUser;
sessionId?: string;
}
}
}
export const authenticate = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
error: 'No authentication token provided',
});
}
const token = authHeader.substring(7);
const decoded = tokenService.verifyAccessToken(token);
if (!decoded) {
return res.status(401).json({
success: false,
error: 'Invalid or expired token',
});
}
// Get user from database
const userResult = await db.query<User>(
`SELECT id, email, email_verified, phone, phone_verified, primary_auth_provider,
totp_enabled, role, status, last_login_at, created_at, updated_at
FROM users WHERE id = $1`,
[decoded.sub]
);
if (userResult.rows.length === 0) {
return res.status(401).json({
success: false,
error: 'User not found',
});
}
const user = userResult.rows[0];
// Check user status
if (user.status === 'banned' || user.status === 'suspended') {
return res.status(403).json({
success: false,
error: 'Account has been suspended',
});
}
// Get profile
const profileResult = await db.query<Profile>(
'SELECT * FROM profiles WHERE user_id = $1',
[user.id]
);
// Attach user to request
req.user = {
...user,
profile: profileResult.rows[0],
};
next();
} catch {
return res.status(401).json({
success: false,
error: 'Authentication failed',
});
}
};
export const optionalAuth = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return next();
}
const token = authHeader.substring(7);
const decoded = tokenService.verifyAccessToken(token);
if (!decoded) {
return next();
}
const userResult = await db.query<User>(
`SELECT id, email, email_verified, phone, phone_verified, primary_auth_provider,
totp_enabled, role, status, last_login_at, created_at, updated_at
FROM users WHERE id = $1`,
[decoded.sub]
);
if (userResult.rows.length > 0) {
const profileResult = await db.query<Profile>(
'SELECT * FROM profiles WHERE user_id = $1',
[userResult.rows[0].id]
);
req.user = {
...userResult.rows[0],
profile: profileResult.rows[0],
};
}
next();
} catch {
next();
}
};
export const requireRole = (...roles: string[]) => {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({
success: false,
error: 'Authentication required',
});
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({
success: false,
error: 'Insufficient permissions',
});
}
next();
};
};
export const requireVerifiedEmail = (
req: Request,
res: Response,
next: NextFunction
) => {
if (!req.user) {
return res.status(401).json({
success: false,
error: 'Authentication required',
});
}
if (!req.user.emailVerified) {
return res.status(403).json({
success: false,
error: 'Email verification required',
});
}
next();
};
export const requireKYC = (level: number = 1) => {
return async (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({
success: false,
error: 'Authentication required',
});
}
const kycResult = await db.query(
`SELECT level FROM kyc_verifications
WHERE user_id = $1 AND status = 'approved'
ORDER BY level DESC LIMIT 1`,
[req.user.id]
);
const currentLevel = kycResult.rows[0]?.level || 0;
if (currentLevel < level) {
return res.status(403).json({
success: false,
error: `KYC level ${level} required. Current level: ${currentLevel}`,
data: { requiredLevel: level, currentLevel },
});
}
next();
};
};

View File

@ -1,77 +0,0 @@
/**
* Global Error Handler Middleware
*/
import { Request, Response, NextFunction } from 'express';
import { logger } from '../../shared/utils/logger.js';
export interface AppError extends Error {
statusCode?: number;
isOperational?: boolean;
code?: string;
}
export const errorHandler = (
err: AppError,
req: Request,
res: Response,
_next: NextFunction
): void => {
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
// Log error
logger.error(`[${req.method}] ${req.path} - ${statusCode}: ${message}`, {
error: err,
stack: err.stack,
body: req.body,
params: req.params,
query: req.query,
});
// Send response
res.status(statusCode).json({
success: false,
error: {
message,
code: err.code,
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
},
timestamp: new Date().toISOString(),
});
};
// Custom error class
export class HttpError extends Error implements AppError {
statusCode: number;
isOperational: boolean;
code?: string;
constructor(message: string, statusCode: number, code?: string) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
this.code = code;
Error.captureStackTrace(this, this.constructor);
}
}
// Common errors
export const BadRequestError = (message = 'Bad Request', code?: string) =>
new HttpError(message, 400, code);
export const UnauthorizedError = (message = 'Unauthorized', code?: string) =>
new HttpError(message, 401, code);
export const ForbiddenError = (message = 'Forbidden', code?: string) =>
new HttpError(message, 403, code);
export const NotFoundError = (message = 'Not Found', code?: string) =>
new HttpError(message, 404, code);
export const ConflictError = (message = 'Conflict', code?: string) =>
new HttpError(message, 409, code);
export const InternalError = (message = 'Internal Server Error', code?: string) =>
new HttpError(message, 500, code);

View File

@ -1,16 +0,0 @@
/**
* 404 Not Found Handler
*/
import { Request, Response } from 'express';
export const notFoundHandler = (req: Request, res: Response): void => {
res.status(404).json({
success: false,
error: {
message: `Route ${req.method} ${req.path} not found`,
code: 'ROUTE_NOT_FOUND',
},
timestamp: new Date().toISOString(),
});
};

View File

@ -1,51 +0,0 @@
/**
* Rate Limiter Middleware
*/
import rateLimit from 'express-rate-limit';
import { config } from '../../config';
export const rateLimiter = rateLimit({
windowMs: config.rateLimit.windowMs,
max: config.rateLimit.max,
message: {
success: false,
error: {
message: 'Too many requests, please try again later',
code: 'RATE_LIMIT_EXCEEDED',
},
},
standardHeaders: true,
legacyHeaders: false,
});
// Standard rate limiter for auth endpoints
export const authRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 30, // 30 requests per window
message: {
success: false,
error: {
message: 'Too many authentication attempts, please try again later',
code: 'AUTH_RATE_LIMIT_EXCEEDED',
},
},
standardHeaders: true,
legacyHeaders: false,
});
// Strict rate limiter for sensitive operations (login, register, OTP)
export const strictRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: {
success: false,
error: {
message: 'Too many attempts, please try again later',
code: 'STRICT_RATE_LIMIT_EXCEEDED',
},
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true, // Don't count successful requests
});

View File

@ -1,8 +0,0 @@
/**
* WebSocket Module Exports
*/
export { wsManager } from './websocket.server';
export type { WSClient, WSMessage, MessageHandler } from './websocket.server';
export { tradingStreamService } from './trading-stream.service';
export type { QuoteData, TradeData, SignalData, DepthData, KlineData } from './trading-stream.service';

View File

@ -1,825 +0,0 @@
/**
* Trading Stream Service
* Real-time market data streaming via WebSocket
* Now with direct Binance WebSocket integration for true real-time updates
*/
import { wsManager, WSClient, WSMessage } from './websocket.server';
import { mlIntegrationService } from '../../modules/ml/services/ml-integration.service';
import { mlOverlayService } from '../../modules/ml/services/ml-overlay.service';
import { binanceService, Kline } from '../../modules/trading/services/binance.service';
import { logger } from '../../shared/utils/logger';
import { EventEmitter } from 'events';
// ============================================================================
// Types
// ============================================================================
export interface QuoteData {
symbol: string;
price: number;
bid: number;
ask: number;
volume: number;
change: number;
changePercent: number;
high: number;
low: number;
open: number;
previousClose: number;
timestamp: Date;
}
export interface TradeData {
symbol: string;
price: number;
quantity: number;
side: 'buy' | 'sell';
timestamp: Date;
}
export interface DepthData {
symbol: string;
bids: [number, number][]; // [price, quantity]
asks: [number, number][];
timestamp: Date;
}
export interface SignalData {
symbol: string;
signalType: 'buy' | 'sell' | 'hold';
confidence: number;
amdPhase: string;
targetPrice: number;
stopLoss: number;
timestamp: Date;
}
export interface KlineData {
symbol: string;
interval: string;
time: number;
open: number;
high: number;
low: number;
close: number;
volume: number;
isFinal: boolean;
timestamp: Date;
}
// Channel prefixes
const CHANNELS = {
QUOTES: 'quotes',
PRICE: 'price',
KLINES: 'klines',
TICKER: 'ticker',
TRADES: 'trades',
DEPTH: 'depth',
SIGNALS: 'signals',
OVERLAYS: 'overlays',
PORTFOLIO: 'portfolio',
ORDERS: 'orders',
ALERTS: 'alerts',
} as const;
// ============================================================================
// Trading Stream Service
// ============================================================================
class TradingStreamService extends EventEmitter {
private quoteIntervals: Map<string, NodeJS.Timeout> = new Map();
private signalIntervals: Map<string, NodeJS.Timeout> = new Map();
private binanceStreamRefs: Map<string, { type: string; symbol: string; interval?: string }> = new Map();
private priceCache: Map<string, QuoteData> = new Map();
private initialized: boolean = false;
private readonly QUOTE_UPDATE_INTERVAL = 1000; // 1 second (fallback only)
private readonly SIGNAL_UPDATE_INTERVAL = 30000; // 30 seconds
private readonly MAX_SYMBOLS_PER_CLIENT = 50;
private readonly PRICE_CACHE_TTL = 5000; // 5 seconds
/**
* Initialize streaming service
*/
initialize(): void {
if (this.initialized) return;
// Register message handlers
wsManager.registerHandler('requestQuote', this.handleQuoteRequest.bind(this));
wsManager.registerHandler('requestSignal', this.handleSignalRequest.bind(this));
wsManager.registerHandler('requestOverlay', this.handleOverlayRequest.bind(this));
// Listen for subscription events
wsManager.on('subscribe', this.handleSubscribe.bind(this));
wsManager.on('unsubscribe', this.handleUnsubscribe.bind(this));
wsManager.on('disconnect', this.handleDisconnect.bind(this));
// Setup Binance WebSocket event listeners
this.setupBinanceListeners();
this.initialized = true;
logger.info('[TradingStream] Service initialized with Binance WebSocket integration');
}
/**
* Setup Binance WebSocket event listeners
*/
private setupBinanceListeners(): void {
// Listen for ticker updates (24h statistics)
binanceService.on('ticker', (data: Record<string, unknown>) => {
const quote = this.transformTickerToQuote(data);
this.priceCache.set(quote.symbol, quote);
// Broadcast to subscribed clients
wsManager.broadcast(`${CHANNELS.TICKER}:${quote.symbol}`, {
type: 'ticker',
data: quote,
});
// Also broadcast on price and quotes channels
wsManager.broadcast(`${CHANNELS.PRICE}:${quote.symbol}`, {
type: 'price',
data: {
symbol: quote.symbol,
price: quote.price,
change24h: quote.change,
changePercent24h: quote.changePercent,
high24h: quote.high,
low24h: quote.low,
volume24h: quote.volume,
timestamp: quote.timestamp.getTime(),
},
});
wsManager.broadcast(`${CHANNELS.QUOTES}:${quote.symbol}`, {
type: 'quote',
data: quote,
});
});
// Listen for kline updates (candlestick data)
binanceService.on('kline', (data: { symbol: string; interval: string; kline: Kline; isFinal: boolean }) => {
const klineData: KlineData = {
symbol: data.symbol,
interval: data.interval,
time: data.kline.openTime,
open: parseFloat(data.kline.open),
high: parseFloat(data.kline.high),
low: parseFloat(data.kline.low),
close: parseFloat(data.kline.close),
volume: parseFloat(data.kline.volume),
isFinal: data.isFinal,
timestamp: new Date(),
};
// Broadcast to subscribed clients
wsManager.broadcast(`${CHANNELS.KLINES}:${data.symbol}:${data.interval}`, {
type: 'kline',
data: klineData,
});
});
// Listen for trade updates
binanceService.on('trade', (data: Record<string, unknown>) => {
const tradeData: TradeData = {
symbol: data.symbol as string,
price: parseFloat(data.price as string),
quantity: parseFloat(data.quantity as string),
side: data.isBuyerMaker ? 'sell' : 'buy',
timestamp: new Date(data.time as number),
};
wsManager.broadcast(`${CHANNELS.TRADES}:${data.symbol as string}`, {
type: 'trade',
data: tradeData,
});
});
// Listen for depth updates
binanceService.on('depth', (data: Record<string, unknown>) => {
const depthData: DepthData = {
symbol: data.symbol as string,
bids: (data.bids as [string, string][]).map((b: [string, string]) => [parseFloat(b[0]), parseFloat(b[1])]),
asks: (data.asks as [string, string][]).map((a: [string, string]) => [parseFloat(a[0]), parseFloat(a[1])]),
timestamp: new Date(),
};
wsManager.broadcast(`${CHANNELS.DEPTH}:${data.symbol as string}`, {
type: 'depth',
data: depthData,
});
});
logger.info('[TradingStream] Binance WebSocket listeners configured');
}
/**
* Transform Binance ticker to QuoteData
*/
private transformTickerToQuote(ticker: Record<string, unknown>): QuoteData {
const price = parseFloat((ticker.c || ticker.lastPrice || '0') as string);
const change = parseFloat((ticker.p || ticker.priceChange || '0') as string);
const changePercent = parseFloat((ticker.P || ticker.priceChangePercent || '0') as string);
return {
symbol: (ticker.s || ticker.symbol) as string,
price,
bid: parseFloat((ticker.b || ticker.bidPrice || '0') as string),
ask: parseFloat((ticker.a || ticker.askPrice || '0') as string),
volume: parseFloat((ticker.v || ticker.volume || '0') as string),
change,
changePercent,
high: parseFloat((ticker.h || ticker.highPrice || '0') as string),
low: parseFloat((ticker.l || ticker.lowPrice || '0') as string),
open: parseFloat((ticker.o || ticker.openPrice || '0') as string),
previousClose: parseFloat((ticker.x || ticker.prevClosePrice || '0') as string),
timestamp: new Date(),
};
}
/**
* Handle subscription to a channel
*/
private handleSubscribe(client: WSClient, channel: string): void {
const parts = channel.split(':');
const type = parts[0];
const symbol = parts[1]?.toUpperCase();
const interval = parts[2]; // For klines
if (!symbol) return;
// Handle different channel types
if (type === CHANNELS.PRICE || type === CHANNELS.TICKER || type === CHANNELS.QUOTES) {
this.startTickerStream(symbol);
} else if (type === CHANNELS.KLINES && interval) {
this.startKlineStream(symbol, interval as '1m' | '3m' | '5m' | '15m' | '30m' | '1h' | '2h' | '4h' | '6h' | '8h' | '12h' | '1d' | '3d' | '1w' | '1M');
} else if (type === CHANNELS.TRADES) {
this.startTradeStream(symbol);
} else if (type === CHANNELS.DEPTH) {
this.startDepthStream(symbol);
} else if (type === CHANNELS.SIGNALS) {
this.startSignalStream(symbol);
}
}
/**
* Handle unsubscription from a channel
*/
private handleUnsubscribe(_client: WSClient, channel: string): void {
const parts = channel.split(':');
const type = parts[0];
const symbol = parts[1]?.toUpperCase();
const interval = parts[2];
// Check if anyone is still subscribed to this channel
if (wsManager.getChannelSubscriberCount(channel) === 0) {
if (type === CHANNELS.PRICE || type === CHANNELS.TICKER || type === CHANNELS.QUOTES) {
this.stopTickerStream(symbol);
} else if (type === CHANNELS.KLINES && interval) {
this.stopKlineStream(symbol, interval as '1m' | '3m' | '5m' | '15m' | '30m' | '1h' | '2h' | '4h' | '6h' | '8h' | '12h' | '1d' | '3d' | '1w' | '1M');
} else if (type === CHANNELS.TRADES) {
this.stopTradeStream(symbol);
} else if (type === CHANNELS.DEPTH) {
this.stopDepthStream(symbol);
} else if (type === CHANNELS.SIGNALS) {
this.stopSignalStream(symbol);
}
}
}
/**
* Handle client disconnect
*/
private handleDisconnect(_client: WSClient): void {
// Clean up empty channels
wsManager.getActiveChannels().forEach((channel) => {
if (wsManager.getChannelSubscriberCount(channel) === 0) {
const parts = channel.split(':');
const type = parts[0];
const symbol = parts[1];
const interval = parts[2];
if (type === CHANNELS.PRICE || type === CHANNELS.TICKER || type === CHANNELS.QUOTES) {
this.stopTickerStream(symbol);
} else if (type === CHANNELS.KLINES && interval) {
this.stopKlineStream(symbol, interval as '1m' | '3m' | '5m' | '15m' | '30m' | '1h' | '2h' | '4h' | '6h' | '8h' | '12h' | '1d' | '3d' | '1w' | '1M');
} else if (type === CHANNELS.TRADES) {
this.stopTradeStream(symbol);
} else if (type === CHANNELS.DEPTH) {
this.stopDepthStream(symbol);
} else if (type === CHANNELS.SIGNALS) {
this.stopSignalStream(symbol);
}
}
});
}
/**
* Handle quote request message
*/
private async handleQuoteRequest(client: WSClient, message: WSMessage): Promise<void> {
const { symbol } = message.data as { symbol: string };
if (!symbol) return;
try {
const quote = await this.fetchQuote(symbol.toUpperCase());
wsManager.send(client, {
type: 'quote',
channel: `${CHANNELS.QUOTES}:${symbol.toUpperCase()}`,
data: quote,
});
} catch {
wsManager.send(client, {
type: 'error',
data: { message: `Failed to fetch quote for ${symbol}` },
});
}
}
/**
* Handle signal request message
*/
private async handleSignalRequest(client: WSClient, message: WSMessage): Promise<void> {
const { symbol } = message.data as { symbol: string };
if (!symbol) return;
try {
const signal = await mlIntegrationService.getSignal(symbol.toUpperCase());
wsManager.send(client, {
type: 'signal',
channel: `${CHANNELS.SIGNALS}:${symbol.toUpperCase()}`,
data: this.transformSignal(signal),
});
} catch {
wsManager.send(client, {
type: 'error',
data: { message: `Failed to fetch signal for ${symbol}` },
});
}
}
/**
* Handle overlay request message
*/
private async handleOverlayRequest(client: WSClient, message: WSMessage): Promise<void> {
const { symbol, config } = message.data as { symbol: string; config?: Record<string, boolean> };
if (!symbol) return;
try {
const overlay = await mlOverlayService.getChartOverlay(symbol.toUpperCase(), config);
wsManager.send(client, {
type: 'overlay',
channel: `${CHANNELS.OVERLAYS}:${symbol.toUpperCase()}`,
data: overlay,
});
} catch {
wsManager.send(client, {
type: 'error',
data: { message: `Failed to fetch overlay for ${symbol}` },
});
}
}
// ==========================================================================
// Binance WebSocket Streaming Methods
// ==========================================================================
/**
* Start ticker stream (24h stats) via Binance WebSocket
*/
private startTickerStream(symbol: string): void {
const streamKey = `ticker:${symbol}`;
// Check if already subscribed
if (this.binanceStreamRefs.has(streamKey)) {
logger.debug('[TradingStream] Ticker stream already active:', { symbol });
return;
}
try {
// Subscribe to Binance WebSocket ticker stream
binanceService.subscribeTicker(symbol);
this.binanceStreamRefs.set(streamKey, { type: 'ticker', symbol });
logger.info('[TradingStream] Started Binance ticker stream:', { symbol });
// Send initial data from cache if available
const cached = this.priceCache.get(symbol);
if (cached) {
wsManager.broadcast(`${CHANNELS.TICKER}:${symbol}`, {
type: 'ticker',
data: cached,
});
}
} catch (error) {
logger.error('[TradingStream] Failed to start ticker stream:', { symbol, error: (error as Error).message });
}
}
/**
* Stop ticker stream
*/
private stopTickerStream(symbol: string): void {
const streamKey = `ticker:${symbol}`;
if (this.binanceStreamRefs.has(streamKey)) {
const streamName = `${symbol.toLowerCase()}@ticker`;
binanceService.unsubscribe(streamName);
this.binanceStreamRefs.delete(streamKey);
this.priceCache.delete(symbol);
logger.info('[TradingStream] Stopped Binance ticker stream:', { symbol });
}
}
/**
* Start kline/candlestick stream via Binance WebSocket
*/
private startKlineStream(symbol: string, interval: string): void {
const streamKey = `klines:${symbol}:${interval}`;
if (this.binanceStreamRefs.has(streamKey)) {
logger.debug('[TradingStream] Kline stream already active:', { symbol, interval });
return;
}
try {
binanceService.subscribeKlines(symbol, interval as '1m' | '3m' | '5m' | '15m' | '30m' | '1h' | '2h' | '4h' | '6h' | '8h' | '12h' | '1d' | '3d' | '1w' | '1M');
this.binanceStreamRefs.set(streamKey, { type: 'klines', symbol, interval });
logger.info('[TradingStream] Started Binance kline stream:', { symbol, interval });
} catch (error) {
logger.error('[TradingStream] Failed to start kline stream:', { symbol, interval, error: (error as Error).message });
}
}
/**
* Stop kline stream
*/
private stopKlineStream(symbol: string, interval: string): void {
const streamKey = `klines:${symbol}:${interval}`;
if (this.binanceStreamRefs.has(streamKey)) {
const streamName = `${symbol.toLowerCase()}@kline_${interval}`;
binanceService.unsubscribe(streamName);
this.binanceStreamRefs.delete(streamKey);
logger.info('[TradingStream] Stopped Binance kline stream:', { symbol, interval });
}
}
/**
* Start trade stream via Binance WebSocket
*/
private startTradeStream(symbol: string): void {
const streamKey = `trades:${symbol}`;
if (this.binanceStreamRefs.has(streamKey)) {
logger.debug('[TradingStream] Trade stream already active:', { symbol });
return;
}
try {
binanceService.subscribeTrades(symbol);
this.binanceStreamRefs.set(streamKey, { type: 'trades', symbol });
logger.info('[TradingStream] Started Binance trade stream:', { symbol });
} catch (error) {
logger.error('[TradingStream] Failed to start trade stream:', { symbol, error: (error as Error).message });
}
}
/**
* Stop trade stream
*/
private stopTradeStream(symbol: string): void {
const streamKey = `trades:${symbol}`;
if (this.binanceStreamRefs.has(streamKey)) {
const streamName = `${symbol.toLowerCase()}@trade`;
binanceService.unsubscribe(streamName);
this.binanceStreamRefs.delete(streamKey);
logger.info('[TradingStream] Stopped Binance trade stream:', { symbol });
}
}
/**
* Start depth/order book stream via Binance WebSocket
*/
private startDepthStream(symbol: string, levels: 5 | 10 | 20 = 10): void {
const streamKey = `depth:${symbol}`;
if (this.binanceStreamRefs.has(streamKey)) {
logger.debug('[TradingStream] Depth stream already active:', { symbol });
return;
}
try {
binanceService.subscribeDepth(symbol, levels);
this.binanceStreamRefs.set(streamKey, { type: 'depth', symbol });
logger.info('[TradingStream] Started Binance depth stream:', { symbol, levels });
} catch (error) {
logger.error('[TradingStream] Failed to start depth stream:', { symbol, error: (error as Error).message });
}
}
/**
* Stop depth stream
*/
private stopDepthStream(symbol: string): void {
const streamKey = `depth:${symbol}`;
if (this.binanceStreamRefs.has(streamKey)) {
const streamName = `${symbol.toLowerCase()}@depth10@100ms`;
binanceService.unsubscribe(streamName);
this.binanceStreamRefs.delete(streamKey);
logger.info('[TradingStream] Stopped Binance depth stream:', { symbol });
}
}
// ==========================================================================
// Legacy Quote Streaming (Fallback)
// ==========================================================================
/**
* Start streaming quotes for a symbol (LEGACY - uses polling as fallback)
*/
private startQuoteStream(symbol: string): void {
const key = `quotes:${symbol}`;
if (this.quoteIntervals.has(key)) return;
const interval = setInterval(async () => {
try {
const quote = await this.fetchQuote(symbol);
wsManager.broadcast(`${CHANNELS.QUOTES}:${symbol}`, {
type: 'quote',
data: quote,
});
} catch (_error) {
logger.error('[TradingStream] Quote fetch error:', { symbol, error: (_error as Error).message });
}
}, this.QUOTE_UPDATE_INTERVAL);
this.quoteIntervals.set(key, interval);
logger.debug('[TradingStream] Started quote stream (polling fallback):', { symbol });
}
/**
* Stop streaming quotes for a symbol (LEGACY)
*/
private stopQuoteStream(symbol: string): void {
const key = `quotes:${symbol}`;
const interval = this.quoteIntervals.get(key);
if (interval) {
clearInterval(interval);
this.quoteIntervals.delete(key);
logger.debug('[TradingStream] Stopped quote stream:', { symbol });
}
}
/**
* Fetch quote data from Binance
*/
private async fetchQuote(symbol: string): Promise<QuoteData> {
try {
// Get 24hr ticker from Binance
const result = await binanceService.get24hrTicker(symbol);
const ticker = Array.isArray(result) ? result[0] : result;
if (!ticker) {
throw new Error('No ticker data');
}
const price = parseFloat(ticker.lastPrice);
const change = parseFloat(ticker.priceChange);
const changePercent = parseFloat(ticker.priceChangePercent);
return {
symbol: ticker.symbol,
price,
bid: parseFloat(ticker.bidPrice),
ask: parseFloat(ticker.askPrice),
volume: parseFloat(ticker.volume),
change,
changePercent,
high: parseFloat(ticker.highPrice),
low: parseFloat(ticker.lowPrice),
open: parseFloat(ticker.openPrice),
previousClose: parseFloat(ticker.prevClosePrice),
timestamp: new Date(),
};
} catch {
// Fallback to simulated data if Binance fails
logger.warn('[TradingStream] Binance fetch failed, using mock data:', { symbol });
return this.getMockQuote(symbol);
}
}
/**
* Get mock quote data (fallback)
*/
private getMockQuote(symbol: string): QuoteData {
const basePrice = this.getBasePrice(symbol);
const change = (Math.random() - 0.5) * basePrice * 0.02;
const price = basePrice + change;
return {
symbol,
price: parseFloat(price.toFixed(2)),
bid: parseFloat((price - 0.01).toFixed(2)),
ask: parseFloat((price + 0.01).toFixed(2)),
volume: Math.floor(Math.random() * 1000000),
change: parseFloat(change.toFixed(2)),
changePercent: parseFloat(((change / basePrice) * 100).toFixed(2)),
high: parseFloat((price + Math.random() * 2).toFixed(2)),
low: parseFloat((price - Math.random() * 2).toFixed(2)),
open: parseFloat((price - change * 0.5).toFixed(2)),
previousClose: parseFloat(basePrice.toFixed(2)),
timestamp: new Date(),
};
}
/**
* Get base price for a symbol (mock fallback)
*/
private getBasePrice(symbol: string): number {
const prices: Record<string, number> = {
BTCUSDT: 97500.00,
ETHUSDT: 3650.00,
BNBUSDT: 720.00,
SOLUSDT: 235.00,
XRPUSDT: 2.45,
DOGEUSDT: 0.42,
ADAUSDT: 1.10,
AVAXUSDT: 48.50,
DOTUSDT: 9.25,
MATICUSDT: 0.58,
};
return prices[symbol.toUpperCase()] || 100 + Math.random() * 100;
}
// ==========================================================================
// Signal Streaming
// ==========================================================================
/**
* Start streaming signals for a symbol
*/
private startSignalStream(symbol: string): void {
const key = `signals:${symbol}`;
if (this.signalIntervals.has(key)) return;
// Initial signal fetch
this.broadcastSignal(symbol);
const interval = setInterval(async () => {
await this.broadcastSignal(symbol);
}, this.SIGNAL_UPDATE_INTERVAL);
this.signalIntervals.set(key, interval);
logger.debug('[TradingStream] Started signal stream:', { symbol });
}
/**
* Stop streaming signals for a symbol
*/
private stopSignalStream(symbol: string): void {
const key = `signals:${symbol}`;
const interval = this.signalIntervals.get(key);
if (interval) {
clearInterval(interval);
this.signalIntervals.delete(key);
logger.debug('[TradingStream] Stopped signal stream:', { symbol });
}
}
/**
* Broadcast signal update
*/
private async broadcastSignal(symbol: string): Promise<void> {
try {
const signal = await mlIntegrationService.getSignal(symbol);
wsManager.broadcast(`${CHANNELS.SIGNALS}:${symbol}`, {
type: 'signal',
data: this.transformSignal(signal),
});
} catch (_error) {
logger.error('[TradingStream] Signal fetch error:', { symbol, error: (_error as Error).message });
}
}
/**
* Transform ML signal to stream format
*/
private transformSignal(signal: unknown): SignalData {
const s = signal as Record<string, unknown>;
const prediction = s.prediction as Record<string, unknown> | undefined;
return {
symbol: s.symbol as string,
signalType: s.signalType as 'buy' | 'sell' | 'hold',
confidence: s.confidence as number,
amdPhase: s.amdPhase as string,
targetPrice: (prediction?.targetPrice as number) || 0,
stopLoss: (prediction?.stopLoss as number) || 0,
timestamp: new Date(s.timestamp as string | number),
};
}
// ==========================================================================
// Public Methods
// ==========================================================================
/**
* Broadcast trade execution to user
*/
broadcastTradeExecution(userId: string, trade: TradeData): void {
wsManager.sendToUser(userId, {
type: 'trade',
channel: `${CHANNELS.TRADES}:${trade.symbol}`,
data: trade,
});
}
/**
* Broadcast order update to user
*/
broadcastOrderUpdate(userId: string, order: unknown): void {
wsManager.sendToUser(userId, {
type: 'orderUpdate',
channel: CHANNELS.ORDERS,
data: order,
});
}
/**
* Broadcast portfolio update to user
*/
broadcastPortfolioUpdate(userId: string, portfolio: unknown): void {
wsManager.sendToUser(userId, {
type: 'portfolioUpdate',
channel: CHANNELS.PORTFOLIO,
data: portfolio,
});
}
/**
* Broadcast alert to user
*/
broadcastAlert(userId: string, alert: unknown): void {
wsManager.sendToUser(userId, {
type: 'alert',
channel: CHANNELS.ALERTS,
data: alert,
});
}
/**
* Broadcast system announcement to all
*/
broadcastAnnouncement(message: string): void {
wsManager.broadcastAll({
type: 'announcement',
data: { message },
});
}
/**
* Get streaming stats
*/
getStats(): {
connectedClients: number;
activeChannels: string[];
quoteStreams: number;
signalStreams: number;
binanceStreams: number;
binanceActiveStreams: string[];
priceCache: number;
} {
return {
connectedClients: wsManager.getClientCount(),
activeChannels: wsManager.getActiveChannels(),
quoteStreams: this.quoteIntervals.size,
signalStreams: this.signalIntervals.size,
binanceStreams: this.binanceStreamRefs.size,
binanceActiveStreams: binanceService.getActiveStreams(),
priceCache: this.priceCache.size,
};
}
/**
* Shutdown service
*/
shutdown(): void {
// Clear polling intervals
this.quoteIntervals.forEach((interval) => clearInterval(interval));
this.signalIntervals.forEach((interval) => clearInterval(interval));
this.quoteIntervals.clear();
this.signalIntervals.clear();
// Unsubscribe from all Binance streams
binanceService.unsubscribeAll();
this.binanceStreamRefs.clear();
this.priceCache.clear();
logger.info('[TradingStream] Service shut down');
}
}
// Export singleton instance
export const tradingStreamService = new TradingStreamService();

View File

@ -1,418 +0,0 @@
/**
* WebSocket Server
* Real-time streaming for trading data
*/
import { WebSocketServer, WebSocket, RawData } from 'ws';
import { Server as HttpServer } from 'http';
import { parse as parseUrl } from 'url';
import jwt from 'jsonwebtoken';
import { config } from '../../config';
import { logger } from '../../shared/utils/logger';
import { EventEmitter } from 'events';
// ============================================================================
// Types
// ============================================================================
export interface WSClient {
id: string;
ws: WebSocket;
userId?: string;
subscriptions: Set<string>;
isAlive: boolean;
connectedAt: Date;
lastPing: Date;
}
export interface WSMessage {
type: string;
channel?: string;
data?: unknown;
timestamp?: string;
}
export interface SubscriptionMessage {
type: 'subscribe' | 'unsubscribe';
channels: string[];
}
export type MessageHandler = (client: WSClient, message: WSMessage) => void | Promise<void>;
// ============================================================================
// WebSocket Manager
// ============================================================================
class WebSocketManager extends EventEmitter {
private wss: WebSocketServer | null = null;
private clients: Map<string, WSClient> = new Map();
private channelSubscribers: Map<string, Set<string>> = new Map();
private messageHandlers: Map<string, MessageHandler> = new Map();
private heartbeatInterval: NodeJS.Timeout | null = null;
private readonly HEARTBEAT_INTERVAL = 30000;
private readonly CLIENT_TIMEOUT = 60000;
/**
* Initialize WebSocket server
*/
initialize(httpServer: HttpServer): void {
this.wss = new WebSocketServer({
server: httpServer,
path: '/ws',
verifyClient: this.verifyClient.bind(this),
});
this.wss.on('connection', this.handleConnection.bind(this));
this.wss.on('error', (error) => {
logger.error('[WS] Server error:', { error: error.message });
});
this.startHeartbeat();
logger.info('[WS] WebSocket server initialized');
}
/**
* Verify client connection (authentication)
*/
private verifyClient(
info: { origin: string; req: { url?: string } },
callback: (result: boolean, code?: number, message?: string) => void
): void {
try {
const url = parseUrl(info.req.url || '', true);
const token = url.query.token as string;
// Allow connection without token (public channels only)
if (!token) {
callback(true);
return;
}
// Verify JWT token
jwt.verify(token, config.jwt.accessSecret);
callback(true);
} catch (error) {
logger.warn('[WS] Client verification failed:', { error: (error as Error).message });
callback(false, 401, 'Unauthorized');
}
}
/**
* Handle new WebSocket connection
*/
private handleConnection(ws: WebSocket, req: { url?: string }): void {
const clientId = this.generateClientId();
const url = parseUrl(req.url || '', true);
const token = url.query.token as string;
let userId: string | undefined;
if (token) {
try {
const decoded = jwt.verify(token, config.jwt.accessSecret) as { sub: string };
userId = decoded.sub;
} catch {
// Token invalid, continue as anonymous
}
}
const client: WSClient = {
id: clientId,
ws,
userId,
subscriptions: new Set(),
isAlive: true,
connectedAt: new Date(),
lastPing: new Date(),
};
this.clients.set(clientId, client);
// Setup event handlers
ws.on('message', (data) => this.handleMessage(client, data));
ws.on('pong', () => {
client.isAlive = true;
client.lastPing = new Date();
});
ws.on('close', () => this.handleDisconnect(client));
ws.on('error', (error) => {
logger.error('[WS] Client error:', { clientId, error: error.message });
});
// Send welcome message
this.send(client, {
type: 'connected',
data: {
clientId,
authenticated: !!userId,
timestamp: new Date().toISOString(),
},
});
this.emit('connection', client);
logger.info('[WS] Client connected:', { clientId, userId, authenticated: !!userId });
}
/**
* Handle incoming message
*/
private handleMessage(client: WSClient, rawData: RawData): void {
try {
const message = JSON.parse(rawData.toString()) as WSMessage;
// Handle subscription messages
if (message.type === 'subscribe' || message.type === 'unsubscribe') {
this.handleSubscription(client, message as SubscriptionMessage);
return;
}
// Handle ping
if (message.type === 'ping') {
this.send(client, { type: 'pong', timestamp: new Date().toISOString() });
return;
}
// Call registered message handler
const handler = this.messageHandlers.get(message.type);
if (handler) {
handler(client, message);
} else {
logger.debug('[WS] Unknown message type:', { type: message.type, clientId: client.id });
}
this.emit('message', client, message);
} catch (error) {
logger.error('[WS] Message parse error:', { error: (error as Error).message });
this.send(client, {
type: 'error',
data: { message: 'Invalid message format' },
});
}
}
/**
* Handle subscription requests
*/
private handleSubscription(client: WSClient, message: SubscriptionMessage): void {
const channels = Array.isArray(message.channels) ? message.channels : [];
channels.forEach((channel) => {
// Check if channel requires authentication
if (this.isPrivateChannel(channel) && !client.userId) {
this.send(client, {
type: 'error',
channel,
data: { message: 'Authentication required for this channel' },
});
return;
}
if (message.type === 'subscribe') {
// Subscribe
client.subscriptions.add(channel);
if (!this.channelSubscribers.has(channel)) {
this.channelSubscribers.set(channel, new Set());
}
this.channelSubscribers.get(channel)!.add(client.id);
this.send(client, {
type: 'subscribed',
channel,
timestamp: new Date().toISOString(),
});
this.emit('subscribe', client, channel);
logger.debug('[WS] Client subscribed:', { clientId: client.id, channel });
} else {
// Unsubscribe
client.subscriptions.delete(channel);
this.channelSubscribers.get(channel)?.delete(client.id);
this.send(client, {
type: 'unsubscribed',
channel,
timestamp: new Date().toISOString(),
});
this.emit('unsubscribe', client, channel);
logger.debug('[WS] Client unsubscribed:', { clientId: client.id, channel });
}
});
}
/**
* Handle client disconnect
*/
private handleDisconnect(client: WSClient): void {
// Remove from all channel subscriptions
client.subscriptions.forEach((channel) => {
this.channelSubscribers.get(channel)?.delete(client.id);
});
this.clients.delete(client.id);
this.emit('disconnect', client);
logger.info('[WS] Client disconnected:', { clientId: client.id, userId: client.userId });
}
/**
* Start heartbeat to detect dead connections
*/
private startHeartbeat(): void {
this.heartbeatInterval = setInterval(() => {
const now = Date.now();
this.clients.forEach((client) => {
if (!client.isAlive) {
// Client didn't respond to last ping
logger.warn('[WS] Client timed out:', { clientId: client.id });
client.ws.terminate();
return;
}
// Check for stale connections
if (now - client.lastPing.getTime() > this.CLIENT_TIMEOUT) {
logger.warn('[WS] Client connection stale:', { clientId: client.id });
client.ws.terminate();
return;
}
client.isAlive = false;
client.ws.ping();
});
}, this.HEARTBEAT_INTERVAL);
}
/**
* Send message to a client
*/
send(client: WSClient, message: WSMessage): void {
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.send(JSON.stringify({
...message,
timestamp: message.timestamp || new Date().toISOString(),
}));
}
}
/**
* Broadcast to a channel
*/
broadcast(channel: string, message: WSMessage): void {
const subscribers = this.channelSubscribers.get(channel);
if (!subscribers) return;
const payload = JSON.stringify({
...message,
channel,
timestamp: message.timestamp || new Date().toISOString(),
});
subscribers.forEach((clientId) => {
const client = this.clients.get(clientId);
if (client && client.ws.readyState === WebSocket.OPEN) {
client.ws.send(payload);
}
});
}
/**
* Broadcast to all clients
*/
broadcastAll(message: WSMessage): void {
const payload = JSON.stringify({
...message,
timestamp: message.timestamp || new Date().toISOString(),
});
this.clients.forEach((client) => {
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.send(payload);
}
});
}
/**
* Send to specific user (all their connections)
*/
sendToUser(userId: string, message: WSMessage): void {
const payload = JSON.stringify({
...message,
timestamp: message.timestamp || new Date().toISOString(),
});
this.clients.forEach((client) => {
if (client.userId === userId && client.ws.readyState === WebSocket.OPEN) {
client.ws.send(payload);
}
});
}
/**
* Register message handler
*/
registerHandler(type: string, handler: MessageHandler): void {
this.messageHandlers.set(type, handler);
}
/**
* Get channel subscriber count
*/
getChannelSubscriberCount(channel: string): number {
return this.channelSubscribers.get(channel)?.size || 0;
}
/**
* Get connected clients count
*/
getClientCount(): number {
return this.clients.size;
}
/**
* Get all subscribed channels
*/
getActiveChannels(): string[] {
return Array.from(this.channelSubscribers.keys()).filter(
(channel) => (this.channelSubscribers.get(channel)?.size || 0) > 0
);
}
/**
* Check if channel is private (requires auth)
*/
private isPrivateChannel(channel: string): boolean {
return (
channel.startsWith('user:') ||
channel.startsWith('portfolio:') ||
channel.startsWith('account:') ||
channel.startsWith('orders:')
);
}
/**
* Generate unique client ID
*/
private generateClientId(): string {
return `ws_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Shutdown WebSocket server
*/
shutdown(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
this.clients.forEach((client) => {
this.send(client, { type: 'shutdown', data: { message: 'Server shutting down' } });
client.ws.close();
});
this.wss?.close();
logger.info('[WS] WebSocket server shut down');
}
}
// Export singleton instance
export const wsManager = new WebSocketManager();

View File

@ -1,172 +0,0 @@
openapi: 3.0.0
info:
title: OrbiQuant IA - Trading Platform API
description: |
API para la plataforma OrbiQuant IA - Trading y análisis cuantitativo con ML e IA.
## Características principales
- Autenticación OAuth2 y JWT
- Trading automatizado y análisis cuantitativo
- Integración con agentes ML/LLM
- WebSocket para datos en tiempo real
- Sistema de pagos y suscripciones
- Gestión de portfolios y estrategias
## Autenticación
La mayoría de los endpoints requieren autenticación mediante Bearer Token (JWT).
version: 1.0.0
contact:
name: OrbiQuant Support
email: support@orbiquant.com
url: https://orbiquant.com
license:
name: Proprietary
servers:
- url: http://localhost:3000/api/v1
description: Desarrollo local
- url: https://api.orbiquant.com/api/v1
description: Producción
tags:
- name: Auth
description: Autenticación y autorización
- name: Users
description: Gestión de usuarios y perfiles
- name: Education
description: Contenido educativo y cursos
- name: Trading
description: Operaciones de trading y órdenes
- name: Investment
description: Gestión de inversiones y análisis
- name: Payments
description: Pagos y suscripciones (Stripe)
- name: Portfolio
description: Gestión de portfolios y activos
- name: ML
description: Machine Learning Engine
- name: LLM
description: Large Language Model Agent
- name: Agents
description: Trading Agents automatizados
- name: Admin
description: Administración del sistema
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: Token JWT obtenido del endpoint de login
ApiKeyAuth:
type: apiKey
in: header
name: X-API-Key
description: API Key para autenticación de servicios
schemas:
Error:
type: object
properties:
success:
type: boolean
example: false
error:
type: string
example: "Error message"
statusCode:
type: number
example: 400
SuccessResponse:
type: object
properties:
success:
type: boolean
example: true
data:
type: object
message:
type: string
security:
- BearerAuth: []
paths:
/health:
get:
tags:
- Health
summary: Health check del servidor
security: []
responses:
'200':
description: Servidor funcionando correctamente
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: healthy
version:
type: string
example: 0.1.0
timestamp:
type: string
format: date-time
environment:
type: string
example: development
/health/services:
get:
tags:
- Health
summary: Health check de microservicios Python
security: []
responses:
'200':
description: Estado de los microservicios
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: healthy
services:
type: object
properties:
mlEngine:
type: object
properties:
status:
type: string
example: healthy
latency:
type: number
example: 45
llmAgent:
type: object
properties:
status:
type: string
example: healthy
latency:
type: number
example: 120
tradingAgents:
type: object
properties:
status:
type: string
example: healthy
latency:
type: number
example: 60

View File

@ -1,196 +0,0 @@
/**
* OrbiQuant IA - Backend API
* ==========================
*
* Main entry point for the Express.js backend API.
*/
import express, { Express, Request, Response } from 'express';
import { createServer } from 'http';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import morgan from 'morgan';
import { config } from './config/index.js';
import { logger } from './shared/utils/logger.js';
import { setupSwagger } from './config/swagger.config.js';
// WebSocket
import { wsManager, tradingStreamService } from './core/websocket/index.js';
// Import routes
import { authRouter } from './modules/auth/auth.routes.js';
import { usersRouter } from './modules/users/users.routes.js';
import { educationRouter } from './modules/education/education.routes.js';
import { tradingRouter } from './modules/trading/trading.routes.js';
import { investmentRouter } from './modules/investment/investment.routes.js';
import { paymentsRouter } from './modules/payments/payments.routes.js';
import { adminRouter } from './modules/admin/admin.routes.js';
import { mlRouter } from './modules/ml/ml.routes.js';
import { llmRouter } from './modules/llm/llm.routes.js';
import { portfolioRouter } from './modules/portfolio/portfolio.routes.js';
import { agentsRouter } from './modules/agents/agents.routes.js';
// Service clients for health checks
import { tradingAgentsClient, mlEngineClient, llmAgentClient } from './shared/clients/index.js';
// Import middleware
import { errorHandler } from './core/middleware/error-handler.js';
import { notFoundHandler } from './core/middleware/not-found.js';
import { rateLimiter } from './core/middleware/rate-limiter.js';
const app: Express = express();
// Trust proxy (for rate limiting behind reverse proxy)
app.set('trust proxy', 1);
// Security middleware
app.use(helmet());
// CORS
app.use(cors({
origin: config.cors.origins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
}));
// Compression
app.use(compression());
// Request logging
app.use(morgan('combined', {
stream: { write: (message) => logger.info(message.trim()) }
}));
// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Rate limiting
app.use(rateLimiter);
// Swagger documentation
setupSwagger(app, '/api/v1');
// Health check (before auth)
app.get('/health', (req: Request, res: Response) => {
res.json({
status: 'healthy',
version: config.app.version,
timestamp: new Date().toISOString(),
environment: config.app.env,
});
});
// Services health check - checks all Python microservices
app.get('/health/services', async (req: Request, res: Response) => {
const services: Record<string, { status: string; latency?: number; error?: string }> = {};
// Check ML Engine
const mlStart = Date.now();
try {
await mlEngineClient.healthCheck();
services.mlEngine = { status: 'healthy', latency: Date.now() - mlStart };
} catch (error) {
services.mlEngine = { status: 'unhealthy', error: (error as Error).message };
}
// Check LLM Agent
const llmStart = Date.now();
try {
await llmAgentClient.healthCheck();
services.llmAgent = { status: 'healthy', latency: Date.now() - llmStart };
} catch (error) {
services.llmAgent = { status: 'unhealthy', error: (error as Error).message };
}
// Check Trading Agents
const agentsStart = Date.now();
try {
await tradingAgentsClient.healthCheck();
services.tradingAgents = { status: 'healthy', latency: Date.now() - agentsStart };
} catch (error) {
services.tradingAgents = { status: 'unhealthy', error: (error as Error).message };
}
const allHealthy = Object.values(services).every(s => s.status === 'healthy');
res.json({
status: allHealthy ? 'healthy' : 'degraded',
services,
timestamp: new Date().toISOString(),
});
});
// API routes
const apiRouter = express.Router();
apiRouter.use('/auth', authRouter);
apiRouter.use('/users', usersRouter);
apiRouter.use('/education', educationRouter);
apiRouter.use('/trading', tradingRouter);
apiRouter.use('/investment', investmentRouter);
apiRouter.use('/payments', paymentsRouter);
apiRouter.use('/admin', adminRouter);
apiRouter.use('/ml', mlRouter);
apiRouter.use('/llm', llmRouter);
apiRouter.use('/portfolio', portfolioRouter);
apiRouter.use('/agents', agentsRouter);
// Mount API router
app.use('/api/v1', apiRouter);
// 404 handler
app.use(notFoundHandler);
// Error handler
app.use(errorHandler);
// Create HTTP server
const httpServer = createServer(app);
// Initialize WebSocket
wsManager.initialize(httpServer);
tradingStreamService.initialize();
// WebSocket stats endpoint
app.get('/api/v1/ws/stats', (req: Request, res: Response) => {
res.json({
success: true,
data: tradingStreamService.getStats(),
});
});
// Start server
const PORT = config.app.port;
httpServer.listen(PORT, () => {
logger.info(`🚀 OrbiQuant API server running on port ${PORT}`);
logger.info(`📡 WebSocket server running on ws://localhost:${PORT}/ws`);
logger.info(`📚 Environment: ${config.app.env}`);
logger.info(`📖 Docs available at http://localhost:${PORT}/api/v1/docs`);
});
// Graceful shutdown
const gracefulShutdown = () => {
logger.info('Shutting down gracefully...');
tradingStreamService.shutdown();
wsManager.shutdown();
httpServer.close(() => {
logger.info('HTTP server closed');
process.exit(0);
});
};
process.on('SIGTERM', () => {
logger.info('SIGTERM received.');
gracefulShutdown();
});
process.on('SIGINT', () => {
logger.info('SIGINT received.');
gracefulShutdown();
});
export { app };

View File

@ -1,431 +0,0 @@
/**
* Admin Routes
* Admin-only endpoints for dashboard, user management, system health, and audit logs
*/
import { Router, Request, Response, NextFunction } from 'express';
import { mlEngineClient, tradingAgentsClient } from '../../shared/clients/index.js';
const router = Router();
// ============================================================================
// Dashboard
// ============================================================================
/**
* GET /api/v1/admin/dashboard
* Get dashboard statistics
*/
router.get('/dashboard', async (req: Request, res: Response, next: NextFunction) => {
try {
// Mock stats for development - replace with actual DB queries in production
const stats = {
users: {
total_users: 150,
active_users: 142,
new_users_week: 12,
new_users_month: 45,
},
trading: {
total_trades: 1256,
trades_today: 48,
winning_trades: 723,
avg_pnl: 125.50,
},
models: {
total_models: 6,
active_models: 5,
predictions_today: 1247,
overall_accuracy: 0.68,
},
agents: {
total_agents: 3,
active_agents: 1,
signals_today: 24,
},
pnl: {
today: 1250.75,
week: 8456.32,
month: 32145.89,
},
system: {
uptime: process.uptime(),
memory: process.memoryUsage(),
version: process.env.npm_package_version || '1.0.0',
},
timestamp: new Date().toISOString(),
};
res.json({
success: true,
data: {
total_models: stats.models.total_models,
active_models: stats.models.active_models,
total_predictions_today: stats.models.predictions_today,
total_predictions_week: stats.models.predictions_today * 7,
overall_accuracy: stats.models.overall_accuracy,
total_agents: stats.agents.total_agents,
active_agents: stats.agents.active_agents,
total_signals_today: stats.agents.signals_today,
total_pnl_today: stats.pnl.today,
total_pnl_week: stats.pnl.week,
total_pnl_month: stats.pnl.month,
system_health: 'healthy',
users: stats.users,
trading: stats.trading,
system: stats.system,
},
});
} catch (error) {
next(error);
}
});
// ============================================================================
// System Health
// ============================================================================
/**
* GET /api/v1/admin/system/health
* Get system-wide health status
*/
router.get('/system/health', async (req: Request, res: Response, next: NextFunction) => {
try {
// Check ML Engine
let mlHealth = 'unknown';
let mlLatency = 0;
try {
const mlStart = Date.now();
await mlEngineClient.healthCheck();
mlLatency = Date.now() - mlStart;
mlHealth = 'healthy';
} catch {
mlHealth = 'unhealthy';
}
// Check Trading Agents
let agentsHealth = 'unknown';
let agentsLatency = 0;
try {
const agentsStart = Date.now();
await tradingAgentsClient.healthCheck();
agentsLatency = Date.now() - agentsStart;
agentsHealth = 'healthy';
} catch {
agentsHealth = 'unhealthy';
}
const overallHealth = (mlHealth === 'healthy' && agentsHealth === 'healthy') ? 'healthy' : 'degraded';
const health = {
status: overallHealth,
services: {
database: {
status: 'healthy', // Mock for now - add actual DB check
latency: 5,
},
mlEngine: {
status: mlHealth,
latency: mlLatency,
},
tradingAgents: {
status: agentsHealth,
latency: agentsLatency,
},
redis: {
status: 'healthy', // Mock for now
latency: 2,
},
},
system: {
uptime: process.uptime(),
memory: {
used: process.memoryUsage().heapUsed,
total: process.memoryUsage().heapTotal,
percentage: (process.memoryUsage().heapUsed / process.memoryUsage().heapTotal) * 100,
},
cpu: process.cpuUsage(),
},
timestamp: new Date().toISOString(),
};
res.json({
success: true,
data: health,
});
} catch (error) {
next(error);
}
});
// ============================================================================
// Users Management
// ============================================================================
/**
* GET /api/v1/admin/users
* List all users with filters and pagination
*/
router.get('/users', async (req: Request, res: Response, next: NextFunction) => {
try {
const { page = 1, limit = 20, status, role, search } = req.query;
// Mock users data for development
const mockUsers = [
{
id: '1',
email: 'admin@orbiquant.local',
role: 'admin',
status: 'active',
created_at: new Date().toISOString(),
full_name: 'Admin OrbiQuant',
},
{
id: '2',
email: 'trader1@example.com',
role: 'premium',
status: 'active',
created_at: new Date().toISOString(),
full_name: 'Trader One',
},
{
id: '3',
email: 'trader2@example.com',
role: 'user',
status: 'active',
created_at: new Date().toISOString(),
full_name: 'Trader Two',
},
];
let filteredUsers = mockUsers;
if (status) {
filteredUsers = filteredUsers.filter(u => u.status === status);
}
if (role) {
filteredUsers = filteredUsers.filter(u => u.role === role);
}
if (search) {
const searchLower = (search as string).toLowerCase();
filteredUsers = filteredUsers.filter(u =>
u.email.toLowerCase().includes(searchLower) ||
u.full_name.toLowerCase().includes(searchLower)
);
}
const total = filteredUsers.length;
const start = (Number(page) - 1) * Number(limit);
const paginatedUsers = filteredUsers.slice(start, start + Number(limit));
res.json({
success: true,
data: paginatedUsers,
meta: {
total,
page: Number(page),
limit: Number(limit),
totalPages: Math.ceil(total / Number(limit)),
},
});
} catch (error) {
next(error);
}
});
/**
* GET /api/v1/admin/users/:id
* Get user details by ID
*/
router.get('/users/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
// Mock user data
const user = {
id,
email: 'admin@orbiquant.local',
role: 'admin',
status: 'active',
created_at: new Date().toISOString(),
full_name: 'Admin OrbiQuant',
avatar_url: null,
bio: 'Platform administrator',
location: 'Remote',
};
res.json({
success: true,
data: user,
});
} catch (error) {
next(error);
}
});
/**
* PATCH /api/v1/admin/users/:id/status
* Update user status
*/
router.patch('/users/:id/status', async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const { status, reason } = req.body;
if (!['active', 'suspended', 'banned'].includes(status)) {
res.status(400).json({
success: false,
error: { message: 'Invalid status value', code: 'VALIDATION_ERROR' },
});
return;
}
// Mock update - replace with actual DB update
res.json({
success: true,
data: {
id,
status,
updated_at: new Date().toISOString(),
},
});
} catch (error) {
next(error);
}
});
/**
* PATCH /api/v1/admin/users/:id/role
* Update user role
*/
router.patch('/users/:id/role', async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const { role } = req.body;
if (!['user', 'premium', 'admin'].includes(role)) {
res.status(400).json({
success: false,
error: { message: 'Invalid role value', code: 'VALIDATION_ERROR' },
});
return;
}
// Mock update - replace with actual DB update
res.json({
success: true,
data: {
id,
role,
updated_at: new Date().toISOString(),
},
});
} catch (error) {
next(error);
}
});
// ============================================================================
// Audit Logs
// ============================================================================
/**
* GET /api/v1/admin/audit/logs
* Get audit logs with filters
*/
router.get('/audit/logs', async (req: Request, res: Response, next: NextFunction) => {
try {
const { page = 1, limit = 50, userId, action, startDate, endDate } = req.query;
// Mock audit logs
const mockLogs = [
{
id: '1',
user_id: '1',
action: 'LOGIN',
resource: 'auth',
details: { ip: '192.168.1.1' },
ip_address: '192.168.1.1',
created_at: new Date().toISOString(),
},
{
id: '2',
user_id: '1',
action: 'UPDATE_SETTINGS',
resource: 'users',
details: { theme: 'dark' },
ip_address: '192.168.1.1',
created_at: new Date(Date.now() - 3600000).toISOString(),
},
{
id: '3',
user_id: '1',
action: 'CREATE_SIGNAL',
resource: 'trading',
details: { symbol: 'XAUUSD', direction: 'long' },
ip_address: '192.168.1.1',
created_at: new Date(Date.now() - 7200000).toISOString(),
},
];
let filteredLogs = mockLogs;
if (userId) {
filteredLogs = filteredLogs.filter(l => l.user_id === userId);
}
if (action) {
filteredLogs = filteredLogs.filter(l => l.action === action);
}
const total = filteredLogs.length;
const start = (Number(page) - 1) * Number(limit);
const paginatedLogs = filteredLogs.slice(start, start + Number(limit));
res.json({
success: true,
data: paginatedLogs,
meta: {
total,
page: Number(page),
limit: Number(limit),
totalPages: Math.ceil(total / Number(limit)),
},
});
} catch (error) {
next(error);
}
});
// ============================================================================
// Stats Endpoint (for admin dashboard widget)
// ============================================================================
/**
* GET /api/v1/admin/stats
* Get admin stats (alias for dashboard endpoint)
*/
router.get('/stats', async (req: Request, res: Response, next: NextFunction) => {
try {
res.json({
success: true,
data: {
total_models: 6,
active_models: 5,
total_predictions_today: 1247,
total_predictions_week: 8729,
overall_accuracy: 0.68,
total_agents: 3,
active_agents: 1,
total_signals_today: 24,
total_pnl_today: 1250.75,
total_pnl_week: 8456.32,
total_pnl_month: 32145.89,
system_health: 'healthy',
},
});
} catch (error) {
next(error);
}
});
export { router as adminRouter };

View File

@ -1,129 +0,0 @@
/**
* Trading Agents Routes
* Routes for managing trading agents (Atlas, Orion, Nova)
*/
import { Router, RequestHandler } from 'express';
import * as agentsController from './controllers/agents.controller';
const router = Router();
// Type cast helper for authenticated routes
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
const authHandler = (fn: Function): RequestHandler => fn as RequestHandler;
// ============================================================================
// Health & Status (Public)
// ============================================================================
/**
* GET /api/v1/agents/health
* Get Trading Agents service health
*/
router.get('/health', agentsController.getHealth);
/**
* GET /api/v1/agents/connection
* Check Trading Agents connection
*/
router.get('/connection', agentsController.checkConnection);
/**
* GET /api/v1/agents/summary
* Get all agents summary
*/
router.get('/summary', agentsController.getAllAgentsSummary);
// ============================================================================
// Agent Lifecycle (Authenticated)
// ============================================================================
/**
* POST /api/v1/agents/:agentType/start
* Start a trading agent
* Body: { initialEquity, symbols?, riskPerTrade?, maxPositions? }
*/
router.post('/:agentType/start', authHandler(agentsController.startAgent));
/**
* POST /api/v1/agents/:agentType/stop
* Stop a trading agent
*/
router.post('/:agentType/stop', authHandler(agentsController.stopAgent));
/**
* POST /api/v1/agents/:agentType/pause
* Pause a trading agent
*/
router.post('/:agentType/pause', authHandler(agentsController.pauseAgent));
/**
* POST /api/v1/agents/:agentType/resume
* Resume a trading agent
*/
router.post('/:agentType/resume', authHandler(agentsController.resumeAgent));
// ============================================================================
// Agent Status & Metrics (Public)
// ============================================================================
/**
* GET /api/v1/agents/:agentType/status
* Get agent status
*/
router.get('/:agentType/status', agentsController.getAgentStatus);
/**
* GET /api/v1/agents/:agentType/metrics
* Get agent metrics
*/
router.get('/:agentType/metrics', agentsController.getAgentMetrics);
// ============================================================================
// Positions & Trades
// ============================================================================
/**
* GET /api/v1/agents/:agentType/positions
* Get agent positions
*/
router.get('/:agentType/positions', agentsController.getAgentPositions);
/**
* GET /api/v1/agents/:agentType/trades
* Get agent trades
* Query params: limit, offset, symbol
*/
router.get('/:agentType/trades', agentsController.getAgentTrades);
/**
* POST /api/v1/agents/:agentType/positions/:positionId/close
* Close a specific position
*/
router.post('/:agentType/positions/:positionId/close', authHandler(agentsController.closePosition));
/**
* POST /api/v1/agents/:agentType/positions/close-all
* Close all positions
*/
router.post('/:agentType/positions/close-all', authHandler(agentsController.closeAllPositions));
// ============================================================================
// Signals (Authenticated)
// ============================================================================
/**
* POST /api/v1/agents/:agentType/signal
* Send signal to an agent
* Body: { symbol, action, confidence, price, stopLoss?, takeProfit? }
*/
router.post('/:agentType/signal', authHandler(agentsController.sendSignal));
/**
* POST /api/v1/agents/signals/broadcast
* Broadcast signal to all running agents
* Body: { symbol, action, confidence, price, stopLoss?, takeProfit? }
*/
router.post('/signals/broadcast', authHandler(agentsController.broadcastSignal));
export { router as agentsRouter };

View File

@ -1,504 +0,0 @@
/**
* Agents Controller
* Handles Trading Agents integration endpoints
*/
import { Request, Response, NextFunction } from 'express';
import { agentsService, StartAgentRequest } from '../services/agents.service';
import { AgentType, SignalInput } from '../../../shared/clients';
// ============================================================================
// Types
// ============================================================================
type AuthRequest = Request;
// ============================================================================
// Health & Status
// ============================================================================
/**
* Get Trading Agents service health
*/
export async function getHealth(req: Request, res: Response, _next: NextFunction): Promise<void> {
try {
const health = await agentsService.getHealth();
res.json({
success: true,
data: health,
});
} catch {
// Service unavailable
res.json({
success: true,
data: {
status: 'unavailable',
message: 'Trading Agents service is not running',
},
});
}
}
/**
* Check Trading Agents connection
*/
export async function checkConnection(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const isAvailable = await agentsService.isAvailable();
res.json({
success: true,
data: { connected: isAvailable },
});
} catch (error) {
next(error);
}
}
/**
* Get all agents summary
*/
export async function getAllAgentsSummary(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const summaries = await agentsService.getAllAgentsSummary();
res.json({
success: true,
data: summaries,
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Agent Lifecycle
// ============================================================================
/**
* Start a trading agent
*/
export async function startAgent(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { agentType } = req.params;
const { initialEquity, symbols, riskPerTrade, maxPositions } = req.body;
if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) {
res.status(400).json({
success: false,
error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' },
});
return;
}
if (!initialEquity || initialEquity <= 0) {
res.status(400).json({
success: false,
error: { message: 'Initial equity is required and must be positive', code: 'VALIDATION_ERROR' },
});
return;
}
const request: StartAgentRequest = {
agentType: agentType as AgentType,
initialEquity,
symbols,
riskPerTrade,
maxPositions,
};
const status = await agentsService.startAgent(request);
res.status(201).json({
success: true,
data: status,
});
} catch (error) {
next(error);
}
}
/**
* Stop a trading agent
*/
export async function stopAgent(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { agentType } = req.params;
if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) {
res.status(400).json({
success: false,
error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' },
});
return;
}
const status = await agentsService.stopAgent(agentType as AgentType);
res.json({
success: true,
data: status,
});
} catch (error) {
next(error);
}
}
/**
* Pause a trading agent
*/
export async function pauseAgent(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { agentType } = req.params;
if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) {
res.status(400).json({
success: false,
error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' },
});
return;
}
const status = await agentsService.pauseAgent(agentType as AgentType);
res.json({
success: true,
data: status,
});
} catch (error) {
next(error);
}
}
/**
* Resume a trading agent
*/
export async function resumeAgent(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { agentType } = req.params;
if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) {
res.status(400).json({
success: false,
error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' },
});
return;
}
const status = await agentsService.resumeAgent(agentType as AgentType);
res.json({
success: true,
data: status,
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Agent Status & Metrics
// ============================================================================
/**
* Get agent status
*/
export async function getAgentStatus(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { agentType } = req.params;
if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) {
res.status(400).json({
success: false,
error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' },
});
return;
}
const status = await agentsService.getAgentStatus(agentType as AgentType);
res.json({
success: true,
data: status,
});
} catch (error) {
next(error);
}
}
/**
* Get agent metrics
*/
export async function getAgentMetrics(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { agentType } = req.params;
if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) {
res.status(400).json({
success: false,
error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' },
});
return;
}
const metrics = await agentsService.getAgentMetrics(agentType as AgentType);
res.json({
success: true,
data: metrics,
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Positions & Trades
// ============================================================================
/**
* Get agent positions
*/
export async function getAgentPositions(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { agentType } = req.params;
if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) {
res.status(400).json({
success: false,
error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' },
});
return;
}
const positions = await agentsService.getAgentPositions(agentType as AgentType);
res.json({
success: true,
data: positions,
});
} catch (error) {
next(error);
}
}
/**
* Get agent trades
*/
export async function getAgentTrades(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { agentType } = req.params;
const { limit, offset, symbol } = req.query;
if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) {
res.status(400).json({
success: false,
error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' },
});
return;
}
const trades = await agentsService.getAgentTrades(agentType as AgentType, {
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
symbol: symbol as string | undefined,
});
res.json({
success: true,
data: trades,
});
} catch (error) {
next(error);
}
}
/**
* Close a position
*/
export async function closePosition(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { agentType, positionId } = req.params;
if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) {
res.status(400).json({
success: false,
error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' },
});
return;
}
const trade = await agentsService.closePosition(agentType as AgentType, positionId);
res.json({
success: true,
data: trade,
});
} catch (error) {
next(error);
}
}
/**
* Close all positions
*/
export async function closeAllPositions(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { agentType } = req.params;
if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) {
res.status(400).json({
success: false,
error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' },
});
return;
}
const result = await agentsService.closeAllPositions(agentType as AgentType);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Signals
// ============================================================================
/**
* Send signal to agent
*/
export async function sendSignal(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { agentType } = req.params;
const signal: SignalInput = req.body;
if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) {
res.status(400).json({
success: false,
error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' },
});
return;
}
if (!signal.symbol || !signal.action || signal.confidence === undefined) {
res.status(400).json({
success: false,
error: { message: 'Signal requires symbol, action, confidence, and price', code: 'VALIDATION_ERROR' },
});
return;
}
const result = await agentsService.sendSignal(agentType as AgentType, signal);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
}
/**
* Broadcast signal to all agents
*/
export async function broadcastSignal(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const signal: SignalInput = req.body;
if (!signal.symbol || !signal.action || signal.confidence === undefined) {
res.status(400).json({
success: false,
error: { message: 'Signal requires symbol, action, confidence, and price', code: 'VALIDATION_ERROR' },
});
return;
}
const result = await agentsService.broadcastSignal(signal);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
}

View File

@ -1,230 +0,0 @@
/**
* Agents Service
* Business logic for Trading Agents integration
*/
import {
tradingAgentsClient,
AgentType,
AgentConfig,
AgentStatusResponse,
AgentMetrics,
AgentPosition,
AgentTrade,
SignalInput,
} from '../../../shared/clients';
import { logger } from '../../../shared/utils/logger';
// ============================================================================
// Types
// ============================================================================
export interface StartAgentRequest {
agentType: AgentType;
initialEquity: number;
symbols?: string[];
riskPerTrade?: number;
maxPositions?: number;
}
export interface AgentSummary {
name: AgentType;
status: string;
equity: number;
positions: number;
todayPnl: number;
winRate?: number;
totalTrades?: number;
}
// ============================================================================
// Service Implementation
// ============================================================================
class AgentsService {
/**
* Check if Trading Agents service is available
*/
async isAvailable(): Promise<boolean> {
return tradingAgentsClient.isAvailable();
}
/**
* Get health status of Trading Agents service
*/
async getHealth() {
return tradingAgentsClient.healthCheck();
}
/**
* Start a trading agent
*/
async startAgent(request: StartAgentRequest): Promise<AgentStatusResponse> {
const config: AgentConfig = {
name: request.agentType,
initial_equity: request.initialEquity,
symbols: request.symbols,
risk_per_trade: request.riskPerTrade,
max_positions: request.maxPositions,
};
logger.info('[AgentsService] Starting agent', {
agentType: request.agentType,
initialEquity: request.initialEquity,
});
return tradingAgentsClient.startAgent(request.agentType, config);
}
/**
* Stop a trading agent
*/
async stopAgent(agentType: AgentType): Promise<AgentStatusResponse> {
logger.info('[AgentsService] Stopping agent', { agentType });
return tradingAgentsClient.stopAgent(agentType);
}
/**
* Pause a trading agent
*/
async pauseAgent(agentType: AgentType): Promise<AgentStatusResponse> {
logger.info('[AgentsService] Pausing agent', { agentType });
return tradingAgentsClient.pauseAgent(agentType);
}
/**
* Resume a trading agent
*/
async resumeAgent(agentType: AgentType): Promise<AgentStatusResponse> {
logger.info('[AgentsService] Resuming agent', { agentType });
return tradingAgentsClient.resumeAgent(agentType);
}
/**
* Get agent status
*/
async getAgentStatus(agentType: AgentType): Promise<AgentStatusResponse> {
return tradingAgentsClient.getAgentStatus(agentType);
}
/**
* Get agent metrics
*/
async getAgentMetrics(agentType: AgentType): Promise<AgentMetrics> {
return tradingAgentsClient.getAgentMetrics(agentType);
}
/**
* Get all agents summary
*/
async getAllAgentsSummary(): Promise<AgentSummary[]> {
const agents: AgentType[] = ['atlas', 'orion', 'nova'];
const summaries: AgentSummary[] = [];
for (const agentType of agents) {
try {
const status = await tradingAgentsClient.getAgentStatus(agentType);
const metrics = status.status === 'running'
? await tradingAgentsClient.getAgentMetrics(agentType)
: null;
summaries.push({
name: agentType,
status: status.status,
equity: status.equity,
positions: status.positions,
todayPnl: status.today_pnl,
winRate: metrics?.win_rate,
totalTrades: metrics?.total_trades,
});
} catch {
// Agent service might not be running
summaries.push({
name: agentType,
status: 'unavailable',
equity: 0,
positions: 0,
todayPnl: 0,
});
}
}
return summaries;
}
/**
* Get agent positions
*/
async getAgentPositions(agentType: AgentType): Promise<AgentPosition[]> {
return tradingAgentsClient.getPositions(agentType);
}
/**
* Get agent trades history
*/
async getAgentTrades(
agentType: AgentType,
options?: { limit?: number; offset?: number; symbol?: string }
): Promise<AgentTrade[]> {
return tradingAgentsClient.getTrades(agentType, options);
}
/**
* Close a specific position
*/
async closePosition(agentType: AgentType, positionId: string): Promise<AgentTrade> {
logger.info('[AgentsService] Closing position', { agentType, positionId });
return tradingAgentsClient.closePosition(agentType, positionId);
}
/**
* Close all positions for an agent
*/
async closeAllPositions(agentType: AgentType): Promise<{ closed: number }> {
logger.info('[AgentsService] Closing all positions', { agentType });
return tradingAgentsClient.closeAllPositions(agentType);
}
/**
* Send a signal to an agent
*/
async sendSignal(agentType: AgentType, signal: SignalInput): Promise<{ received: boolean }> {
logger.debug('[AgentsService] Sending signal', {
agentType,
symbol: signal.symbol,
action: signal.action,
});
return tradingAgentsClient.sendSignal(agentType, signal);
}
/**
* Broadcast signal to all running agents
*/
async broadcastSignal(signal: SignalInput): Promise<{ agents_notified: number }> {
logger.info('[AgentsService] Broadcasting signal', {
symbol: signal.symbol,
action: signal.action,
});
return tradingAgentsClient.broadcastSignal(signal);
}
/**
* Map product ID to agent type
* Used for Investment module integration
*/
getAgentTypeByProduct(productId: string): AgentType {
// Product mapping logic
// Conservative products -> Atlas
// Moderate products -> Orion
// Aggressive products -> Nova
if (productId.includes('conservative') || productId.includes('atlas')) {
return 'atlas';
} else if (productId.includes('aggressive') || productId.includes('nova')) {
return 'nova';
}
return 'orion'; // Default to moderate
}
}
// Export singleton
export const agentsService = new AgentsService();

View File

@ -1,305 +0,0 @@
// ============================================================================
// OrbiQuant IA - Auth Routes
// ============================================================================
import { Router } from 'express';
import { validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
import { authRateLimiter, strictRateLimiter } from '../../core/middleware/rate-limiter';
import { authenticate } from '../../core/middleware/auth.middleware';
import * as authController from './controllers/auth.controller';
import * as validators from './validators/auth.validators';
const router = Router();
// Validation middleware
const validate = (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array().map((e) => ({
field: e.type === 'field' ? (e as Record<string, unknown>).path as string | undefined : undefined,
message: e.msg,
})),
});
}
next();
};
// Apply rate limiting
router.use(authRateLimiter);
// ============================================================================
// Email/Password Authentication
// ============================================================================
/**
* POST /api/v1/auth/register
* Register a new user with email/password
*/
router.post(
'/register',
strictRateLimiter,
validators.registerValidator,
validate,
authController.register
);
/**
* POST /api/v1/auth/login
* Login with email/password
*/
router.post(
'/login',
strictRateLimiter,
validators.loginValidator,
validate,
authController.login
);
/**
* POST /api/v1/auth/verify-email
* Verify email with token
*/
router.post(
'/verify-email',
validators.tokenValidator,
validate,
authController.verifyEmail
);
/**
* POST /api/v1/auth/forgot-password
* Request password reset email
*/
router.post(
'/forgot-password',
strictRateLimiter,
validators.emailValidator,
validate,
authController.forgotPassword
);
/**
* POST /api/v1/auth/reset-password
* Reset password with token
*/
router.post(
'/reset-password',
validators.resetPasswordValidator,
validate,
authController.resetPassword
);
/**
* POST /api/v1/auth/change-password
* Change password (authenticated)
*/
router.post(
'/change-password',
authenticate,
validators.changePasswordValidator,
validate,
authController.changePassword
);
// ============================================================================
// Phone Authentication (SMS/WhatsApp)
// ============================================================================
/**
* POST /api/v1/auth/phone/send-otp
* Send OTP to phone via SMS or WhatsApp
*/
router.post(
'/phone/send-otp',
strictRateLimiter,
validators.phoneOTPValidator,
validate,
authController.sendPhoneOTP
);
/**
* POST /api/v1/auth/phone/verify-otp
* Verify OTP and login/register
*/
router.post(
'/phone/verify-otp',
validators.verifyPhoneOTPValidator,
validate,
authController.verifyPhoneOTP
);
// ============================================================================
// OAuth Authentication
// ============================================================================
/**
* GET /api/v1/auth/:provider
* Get OAuth authorization URL
*/
router.get(
'/:provider',
validators.oauthProviderValidator,
validate,
authController.getOAuthUrl
);
/**
* GET /api/v1/auth/:provider/callback
* OAuth callback handler
*/
router.get(
'/:provider/callback',
validators.oauthProviderValidator,
validate,
authController.handleOAuthCallback
);
/**
* POST /api/v1/auth/:provider/callback
* OAuth callback handler (POST for Apple)
*/
router.post(
'/:provider/callback',
validators.oauthProviderValidator,
validate,
authController.handleOAuthCallback
);
/**
* POST /api/v1/auth/:provider/token
* Verify OAuth token directly (for mobile apps)
*/
router.post(
'/:provider/token',
validators.oauthProviderValidator,
validate,
authController.verifyOAuthToken
);
// ============================================================================
// Token Management
// ============================================================================
/**
* POST /api/v1/auth/refresh
* Refresh access token
*/
router.post(
'/refresh',
validators.refreshTokenValidator,
validate,
authController.refreshToken
);
/**
* POST /api/v1/auth/logout
* Logout current session
*/
router.post('/logout', authenticate, authController.logout);
/**
* POST /api/v1/auth/logout-all
* Logout all sessions
*/
router.post('/logout-all', authenticate, authController.logoutAll);
/**
* GET /api/v1/auth/sessions
* Get active sessions
*/
router.get('/sessions', authenticate, authController.getSessions);
/**
* DELETE /api/v1/auth/sessions/:sessionId
* Revoke specific session
*/
router.delete(
'/sessions/:sessionId',
authenticate,
validators.sessionIdValidator,
validate,
authController.revokeSession
);
// ============================================================================
// Two-Factor Authentication
// ============================================================================
/**
* POST /api/v1/auth/2fa/setup
* Set up 2FA (get QR code and backup codes)
*/
router.post('/2fa/setup', authenticate, authController.setup2FA);
/**
* POST /api/v1/auth/2fa/enable
* Enable 2FA after setup
*/
router.post(
'/2fa/enable',
authenticate,
validators.totpCodeValidator,
validate,
authController.enable2FA
);
/**
* POST /api/v1/auth/2fa/disable
* Disable 2FA
*/
router.post(
'/2fa/disable',
authenticate,
validators.totpCodeValidator,
validate,
authController.disable2FA
);
/**
* POST /api/v1/auth/2fa/backup-codes
* Regenerate backup codes
*/
router.post(
'/2fa/backup-codes',
authenticate,
validators.totpCodeValidator,
validate,
authController.regenerateBackupCodes
);
// ============================================================================
// Account Linking
// ============================================================================
/**
* GET /api/v1/auth/linked-accounts
* Get linked OAuth accounts
*/
router.get('/linked-accounts', authenticate, authController.getLinkedAccounts);
/**
* DELETE /api/v1/auth/linked-accounts/:provider
* Unlink OAuth account
*/
router.delete(
'/linked-accounts/:provider',
authenticate,
validators.oauthProviderValidator,
validate,
authController.unlinkAccount
);
// ============================================================================
// Current User
// ============================================================================
/**
* GET /api/v1/auth/me
* Get current authenticated user
*/
router.get('/me', authenticate, authController.getCurrentUser);
export { router as authRouter };

View File

@ -1,570 +0,0 @@
// ============================================================================
// OrbiQuant IA - Auth Controller
// ============================================================================
import { Request, Response, NextFunction } from 'express';
import { emailService } from '../services/email.service';
import { oauthService } from '../services/oauth.service';
import { phoneService } from '../services/phone.service';
import { twoFactorService } from '../services/twofa.service';
import { tokenService } from '../services/token.service';
import { config } from '../../../config';
import { logger } from '../../../shared/utils/logger';
import type { AuthProvider } from '../types/auth.types';
// Helper to get client info
const getClientInfo = (req: Request) => ({
userAgent: req.headers['user-agent'],
ipAddress: req.ip || req.socket.remoteAddress,
});
// ============================================================================
// Email/Password Authentication
// ============================================================================
export const register = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, password, firstName, lastName, acceptTerms } = req.body;
const { userAgent, ipAddress } = getClientInfo(req);
const result = await emailService.register(
{ email, password, firstName, lastName, acceptTerms },
userAgent,
ipAddress
);
res.status(201).json({
success: true,
message: result.message,
data: { userId: result.userId },
});
} catch (error) {
next(error);
}
};
export const login = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, password, totpCode, rememberMe } = req.body;
const { userAgent, ipAddress } = getClientInfo(req);
const result = await emailService.login(
{ email, password, totpCode, rememberMe },
userAgent,
ipAddress
);
if (result.requiresTwoFactor) {
return res.status(200).json({
success: true,
requiresTwoFactor: true,
message: 'Please enter your 2FA code',
});
}
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
export const verifyEmail = async (req: Request, res: Response, next: NextFunction) => {
try {
const { token } = req.body;
const result = await emailService.verifyEmail(token);
res.json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
};
export const forgotPassword = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email } = req.body;
const result = await emailService.sendPasswordResetEmail(email);
res.json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
};
export const resetPassword = async (req: Request, res: Response, next: NextFunction) => {
try {
const { token, password } = req.body;
const result = await emailService.resetPassword(token, password);
res.json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
};
export const changePassword = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const { currentPassword, newPassword } = req.body;
const result = await emailService.changePassword(userId, currentPassword, newPassword);
res.json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
};
// ============================================================================
// Phone Authentication (SMS/WhatsApp)
// ============================================================================
export const sendPhoneOTP = async (req: Request, res: Response, next: NextFunction) => {
try {
const { phoneNumber, countryCode, channel } = req.body;
const result = await phoneService.sendOTP(phoneNumber, countryCode, channel);
res.json({
success: true,
message: result.message,
data: { expiresAt: result.expiresAt },
});
} catch (error) {
next(error);
}
};
export const verifyPhoneOTP = async (req: Request, res: Response, next: NextFunction) => {
try {
const { phoneNumber, countryCode, otpCode } = req.body;
const { userAgent, ipAddress } = getClientInfo(req);
const result = await phoneService.verifyOTP(
phoneNumber,
countryCode,
otpCode,
userAgent,
ipAddress
);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
// ============================================================================
// OAuth Authentication
// ============================================================================
// Store OAuth state in memory (use Redis in production)
const oauthStates = new Map<string, { codeVerifier?: string; returnUrl?: string }>();
export const getOAuthUrl = async (req: Request, res: Response, next: NextFunction) => {
try {
const provider = req.params.provider as AuthProvider;
const { returnUrl } = req.query;
const state = oauthService.generateState();
const stateData: { codeVerifier?: string; returnUrl?: string } = {
returnUrl: returnUrl as string,
};
let authUrl: string;
switch (provider) {
case 'google':
authUrl = oauthService.getGoogleAuthUrl(state);
break;
case 'facebook':
authUrl = oauthService.getFacebookAuthUrl(state);
break;
case 'twitter': {
const codeVerifier = oauthService.generateCodeVerifier();
const codeChallenge = oauthService.generateCodeChallenge(codeVerifier);
stateData.codeVerifier = codeVerifier;
authUrl = oauthService.getTwitterAuthUrl(state, codeChallenge);
break;
}
case 'apple':
authUrl = oauthService.getAppleAuthUrl(state);
break;
case 'github':
authUrl = oauthService.getGitHubAuthUrl(state);
break;
default:
return res.status(400).json({
success: false,
error: 'Invalid OAuth provider',
});
}
// Store state
oauthStates.set(state, stateData);
// Clean up old states after 10 minutes
setTimeout(() => oauthStates.delete(state), 10 * 60 * 1000);
res.json({
success: true,
data: { authUrl },
});
} catch (error) {
next(error);
}
};
export const handleOAuthCallback = async (req: Request, res: Response, _next: NextFunction) => {
try {
const provider = req.params.provider as AuthProvider;
const { code, state } = req.query;
const { userAgent, ipAddress } = getClientInfo(req);
// Verify state
const stateData = oauthStates.get(state as string);
if (!stateData) {
return res.redirect(`${config.app.frontendUrl}/login?error=invalid_state`);
}
oauthStates.delete(state as string);
let oauthData;
switch (provider) {
case 'google':
oauthData = await oauthService.verifyGoogleToken(code as string);
break;
case 'facebook':
oauthData = await oauthService.verifyFacebookToken(code as string);
break;
case 'twitter':
if (!stateData.codeVerifier) {
return res.redirect(`${config.app.frontendUrl}/login?error=missing_code_verifier`);
}
oauthData = await oauthService.verifyTwitterToken(code as string, stateData.codeVerifier);
break;
case 'apple':
oauthData = await oauthService.verifyAppleToken(code as string, req.query.id_token as string);
break;
case 'github':
oauthData = await oauthService.verifyGitHubToken(code as string);
break;
default:
return res.redirect(`${config.app.frontendUrl}/login?error=invalid_provider`);
}
if (!oauthData) {
return res.redirect(`${config.app.frontendUrl}/login?error=oauth_failed`);
}
// Handle OAuth login/registration
const result = await oauthService.handleOAuthCallback(oauthData, userAgent, ipAddress);
// Redirect with tokens
const params = new URLSearchParams({
accessToken: result.tokens.accessToken,
refreshToken: result.tokens.refreshToken,
isNewUser: result.isNewUser?.toString() || 'false',
});
const returnUrl = stateData.returnUrl || '/dashboard';
res.redirect(`${config.app.frontendUrl}/auth/callback?${params}&returnUrl=${encodeURIComponent(returnUrl)}`);
} catch (error) {
logger.error('OAuth callback error', { error });
res.redirect(`${config.app.frontendUrl}/login?error=oauth_error`);
}
};
// Mobile/SPA OAuth - verify token directly
export const verifyOAuthToken = async (req: Request, res: Response, next: NextFunction) => {
try {
const provider = req.params.provider as AuthProvider;
const { token } = req.body;
const { userAgent, ipAddress } = getClientInfo(req);
let oauthData;
switch (provider) {
case 'google':
// For mobile, we receive an ID token directly
oauthData = await oauthService.verifyGoogleIdToken(token);
break;
// Other providers would need their mobile SDKs
default:
return res.status(400).json({
success: false,
error: 'Provider not supported for direct token verification',
});
}
if (!oauthData) {
return res.status(401).json({
success: false,
error: 'Invalid OAuth token',
});
}
const result = await oauthService.handleOAuthCallback(oauthData, userAgent, ipAddress);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
// ============================================================================
// Token Management
// ============================================================================
export const refreshToken = async (req: Request, res: Response, next: NextFunction) => {
try {
const { refreshToken } = req.body;
const tokens = await tokenService.refreshSession(refreshToken);
if (!tokens) {
return res.status(401).json({
success: false,
error: 'Invalid or expired refresh token',
});
}
res.json({
success: true,
data: tokens,
});
} catch (error) {
next(error);
}
};
export const logout = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const sessionId = req.sessionId;
if (sessionId) {
await tokenService.revokeSession(sessionId, userId);
}
res.json({
success: true,
message: 'Logged out successfully',
});
} catch (error) {
next(error);
}
};
export const logoutAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const count = await tokenService.revokeAllUserSessions(userId);
res.json({
success: true,
message: `Logged out from ${count} sessions`,
});
} catch (error) {
next(error);
}
};
export const getSessions = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const sessions = await tokenService.getActiveSessions(userId);
res.json({
success: true,
data: sessions.map((s) => ({
id: s.id,
userAgent: s.userAgent,
ipAddress: s.ipAddress,
createdAt: s.createdAt,
lastActiveAt: s.lastActiveAt,
isCurrent: s.id === req.sessionId,
})),
});
} catch (error) {
next(error);
}
};
export const revokeSession = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const { sessionId } = req.params;
const revoked = await tokenService.revokeSession(sessionId, userId);
if (!revoked) {
return res.status(404).json({
success: false,
error: 'Session not found',
});
}
res.json({
success: true,
message: 'Session revoked',
});
} catch (error) {
next(error);
}
};
// ============================================================================
// Two-Factor Authentication
// ============================================================================
export const setup2FA = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const result = await twoFactorService.setupTOTP(userId);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
export const enable2FA = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const { code } = req.body;
const result = await twoFactorService.enableTOTP(userId, code);
res.json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
};
export const disable2FA = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const { code } = req.body;
const result = await twoFactorService.disableTOTP(userId, code);
res.json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
};
export const regenerateBackupCodes = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const { code } = req.body;
const result = await twoFactorService.regenerateBackupCodes(userId, code);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
// ============================================================================
// Account Linking
// ============================================================================
export const getLinkedAccounts = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const accounts = await oauthService.getLinkedAccounts(userId);
res.json({
success: true,
data: accounts,
});
} catch (error) {
next(error);
}
};
export const unlinkAccount = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const provider = req.params.provider as AuthProvider;
await oauthService.unlinkOAuthAccount(userId, provider);
res.json({
success: true,
message: `${provider} account unlinked`,
});
} catch (error) {
next(error);
}
};
// ============================================================================
// Current User
// ============================================================================
export const getCurrentUser = async (req: Request, res: Response, next: NextFunction) => {
try {
res.json({
success: true,
data: {
user: req.user,
},
});
} catch (error) {
next(error);
}
};

View File

@ -1,168 +0,0 @@
/**
* EmailAuthController
*
* @description Controller for email/password authentication.
* Extracted from auth.controller.ts (P0-009: Auth Controller split).
*
* Routes:
* - POST /auth/register - Register new user
* - POST /auth/login - Login with email/password
* - POST /auth/verify-email - Verify email address
* - POST /auth/forgot-password - Request password reset
* - POST /auth/reset-password - Reset password with token
* - POST /auth/change-password - Change password (authenticated)
*
* @see OAuthController - OAuth authentication
* @see TwoFactorController - 2FA operations
* @see TokenController - Token management
*/
import { Request, Response, NextFunction } from 'express';
import { emailService } from '../services/email.service';
/**
* Gets client info from request
*/
const getClientInfo = (req: Request) => ({
userAgent: req.headers['user-agent'],
ipAddress: req.ip || req.socket.remoteAddress,
});
/**
* POST /auth/register
*
* Register a new user with email/password
*/
export const register = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, password, firstName, lastName, acceptTerms } = req.body;
const { userAgent, ipAddress } = getClientInfo(req);
const result = await emailService.register(
{ email, password, firstName, lastName, acceptTerms },
userAgent,
ipAddress,
);
res.status(201).json({
success: true,
message: result.message,
data: { userId: result.userId },
});
} catch (error) {
next(error);
}
};
/**
* POST /auth/login
*
* Login with email/password (supports 2FA)
*/
export const login = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, password, totpCode, rememberMe } = req.body;
const { userAgent, ipAddress } = getClientInfo(req);
const result = await emailService.login(
{ email, password, totpCode, rememberMe },
userAgent,
ipAddress,
);
if (result.requiresTwoFactor) {
return res.status(200).json({
success: true,
requiresTwoFactor: true,
message: 'Please enter your 2FA code',
});
}
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
/**
* POST /auth/verify-email
*
* Verify email address with token
*/
export const verifyEmail = async (req: Request, res: Response, next: NextFunction) => {
try {
const { token } = req.body;
const result = await emailService.verifyEmail(token);
res.json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
};
/**
* POST /auth/forgot-password
*
* Request password reset email
*/
export const forgotPassword = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email } = req.body;
const result = await emailService.sendPasswordResetEmail(email);
res.json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
};
/**
* POST /auth/reset-password
*
* Reset password using token from email
*/
export const resetPassword = async (req: Request, res: Response, next: NextFunction) => {
try {
const { token, password } = req.body;
const result = await emailService.resetPassword(token, password);
res.json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
};
/**
* POST /auth/change-password
*
* Change password for authenticated user
*/
export const changePassword = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const { currentPassword, newPassword } = req.body;
const result = await emailService.changePassword(userId, currentPassword, newPassword);
res.json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
};

View File

@ -1,57 +0,0 @@
/**
* Auth Controllers
*
* @description Export all auth controllers.
* Result of P0-009: Auth Controller split.
*
* Original auth.controller.ts (571 LOC) divided into:
* - email-auth.controller.ts: Email/password authentication
* - oauth.controller.ts: OAuth providers (Google, Facebook, Twitter, Apple, GitHub)
* - phone-auth.controller.ts: Phone OTP authentication
* - two-factor.controller.ts: 2FA/TOTP operations
* - token.controller.ts: Token and session management
*/
// Email/Password Authentication
export {
register,
login,
verifyEmail,
forgotPassword,
resetPassword,
changePassword,
} from './email-auth.controller';
// OAuth Authentication
export {
getOAuthUrl,
handleOAuthCallback,
verifyOAuthToken,
getLinkedAccounts,
unlinkAccount,
} from './oauth.controller';
// Phone Authentication
export {
sendPhoneOTP,
verifyPhoneOTP,
} from './phone-auth.controller';
// Two-Factor Authentication
export {
setup2FA,
enable2FA,
disable2FA,
regenerateBackupCodes,
get2FAStatus,
} from './two-factor.controller';
// Token/Session Management
export {
refreshToken,
logout,
logoutAll,
getSessions,
revokeSession,
getCurrentUser,
} from './token.controller';

View File

@ -1,248 +0,0 @@
/**
* OAuthController
*
* @description Controller for OAuth authentication (Google, Facebook, Twitter, Apple, GitHub).
* Extracted from auth.controller.ts (P0-009: Auth Controller split).
*
* Routes:
* - GET /auth/oauth/:provider - Get OAuth authorization URL
* - GET /auth/callback/:provider - Handle OAuth callback
* - POST /auth/oauth/:provider/verify - Verify OAuth token (mobile/SPA)
* - GET /auth/accounts - Get linked OAuth accounts
* - DELETE /auth/accounts/:provider - Unlink OAuth account
*
* @see EmailAuthController - Email/password authentication
* @see TwoFactorController - 2FA operations
* @see oauthStateStore - Redis-based state storage (P0-010)
*/
import { Request, Response, NextFunction } from 'express';
import { oauthService } from '../services/oauth.service';
import { oauthStateStore } from '../stores/oauth-state.store';
import { config } from '../../../config';
import { logger } from '../../../shared/utils/logger';
import type { AuthProvider } from '../types/auth.types';
/**
* Gets client info from request
*/
const getClientInfo = (req: Request) => ({
userAgent: req.headers['user-agent'],
ipAddress: req.ip || req.socket.remoteAddress,
});
/**
* GET /auth/oauth/:provider
*
* Get OAuth authorization URL for provider
*/
export const getOAuthUrl = async (req: Request, res: Response, next: NextFunction) => {
try {
const provider = req.params.provider as AuthProvider;
const { returnUrl } = req.query;
const state = oauthService.generateState();
let codeVerifier: string | undefined;
let authUrl: string;
switch (provider) {
case 'google':
authUrl = oauthService.getGoogleAuthUrl(state);
break;
case 'facebook':
authUrl = oauthService.getFacebookAuthUrl(state);
break;
case 'twitter': {
codeVerifier = oauthService.generateCodeVerifier();
const codeChallenge = oauthService.generateCodeChallenge(codeVerifier);
authUrl = oauthService.getTwitterAuthUrl(state, codeChallenge);
break;
}
case 'apple':
authUrl = oauthService.getAppleAuthUrl(state);
break;
case 'github':
authUrl = oauthService.getGitHubAuthUrl(state);
break;
default:
return res.status(400).json({
success: false,
error: 'Invalid OAuth provider',
});
}
// Store state in Redis (P0-010: OAuth state → Redis)
await oauthStateStore.set(state, {
provider,
codeVerifier,
returnUrl: returnUrl as string,
});
res.json({
success: true,
data: { authUrl },
});
} catch (error) {
next(error);
}
};
/**
* GET /auth/callback/:provider
*
* Handle OAuth callback from provider
*/
export const handleOAuthCallback = async (req: Request, res: Response, _next: NextFunction) => {
try {
const provider = req.params.provider as AuthProvider;
const { code, state } = req.query;
const { userAgent, ipAddress } = getClientInfo(req);
// Verify and retrieve state from Redis (P0-010)
const stateData = await oauthStateStore.getAndDelete(state as string);
if (!stateData) {
return res.redirect(`${config.app.frontendUrl}/login?error=invalid_state`);
}
let oauthData;
switch (provider) {
case 'google':
oauthData = await oauthService.verifyGoogleToken(code as string);
break;
case 'facebook':
oauthData = await oauthService.verifyFacebookToken(code as string);
break;
case 'twitter':
if (!stateData.codeVerifier) {
return res.redirect(`${config.app.frontendUrl}/login?error=missing_code_verifier`);
}
oauthData = await oauthService.verifyTwitterToken(code as string, stateData.codeVerifier);
break;
case 'apple':
oauthData = await oauthService.verifyAppleToken(code as string, req.query.id_token as string);
break;
case 'github':
oauthData = await oauthService.verifyGitHubToken(code as string);
break;
default:
return res.redirect(`${config.app.frontendUrl}/login?error=invalid_provider`);
}
if (!oauthData) {
return res.redirect(`${config.app.frontendUrl}/login?error=oauth_failed`);
}
// Handle OAuth login/registration
const result = await oauthService.handleOAuthCallback(oauthData, userAgent, ipAddress);
// Redirect with tokens
const params = new URLSearchParams({
accessToken: result.tokens.accessToken,
refreshToken: result.tokens.refreshToken,
isNewUser: result.isNewUser?.toString() || 'false',
});
const returnUrl = stateData.returnUrl || '/dashboard';
res.redirect(`${config.app.frontendUrl}/auth/callback?${params}&returnUrl=${encodeURIComponent(returnUrl)}`);
} catch (error) {
logger.error('OAuth callback error', { error });
res.redirect(`${config.app.frontendUrl}/login?error=oauth_error`);
}
};
/**
* POST /auth/oauth/:provider/verify
*
* Verify OAuth token directly (for mobile/SPA)
*/
export const verifyOAuthToken = async (req: Request, res: Response, next: NextFunction) => {
try {
const provider = req.params.provider as AuthProvider;
const { token } = req.body;
const { userAgent, ipAddress } = getClientInfo(req);
let oauthData;
switch (provider) {
case 'google':
// For mobile, we receive an ID token directly
oauthData = await oauthService.verifyGoogleIdToken(token);
break;
// Other providers would need their mobile SDKs
default:
return res.status(400).json({
success: false,
error: 'Provider not supported for direct token verification',
});
}
if (!oauthData) {
return res.status(401).json({
success: false,
error: 'Invalid OAuth token',
});
}
const result = await oauthService.handleOAuthCallback(oauthData, userAgent, ipAddress);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
/**
* GET /auth/accounts
*
* Get all linked OAuth accounts for authenticated user
*/
export const getLinkedAccounts = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const accounts = await oauthService.getLinkedAccounts(userId);
res.json({
success: true,
data: accounts,
});
} catch (error) {
next(error);
}
};
/**
* DELETE /auth/accounts/:provider
*
* Unlink an OAuth account from user profile
*/
export const unlinkAccount = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const provider = req.params.provider as AuthProvider;
await oauthService.unlinkOAuthAccount(userId, provider);
res.json({
success: true,
message: `${provider} account unlinked`,
});
} catch (error) {
next(error);
}
};

View File

@ -1,71 +0,0 @@
/**
* PhoneAuthController
*
* @description Controller for phone-based authentication (SMS/WhatsApp OTP).
* Extracted from auth.controller.ts (P0-009: Auth Controller split).
*
* Routes:
* - POST /auth/phone/send-otp - Send OTP via SMS or WhatsApp
* - POST /auth/phone/verify - Verify phone OTP and authenticate
*
* @see EmailAuthController - Email/password authentication
* @see OAuthController - OAuth authentication
*/
import { Request, Response, NextFunction } from 'express';
import { phoneService } from '../services/phone.service';
/**
* Gets client info from request
*/
const getClientInfo = (req: Request) => ({
userAgent: req.headers['user-agent'],
ipAddress: req.ip || req.socket.remoteAddress,
});
/**
* POST /auth/phone/send-otp
*
* Send OTP to phone number via SMS or WhatsApp
*/
export const sendPhoneOTP = async (req: Request, res: Response, next: NextFunction) => {
try {
const { phoneNumber, countryCode, channel } = req.body;
const result = await phoneService.sendOTP(phoneNumber, countryCode, channel);
res.json({
success: true,
message: result.message,
data: { expiresAt: result.expiresAt },
});
} catch (error) {
next(error);
}
};
/**
* POST /auth/phone/verify
*
* Verify phone OTP and authenticate user
*/
export const verifyPhoneOTP = async (req: Request, res: Response, next: NextFunction) => {
try {
const { phoneNumber, countryCode, otpCode } = req.body;
const { userAgent, ipAddress } = getClientInfo(req);
const result = await phoneService.verifyOTP(
phoneNumber,
countryCode,
otpCode,
userAgent,
ipAddress,
);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};

View File

@ -1,162 +0,0 @@
/**
* TokenController
*
* @description Controller for token and session management.
* Extracted from auth.controller.ts (P0-009: Auth Controller split).
*
* Routes:
* - POST /auth/refresh - Refresh access token
* - POST /auth/logout - Logout current session
* - POST /auth/logout/all - Logout all sessions
* - GET /auth/sessions - Get active sessions
* - DELETE /auth/sessions/:sessionId - Revoke specific session
* - GET /auth/me - Get current user info
*
* @see EmailAuthController - Email/password authentication
* @see OAuthController - OAuth authentication
*/
import { Request, Response, NextFunction } from 'express';
import { tokenService } from '../services/token.service';
/**
* POST /auth/refresh
*
* Refresh access token using refresh token
*/
export const refreshToken = async (req: Request, res: Response, next: NextFunction) => {
try {
const { refreshToken } = req.body;
const tokens = await tokenService.refreshSession(refreshToken);
if (!tokens) {
return res.status(401).json({
success: false,
error: 'Invalid or expired refresh token',
});
}
res.json({
success: true,
data: tokens,
});
} catch (error) {
next(error);
}
};
/**
* POST /auth/logout
*
* Logout current session
*/
export const logout = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const sessionId = req.sessionId;
if (sessionId) {
await tokenService.revokeSession(sessionId, userId);
}
res.json({
success: true,
message: 'Logged out successfully',
});
} catch (error) {
next(error);
}
};
/**
* POST /auth/logout/all
*
* Logout from all sessions
*/
export const logoutAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const count = await tokenService.revokeAllUserSessions(userId);
res.json({
success: true,
message: `Logged out from ${count} sessions`,
});
} catch (error) {
next(error);
}
};
/**
* GET /auth/sessions
*
* Get all active sessions for authenticated user
*/
export const getSessions = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const sessions = await tokenService.getActiveSessions(userId);
res.json({
success: true,
data: sessions.map((s) => ({
id: s.id,
userAgent: s.userAgent,
ipAddress: s.ipAddress,
createdAt: s.createdAt,
lastActiveAt: s.lastActiveAt,
isCurrent: s.id === req.sessionId,
})),
});
} catch (error) {
next(error);
}
};
/**
* DELETE /auth/sessions/:sessionId
*
* Revoke a specific session
*/
export const revokeSession = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const { sessionId } = req.params;
const revoked = await tokenService.revokeSession(sessionId, userId);
if (!revoked) {
return res.status(404).json({
success: false,
error: 'Session not found',
});
}
res.json({
success: true,
message: 'Session revoked',
});
} catch (error) {
next(error);
}
};
/**
* GET /auth/me
*
* Get current authenticated user information
*/
export const getCurrentUser = async (req: Request, res: Response, next: NextFunction) => {
try {
res.json({
success: true,
data: {
user: req.user,
},
});
} catch (error) {
next(error);
}
};

View File

@ -1,124 +0,0 @@
/**
* TwoFactorController
*
* @description Controller for Two-Factor Authentication (2FA/TOTP).
* Extracted from auth.controller.ts (P0-009: Auth Controller split).
*
* Routes:
* - POST /auth/2fa/setup - Generate TOTP secret and QR code
* - POST /auth/2fa/enable - Enable 2FA with verification code
* - POST /auth/2fa/disable - Disable 2FA with verification code
* - POST /auth/2fa/backup-codes - Regenerate backup codes
*
* @see EmailAuthController - Email/password authentication (handles 2FA during login)
* @see TokenController - Token management
*/
import { Request, Response, NextFunction } from 'express';
import { twoFactorService } from '../services/twofa.service';
/**
* POST /auth/2fa/setup
*
* Generate TOTP secret and QR code for 2FA setup
*/
export const setup2FA = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const result = await twoFactorService.setupTOTP(userId);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
/**
* POST /auth/2fa/enable
*
* Enable 2FA after verifying the setup code
*/
export const enable2FA = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const { code } = req.body;
const result = await twoFactorService.enableTOTP(userId, code);
res.json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
};
/**
* POST /auth/2fa/disable
*
* Disable 2FA with verification code
*/
export const disable2FA = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const { code } = req.body;
const result = await twoFactorService.disableTOTP(userId, code);
res.json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
};
/**
* POST /auth/2fa/backup-codes
*
* Regenerate backup codes (requires 2FA verification)
*/
export const regenerateBackupCodes = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const { code } = req.body;
const result = await twoFactorService.regenerateBackupCodes(userId, code);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
/**
* GET /auth/2fa/status
*
* Get 2FA status for authenticated user
*/
export const get2FAStatus = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const status = await twoFactorService.getTOTPStatus(userId);
res.json({
success: true,
data: {
enabled: status.enabled,
method: status.method,
backupCodesRemaining: status.backupCodesRemaining,
},
});
} catch (error) {
next(error);
}
};

View File

@ -1,41 +0,0 @@
/**
* Change Password DTO - Input validation for password changes
*/
import { IsString, MinLength, MaxLength, Matches, IsNotEmpty } from 'class-validator';
export class ChangePasswordDto {
@IsString()
@IsNotEmpty({ message: 'Current password is required' })
currentPassword: string;
@IsString()
@MinLength(8, { message: 'New password must be at least 8 characters long' })
@MaxLength(128, { message: 'New password cannot exceed 128 characters' })
@Matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
{ message: 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character' }
)
newPassword: string;
}
export class ResetPasswordDto {
@IsString()
@IsNotEmpty({ message: 'Reset token is required' })
token: string;
@IsString()
@MinLength(8, { message: 'Password must be at least 8 characters long' })
@MaxLength(128)
@Matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
{ message: 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character' }
)
newPassword: string;
}
export class ForgotPasswordDto {
@IsString()
@IsNotEmpty({ message: 'Email is required' })
email: string;
}

View File

@ -1,17 +0,0 @@
/**
* Auth DTOs - Export all validation DTOs
*/
export { RegisterDto } from './register.dto';
export { LoginDto } from './login.dto';
export { RefreshTokenDto } from './refresh-token.dto';
export {
ChangePasswordDto,
ResetPasswordDto,
ForgotPasswordDto,
} from './change-password.dto';
export {
OAuthInitiateDto,
OAuthCallbackDto,
type OAuthProvider,
} from './oauth.dto';

View File

@ -1,29 +0,0 @@
/**
* Login DTO - Input validation for user login
*
* @usage
* ```typescript
* router.post('/login', validateDto(LoginDto), authController.login);
* ```
*/
import { IsEmail, IsString, MinLength, MaxLength, IsOptional, IsBoolean, Length } from 'class-validator';
export class LoginDto {
@IsEmail({}, { message: 'Please provide a valid email address' })
email: string;
@IsString()
@MinLength(1, { message: 'Password is required' })
@MaxLength(128)
password: string;
@IsString()
@Length(6, 6, { message: 'TOTP code must be exactly 6 digits' })
@IsOptional()
totpCode?: string;
@IsBoolean()
@IsOptional()
rememberMe?: boolean;
}

View File

@ -1,36 +0,0 @@
/**
* OAuth DTOs - Input validation for OAuth flows
*/
import { IsString, IsNotEmpty, IsIn, IsOptional } from 'class-validator';
const SUPPORTED_PROVIDERS = ['google', 'github', 'apple'] as const;
export type OAuthProvider = typeof SUPPORTED_PROVIDERS[number];
export class OAuthInitiateDto {
@IsString()
@IsIn(SUPPORTED_PROVIDERS, { message: 'Unsupported OAuth provider' })
provider: OAuthProvider;
@IsString()
@IsOptional()
redirectUri?: string;
}
export class OAuthCallbackDto {
@IsString()
@IsNotEmpty({ message: 'Authorization code is required' })
code: string;
@IsString()
@IsNotEmpty({ message: 'State parameter is required' })
state: string;
@IsString()
@IsOptional()
error?: string;
@IsString()
@IsOptional()
error_description?: string;
}

View File

@ -1,11 +0,0 @@
/**
* Refresh Token DTO - Input validation for token refresh
*/
import { IsString, IsNotEmpty } from 'class-validator';
export class RefreshTokenDto {
@IsString()
@IsNotEmpty({ message: 'Refresh token is required' })
refreshToken: string;
}

View File

@ -1,38 +0,0 @@
/**
* Register DTO - Input validation for user registration
*
* @usage
* ```typescript
* router.post('/register', validateDto(RegisterDto), authController.register);
* ```
*/
import { IsEmail, IsString, MinLength, MaxLength, IsBoolean, IsOptional, Matches } from 'class-validator';
export class RegisterDto {
@IsEmail({}, { message: 'Please provide a valid email address' })
email: string;
@IsString()
@MinLength(8, { message: 'Password must be at least 8 characters long' })
@MaxLength(128, { message: 'Password cannot exceed 128 characters' })
@Matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
{ message: 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character' }
)
password: string;
@IsString()
@MinLength(1, { message: 'First name is required' })
@MaxLength(100)
@IsOptional()
firstName?: string;
@IsString()
@MaxLength(100)
@IsOptional()
lastName?: string;
@IsBoolean({ message: 'You must accept the terms and conditions' })
acceptTerms: boolean;
}

View File

@ -1,497 +0,0 @@
/**
* Email Service Unit Tests
*
* Tests for email authentication service including:
* - User registration
* - User login
* - Email verification
* - Password reset flows
*/
import type { User, Profile } from '../../types/auth.types';
import { mockDb, createMockQueryResult, createMockPoolClient, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock';
import { sentEmails, resetEmailMocks, findEmailByRecipient } from '../../../../__tests__/mocks/email.mock';
// Mock database
jest.mock('../../../../shared/database', () => ({
db: mockDb,
}));
// Import service after mocks are set up
import { EmailService } from '../email.service';
// Mock dependencies
jest.mock('../token.service', () => ({
tokenService: {
generateEmailToken: jest.fn(() => 'mock-email-token-123'),
hashToken: jest.fn((token: string) => `hashed-${token}`),
createSession: jest.fn(() => ({
session: {
id: 'session-123',
userId: 'user-123',
refreshToken: 'refresh-token-123',
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
lastActiveAt: new Date(),
},
tokens: {
accessToken: 'access-token-123',
refreshToken: 'refresh-token-123',
expiresIn: 900,
tokenType: 'Bearer' as const,
},
})),
revokeAllUserSessions: jest.fn(() => Promise.resolve(2)),
},
}));
jest.mock('../twofa.service', () => ({
twoFactorService: {
verifyTOTP: jest.fn(() => Promise.resolve(true)),
},
}));
jest.mock('bcryptjs', () => ({
hash: jest.fn((password: string) => Promise.resolve(`hashed-${password}`)),
compare: jest.fn((password: string, hash: string) => {
return Promise.resolve(hash === `hashed-${password}`);
}),
}));
describe('EmailService', () => {
let emailService: EmailService;
beforeEach(() => {
resetDatabaseMocks();
resetEmailMocks();
emailService = new EmailService();
});
describe('register', () => {
const validRegistrationData = {
email: 'newuser@example.com',
password: 'StrongPass123!',
firstName: 'John',
lastName: 'Doe',
acceptTerms: true,
};
it('should successfully register a new user', async () => {
// Mock: Check if user exists (should not exist)
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
// Mock: Get pool client for transaction
const mockClient = createMockPoolClient();
mockDb.getClient.mockResolvedValueOnce(mockClient);
// Mock: Create user
const mockUser: User = {
id: 'user-123',
email: 'newuser@example.com',
emailVerified: false,
phoneVerified: false,
primaryAuthProvider: 'email',
totpEnabled: false,
role: 'investor',
status: 'pending',
failedLoginAttempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
mockClient.query
.mockResolvedValueOnce({ command: 'BEGIN', rowCount: 0, rows: [], oid: 0, fields: [] })
.mockResolvedValueOnce(createMockQueryResult([mockUser]))
.mockResolvedValueOnce(createMockQueryResult([]))
.mockResolvedValueOnce(createMockQueryResult([]))
.mockResolvedValueOnce({ command: 'COMMIT', rowCount: 0, rows: [], oid: 0, fields: [] });
// Mock: Store verification token
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
// Mock: Log auth event
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const result = await emailService.register(validRegistrationData, 'Mozilla/5.0', '127.0.0.1');
expect(result).toEqual({
userId: 'user-123',
message: 'Registration successful. Please check your email to verify your account.',
});
// Verify email was sent
expect(sentEmails).toHaveLength(1);
const verificationEmail = findEmailByRecipient('newuser@example.com');
expect(verificationEmail).toBeDefined();
expect(verificationEmail?.subject).toContain('Verifica tu cuenta');
});
it('should reject registration if email already exists', async () => {
// Mock: User exists
mockDb.query.mockResolvedValueOnce(
createMockQueryResult([{ id: 'existing-user-123' }])
);
await expect(
emailService.register(validRegistrationData, 'Mozilla/5.0', '127.0.0.1')
).rejects.toThrow('Email already registered');
});
it('should reject registration if terms not accepted', async () => {
const invalidData = { ...validRegistrationData, acceptTerms: false };
await expect(
emailService.register(invalidData, 'Mozilla/5.0', '127.0.0.1')
).rejects.toThrow('You must accept the terms and conditions');
});
it('should reject weak passwords', async () => {
// Mock: User does not exist
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const weakPasswordData = { ...validRegistrationData, password: 'weak' };
await expect(
emailService.register(weakPasswordData, 'Mozilla/5.0', '127.0.0.1')
).rejects.toThrow('Password must be at least 8 characters long');
});
it('should rollback transaction on error', async () => {
// Mock: Check if user exists (should not exist)
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
// Mock: Get pool client for transaction
const mockClient = createMockPoolClient();
mockDb.getClient.mockResolvedValueOnce(mockClient);
// Mock transaction failure
mockClient.query
.mockResolvedValueOnce({ command: 'BEGIN', rowCount: 0, rows: [], oid: 0, fields: [] })
.mockRejectedValueOnce(new Error('Database error'));
await expect(
emailService.register(validRegistrationData, 'Mozilla/5.0', '127.0.0.1')
).rejects.toThrow('Database error');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
});
});
describe('login', () => {
const loginData = {
email: 'user@example.com',
password: 'StrongPass123!',
};
const mockUser: User = {
id: 'user-123',
email: 'user@example.com',
emailVerified: true,
phoneVerified: false,
encryptedPassword: 'hashed-StrongPass123!',
primaryAuthProvider: 'email',
totpEnabled: false,
role: 'investor',
status: 'active',
failedLoginAttempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockProfile: Profile = {
id: 'profile-123',
userId: 'user-123',
firstName: 'John',
lastName: 'Doe',
displayName: 'John Doe',
timezone: 'UTC',
language: 'en',
preferredCurrency: 'USD',
};
it('should successfully login with valid credentials', async () => {
// Mock: Get user
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUser]));
// Mock: Reset failed attempts
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
// Mock: Get profile
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockProfile]));
// Mock: Log success
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const result = await emailService.login(loginData, 'Mozilla/5.0', '127.0.0.1');
expect(result.user).toBeDefined();
expect(result.user.id).toBe('user-123');
expect(result.user).not.toHaveProperty('encryptedPassword');
expect(result.profile).toEqual(mockProfile);
expect(result.tokens).toBeDefined();
expect(result.tokens.accessToken).toBe('access-token-123');
});
it('should reject login with invalid email', async () => {
// Mock: User not found
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([]))
.mockResolvedValueOnce(createMockQueryResult([])); // Log failed login
await expect(
emailService.login(loginData, 'Mozilla/5.0', '127.0.0.1')
).rejects.toThrow('Invalid email or password');
});
it('should reject login with invalid password', async () => {
// Mock: Get user
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUser]));
// Mock: Increment failed attempts
mockDb.query.mockResolvedValueOnce(
createMockQueryResult([{ failed_login_attempts: 1 }])
);
// Mock: Log failed login
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const invalidLogin = { ...loginData, password: 'WrongPassword123!' };
await expect(
emailService.login(invalidLogin, 'Mozilla/5.0', '127.0.0.1')
).rejects.toThrow('Invalid email or password');
});
it('should reject login if email not verified', async () => {
const unverifiedUser = { ...mockUser, emailVerified: false, status: 'pending' as const };
// Mock: Get user
mockDb.query.mockResolvedValueOnce(createMockQueryResult([unverifiedUser]));
await expect(
emailService.login(loginData, 'Mozilla/5.0', '127.0.0.1')
).rejects.toThrow('Please verify your email before logging in');
});
it('should reject login if account is banned', async () => {
const bannedUser = { ...mockUser, status: 'banned' as const };
// Mock: Get user
mockDb.query.mockResolvedValueOnce(createMockQueryResult([bannedUser]));
await expect(
emailService.login(loginData, 'Mozilla/5.0', '127.0.0.1')
).rejects.toThrow('Account has been suspended');
});
it('should reject login if account is locked', async () => {
const lockedUser = {
...mockUser,
lockedUntil: new Date(Date.now() + 60 * 60 * 1000),
};
// Mock: Get user
mockDb.query.mockResolvedValueOnce(createMockQueryResult([lockedUser]));
await expect(
emailService.login(loginData, 'Mozilla/5.0', '127.0.0.1')
).rejects.toThrow('Account is temporarily locked');
});
it('should require 2FA code when enabled', async () => {
const user2FA = { ...mockUser, totpEnabled: true };
// Mock: Get user
mockDb.query.mockResolvedValueOnce(createMockQueryResult([user2FA]));
const result = await emailService.login(loginData, 'Mozilla/5.0', '127.0.0.1');
expect(result.requiresTwoFactor).toBe(true);
expect(result.tokens.accessToken).toBe('');
});
});
describe('verifyEmail', () => {
it('should successfully verify email with valid token', async () => {
const mockVerification = {
id: 'verification-123',
email: 'user@example.com',
token: 'mock-email-token-123',
tokenHash: 'hashed-mock-email-token-123',
userId: 'user-123',
purpose: 'verify',
used: false,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
createdAt: new Date(),
};
// Mock: Get verification
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockVerification]));
// Mock: Mark token as used
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
// Mock: Activate user
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
// Mock: Log event
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const result = await emailService.verifyEmail('mock-email-token-123');
expect(result).toEqual({
success: true,
message: 'Email verified successfully. You can now log in.',
});
});
it('should reject invalid verification token', async () => {
// Mock: Token not found
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
await expect(emailService.verifyEmail('invalid-token')).rejects.toThrow(
'Invalid or expired verification link'
);
});
});
describe('sendPasswordResetEmail', () => {
it('should send password reset email for existing user', async () => {
// Mock: User exists
mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ id: 'user-123' }]));
// Mock: Store reset token
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
// Mock: Log event
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const result = await emailService.sendPasswordResetEmail('user@example.com');
expect(result.message).toContain('If an account exists with this email');
expect(sentEmails).toHaveLength(1);
const resetEmail = findEmailByRecipient('user@example.com');
expect(resetEmail).toBeDefined();
expect(resetEmail?.subject).toContain('Restablece tu contraseña');
});
it('should not reveal if user does not exist', async () => {
// Mock: User not found
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const result = await emailService.sendPasswordResetEmail('nonexistent@example.com');
expect(result.message).toContain('If an account exists with this email');
expect(sentEmails).toHaveLength(0);
});
});
describe('resetPassword', () => {
it('should successfully reset password with valid token', async () => {
const mockVerification = {
id: 'verification-123',
email: 'user@example.com',
token: 'reset-token-123',
tokenHash: 'hashed-reset-token-123',
userId: 'user-123',
purpose: 'reset_password',
used: false,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
createdAt: new Date(),
};
// Mock: Get verification
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockVerification]));
// Mock: Update password
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
// Mock: Mark token as used
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
// Mock: Log event
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const result = await emailService.resetPassword('reset-token-123', 'NewStrongPass123!');
expect(result.message).toContain('Password reset successfully');
});
it('should reject weak new password', async () => {
const mockVerification = {
id: 'verification-123',
email: 'user@example.com',
userId: 'user-123',
purpose: 'reset_password',
used: false,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
};
// Mock: Get verification
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockVerification]));
await expect(emailService.resetPassword('reset-token-123', 'weak')).rejects.toThrow(
'Password must be at least 8 characters long'
);
});
});
describe('changePassword', () => {
it('should successfully change password with valid current password', async () => {
const mockUser: User = {
id: 'user-123',
email: 'user@example.com',
emailVerified: true,
phoneVerified: false,
encryptedPassword: 'hashed-OldPass123!',
primaryAuthProvider: 'email',
totpEnabled: false,
role: 'investor',
status: 'active',
failedLoginAttempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
// Mock: Get user
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUser]));
// Mock: Update password
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const result = await emailService.changePassword(
'user-123',
'OldPass123!',
'NewStrongPass123!'
);
expect(result.message).toBe('Password changed successfully');
});
it('should reject incorrect current password', async () => {
const mockUser: User = {
id: 'user-123',
email: 'user@example.com',
emailVerified: true,
phoneVerified: false,
encryptedPassword: 'hashed-OldPass123!',
primaryAuthProvider: 'email',
totpEnabled: false,
role: 'investor',
status: 'active',
failedLoginAttempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
// Mock: Get user
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUser]));
await expect(
emailService.changePassword('user-123', 'WrongPass123!', 'NewStrongPass123!')
).rejects.toThrow('Current password is incorrect');
});
});
});

View File

@ -1,489 +0,0 @@
/**
* Token Service Unit Tests
*
* Tests for token management including:
* - JWT token generation
* - Token verification
* - Session management
* - Token refresh
* - Session revocation
*/
import jwt from 'jsonwebtoken';
import type { User, Session, JWTPayload, JWTRefreshPayload } from '../../types/auth.types';
import { mockDb, createMockQueryResult, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock';
// Mock database
jest.mock('../../../../shared/database', () => ({
db: mockDb,
}));
// Mock config
jest.mock('../../../../config', () => ({
config: {
jwt: {
accessSecret: 'test-access-secret',
refreshSecret: 'test-refresh-secret',
accessExpiry: '15m',
refreshExpiry: '7d',
},
},
}));
// Import service after mocks
import { TokenService } from '../token.service';
describe('TokenService', () => {
let tokenService: TokenService;
const mockUser: User = {
id: 'user-123',
email: 'user@example.com',
emailVerified: true,
phoneVerified: false,
primaryAuthProvider: 'email',
totpEnabled: false,
role: 'investor',
status: 'active',
failedLoginAttempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(() => {
resetDatabaseMocks();
tokenService = new TokenService();
});
describe('generateAccessToken', () => {
it('should generate a valid access token', () => {
const token = tokenService.generateAccessToken(mockUser);
expect(token).toBeTruthy();
expect(typeof token).toBe('string');
// Verify token structure
const decoded = jwt.verify(token, 'test-access-secret') as JWTPayload;
expect(decoded.sub).toBe('user-123');
expect(decoded.email).toBe('user@example.com');
expect(decoded.role).toBe('investor');
expect(decoded.provider).toBe('email');
expect(decoded.exp).toBeDefined();
expect(decoded.iat).toBeDefined();
});
it('should include correct expiry time', () => {
const token = tokenService.generateAccessToken(mockUser);
const decoded = jwt.verify(token, 'test-access-secret') as JWTPayload;
const now = Math.floor(Date.now() / 1000);
const expectedExpiry = now + 15 * 60; // 15 minutes
expect(decoded.exp).toBeGreaterThan(now);
expect(decoded.exp).toBeLessThanOrEqual(expectedExpiry + 5); // Allow 5 second buffer
});
});
describe('generateRefreshToken', () => {
it('should generate a valid refresh token', () => {
const token = tokenService.generateRefreshToken('user-123', 'session-123');
expect(token).toBeTruthy();
expect(typeof token).toBe('string');
// Verify token structure
const decoded = jwt.verify(token, 'test-refresh-secret') as JWTRefreshPayload;
expect(decoded.sub).toBe('user-123');
expect(decoded.sessionId).toBe('session-123');
expect(decoded.exp).toBeDefined();
expect(decoded.iat).toBeDefined();
});
it('should include correct expiry time for refresh token', () => {
const token = tokenService.generateRefreshToken('user-123', 'session-123');
const decoded = jwt.verify(token, 'test-refresh-secret') as JWTRefreshPayload;
const now = Math.floor(Date.now() / 1000);
const expectedExpiry = now + 7 * 24 * 60 * 60; // 7 days
expect(decoded.exp).toBeGreaterThan(now);
expect(decoded.exp).toBeLessThanOrEqual(expectedExpiry + 5); // Allow 5 second buffer
});
});
describe('verifyAccessToken', () => {
it('should verify a valid access token', () => {
const token = tokenService.generateAccessToken(mockUser);
const payload = tokenService.verifyAccessToken(token);
expect(payload).toBeTruthy();
expect(payload?.sub).toBe('user-123');
expect(payload?.email).toBe('user@example.com');
});
it('should return null for invalid token', () => {
const payload = tokenService.verifyAccessToken('invalid-token');
expect(payload).toBeNull();
});
it('should return null for expired token', () => {
// Create an expired token
const expiredToken = jwt.sign(
{
sub: 'user-123',
email: 'user@example.com',
role: 'investor',
provider: 'email',
},
'test-access-secret',
{ expiresIn: '-1h' } // Expired 1 hour ago
);
const payload = tokenService.verifyAccessToken(expiredToken);
expect(payload).toBeNull();
});
it('should return null for token with wrong secret', () => {
const wrongToken = jwt.sign(
{
sub: 'user-123',
email: 'user@example.com',
},
'wrong-secret',
{ expiresIn: '15m' }
);
const payload = tokenService.verifyAccessToken(wrongToken);
expect(payload).toBeNull();
});
});
describe('verifyRefreshToken', () => {
it('should verify a valid refresh token', () => {
const token = tokenService.generateRefreshToken('user-123', 'session-123');
const payload = tokenService.verifyRefreshToken(token);
expect(payload).toBeTruthy();
expect(payload?.sub).toBe('user-123');
expect(payload?.sessionId).toBe('session-123');
});
it('should return null for invalid refresh token', () => {
const payload = tokenService.verifyRefreshToken('invalid-token');
expect(payload).toBeNull();
});
});
describe('createSession', () => {
it('should create a new session with tokens', async () => {
const mockSession: Session = {
id: 'session-123',
userId: 'user-123',
refreshToken: expect.any(String),
userAgent: 'Mozilla/5.0',
ipAddress: '127.0.0.1',
expiresAt: expect.any(Date),
createdAt: expect.any(Date),
lastActiveAt: expect.any(Date),
};
// Mock: Insert session
mockDb.query.mockResolvedValueOnce(
createMockQueryResult([{
id: 'session-123',
userId: 'user-123',
refreshToken: 'refresh-token-value',
userAgent: 'Mozilla/5.0',
ipAddress: '127.0.0.1',
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
lastActiveAt: new Date(),
}])
);
// Mock: Get user for access token
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUser]));
const result = await tokenService.createSession(
'user-123',
'Mozilla/5.0',
'127.0.0.1',
{ device: 'desktop' }
);
expect(result.session).toBeDefined();
expect(result.session.userId).toBe('user-123');
expect(result.tokens).toBeDefined();
expect(result.tokens.accessToken).toBeTruthy();
expect(result.tokens.refreshToken).toBeTruthy();
expect(result.tokens.tokenType).toBe('Bearer');
expect(result.tokens.expiresIn).toBe(900); // 15 minutes in seconds
});
it('should store device information', async () => {
const deviceInfo = {
browser: 'Chrome',
os: 'Windows 10',
device: 'desktop',
};
// Mock: Insert session
mockDb.query.mockResolvedValueOnce(
createMockQueryResult([{
id: 'session-123',
userId: 'user-123',
refreshToken: 'refresh-token-value',
deviceInfo,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
lastActiveAt: new Date(),
}])
);
// Mock: Get user
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUser]));
const result = await tokenService.createSession(
'user-123',
'Mozilla/5.0',
'127.0.0.1',
deviceInfo
);
// Verify INSERT query includes device info
const insertQuery = mockDb.query.mock.calls[0][0];
expect(insertQuery).toContain('device_info');
});
});
describe('refreshSession', () => {
it('should refresh tokens for valid session', async () => {
const refreshToken = tokenService.generateRefreshToken('user-123', 'session-123');
const mockSession: Session = {
id: 'session-123',
userId: 'user-123',
refreshToken: 'refresh-token-value',
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
lastActiveAt: new Date(),
};
// Mock: Get session
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockSession]));
// Mock: Update last active
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
// Mock: Get user
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUser]));
const result = await tokenService.refreshSession(refreshToken);
expect(result).toBeDefined();
expect(result?.accessToken).toBeTruthy();
expect(result?.refreshToken).toBeTruthy();
expect(result?.tokenType).toBe('Bearer');
});
it('should return null for invalid refresh token', async () => {
const result = await tokenService.refreshSession('invalid-token');
expect(result).toBeNull();
});
it('should return null for revoked session', async () => {
const refreshToken = tokenService.generateRefreshToken('user-123', 'session-123');
// Mock: Session is revoked
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const result = await tokenService.refreshSession(refreshToken);
expect(result).toBeNull();
});
it('should return null for expired session', async () => {
const refreshToken = tokenService.generateRefreshToken('user-123', 'session-123');
const expiredSession: Session = {
id: 'session-123',
userId: 'user-123',
refreshToken: 'refresh-token-value',
expiresAt: new Date(Date.now() - 1000), // Expired
createdAt: new Date(),
lastActiveAt: new Date(),
};
// Mock: Session exists but is expired
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const result = await tokenService.refreshSession(refreshToken);
expect(result).toBeNull();
});
});
describe('revokeSession', () => {
it('should revoke an active session', async () => {
// Mock: Revoke session
mockDb.query.mockResolvedValueOnce({
command: 'UPDATE',
rowCount: 1,
rows: [],
oid: 0,
fields: [],
});
const result = await tokenService.revokeSession('session-123', 'user-123');
expect(result).toBe(true);
});
it('should return false if session not found', async () => {
// Mock: Session not found
mockDb.query.mockResolvedValueOnce({
command: 'UPDATE',
rowCount: 0,
rows: [],
oid: 0,
fields: [],
});
const result = await tokenService.revokeSession('session-123', 'user-123');
expect(result).toBe(false);
});
});
describe('revokeAllUserSessions', () => {
it('should revoke all user sessions', async () => {
// Mock: Revoke all sessions
mockDb.query.mockResolvedValueOnce({
command: 'UPDATE',
rowCount: 3,
rows: [],
oid: 0,
fields: [],
});
const result = await tokenService.revokeAllUserSessions('user-123');
expect(result).toBe(3);
});
it('should revoke all sessions except specified one', async () => {
// Mock: Revoke all except one
mockDb.query.mockResolvedValueOnce({
command: 'UPDATE',
rowCount: 2,
rows: [],
oid: 0,
fields: [],
});
const result = await tokenService.revokeAllUserSessions('user-123', 'keep-session-123');
expect(result).toBe(2);
// Verify query includes exception
const query = mockDb.query.mock.calls[0][0];
expect(query).toContain('id != $2');
});
it('should return 0 if no sessions found', async () => {
// Mock: No sessions to revoke
mockDb.query.mockResolvedValueOnce({
command: 'UPDATE',
rowCount: 0,
rows: [],
oid: 0,
fields: [],
});
const result = await tokenService.revokeAllUserSessions('user-123');
expect(result).toBe(0);
});
});
describe('getActiveSessions', () => {
it('should return all active sessions for user', async () => {
const mockSessions: Session[] = [
{
id: 'session-1',
userId: 'user-123',
refreshToken: 'token-1',
userAgent: 'Chrome',
ipAddress: '127.0.0.1',
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
lastActiveAt: new Date(),
},
{
id: 'session-2',
userId: 'user-123',
refreshToken: 'token-2',
userAgent: 'Firefox',
ipAddress: '127.0.0.2',
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
lastActiveAt: new Date(),
},
];
// Mock: Get sessions
mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockSessions));
const result = await tokenService.getActiveSessions('user-123');
expect(result).toHaveLength(2);
expect(result[0].id).toBe('session-1');
expect(result[1].id).toBe('session-2');
});
it('should return empty array if no active sessions', async () => {
// Mock: No sessions
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const result = await tokenService.getActiveSessions('user-123');
expect(result).toHaveLength(0);
});
});
describe('generateEmailToken', () => {
it('should generate a random email token', () => {
const token1 = tokenService.generateEmailToken();
const token2 = tokenService.generateEmailToken();
expect(token1).toBeTruthy();
expect(token2).toBeTruthy();
expect(token1).not.toBe(token2);
expect(token1.length).toBe(64); // 32 bytes = 64 hex chars
});
});
describe('hashToken', () => {
it('should hash a token consistently', () => {
const token = 'test-token-123';
const hash1 = tokenService.hashToken(token);
const hash2 = tokenService.hashToken(token);
expect(hash1).toBeTruthy();
expect(hash1).toBe(hash2);
expect(hash1.length).toBe(64); // SHA-256 = 64 hex chars
});
it('should produce different hashes for different tokens', () => {
const hash1 = tokenService.hashToken('token-1');
const hash2 = tokenService.hashToken('token-2');
expect(hash1).not.toBe(hash2);
});
});
describe('parseExpiry', () => {
it('should parse different time formats correctly', () => {
// Access private method via type assertion for testing
const service = tokenService as unknown as {
parseExpiry: (expiry: string) => number;
};
expect(service.parseExpiry('60s')).toBe(60 * 1000);
expect(service.parseExpiry('15m')).toBe(15 * 60 * 1000);
expect(service.parseExpiry('2h')).toBe(2 * 60 * 60 * 1000);
expect(service.parseExpiry('7d')).toBe(7 * 24 * 60 * 60 * 1000);
});
});
});

View File

@ -1,583 +0,0 @@
// ============================================================================
// OrbiQuant IA - Email Authentication Service
// ============================================================================
import bcrypt from 'bcryptjs';
import nodemailer from 'nodemailer';
import { config } from '../../../config';
import { db } from '../../../shared/database';
import { tokenService } from './token.service';
import { twoFactorService } from './twofa.service';
import { logger } from '../../../shared/utils/logger';
import type {
User,
Profile,
AuthResponse,
RegisterEmailRequest,
LoginEmailRequest,
} from '../types/auth.types';
interface EmailVerification {
id: string;
email: string;
token: string;
tokenHash: string;
userId?: string;
purpose: string;
used: boolean;
expiresAt: Date;
usedAt?: Date;
createdAt: Date;
}
export class EmailService {
private transporter: nodemailer.Transporter;
constructor() {
this.transporter = nodemailer.createTransport({
host: config.email.host,
port: config.email.port,
secure: config.email.secure,
auth: {
user: config.email.user,
pass: config.email.password,
},
});
}
private async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12);
}
private async verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
async register(
data: RegisterEmailRequest,
userAgent?: string,
ipAddress?: string
): Promise<{ userId: string; message: string }> {
const { email, password, firstName, lastName, acceptTerms } = data;
if (!acceptTerms) {
throw new Error('You must accept the terms and conditions');
}
// Check if user exists
const existing = await db.query(
'SELECT id FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (existing.rows.length > 0) {
throw new Error('Email already registered');
}
// Validate password strength
this.validatePassword(password);
const client = await db.getClient();
try {
await client.query('BEGIN');
// Hash password
const hashedPassword = await this.hashPassword(password);
// Create user
const userResult = await client.query<User>(
`INSERT INTO users (email, encrypted_password, primary_auth_provider, status)
VALUES ($1, $2, 'email', 'pending')
RETURNING *`,
[email.toLowerCase(), hashedPassword]
);
const user = userResult.rows[0];
// Create profile
await client.query(
`INSERT INTO profiles (user_id, first_name, last_name, display_name)
VALUES ($1, $2, $3, $4)`,
[user.id, firstName, lastName, `${firstName || ''} ${lastName || ''}`.trim() || null]
);
// Create user settings
await client.query(
'INSERT INTO user_settings (user_id) VALUES ($1)',
[user.id]
);
await client.query('COMMIT');
// Send verification email
await this.sendVerificationEmail(user.id, email);
// Log event
await db.query(
`INSERT INTO auth_logs (user_id, event, provider, ip_address, user_agent, success)
VALUES ($1, 'register', 'email', $2, $3, true)`,
[user.id, ipAddress, userAgent]
);
logger.info('User registered', { userId: user.id, email });
return {
userId: user.id,
message: 'Registration successful. Please check your email to verify your account.',
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async login(
data: LoginEmailRequest,
userAgent?: string,
ipAddress?: string
): Promise<AuthResponse> {
const { email, password, totpCode } = data;
// Get user
const userResult = await db.query<User>(
'SELECT * FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (userResult.rows.length === 0) {
await this.logFailedLogin(null, ipAddress, userAgent, 'User not found');
throw new Error('Invalid email or password');
}
const user = userResult.rows[0];
// Check if account is locked
if (user.lockedUntil && new Date(user.lockedUntil) > new Date()) {
throw new Error('Account is temporarily locked. Please try again later.');
}
// Check status
if (user.status === 'banned') {
throw new Error('Account has been suspended');
}
// Verify password
if (!user.encryptedPassword) {
throw new Error('Please use your social login method');
}
const validPassword = await this.verifyPassword(password, user.encryptedPassword);
if (!validPassword) {
await this.handleFailedLogin(user.id, ipAddress, userAgent);
throw new Error('Invalid email or password');
}
// Check email verification
if (!user.emailVerified && user.status === 'pending') {
throw new Error('Please verify your email before logging in');
}
// Check 2FA
if (user.totpEnabled) {
if (!totpCode) {
return {
user: { id: user.id } as Omit<User, 'encryptedPassword'>,
tokens: { accessToken: '', refreshToken: '', expiresIn: 0, tokenType: 'Bearer' },
requiresTwoFactor: true,
};
}
// Verify TOTP code - will be handled by 2FA service
const valid = await this.verifyTOTP(user.id, totpCode);
if (!valid) {
throw new Error('Invalid 2FA code');
}
}
// Reset failed attempts on successful login
await db.query(
'UPDATE users SET failed_login_attempts = 0, locked_until = NULL, last_login_at = NOW(), last_login_ip = $1 WHERE id = $2',
[ipAddress, user.id]
);
// Get profile
const profileResult = await db.query<Profile>(
'SELECT * FROM profiles WHERE user_id = $1',
[user.id]
);
const profile = profileResult.rows[0];
// Log success
await db.query(
`INSERT INTO auth_logs (user_id, event, provider, ip_address, user_agent, success)
VALUES ($1, 'login_success', 'email', $2, $3, true)`,
[user.id, ipAddress, userAgent]
);
// Create session
const { tokens } = await tokenService.createSession(user.id, userAgent, ipAddress);
// Remove sensitive data
const { encryptedPassword: _, ...safeUser } = user;
return {
user: safeUser as Omit<User, 'encryptedPassword'>,
profile,
tokens,
};
}
private async handleFailedLogin(
userId: string,
ipAddress?: string,
userAgent?: string
): Promise<void> {
// Increment failed attempts
const result = await db.query<{ failed_login_attempts: number }>(
`UPDATE users
SET failed_login_attempts = failed_login_attempts + 1
WHERE id = $1
RETURNING failed_login_attempts`,
[userId]
);
const attempts = result.rows[0].failed_login_attempts;
// Lock account after 5 failed attempts
if (attempts >= 5) {
const lockDuration = Math.min(attempts * 5, 60); // Max 60 minutes
await db.query(
`UPDATE users SET locked_until = NOW() + INTERVAL '${lockDuration} minutes' WHERE id = $1`,
[userId]
);
await db.query(
`INSERT INTO auth_logs (user_id, event, provider, ip_address, user_agent, success, metadata)
VALUES ($1, 'account_locked', 'email', $2, $3, true, $4)`,
[userId, ipAddress, userAgent, JSON.stringify({ lockDurationMinutes: lockDuration })]
);
}
await this.logFailedLogin(userId, ipAddress, userAgent, 'Invalid password');
}
private async logFailedLogin(
userId: string | null,
ipAddress?: string,
userAgent?: string,
reason?: string
): Promise<void> {
await db.query(
`INSERT INTO auth_logs (user_id, event, provider, ip_address, user_agent, success, error_message)
VALUES ($1, 'login_failed', 'email', $2, $3, false, $4)`,
[userId, ipAddress, userAgent, reason]
);
}
async sendVerificationEmail(userId: string, email: string): Promise<void> {
const token = tokenService.generateEmailToken();
const tokenHash = tokenService.hashToken(token);
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
// Store verification token
await db.query(
`INSERT INTO email_verifications (email, token, token_hash, user_id, purpose, expires_at)
VALUES ($1, $2, $3, $4, 'verify', $5)`,
[email, token, tokenHash, userId, expiresAt]
);
// Send email
const verificationUrl = `${config.app.frontendUrl}/verify-email?token=${token}`;
await this.transporter.sendMail({
from: `"OrbiQuant" <${config.email.from}>`,
to: email,
subject: 'Verifica tu cuenta de OrbiQuant',
html: this.getVerificationEmailTemplate(verificationUrl),
});
logger.info('Verification email sent', { email });
}
async verifyEmail(token: string): Promise<{ success: boolean; message: string }> {
const tokenHash = tokenService.hashToken(token);
const result = await db.query<EmailVerification>(
`SELECT * FROM email_verifications
WHERE token_hash = $1 AND purpose = 'verify' AND used = FALSE AND expires_at > NOW()`,
[tokenHash]
);
if (result.rows.length === 0) {
throw new Error('Invalid or expired verification link');
}
const verification = result.rows[0];
// Mark token as used
await db.query(
'UPDATE email_verifications SET used = TRUE, used_at = NOW() WHERE id = $1',
[verification.id]
);
// Activate user
await db.query(
`UPDATE users SET email_verified = TRUE, status = 'active', confirmed_at = NOW()
WHERE id = $1`,
[verification.userId]
);
// Log event
await db.query(
`INSERT INTO auth_logs (user_id, event, provider, success)
VALUES ($1, 'email_verified', 'email', true)`,
[verification.userId]
);
return {
success: true,
message: 'Email verified successfully. You can now log in.',
};
}
async sendPasswordResetEmail(email: string): Promise<{ message: string }> {
const userResult = await db.query<User>(
'SELECT id FROM users WHERE email = $1',
[email.toLowerCase()]
);
// Don't reveal if user exists
if (userResult.rows.length === 0) {
return { message: 'If an account exists with this email, a reset link has been sent.' };
}
const user = userResult.rows[0];
const token = tokenService.generateEmailToken();
const tokenHash = tokenService.hashToken(token);
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
// Store reset token
await db.query(
`INSERT INTO email_verifications (email, token, token_hash, user_id, purpose, expires_at)
VALUES ($1, $2, $3, $4, 'reset_password', $5)`,
[email.toLowerCase(), token, tokenHash, user.id, expiresAt]
);
// Send email
const resetUrl = `${config.app.frontendUrl}/reset-password?token=${token}`;
await this.transporter.sendMail({
from: `"OrbiQuant" <${config.email.from}>`,
to: email,
subject: 'Restablece tu contraseña de OrbiQuant',
html: this.getPasswordResetEmailTemplate(resetUrl),
});
// Log event
await db.query(
`INSERT INTO auth_logs (user_id, event, provider, success)
VALUES ($1, 'password_reset_request', 'email', true)`,
[user.id]
);
logger.info('Password reset email sent', { email });
return { message: 'If an account exists with this email, a reset link has been sent.' };
}
async resetPassword(token: string, newPassword: string): Promise<{ message: string }> {
const tokenHash = tokenService.hashToken(token);
const result = await db.query<EmailVerification>(
`SELECT * FROM email_verifications
WHERE token_hash = $1 AND purpose = 'reset_password' AND used = FALSE AND expires_at > NOW()`,
[tokenHash]
);
if (result.rows.length === 0) {
throw new Error('Invalid or expired reset link');
}
const verification = result.rows[0];
// Validate new password
this.validatePassword(newPassword);
// Hash new password
const hashedPassword = await this.hashPassword(newPassword);
// Update password
await db.query(
'UPDATE users SET encrypted_password = $1, updated_at = NOW() WHERE id = $2',
[hashedPassword, verification.userId]
);
// Mark token as used
await db.query(
'UPDATE email_verifications SET used = TRUE, used_at = NOW() WHERE id = $1',
[verification.id]
);
// Revoke all sessions
await tokenService.revokeAllUserSessions(verification.userId!);
// Log event
await db.query(
`INSERT INTO auth_logs (user_id, event, provider, success)
VALUES ($1, 'password_reset_complete', 'email', true)`,
[verification.userId]
);
return { message: 'Password reset successfully. Please log in with your new password.' };
}
async changePassword(
userId: string,
currentPassword: string,
newPassword: string
): Promise<{ message: string }> {
// Get user
const userResult = await db.query<User>(
'SELECT * FROM users WHERE id = $1',
[userId]
);
if (userResult.rows.length === 0) {
throw new Error('User not found');
}
const user = userResult.rows[0];
if (!user.encryptedPassword) {
throw new Error('Cannot change password for social login accounts');
}
// Verify current password
const valid = await this.verifyPassword(currentPassword, user.encryptedPassword);
if (!valid) {
throw new Error('Current password is incorrect');
}
// Validate new password
this.validatePassword(newPassword);
// Hash and update
const hashedPassword = await this.hashPassword(newPassword);
await db.query(
'UPDATE users SET encrypted_password = $1, updated_at = NOW() WHERE id = $2',
[hashedPassword, userId]
);
return { message: 'Password changed successfully' };
}
private validatePassword(password: string): void {
if (password.length < 8) {
throw new Error('Password must be at least 8 characters long');
}
if (!/[A-Z]/.test(password)) {
throw new Error('Password must contain at least one uppercase letter');
}
if (!/[a-z]/.test(password)) {
throw new Error('Password must contain at least one lowercase letter');
}
if (!/[0-9]/.test(password)) {
throw new Error('Password must contain at least one number');
}
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
throw new Error('Password must contain at least one special character');
}
}
private async verifyTOTP(userId: string, code: string): Promise<boolean> {
return twoFactorService.verifyTOTP(userId, code);
}
private getVerificationEmailTemplate(url: string): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { text-align: center; padding: 20px 0; }
.logo { font-size: 28px; font-weight: bold; color: #6366f1; }
.content { background: #f9fafb; border-radius: 8px; padding: 30px; margin: 20px 0; }
.button { display: inline-block; background: #6366f1; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; font-weight: 500; }
.footer { text-align: center; color: #6b7280; font-size: 14px; margin-top: 20px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">OrbiQuant</div>
</div>
<div class="content">
<h2>Verifica tu cuenta</h2>
<p>Gracias por registrarte en OrbiQuant. Haz clic en el boton de abajo para verificar tu cuenta:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="${url}" class="button">Verificar Email</a>
</p>
<p>Si no creaste esta cuenta, puedes ignorar este email.</p>
<p style="color: #6b7280; font-size: 14px;">Este enlace expira en 24 horas.</p>
</div>
<div class="footer">
<p>&copy; 2025 OrbiQuant. Todos los derechos reservados.</p>
</div>
</div>
</body>
</html>
`;
}
private getPasswordResetEmailTemplate(url: string): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { text-align: center; padding: 20px 0; }
.logo { font-size: 28px; font-weight: bold; color: #6366f1; }
.content { background: #f9fafb; border-radius: 8px; padding: 30px; margin: 20px 0; }
.button { display: inline-block; background: #6366f1; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; font-weight: 500; }
.footer { text-align: center; color: #6b7280; font-size: 14px; margin-top: 20px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">OrbiQuant</div>
</div>
<div class="content">
<h2>Restablece tu contrasena</h2>
<p>Recibimos una solicitud para restablecer la contrasena de tu cuenta. Haz clic en el boton de abajo:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="${url}" class="button">Restablecer Contrasena</a>
</p>
<p>Si no solicitaste este cambio, puedes ignorar este email. Tu contrasena no sera modificada.</p>
<p style="color: #6b7280; font-size: 14px;">Este enlace expira en 1 hora.</p>
</div>
<div class="footer">
<p>&copy; 2025 OrbiQuant. Todos los derechos reservados.</p>
</div>
</div>
</body>
</html>
`;
}
}
export const emailService = new EmailService();

View File

@ -1,624 +0,0 @@
// ============================================================================
// OrbiQuant IA - OAuth Service
// ============================================================================
import { OAuth2Client } from 'google-auth-library';
import axios from 'axios';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { config } from '../../../config';
import { db } from '../../../shared/database';
import { tokenService } from './token.service';
import { logger } from '../../../shared/utils/logger';
import type {
AuthProvider,
OAuthAccount,
OAuthCallbackData,
User,
Profile,
AuthResponse,
} from '../types/auth.types';
export class OAuthService {
private googleClient: OAuth2Client;
constructor() {
this.googleClient = new OAuth2Client(
config.oauth.google.clientId,
config.oauth.google.clientSecret,
config.oauth.google.callbackUrl
);
}
// ============================================================================
// Google OAuth
// ============================================================================
getGoogleAuthUrl(state: string): string {
return this.googleClient.generateAuthUrl({
access_type: 'offline',
scope: config.oauth.google.scope,
state,
prompt: 'consent',
});
}
async verifyGoogleToken(code: string): Promise<OAuthCallbackData | null> {
try {
const { tokens } = await this.googleClient.getToken(code);
this.googleClient.setCredentials(tokens);
const ticket = await this.googleClient.verifyIdToken({
idToken: tokens.id_token!,
audience: config.oauth.google.clientId,
});
const payload = ticket.getPayload();
if (!payload) return null;
return {
provider: 'google',
providerAccountId: payload.sub,
email: payload.email,
name: payload.name,
avatarUrl: payload.picture,
accessToken: tokens.access_token!,
refreshToken: tokens.refresh_token ?? undefined,
expiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : undefined,
profile: payload as unknown as Record<string, unknown>,
};
} catch (error) {
logger.error('Google token verification failed', { error });
return null;
}
}
async verifyGoogleIdToken(idToken: string): Promise<OAuthCallbackData | null> {
try {
const ticket = await this.googleClient.verifyIdToken({
idToken,
audience: config.oauth.google.clientId,
});
const payload = ticket.getPayload();
if (!payload) return null;
return {
provider: 'google',
providerAccountId: payload.sub,
email: payload.email,
name: payload.name,
avatarUrl: payload.picture,
accessToken: idToken,
profile: payload as unknown as Record<string, unknown>,
};
} catch (error) {
logger.error('Google ID token verification failed', { error });
return null;
}
}
// ============================================================================
// Facebook OAuth
// ============================================================================
getFacebookAuthUrl(state: string): string {
const params = new URLSearchParams({
client_id: config.oauth.facebook.clientId,
redirect_uri: config.oauth.facebook.callbackUrl,
scope: config.oauth.facebook.scope.join(','),
state,
response_type: 'code',
});
return `https://www.facebook.com/v18.0/dialog/oauth?${params}`;
}
async verifyFacebookToken(code: string): Promise<OAuthCallbackData | null> {
try {
// Exchange code for access token
const tokenResponse = await axios.get('https://graph.facebook.com/v18.0/oauth/access_token', {
params: {
client_id: config.oauth.facebook.clientId,
client_secret: config.oauth.facebook.clientSecret,
redirect_uri: config.oauth.facebook.callbackUrl,
code,
},
});
const { access_token, expires_in } = tokenResponse.data;
// Get user profile
const profileResponse = await axios.get('https://graph.facebook.com/v18.0/me', {
params: {
fields: 'id,name,email,picture.type(large)',
access_token,
},
});
const profile = profileResponse.data;
return {
provider: 'facebook',
providerAccountId: profile.id,
email: profile.email,
name: profile.name,
avatarUrl: profile.picture?.data?.url,
accessToken: access_token,
expiresAt: expires_in ? new Date(Date.now() + expires_in * 1000) : undefined,
profile,
};
} catch (error) {
logger.error('Facebook token verification failed', { error });
return null;
}
}
// ============================================================================
// Twitter/X OAuth 2.0
// ============================================================================
getTwitterAuthUrl(state: string, codeChallenge: string): string {
const params = new URLSearchParams({
response_type: 'code',
client_id: config.oauth.twitter.clientId,
redirect_uri: config.oauth.twitter.callbackUrl,
scope: config.oauth.twitter.scope.join(' '),
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return `https://twitter.com/i/oauth2/authorize?${params}`;
}
async verifyTwitterToken(code: string, codeVerifier: string): Promise<OAuthCallbackData | null> {
try {
// Exchange code for access token
const tokenResponse = await axios.post(
'https://api.twitter.com/2/oauth2/token',
new URLSearchParams({
code,
grant_type: 'authorization_code',
client_id: config.oauth.twitter.clientId,
redirect_uri: config.oauth.twitter.callbackUrl,
code_verifier: codeVerifier,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(
`${config.oauth.twitter.clientId}:${config.oauth.twitter.clientSecret}`
).toString('base64')}`,
},
}
);
const { access_token, refresh_token, expires_in } = tokenResponse.data;
// Get user profile
const profileResponse = await axios.get('https://api.twitter.com/2/users/me', {
params: {
'user.fields': 'id,name,username,profile_image_url',
},
headers: {
Authorization: `Bearer ${access_token}`,
},
});
const profile = profileResponse.data.data;
return {
provider: 'twitter',
providerAccountId: profile.id,
name: profile.name,
avatarUrl: profile.profile_image_url,
accessToken: access_token,
refreshToken: refresh_token,
expiresAt: expires_in ? new Date(Date.now() + expires_in * 1000) : undefined,
profile,
};
} catch (error) {
logger.error('Twitter token verification failed', { error });
return null;
}
}
// ============================================================================
// Apple Sign In
// ============================================================================
getAppleAuthUrl(state: string): string {
const params = new URLSearchParams({
client_id: config.oauth.apple.clientId,
redirect_uri: config.oauth.apple.callbackUrl,
response_type: 'code id_token',
scope: config.oauth.apple.scope.join(' '),
response_mode: 'form_post',
state,
});
return `https://appleid.apple.com/auth/authorize?${params}`;
}
async verifyAppleToken(code: string, idToken: string): Promise<OAuthCallbackData | null> {
try {
// Verify ID token
const decodedHeader = jwt.decode(idToken, { complete: true });
if (!decodedHeader) return null;
// Get Apple's public keys
const keysResponse = await axios.get('https://appleid.apple.com/auth/keys');
const keys = keysResponse.data.keys;
// Find matching key
const key = keys.find((k: { kid: string }) => k.kid === decodedHeader.header.kid);
if (!key) return null;
// Verify token (simplified - in production use proper JWT verification)
const decoded = jwt.decode(idToken) as {
sub: string;
email?: string;
email_verified?: boolean;
} | null;
if (!decoded) return null;
return {
provider: 'apple',
providerAccountId: decoded.sub,
email: decoded.email,
accessToken: code,
profile: decoded as unknown as Record<string, unknown>,
};
} catch (error) {
logger.error('Apple token verification failed', { error });
return null;
}
}
// ============================================================================
// GitHub OAuth
// ============================================================================
getGitHubAuthUrl(state: string): string {
const params = new URLSearchParams({
client_id: config.oauth.github.clientId,
redirect_uri: config.oauth.github.callbackUrl,
scope: config.oauth.github.scope.join(' '),
state,
});
return `https://github.com/login/oauth/authorize?${params}`;
}
async verifyGitHubToken(code: string): Promise<OAuthCallbackData | null> {
try {
// Exchange code for access token
const tokenResponse = await axios.post(
'https://github.com/login/oauth/access_token',
{
client_id: config.oauth.github.clientId,
client_secret: config.oauth.github.clientSecret,
code,
redirect_uri: config.oauth.github.callbackUrl,
},
{
headers: {
Accept: 'application/json',
},
}
);
const { access_token } = tokenResponse.data;
// Get user profile
const profileResponse = await axios.get('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${access_token}`,
},
});
const profile = profileResponse.data;
// Get user email if not public
let email = profile.email;
if (!email) {
const emailsResponse = await axios.get('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${access_token}`,
},
});
const primaryEmail = emailsResponse.data.find(
(e: { primary: boolean; verified: boolean }) => e.primary && e.verified
);
email = primaryEmail?.email;
}
return {
provider: 'github',
providerAccountId: profile.id.toString(),
email,
name: profile.name || profile.login,
avatarUrl: profile.avatar_url,
accessToken: access_token,
profile,
};
} catch (error) {
logger.error('GitHub token verification failed', { error });
return null;
}
}
// ============================================================================
// Common OAuth Flow
// ============================================================================
async handleOAuthCallback(
data: OAuthCallbackData,
userAgent?: string,
ipAddress?: string
): Promise<AuthResponse> {
// Check if OAuth account exists
const existingOAuth = await db.query<OAuthAccount>(
`SELECT * FROM oauth_accounts
WHERE provider = $1 AND provider_account_id = $2`,
[data.provider, data.providerAccountId]
);
let user: User;
let profile: Profile | undefined;
let isNewUser = false;
if (existingOAuth.rows.length > 0) {
// Existing OAuth account - get user
const userResult = await db.query<User>(
'SELECT * FROM users WHERE id = $1',
[existingOAuth.rows[0].userId]
);
user = userResult.rows[0];
// Update OAuth tokens
await db.query(
`UPDATE oauth_accounts
SET access_token = $1, refresh_token = $2, token_expires_at = $3, updated_at = NOW()
WHERE id = $4`,
[data.accessToken, data.refreshToken, data.expiresAt, existingOAuth.rows[0].id]
);
// Get profile
const profileResult = await db.query<Profile>(
'SELECT * FROM profiles WHERE user_id = $1',
[user.id]
);
profile = profileResult.rows[0];
} else if (data.email) {
// Check if user with this email exists
const existingUser = await db.query<User>(
'SELECT * FROM users WHERE email = $1',
[data.email]
);
if (existingUser.rows.length > 0) {
// Link OAuth to existing user
user = existingUser.rows[0];
await db.query(
`INSERT INTO oauth_accounts
(user_id, provider, provider_account_id, access_token, refresh_token, token_expires_at, provider_email, provider_name, provider_avatar_url, provider_profile)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
user.id,
data.provider,
data.providerAccountId,
data.accessToken,
data.refreshToken,
data.expiresAt,
data.email,
data.name,
data.avatarUrl,
JSON.stringify(data.profile),
]
);
const profileResult = await db.query<Profile>(
'SELECT * FROM profiles WHERE user_id = $1',
[user.id]
);
profile = profileResult.rows[0];
} else {
// Create new user
isNewUser = true;
const result = await this.createUserFromOAuth(data);
user = result.user;
profile = result.profile;
}
} else {
// No email - create user without email (for Twitter/Apple without email)
isNewUser = true;
const result = await this.createUserFromOAuth(data);
user = result.user;
profile = result.profile;
}
// Update last login
await db.query(
`UPDATE users SET last_login_at = NOW(), last_login_ip = $1 WHERE id = $2`,
[ipAddress, user.id]
);
// Log auth event
await db.query(
`INSERT INTO auth_logs (user_id, event, provider, ip_address, user_agent, success)
VALUES ($1, $2, $3, $4, $5, true)`,
[user.id, isNewUser ? 'register' : 'login_success', data.provider, ipAddress, userAgent]
);
// Create session and tokens
const { tokens } = await tokenService.createSession(user.id, userAgent, ipAddress);
// Remove sensitive data
const { encryptedPassword: _, ...safeUser } = user;
return {
user: safeUser as Omit<User, 'encryptedPassword'>,
profile,
tokens,
isNewUser,
};
}
private async createUserFromOAuth(data: OAuthCallbackData): Promise<{ user: User; profile: Profile }> {
const client = await db.getClient();
try {
await client.query('BEGIN');
// Create user
const userResult = await client.query<User>(
`INSERT INTO users (email, email_verified, primary_auth_provider, status)
VALUES ($1, $2, $3, 'active')
RETURNING *`,
[data.email || `${data.provider}_${data.providerAccountId}@oauth.orbiquant.com`, true, data.provider]
);
const user = userResult.rows[0];
// Create profile
const names = data.name?.split(' ') || [];
const profileResult = await client.query<Profile>(
`INSERT INTO profiles (user_id, first_name, last_name, display_name, avatar_url)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[user.id, names[0] || null, names.slice(1).join(' ') || null, data.name, data.avatarUrl]
);
const profile = profileResult.rows[0];
// Create user settings
await client.query(
'INSERT INTO user_settings (user_id) VALUES ($1)',
[user.id]
);
// Create OAuth account
await client.query(
`INSERT INTO oauth_accounts
(user_id, provider, provider_account_id, access_token, refresh_token, token_expires_at, provider_email, provider_name, provider_avatar_url, provider_profile)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
user.id,
data.provider,
data.providerAccountId,
data.accessToken,
data.refreshToken,
data.expiresAt,
data.email,
data.name,
data.avatarUrl,
JSON.stringify(data.profile),
]
);
await client.query('COMMIT');
return { user, profile };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async linkOAuthAccount(userId: string, data: OAuthCallbackData): Promise<OAuthAccount> {
// Check if already linked
const existing = await db.query(
'SELECT * FROM oauth_accounts WHERE user_id = $1 AND provider = $2',
[userId, data.provider]
);
if (existing.rows.length > 0) {
throw new Error(`${data.provider} account already linked`);
}
// Check if OAuth account is linked to another user
const otherUser = await db.query(
'SELECT * FROM oauth_accounts WHERE provider = $1 AND provider_account_id = $2',
[data.provider, data.providerAccountId]
);
if (otherUser.rows.length > 0) {
throw new Error(`This ${data.provider} account is already linked to another user`);
}
const result = await db.query<OAuthAccount>(
`INSERT INTO oauth_accounts
(user_id, provider, provider_account_id, access_token, refresh_token, token_expires_at, provider_email, provider_name, provider_avatar_url, provider_profile)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[
userId,
data.provider,
data.providerAccountId,
data.accessToken,
data.refreshToken,
data.expiresAt,
data.email,
data.name,
data.avatarUrl,
JSON.stringify(data.profile),
]
);
return result.rows[0];
}
async unlinkOAuthAccount(userId: string, provider: AuthProvider): Promise<void> {
// Check if user has other auth methods
const user = await db.query<User>(
'SELECT * FROM users WHERE id = $1',
[userId]
);
const oauthAccounts = await db.query(
'SELECT * FROM oauth_accounts WHERE user_id = $1',
[userId]
);
const hasPassword = !!user.rows[0].encryptedPassword;
const hasOtherOAuth = oauthAccounts.rows.length > 1;
if (!hasPassword && !hasOtherOAuth) {
throw new Error('Cannot unlink the only authentication method');
}
await db.query(
'DELETE FROM oauth_accounts WHERE user_id = $1 AND provider = $2',
[userId, provider]
);
}
async getLinkedAccounts(userId: string): Promise<OAuthAccount[]> {
const result = await db.query<OAuthAccount>(
`SELECT id, user_id, provider, provider_account_id, provider_email, provider_name, provider_avatar_url, created_at
FROM oauth_accounts WHERE user_id = $1`,
[userId]
);
return result.rows;
}
// PKCE helpers for Twitter
generateCodeVerifier(): string {
return crypto.randomBytes(32).toString('base64url');
}
generateCodeChallenge(verifier: string): string {
return crypto.createHash('sha256').update(verifier).digest('base64url');
}
generateState(): string {
return crypto.randomBytes(16).toString('hex');
}
}
export const oauthService = new OAuthService();

View File

@ -1,435 +0,0 @@
// ============================================================================
// OrbiQuant IA - Phone Authentication Service (SMS/WhatsApp)
// ============================================================================
import twilio from 'twilio';
import crypto from 'crypto';
import { config } from '../../../config';
import { db } from '../../../shared/database';
import { tokenService } from './token.service';
import { logger } from '../../../shared/utils/logger';
import type { User, Profile, AuthResponse } from '../types/auth.types';
type PhoneChannel = 'sms' | 'whatsapp' | 'call';
interface PhoneVerification {
id: string;
phoneNumber: string;
countryCode: string;
otpCode: string;
otpHash: string;
channel: PhoneChannel;
verified: boolean;
attempts: number;
maxAttempts: number;
userId?: string;
purpose: string;
expiresAt: Date;
verifiedAt?: Date;
createdAt: Date;
}
export class PhoneService {
private twilioClient: twilio.Twilio | null = null;
private verifyServiceSid: string = '';
private isConfigured: boolean = false;
constructor() {
// Only initialize Twilio if credentials are properly configured
const accountSid = config.twilio?.accountSid;
const authToken = config.twilio?.authToken;
if (accountSid && authToken && accountSid.startsWith('AC')) {
try {
this.twilioClient = twilio(accountSid, authToken);
this.verifyServiceSid = config.twilio.verifyServiceSid || '';
this.isConfigured = true;
logger.info('[PhoneService] Twilio initialized successfully');
} catch (_error) {
logger.warn('[PhoneService] Failed to initialize Twilio:', _error);
this.isConfigured = false;
}
} else {
logger.warn('[PhoneService] Twilio not configured - phone features disabled');
this.verifyServiceSid = '';
this.isConfigured = false;
}
}
private ensureConfigured(): void {
if (!this.isConfigured || !this.twilioClient) {
throw new Error('Phone service is not configured. Please set valid Twilio credentials.');
}
}
private generateOTP(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
private hashOTP(otp: string, _salt: string): string {
return crypto.createHmac('sha256', _salt).update(otp).digest('hex');
}
private formatPhoneNumber(phoneNumber: string, countryCode: string): string {
// Remove any non-numeric characters except +
const cleaned = phoneNumber.replace(/[^\d+]/g, '');
// Add country code if not present
if (!cleaned.startsWith('+')) {
return `+${countryCode}${cleaned}`;
}
return cleaned;
}
async sendOTP(
phoneNumber: string,
countryCode: string,
channel: PhoneChannel = 'whatsapp',
purpose: string = 'login',
userId?: string
): Promise<{ success: boolean; expiresAt: Date; message: string }> {
const formattedPhone = this.formatPhoneNumber(phoneNumber, countryCode);
// Check rate limiting - max 3 OTPs per phone per hour
const recentOTPs = await db.query(
`SELECT COUNT(*) FROM phone_verifications
WHERE phone_number = $1 AND created_at > NOW() - INTERVAL '1 hour'`,
[formattedPhone]
);
if (parseInt(recentOTPs.rows[0].count) >= 3) {
throw new Error('Too many OTP requests. Please try again later.');
}
// Generate OTP
const otpCode = this.generateOTP();
const salt = crypto.randomBytes(16).toString('hex');
const otpHash = this.hashOTP(otpCode, salt);
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
try {
this.ensureConfigured();
// Use Twilio Verify service
if (config.twilio.useVerifyService) {
await this.twilioClient!.verify.v2
.services(this.verifyServiceSid)
.verifications.create({
to: formattedPhone,
channel: channel === 'whatsapp' ? 'whatsapp' : channel,
});
} else {
// Send via regular SMS/WhatsApp
const toNumber = channel === 'whatsapp' ? `whatsapp:${formattedPhone}` : formattedPhone;
const fromNumber =
channel === 'whatsapp'
? `whatsapp:${config.twilio.whatsappNumber}`
: config.twilio.phoneNumber;
await this.twilioClient!.messages.create({
body: `Tu codigo de verificacion OrbiQuant es: ${otpCode}. Expira en 10 minutos.`,
from: fromNumber,
to: toNumber,
});
}
// Store verification in database
await db.query(
`INSERT INTO phone_verifications
(phone_number, country_code, otp_code, otp_hash, channel, purpose, user_id, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[formattedPhone, countryCode, config.twilio.useVerifyService ? '' : otpCode, otpHash, channel, purpose, userId, expiresAt]
);
logger.info('OTP sent successfully', { phone: formattedPhone, channel, purpose });
return {
success: true,
expiresAt,
message: `Codigo enviado via ${channel === 'whatsapp' ? 'WhatsApp' : 'SMS'}`,
};
} catch (error) {
logger.error('Failed to send OTP', { error, phone: formattedPhone, channel });
throw new Error('Failed to send verification code. Please try again.');
}
}
async verifyOTP(
phoneNumber: string,
countryCode: string,
otpCode: string,
userAgent?: string,
ipAddress?: string
): Promise<AuthResponse> {
const formattedPhone = this.formatPhoneNumber(phoneNumber, countryCode);
if (config.twilio.useVerifyService) {
// Use Twilio Verify service
this.ensureConfigured();
try {
const verification = await this.twilioClient!.verify.v2
.services(this.verifyServiceSid)
.verificationChecks.create({
to: formattedPhone,
code: otpCode,
});
if (verification.status !== 'approved') {
throw new Error('Invalid or expired verification code');
}
} catch {
throw new Error('Invalid or expired verification code');
}
} else {
// Manual verification
const verificationResult = await db.query<PhoneVerification>(
`SELECT * FROM phone_verifications
WHERE phone_number = $1
AND expires_at > NOW()
AND verified = FALSE
AND attempts < max_attempts
ORDER BY created_at DESC
LIMIT 1`,
[formattedPhone]
);
if (verificationResult.rows.length === 0) {
throw new Error('No pending verification found or code expired');
}
const verification = verificationResult.rows[0];
// Update attempts
await db.query(
'UPDATE phone_verifications SET attempts = attempts + 1 WHERE id = $1',
[verification.id]
);
// Verify OTP
if (verification.otpCode !== otpCode) {
if (verification.attempts + 1 >= verification.maxAttempts) {
throw new Error('Maximum attempts exceeded. Please request a new code.');
}
throw new Error('Invalid verification code');
}
// Mark as verified
await db.query(
'UPDATE phone_verifications SET verified = TRUE, verified_at = NOW() WHERE id = $1',
[verification.id]
);
}
// Check if user exists with this phone
const existingUser = await db.query<User>(
'SELECT * FROM users WHERE phone = $1',
[formattedPhone]
);
let user: User;
let profile: Profile | undefined;
let isNewUser = false;
if (existingUser.rows.length > 0) {
user = existingUser.rows[0];
// Update phone verified status
if (!user.phoneVerified) {
await db.query(
'UPDATE users SET phone_verified = TRUE WHERE id = $1',
[user.id]
);
user.phoneVerified = true;
}
// Get profile
const profileResult = await db.query<Profile>(
'SELECT * FROM profiles WHERE user_id = $1',
[user.id]
);
profile = profileResult.rows[0];
} else {
// Create new user with phone
isNewUser = true;
const result = await this.createUserFromPhone(formattedPhone);
user = result.user;
profile = result.profile;
}
// Update last login
await db.query(
'UPDATE users SET last_login_at = NOW(), last_login_ip = $1 WHERE id = $2',
[ipAddress, user.id]
);
// Log auth event
await db.query(
`INSERT INTO auth_logs (user_id, event, provider, ip_address, user_agent, success)
VALUES ($1, $2, 'phone', $3, $4, true)`,
[user.id, isNewUser ? 'register' : 'login_success', ipAddress, userAgent]
);
// Create session and tokens
const { tokens } = await tokenService.createSession(user.id, userAgent, ipAddress);
// Remove sensitive data
const { encryptedPassword: _, ...safeUser } = user;
return {
user: safeUser as Omit<User, 'encryptedPassword'>,
profile,
tokens,
isNewUser,
};
}
private async createUserFromPhone(phone: string): Promise<{ user: User; profile: Profile }> {
const client = await db.getClient();
try {
await client.query('BEGIN');
// Create user
const userResult = await client.query<User>(
`INSERT INTO users (phone, phone_verified, primary_auth_provider, status)
VALUES ($1, TRUE, 'phone', 'active')
RETURNING *`,
[phone]
);
const user = userResult.rows[0];
// Create empty profile
const profileResult = await client.query<Profile>(
'INSERT INTO profiles (user_id) VALUES ($1) RETURNING *',
[user.id]
);
const profile = profileResult.rows[0];
// Create user settings
await client.query(
'INSERT INTO user_settings (user_id) VALUES ($1)',
[user.id]
);
await client.query('COMMIT');
return { user, profile };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async linkPhoneToUser(
userId: string,
phoneNumber: string,
countryCode: string
): Promise<void> {
const formattedPhone = this.formatPhoneNumber(phoneNumber, countryCode);
// Check if phone already linked to another user
const existing = await db.query(
'SELECT id FROM users WHERE phone = $1 AND id != $2',
[formattedPhone, userId]
);
if (existing.rows.length > 0) {
throw new Error('This phone number is already linked to another account');
}
// Update user
await db.query(
'UPDATE users SET phone = $1, phone_verified = FALSE WHERE id = $2',
[formattedPhone, userId]
);
// Send verification OTP
await this.sendOTP(phoneNumber, countryCode, 'whatsapp', 'verify', userId);
}
async verifyLinkedPhone(
userId: string,
phoneNumber: string,
countryCode: string,
otpCode: string
): Promise<void> {
const formattedPhone = this.formatPhoneNumber(phoneNumber, countryCode);
// Get user
const userResult = await db.query<User>(
'SELECT * FROM users WHERE id = $1',
[userId]
);
if (userResult.rows.length === 0) {
throw new Error('User not found');
}
const user = userResult.rows[0];
if (user.phone !== formattedPhone) {
throw new Error('Phone number does not match');
}
// Verify OTP using Twilio or manual
if (config.twilio.useVerifyService) {
this.ensureConfigured();
const verification = await this.twilioClient!.verify.v2
.services(this.verifyServiceSid)
.verificationChecks.create({
to: formattedPhone,
code: otpCode,
});
if (verification.status !== 'approved') {
throw new Error('Invalid or expired verification code');
}
} else {
// Manual verification
const verificationResult = await db.query<PhoneVerification>(
`SELECT * FROM phone_verifications
WHERE phone_number = $1
AND user_id = $2
AND purpose = 'verify'
AND expires_at > NOW()
AND verified = FALSE
ORDER BY created_at DESC
LIMIT 1`,
[formattedPhone, userId]
);
if (verificationResult.rows.length === 0) {
throw new Error('No pending verification found');
}
const verification = verificationResult.rows[0];
if (verification.otpCode !== otpCode) {
throw new Error('Invalid verification code');
}
await db.query(
'UPDATE phone_verifications SET verified = TRUE, verified_at = NOW() WHERE id = $1',
[verification.id]
);
}
// Mark phone as verified
await db.query(
'UPDATE users SET phone_verified = TRUE WHERE id = $1',
[userId]
);
// Log event
await db.query(
`INSERT INTO auth_logs (user_id, event, provider, success)
VALUES ($1, 'phone_verified', 'phone', true)`,
[userId]
);
}
}
export const phoneService = new PhoneService();

View File

@ -1,211 +0,0 @@
// ============================================================================
// OrbiQuant IA - Token Service
// ============================================================================
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
import { config } from '../../../config';
import { db } from '../../../shared/database';
import type {
User,
AuthTokens,
JWTPayload,
JWTRefreshPayload,
Session,
} from '../types/auth.types';
export class TokenService {
private readonly accessTokenSecret: string;
private readonly refreshTokenSecret: string;
private readonly accessTokenExpiry: string;
private readonly refreshTokenExpiry: string;
private readonly refreshTokenExpiryMs: number;
constructor() {
this.accessTokenSecret = config.jwt.accessSecret;
this.refreshTokenSecret = config.jwt.refreshSecret;
this.accessTokenExpiry = config.jwt.accessExpiry;
this.refreshTokenExpiry = config.jwt.refreshExpiry;
this.refreshTokenExpiryMs = this.parseExpiry(config.jwt.refreshExpiry);
}
private parseExpiry(expiry: string): number {
const match = expiry.match(/^(\d+)([smhd])$/);
if (!match) return 7 * 24 * 60 * 60 * 1000; // default 7 days
const value = parseInt(match[1], 10);
const unit = match[2];
switch (unit) {
case 's': return value * 1000;
case 'm': return value * 60 * 1000;
case 'h': return value * 60 * 60 * 1000;
case 'd': return value * 24 * 60 * 60 * 1000;
default: return 7 * 24 * 60 * 60 * 1000;
}
}
generateAccessToken(user: User): string {
const payload: Omit<JWTPayload, 'iat' | 'exp'> = {
sub: user.id,
email: user.email,
role: user.role,
provider: user.primaryAuthProvider,
};
return jwt.sign(payload, this.accessTokenSecret, {
expiresIn: this.accessTokenExpiry as jwt.SignOptions['expiresIn'],
});
}
generateRefreshToken(userId: string, sessionId: string): string {
const payload: Omit<JWTRefreshPayload, 'iat' | 'exp'> = {
sub: userId,
sessionId,
};
return jwt.sign(payload, this.refreshTokenSecret, {
expiresIn: this.refreshTokenExpiry as jwt.SignOptions['expiresIn'],
});
}
verifyAccessToken(token: string): JWTPayload | null {
try {
return jwt.verify(token, this.accessTokenSecret) as JWTPayload;
} catch {
return null;
}
}
verifyRefreshToken(token: string): JWTRefreshPayload | null {
try {
return jwt.verify(token, this.refreshTokenSecret) as JWTRefreshPayload;
} catch {
return null;
}
}
async createSession(
userId: string,
userAgent?: string,
ipAddress?: string,
deviceInfo?: Record<string, unknown>
): Promise<{ session: Session; tokens: AuthTokens }> {
const sessionId = uuidv4();
const refreshTokenValue = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + this.refreshTokenExpiryMs);
const result = await db.query<Session>(
`INSERT INTO sessions (id, user_id, refresh_token, user_agent, ip_address, device_info, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[sessionId, userId, refreshTokenValue, userAgent, ipAddress, JSON.stringify(deviceInfo), expiresAt]
);
const session = result.rows[0];
// Get user for access token
const userResult = await db.query<User>(
'SELECT * FROM users WHERE id = $1',
[userId]
);
const user = userResult.rows[0];
const accessToken = this.generateAccessToken(user);
const refreshToken = this.generateRefreshToken(userId, sessionId);
return {
session,
tokens: {
accessToken,
refreshToken,
expiresIn: this.parseExpiry(this.accessTokenExpiry) / 1000,
tokenType: 'Bearer',
},
};
}
async refreshSession(refreshToken: string): Promise<AuthTokens | null> {
const decoded = this.verifyRefreshToken(refreshToken);
if (!decoded) return null;
// Check session exists and is valid
const sessionResult = await db.query<Session>(
`SELECT * FROM sessions
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > NOW()`,
[decoded.sessionId, decoded.sub]
);
if (sessionResult.rows.length === 0) return null;
// Update last active
await db.query(
'UPDATE sessions SET last_active_at = NOW() WHERE id = $1',
[decoded.sessionId]
);
// Get user
const userResult = await db.query<User>(
'SELECT * FROM users WHERE id = $1',
[decoded.sub]
);
if (userResult.rows.length === 0) return null;
const user = userResult.rows[0];
const newAccessToken = this.generateAccessToken(user);
const newRefreshToken = this.generateRefreshToken(user.id, decoded.sessionId);
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expiresIn: this.parseExpiry(this.accessTokenExpiry) / 1000,
tokenType: 'Bearer',
};
}
async revokeSession(sessionId: string, userId: string): Promise<boolean> {
const result = await db.query(
`UPDATE sessions SET revoked_at = NOW()
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL`,
[sessionId, userId]
);
return (result.rowCount ?? 0) > 0;
}
async revokeAllUserSessions(userId: string, exceptSessionId?: string): Promise<number> {
let query = 'UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL';
const params: (string | undefined)[] = [userId];
if (exceptSessionId) {
query += ' AND id != $2';
params.push(exceptSessionId);
}
const result = await db.query(query, params);
return result.rowCount ?? 0;
}
async getActiveSessions(userId: string): Promise<Session[]> {
const result = await db.query<Session>(
`SELECT * FROM sessions
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW()
ORDER BY last_active_at DESC`,
[userId]
);
return result.rows;
}
generateEmailToken(): string {
return crypto.randomBytes(32).toString('hex');
}
hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
}
export const tokenService = new TokenService();

View File

@ -1,293 +0,0 @@
// ============================================================================
// OrbiQuant IA - Two-Factor Authentication Service
// ============================================================================
import speakeasy from 'speakeasy';
import QRCode from 'qrcode';
import crypto from 'crypto';
import { db } from '../../../shared/database';
import { logger } from '../../../shared/utils/logger';
import type { User, TwoFactorSetupResponse } from '../types/auth.types';
export class TwoFactorService {
private readonly appName = 'OrbiQuant';
async setupTOTP(userId: string): Promise<TwoFactorSetupResponse> {
// Get user
const userResult = await db.query<User>(
'SELECT * FROM users WHERE id = $1',
[userId]
);
if (userResult.rows.length === 0) {
throw new Error('User not found');
}
const user = userResult.rows[0];
if (user.totpEnabled) {
throw new Error('2FA is already enabled');
}
// Generate secret
const secret = speakeasy.generateSecret({
name: `${this.appName} (${user.email})`,
length: 32,
});
// Generate backup codes
const backupCodes = this.generateBackupCodes();
const hashedBackupCodes = backupCodes.map((code) =>
crypto.createHash('sha256').update(code).digest('hex')
);
// Store secret temporarily (not enabled yet)
await db.query(
`UPDATE users
SET totp_secret = $1, backup_codes = $2
WHERE id = $3`,
[secret.base32, hashedBackupCodes, userId]
);
// Generate QR code
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url!);
return {
secret: secret.base32,
qrCodeUrl,
backupCodes,
};
}
async enableTOTP(userId: string, code: string): Promise<{ message: string }> {
// Get user with secret
const userResult = await db.query<User>(
'SELECT * FROM users WHERE id = $1',
[userId]
);
if (userResult.rows.length === 0) {
throw new Error('User not found');
}
const user = userResult.rows[0];
if (user.totpEnabled) {
throw new Error('2FA is already enabled');
}
if (!user.totpSecret) {
throw new Error('Please set up 2FA first');
}
// Verify code
const verified = speakeasy.totp.verify({
secret: user.totpSecret,
encoding: 'base32',
token: code,
window: 1,
});
if (!verified) {
throw new Error('Invalid verification code');
}
// Enable 2FA
await db.query(
'UPDATE users SET totp_enabled = TRUE WHERE id = $1',
[userId]
);
// Log event
await db.query(
`INSERT INTO auth_logs (user_id, event, success)
VALUES ($1, '2fa_enabled', true)`,
[userId]
);
logger.info('2FA enabled', { userId });
return { message: '2FA enabled successfully' };
}
async disableTOTP(
userId: string,
code: string,
_password?: string
): Promise<{ message: string }> {
// Get user
const userResult = await db.query<User>(
'SELECT * FROM users WHERE id = $1',
[userId]
);
if (userResult.rows.length === 0) {
throw new Error('User not found');
}
const user = userResult.rows[0];
if (!user.totpEnabled) {
throw new Error('2FA is not enabled');
}
// Verify TOTP or backup code
const validTOTP = this.verifyTOTPCode(user.totpSecret!, code);
const validBackup = await this.verifyBackupCode(userId, code);
if (!validTOTP && !validBackup) {
throw new Error('Invalid verification code');
}
// Disable 2FA
await db.query(
`UPDATE users
SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL
WHERE id = $1`,
[userId]
);
// Log event
await db.query(
`INSERT INTO auth_logs (user_id, event, success)
VALUES ($1, '2fa_disabled', true)`,
[userId]
);
logger.info('2FA disabled', { userId });
return { message: '2FA disabled successfully' };
}
async verifyTOTP(userId: string, code: string): Promise<boolean> {
// Get user
const userResult = await db.query<User>(
'SELECT totp_secret, totp_enabled, backup_codes FROM users WHERE id = $1',
[userId]
);
if (userResult.rows.length === 0) {
return false;
}
const user = userResult.rows[0];
if (!user.totpEnabled || !user.totpSecret) {
return false;
}
// Try TOTP first
if (this.verifyTOTPCode(user.totpSecret, code)) {
// Log successful verification
await db.query(
`INSERT INTO auth_logs (user_id, event, success)
VALUES ($1, '2fa_verified', true)`,
[userId]
);
return true;
}
// Try backup code
if (await this.verifyBackupCode(userId, code)) {
return true;
}
return false;
}
private verifyTOTPCode(secret: string, code: string): boolean {
return speakeasy.totp.verify({
secret,
encoding: 'base32',
token: code,
window: 1, // Allow 1 step tolerance (30 seconds)
});
}
private async verifyBackupCode(userId: string, code: string): Promise<boolean> {
const userResult = await db.query<{ backup_codes: string[] }>(
'SELECT backup_codes FROM users WHERE id = $1',
[userId]
);
if (userResult.rows.length === 0 || !userResult.rows[0].backup_codes) {
return false;
}
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
const backupCodes = userResult.rows[0].backup_codes;
const codeIndex = backupCodes.indexOf(hashedCode);
if (codeIndex === -1) {
return false;
}
// Remove used backup code
const updatedCodes = [...backupCodes];
updatedCodes.splice(codeIndex, 1);
await db.query(
'UPDATE users SET backup_codes = $1 WHERE id = $2',
[updatedCodes, userId]
);
// Log backup code usage
await db.query(
`INSERT INTO auth_logs (user_id, event, success, metadata)
VALUES ($1, '2fa_verified', true, '{"method": "backup_code"}')`,
[userId]
);
logger.info('Backup code used', { userId, remainingCodes: updatedCodes.length });
return true;
}
async regenerateBackupCodes(userId: string, code: string): Promise<{ backupCodes: string[] }> {
// Verify current 2FA
const valid = await this.verifyTOTP(userId, code);
if (!valid) {
throw new Error('Invalid verification code');
}
// Generate new backup codes
const backupCodes = this.generateBackupCodes();
const hashedBackupCodes = backupCodes.map((c) =>
crypto.createHash('sha256').update(c).digest('hex')
);
// Update in database
await db.query(
'UPDATE users SET backup_codes = $1 WHERE id = $2',
[hashedBackupCodes, userId]
);
logger.info('Backup codes regenerated', { userId });
return { backupCodes };
}
private generateBackupCodes(count: number = 10): string[] {
const codes: string[] = [];
for (let i = 0; i < count; i++) {
// Generate 8-character alphanumeric code
const code = crypto.randomBytes(4).toString('hex').toUpperCase();
// Format as XXXX-XXXX
codes.push(`${code.slice(0, 4)}-${code.slice(4)}`);
}
return codes;
}
async getBackupCodesCount(userId: string): Promise<number> {
const result = await db.query<{ backup_codes: string[] }>(
'SELECT backup_codes FROM users WHERE id = $1',
[userId]
);
return result.rows[0]?.backup_codes?.length || 0;
}
}
export const twoFactorService = new TwoFactorService();

View File

@ -1,409 +0,0 @@
/**
* OAuth State Store Unit Tests
*
* Tests for OAuth state management including:
* - State storage and retrieval
* - State expiration
* - One-time use (getAndDelete)
* - Redis vs in-memory fallback
*/
import { OAuthStateStore, OAuthStateData } from '../oauth-state.store';
import { mockRedisClient, resetRedisMock } from '../../../../__tests__/mocks/redis.mock';
// Mock config to use in-memory store for testing
jest.mock('../../../../config', () => ({
config: {
redis: {
// No redis config - will use in-memory fallback
},
},
}));
// Mock logger
jest.mock('../../../../shared/utils/logger', () => ({
logger: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
}));
describe('OAuthStateStore', () => {
let store: OAuthStateStore;
beforeEach(() => {
resetRedisMock();
store = new OAuthStateStore();
});
describe('set', () => {
it('should store OAuth state with all properties', async () => {
const state = 'state-token-123';
const data = {
codeVerifier: 'verifier-123',
returnUrl: 'https://example.com/callback',
provider: 'google' as const,
};
await store.set(state, data);
const retrieved = await store.get(state);
expect(retrieved).toBeDefined();
expect(retrieved?.codeVerifier).toBe('verifier-123');
expect(retrieved?.returnUrl).toBe('https://example.com/callback');
expect(retrieved?.provider).toBe('google');
expect(retrieved?.createdAt).toBeDefined();
});
it('should store minimal OAuth state', async () => {
const state = 'state-token-456';
const data = {
returnUrl: 'https://example.com/dashboard',
};
await store.set(state, data);
const retrieved = await store.get(state);
expect(retrieved).toBeDefined();
expect(retrieved?.returnUrl).toBe('https://example.com/dashboard');
expect(retrieved?.codeVerifier).toBeUndefined();
expect(retrieved?.createdAt).toBeDefined();
});
it('should set custom TTL', async () => {
const state = 'state-token-ttl';
const data = { returnUrl: 'https://example.com' };
const ttl = 60; // 60 seconds
await store.set(state, data, ttl);
const retrieved = await store.get(state);
expect(retrieved).toBeDefined();
});
it('should handle storage errors gracefully', async () => {
const state = 'state-error';
const data = { returnUrl: 'https://example.com' };
// Mock setex to throw error
const originalSetex = mockRedisClient.setex;
mockRedisClient.setex = jest.fn().mockRejectedValue(new Error('Storage error'));
await expect(store.set(state, data)).rejects.toThrow('Failed to store OAuth state');
// Restore
mockRedisClient.setex = originalSetex;
});
});
describe('get', () => {
it('should retrieve existing OAuth state', async () => {
const state = 'state-get-123';
const data = {
codeVerifier: 'verifier-abc',
returnUrl: 'https://example.com/auth',
provider: 'facebook' as const,
};
await store.set(state, data);
const retrieved = await store.get(state);
expect(retrieved).toBeDefined();
expect(retrieved?.codeVerifier).toBe('verifier-abc');
expect(retrieved?.provider).toBe('facebook');
});
it('should return null for non-existent state', async () => {
const retrieved = await store.get('non-existent-state');
expect(retrieved).toBeNull();
});
it('should return null for expired state', async () => {
const state = 'state-expired';
const data = { returnUrl: 'https://example.com' };
// Set with very short TTL
await mockRedisClient.setex('oauth:state:' + state, 0, JSON.stringify({
...data,
createdAt: Date.now() - 1000,
}));
// Wait a moment for expiration
await new Promise(resolve => setTimeout(resolve, 10));
const retrieved = await store.get(state);
expect(retrieved).toBeNull();
});
it('should handle retrieval errors gracefully', async () => {
const state = 'state-get-error';
// Mock get to throw error
const originalGet = mockRedisClient.get;
mockRedisClient.get = jest.fn().mockRejectedValue(new Error('Retrieval error'));
const retrieved = await store.get(state);
expect(retrieved).toBeNull();
// Restore
mockRedisClient.get = originalGet;
});
});
describe('delete', () => {
it('should delete existing OAuth state', async () => {
const state = 'state-delete-123';
const data = { returnUrl: 'https://example.com' };
await store.set(state, data);
// Verify exists
const before = await store.get(state);
expect(before).toBeDefined();
// Delete
await store.delete(state);
// Verify deleted
const after = await store.get(state);
expect(after).toBeNull();
});
it('should not throw error when deleting non-existent state', async () => {
await expect(store.delete('non-existent-state')).resolves.not.toThrow();
});
it('should handle deletion errors gracefully', async () => {
const state = 'state-delete-error';
// Mock del to throw error
const originalDel = mockRedisClient.del;
mockRedisClient.del = jest.fn().mockRejectedValue(new Error('Deletion error'));
await expect(store.delete(state)).resolves.not.toThrow();
// Restore
mockRedisClient.del = originalDel;
});
});
describe('getAndDelete', () => {
it('should retrieve and delete state (one-time use)', async () => {
const state = 'state-one-time-123';
const data = {
codeVerifier: 'verifier-one-time',
returnUrl: 'https://example.com/oauth-callback',
provider: 'github' as const,
};
await store.set(state, data);
// Get and delete
const retrieved = await store.getAndDelete(state);
expect(retrieved).toBeDefined();
expect(retrieved?.codeVerifier).toBe('verifier-one-time');
expect(retrieved?.provider).toBe('github');
// Verify it's deleted
const shouldBeNull = await store.get(state);
expect(shouldBeNull).toBeNull();
});
it('should return null and not error for non-existent state', async () => {
const retrieved = await store.getAndDelete('non-existent-state');
expect(retrieved).toBeNull();
});
it('should only retrieve once (prevents replay attacks)', async () => {
const state = 'state-replay-protection';
const data = { returnUrl: 'https://example.com' };
await store.set(state, data);
// First retrieval should work
const first = await store.getAndDelete(state);
expect(first).toBeDefined();
// Second retrieval should return null
const second = await store.getAndDelete(state);
expect(second).toBeNull();
});
});
describe('exists', () => {
it('should return true for existing state', async () => {
const state = 'state-exists-123';
await store.set(state, { returnUrl: 'https://example.com' });
const exists = await store.exists(state);
expect(exists).toBe(true);
});
it('should return false for non-existent state', async () => {
const exists = await store.exists('non-existent-state');
expect(exists).toBe(false);
});
it('should return false for expired state', async () => {
const state = 'state-exists-expired';
// Set with very short TTL
await mockRedisClient.setex('oauth:state:' + state, 0, JSON.stringify({
returnUrl: 'https://example.com',
createdAt: Date.now(),
}));
await new Promise(resolve => setTimeout(resolve, 10));
const exists = await store.exists(state);
expect(exists).toBe(false);
});
});
describe('getStorageType', () => {
it('should return memory for in-memory store', () => {
const type = store.getStorageType();
expect(type).toBe('memory');
});
});
describe('State expiration', () => {
it('should automatically expire state after TTL', async () => {
const state = 'state-auto-expire';
const data = { returnUrl: 'https://example.com' };
// Set with 1 second TTL
await store.set(state, data, 1);
// Should exist immediately
const immediate = await store.get(state);
expect(immediate).toBeDefined();
// Wait for expiration
await new Promise(resolve => setTimeout(resolve, 1100));
// Should be expired
const expired = await store.get(state);
expect(expired).toBeNull();
});
it('should not retrieve expired state even if get is called', async () => {
const state = 'state-no-expired-retrieval';
const data = { returnUrl: 'https://example.com' };
// Manually set expired state
await mockRedisClient.setex('oauth:state:' + state, -1, JSON.stringify({
...data,
createdAt: Date.now() - 1000000,
}));
const retrieved = await store.get(state);
expect(retrieved).toBeNull();
});
});
describe('Multiple providers', () => {
it('should handle states from different providers', async () => {
const states = [
{ token: 'google-state-123', data: { provider: 'google' as const, returnUrl: 'https://example.com/g' } },
{ token: 'facebook-state-456', data: { provider: 'facebook' as const, returnUrl: 'https://example.com/f' } },
{ token: 'github-state-789', data: { provider: 'github' as const, returnUrl: 'https://example.com/gh' } },
];
// Store all
for (const { token, data } of states) {
await store.set(token, data);
}
// Retrieve all
for (const { token, data } of states) {
const retrieved = await store.get(token);
expect(retrieved?.provider).toBe(data.provider);
expect(retrieved?.returnUrl).toBe(data.returnUrl);
}
});
it('should keep states isolated', async () => {
await store.set('state-1', { provider: 'google' as const, returnUrl: 'url1' });
await store.set('state-2', { provider: 'facebook' as const, returnUrl: 'url2' });
// Delete one
await store.delete('state-1');
// Other should still exist
const state2 = await store.get('state-2');
expect(state2).toBeDefined();
expect(state2?.provider).toBe('facebook');
});
});
describe('PKCE support', () => {
it('should store and retrieve code verifier for PKCE', async () => {
const state = 'pkce-state-123';
const codeVerifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
await store.set(state, {
codeVerifier,
returnUrl: 'https://example.com/callback',
provider: 'google' as const,
});
const retrieved = await store.get(state);
expect(retrieved?.codeVerifier).toBe(codeVerifier);
});
it('should work without code verifier (non-PKCE flows)', async () => {
const state = 'non-pkce-state-123';
await store.set(state, {
returnUrl: 'https://example.com/callback',
provider: 'facebook' as const,
});
const retrieved = await store.get(state);
expect(retrieved?.codeVerifier).toBeUndefined();
expect(retrieved?.returnUrl).toBeDefined();
});
});
describe('Security considerations', () => {
it('should use prefixed keys to avoid collisions', async () => {
const state = 'state-123';
await store.set(state, { returnUrl: 'https://example.com' });
// Check that the key in Redis has the prefix
const directGet = await mockRedisClient.get('oauth:state:' + state);
expect(directGet).toBeDefined();
// Without prefix should not work
const withoutPrefix = await mockRedisClient.get(state);
expect(withoutPrefix).toBeNull();
});
it('should store createdAt timestamp for audit', async () => {
const state = 'state-audit-123';
const beforeCreate = Date.now();
await store.set(state, { returnUrl: 'https://example.com' });
const afterCreate = Date.now();
const retrieved = await store.get(state);
expect(retrieved?.createdAt).toBeGreaterThanOrEqual(beforeCreate);
expect(retrieved?.createdAt).toBeLessThanOrEqual(afterCreate);
});
it('should handle malformed JSON gracefully', async () => {
const state = 'malformed-state';
// Manually set malformed data
await mockRedisClient.setex('oauth:state:' + state, 600, 'not-valid-json{');
const retrieved = await store.get(state);
expect(retrieved).toBeNull();
});
});
});

View File

@ -1,239 +0,0 @@
/**
* OAuth State Store - Redis-based storage for OAuth state
*
* @description Replaces in-memory Map storage with Redis for:
* - Scalability (works across multiple instances)
* - Persistence (survives server restarts)
* - Automatic expiration (TTL)
* - Security (state can't be enumerated)
*
* @usage
* ```typescript
* import { oauthStateStore } from '../stores/oauth-state.store';
*
* // Store state
* await oauthStateStore.set(state, { codeVerifier, returnUrl });
*
* // Retrieve and delete (one-time use)
* const data = await oauthStateStore.getAndDelete(state);
* ```
*
* @migration From auth.controller.ts:
* - Remove: const oauthStates = new Map<...>();
* - Replace: oauthStates.set(...) await oauthStateStore.set(...)
* - Replace: oauthStates.get(...) await oauthStateStore.get(...)
* - Replace: oauthStates.delete(...) await oauthStateStore.delete(...)
*/
import { config } from '../../../config';
import { logger } from '../../../shared/utils/logger';
/**
* OAuth state data structure
*/
export interface OAuthStateData {
/** PKCE code verifier for providers that support it */
codeVerifier?: string;
/** URL to redirect after authentication */
returnUrl?: string;
/** OAuth provider (google, facebook, apple, github) */
provider?: string;
/** Timestamp when state was created */
createdAt: number;
}
/**
* State store configuration
*/
const STATE_PREFIX = 'oauth:state:';
const DEFAULT_TTL_SECONDS = 600; // 10 minutes
/**
* Redis client interface (simplified)
* Can be ioredis or node-redis
*/
interface RedisClientLike {
get(key: string): Promise<string | null>;
setex(key: string, seconds: number, value: string): Promise<unknown>;
del(key: string): Promise<unknown>;
quit?(): Promise<unknown>;
}
/**
* In-memory fallback store (for development/testing)
*/
class InMemoryStore {
private store = new Map<string, { value: string; expiresAt: number }>();
async get(key: string): Promise<string | null> {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value;
}
async setex(key: string, seconds: number, value: string): Promise<void> {
this.store.set(key, {
value,
expiresAt: Date.now() + seconds * 1000,
});
}
async del(key: string): Promise<void> {
this.store.delete(key);
}
}
/**
* OAuth State Store
*
* Uses Redis for production, falls back to in-memory for development.
*/
class OAuthStateStore {
private client: RedisClientLike;
private isRedis: boolean;
constructor() {
// Initialize Redis or fallback to in-memory
if (config.redis?.url || config.redis?.host) {
this.client = this.createRedisClient();
this.isRedis = true;
logger.info('OAuthStateStore: Using Redis backend');
} else {
this.client = new InMemoryStore();
this.isRedis = false;
logger.warn('OAuthStateStore: Using in-memory fallback (not recommended for production)');
}
}
/**
* Create Redis client based on config
*/
private createRedisClient(): RedisClientLike {
try {
// Try to use ioredis if available
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Redis = require('ioredis');
return new Redis(config.redis?.url || {
host: config.redis?.host || 'localhost',
port: config.redis?.port || 6379,
password: config.redis?.password,
db: config.redis?.db || 0,
});
} catch {
// Fallback to node-redis
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { createClient } = require('redis');
const client = createClient({
url: config.redis?.url || `redis://${config.redis?.host || 'localhost'}:${config.redis?.port || 6379}`,
});
client.connect();
return client;
} catch {
logger.warn('No Redis client available, using in-memory store');
return new InMemoryStore();
}
}
}
/**
* Store OAuth state
*
* @param state - Unique state token
* @param data - State data to store
* @param ttlSeconds - Time to live in seconds (default: 10 minutes)
*/
async set(
state: string,
data: Omit<OAuthStateData, 'createdAt'>,
ttlSeconds: number = DEFAULT_TTL_SECONDS,
): Promise<void> {
const key = STATE_PREFIX + state;
const value = JSON.stringify({
...data,
createdAt: Date.now(),
});
try {
await this.client.setex(key, ttlSeconds, value);
} catch (error) {
logger.error('Failed to store OAuth state', { error, state: state.substring(0, 8) + '...' });
throw new Error('Failed to store OAuth state');
}
}
/**
* Retrieve OAuth state
*
* @param state - State token to retrieve
* @returns State data or null if not found/expired
*/
async get(state: string): Promise<OAuthStateData | null> {
const key = STATE_PREFIX + state;
try {
const value = await this.client.get(key);
if (!value) return null;
return JSON.parse(value) as OAuthStateData;
} catch (error) {
logger.error('Failed to retrieve OAuth state', { error });
return null;
}
}
/**
* Retrieve and delete OAuth state (one-time use)
*
* @param state - State token to retrieve and delete
* @returns State data or null if not found/expired
*/
async getAndDelete(state: string): Promise<OAuthStateData | null> {
const data = await this.get(state);
if (data) {
await this.delete(state);
}
return data;
}
/**
* Delete OAuth state
*
* @param state - State token to delete
*/
async delete(state: string): Promise<void> {
const key = STATE_PREFIX + state;
try {
await this.client.del(key);
} catch (error) {
logger.error('Failed to delete OAuth state', { error });
}
}
/**
* Check if state exists
*
* @param state - State token to check
*/
async exists(state: string): Promise<boolean> {
const data = await this.get(state);
return data !== null;
}
/**
* Get storage type (for logging/debugging)
*/
getStorageType(): 'redis' | 'memory' {
return this.isRedis ? 'redis' : 'memory';
}
}
// Export singleton instance
export const oauthStateStore = new OAuthStateStore();
// Export class for testing
export { OAuthStateStore };

View File

@ -1,217 +0,0 @@
// ============================================================================
// OrbiQuant IA - Auth Types
// ============================================================================
export type AuthProvider =
| 'email'
| 'phone'
| 'google'
| 'facebook'
| 'twitter'
| 'apple'
| 'github';
export type UserRole = 'investor' | 'trader' | 'student' | 'instructor' | 'admin' | 'superadmin';
export enum UserRoleEnum {
INVESTOR = 'investor',
TRADER = 'trader',
STUDENT = 'student',
INSTRUCTOR = 'instructor',
ADMIN = 'admin',
SUPER_ADMIN = 'superadmin',
}
export type UserStatus = 'pending' | 'active' | 'suspended' | 'banned';
export interface User {
id: string;
email: string;
emailVerified: boolean;
phone?: string;
phoneVerified: boolean;
encryptedPassword?: string;
primaryAuthProvider: AuthProvider;
totpEnabled: boolean;
totpSecret?: string;
role: UserRole;
status: UserStatus;
failedLoginAttempts: number;
lockedUntil?: Date;
lastLoginAt?: Date;
lastLoginIp?: string;
createdAt: Date;
updatedAt: Date;
}
/**
* Authenticated user type (without password, with optional profile)
*/
export interface AuthenticatedUser extends Omit<User, 'encryptedPassword'> {
profile?: Profile;
}
export interface Profile {
id: string;
userId: string;
firstName?: string;
lastName?: string;
displayName?: string;
avatarUrl?: string;
dateOfBirth?: Date;
countryCode?: string;
timezone: string;
language: string;
preferredCurrency: string;
}
export interface OAuthAccount {
id: string;
userId: string;
provider: AuthProvider;
providerAccountId: string;
accessToken?: string;
refreshToken?: string;
tokenExpiresAt?: Date;
providerEmail?: string;
providerName?: string;
providerAvatarUrl?: string;
providerProfile?: Record<string, unknown>;
}
export interface Session {
id: string;
userId: string;
refreshToken: string;
userAgent?: string;
ipAddress?: string;
deviceInfo?: Record<string, unknown>;
expiresAt: Date;
revokedAt?: Date;
createdAt: Date;
lastActiveAt: Date;
}
// Request/Response Types
export interface RegisterEmailRequest {
email: string;
password: string;
firstName?: string;
lastName?: string;
acceptTerms: boolean;
}
export interface LoginEmailRequest {
email: string;
password: string;
rememberMe?: boolean;
totpCode?: string;
}
export interface LoginPhoneRequest {
phoneNumber: string;
countryCode: string;
channel?: 'sms' | 'whatsapp';
}
export interface VerifyPhoneOTPRequest {
phoneNumber: string;
countryCode: string;
otpCode: string;
}
export interface OAuthCallbackData {
provider: AuthProvider;
providerAccountId: string;
email?: string;
name?: string;
avatarUrl?: string;
accessToken: string;
refreshToken?: string;
expiresAt?: Date;
profile?: Record<string, unknown>;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: number;
tokenType: 'Bearer';
}
export interface AuthResponse {
user: Omit<User, 'encryptedPassword'>;
profile?: Profile;
tokens: AuthTokens;
requiresTwoFactor?: boolean;
isNewUser?: boolean;
}
export interface TwoFactorSetupResponse {
secret: string;
qrCodeUrl: string;
backupCodes: string[];
}
export interface RefreshTokenRequest {
refreshToken: string;
}
export interface ForgotPasswordRequest {
email: string;
}
export interface ResetPasswordRequest {
token: string;
password: string;
}
export interface ChangePasswordRequest {
currentPassword: string;
newPassword: string;
}
export interface VerifyEmailRequest {
token: string;
}
export interface Enable2FARequest {
totpCode: string;
}
export interface Verify2FARequest {
totpCode: string;
}
// OAuth Provider Configs
export interface OAuthProviderConfig {
clientId: string;
clientSecret: string;
callbackUrl: string;
scope: string[];
}
export interface OAuthProviderConfigs {
google: OAuthProviderConfig;
facebook: OAuthProviderConfig;
twitter: OAuthProviderConfig & { consumerKey: string; consumerSecret: string };
apple: OAuthProviderConfig & { teamId: string; keyId: string; privateKey: string };
github: OAuthProviderConfig;
}
// JWT Payload
export interface JWTPayload {
sub: string; // user id
email: string;
role: UserRole;
provider: AuthProvider;
iat: number;
exp: number;
}
export interface JWTRefreshPayload {
sub: string;
sessionId: string;
iat: number;
exp: number;
}

View File

@ -1,159 +0,0 @@
// ============================================================================
// OrbiQuant IA - Auth Validators
// ============================================================================
import { body, param } from 'express-validator';
export const registerValidator = [
body('email')
.isEmail()
.normalizeEmail()
.withMessage('Please provide a valid email'),
body('password')
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters')
.matches(/[A-Z]/)
.withMessage('Password must contain at least one uppercase letter')
.matches(/[a-z]/)
.withMessage('Password must contain at least one lowercase letter')
.matches(/[0-9]/)
.withMessage('Password must contain at least one number')
.matches(/[!@#$%^&*(),.?":{}|<>]/)
.withMessage('Password must contain at least one special character'),
body('firstName')
.optional()
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('First name must be between 1 and 100 characters'),
body('lastName')
.optional()
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('Last name must be between 1 and 100 characters'),
body('acceptTerms')
.isBoolean()
.equals('true')
.withMessage('You must accept the terms and conditions'),
];
export const loginValidator = [
body('email')
.isEmail()
.normalizeEmail()
.withMessage('Please provide a valid email'),
body('password')
.notEmpty()
.withMessage('Password is required'),
body('totpCode')
.optional()
.isLength({ min: 6, max: 6 })
.withMessage('TOTP code must be 6 digits'),
body('rememberMe')
.optional()
.isBoolean(),
];
export const emailValidator = [
body('email')
.isEmail()
.normalizeEmail()
.withMessage('Please provide a valid email'),
];
export const tokenValidator = [
body('token')
.notEmpty()
.isLength({ min: 32 })
.withMessage('Invalid token'),
];
export const resetPasswordValidator = [
body('token')
.notEmpty()
.isLength({ min: 32 })
.withMessage('Invalid token'),
body('password')
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters')
.matches(/[A-Z]/)
.withMessage('Password must contain at least one uppercase letter')
.matches(/[a-z]/)
.withMessage('Password must contain at least one lowercase letter')
.matches(/[0-9]/)
.withMessage('Password must contain at least one number')
.matches(/[!@#$%^&*(),.?":{}|<>]/)
.withMessage('Password must contain at least one special character'),
];
export const changePasswordValidator = [
body('currentPassword')
.notEmpty()
.withMessage('Current password is required'),
body('newPassword')
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters')
.matches(/[A-Z]/)
.withMessage('Password must contain at least one uppercase letter')
.matches(/[a-z]/)
.withMessage('Password must contain at least one lowercase letter')
.matches(/[0-9]/)
.withMessage('Password must contain at least one number')
.matches(/[!@#$%^&*(),.?":{}|<>]/)
.withMessage('Password must contain at least one special character'),
];
export const phoneOTPValidator = [
body('phoneNumber')
.notEmpty()
.matches(/^[0-9+\-\s()]+$/)
.withMessage('Please provide a valid phone number'),
body('countryCode')
.notEmpty()
.matches(/^[0-9]{1,4}$/)
.withMessage('Please provide a valid country code'),
body('channel')
.optional()
.isIn(['sms', 'whatsapp', 'call'])
.withMessage('Invalid channel'),
];
export const verifyPhoneOTPValidator = [
body('phoneNumber')
.notEmpty()
.matches(/^[0-9+\-\s()]+$/)
.withMessage('Please provide a valid phone number'),
body('countryCode')
.notEmpty()
.matches(/^[0-9]{1,4}$/)
.withMessage('Please provide a valid country code'),
body('otpCode')
.notEmpty()
.isLength({ min: 6, max: 6 })
.matches(/^[0-9]+$/)
.withMessage('OTP code must be 6 digits'),
];
export const oauthProviderValidator = [
param('provider')
.isIn(['google', 'facebook', 'twitter', 'apple', 'github'])
.withMessage('Invalid OAuth provider'),
];
export const refreshTokenValidator = [
body('refreshToken')
.notEmpty()
.withMessage('Refresh token is required'),
];
export const totpCodeValidator = [
body('code')
.notEmpty()
.matches(/^[0-9A-Z-]{6,10}$/)
.withMessage('Invalid verification code'),
];
export const sessionIdValidator = [
param('sessionId')
.isUUID()
.withMessage('Invalid session ID'),
];

View File

@ -1,675 +0,0 @@
/**
* Education Controller
* Handles education-related endpoints
*/
import { Request, Response, NextFunction } from 'express';
import { courseService } from '../services/course.service';
import { enrollmentService } from '../services/enrollment.service';
import type { CourseFilters, PaginationOptions } from '../types/education.types';
type AuthRequest = Request;
// ============================================================================
// Categories
// ============================================================================
export async function getCategories(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const categories = await courseService.getCategories();
res.json({
success: true,
data: categories,
});
} catch (error) {
next(error);
}
}
export async function createCategory(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { name, slug, description, icon, parentId, sortOrder } = req.body;
if (!name) {
res.status(400).json({
success: false,
error: { message: 'Category name is required', code: 'VALIDATION_ERROR' },
});
return;
}
const category = await courseService.createCategory({
name,
slug,
description,
icon,
parentId,
sortOrder,
});
res.status(201).json({
success: true,
data: category,
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Courses
// ============================================================================
export async function getCourses(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const filters: CourseFilters = {
categoryId: req.query.categoryId as string,
level: req.query.level as CourseFilters['level'],
status: (req.query.status as CourseFilters['status']) || 'published',
isFree: req.query.isFree === 'true' ? true : req.query.isFree === 'false' ? false : undefined,
search: req.query.search as string,
minRating: req.query.minRating ? parseFloat(req.query.minRating as string) : undefined,
};
const pagination: PaginationOptions = {
page: parseInt(req.query.page as string, 10) || 1,
pageSize: Math.min(parseInt(req.query.pageSize as string, 10) || 20, 100),
sortBy: req.query.sortBy as string,
sortOrder: req.query.sortOrder as 'asc' | 'desc',
};
const result = await courseService.getCourses(filters, pagination);
res.json({
success: true,
...result,
});
} catch (error) {
next(error);
}
}
export async function getCourseById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { courseId } = req.params;
const course = await courseService.getCourseWithDetails(courseId);
if (!course) {
res.status(404).json({
success: false,
error: { message: 'Course not found', code: 'NOT_FOUND' },
});
return;
}
res.json({
success: true,
data: course,
});
} catch (error) {
next(error);
}
}
export async function getCourseBySlug(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { slug } = req.params;
const course = await courseService.getCourseBySlug(slug);
if (!course) {
res.status(404).json({
success: false,
error: { message: 'Course not found', code: 'NOT_FOUND' },
});
return;
}
const courseWithDetails = await courseService.getCourseWithDetails(course.id);
res.json({
success: true,
data: courseWithDetails,
});
} catch (error) {
next(error);
}
}
export async function createCourse(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const {
title, slug, description, shortDescription, thumbnailUrl, previewVideoUrl,
categoryId, level, tags, isFree, price, currency, requiresSubscription,
minSubscriptionTier, durationMinutes,
} = req.body;
if (!title) {
res.status(400).json({
success: false,
error: { message: 'Course title is required', code: 'VALIDATION_ERROR' },
});
return;
}
const course = await courseService.createCourse({
title,
slug,
description,
shortDescription,
thumbnailUrl,
previewVideoUrl,
categoryId,
level,
tags,
isFree,
price,
currency,
requiresSubscription,
minSubscriptionTier,
durationMinutes,
instructorId: userId,
});
res.status(201).json({
success: true,
data: course,
});
} catch (error) {
next(error);
}
}
export async function updateCourse(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { courseId } = req.params;
const updates = req.body;
const course = await courseService.updateCourse(courseId, updates);
if (!course) {
res.status(404).json({
success: false,
error: { message: 'Course not found', code: 'NOT_FOUND' },
});
return;
}
res.json({
success: true,
data: course,
});
} catch (error) {
next(error);
}
}
export async function deleteCourse(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { courseId } = req.params;
const deleted = await courseService.deleteCourse(courseId);
if (!deleted) {
res.status(404).json({
success: false,
error: { message: 'Course not found', code: 'NOT_FOUND' },
});
return;
}
res.json({
success: true,
message: 'Course deleted successfully',
});
} catch (error) {
next(error);
}
}
export async function publishCourse(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { courseId } = req.params;
const course = await courseService.publishCourse(courseId);
if (!course) {
res.status(404).json({
success: false,
error: { message: 'Course not found', code: 'NOT_FOUND' },
});
return;
}
res.json({
success: true,
data: course,
message: 'Course published successfully',
});
} catch (error) {
next(error);
}
}
export async function getPopularCourses(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const limit = Math.min(parseInt(req.query.limit as string, 10) || 10, 50);
const courses = await courseService.getPopularCourses(limit);
res.json({
success: true,
data: courses,
});
} catch (error) {
next(error);
}
}
export async function getNewCourses(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const limit = Math.min(parseInt(req.query.limit as string, 10) || 10, 50);
const courses = await courseService.getNewCourses(limit);
res.json({
success: true,
data: courses,
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Modules
// ============================================================================
export async function getCourseModules(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { courseId } = req.params;
const modules = await courseService.getCourseModules(courseId);
res.json({
success: true,
data: modules,
});
} catch (error) {
next(error);
}
}
export async function createModule(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { courseId } = req.params;
const { title, description, sortOrder, unlockAfterModuleId } = req.body;
if (!title) {
res.status(400).json({
success: false,
error: { message: 'Module title is required', code: 'VALIDATION_ERROR' },
});
return;
}
const module = await courseService.createModule({
courseId,
title,
description,
sortOrder,
unlockAfterModuleId,
});
res.status(201).json({
success: true,
data: module,
});
} catch (error) {
next(error);
}
}
export async function deleteModule(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { moduleId } = req.params;
const deleted = await courseService.deleteModule(moduleId);
if (!deleted) {
res.status(404).json({
success: false,
error: { message: 'Module not found', code: 'NOT_FOUND' },
});
return;
}
res.json({
success: true,
message: 'Module deleted successfully',
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Lessons
// ============================================================================
export async function getModuleLessons(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { moduleId } = req.params;
const lessons = await courseService.getModuleLessons(moduleId);
res.json({
success: true,
data: lessons,
});
} catch (error) {
next(error);
}
}
export async function getLessonById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { lessonId } = req.params;
const lesson = await courseService.getLessonById(lessonId);
if (!lesson) {
res.status(404).json({
success: false,
error: { message: 'Lesson not found', code: 'NOT_FOUND' },
});
return;
}
res.json({
success: true,
data: lesson,
});
} catch (error) {
next(error);
}
}
export async function createLesson(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { moduleId } = req.params;
const {
courseId, title, slug, contentType, videoUrl, videoDurationSeconds,
videoProvider, contentMarkdown, resources, sortOrder, isPreview,
} = req.body;
if (!title || !courseId) {
res.status(400).json({
success: false,
error: { message: 'Title and courseId are required', code: 'VALIDATION_ERROR' },
});
return;
}
const lesson = await courseService.createLesson({
moduleId,
courseId,
title,
slug,
contentType,
videoUrl,
videoDurationSeconds,
videoProvider,
contentMarkdown,
resources,
sortOrder,
isPreview,
});
res.status(201).json({
success: true,
data: lesson,
});
} catch (error) {
next(error);
}
}
export async function deleteLesson(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { lessonId } = req.params;
const deleted = await courseService.deleteLesson(lessonId);
if (!deleted) {
res.status(404).json({
success: false,
error: { message: 'Lesson not found', code: 'NOT_FOUND' },
});
return;
}
res.json({
success: true,
message: 'Lesson deleted successfully',
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Enrollments
// ============================================================================
export async function getMyEnrollments(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const enrollments = await enrollmentService.getUserEnrollments(userId);
res.json({
success: true,
data: enrollments,
});
} catch (error) {
next(error);
}
}
export async function enrollInCourse(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { courseId } = req.params;
// Check if course exists and is free or user has purchased
const course = await courseService.getCourseById(courseId);
if (!course) {
res.status(404).json({
success: false,
error: { message: 'Course not found', code: 'NOT_FOUND' },
});
return;
}
if (!course.isFree) {
res.status(403).json({
success: false,
error: { message: 'Payment required for this course', code: 'PAYMENT_REQUIRED' },
});
return;
}
const enrollment = await enrollmentService.createEnrollment({
userId,
courseId,
});
res.status(201).json({
success: true,
data: enrollment,
message: 'Successfully enrolled in course',
});
} catch (error) {
if ((error as Error).message === 'Already enrolled in this course') {
res.status(409).json({
success: false,
error: { message: 'Already enrolled in this course', code: 'ALREADY_ENROLLED' },
});
return;
}
next(error);
}
}
export async function getEnrollmentStatus(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { courseId } = req.params;
const enrollment = await enrollmentService.getEnrollment(userId, courseId);
const progress = enrollment
? await enrollmentService.getCourseProgress(userId, courseId)
: [];
res.json({
success: true,
data: {
enrolled: !!enrollment,
enrollment,
lessonProgress: progress,
},
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Progress
// ============================================================================
export async function updateLessonProgress(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { lessonId } = req.params;
const { videoWatchedSeconds, videoCompleted, userNotes } = req.body;
const progress = await enrollmentService.updateLessonProgress(userId, lessonId, {
videoWatchedSeconds,
videoCompleted,
userNotes,
});
res.json({
success: true,
data: progress,
});
} catch (error) {
if ((error as Error).message === 'Not enrolled in this course') {
res.status(403).json({
success: false,
error: { message: 'You must be enrolled to track progress', code: 'NOT_ENROLLED' },
});
return;
}
next(error);
}
}
export async function markLessonComplete(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { lessonId } = req.params;
const progress = await enrollmentService.markLessonComplete(userId, lessonId);
res.json({
success: true,
data: progress,
message: 'Lesson marked as complete',
});
} catch (error) {
next(error);
}
}
// ============================================================================
// User Stats
// ============================================================================
export async function getMyLearningStats(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const stats = await enrollmentService.getUserLearningStats(userId);
res.json({
success: true,
data: stats,
});
} catch (error) {
next(error);
}
}
export async function getCourseStats(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { courseId } = req.params;
const stats = await enrollmentService.getCourseEnrollmentStats(courseId);
res.json({
success: true,
data: stats,
});
} catch (error) {
next(error);
}
}

View File

@ -1,182 +0,0 @@
/**
* Education Routes
* Course, lesson, and enrollment management
*/
import { Router, RequestHandler } from 'express';
import * as educationController from './controllers/education.controller';
import { requireAuth } from '../../core/guards/auth.guard';
const router = Router();
// Type cast helper for authenticated routes
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
const authHandler = (fn: Function): RequestHandler => fn as RequestHandler;
// ============================================================================
// Public Routes
// ============================================================================
/**
* GET /api/v1/education/categories
* Get all course categories
*/
router.get('/categories', educationController.getCategories);
/**
* GET /api/v1/education/courses
* List published courses with filters and pagination
* Query params: categoryId, level, isFree, search, minRating, page, pageSize, sortBy, sortOrder
*/
router.get('/courses', educationController.getCourses);
/**
* GET /api/v1/education/courses/popular
* Get popular courses
* Query params: limit
*/
router.get('/courses/popular', educationController.getPopularCourses);
/**
* GET /api/v1/education/courses/new
* Get newest courses
* Query params: limit
*/
router.get('/courses/new', educationController.getNewCourses);
/**
* GET /api/v1/education/courses/:courseId
* Get course by ID with full details
*/
router.get('/courses/:courseId', educationController.getCourseById);
/**
* GET /api/v1/education/courses/slug/:slug
* Get course by slug with full details
*/
router.get('/courses/slug/:slug', educationController.getCourseBySlug);
/**
* GET /api/v1/education/courses/:courseId/modules
* Get course modules with lessons
*/
router.get('/courses/:courseId/modules', educationController.getCourseModules);
/**
* GET /api/v1/education/courses/:courseId/stats
* Get course enrollment statistics
*/
router.get('/courses/:courseId/stats', educationController.getCourseStats);
/**
* GET /api/v1/education/modules/:moduleId/lessons
* Get module lessons
*/
router.get('/modules/:moduleId/lessons', educationController.getModuleLessons);
/**
* GET /api/v1/education/lessons/:lessonId
* Get lesson by ID
*/
router.get('/lessons/:lessonId', educationController.getLessonById);
// ============================================================================
// Authenticated User Routes
// ============================================================================
/**
* GET /api/v1/education/my/enrollments
* Get current user's course enrollments
*/
router.get('/my/enrollments', authHandler(requireAuth), authHandler(educationController.getMyEnrollments));
/**
* GET /api/v1/education/my/stats
* Get current user's learning statistics
*/
router.get('/my/stats', authHandler(requireAuth), authHandler(educationController.getMyLearningStats));
/**
* POST /api/v1/education/courses/:courseId/enroll
* Enroll in a free course
*/
router.post('/courses/:courseId/enroll', authHandler(requireAuth), authHandler(educationController.enrollInCourse));
/**
* GET /api/v1/education/courses/:courseId/enrollment
* Get enrollment status for a course
*/
router.get('/courses/:courseId/enrollment', authHandler(requireAuth), authHandler(educationController.getEnrollmentStatus));
/**
* POST /api/v1/education/lessons/:lessonId/progress
* Update lesson progress
* Body: { videoWatchedSeconds?, videoCompleted?, userNotes? }
*/
router.post('/lessons/:lessonId/progress', authHandler(requireAuth), authHandler(educationController.updateLessonProgress));
/**
* POST /api/v1/education/lessons/:lessonId/complete
* Mark lesson as complete
*/
router.post('/lessons/:lessonId/complete', authHandler(requireAuth), authHandler(educationController.markLessonComplete));
// ============================================================================
// Instructor/Admin Routes (Course Management)
// ============================================================================
/**
* POST /api/v1/education/categories
* Create a new category (admin only)
*/
router.post('/categories', authHandler(requireAuth), authHandler(educationController.createCategory));
/**
* POST /api/v1/education/courses
* Create a new course (instructor/admin)
*/
router.post('/courses', authHandler(requireAuth), authHandler(educationController.createCourse));
/**
* PATCH /api/v1/education/courses/:courseId
* Update course details
*/
router.patch('/courses/:courseId', authHandler(requireAuth), authHandler(educationController.updateCourse));
/**
* DELETE /api/v1/education/courses/:courseId
* Delete a course
*/
router.delete('/courses/:courseId', authHandler(requireAuth), authHandler(educationController.deleteCourse));
/**
* POST /api/v1/education/courses/:courseId/publish
* Publish a course
*/
router.post('/courses/:courseId/publish', authHandler(requireAuth), authHandler(educationController.publishCourse));
/**
* POST /api/v1/education/courses/:courseId/modules
* Create a module in a course
*/
router.post('/courses/:courseId/modules', authHandler(requireAuth), authHandler(educationController.createModule));
/**
* DELETE /api/v1/education/modules/:moduleId
* Delete a module
*/
router.delete('/modules/:moduleId', authHandler(requireAuth), authHandler(educationController.deleteModule));
/**
* POST /api/v1/education/modules/:moduleId/lessons
* Create a lesson in a module
*/
router.post('/modules/:moduleId/lessons', authHandler(requireAuth), authHandler(educationController.createLesson));
/**
* DELETE /api/v1/education/lessons/:lessonId
* Delete a lesson
*/
router.delete('/lessons/:lessonId', authHandler(requireAuth), authHandler(educationController.deleteLesson));
export { router as educationRouter };

View File

@ -1,568 +0,0 @@
/**
* Course Service
* Handles course management operations
*/
import { db } from '../../../shared/database';
import { logger } from '../../../shared/utils/logger';
import type {
Course,
CourseWithDetails,
CreateCourseInput,
UpdateCourseInput,
CourseFilters,
Category,
CreateCategoryInput,
Module,
ModuleWithLessons,
CreateModuleInput,
Lesson,
CreateLessonInput,
PaginatedResult,
PaginationOptions,
} from '../types/education.types';
// ============================================================================
// Helper Functions
// ============================================================================
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
}
function transformCourse(row: Record<string, unknown>): Course {
return {
id: row.id as string,
title: row.title as string,
slug: row.slug as string,
description: row.description as string | undefined,
shortDescription: row.short_description as string | undefined,
thumbnailUrl: row.thumbnail_url as string | undefined,
previewVideoUrl: row.preview_video_url as string | undefined,
categoryId: row.category_id as string | undefined,
level: row.level as Course['level'],
tags: (row.tags as string[]) || [],
isFree: row.is_free as boolean,
price: parseFloat(row.price as string) || 0,
currency: row.currency as string,
requiresSubscription: row.requires_subscription as boolean,
minSubscriptionTier: row.min_subscription_tier as string | undefined,
durationMinutes: row.duration_minutes as number | undefined,
lessonsCount: row.lessons_count as number,
enrolledCount: row.enrolled_count as number,
averageRating: parseFloat(row.average_rating as string) || 0,
ratingsCount: row.ratings_count as number,
status: row.status as Course['status'],
publishedAt: row.published_at ? new Date(row.published_at as string) : undefined,
instructorId: row.instructor_id as string | undefined,
aiGenerated: row.ai_generated as boolean,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
}
function transformLesson(row: Record<string, unknown>): Lesson {
return {
id: row.id as string,
moduleId: row.module_id as string,
courseId: row.course_id as string,
title: row.title as string,
slug: row.slug as string,
contentType: row.content_type as Lesson['contentType'],
videoUrl: row.video_url as string | undefined,
videoDurationSeconds: row.video_duration_seconds as number | undefined,
videoProvider: row.video_provider as string | undefined,
contentMarkdown: row.content_markdown as string | undefined,
contentHtml: row.content_html as string | undefined,
resources: (row.resources as Lesson['resources']) || [],
sortOrder: row.sort_order as number,
isPreview: row.is_preview as boolean,
aiGenerated: row.ai_generated as boolean,
aiSummary: row.ai_summary as string | undefined,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
}
// ============================================================================
// Course Service Class
// ============================================================================
class CourseService {
// ==========================================================================
// Categories
// ==========================================================================
async getCategories(): Promise<Category[]> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM education.categories ORDER BY sort_order, name`
);
return result.rows.map((row) => ({
id: row.id as string,
name: row.name as string,
slug: row.slug as string,
description: row.description as string | undefined,
icon: row.icon as string | undefined,
parentId: row.parent_id as string | undefined,
sortOrder: row.sort_order as number,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
}));
}
async getCategoryById(id: string): Promise<Category | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM education.categories WHERE id = $1`,
[id]
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
return {
id: row.id as string,
name: row.name as string,
slug: row.slug as string,
description: row.description as string | undefined,
icon: row.icon as string | undefined,
parentId: row.parent_id as string | undefined,
sortOrder: row.sort_order as number,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
}
async createCategory(input: CreateCategoryInput): Promise<Category> {
const slug = input.slug || generateSlug(input.name);
const result = await db.query<Record<string, unknown>>(
`INSERT INTO education.categories (name, slug, description, icon, parent_id, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[input.name, slug, input.description, input.icon, input.parentId, input.sortOrder || 0]
);
const row = result.rows[0];
return {
id: row.id as string,
name: row.name as string,
slug: row.slug as string,
description: row.description as string | undefined,
icon: row.icon as string | undefined,
parentId: row.parent_id as string | undefined,
sortOrder: row.sort_order as number,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
}
// ==========================================================================
// Courses
// ==========================================================================
async getCourses(
filters: CourseFilters = {},
pagination: PaginationOptions = {}
): Promise<PaginatedResult<Course>> {
const { page = 1, pageSize = 20, sortBy = 'created_at', sortOrder = 'desc' } = pagination;
const offset = (page - 1) * pageSize;
const conditions: string[] = [];
const params: (string | number | boolean | null)[] = [];
let paramIndex = 1;
if (filters.categoryId) {
conditions.push(`category_id = $${paramIndex++}`);
params.push(filters.categoryId);
}
if (filters.level) {
conditions.push(`level = $${paramIndex++}`);
params.push(filters.level);
}
if (filters.status) {
conditions.push(`status = $${paramIndex++}`);
params.push(filters.status);
}
if (filters.isFree !== undefined) {
conditions.push(`is_free = $${paramIndex++}`);
params.push(filters.isFree);
}
if (filters.instructorId) {
conditions.push(`instructor_id = $${paramIndex++}`);
params.push(filters.instructorId);
}
if (filters.search) {
conditions.push(`(title ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`);
params.push(`%${filters.search}%`);
paramIndex++;
}
if (filters.minRating) {
conditions.push(`average_rating >= $${paramIndex++}`);
params.push(filters.minRating);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const allowedSortColumns = ['created_at', 'title', 'price', 'average_rating', 'enrolled_count'];
const safeSort = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at';
const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC';
const countResult = await db.query<{ count: string }>(
`SELECT COUNT(*) as count FROM education.courses ${whereClause}`,
params
);
const total = parseInt(countResult.rows[0].count, 10);
params.push(pageSize, offset);
const dataResult = await db.query<Record<string, unknown>>(
`SELECT * FROM education.courses ${whereClause}
ORDER BY ${safeSort} ${safeSortOrder}
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
params
);
return {
data: dataResult.rows.map(transformCourse),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
async getCourseById(id: string): Promise<Course | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM education.courses WHERE id = $1`,
[id]
);
if (result.rows.length === 0) return null;
return transformCourse(result.rows[0]);
}
async getCourseBySlug(slug: string): Promise<Course | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM education.courses WHERE slug = $1`,
[slug]
);
if (result.rows.length === 0) return null;
return transformCourse(result.rows[0]);
}
async getCourseWithDetails(id: string): Promise<CourseWithDetails | null> {
const course = await this.getCourseById(id);
if (!course) return null;
// Get category
let category: Category | undefined;
if (course.categoryId) {
category = (await this.getCategoryById(course.categoryId)) || undefined;
}
// Get modules with lessons
const modules = await this.getCourseModules(id);
return {
...course,
category,
modules,
};
}
async createCourse(input: CreateCourseInput): Promise<Course> {
const slug = input.slug || generateSlug(input.title);
const result = await db.query<Record<string, unknown>>(
`INSERT INTO education.courses (
title, slug, description, short_description, thumbnail_url, preview_video_url,
category_id, level, tags, is_free, price, currency, requires_subscription,
min_subscription_tier, duration_minutes, instructor_id, status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'draft')
RETURNING *`,
[
input.title,
slug,
input.description,
input.shortDescription,
input.thumbnailUrl,
input.previewVideoUrl,
input.categoryId,
input.level || 'beginner',
input.tags || [],
input.isFree ?? false,
input.price || 0,
input.currency || 'USD',
input.requiresSubscription ?? false,
input.minSubscriptionTier,
input.durationMinutes,
input.instructorId,
]
);
logger.info('[CourseService] Course created:', { courseId: result.rows[0].id, title: input.title });
return transformCourse(result.rows[0]);
}
async updateCourse(id: string, input: UpdateCourseInput): Promise<Course | null> {
const updates: string[] = [];
const params: (string | number | boolean | string[] | null)[] = [];
let paramIndex = 1;
if (input.title !== undefined) {
updates.push(`title = $${paramIndex++}`);
params.push(input.title);
}
if (input.slug !== undefined) {
updates.push(`slug = $${paramIndex++}`);
params.push(input.slug);
}
if (input.description !== undefined) {
updates.push(`description = $${paramIndex++}`);
params.push(input.description);
}
if (input.shortDescription !== undefined) {
updates.push(`short_description = $${paramIndex++}`);
params.push(input.shortDescription);
}
if (input.thumbnailUrl !== undefined) {
updates.push(`thumbnail_url = $${paramIndex++}`);
params.push(input.thumbnailUrl);
}
if (input.categoryId !== undefined) {
updates.push(`category_id = $${paramIndex++}`);
params.push(input.categoryId);
}
if (input.level !== undefined) {
updates.push(`level = $${paramIndex++}`);
params.push(input.level);
}
if (input.tags !== undefined) {
updates.push(`tags = $${paramIndex++}`);
params.push(input.tags);
}
if (input.isFree !== undefined) {
updates.push(`is_free = $${paramIndex++}`);
params.push(input.isFree);
}
if (input.price !== undefined) {
updates.push(`price = $${paramIndex++}`);
params.push(input.price);
}
if (input.status !== undefined) {
updates.push(`status = $${paramIndex++}`);
params.push(input.status);
if (input.status === 'published') {
updates.push(`published_at = CURRENT_TIMESTAMP`);
}
}
if (updates.length === 0) return this.getCourseById(id);
params.push(id);
const result = await db.query<Record<string, unknown>>(
`UPDATE education.courses SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
params
);
if (result.rows.length === 0) return null;
logger.info('[CourseService] Course updated:', { courseId: id });
return transformCourse(result.rows[0]);
}
async deleteCourse(id: string): Promise<boolean> {
const result = await db.query(`DELETE FROM education.courses WHERE id = $1`, [id]);
logger.info('[CourseService] Course deleted:', { courseId: id });
return (result.rowCount ?? 0) > 0;
}
async publishCourse(id: string): Promise<Course | null> {
return this.updateCourse(id, { status: 'published' });
}
async archiveCourse(id: string): Promise<Course | null> {
return this.updateCourse(id, { status: 'archived' });
}
// ==========================================================================
// Modules
// ==========================================================================
async getCourseModules(courseId: string): Promise<ModuleWithLessons[]> {
const modulesResult = await db.query<Record<string, unknown>>(
`SELECT * FROM education.modules WHERE course_id = $1 ORDER BY sort_order`,
[courseId]
);
const modules: ModuleWithLessons[] = [];
for (const row of modulesResult.rows) {
const lessons = await this.getModuleLessons(row.id as string);
modules.push({
id: row.id as string,
courseId: row.course_id as string,
title: row.title as string,
description: row.description as string | undefined,
sortOrder: row.sort_order as number,
unlockAfterModuleId: row.unlock_after_module_id as string | undefined,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
lessons,
});
}
return modules;
}
async getModuleById(id: string): Promise<Module | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM education.modules WHERE id = $1`,
[id]
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
return {
id: row.id as string,
courseId: row.course_id as string,
title: row.title as string,
description: row.description as string | undefined,
sortOrder: row.sort_order as number,
unlockAfterModuleId: row.unlock_after_module_id as string | undefined,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
}
async createModule(input: CreateModuleInput): Promise<Module> {
const result = await db.query<Record<string, unknown>>(
`INSERT INTO education.modules (course_id, title, description, sort_order, unlock_after_module_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[input.courseId, input.title, input.description, input.sortOrder || 0, input.unlockAfterModuleId]
);
const row = result.rows[0];
return {
id: row.id as string,
courseId: row.course_id as string,
title: row.title as string,
description: row.description as string | undefined,
sortOrder: row.sort_order as number,
unlockAfterModuleId: row.unlock_after_module_id as string | undefined,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
}
async deleteModule(id: string): Promise<boolean> {
const result = await db.query(`DELETE FROM education.modules WHERE id = $1`, [id]);
return (result.rowCount ?? 0) > 0;
}
// ==========================================================================
// Lessons
// ==========================================================================
async getModuleLessons(moduleId: string): Promise<Lesson[]> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM education.lessons WHERE module_id = $1 ORDER BY sort_order`,
[moduleId]
);
return result.rows.map(transformLesson);
}
async getLessonById(id: string): Promise<Lesson | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM education.lessons WHERE id = $1`,
[id]
);
if (result.rows.length === 0) return null;
return transformLesson(result.rows[0]);
}
async createLesson(input: CreateLessonInput): Promise<Lesson> {
const slug = input.slug || generateSlug(input.title);
const result = await db.query<Record<string, unknown>>(
`INSERT INTO education.lessons (
module_id, course_id, title, slug, content_type, video_url,
video_duration_seconds, video_provider, content_markdown,
resources, sort_order, is_preview
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *`,
[
input.moduleId,
input.courseId,
input.title,
slug,
input.contentType || 'video',
input.videoUrl,
input.videoDurationSeconds,
input.videoProvider,
input.contentMarkdown,
JSON.stringify(input.resources || []),
input.sortOrder || 0,
input.isPreview ?? false,
]
);
// Update course lessons count
await db.query(
`UPDATE education.courses SET lessons_count = lessons_count + 1 WHERE id = $1`,
[input.courseId]
);
return transformLesson(result.rows[0]);
}
async deleteLesson(id: string): Promise<boolean> {
// Get lesson to update course count
const lesson = await this.getLessonById(id);
if (!lesson) return false;
const result = await db.query(`DELETE FROM education.lessons WHERE id = $1`, [id]);
if ((result.rowCount ?? 0) > 0) {
await db.query(
`UPDATE education.courses SET lessons_count = GREATEST(lessons_count - 1, 0) WHERE id = $1`,
[lesson.courseId]
);
}
return (result.rowCount ?? 0) > 0;
}
// ==========================================================================
// Statistics
// ==========================================================================
async updateCourseStats(courseId: string): Promise<void> {
await db.query(
`UPDATE education.courses
SET lessons_count = (SELECT COUNT(*) FROM education.lessons WHERE course_id = $1),
duration_minutes = (SELECT COALESCE(SUM(video_duration_seconds) / 60, 0) FROM education.lessons WHERE course_id = $1)
WHERE id = $1`,
[courseId]
);
}
async getPopularCourses(limit: number = 10): Promise<Course[]> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM education.courses
WHERE status = 'published'
ORDER BY enrolled_count DESC, average_rating DESC
LIMIT $1`,
[limit]
);
return result.rows.map(transformCourse);
}
async getNewCourses(limit: number = 10): Promise<Course[]> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM education.courses
WHERE status = 'published'
ORDER BY published_at DESC
LIMIT $1`,
[limit]
);
return result.rows.map(transformCourse);
}
}
export const courseService = new CourseService();

View File

@ -1,420 +0,0 @@
/**
* Enrollment Service
* Handles course enrollments and progress tracking
*/
import { db } from '../../../shared/database';
import { logger } from '../../../shared/utils/logger';
import { courseService } from './course.service';
import type {
Enrollment,
EnrollmentWithCourse,
CreateEnrollmentInput,
LessonProgress,
UpdateLessonProgressInput,
Course,
} from '../types/education.types';
// ============================================================================
// Helper Functions
// ============================================================================
function transformEnrollment(row: Record<string, unknown>): Enrollment {
return {
id: row.id as string,
userId: row.user_id as string,
courseId: row.course_id as string,
status: row.status as Enrollment['status'],
progressPercentage: parseFloat(row.progress_percentage as string) || 0,
lessonsCompleted: row.lessons_completed as number,
enrolledAt: new Date(row.enrolled_at as string),
expiresAt: row.expires_at ? new Date(row.expires_at as string) : undefined,
completedAt: row.completed_at ? new Date(row.completed_at as string) : undefined,
paymentId: row.payment_id as string | undefined,
certificateIssued: row.certificate_issued as boolean,
certificateUrl: row.certificate_url as string | undefined,
certificateIssuedAt: row.certificate_issued_at ? new Date(row.certificate_issued_at as string) : undefined,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
}
function transformLessonProgress(row: Record<string, unknown>): LessonProgress {
return {
id: row.id as string,
userId: row.user_id as string,
lessonId: row.lesson_id as string,
enrollmentId: row.enrollment_id as string,
videoWatchedSeconds: row.video_watched_seconds as number,
videoCompleted: row.video_completed as boolean,
startedAt: row.started_at ? new Date(row.started_at as string) : undefined,
completedAt: row.completed_at ? new Date(row.completed_at as string) : undefined,
userNotes: row.user_notes as string | undefined,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
}
// ============================================================================
// Enrollment Service Class
// ============================================================================
class EnrollmentService {
// ==========================================================================
// Enrollments
// ==========================================================================
async getUserEnrollments(userId: string): Promise<EnrollmentWithCourse[]> {
const result = await db.query<Record<string, unknown>>(
`SELECT e.*, c.title, c.slug, c.thumbnail_url, c.level, c.lessons_count,
c.duration_minutes, c.instructor_id
FROM education.enrollments e
JOIN education.courses c ON e.course_id = c.id
WHERE e.user_id = $1
ORDER BY e.enrolled_at DESC`,
[userId]
);
return result.rows.map((row) => ({
...transformEnrollment(row),
course: {
id: row.course_id as string,
title: row.title as string,
slug: row.slug as string,
thumbnailUrl: row.thumbnail_url as string | undefined,
level: row.level as Course['level'],
lessonsCount: row.lessons_count as number,
durationMinutes: row.duration_minutes as number | undefined,
instructorId: row.instructor_id as string | undefined,
} as Course,
}));
}
async getEnrollment(userId: string, courseId: string): Promise<Enrollment | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM education.enrollments WHERE user_id = $1 AND course_id = $2`,
[userId, courseId]
);
if (result.rows.length === 0) return null;
return transformEnrollment(result.rows[0]);
}
async getEnrollmentById(id: string): Promise<Enrollment | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM education.enrollments WHERE id = $1`,
[id]
);
if (result.rows.length === 0) return null;
return transformEnrollment(result.rows[0]);
}
async isEnrolled(userId: string, courseId: string): Promise<boolean> {
const result = await db.query<{ exists: boolean }>(
`SELECT EXISTS(
SELECT 1 FROM education.enrollments
WHERE user_id = $1 AND course_id = $2 AND status = 'active'
) as exists`,
[userId, courseId]
);
return result.rows[0].exists;
}
async createEnrollment(input: CreateEnrollmentInput): Promise<Enrollment> {
// Check if already enrolled
const existing = await this.getEnrollment(input.userId, input.courseId);
if (existing) {
if (existing.status === 'active') {
throw new Error('Already enrolled in this course');
}
// Reactivate expired/cancelled enrollment
const result = await db.query<Record<string, unknown>>(
`UPDATE education.enrollments
SET status = 'active', expires_at = $1
WHERE id = $2
RETURNING *`,
[input.expiresAt, existing.id]
);
return transformEnrollment(result.rows[0]);
}
// Check if course exists
const course = await courseService.getCourseById(input.courseId);
if (!course) {
throw new Error('Course not found');
}
// Create enrollment
const result = await db.query<Record<string, unknown>>(
`INSERT INTO education.enrollments (user_id, course_id, payment_id, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[input.userId, input.courseId, input.paymentId, input.expiresAt]
);
// Update course enrolled count
await db.query(
`UPDATE education.courses SET enrolled_count = enrolled_count + 1 WHERE id = $1`,
[input.courseId]
);
logger.info('[EnrollmentService] User enrolled in course:', {
userId: input.userId,
courseId: input.courseId,
});
return transformEnrollment(result.rows[0]);
}
async cancelEnrollment(userId: string, courseId: string): Promise<boolean> {
const result = await db.query(
`UPDATE education.enrollments SET status = 'cancelled' WHERE user_id = $1 AND course_id = $2`,
[userId, courseId]
);
if ((result.rowCount ?? 0) > 0) {
await db.query(
`UPDATE education.courses SET enrolled_count = GREATEST(enrolled_count - 1, 0) WHERE id = $1`,
[courseId]
);
}
return (result.rowCount ?? 0) > 0;
}
// ==========================================================================
// Progress Tracking
// ==========================================================================
async getLessonProgress(userId: string, lessonId: string): Promise<LessonProgress | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM education.lesson_progress WHERE user_id = $1 AND lesson_id = $2`,
[userId, lessonId]
);
if (result.rows.length === 0) return null;
return transformLessonProgress(result.rows[0]);
}
async getCourseProgress(userId: string, courseId: string): Promise<LessonProgress[]> {
const result = await db.query<Record<string, unknown>>(
`SELECT lp.* FROM education.lesson_progress lp
JOIN education.lessons l ON lp.lesson_id = l.id
WHERE lp.user_id = $1 AND l.course_id = $2`,
[userId, courseId]
);
return result.rows.map(transformLessonProgress);
}
async updateLessonProgress(
userId: string,
lessonId: string,
input: UpdateLessonProgressInput
): Promise<LessonProgress> {
// Get lesson and enrollment
const lesson = await courseService.getLessonById(lessonId);
if (!lesson) {
throw new Error('Lesson not found');
}
const enrollment = await this.getEnrollment(userId, lesson.courseId);
if (!enrollment) {
throw new Error('Not enrolled in this course');
}
// Check if progress exists
const existing = await this.getLessonProgress(userId, lessonId);
if (existing) {
// Update existing progress
const updates: string[] = [];
const params: (string | number | boolean | null)[] = [];
let paramIndex = 1;
if (input.videoWatchedSeconds !== undefined) {
updates.push(`video_watched_seconds = GREATEST(video_watched_seconds, $${paramIndex++})`);
params.push(input.videoWatchedSeconds);
}
if (input.videoCompleted !== undefined && input.videoCompleted) {
updates.push(`video_completed = true`);
updates.push(`completed_at = COALESCE(completed_at, CURRENT_TIMESTAMP)`);
}
if (input.userNotes !== undefined) {
updates.push(`user_notes = $${paramIndex++}`);
params.push(input.userNotes);
}
params.push(userId, lessonId);
const result = await db.query<Record<string, unknown>>(
`UPDATE education.lesson_progress
SET ${updates.join(', ')}
WHERE user_id = $${paramIndex++} AND lesson_id = $${paramIndex}
RETURNING *`,
params
);
// Update enrollment progress if lesson completed
if (input.videoCompleted && !existing.videoCompleted) {
await this.updateEnrollmentProgress(enrollment.id, lesson.courseId);
}
return transformLessonProgress(result.rows[0]);
} else {
// Create new progress record
const result = await db.query<Record<string, unknown>>(
`INSERT INTO education.lesson_progress (
user_id, lesson_id, enrollment_id, video_watched_seconds, video_completed,
started_at, completed_at, user_notes
) VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP, $6, $7)
RETURNING *`,
[
userId,
lessonId,
enrollment.id,
input.videoWatchedSeconds || 0,
input.videoCompleted || false,
input.videoCompleted ? new Date() : null,
input.userNotes,
]
);
if (input.videoCompleted) {
await this.updateEnrollmentProgress(enrollment.id, lesson.courseId);
}
return transformLessonProgress(result.rows[0]);
}
}
async markLessonComplete(userId: string, lessonId: string): Promise<LessonProgress> {
return this.updateLessonProgress(userId, lessonId, { videoCompleted: true });
}
private async updateEnrollmentProgress(enrollmentId: string, courseId: string): Promise<void> {
// Calculate progress
const statsResult = await db.query<{ total: string; completed: string }>(
`SELECT
(SELECT COUNT(*) FROM education.lessons WHERE course_id = $1) as total,
(SELECT COUNT(*) FROM education.lesson_progress lp
JOIN education.lessons l ON lp.lesson_id = l.id
WHERE lp.enrollment_id = $2 AND lp.video_completed = true) as completed`,
[courseId, enrollmentId]
);
const total = parseInt(statsResult.rows[0].total, 10);
const completed = parseInt(statsResult.rows[0].completed, 10);
const progress = total > 0 ? (completed / total) * 100 : 0;
// Update enrollment
const isCompleted = progress >= 100;
await db.query(
`UPDATE education.enrollments
SET progress_percentage = $1,
lessons_completed = $2,
status = CASE WHEN $3 THEN 'completed' ELSE status END,
completed_at = CASE WHEN $3 AND completed_at IS NULL THEN CURRENT_TIMESTAMP ELSE completed_at END
WHERE id = $4`,
[progress, completed, isCompleted, enrollmentId]
);
if (isCompleted) {
logger.info('[EnrollmentService] Course completed:', { enrollmentId, courseId });
}
}
// ==========================================================================
// Certificates
// ==========================================================================
async issueCertificate(enrollmentId: string): Promise<Enrollment | null> {
const enrollment = await this.getEnrollmentById(enrollmentId);
if (!enrollment) return null;
if (enrollment.status !== 'completed') {
throw new Error('Course must be completed to issue certificate');
}
// Generate certificate URL (placeholder - would integrate with PDF generator)
const certificateUrl = `/api/v1/education/certificates/${enrollmentId}`;
const result = await db.query<Record<string, unknown>>(
`UPDATE education.enrollments
SET certificate_issued = true,
certificate_url = $1,
certificate_issued_at = CURRENT_TIMESTAMP
WHERE id = $2
RETURNING *`,
[certificateUrl, enrollmentId]
);
logger.info('[EnrollmentService] Certificate issued:', { enrollmentId });
return transformEnrollment(result.rows[0]);
}
// ==========================================================================
// Analytics
// ==========================================================================
async getCourseEnrollmentStats(courseId: string): Promise<{
totalEnrolled: number;
activeEnrollments: number;
completedEnrollments: number;
averageProgress: number;
certificatesIssued: number;
}> {
const result = await db.query<Record<string, string>>(
`SELECT
COUNT(*) as total_enrolled,
COUNT(*) FILTER (WHERE status = 'active') as active_enrollments,
COUNT(*) FILTER (WHERE status = 'completed') as completed_enrollments,
COALESCE(AVG(progress_percentage), 0) as average_progress,
COUNT(*) FILTER (WHERE certificate_issued = true) as certificates_issued
FROM education.enrollments
WHERE course_id = $1`,
[courseId]
);
const stats = result.rows[0];
return {
totalEnrolled: parseInt(stats.total_enrolled, 10),
activeEnrollments: parseInt(stats.active_enrollments, 10),
completedEnrollments: parseInt(stats.completed_enrollments, 10),
averageProgress: parseFloat(stats.average_progress) || 0,
certificatesIssued: parseInt(stats.certificates_issued, 10),
};
}
async getUserLearningStats(userId: string): Promise<{
totalCourses: number;
completedCourses: number;
totalLessonsCompleted: number;
totalMinutesWatched: number;
certificatesEarned: number;
}> {
const enrollmentResult = await db.query<Record<string, string>>(
`SELECT
COUNT(*) as total_courses,
COUNT(*) FILTER (WHERE status = 'completed') as completed_courses,
SUM(lessons_completed) as total_lessons_completed,
COUNT(*) FILTER (WHERE certificate_issued = true) as certificates_earned
FROM education.enrollments
WHERE user_id = $1`,
[userId]
);
const progressResult = await db.query<{ total_seconds: string }>(
`SELECT COALESCE(SUM(video_watched_seconds), 0) as total_seconds
FROM education.lesson_progress
WHERE user_id = $1`,
[userId]
);
const stats = enrollmentResult.rows[0];
return {
totalCourses: parseInt(stats.total_courses, 10),
completedCourses: parseInt(stats.completed_courses, 10),
totalLessonsCompleted: parseInt(stats.total_lessons_completed, 10) || 0,
totalMinutesWatched: Math.floor(parseInt(progressResult.rows[0].total_seconds, 10) / 60),
certificatesEarned: parseInt(stats.certificates_earned, 10),
};
}
}
export const enrollmentService = new EnrollmentService();

View File

@ -1,401 +0,0 @@
/**
* Education Module Types
*/
// ============================================================================
// Enums
// ============================================================================
export type CourseLevel = 'beginner' | 'intermediate' | 'advanced' | 'expert';
export type CourseStatus = 'draft' | 'published' | 'archived';
export type ContentType = 'video' | 'text' | 'quiz' | 'exercise' | 'resource';
export type EnrollmentStatus = 'active' | 'completed' | 'expired' | 'cancelled';
export type QuestionType = 'multiple_choice' | 'true_false' | 'multiple_answer' | 'short_answer';
// ============================================================================
// Category
// ============================================================================
export interface Category {
id: string;
name: string;
slug: string;
description?: string;
icon?: string;
parentId?: string;
sortOrder: number;
createdAt: Date;
updatedAt: Date;
}
export interface CreateCategoryInput {
name: string;
slug?: string;
description?: string;
icon?: string;
parentId?: string;
sortOrder?: number;
}
// ============================================================================
// Course
// ============================================================================
export interface Course {
id: string;
title: string;
slug: string;
description?: string;
shortDescription?: string;
thumbnailUrl?: string;
previewVideoUrl?: string;
categoryId?: string;
level: CourseLevel;
tags: string[];
isFree: boolean;
price: number;
currency: string;
requiresSubscription: boolean;
minSubscriptionTier?: string;
durationMinutes?: number;
lessonsCount: number;
enrolledCount: number;
averageRating: number;
ratingsCount: number;
status: CourseStatus;
publishedAt?: Date;
instructorId?: string;
aiGenerated: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface CourseWithDetails extends Course {
category?: Category;
instructor?: {
id: string;
email: string;
profile?: {
firstName?: string;
lastName?: string;
avatarUrl?: string;
};
};
modules?: ModuleWithLessons[];
}
export interface CreateCourseInput {
title: string;
slug?: string;
description?: string;
shortDescription?: string;
thumbnailUrl?: string;
previewVideoUrl?: string;
categoryId?: string;
level?: CourseLevel;
tags?: string[];
isFree?: boolean;
price?: number;
currency?: string;
requiresSubscription?: boolean;
minSubscriptionTier?: string;
durationMinutes?: number;
instructorId?: string;
}
export interface UpdateCourseInput extends Partial<CreateCourseInput> {
status?: CourseStatus;
}
export interface CourseFilters {
categoryId?: string;
level?: CourseLevel;
status?: CourseStatus;
isFree?: boolean;
instructorId?: string;
search?: string;
minRating?: number;
tags?: string[];
}
// ============================================================================
// Module
// ============================================================================
export interface Module {
id: string;
courseId: string;
title: string;
description?: string;
sortOrder: number;
unlockAfterModuleId?: string;
createdAt: Date;
updatedAt: Date;
}
export interface ModuleWithLessons extends Module {
lessons: Lesson[];
}
export interface CreateModuleInput {
courseId: string;
title: string;
description?: string;
sortOrder?: number;
unlockAfterModuleId?: string;
}
// ============================================================================
// Lesson
// ============================================================================
export interface LessonResource {
name: string;
url: string;
type: string;
}
export interface Lesson {
id: string;
moduleId: string;
courseId: string;
title: string;
slug: string;
contentType: ContentType;
videoUrl?: string;
videoDurationSeconds?: number;
videoProvider?: string;
contentMarkdown?: string;
contentHtml?: string;
resources: LessonResource[];
sortOrder: number;
isPreview: boolean;
aiGenerated: boolean;
aiSummary?: string;
createdAt: Date;
updatedAt: Date;
}
export interface LessonWithProgress extends Lesson {
progress?: LessonProgress;
}
export interface CreateLessonInput {
moduleId: string;
courseId: string;
title: string;
slug?: string;
contentType?: ContentType;
videoUrl?: string;
videoDurationSeconds?: number;
videoProvider?: string;
contentMarkdown?: string;
resources?: LessonResource[];
sortOrder?: number;
isPreview?: boolean;
}
// ============================================================================
// Quiz
// ============================================================================
export interface QuizOption {
id: string;
text: string;
isCorrect: boolean;
}
export interface QuizQuestion {
id: string;
quizId: string;
questionType: QuestionType;
questionText: string;
explanation?: string;
options?: QuizOption[];
correctAnswers?: string[];
points: number;
sortOrder: number;
createdAt: Date;
}
export interface Quiz {
id: string;
lessonId?: string;
courseId: string;
title: string;
description?: string;
passingScore: number;
maxAttempts?: number;
timeLimitMinutes?: number;
shuffleQuestions: boolean;
showCorrectAnswers: boolean;
aiGenerated: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface QuizWithQuestions extends Quiz {
questions: QuizQuestion[];
}
export interface CreateQuizInput {
lessonId?: string;
courseId: string;
title: string;
description?: string;
passingScore?: number;
maxAttempts?: number;
timeLimitMinutes?: number;
shuffleQuestions?: boolean;
showCorrectAnswers?: boolean;
}
export interface CreateQuizQuestionInput {
quizId: string;
questionType?: QuestionType;
questionText: string;
explanation?: string;
options?: Omit<QuizOption, 'id'>[];
correctAnswers?: string[];
points?: number;
sortOrder?: number;
}
// ============================================================================
// Enrollment
// ============================================================================
export interface Enrollment {
id: string;
userId: string;
courseId: string;
status: EnrollmentStatus;
progressPercentage: number;
lessonsCompleted: number;
enrolledAt: Date;
expiresAt?: Date;
completedAt?: Date;
paymentId?: string;
certificateIssued: boolean;
certificateUrl?: string;
certificateIssuedAt?: Date;
createdAt: Date;
updatedAt: Date;
}
export interface EnrollmentWithCourse extends Enrollment {
course: Course;
}
export interface CreateEnrollmentInput {
userId: string;
courseId: string;
paymentId?: string;
expiresAt?: Date;
}
// ============================================================================
// Lesson Progress
// ============================================================================
export interface LessonProgress {
id: string;
userId: string;
lessonId: string;
enrollmentId: string;
videoWatchedSeconds: number;
videoCompleted: boolean;
startedAt?: Date;
completedAt?: Date;
userNotes?: string;
createdAt: Date;
updatedAt: Date;
}
export interface UpdateLessonProgressInput {
videoWatchedSeconds?: number;
videoCompleted?: boolean;
userNotes?: string;
}
// ============================================================================
// Quiz Attempts
// ============================================================================
export interface QuizAnswer {
questionId: string;
answer: string | string[];
isCorrect: boolean;
}
export interface QuizAttempt {
id: string;
userId: string;
quizId: string;
enrollmentId?: string;
score: number;
passed: boolean;
answers: QuizAnswer[];
startedAt: Date;
submittedAt?: Date;
timeSpentSeconds?: number;
createdAt: Date;
}
export interface SubmitQuizInput {
quizId: string;
answers: { questionId: string; answer: string | string[] }[];
}
// ============================================================================
// Reviews
// ============================================================================
export interface CourseReview {
id: string;
userId: string;
courseId: string;
rating: number;
reviewText?: string;
isApproved: boolean;
isFeatured: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface CourseReviewWithUser extends CourseReview {
user: {
id: string;
email: string;
profile?: {
firstName?: string;
lastName?: string;
avatarUrl?: string;
};
};
}
export interface CreateReviewInput {
courseId: string;
rating: number;
reviewText?: string;
}
// ============================================================================
// Pagination
// ============================================================================
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface PaginationOptions {
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}

View File

@ -1,530 +0,0 @@
/**
* Investment Controller
* Handles investment-related endpoints
*/
import { Request, Response, NextFunction } from 'express';
import { productService, RiskProfile } from '../services/product.service';
import { accountService, CreateAccountInput } from '../services/account.service';
import {
transactionService,
TransactionType,
TransactionStatus,
WithdrawalStatus,
} from '../services/transaction.service';
// ============================================================================
// Types
// ============================================================================
// Use Request directly - user is already declared globally in auth.middleware.ts
type AuthRequest = Request;
// ============================================================================
// Product Controllers
// ============================================================================
/**
* Get all investment products
*/
export async function getProducts(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { riskProfile } = req.query;
let products;
if (riskProfile) {
products = await productService.getProductsByRiskProfile(riskProfile as RiskProfile);
} else {
products = await productService.getProducts();
}
res.json({
success: true,
data: products,
});
} catch (error) {
next(error);
}
}
/**
* Get product by ID
*/
export async function getProductById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { productId } = req.params;
const product = await productService.getProductById(productId);
if (!product) {
res.status(404).json({
success: false,
error: { message: 'Product not found', code: 'NOT_FOUND' },
});
return;
}
// Get additional stats
const stats = await productService.getProductStats(productId);
res.json({
success: true,
data: { ...product, stats },
});
} catch (error) {
next(error);
}
}
/**
* Get product performance
*/
export async function getProductPerformance(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { productId } = req.params;
const { period = 'month' } = req.query;
const performance = await productService.getProductPerformance(
productId,
period as 'week' | 'month' | '3months' | 'year'
);
res.json({
success: true,
data: performance,
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Account Controllers
// ============================================================================
/**
* Get user accounts
*/
export async function getUserAccounts(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const accounts = await accountService.getUserAccounts(userId);
res.json({
success: true,
data: accounts,
});
} catch (error) {
next(error);
}
}
/**
* Get account summary
*/
export async function getAccountSummary(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const summary = await accountService.getAccountSummary(userId);
res.json({
success: true,
data: summary,
});
} catch (error) {
next(error);
}
}
/**
* Get account by ID
*/
export async function getAccountById(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { accountId } = req.params;
const account = await accountService.getAccountById(accountId);
if (!account) {
res.status(404).json({
success: false,
error: { message: 'Account not found', code: 'NOT_FOUND' },
});
return;
}
if (account.userId !== userId) {
res.status(403).json({
success: false,
error: { message: 'Forbidden', code: 'FORBIDDEN' },
});
return;
}
// Get performance history
const performance = await accountService.getAccountPerformance(accountId, 30);
res.json({
success: true,
data: { ...account, performance },
});
} catch (error) {
next(error);
}
}
/**
* Create investment account
*/
export async function createAccount(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { productId, initialDeposit } = req.body;
if (!productId || !initialDeposit) {
res.status(400).json({
success: false,
error: { message: 'Product ID and initial deposit are required', code: 'VALIDATION_ERROR' },
});
return;
}
const input: CreateAccountInput = {
userId,
productId,
initialDeposit: Number(initialDeposit),
};
const account = await accountService.createAccount(input);
res.status(201).json({
success: true,
data: account,
});
} catch (error) {
next(error);
}
}
/**
* Close account
*/
export async function closeAccount(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { accountId } = req.params;
const account = await accountService.getAccountById(accountId);
if (!account) {
res.status(404).json({
success: false,
error: { message: 'Account not found', code: 'NOT_FOUND' },
});
return;
}
if (account.userId !== userId) {
res.status(403).json({
success: false,
error: { message: 'Forbidden', code: 'FORBIDDEN' },
});
return;
}
const closedAccount = await accountService.closeAccount(accountId);
res.json({
success: true,
data: closedAccount,
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Transaction Controllers
// ============================================================================
/**
* Get account transactions
*/
export async function getTransactions(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { accountId } = req.params;
const { type, status, limit = 50, offset = 0 } = req.query;
const account = await accountService.getAccountById(accountId);
if (!account) {
res.status(404).json({
success: false,
error: { message: 'Account not found', code: 'NOT_FOUND' },
});
return;
}
if (account.userId !== userId) {
res.status(403).json({
success: false,
error: { message: 'Forbidden', code: 'FORBIDDEN' },
});
return;
}
const { transactions, total } = await transactionService.getAccountTransactions(accountId, {
type: type as TransactionType | undefined,
status: status as TransactionStatus | undefined,
limit: Number(limit),
offset: Number(offset),
});
res.json({
success: true,
data: transactions,
pagination: {
total,
limit: Number(limit),
offset: Number(offset),
},
});
} catch (error) {
next(error);
}
}
/**
* Create deposit
*/
export async function createDeposit(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { accountId } = req.params;
const { amount } = req.body;
const account = await accountService.getAccountById(accountId);
if (!account) {
res.status(404).json({
success: false,
error: { message: 'Account not found', code: 'NOT_FOUND' },
});
return;
}
if (account.userId !== userId) {
res.status(403).json({
success: false,
error: { message: 'Forbidden', code: 'FORBIDDEN' },
});
return;
}
if (!amount || amount <= 0) {
res.status(400).json({
success: false,
error: { message: 'Valid amount is required', code: 'VALIDATION_ERROR' },
});
return;
}
const transaction = await transactionService.createDeposit({
accountId,
amount: Number(amount),
});
res.status(201).json({
success: true,
data: transaction,
});
} catch (error) {
next(error);
}
}
/**
* Create withdrawal request
*/
export async function createWithdrawal(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { accountId } = req.params;
const { amount, bankInfo, cryptoInfo } = req.body;
const account = await accountService.getAccountById(accountId);
if (!account) {
res.status(404).json({
success: false,
error: { message: 'Account not found', code: 'NOT_FOUND' },
});
return;
}
if (account.userId !== userId) {
res.status(403).json({
success: false,
error: { message: 'Forbidden', code: 'FORBIDDEN' },
});
return;
}
if (!amount || amount <= 0) {
res.status(400).json({
success: false,
error: { message: 'Valid amount is required', code: 'VALIDATION_ERROR' },
});
return;
}
const withdrawal = await transactionService.createWithdrawal(userId, {
accountId,
amount: Number(amount),
bankInfo,
cryptoInfo,
});
res.status(201).json({
success: true,
data: withdrawal,
message: 'Withdrawal request submitted. Processing time: 72 hours.',
});
} catch (error) {
next(error);
}
}
/**
* Get user withdrawals
*/
export async function getWithdrawals(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { status } = req.query;
const withdrawals = await transactionService.getUserWithdrawals(
userId,
status as WithdrawalStatus | undefined
);
res.json({
success: true,
data: withdrawals,
});
} catch (error) {
next(error);
}
}
/**
* Get account distributions
*/
export async function getDistributions(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { accountId } = req.params;
const account = await accountService.getAccountById(accountId);
if (!account) {
res.status(404).json({
success: false,
error: { message: 'Account not found', code: 'NOT_FOUND' },
});
return;
}
if (account.userId !== userId) {
res.status(403).json({
success: false,
error: { message: 'Forbidden', code: 'FORBIDDEN' },
});
return;
}
const distributions = await transactionService.getAccountDistributions(accountId);
res.json({
success: true,
data: distributions,
});
} catch (error) {
next(error);
}
}

View File

@ -1,117 +0,0 @@
/**
* Investment Routes
* Products, accounts, and transaction endpoints
*/
import { Router, RequestHandler } from 'express';
import * as investmentController from './controllers/investment.controller';
const router = Router();
// Type cast helper for authenticated routes
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
const authHandler = (fn: Function): RequestHandler => fn as RequestHandler;
// ============================================================================
// Product Routes (Public)
// ============================================================================
/**
* GET /api/v1/investment/products
* Get all investment products
* Query params: riskProfile
*/
router.get('/products', investmentController.getProducts);
/**
* GET /api/v1/investment/products/:productId
* Get product details with stats
*/
router.get('/products/:productId', investmentController.getProductById);
/**
* GET /api/v1/investment/products/:productId/performance
* Get product performance history
* Query params: period (week, month, 3months, year)
*/
router.get('/products/:productId/performance', investmentController.getProductPerformance);
// ============================================================================
// Account Routes (Authenticated)
// TODO: Add authentication middleware
// ============================================================================
/**
* GET /api/v1/investment/accounts
* Get user's investment accounts
*/
router.get('/accounts', authHandler(investmentController.getUserAccounts));
/**
* GET /api/v1/investment/accounts/summary
* Get account summary (portfolio overview)
*/
router.get('/accounts/summary', authHandler(investmentController.getAccountSummary));
/**
* POST /api/v1/investment/accounts
* Create a new investment account
* Body: { productId, initialDeposit }
*/
router.post('/accounts', authHandler(investmentController.createAccount));
/**
* GET /api/v1/investment/accounts/:accountId
* Get account details with performance
*/
router.get('/accounts/:accountId', authHandler(investmentController.getAccountById));
/**
* POST /api/v1/investment/accounts/:accountId/close
* Close an investment account
*/
router.post('/accounts/:accountId/close', authHandler(investmentController.closeAccount));
// ============================================================================
// Transaction Routes (Authenticated)
// ============================================================================
/**
* GET /api/v1/investment/accounts/:accountId/transactions
* Get account transactions
* Query params: type, status, limit, offset
*/
router.get('/accounts/:accountId/transactions', authHandler(investmentController.getTransactions));
/**
* POST /api/v1/investment/accounts/:accountId/deposit
* Create a deposit
* Body: { amount }
*/
router.post('/accounts/:accountId/deposit', authHandler(investmentController.createDeposit));
/**
* POST /api/v1/investment/accounts/:accountId/withdraw
* Create a withdrawal request
* Body: { amount, bankInfo?, cryptoInfo? }
*/
router.post('/accounts/:accountId/withdraw', authHandler(investmentController.createWithdrawal));
/**
* GET /api/v1/investment/accounts/:accountId/distributions
* Get account distributions
*/
router.get('/accounts/:accountId/distributions', authHandler(investmentController.getDistributions));
// ============================================================================
// Withdrawal Routes (Authenticated)
// ============================================================================
/**
* GET /api/v1/investment/withdrawals
* Get user's withdrawal requests
* Query params: status
*/
router.get('/withdrawals', authHandler(investmentController.getWithdrawals));
export { router as investmentRouter };

View File

@ -1,547 +0,0 @@
/**
* Investment Account Service Unit Tests
*
* Tests for investment account service including:
* - Account creation and management
* - Balance tracking
* - Performance calculations
* - Account status management
*/
import { mockDb, createMockQueryResult, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock';
// Mock database (account service uses in-memory storage)
jest.mock('../../../../shared/database', () => ({
db: mockDb,
}));
// Mock logger
jest.mock('../../../../shared/utils/logger', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
// Mock product service
const mockGetProductById = jest.fn();
jest.mock('../product.service', () => ({
productService: {
getProductById: mockGetProductById,
getAllProducts: jest.fn(),
},
}));
// Import service after mocks
import { accountService } from '../account.service';
describe('AccountService', () => {
beforeEach(() => {
resetDatabaseMocks();
mockGetProductById.mockReset();
jest.clearAllMocks();
});
describe('createAccount', () => {
it('should create a new investment account', async () => {
const mockProduct = {
id: 'product-123',
code: 'atlas',
name: 'Atlas - El Guardián',
riskProfile: 'conservative',
minInvestment: 100,
isActive: true,
};
mockGetProductById.mockResolvedValueOnce(mockProduct);
const result = await accountService.createAccount({
userId: 'user-123',
productId: 'product-123',
initialDeposit: 1000,
});
expect(result.userId).toBe('user-123');
expect(result.productId).toBe('product-123');
expect(result.balance).toBe(1000);
expect(result.initialInvestment).toBe(1000);
expect(result.status).toBe('active');
});
it('should validate minimum investment amount', async () => {
const mockProduct = {
id: 'product-123',
code: 'orion',
name: 'Orion - El Explorador',
riskProfile: 'moderate',
minInvestment: 500,
isActive: true,
};
mockGetProductById.mockResolvedValueOnce(mockProduct);
await expect(
accountService.createAccount({
userId: 'user-123',
productId: 'product-123',
initialDeposit: 100,
})
).rejects.toThrow('Minimum investment is 500');
});
it('should reject inactive products', async () => {
const mockProduct = {
id: 'product-124',
code: 'inactive',
name: 'Inactive Product',
riskProfile: 'moderate',
minInvestment: 100,
isActive: false,
};
mockGetProductById.mockResolvedValueOnce(mockProduct);
await expect(
accountService.createAccount({
userId: 'user-123',
productId: 'product-124',
initialDeposit: 1000,
})
).rejects.toThrow('Product is not active');
});
it('should prevent duplicate accounts for same product', async () => {
const mockProduct = {
id: 'product-123',
code: 'atlas',
name: 'Atlas',
riskProfile: 'conservative',
minInvestment: 100,
isActive: true,
};
mockGetProductById.mockResolvedValue(mockProduct);
await accountService.createAccount({
userId: 'user-123',
productId: 'product-123',
initialDeposit: 1000,
});
await expect(
accountService.createAccount({
userId: 'user-123',
productId: 'product-123',
initialDeposit: 500,
})
).rejects.toThrow('Account already exists for this product');
});
it('should handle non-existent product', async () => {
mockGetProductById.mockResolvedValueOnce(null);
await expect(
accountService.createAccount({
userId: 'user-123',
productId: 'non-existent',
initialDeposit: 1000,
})
).rejects.toThrow('Product not found');
});
});
describe('getUserAccounts', () => {
it('should retrieve all accounts for a user', async () => {
const mockProducts = [
{ id: 'product-1', code: 'atlas', name: 'Atlas' },
{ id: 'product-2', code: 'orion', name: 'Orion' },
];
mockGetProductById.mockImplementation((id) =>
Promise.resolve(mockProducts.find(p => p.id === id))
);
await accountService.createAccount({
userId: 'user-123',
productId: 'product-1',
initialDeposit: 1000,
});
await accountService.createAccount({
userId: 'user-123',
productId: 'product-2',
initialDeposit: 2000,
});
const result = await accountService.getUserAccounts('user-123');
expect(result).toHaveLength(2);
expect(result[0].userId).toBe('user-123');
expect(result[1].userId).toBe('user-123');
expect(result[0].product).toBeDefined();
});
it('should return empty array for user with no accounts', async () => {
const result = await accountService.getUserAccounts('user-999');
expect(result).toEqual([]);
});
it('should include product information', async () => {
const mockProduct = {
id: 'product-123',
code: 'atlas',
name: 'Atlas - El Guardián',
riskProfile: 'conservative',
minInvestment: 100,
isActive: true,
};
mockGetProductById.mockResolvedValue(mockProduct);
await accountService.createAccount({
userId: 'user-123',
productId: 'product-123',
initialDeposit: 1000,
});
const result = await accountService.getUserAccounts('user-123');
expect(result[0].product).toEqual(mockProduct);
});
});
describe('getAccountById', () => {
it('should retrieve an account by ID', async () => {
const mockProduct = {
id: 'product-123',
code: 'atlas',
name: 'Atlas',
minInvestment: 100,
isActive: true,
};
mockGetProductById.mockResolvedValue(mockProduct);
const created = await accountService.createAccount({
userId: 'user-123',
productId: 'product-123',
initialDeposit: 1000,
});
const result = await accountService.getAccountById(created.id);
expect(result).toBeDefined();
expect(result?.id).toBe(created.id);
expect(result?.product).toBeDefined();
});
it('should return null for non-existent account', async () => {
const result = await accountService.getAccountById('non-existent');
expect(result).toBeNull();
});
});
describe('getAccountByUserAndProduct', () => {
it('should retrieve account by user and product', async () => {
const mockProduct = {
id: 'product-123',
code: 'atlas',
name: 'Atlas',
minInvestment: 100,
isActive: true,
};
mockGetProductById.mockResolvedValue(mockProduct);
await accountService.createAccount({
userId: 'user-123',
productId: 'product-123',
initialDeposit: 1000,
});
const result = await accountService.getAccountByUserAndProduct('user-123', 'product-123');
expect(result).toBeDefined();
expect(result?.userId).toBe('user-123');
expect(result?.productId).toBe('product-123');
});
it('should return null if account does not exist', async () => {
const result = await accountService.getAccountByUserAndProduct('user-999', 'product-999');
expect(result).toBeNull();
});
it('should exclude closed accounts', async () => {
const mockProduct = {
id: 'product-123',
code: 'atlas',
name: 'Atlas',
minInvestment: 100,
isActive: true,
};
mockGetProductById.mockResolvedValue(mockProduct);
const account = await accountService.createAccount({
userId: 'user-123',
productId: 'product-123',
initialDeposit: 1000,
});
await accountService.closeAccount(account.id);
const result = await accountService.getAccountByUserAndProduct('user-123', 'product-123');
expect(result).toBeNull();
});
});
describe('updateAccountBalance', () => {
it('should update account balance', async () => {
const mockProduct = {
id: 'product-123',
code: 'atlas',
name: 'Atlas',
minInvestment: 100,
isActive: true,
};
mockGetProductById.mockResolvedValue(mockProduct);
const account = await accountService.createAccount({
userId: 'user-123',
productId: 'product-123',
initialDeposit: 1000,
});
const result = await accountService.updateAccountBalance(account.id, 1500);
expect(result.balance).toBe(1500);
expect(result.updatedAt).toBeDefined();
});
it('should calculate unrealized P&L', async () => {
const mockProduct = {
id: 'product-123',
code: 'atlas',
name: 'Atlas',
minInvestment: 100,
isActive: true,
};
mockGetProductById.mockResolvedValue(mockProduct);
const account = await accountService.createAccount({
userId: 'user-123',
productId: 'product-123',
initialDeposit: 1000,
});
const result = await accountService.updateAccountBalance(account.id, 1200);
expect(result.unrealizedPnl).toBe(200);
expect(result.unrealizedPnlPercent).toBe(20);
});
it('should handle account not found', async () => {
await expect(
accountService.updateAccountBalance('non-existent', 1000)
).rejects.toThrow('Account not found');
});
});
describe('closeAccount', () => {
it('should close an active account', async () => {
const mockProduct = {
id: 'product-123',
code: 'atlas',
name: 'Atlas',
minInvestment: 100,
isActive: true,
};
mockGetProductById.mockResolvedValue(mockProduct);
const account = await accountService.createAccount({
userId: 'user-123',
productId: 'product-123',
initialDeposit: 1000,
});
const result = await accountService.closeAccount(account.id);
expect(result.status).toBe('closed');
expect(result.closedAt).toBeDefined();
});
it('should require zero balance to close', async () => {
const mockProduct = {
id: 'product-123',
code: 'atlas',
name: 'Atlas',
minInvestment: 100,
isActive: true,
};
mockGetProductById.mockResolvedValue(mockProduct);
const account = await accountService.createAccount({
userId: 'user-123',
productId: 'product-123',
initialDeposit: 1000,
});
await expect(accountService.closeAccount(account.id)).rejects.toThrow(
'Cannot close account with non-zero balance'
);
});
it('should prevent closing already closed account', async () => {
const mockProduct = {
id: 'product-123',
code: 'atlas',
name: 'Atlas',
minInvestment: 100,
isActive: true,
};
mockGetProductById.mockResolvedValue(mockProduct);
const account = await accountService.createAccount({
userId: 'user-123',
productId: 'product-123',
initialDeposit: 1000,
});
await accountService.updateAccountBalance(account.id, 0);
await accountService.closeAccount(account.id);
await expect(accountService.closeAccount(account.id)).rejects.toThrow(
'Account is already closed'
);
});
});
describe('suspendAccount', () => {
it('should suspend an active account', async () => {
const mockProduct = {
id: 'product-123',
code: 'atlas',
name: 'Atlas',
minInvestment: 100,
isActive: true,
};
mockGetProductById.mockResolvedValue(mockProduct);
const account = await accountService.createAccount({
userId: 'user-123',
productId: 'product-123',
initialDeposit: 1000,
});
const result = await accountService.suspendAccount(account.id);
expect(result.status).toBe('suspended');
});
it('should prevent operations on suspended account', async () => {
const mockProduct = {
id: 'product-123',
code: 'atlas',
name: 'Atlas',
minInvestment: 100,
isActive: true,
};
mockGetProductById.mockResolvedValue(mockProduct);
const account = await accountService.createAccount({
userId: 'user-123',
productId: 'product-123',
initialDeposit: 1000,
});
await accountService.suspendAccount(account.id);
await expect(
accountService.updateAccountBalance(account.id, 1500)
).rejects.toThrow('Account is suspended');
});
});
describe('getAccountSummary', () => {
it('should calculate account summary for user', async () => {
const mockProduct = {
id: 'product-123',
code: 'atlas',
name: 'Atlas',
minInvestment: 100,
isActive: true,
};
mockGetProductById.mockResolvedValue(mockProduct);
await accountService.createAccount({
userId: 'user-123',
productId: 'product-123',
initialDeposit: 1000,
});
const account2 = await accountService.createAccount({
userId: 'user-123',
productId: 'product-124',
initialDeposit: 2000,
});
await accountService.updateAccountBalance(account2.id, 2500);
const result = await accountService.getAccountSummary('user-123');
expect(result.totalBalance).toBeGreaterThan(0);
expect(result.totalDeposited).toBe(3000);
expect(result.totalEarnings).toBeGreaterThan(0);
expect(result.overallReturn).toBeGreaterThan(0);
expect(result.accounts).toHaveLength(2);
});
it('should handle user with no accounts', async () => {
const result = await accountService.getAccountSummary('user-999');
expect(result.totalBalance).toBe(0);
expect(result.totalDeposited).toBe(0);
expect(result.accounts).toEqual([]);
});
it('should exclude closed accounts from summary', async () => {
const mockProduct = {
id: 'product-123',
code: 'atlas',
name: 'Atlas',
minInvestment: 100,
isActive: true,
};
mockGetProductById.mockResolvedValue(mockProduct);
const account = await accountService.createAccount({
userId: 'user-123',
productId: 'product-123',
initialDeposit: 1000,
});
await accountService.updateAccountBalance(account.id, 0);
await accountService.closeAccount(account.id);
const result = await accountService.getAccountSummary('user-123');
expect(result.accounts).toHaveLength(0);
expect(result.totalBalance).toBe(0);
});
});
});

View File

@ -1,378 +0,0 @@
/**
* Investment Product Service Unit Tests
*
* Tests for investment product service including:
* - Product retrieval and filtering
* - Product validation
* - Risk profile matching
*/
import { mockDb, createMockQueryResult, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock';
// Mock database
jest.mock('../../../../shared/database', () => ({
db: mockDb,
}));
// Mock logger
jest.mock('../../../../shared/utils/logger', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
// Import service after mocks
import { productService } from '../product.service';
describe('ProductService', () => {
beforeEach(() => {
resetDatabaseMocks();
jest.clearAllMocks();
});
describe('getAllProducts', () => {
it('should retrieve all investment products', async () => {
const result = await productService.getAllProducts();
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toHaveProperty('id');
expect(result[0]).toHaveProperty('code');
expect(result[0]).toHaveProperty('name');
expect(result[0]).toHaveProperty('riskProfile');
});
it('should return products with all required fields', async () => {
const result = await productService.getAllProducts();
result.forEach(product => {
expect(product).toHaveProperty('id');
expect(product).toHaveProperty('code');
expect(product).toHaveProperty('name');
expect(product).toHaveProperty('description');
expect(product).toHaveProperty('riskProfile');
expect(product).toHaveProperty('targetReturnMin');
expect(product).toHaveProperty('targetReturnMax');
expect(product).toHaveProperty('minInvestment');
expect(product).toHaveProperty('managementFee');
expect(product).toHaveProperty('performanceFee');
expect(product).toHaveProperty('isActive');
});
});
it('should filter active products only', async () => {
const result = await productService.getAllProducts({ activeOnly: true });
expect(result.every(p => p.isActive)).toBe(true);
});
});
describe('getProductById', () => {
it('should retrieve a product by ID', async () => {
const allProducts = await productService.getAllProducts();
const productId = allProducts[0].id;
const result = await productService.getProductById(productId);
expect(result).toBeDefined();
expect(result?.id).toBe(productId);
});
it('should return null for non-existent product', async () => {
const result = await productService.getProductById('non-existent-id');
expect(result).toBeNull();
});
});
describe('getProductByCode', () => {
it('should retrieve Atlas product by code', async () => {
const result = await productService.getProductByCode('atlas');
expect(result).toBeDefined();
expect(result?.code).toBe('atlas');
expect(result?.name).toContain('Atlas');
expect(result?.riskProfile).toBe('conservative');
});
it('should retrieve Orion product by code', async () => {
const result = await productService.getProductByCode('orion');
expect(result).toBeDefined();
expect(result?.code).toBe('orion');
expect(result?.name).toContain('Orion');
expect(result?.riskProfile).toBe('moderate');
});
it('should retrieve Nova product by code', async () => {
const result = await productService.getProductByCode('nova');
expect(result).toBeDefined();
expect(result?.code).toBe('nova');
expect(result?.name).toContain('Nova');
expect(result?.riskProfile).toBe('aggressive');
});
it('should return null for invalid product code', async () => {
const result = await productService.getProductByCode('invalid-code');
expect(result).toBeNull();
});
it('should be case-insensitive', async () => {
const result1 = await productService.getProductByCode('ATLAS');
const result2 = await productService.getProductByCode('atlas');
const result3 = await productService.getProductByCode('Atlas');
expect(result1?.code).toBe('atlas');
expect(result2?.code).toBe('atlas');
expect(result3?.code).toBe('atlas');
});
});
describe('getProductsByRiskProfile', () => {
it('should retrieve conservative products', async () => {
const result = await productService.getProductsByRiskProfile('conservative');
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
expect(result.every(p => p.riskProfile === 'conservative')).toBe(true);
});
it('should retrieve moderate products', async () => {
const result = await productService.getProductsByRiskProfile('moderate');
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
expect(result.every(p => p.riskProfile === 'moderate')).toBe(true);
});
it('should retrieve aggressive products', async () => {
const result = await productService.getProductsByRiskProfile('aggressive');
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
expect(result.every(p => p.riskProfile === 'aggressive')).toBe(true);
});
it('should return empty array for invalid risk profile', async () => {
const result = await productService.getProductsByRiskProfile('invalid' as any);
expect(result).toEqual([]);
});
});
describe('product characteristics', () => {
it('should have Atlas with lowest minimum investment', async () => {
const atlas = await productService.getProductByCode('atlas');
expect(atlas).toBeDefined();
expect(atlas?.minInvestment).toBeLessThanOrEqual(100);
});
it('should have conservative products with lower drawdown', async () => {
const conservativeProducts = await productService.getProductsByRiskProfile('conservative');
conservativeProducts.forEach(product => {
expect(product.maxDrawdown).toBeLessThanOrEqual(10);
});
});
it('should have aggressive products with higher return targets', async () => {
const aggressiveProducts = await productService.getProductsByRiskProfile('aggressive');
aggressiveProducts.forEach(product => {
expect(product.targetReturnMax).toBeGreaterThan(10);
});
});
it('should have performance fee defined for all products', async () => {
const allProducts = await productService.getAllProducts();
allProducts.forEach(product => {
expect(product.performanceFee).toBeGreaterThanOrEqual(0);
expect(product.performanceFee).toBeLessThanOrEqual(100);
});
});
it('should have valid target return ranges', async () => {
const allProducts = await productService.getAllProducts();
allProducts.forEach(product => {
expect(product.targetReturnMin).toBeGreaterThan(0);
expect(product.targetReturnMax).toBeGreaterThan(product.targetReturnMin);
});
});
});
describe('product features', () => {
it('should have Atlas with conservative features', async () => {
const atlas = await productService.getProductByCode('atlas');
expect(atlas?.features).toBeDefined();
expect(atlas?.features.length).toBeGreaterThan(0);
expect(atlas?.strategy).toBeDefined();
expect(atlas?.assets).toBeDefined();
expect(atlas?.assets).toContain('BTC');
expect(atlas?.assets).toContain('ETH');
});
it('should have Orion with moderate features', async () => {
const orion = await productService.getProductByCode('orion');
expect(orion?.features).toBeDefined();
expect(orion?.strategy).toBeDefined();
expect(orion?.assets).toBeDefined();
expect(orion?.assets.length).toBeGreaterThan(2);
});
it('should have Nova with aggressive features', async () => {
const nova = await productService.getProductByCode('nova');
expect(nova?.features).toBeDefined();
expect(nova?.strategy).toBeDefined();
expect(nova?.assets).toBeDefined();
expect(nova?.tradingFrequency).toBeDefined();
});
it('should have all products with descriptions', async () => {
const allProducts = await productService.getAllProducts();
allProducts.forEach(product => {
expect(product.description).toBeDefined();
expect(product.description.length).toBeGreaterThan(50);
});
});
});
describe('getRecommendedProduct', () => {
it('should recommend conservative product for low risk tolerance', async () => {
const result = await productService.getRecommendedProduct({
riskTolerance: 'low',
investmentAmount: 1000,
});
expect(result).toBeDefined();
expect(result?.riskProfile).toBe('conservative');
expect(result?.minInvestment).toBeLessThanOrEqual(1000);
});
it('should recommend moderate product for medium risk tolerance', async () => {
const result = await productService.getRecommendedProduct({
riskTolerance: 'medium',
investmentAmount: 5000,
});
expect(result).toBeDefined();
expect(result?.riskProfile).toBe('moderate');
});
it('should recommend aggressive product for high risk tolerance', async () => {
const result = await productService.getRecommendedProduct({
riskTolerance: 'high',
investmentAmount: 10000,
});
expect(result).toBeDefined();
expect(result?.riskProfile).toBe('aggressive');
});
it('should filter by minimum investment amount', async () => {
const result = await productService.getRecommendedProduct({
riskTolerance: 'high',
investmentAmount: 200,
});
expect(result).toBeDefined();
if (result) {
expect(result.minInvestment).toBeLessThanOrEqual(200);
}
});
it('should return null if no product matches criteria', async () => {
const result = await productService.getRecommendedProduct({
riskTolerance: 'low',
investmentAmount: 10,
});
expect(result).toBeNull();
});
});
describe('validateProduct', () => {
it('should validate active product', async () => {
const allProducts = await productService.getAllProducts({ activeOnly: true });
const product = allProducts[0];
const result = await productService.validateProduct(product.id);
expect(result).toBe(true);
});
it('should reject inactive product', async () => {
const result = await productService.validateProduct('inactive-product-id');
expect(result).toBe(false);
});
it('should reject non-existent product', async () => {
const result = await productService.validateProduct('non-existent-id');
expect(result).toBe(false);
});
});
describe('getProductPerformanceMetrics', () => {
it('should retrieve performance metrics for a product', async () => {
const atlas = await productService.getProductByCode('atlas');
const result = await productService.getProductPerformanceMetrics(atlas!.id);
expect(result).toBeDefined();
expect(result).toHaveProperty('targetReturnMin');
expect(result).toHaveProperty('targetReturnMax');
expect(result).toHaveProperty('maxDrawdown');
expect(result).toHaveProperty('sharpeRatio');
expect(result).toHaveProperty('volatility');
});
it('should calculate risk-adjusted returns', async () => {
const orion = await productService.getProductByCode('orion');
const result = await productService.getProductPerformanceMetrics(orion!.id);
expect(result.sharpeRatio).toBeGreaterThan(0);
expect(result.volatility).toBeGreaterThan(0);
});
});
describe('compareProducts', () => {
it('should compare two products', async () => {
const atlas = await productService.getProductByCode('atlas');
const nova = await productService.getProductByCode('nova');
const result = await productService.compareProducts(atlas!.id, nova!.id);
expect(result).toBeDefined();
expect(result.product1).toEqual(atlas);
expect(result.product2).toEqual(nova);
expect(result.comparison).toBeDefined();
expect(result.comparison).toHaveProperty('riskDifference');
expect(result.comparison).toHaveProperty('returnDifference');
expect(result.comparison).toHaveProperty('feeDifference');
});
it('should highlight key differences', async () => {
const atlas = await productService.getProductByCode('atlas');
const orion = await productService.getProductByCode('orion');
const result = await productService.compareProducts(atlas!.id, orion!.id);
expect(result.comparison.riskDifference).not.toBe(0);
expect(result.comparison.returnDifference).toBeGreaterThan(0);
});
});
});

View File

@ -1,606 +0,0 @@
/**
* Investment Transaction Service Unit Tests
*
* Tests for transaction service including:
* - Deposits and withdrawals
* - Transaction tracking
* - Distribution processing
* - Fee calculations
*/
import { mockDb, createMockQueryResult, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock';
// Mock database
jest.mock('../../../../shared/database', () => ({
db: mockDb,
}));
// Mock logger
jest.mock('../../../../shared/utils/logger', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
// Mock account service
const mockGetAccountById = jest.fn();
const mockUpdateAccountBalance = jest.fn();
jest.mock('../account.service', () => ({
accountService: {
getAccountById: mockGetAccountById,
updateAccountBalance: mockUpdateAccountBalance,
},
}));
// Import service after mocks
import { transactionService } from '../transaction.service';
describe('TransactionService', () => {
beforeEach(() => {
resetDatabaseMocks();
mockGetAccountById.mockReset();
mockUpdateAccountBalance.mockReset();
jest.clearAllMocks();
});
describe('createDeposit', () => {
it('should create a new deposit transaction', async () => {
const mockAccount = {
id: 'account-123',
userId: 'user-123',
balance: 1000,
status: 'active',
};
mockGetAccountById.mockResolvedValueOnce(mockAccount);
mockUpdateAccountBalance.mockResolvedValueOnce({
...mockAccount,
balance: 1500,
});
const result = await transactionService.createDeposit({
accountId: 'account-123',
amount: 500,
stripePaymentId: 'pi_123456',
});
expect(result.type).toBe('deposit');
expect(result.amount).toBe(500);
expect(result.status).toBe('completed');
expect(result.balanceBefore).toBe(1000);
expect(result.balanceAfter).toBe(1500);
expect(result.stripePaymentId).toBe('pi_123456');
});
it('should validate minimum deposit amount', async () => {
await expect(
transactionService.createDeposit({
accountId: 'account-123',
amount: 5,
})
).rejects.toThrow('Minimum deposit amount is 10');
});
it('should reject deposit to suspended account', async () => {
const mockAccount = {
id: 'account-123',
userId: 'user-123',
balance: 1000,
status: 'suspended',
};
mockGetAccountById.mockResolvedValueOnce(mockAccount);
await expect(
transactionService.createDeposit({
accountId: 'account-123',
amount: 500,
})
).rejects.toThrow('Cannot deposit to suspended account');
});
it('should reject deposit to closed account', async () => {
const mockAccount = {
id: 'account-123',
userId: 'user-123',
balance: 0,
status: 'closed',
};
mockGetAccountById.mockResolvedValueOnce(mockAccount);
await expect(
transactionService.createDeposit({
accountId: 'account-123',
amount: 500,
})
).rejects.toThrow('Cannot deposit to closed account');
});
it('should handle account not found', async () => {
mockGetAccountById.mockResolvedValueOnce(null);
await expect(
transactionService.createDeposit({
accountId: 'non-existent',
amount: 500,
})
).rejects.toThrow('Account not found');
});
});
describe('createWithdrawalRequest', () => {
it('should create withdrawal request with bank info', async () => {
const mockAccount = {
id: 'account-123',
userId: 'user-123',
balance: 5000,
status: 'active',
};
mockGetAccountById.mockResolvedValueOnce(mockAccount);
const result = await transactionService.createWithdrawalRequest({
accountId: 'account-123',
amount: 1000,
bankInfo: {
bankName: 'Bank of Test',
accountNumber: '1234567890',
routingNumber: '987654321',
accountHolderName: 'John Doe',
},
});
expect(result.amount).toBe(1000);
expect(result.status).toBe('pending');
expect(result.bankInfo).toBeDefined();
expect(result.bankInfo?.bankName).toBe('Bank of Test');
expect(result.cryptoInfo).toBeNull();
});
it('should create withdrawal request with crypto info', async () => {
const mockAccount = {
id: 'account-123',
userId: 'user-123',
balance: 5000,
status: 'active',
};
mockGetAccountById.mockResolvedValueOnce(mockAccount);
const result = await transactionService.createWithdrawalRequest({
accountId: 'account-123',
amount: 2000,
cryptoInfo: {
network: 'ethereum',
address: '0x1234567890abcdef',
},
});
expect(result.cryptoInfo).toBeDefined();
expect(result.cryptoInfo?.network).toBe('ethereum');
expect(result.cryptoInfo?.address).toBe('0x1234567890abcdef');
expect(result.bankInfo).toBeNull();
});
it('should validate minimum withdrawal amount', async () => {
await expect(
transactionService.createWithdrawalRequest({
accountId: 'account-123',
amount: 5,
})
).rejects.toThrow('Minimum withdrawal amount is 50');
});
it('should validate sufficient balance', async () => {
const mockAccount = {
id: 'account-123',
userId: 'user-123',
balance: 100,
status: 'active',
};
mockGetAccountById.mockResolvedValueOnce(mockAccount);
await expect(
transactionService.createWithdrawalRequest({
accountId: 'account-123',
amount: 500,
})
).rejects.toThrow('Insufficient balance');
});
it('should require either bank or crypto info', async () => {
const mockAccount = {
id: 'account-123',
userId: 'user-123',
balance: 5000,
status: 'active',
};
mockGetAccountById.mockResolvedValueOnce(mockAccount);
await expect(
transactionService.createWithdrawalRequest({
accountId: 'account-123',
amount: 1000,
})
).rejects.toThrow('Either bank info or crypto info is required');
});
});
describe('processWithdrawal', () => {
it('should process approved withdrawal', async () => {
const mockAccount = {
id: 'account-123',
userId: 'user-123',
balance: 5000,
status: 'active',
};
const mockWithdrawal = {
id: 'withdrawal-123',
accountId: 'account-123',
amount: 1000,
status: 'pending',
};
mockGetAccountById.mockResolvedValueOnce(mockAccount);
mockUpdateAccountBalance.mockResolvedValueOnce({
...mockAccount,
balance: 4000,
});
const result = await transactionService.processWithdrawal(
'withdrawal-123',
'approved'
);
expect(result.status).toBe('completed');
expect(result.completedAt).toBeDefined();
expect(mockUpdateAccountBalance).toHaveBeenCalledWith('account-123', 4000);
});
it('should reject withdrawal with reason', async () => {
const mockWithdrawal = {
id: 'withdrawal-123',
accountId: 'account-123',
amount: 1000,
status: 'pending',
};
const result = await transactionService.processWithdrawal(
'withdrawal-123',
'rejected',
'Suspicious activity detected'
);
expect(result.status).toBe('rejected');
expect(result.rejectionReason).toBe('Suspicious activity detected');
expect(mockUpdateAccountBalance).not.toHaveBeenCalled();
});
it('should handle withdrawal not found', async () => {
await expect(
transactionService.processWithdrawal('non-existent', 'approved')
).rejects.toThrow('Withdrawal request not found');
});
it('should prevent processing already completed withdrawal', async () => {
const mockWithdrawal = {
id: 'withdrawal-123',
accountId: 'account-123',
amount: 1000,
status: 'completed',
};
await expect(
transactionService.processWithdrawal('withdrawal-123', 'approved')
).rejects.toThrow('Withdrawal already processed');
});
});
describe('getAccountTransactions', () => {
it('should retrieve all transactions for an account', async () => {
const mockTransactions = [
{
id: 'tx-1',
accountId: 'account-123',
type: 'deposit',
amount: 1000,
status: 'completed',
createdAt: new Date(),
},
{
id: 'tx-2',
accountId: 'account-123',
type: 'withdrawal',
amount: 500,
status: 'completed',
createdAt: new Date(),
},
];
const result = await transactionService.getAccountTransactions('account-123');
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
});
it('should filter transactions by type', async () => {
const result = await transactionService.getAccountTransactions('account-123', {
type: 'deposit',
});
expect(result.every(tx => tx.type === 'deposit')).toBe(true);
});
it('should filter transactions by status', async () => {
const result = await transactionService.getAccountTransactions('account-123', {
status: 'completed',
});
expect(result.every(tx => tx.status === 'completed')).toBe(true);
});
it('should filter transactions by date range', async () => {
const startDate = new Date('2024-01-01');
const endDate = new Date('2024-12-31');
const result = await transactionService.getAccountTransactions('account-123', {
startDate,
endDate,
});
result.forEach(tx => {
expect(tx.createdAt >= startDate).toBe(true);
expect(tx.createdAt <= endDate).toBe(true);
});
});
it('should limit results', async () => {
const result = await transactionService.getAccountTransactions('account-123', {
limit: 10,
});
expect(result.length).toBeLessThanOrEqual(10);
});
});
describe('createDistribution', () => {
it('should create earnings distribution', async () => {
const mockAccount = {
id: 'account-123',
userId: 'user-123',
balance: 10000,
product: {
performanceFee: 20,
},
};
mockGetAccountById.mockResolvedValueOnce(mockAccount);
const result = await transactionService.createDistribution({
accountId: 'account-123',
periodStart: new Date('2024-01-01'),
periodEnd: new Date('2024-01-31'),
grossEarnings: 1000,
});
expect(result.grossEarnings).toBe(1000);
expect(result.performanceFee).toBe(200);
expect(result.netEarnings).toBe(800);
expect(result.status).toBe('pending');
});
it('should calculate performance fee correctly', async () => {
const mockAccount = {
id: 'account-123',
userId: 'user-123',
balance: 10000,
product: {
performanceFee: 25,
},
};
mockGetAccountById.mockResolvedValueOnce(mockAccount);
const result = await transactionService.createDistribution({
accountId: 'account-123',
periodStart: new Date('2024-01-01'),
periodEnd: new Date('2024-01-31'),
grossEarnings: 2000,
});
expect(result.performanceFee).toBe(500);
expect(result.netEarnings).toBe(1500);
});
it('should handle zero or negative earnings', async () => {
const mockAccount = {
id: 'account-123',
userId: 'user-123',
balance: 10000,
product: {
performanceFee: 20,
},
};
mockGetAccountById.mockResolvedValueOnce(mockAccount);
const result = await transactionService.createDistribution({
accountId: 'account-123',
periodStart: new Date('2024-01-01'),
periodEnd: new Date('2024-01-31'),
grossEarnings: -500,
});
expect(result.grossEarnings).toBe(-500);
expect(result.performanceFee).toBe(0);
expect(result.netEarnings).toBe(-500);
});
});
describe('processDistribution', () => {
it('should process pending distribution', async () => {
const mockAccount = {
id: 'account-123',
userId: 'user-123',
balance: 10000,
status: 'active',
};
const mockDistribution = {
id: 'dist-123',
accountId: 'account-123',
netEarnings: 800,
status: 'pending',
};
mockGetAccountById.mockResolvedValueOnce(mockAccount);
mockUpdateAccountBalance.mockResolvedValueOnce({
...mockAccount,
balance: 10800,
});
const result = await transactionService.processDistribution('dist-123');
expect(result.status).toBe('distributed');
expect(result.distributedAt).toBeDefined();
expect(mockUpdateAccountBalance).toHaveBeenCalledWith('account-123', 10800);
});
it('should create transaction record for distribution', async () => {
const mockAccount = {
id: 'account-123',
userId: 'user-123',
balance: 10000,
status: 'active',
};
mockGetAccountById.mockResolvedValueOnce(mockAccount);
mockUpdateAccountBalance.mockResolvedValueOnce({
...mockAccount,
balance: 10800,
});
await transactionService.processDistribution('dist-123');
// Verify transaction was created
const transactions = await transactionService.getAccountTransactions('account-123');
const distributionTx = transactions.find(tx => tx.type === 'distribution');
expect(distributionTx).toBeDefined();
});
it('should handle negative distribution (loss)', async () => {
const mockAccount = {
id: 'account-123',
userId: 'user-123',
balance: 10000,
status: 'active',
};
mockGetAccountById.mockResolvedValueOnce(mockAccount);
mockUpdateAccountBalance.mockResolvedValueOnce({
...mockAccount,
balance: 9500,
});
const result = await transactionService.processDistribution('dist-124');
expect(result.status).toBe('distributed');
expect(mockUpdateAccountBalance).toHaveBeenCalled();
});
});
describe('getTransactionById', () => {
it('should retrieve a transaction by ID', async () => {
const mockAccount = {
id: 'account-123',
userId: 'user-123',
balance: 1000,
status: 'active',
};
mockGetAccountById.mockResolvedValueOnce(mockAccount);
mockUpdateAccountBalance.mockResolvedValueOnce({
...mockAccount,
balance: 1500,
});
const created = await transactionService.createDeposit({
accountId: 'account-123',
amount: 500,
});
const result = await transactionService.getTransactionById(created.id);
expect(result).toBeDefined();
expect(result?.id).toBe(created.id);
});
it('should return null for non-existent transaction', async () => {
const result = await transactionService.getTransactionById('non-existent');
expect(result).toBeNull();
});
});
describe('getUserTransactions', () => {
it('should retrieve all transactions for a user', async () => {
const result = await transactionService.getUserTransactions('user-123');
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
});
it('should filter by account', async () => {
const result = await transactionService.getUserTransactions('user-123', {
accountId: 'account-123',
});
expect(result.every(tx => tx.accountId === 'account-123')).toBe(true);
});
it('should paginate results', async () => {
const page1 = await transactionService.getUserTransactions('user-123', {
limit: 10,
offset: 0,
});
const page2 = await transactionService.getUserTransactions('user-123', {
limit: 10,
offset: 10,
});
expect(page1.length).toBeLessThanOrEqual(10);
expect(page2.length).toBeLessThanOrEqual(10);
});
});
describe('getTransactionStatistics', () => {
it('should calculate transaction statistics', async () => {
const result = await transactionService.getTransactionStatistics('account-123');
expect(result).toBeDefined();
expect(result).toHaveProperty('totalDeposits');
expect(result).toHaveProperty('totalWithdrawals');
expect(result).toHaveProperty('totalEarnings');
expect(result).toHaveProperty('totalFees');
expect(result).toHaveProperty('netFlow');
});
it('should filter statistics by date range', async () => {
const result = await transactionService.getTransactionStatistics('account-123', {
startDate: new Date('2024-01-01'),
endDate: new Date('2024-12-31'),
});
expect(result).toBeDefined();
});
});
});

View File

@ -1,344 +0,0 @@
/**
* Investment Account Service
* Manages user investment accounts
*/
import { v4 as uuidv4 } from 'uuid';
import { productService, InvestmentProduct } from './product.service';
// ============================================================================
// Types
// ============================================================================
export type AccountStatus = 'active' | 'suspended' | 'closed';
export interface InvestmentAccount {
id: string;
userId: string;
productId: string;
product?: InvestmentProduct;
status: AccountStatus;
balance: number;
initialInvestment: number;
totalDeposited: number;
totalWithdrawn: number;
totalEarnings: number;
totalFeesPaid: number;
unrealizedPnl: number;
unrealizedPnlPercent: number;
openedAt: Date;
closedAt: Date | null;
updatedAt: Date;
}
export interface CreateAccountInput {
userId: string;
productId: string;
initialDeposit: number;
}
export interface AccountSummary {
totalBalance: number;
totalEarnings: number;
totalDeposited: number;
totalWithdrawn: number;
overallReturn: number;
overallReturnPercent: number;
accounts: InvestmentAccount[];
}
// ============================================================================
// In-Memory Storage
// ============================================================================
const accounts: Map<string, InvestmentAccount> = new Map();
// ============================================================================
// Account Service
// ============================================================================
class AccountService {
/**
* Get all accounts for a user
*/
async getUserAccounts(userId: string): Promise<InvestmentAccount[]> {
const userAccounts = Array.from(accounts.values()).filter((a) => a.userId === userId);
// Attach product info
for (const account of userAccounts) {
account.product = (await productService.getProductById(account.productId)) || undefined;
}
return userAccounts;
}
/**
* Get account by ID
*/
async getAccountById(accountId: string): Promise<InvestmentAccount | null> {
const account = accounts.get(accountId);
if (!account) return null;
account.product = (await productService.getProductById(account.productId)) || undefined;
return account;
}
/**
* Get account by user and product
*/
async getAccountByUserAndProduct(
userId: string,
productId: string
): Promise<InvestmentAccount | null> {
const account = Array.from(accounts.values()).find(
(a) => a.userId === userId && a.productId === productId && a.status !== 'closed'
);
if (!account) return null;
account.product = (await productService.getProductById(account.productId)) || undefined;
return account;
}
/**
* Create a new investment account
*/
async createAccount(input: CreateAccountInput): Promise<InvestmentAccount> {
// Validate product exists
const product = await productService.getProductById(input.productId);
if (!product) {
throw new Error(`Product not found: ${input.productId}`);
}
// Check minimum investment
if (input.initialDeposit < product.minInvestment) {
throw new Error(
`Minimum investment for ${product.name} is $${product.minInvestment}`
);
}
// Check if user already has an account with this product
const existingAccount = await this.getAccountByUserAndProduct(
input.userId,
input.productId
);
if (existingAccount) {
throw new Error(`User already has an account with ${product.name}`);
}
const account: InvestmentAccount = {
id: uuidv4(),
userId: input.userId,
productId: input.productId,
product,
status: 'active',
balance: input.initialDeposit,
initialInvestment: input.initialDeposit,
totalDeposited: input.initialDeposit,
totalWithdrawn: 0,
totalEarnings: 0,
totalFeesPaid: 0,
unrealizedPnl: 0,
unrealizedPnlPercent: 0,
openedAt: new Date(),
closedAt: null,
updatedAt: new Date(),
};
accounts.set(account.id, account);
return account;
}
/**
* Deposit funds to an account
*/
async deposit(accountId: string, amount: number): Promise<InvestmentAccount> {
const account = accounts.get(accountId);
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
if (account.status !== 'active') {
throw new Error(`Cannot deposit to ${account.status} account`);
}
if (amount <= 0) {
throw new Error('Deposit amount must be positive');
}
account.balance += amount;
account.totalDeposited += amount;
account.updatedAt = new Date();
return account;
}
/**
* Record earnings for an account
*/
async recordEarnings(
accountId: string,
grossEarnings: number,
performanceFee: number
): Promise<InvestmentAccount> {
const account = accounts.get(accountId);
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
const netEarnings = grossEarnings - performanceFee;
account.balance += netEarnings;
account.totalEarnings += netEarnings;
account.totalFeesPaid += performanceFee;
account.updatedAt = new Date();
return account;
}
/**
* Update unrealized P&L
*/
async updateUnrealizedPnl(
accountId: string,
unrealizedPnl: number
): Promise<InvestmentAccount> {
const account = accounts.get(accountId);
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
account.unrealizedPnl = unrealizedPnl;
account.unrealizedPnlPercent =
account.totalDeposited > 0
? (unrealizedPnl / account.totalDeposited) * 100
: 0;
account.updatedAt = new Date();
return account;
}
/**
* Close an account
*/
async closeAccount(accountId: string): Promise<InvestmentAccount> {
const account = accounts.get(accountId);
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
if (account.status === 'closed') {
throw new Error('Account is already closed');
}
account.status = 'closed';
account.closedAt = new Date();
account.updatedAt = new Date();
return account;
}
/**
* Suspend an account
*/
async suspendAccount(accountId: string): Promise<InvestmentAccount> {
const account = accounts.get(accountId);
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
account.status = 'suspended';
account.updatedAt = new Date();
return account;
}
/**
* Reactivate a suspended account
*/
async reactivateAccount(accountId: string): Promise<InvestmentAccount> {
const account = accounts.get(accountId);
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
if (account.status !== 'suspended') {
throw new Error('Only suspended accounts can be reactivated');
}
account.status = 'active';
account.updatedAt = new Date();
return account;
}
/**
* Get account summary for a user
*/
async getAccountSummary(userId: string): Promise<AccountSummary> {
const userAccounts = await this.getUserAccounts(userId);
const summary: AccountSummary = {
totalBalance: 0,
totalEarnings: 0,
totalDeposited: 0,
totalWithdrawn: 0,
overallReturn: 0,
overallReturnPercent: 0,
accounts: userAccounts,
};
for (const account of userAccounts) {
if (account.status !== 'closed') {
summary.totalBalance += account.balance;
summary.totalEarnings += account.totalEarnings;
summary.totalDeposited += account.totalDeposited;
summary.totalWithdrawn += account.totalWithdrawn;
}
}
summary.overallReturn = summary.totalBalance - summary.totalDeposited + summary.totalWithdrawn;
summary.overallReturnPercent =
summary.totalDeposited > 0
? (summary.overallReturn / summary.totalDeposited) * 100
: 0;
return summary;
}
/**
* Get account performance history
*/
async getAccountPerformance(
accountId: string,
days: number = 30
): Promise<{ date: string; balance: number; pnl: number }[]> {
const account = accounts.get(accountId);
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
// Generate mock performance data
const performance: { date: string; balance: number; pnl: number }[] = [];
let balance = account.initialInvestment;
for (let i = days; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const dailyChange = balance * ((Math.random() - 0.3) * 0.02);
balance += dailyChange;
performance.push({
date: date.toISOString().split('T')[0],
balance,
pnl: balance - account.initialInvestment,
});
}
return performance;
}
}
// Export singleton instance
export const accountService = new AccountService();

View File

@ -1,247 +0,0 @@
/**
* Investment Product Service
* Manages investment products (Atlas, Orion, Nova)
*/
import { v4 as uuidv4 } from 'uuid';
// ============================================================================
// Types
// ============================================================================
export type RiskProfile = 'conservative' | 'moderate' | 'aggressive';
export interface InvestmentProduct {
id: string;
code: string;
name: string;
description: string;
riskProfile: RiskProfile;
targetReturnMin: number;
targetReturnMax: number;
maxDrawdown: number;
minInvestment: number;
managementFee: number;
performanceFee: number;
isActive: boolean;
features: string[];
strategy: string;
assets: string[];
tradingFrequency: string;
createdAt: Date;
}
// ============================================================================
// Default Products
// ============================================================================
const DEFAULT_PRODUCTS: InvestmentProduct[] = [
{
id: uuidv4(),
code: 'atlas',
name: 'Atlas - El Guardián',
description:
'Estrategia conservadora diseñada para inversores que priorizan la preservación del capital. Utiliza mean reversion y grid trading en los principales activos del mercado.',
riskProfile: 'conservative',
targetReturnMin: 3,
targetReturnMax: 5,
maxDrawdown: 5,
minInvestment: 100,
managementFee: 0,
performanceFee: 20,
isActive: true,
features: [
'Bajo riesgo',
'Preservación de capital',
'Operaciones conservadoras',
'Solo activos principales',
],
strategy: 'Mean reversion + Grid trading',
assets: ['BTC', 'ETH'],
tradingFrequency: '2-5 trades/día',
createdAt: new Date(),
},
{
id: uuidv4(),
code: 'orion',
name: 'Orion - El Explorador',
description:
'Estrategia moderada para inversores que buscan un balance entre riesgo y rentabilidad. Aprovecha tendencias del mercado y breakouts en un rango más amplio de activos.',
riskProfile: 'moderate',
targetReturnMin: 5,
targetReturnMax: 10,
maxDrawdown: 10,
minInvestment: 500,
managementFee: 0,
performanceFee: 20,
isActive: true,
features: [
'Riesgo moderado',
'Balance rentabilidad/riesgo',
'Diversificación activos',
'Seguimiento de tendencias',
],
strategy: 'Trend following + Breakouts',
assets: ['BTC', 'ETH', 'Top 10 Altcoins'],
tradingFrequency: '5-15 trades/día',
createdAt: new Date(),
},
{
id: uuidv4(),
code: 'nova',
name: 'Nova - La Estrella',
description:
'Estrategia agresiva para inversores con alta tolerancia al riesgo. Maximiza oportunidades mediante momentum trading y scalping en todos los pares disponibles.',
riskProfile: 'aggressive',
targetReturnMin: 10,
targetReturnMax: 20,
maxDrawdown: 20,
minInvestment: 1000,
managementFee: 0,
performanceFee: 20,
isActive: true,
features: [
'Alto riesgo/alta rentabilidad',
'Trading activo',
'Todos los activos',
'Máximas oportunidades',
],
strategy: 'Momentum + Scalping',
assets: ['Todos los pares disponibles'],
tradingFrequency: '20+ trades/día',
createdAt: new Date(),
},
];
// ============================================================================
// In-Memory Storage
// ============================================================================
const products: Map<string, InvestmentProduct> = new Map(
DEFAULT_PRODUCTS.map((p) => [p.id, p])
);
// ============================================================================
// Product Service
// ============================================================================
class ProductService {
/**
* Get all active products
*/
async getProducts(): Promise<InvestmentProduct[]> {
return Array.from(products.values()).filter((p) => p.isActive);
}
/**
* Get product by ID
*/
async getProductById(id: string): Promise<InvestmentProduct | null> {
return products.get(id) || null;
}
/**
* Get product by code
*/
async getProductByCode(code: string): Promise<InvestmentProduct | null> {
return Array.from(products.values()).find((p) => p.code === code) || null;
}
/**
* Get products by risk profile
*/
async getProductsByRiskProfile(riskProfile: RiskProfile): Promise<InvestmentProduct[]> {
return Array.from(products.values()).filter(
(p) => p.riskProfile === riskProfile && p.isActive
);
}
/**
* Create a new product (admin only)
*/
async createProduct(
input: Omit<InvestmentProduct, 'id' | 'createdAt'>
): Promise<InvestmentProduct> {
const product: InvestmentProduct = {
...input,
id: uuidv4(),
createdAt: new Date(),
};
products.set(product.id, product);
return product;
}
/**
* Update a product (admin only)
*/
async updateProduct(
id: string,
updates: Partial<Omit<InvestmentProduct, 'id' | 'createdAt'>>
): Promise<InvestmentProduct | null> {
const product = products.get(id);
if (!product) return null;
const updated = { ...product, ...updates };
products.set(id, updated);
return updated;
}
/**
* Deactivate a product (admin only)
*/
async deactivateProduct(id: string): Promise<boolean> {
const product = products.get(id);
if (!product) return false;
product.isActive = false;
return true;
}
/**
* Get product statistics
*/
async getProductStats(_productId: string): Promise<{
totalInvestors: number;
totalAum: number;
avgReturn: number;
winRate: number;
}> {
// TODO: Calculate from real data
return {
totalInvestors: Math.floor(Math.random() * 1000) + 100,
totalAum: Math.floor(Math.random() * 10000000) + 1000000,
avgReturn: Math.random() * 15 + 5,
winRate: Math.random() * 20 + 60,
};
}
/**
* Get product performance history
*/
async getProductPerformance(
productId: string,
period: 'week' | 'month' | '3months' | 'year'
): Promise<{ date: string; return: number }[]> {
const days =
period === 'week' ? 7 : period === 'month' ? 30 : period === '3months' ? 90 : 365;
const performance: { date: string; return: number }[] = [];
let cumulativeReturn = 0;
for (let i = days; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const dailyReturn = (Math.random() - 0.3) * 2;
cumulativeReturn += dailyReturn;
performance.push({
date: date.toISOString().split('T')[0],
return: cumulativeReturn,
});
}
return performance;
}
}
// Export singleton instance
export const productService = new ProductService();

View File

@ -1,589 +0,0 @@
/**
* Investment Transaction Service
* Manages deposits, withdrawals, and distributions
*/
import { v4 as uuidv4 } from 'uuid';
import { accountService } from './account.service';
// ============================================================================
// Types
// ============================================================================
export type TransactionType = 'deposit' | 'withdrawal' | 'earning' | 'fee' | 'distribution';
export type TransactionStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
export type WithdrawalStatus = 'pending' | 'processing' | 'completed' | 'rejected';
export interface Transaction {
id: string;
accountId: string;
userId: string;
type: TransactionType;
status: TransactionStatus;
amount: number;
balanceBefore: number;
balanceAfter: number;
stripePaymentId: string | null;
description: string;
processedAt: Date | null;
createdAt: Date;
}
export interface WithdrawalRequest {
id: string;
accountId: string;
userId: string;
amount: number;
status: WithdrawalStatus;
bankInfo: {
bankName: string;
accountNumber: string;
routingNumber: string;
accountHolderName: string;
} | null;
cryptoInfo: {
network: string;
address: string;
} | null;
rejectionReason: string | null;
requestedAt: Date;
processedAt: Date | null;
completedAt: Date | null;
}
export interface Distribution {
id: string;
accountId: string;
userId: string;
periodStart: Date;
periodEnd: Date;
grossEarnings: number;
performanceFee: number;
netEarnings: number;
status: 'pending' | 'distributed';
distributedAt: Date | null;
createdAt: Date;
}
export interface CreateDepositInput {
accountId: string;
amount: number;
stripePaymentId?: string;
}
export interface CreateWithdrawalInput {
accountId: string;
amount: number;
bankInfo?: {
bankName: string;
accountNumber: string;
routingNumber: string;
accountHolderName: string;
};
cryptoInfo?: {
network: string;
address: string;
};
}
// ============================================================================
// In-Memory Storage
// ============================================================================
const transactions: Map<string, Transaction> = new Map();
const withdrawalRequests: Map<string, WithdrawalRequest> = new Map();
const distributions: Map<string, Distribution> = new Map();
// ============================================================================
// Transaction Service
// ============================================================================
class TransactionService {
// ==========================================================================
// Transactions
// ==========================================================================
/**
* Get transactions for an account
*/
async getAccountTransactions(
accountId: string,
options: {
type?: TransactionType;
status?: TransactionStatus;
limit?: number;
offset?: number;
} = {}
): Promise<{ transactions: Transaction[]; total: number }> {
let accountTransactions = Array.from(transactions.values())
.filter((t) => t.accountId === accountId)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
if (options.type) {
accountTransactions = accountTransactions.filter((t) => t.type === options.type);
}
if (options.status) {
accountTransactions = accountTransactions.filter((t) => t.status === options.status);
}
const total = accountTransactions.length;
if (options.offset) {
accountTransactions = accountTransactions.slice(options.offset);
}
if (options.limit) {
accountTransactions = accountTransactions.slice(0, options.limit);
}
return { transactions: accountTransactions, total };
}
/**
* Get transaction by ID
*/
async getTransactionById(transactionId: string): Promise<Transaction | null> {
return transactions.get(transactionId) || null;
}
/**
* Create a deposit transaction
*/
async createDeposit(input: CreateDepositInput): Promise<Transaction> {
const account = await accountService.getAccountById(input.accountId);
if (!account) {
throw new Error(`Account not found: ${input.accountId}`);
}
if (input.amount <= 0) {
throw new Error('Deposit amount must be positive');
}
const balanceBefore = account.balance;
// Process deposit
await accountService.deposit(input.accountId, input.amount);
const transaction: Transaction = {
id: uuidv4(),
accountId: input.accountId,
userId: account.userId,
type: 'deposit',
status: 'completed',
amount: input.amount,
balanceBefore,
balanceAfter: balanceBefore + input.amount,
stripePaymentId: input.stripePaymentId || null,
description: `Deposit of $${input.amount.toFixed(2)}`,
processedAt: new Date(),
createdAt: new Date(),
};
transactions.set(transaction.id, transaction);
return transaction;
}
/**
* Create a pending deposit (for Stripe webhooks)
*/
async createPendingDeposit(
accountId: string,
amount: number,
stripePaymentId: string
): Promise<Transaction> {
const account = await accountService.getAccountById(accountId);
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
const transaction: Transaction = {
id: uuidv4(),
accountId,
userId: account.userId,
type: 'deposit',
status: 'pending',
amount,
balanceBefore: account.balance,
balanceAfter: account.balance,
stripePaymentId,
description: `Pending deposit of $${amount.toFixed(2)}`,
processedAt: null,
createdAt: new Date(),
};
transactions.set(transaction.id, transaction);
return transaction;
}
/**
* Complete a pending deposit
*/
async completeDeposit(transactionId: string): Promise<Transaction> {
const transaction = transactions.get(transactionId);
if (!transaction) {
throw new Error(`Transaction not found: ${transactionId}`);
}
if (transaction.status !== 'pending') {
throw new Error('Transaction is not pending');
}
// Process deposit
await accountService.deposit(transaction.accountId, transaction.amount);
// Get updated account
const account = await accountService.getAccountById(transaction.accountId);
transaction.status = 'completed';
transaction.balanceAfter = account!.balance;
transaction.processedAt = new Date();
transaction.description = `Deposit of $${transaction.amount.toFixed(2)}`;
return transaction;
}
// ==========================================================================
// Withdrawals
// ==========================================================================
/**
* Get withdrawal requests for a user
*/
async getUserWithdrawals(
userId: string,
status?: WithdrawalStatus
): Promise<WithdrawalRequest[]> {
let userWithdrawals = Array.from(withdrawalRequests.values())
.filter((w) => w.userId === userId)
.sort((a, b) => b.requestedAt.getTime() - a.requestedAt.getTime());
if (status) {
userWithdrawals = userWithdrawals.filter((w) => w.status === status);
}
return userWithdrawals;
}
/**
* Get withdrawal request by ID
*/
async getWithdrawalById(withdrawalId: string): Promise<WithdrawalRequest | null> {
return withdrawalRequests.get(withdrawalId) || null;
}
/**
* Create a withdrawal request
*/
async createWithdrawal(
userId: string,
input: CreateWithdrawalInput
): Promise<WithdrawalRequest> {
const account = await accountService.getAccountById(input.accountId);
if (!account) {
throw new Error(`Account not found: ${input.accountId}`);
}
if (account.userId !== userId) {
throw new Error('Unauthorized');
}
if (account.status !== 'active') {
throw new Error('Cannot withdraw from inactive account');
}
if (input.amount <= 0) {
throw new Error('Withdrawal amount must be positive');
}
if (input.amount > account.balance) {
throw new Error('Insufficient balance');
}
if (!input.bankInfo && !input.cryptoInfo) {
throw new Error('Bank or crypto information is required');
}
// Check daily withdrawal limit
const dailyLimit = 10000;
const todayWithdrawals = Array.from(withdrawalRequests.values())
.filter((w) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return (
w.userId === userId &&
w.status !== 'rejected' &&
w.requestedAt >= today
);
})
.reduce((sum, w) => sum + w.amount, 0);
if (todayWithdrawals + input.amount > dailyLimit) {
throw new Error(`Daily withdrawal limit of $${dailyLimit} exceeded`);
}
const withdrawal: WithdrawalRequest = {
id: uuidv4(),
accountId: input.accountId,
userId,
amount: input.amount,
status: 'pending',
bankInfo: input.bankInfo || null,
cryptoInfo: input.cryptoInfo || null,
rejectionReason: null,
requestedAt: new Date(),
processedAt: null,
completedAt: null,
};
withdrawalRequests.set(withdrawal.id, withdrawal);
// Create pending transaction
const transaction: Transaction = {
id: uuidv4(),
accountId: input.accountId,
userId,
type: 'withdrawal',
status: 'pending',
amount: -input.amount,
balanceBefore: account.balance,
balanceAfter: account.balance,
stripePaymentId: null,
description: `Pending withdrawal of $${input.amount.toFixed(2)}`,
processedAt: null,
createdAt: new Date(),
};
transactions.set(transaction.id, transaction);
return withdrawal;
}
/**
* Process a withdrawal (admin)
*/
async processWithdrawal(withdrawalId: string): Promise<WithdrawalRequest> {
const withdrawal = withdrawalRequests.get(withdrawalId);
if (!withdrawal) {
throw new Error(`Withdrawal not found: ${withdrawalId}`);
}
if (withdrawal.status !== 'pending') {
throw new Error('Withdrawal is not pending');
}
withdrawal.status = 'processing';
withdrawal.processedAt = new Date();
return withdrawal;
}
/**
* Complete a withdrawal (admin)
*/
async completeWithdrawal(withdrawalId: string): Promise<WithdrawalRequest> {
const withdrawal = withdrawalRequests.get(withdrawalId);
if (!withdrawal) {
throw new Error(`Withdrawal not found: ${withdrawalId}`);
}
if (withdrawal.status !== 'processing') {
throw new Error('Withdrawal is not being processed');
}
const account = await accountService.getAccountById(withdrawal.accountId);
if (!account) {
throw new Error('Account not found');
}
// Deduct from account
account.balance -= withdrawal.amount;
account.totalWithdrawn += withdrawal.amount;
account.updatedAt = new Date();
// Update withdrawal
withdrawal.status = 'completed';
withdrawal.completedAt = new Date();
// Update transaction
const pendingTx = Array.from(transactions.values()).find(
(t) =>
t.accountId === withdrawal.accountId &&
t.type === 'withdrawal' &&
t.status === 'pending' &&
t.amount === -withdrawal.amount
);
if (pendingTx) {
pendingTx.status = 'completed';
pendingTx.balanceAfter = account.balance;
pendingTx.processedAt = new Date();
pendingTx.description = `Withdrawal of $${withdrawal.amount.toFixed(2)}`;
}
return withdrawal;
}
/**
* Reject a withdrawal (admin)
*/
async rejectWithdrawal(
withdrawalId: string,
reason: string
): Promise<WithdrawalRequest> {
const withdrawal = withdrawalRequests.get(withdrawalId);
if (!withdrawal) {
throw new Error(`Withdrawal not found: ${withdrawalId}`);
}
if (withdrawal.status === 'completed') {
throw new Error('Cannot reject completed withdrawal');
}
withdrawal.status = 'rejected';
withdrawal.rejectionReason = reason;
withdrawal.processedAt = new Date();
// Cancel transaction
const pendingTx = Array.from(transactions.values()).find(
(t) =>
t.accountId === withdrawal.accountId &&
t.type === 'withdrawal' &&
t.status === 'pending' &&
t.amount === -withdrawal.amount
);
if (pendingTx) {
pendingTx.status = 'cancelled';
pendingTx.description = `Withdrawal rejected: ${reason}`;
}
return withdrawal;
}
// ==========================================================================
// Distributions
// ==========================================================================
/**
* Get distributions for an account
*/
async getAccountDistributions(accountId: string): Promise<Distribution[]> {
return Array.from(distributions.values())
.filter((d) => d.accountId === accountId)
.sort((a, b) => b.periodEnd.getTime() - a.periodEnd.getTime());
}
/**
* Create a distribution
*/
async createDistribution(
accountId: string,
periodStart: Date,
periodEnd: Date,
grossEarnings: number,
performanceFeePercent: number
): Promise<Distribution> {
const account = await accountService.getAccountById(accountId);
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
const performanceFee = grossEarnings * (performanceFeePercent / 100);
const netEarnings = grossEarnings - performanceFee;
const distribution: Distribution = {
id: uuidv4(),
accountId,
userId: account.userId,
periodStart,
periodEnd,
grossEarnings,
performanceFee,
netEarnings,
status: 'pending',
distributedAt: null,
createdAt: new Date(),
};
distributions.set(distribution.id, distribution);
return distribution;
}
/**
* Distribute earnings
*/
async distributeEarnings(distributionId: string): Promise<Distribution> {
const distribution = distributions.get(distributionId);
if (!distribution) {
throw new Error(`Distribution not found: ${distributionId}`);
}
if (distribution.status === 'distributed') {
throw new Error('Distribution already completed');
}
// Record earnings in account
await accountService.recordEarnings(
distribution.accountId,
distribution.grossEarnings,
distribution.performanceFee
);
// Create transactions
const account = await accountService.getAccountById(distribution.accountId);
if (distribution.netEarnings !== 0) {
const earningTx: Transaction = {
id: uuidv4(),
accountId: distribution.accountId,
userId: distribution.userId,
type: 'earning',
status: 'completed',
amount: distribution.netEarnings,
balanceBefore: account!.balance - distribution.netEarnings,
balanceAfter: account!.balance,
stripePaymentId: null,
description: `Earnings for ${distribution.periodStart.toLocaleDateString()} - ${distribution.periodEnd.toLocaleDateString()}`,
processedAt: new Date(),
createdAt: new Date(),
};
transactions.set(earningTx.id, earningTx);
}
if (distribution.performanceFee > 0) {
const feeTx: Transaction = {
id: uuidv4(),
accountId: distribution.accountId,
userId: distribution.userId,
type: 'fee',
status: 'completed',
amount: -distribution.performanceFee,
balanceBefore: account!.balance,
balanceAfter: account!.balance,
stripePaymentId: null,
description: `Performance fee (${((distribution.performanceFee / distribution.grossEarnings) * 100).toFixed(0)}%)`,
processedAt: new Date(),
createdAt: new Date(),
};
transactions.set(feeTx.id, feeTx);
}
distribution.status = 'distributed';
distribution.distributedAt = new Date();
return distribution;
}
/**
* Get pending distributions
*/
async getPendingDistributions(): Promise<Distribution[]> {
return Array.from(distributions.values())
.filter((d) => d.status === 'pending')
.sort((a, b) => a.periodEnd.getTime() - b.periodEnd.getTime());
}
}
// Export singleton instance
export const transactionService = new TransactionService();

View File

@ -1,260 +0,0 @@
/**
* LLM Controller
* Handles AI chat assistant endpoints
*/
import { Request, Response, NextFunction } from 'express';
import { llmService } from '../services/llm.service';
// ============================================================================
// Types
// ============================================================================
// Use Request directly - user is already declared globally in auth.middleware.ts
type AuthRequest = Request;
// ============================================================================
// Session Management
// ============================================================================
/**
* Create a new chat session
*/
export async function createSession(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { watchlist, preferences, portfolioSummary } = req.body;
const session = await llmService.createSession(userId, {
watchlist,
preferences,
portfolioSummary,
});
res.status(201).json({
success: true,
data: {
sessionId: session.id,
createdAt: session.createdAt,
},
});
} catch (error) {
next(error);
}
}
/**
* Get user's chat sessions
*/
export async function getSessions(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const sessions = await llmService.getUserSessions(userId);
res.json({
success: true,
data: sessions.map((s) => ({
id: s.id,
messagesCount: s.messages.length,
lastMessage: s.messages[s.messages.length - 1]?.content.substring(0, 100),
createdAt: s.createdAt,
updatedAt: s.updatedAt,
})),
});
} catch (error) {
next(error);
}
}
/**
* Get a specific session with messages
*/
export async function getSession(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { sessionId } = req.params;
const session = await llmService.getSession(sessionId);
if (!session) {
res.status(404).json({
success: false,
error: { message: 'Session not found', code: 'NOT_FOUND' },
});
return;
}
if (session.userId !== userId) {
res.status(403).json({
success: false,
error: { message: 'Forbidden', code: 'FORBIDDEN' },
});
return;
}
res.json({
success: true,
data: session,
});
} catch (error) {
next(error);
}
}
/**
* Delete a session
*/
export async function deleteSession(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { sessionId } = req.params;
const session = await llmService.getSession(sessionId);
if (!session) {
res.status(404).json({
success: false,
error: { message: 'Session not found', code: 'NOT_FOUND' },
});
return;
}
if (session.userId !== userId) {
res.status(403).json({
success: false,
error: { message: 'Forbidden', code: 'FORBIDDEN' },
});
return;
}
await llmService.deleteSession(sessionId);
res.json({
success: true,
message: 'Session deleted',
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Chat
// ============================================================================
/**
* Send a message and get a response
*/
export async function chat(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { sessionId } = req.params;
const { message } = req.body;
if (!message || typeof message !== 'string' || message.trim().length === 0) {
res.status(400).json({
success: false,
error: { message: 'Message is required', code: 'VALIDATION_ERROR' },
});
return;
}
const session = await llmService.getSession(sessionId);
if (!session) {
res.status(404).json({
success: false,
error: { message: 'Session not found', code: 'NOT_FOUND' },
});
return;
}
if (session.userId !== userId) {
res.status(403).json({
success: false,
error: { message: 'Forbidden', code: 'FORBIDDEN' },
});
return;
}
const response = await llmService.chat(sessionId, message.trim());
res.json({
success: true,
data: response,
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Quick Actions
// ============================================================================
/**
* Get quick analysis for a symbol (no session required)
*/
export async function getQuickAnalysis(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
if (!symbol) {
res.status(400).json({
success: false,
error: { message: 'Symbol is required', code: 'VALIDATION_ERROR' },
});
return;
}
const analysis = await llmService.getQuickAnalysis(symbol.toUpperCase());
res.json({
success: true,
data: {
symbol: symbol.toUpperCase(),
analysis,
timestamp: new Date().toISOString(),
},
});
} catch (error) {
next(error);
}
}

View File

@ -1,65 +0,0 @@
/**
* LLM Routes
* AI chat assistant endpoints
*/
import { Router, RequestHandler } from 'express';
import * as llmController from './controllers/llm.controller';
const router = Router();
// Type cast helper for authenticated routes
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
const authHandler = (fn: Function): RequestHandler => fn as RequestHandler;
// ============================================================================
// Session Management (Authenticated)
// ============================================================================
/**
* POST /api/v1/llm/sessions
* Create a new chat session
* Body: { watchlist?, preferences?, portfolioSummary? }
*/
router.post('/sessions', authHandler(llmController.createSession));
/**
* GET /api/v1/llm/sessions
* Get user's chat sessions
*/
router.get('/sessions', authHandler(llmController.getSessions));
/**
* GET /api/v1/llm/sessions/:sessionId
* Get a specific session with messages
*/
router.get('/sessions/:sessionId', authHandler(llmController.getSession));
/**
* DELETE /api/v1/llm/sessions/:sessionId
* Delete a session
*/
router.delete('/sessions/:sessionId', authHandler(llmController.deleteSession));
// ============================================================================
// Chat (Authenticated)
// ============================================================================
/**
* POST /api/v1/llm/sessions/:sessionId/chat
* Send a message and get a response
* Body: { message: string }
*/
router.post('/sessions/:sessionId/chat', authHandler(llmController.chat));
// ============================================================================
// Quick Actions (Public)
// ============================================================================
/**
* GET /api/v1/llm/analyze/:symbol
* Get quick analysis for a symbol
*/
router.get('/analyze/:symbol', llmController.getQuickAnalysis);
export { router as llmRouter };

View File

@ -1,494 +0,0 @@
/**
* LLM Service
* AI-powered trading assistant using Claude or OpenAI
*/
import Anthropic from '@anthropic-ai/sdk';
import { v4 as uuidv4 } from 'uuid';
import { mlIntegrationService } from '../../ml/services/ml-integration.service';
import { marketService } from '../../trading/services/market.service';
// ============================================================================
// Types
// ============================================================================
export type MessageRole = 'user' | 'assistant' | 'system';
export interface ChatMessage {
id: string;
role: MessageRole;
content: string;
timestamp: Date;
metadata?: {
toolCalls?: ToolCall[];
signal?: unknown;
error?: string;
};
}
export interface ChatSession {
id: string;
userId: string;
messages: ChatMessage[];
context: SessionContext;
createdAt: Date;
updatedAt: Date;
}
export interface SessionContext {
watchlist: string[];
preferences: {
riskProfile: 'conservative' | 'moderate' | 'aggressive';
preferredTimeframe: string;
language: 'es' | 'en';
};
recentSignals: unknown[];
portfolioSummary?: unknown;
}
export interface ToolCall {
name: string;
input: Record<string, unknown>;
output?: unknown;
}
export interface LLMConfig {
provider: 'anthropic' | 'openai';
model: string;
maxTokens: number;
temperature: number;
}
// ============================================================================
// Tool Definitions
// ============================================================================
const TRADING_TOOLS = [
{
name: 'get_signal',
description: 'Get the current ML trading signal for a cryptocurrency symbol',
input_schema: {
type: 'object',
properties: {
symbol: {
type: 'string',
description: 'The trading pair symbol (e.g., BTCUSDT)',
},
timeHorizon: {
type: 'string',
enum: ['scalp', 'intraday', 'swing'],
description: 'The trading time horizon',
},
},
required: ['symbol'],
},
},
{
name: 'get_price',
description: 'Get the current price for a cryptocurrency symbol',
input_schema: {
type: 'object',
properties: {
symbol: {
type: 'string',
description: 'The trading pair symbol (e.g., BTCUSDT)',
},
},
required: ['symbol'],
},
},
{
name: 'get_indicators',
description: 'Get technical indicators for a symbol (RSI, MACD, etc.)',
input_schema: {
type: 'object',
properties: {
symbol: {
type: 'string',
description: 'The trading pair symbol',
},
},
required: ['symbol'],
},
},
{
name: 'analyze_chart',
description: 'Get a technical analysis summary for a symbol',
input_schema: {
type: 'object',
properties: {
symbol: {
type: 'string',
description: 'The trading pair symbol',
},
timeframe: {
type: 'string',
description: 'The chart timeframe (1h, 4h, 1d)',
},
},
required: ['symbol'],
},
},
{
name: 'get_amd_phase',
description: 'Get the current AMD (Accumulation/Manipulation/Distribution) phase',
input_schema: {
type: 'object',
properties: {
symbol: {
type: 'string',
description: 'The trading pair symbol',
},
},
required: ['symbol'],
},
},
];
// ============================================================================
// System Prompts
// ============================================================================
const SYSTEM_PROMPT = `Eres un asistente de trading experto para la plataforma OrbiQuant IA. Tu rol es:
1. **Interpretar señales ML**: Explicar las señales del modelo de machine learning en lenguaje claro.
2. **Recomendar estrategias**: Sugerir puntos de entrada/salida basados en el análisis técnico.
3. **Educar al usuario**: Explicar conceptos de trading cuando sea necesario.
4. **Gestionar riesgo**: Siempre mencionar stop loss y take profit cuando sugieras trades.
## Personalidad
- Profesional pero accesible
- Directo y conciso
- Siempre menciona el nivel de confianza de las predicciones
- Nunca prometas ganancias garantizadas
- Incluye disclaimers de riesgo cuando sea apropiado
## Formato de Respuestas
- Usa emojis para señales: 📈 (buy), 📉 (sell), (hold)
- Estructura las recomendaciones claramente
- Incluye niveles de precio específicos cuando des recomendaciones
- Usa formato markdown para mejor legibilidad
## Restricciones
- No ejecutes trades directamente, solo sugiere
- No proporciones asesoría financiera personal
- Siempre aclara que las predicciones son probabilísticas
- Recuerda al usuario que el trading conlleva riesgos`;
// ============================================================================
// In-Memory Storage
// ============================================================================
const sessions: Map<string, ChatSession> = new Map();
// ============================================================================
// LLM Service
// ============================================================================
class LLMService {
private anthropic: Anthropic | null = null;
private config: LLMConfig;
constructor() {
this.config = {
provider: 'anthropic',
model: 'claude-3-5-sonnet-20241022',
maxTokens: 4096,
temperature: 0.7,
};
// Initialize Anthropic client if API key is available
const apiKey = process.env.ANTHROPIC_API_KEY;
if (apiKey) {
this.anthropic = new Anthropic({ apiKey });
}
}
// ==========================================================================
// Session Management
// ==========================================================================
/**
* Create a new chat session
*/
async createSession(
userId: string,
context?: Partial<SessionContext>
): Promise<ChatSession> {
const session: ChatSession = {
id: uuidv4(),
userId,
messages: [],
context: {
watchlist: context?.watchlist || ['BTCUSDT', 'ETHUSDT'],
preferences: {
riskProfile: context?.preferences?.riskProfile || 'moderate',
preferredTimeframe: context?.preferences?.preferredTimeframe || '1h',
language: context?.preferences?.language || 'es',
},
recentSignals: [],
portfolioSummary: context?.portfolioSummary,
},
createdAt: new Date(),
updatedAt: new Date(),
};
sessions.set(session.id, session);
return session;
}
/**
* Get a session by ID
*/
async getSession(sessionId: string): Promise<ChatSession | null> {
return sessions.get(sessionId) || null;
}
/**
* Get sessions for a user
*/
async getUserSessions(userId: string): Promise<ChatSession[]> {
return Array.from(sessions.values())
.filter((s) => s.userId === userId)
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
}
/**
* Delete a session
*/
async deleteSession(sessionId: string): Promise<boolean> {
return sessions.delete(sessionId);
}
// ==========================================================================
// Chat
// ==========================================================================
/**
* Send a message and get a response
*/
async chat(
sessionId: string,
userMessage: string
): Promise<ChatMessage> {
const session = sessions.get(sessionId);
if (!session) {
throw new Error(`Session not found: ${sessionId}`);
}
// Add user message to session
const userMsg: ChatMessage = {
id: uuidv4(),
role: 'user',
content: userMessage,
timestamp: new Date(),
};
session.messages.push(userMsg);
// Generate response
let response: ChatMessage;
try {
if (this.anthropic) {
response = await this.generateAnthropicResponse(session);
} else {
response = await this.generateMockResponse(session, userMessage);
}
} catch (error) {
response = {
id: uuidv4(),
role: 'assistant',
content: 'Lo siento, hubo un error procesando tu solicitud. Por favor, intenta de nuevo.',
timestamp: new Date(),
metadata: {
error: error instanceof Error ? error.message : 'Unknown error',
},
};
}
// Add response to session
session.messages.push(response);
session.updatedAt = new Date();
return response;
}
/**
* Get quick analysis for a symbol
*/
async getQuickAnalysis(symbol: string): Promise<string> {
try {
const [signal, indicators, price] = await Promise.all([
mlIntegrationService.getSignal(symbol).catch(() => null),
mlIntegrationService.getIndicators(symbol).catch(() => null),
marketService.getPrice(symbol).catch(() => null),
]);
if (!price) {
return `No se pudo obtener información para ${symbol}`;
}
let analysis = `## Análisis Rápido: ${symbol}\n\n`;
analysis += `**Precio Actual:** $${price.price.toLocaleString()}\n\n`;
if (signal) {
const emoji = signal.signalType === 'buy' ? '📈' : signal.signalType === 'sell' ? '📉' : '⚖️';
analysis += `${emoji} **Señal:** ${signal.signalType.toUpperCase()} (Confianza: ${(signal.confidence * 100).toFixed(1)}%)\n`;
analysis += `**Fase AMD:** ${signal.amdPhase}\n\n`;
}
if (indicators) {
analysis += `### Indicadores Técnicos\n`;
analysis += `- **RSI:** ${indicators.rsi.toFixed(1)} ${indicators.rsi > 70 ? '(Sobrecompra)' : indicators.rsi < 30 ? '(Sobreventa)' : '(Neutral)'}\n`;
analysis += `- **MACD:** ${indicators.macd.histogram > 0 ? 'Alcista' : 'Bajista'}\n`;
analysis += `- **ATR:** ${indicators.atrPercent.toFixed(2)}% (Volatilidad)\n`;
}
return analysis;
} catch {
return `Error al analizar ${symbol}`;
}
}
// ==========================================================================
// Private Methods
// ==========================================================================
private async generateAnthropicResponse(session: ChatSession): Promise<ChatMessage> {
if (!this.anthropic) {
throw new Error('Anthropic client not initialized');
}
// Build messages for API
const messages = session.messages.map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
}));
// Call Anthropic API with tools
const response = await this.anthropic.messages.create({
model: this.config.model,
max_tokens: this.config.maxTokens,
temperature: this.config.temperature,
system: SYSTEM_PROMPT,
messages,
tools: TRADING_TOOLS as Anthropic.Tool[],
});
// Process tool calls if any
const toolCalls: ToolCall[] = [];
let finalContent = '';
for (const block of response.content) {
if (block.type === 'text') {
finalContent += block.text;
} else if (block.type === 'tool_use') {
const toolResult = await this.executeTool(block.name, block.input as Record<string, unknown>);
toolCalls.push({
name: block.name,
input: block.input as Record<string, unknown>,
output: toolResult,
});
}
}
// If there were tool calls, make a follow-up request
if (toolCalls.length > 0 && response.stop_reason === 'tool_use') {
const toolResults = toolCalls.map((tc) => ({
type: 'tool_result' as const,
tool_use_id: uuidv4(),
content: JSON.stringify(tc.output),
}));
const followUp = await this.anthropic.messages.create({
model: this.config.model,
max_tokens: this.config.maxTokens,
system: SYSTEM_PROMPT,
messages: [
...messages,
{ role: 'assistant', content: response.content },
{ role: 'user', content: toolResults },
],
});
for (const block of followUp.content) {
if (block.type === 'text') {
finalContent = block.text;
}
}
}
return {
id: uuidv4(),
role: 'assistant',
content: finalContent,
timestamp: new Date(),
metadata: {
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
},
};
}
private async generateMockResponse(
session: ChatSession,
userMessage: string
): Promise<ChatMessage> {
// Simple mock response for development without API key
const lowerMessage = userMessage.toLowerCase();
let response = '';
if (lowerMessage.includes('bitcoin') || lowerMessage.includes('btc')) {
response = await this.getQuickAnalysis('BTCUSDT');
} else if (lowerMessage.includes('ethereum') || lowerMessage.includes('eth')) {
response = await this.getQuickAnalysis('ETHUSDT');
} else if (lowerMessage.includes('señal') || lowerMessage.includes('signal')) {
response = `Para obtener una señal, especifica el símbolo. Por ejemplo: "¿Cuál es la señal para Bitcoin?"`;
} else if (lowerMessage.includes('hola') || lowerMessage.includes('hello')) {
response = `¡Hola! Soy tu asistente de trading de OrbiQuant. Puedo ayudarte a:\n\n- 📊 Analizar criptomonedas\n- 📈 Interpretar señales de trading\n- 📚 Explicar conceptos de trading\n\n¿Qué te gustaría saber?`;
} else {
response = `Entiendo tu consulta. Para darte información precisa, puedes preguntarme sobre:\n\n- Señales de trading (ej: "¿Cuál es la señal para BTC?")\n- Análisis técnico (ej: "Analiza ETH")\n- Conceptos de trading (ej: "¿Qué es AMD?")\n\n¿En qué puedo ayudarte?`;
}
return {
id: uuidv4(),
role: 'assistant',
content: response,
timestamp: new Date(),
};
}
private async executeTool(
name: string,
input: Record<string, unknown>
): Promise<unknown> {
switch (name) {
case 'get_signal':
return mlIntegrationService.getSignal(
input.symbol as string,
(input.timeHorizon as 'scalp' | 'intraday' | 'swing') || 'intraday'
);
case 'get_price':
return marketService.getPrice(input.symbol as string);
case 'get_indicators':
return mlIntegrationService.getIndicators(input.symbol as string);
case 'analyze_chart': {
const [signal, indicators] = await Promise.all([
mlIntegrationService.getSignal(input.symbol as string),
mlIntegrationService.getIndicators(input.symbol as string),
]);
return { signal, indicators };
}
case 'get_amd_phase':
return mlIntegrationService.getAMDPhase(input.symbol as string);
default:
throw new Error(`Unknown tool: ${name}`);
}
}
}
// Export singleton instance
export const llmService = new LLMService();

View File

@ -1,248 +0,0 @@
/**
* ML Overlay Controller
* Handles chart overlay endpoints for trading visualization
*/
import { Request, Response, NextFunction } from 'express';
import { mlOverlayService, OverlayConfig } from '../services/ml-overlay.service';
// ============================================================================
// Overlay Endpoints
// ============================================================================
/**
* Get complete chart overlay for a symbol
*/
export async function getChartOverlay(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const { symbol } = req.params;
const config = parseOverlayConfig(req.query);
const overlay = await mlOverlayService.getChartOverlay(symbol.toUpperCase(), config);
res.json({
success: true,
data: overlay,
});
} catch (error) {
next(error);
}
}
/**
* Get overlays for multiple symbols
*/
export async function getBatchOverlays(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const { symbols } = req.body;
const config = parseOverlayConfig(req.body);
if (!symbols || !Array.isArray(symbols) || symbols.length === 0) {
res.status(400).json({
success: false,
error: { message: 'Symbols array is required', code: 'VALIDATION_ERROR' },
});
return;
}
if (symbols.length > 20) {
res.status(400).json({
success: false,
error: { message: 'Maximum 20 symbols allowed per request', code: 'VALIDATION_ERROR' },
});
return;
}
const overlays = await mlOverlayService.getBatchOverlays(
symbols.map((s: string) => s.toUpperCase()),
config
);
// Convert Map to object for JSON response
const result: Record<string, unknown> = {};
overlays.forEach((value, key) => {
result[key] = value;
});
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
}
/**
* Get price levels only
*/
export async function getPriceLevels(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const { symbol } = req.params;
const levels = await mlOverlayService.getPriceLevels(symbol.toUpperCase());
res.json({
success: true,
data: {
symbol: symbol.toUpperCase(),
levels,
timestamp: new Date().toISOString(),
},
});
} catch (error) {
next(error);
}
}
/**
* Get signal markers
*/
export async function getSignalMarkers(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const { symbol } = req.params;
const limit = Math.min(Number(req.query.limit) || 20, 100);
const markers = await mlOverlayService.getSignalMarkers(symbol.toUpperCase(), limit);
res.json({
success: true,
data: {
symbol: symbol.toUpperCase(),
markers,
timestamp: new Date().toISOString(),
},
});
} catch (error) {
next(error);
}
}
/**
* Get AMD phase overlay
*/
export async function getAMDPhaseOverlay(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const { symbol } = req.params;
const amdPhase = await mlOverlayService.getAMDPhaseOverlay(symbol.toUpperCase());
res.json({
success: true,
data: {
symbol: symbol.toUpperCase(),
...amdPhase,
timestamp: new Date().toISOString(),
},
});
} catch (error) {
next(error);
}
}
/**
* Get prediction bands
*/
export async function getPredictionBands(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const { symbol } = req.params;
const horizonMinutes = Math.min(Number(req.query.horizon) || 90, 480);
const intervals = Math.min(Number(req.query.intervals) || 10, 50);
const bands = await mlOverlayService.getPredictionBands(
symbol.toUpperCase(),
horizonMinutes,
intervals
);
res.json({
success: true,
data: {
symbol: symbol.toUpperCase(),
horizonMinutes,
bands,
timestamp: new Date().toISOString(),
},
});
} catch (error) {
next(error);
}
}
/**
* Clear overlay cache
*/
export async function clearCache(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const { symbol } = req.params;
mlOverlayService.clearCache(symbol?.toUpperCase());
res.json({
success: true,
message: symbol ? `Cache cleared for ${symbol.toUpperCase()}` : 'All cache cleared',
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Helper Functions
// ============================================================================
function parseOverlayConfig(query: Record<string, unknown>): Partial<OverlayConfig> {
const config: Partial<OverlayConfig> = {};
if (query.showPriceLevels !== undefined) {
config.showPriceLevels = query.showPriceLevels === 'true' || query.showPriceLevels === true;
}
if (query.showTrendLines !== undefined) {
config.showTrendLines = query.showTrendLines === 'true' || query.showTrendLines === true;
}
if (query.showSignalMarkers !== undefined) {
config.showSignalMarkers = query.showSignalMarkers === 'true' || query.showSignalMarkers === true;
}
if (query.showZones !== undefined) {
config.showZones = query.showZones === 'true' || query.showZones === true;
}
if (query.showPredictionBands !== undefined) {
config.showPredictionBands = query.showPredictionBands === 'true' || query.showPredictionBands === true;
}
if (query.showIndicators !== undefined) {
config.showIndicators = query.showIndicators === 'true' || query.showIndicators === true;
}
if (query.showAMDPhase !== undefined) {
config.showAMDPhase = query.showAMDPhase === 'true' || query.showAMDPhase === true;
}
return config;
}

View File

@ -1,301 +0,0 @@
/**
* ML Controller
* Handles ML Engine integration endpoints
*/
import { Request, Response, NextFunction } from 'express';
import {
mlIntegrationService,
TimeHorizon,
SignalType,
} from '../services/ml-integration.service';
// ============================================================================
// Types
// ============================================================================
// Use Request directly - user is already declared globally in auth.middleware.ts
type AuthRequest = Request;
// ============================================================================
// Health & Status
// ============================================================================
/**
* Get ML Engine health status
*/
export async function getHealth(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const health = await mlIntegrationService.getHealth();
res.json({
success: true,
data: health,
});
} catch (error) {
next(error);
}
}
/**
* Check ML Engine connection
*/
export async function checkConnection(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const isConnected = await mlIntegrationService.checkConnection();
res.json({
success: true,
data: { connected: isConnected },
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Signals
// ============================================================================
/**
* Get trading signal for a symbol
*/
export async function getSignal(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
const { timeHorizon = 'intraday' } = req.query;
const signal = await mlIntegrationService.getSignal(
symbol,
timeHorizon as TimeHorizon
);
res.json({
success: true,
data: signal,
});
} catch (error) {
next(error);
}
}
/**
* Get signals for multiple symbols
*/
export async function getSignals(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbols, timeHorizon = 'intraday' } = req.body;
if (!symbols || !Array.isArray(symbols) || symbols.length === 0) {
res.status(400).json({
success: false,
error: { message: 'Symbols array is required', code: 'VALIDATION_ERROR' },
});
return;
}
const signals = await mlIntegrationService.getSignals(
symbols,
timeHorizon as TimeHorizon
);
res.json({
success: true,
data: signals,
});
} catch (error) {
next(error);
}
}
/**
* Get historical signals
*/
export async function getHistoricalSignals(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
const { startTime, endTime, limit, signalType } = req.query;
const signals = await mlIntegrationService.getHistoricalSignals(symbol, {
startTime: startTime ? new Date(startTime as string) : undefined,
endTime: endTime ? new Date(endTime as string) : undefined,
limit: limit ? Number(limit) : undefined,
signalType: signalType as SignalType | undefined,
});
res.json({
success: true,
data: signals,
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Predictions
// ============================================================================
/**
* Get price prediction
*/
export async function getPrediction(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
const { horizonMinutes = 90 } = req.query;
const prediction = await mlIntegrationService.getPrediction(
symbol,
Number(horizonMinutes)
);
res.json({
success: true,
data: prediction,
});
} catch (error) {
next(error);
}
}
/**
* Get AMD phase prediction
*/
export async function getAMDPhase(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
const amdPhase = await mlIntegrationService.getAMDPhase(symbol);
res.json({
success: true,
data: amdPhase,
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Indicators
// ============================================================================
/**
* Get technical indicators
*/
export async function getIndicators(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
const indicators = await mlIntegrationService.getIndicators(symbol);
res.json({
success: true,
data: indicators,
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Backtesting
// ============================================================================
/**
* Run backtest
*/
export async function runBacktest(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { symbol, startDate, endDate, initialCapital, strategy, params } = req.body;
if (!symbol || !startDate || !endDate) {
res.status(400).json({
success: false,
error: { message: 'Symbol, startDate, and endDate are required', code: 'VALIDATION_ERROR' },
});
return;
}
const result = await mlIntegrationService.runBacktest(symbol, {
startDate: new Date(startDate),
endDate: new Date(endDate),
initialCapital,
strategy,
params,
});
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Models (Admin)
// ============================================================================
/**
* Get available models
*/
export async function getModels(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const models = await mlIntegrationService.getModels();
res.json({
success: true,
data: models,
});
} catch (error) {
next(error);
}
}
/**
* Trigger model retraining
*/
export async function triggerRetraining(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
// TODO: Add admin check
const { symbol } = req.body;
const result = await mlIntegrationService.triggerRetraining(symbol);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
}
/**
* Get retraining job status
*/
export async function getRetrainingStatus(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { jobId } = req.params;
const status = await mlIntegrationService.getRetrainingStatus(jobId);
res.json({
success: true,
data: status,
});
} catch (error) {
next(error);
}
}

View File

@ -1,168 +0,0 @@
/**
* ML Routes
* ML Engine integration endpoints
*/
import { Router, RequestHandler } from 'express';
import * as mlController from './controllers/ml.controller';
import * as mlOverlayController from './controllers/ml-overlay.controller';
const router = Router();
// Type cast helper for authenticated routes
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
const authHandler = (fn: Function): RequestHandler => fn as RequestHandler;
// ============================================================================
// Health & Status
// ============================================================================
/**
* GET /api/v1/ml/health
* Get ML Engine health status
*/
router.get('/health', mlController.getHealth);
/**
* GET /api/v1/ml/connection
* Check ML Engine connection
*/
router.get('/connection', mlController.checkConnection);
// ============================================================================
// Signals
// ============================================================================
/**
* GET /api/v1/ml/signals/:symbol
* Get trading signal for a symbol
* Query params: timeHorizon (scalp, intraday, swing)
*/
router.get('/signals/:symbol', mlController.getSignal);
/**
* POST /api/v1/ml/signals/batch
* Get signals for multiple symbols
* Body: { symbols: string[], timeHorizon?: string }
*/
router.post('/signals/batch', mlController.getSignals);
/**
* GET /api/v1/ml/signals/:symbol/history
* Get historical signals
* Query params: startTime, endTime, limit, signalType
*/
router.get('/signals/:symbol/history', mlController.getHistoricalSignals);
// ============================================================================
// Predictions
// ============================================================================
/**
* GET /api/v1/ml/predictions/:symbol
* Get price prediction
* Query params: horizonMinutes
*/
router.get('/predictions/:symbol', mlController.getPrediction);
/**
* GET /api/v1/ml/amd/:symbol
* Get AMD phase prediction
*/
router.get('/amd/:symbol', mlController.getAMDPhase);
// ============================================================================
// Indicators
// ============================================================================
/**
* GET /api/v1/ml/indicators/:symbol
* Get technical indicators
*/
router.get('/indicators/:symbol', mlController.getIndicators);
// ============================================================================
// Backtesting (Authenticated)
// ============================================================================
/**
* POST /api/v1/ml/backtest
* Run backtest
* Body: { symbol, startDate, endDate, initialCapital?, strategy?, params? }
*/
router.post('/backtest', authHandler(mlController.runBacktest));
// ============================================================================
// Models (Admin)
// ============================================================================
/**
* GET /api/v1/ml/models
* Get available models
*/
router.get('/models', mlController.getModels);
/**
* POST /api/v1/ml/models/retrain
* Trigger model retraining
* Body: { symbol?: string }
*/
router.post('/models/retrain', authHandler(mlController.triggerRetraining));
/**
* GET /api/v1/ml/models/retrain/:jobId
* Get retraining job status
*/
router.get('/models/retrain/:jobId', mlController.getRetrainingStatus);
// ============================================================================
// Chart Overlays
// ============================================================================
/**
* GET /api/v1/ml/overlays/:symbol
* Get complete chart overlay for a symbol
* Query params: showPriceLevels, showTrendLines, showSignalMarkers, showZones, showIndicators, showAMDPhase
*/
router.get('/overlays/:symbol', mlOverlayController.getChartOverlay);
/**
* POST /api/v1/ml/overlays/batch
* Get overlays for multiple symbols
* Body: { symbols: string[], ...config }
*/
router.post('/overlays/batch', mlOverlayController.getBatchOverlays);
/**
* GET /api/v1/ml/overlays/:symbol/levels
* Get price levels only
*/
router.get('/overlays/:symbol/levels', mlOverlayController.getPriceLevels);
/**
* GET /api/v1/ml/overlays/:symbol/signals
* Get signal markers
* Query params: limit
*/
router.get('/overlays/:symbol/signals', mlOverlayController.getSignalMarkers);
/**
* GET /api/v1/ml/overlays/:symbol/amd
* Get AMD phase overlay
*/
router.get('/overlays/:symbol/amd', mlOverlayController.getAMDPhaseOverlay);
/**
* GET /api/v1/ml/overlays/:symbol/predictions
* Get prediction bands
* Query params: horizon, intervals
*/
router.get('/overlays/:symbol/predictions', mlOverlayController.getPredictionBands);
/**
* DELETE /api/v1/ml/overlays/cache/:symbol?
* Clear overlay cache
*/
router.delete('/overlays/cache{/:symbol}', mlOverlayController.clearCache);
export { router as mlRouter };

View File

@ -1,538 +0,0 @@
/**
* ML Integration Service
* Connects to the FastAPI ML Engine for trading signals and predictions
*/
import axios, { AxiosInstance, AxiosError } from 'axios';
import { EventEmitter } from 'events';
// ============================================================================
// Types
// ============================================================================
export type SignalType = 'buy' | 'sell' | 'hold';
export type TimeHorizon = 'scalp' | 'intraday' | 'swing';
export type AMDPhase = 'accumulation' | 'manipulation' | 'distribution' | 'unknown';
export interface MLSignal {
id: string;
symbol: string;
timestamp: Date;
signalType: SignalType;
confidence: number;
timeHorizon: TimeHorizon;
amdPhase: AMDPhase;
indicators: MLIndicators;
prediction: MLPrediction;
reasoning: string;
expiresAt: Date;
}
export interface MLIndicators {
rsi: number;
macd: {
value: number;
signal: number;
histogram: number;
};
atr: number;
atrPercent: number;
volumeRatio: number;
ema20: number;
ema50: number;
ema200: number;
bollingerBands: {
upper: number;
middle: number;
lower: number;
width: number;
percentB: number;
};
supportLevels: number[];
resistanceLevels: number[];
}
export interface MLPrediction {
targetPrice: number;
expectedHigh: number;
expectedLow: number;
stopLoss: number;
takeProfit: number;
riskRewardRatio: number;
probabilityUp: number;
probabilityDown: number;
volatilityForecast: number;
}
export interface ModelHealth {
status: 'healthy' | 'degraded' | 'unhealthy';
lastTraining: Date;
accuracy: number;
precision: number;
recall: number;
f1Score: number;
totalPredictions: number;
correctPredictions: number;
uptime: number;
}
export interface BacktestResult {
symbol: string;
startDate: Date;
endDate: Date;
initialCapital: number;
finalCapital: number;
totalReturn: number;
annualizedReturn: number;
maxDrawdown: number;
sharpeRatio: number;
winRate: number;
totalTrades: number;
profitFactor: number;
trades: BacktestTrade[];
}
export interface BacktestTrade {
entryTime: Date;
exitTime: Date;
side: 'long' | 'short';
entryPrice: number;
exitPrice: number;
quantity: number;
pnl: number;
pnlPercent: number;
exitReason: 'take_profit' | 'stop_loss' | 'signal' | 'expiry';
}
export interface MLEngineConfig {
baseUrl: string;
apiKey?: string;
timeout: number;
retryAttempts: number;
retryDelay: number;
}
// ============================================================================
// Default Configuration
// ============================================================================
const DEFAULT_CONFIG: MLEngineConfig = {
baseUrl: process.env.ML_ENGINE_URL || 'http://localhost:8000',
apiKey: process.env.ML_ENGINE_API_KEY,
timeout: 30000,
retryAttempts: 3,
retryDelay: 1000,
};
// ============================================================================
// ML Integration Service
// ============================================================================
class MLIntegrationService extends EventEmitter {
private client: AxiosInstance;
private config: MLEngineConfig;
private isConnected: boolean = false;
private reconnectInterval: NodeJS.Timeout | null = null;
constructor(config: Partial<MLEngineConfig> = {}) {
super();
this.config = { ...DEFAULT_CONFIG, ...config };
this.client = axios.create({
baseURL: this.config.baseUrl,
timeout: this.config.timeout,
headers: {
'Content-Type': 'application/json',
...(this.config.apiKey && { 'X-API-Key': this.config.apiKey }),
},
});
// Add response interceptor for error handling
this.client.interceptors.response.use(
(response) => response,
(error: AxiosError) => this.handleError(error)
);
}
// ==========================================================================
// Connection Management
// ==========================================================================
/**
* Check ML Engine connection
*/
async checkConnection(): Promise<boolean> {
try {
const response = await this.client.get('/health');
this.isConnected = response.data.status === 'healthy';
this.emit('connectionStatus', this.isConnected);
return this.isConnected;
} catch {
this.isConnected = false;
this.emit('connectionStatus', false);
return false;
}
}
/**
* Get ML Engine health status
*/
async getHealth(): Promise<ModelHealth> {
const response = await this.client.get('/health');
return {
status: response.data.status,
lastTraining: new Date(response.data.last_training),
accuracy: response.data.accuracy,
precision: response.data.precision,
recall: response.data.recall,
f1Score: response.data.f1_score,
totalPredictions: response.data.total_predictions,
correctPredictions: response.data.correct_predictions,
uptime: response.data.uptime,
};
}
/**
* Start reconnection monitoring
*/
startReconnectMonitor(intervalMs: number = 30000): void {
if (this.reconnectInterval) return;
this.reconnectInterval = setInterval(async () => {
if (!this.isConnected) {
console.log('[MLService] Attempting to reconnect to ML Engine...');
await this.checkConnection();
}
}, intervalMs);
}
/**
* Stop reconnection monitoring
*/
stopReconnectMonitor(): void {
if (this.reconnectInterval) {
clearInterval(this.reconnectInterval);
this.reconnectInterval = null;
}
}
// ==========================================================================
// Signal Generation
// ==========================================================================
/**
* Get current trading signal for a symbol
*/
async getSignal(symbol: string, timeHorizon: TimeHorizon = 'intraday'): Promise<MLSignal> {
const response = await this.client.get(`/signals/${symbol}`, {
params: { time_horizon: timeHorizon },
});
return this.transformSignal(response.data);
}
/**
* Get signals for multiple symbols
*/
async getSignals(
symbols: string[],
timeHorizon: TimeHorizon = 'intraday'
): Promise<MLSignal[]> {
const response = await this.client.post('/signals/batch', {
symbols,
time_horizon: timeHorizon,
});
return response.data.signals.map(this.transformSignal);
}
/**
* Get historical signals
*/
async getHistoricalSignals(
symbol: string,
options: {
startTime?: Date;
endTime?: Date;
limit?: number;
signalType?: SignalType;
} = {}
): Promise<MLSignal[]> {
const response = await this.client.get(`/signals/${symbol}/history`, {
params: {
start_time: options.startTime?.toISOString(),
end_time: options.endTime?.toISOString(),
limit: options.limit || 100,
signal_type: options.signalType,
},
});
return response.data.signals.map(this.transformSignal);
}
// ==========================================================================
// Predictions
// ==========================================================================
/**
* Get price prediction for a symbol
*/
async getPrediction(
symbol: string,
horizonMinutes: number = 90
): Promise<MLPrediction> {
const response = await this.client.get(`/predictions/${symbol}`, {
params: { horizon_minutes: horizonMinutes },
});
return {
targetPrice: response.data.target_price,
expectedHigh: response.data.expected_high,
expectedLow: response.data.expected_low,
stopLoss: response.data.stop_loss,
takeProfit: response.data.take_profit,
riskRewardRatio: response.data.risk_reward_ratio,
probabilityUp: response.data.probability_up,
probabilityDown: response.data.probability_down,
volatilityForecast: response.data.volatility_forecast,
};
}
/**
* Get AMD phase prediction
*/
async getAMDPhase(symbol: string): Promise<{
phase: AMDPhase;
confidence: number;
expectedDuration: number;
nextPhase: AMDPhase;
}> {
const response = await this.client.get(`/amd/${symbol}`);
return {
phase: response.data.phase,
confidence: response.data.confidence,
expectedDuration: response.data.expected_duration,
nextPhase: response.data.next_phase,
};
}
// ==========================================================================
// Indicators
// ==========================================================================
/**
* Get technical indicators for a symbol
*/
async getIndicators(symbol: string): Promise<MLIndicators> {
const response = await this.client.get(`/indicators/${symbol}`);
return {
rsi: response.data.rsi,
macd: {
value: response.data.macd.value,
signal: response.data.macd.signal,
histogram: response.data.macd.histogram,
},
atr: response.data.atr,
atrPercent: response.data.atr_percent,
volumeRatio: response.data.volume_ratio,
ema20: response.data.ema_20,
ema50: response.data.ema_50,
ema200: response.data.ema_200,
bollingerBands: {
upper: response.data.bollinger.upper,
middle: response.data.bollinger.middle,
lower: response.data.bollinger.lower,
width: response.data.bollinger.width,
percentB: response.data.bollinger.percent_b,
},
supportLevels: response.data.support_levels,
resistanceLevels: response.data.resistance_levels,
};
}
// ==========================================================================
// Backtesting
// ==========================================================================
/**
* Run backtest for a strategy
*/
async runBacktest(
symbol: string,
options: {
startDate: Date;
endDate: Date;
initialCapital?: number;
strategy?: string;
params?: Record<string, unknown>;
}
): Promise<BacktestResult> {
const response = await this.client.post('/backtest', {
symbol,
start_date: options.startDate.toISOString(),
end_date: options.endDate.toISOString(),
initial_capital: options.initialCapital || 10000,
strategy: options.strategy || 'default',
params: options.params || {},
});
return {
symbol: response.data.symbol,
startDate: new Date(response.data.start_date),
endDate: new Date(response.data.end_date),
initialCapital: response.data.initial_capital,
finalCapital: response.data.final_capital,
totalReturn: response.data.total_return,
annualizedReturn: response.data.annualized_return,
maxDrawdown: response.data.max_drawdown,
sharpeRatio: response.data.sharpe_ratio,
winRate: response.data.win_rate,
totalTrades: response.data.total_trades,
profitFactor: response.data.profit_factor,
trades: response.data.trades.map((t: Record<string, unknown>) => ({
entryTime: new Date(t.entry_time as string),
exitTime: new Date(t.exit_time as string),
side: t.side,
entryPrice: t.entry_price,
exitPrice: t.exit_price,
quantity: t.quantity,
pnl: t.pnl,
pnlPercent: t.pnl_percent,
exitReason: t.exit_reason,
})),
};
}
// ==========================================================================
// Model Management
// ==========================================================================
/**
* Trigger model retraining
*/
async triggerRetraining(symbol?: string): Promise<{ jobId: string }> {
const response = await this.client.post('/models/retrain', {
symbol,
});
return {
jobId: response.data.job_id,
};
}
/**
* Get retraining job status
*/
async getRetrainingStatus(jobId: string): Promise<{
status: 'pending' | 'running' | 'completed' | 'failed';
progress: number;
message: string;
}> {
const response = await this.client.get(`/models/retrain/${jobId}`);
return {
status: response.data.status,
progress: response.data.progress,
message: response.data.message,
};
}
/**
* Get available models
*/
async getModels(): Promise<
{
id: string;
symbol: string;
version: string;
accuracy: number;
createdAt: Date;
isActive: boolean;
}[]
> {
const response = await this.client.get('/models');
return response.data.models.map((m: Record<string, unknown>) => ({
id: m.id,
symbol: m.symbol,
version: m.version,
accuracy: m.accuracy,
createdAt: new Date(m.created_at as string),
isActive: m.is_active,
}));
}
// ==========================================================================
// Private Methods
// ==========================================================================
private transformSignal(data: Record<string, unknown>): MLSignal {
return {
id: data.id as string,
symbol: data.symbol as string,
timestamp: new Date(data.timestamp as string),
signalType: data.signal_type as SignalType,
confidence: data.confidence as number,
timeHorizon: data.time_horizon as TimeHorizon,
amdPhase: data.amd_phase as AMDPhase,
indicators: data.indicators as MLIndicators,
prediction: data.prediction as MLPrediction,
reasoning: data.reasoning as string,
expiresAt: new Date(data.expires_at as string),
};
}
private async handleError(error: AxiosError): Promise<never> {
if (!error.response) {
// Network error
this.isConnected = false;
this.emit('connectionStatus', false);
throw new Error('ML Engine is not reachable');
}
const status = error.response.status;
const message = (error.response.data as Record<string, unknown>)?.detail || error.message;
if (status === 429) {
throw new Error('Rate limit exceeded on ML Engine');
}
if (status === 503) {
this.isConnected = false;
this.emit('connectionStatus', false);
throw new Error('ML Engine is temporarily unavailable');
}
throw new Error(`ML Engine error: ${message}`);
}
/**
* Retry a request with exponential backoff
*/
private async retryRequest<T>(
request: () => Promise<T>,
attempts: number = this.config.retryAttempts
): Promise<T> {
let lastError: Error | undefined;
for (let i = 0; i < attempts; i++) {
try {
return await request();
} catch (error) {
lastError = error as Error;
if (i < attempts - 1) {
await new Promise((resolve) =>
setTimeout(resolve, this.config.retryDelay * Math.pow(2, i))
);
}
}
}
throw lastError;
}
}
// Export singleton instance
export const mlIntegrationService = new MLIntegrationService();

View File

@ -1,517 +0,0 @@
/**
* ML Overlay Service
* Provides chart overlay data from ML predictions for trading visualization
*/
import { mlIntegrationService, MLSignal, MLIndicators, AMDPhase } from './ml-integration.service';
// ============================================================================
// Types
// ============================================================================
export interface PriceLevel {
price: number;
type: 'support' | 'resistance' | 'target' | 'stop_loss' | 'entry';
strength: 'weak' | 'moderate' | 'strong';
label?: string;
}
export interface TrendLine {
startTime: Date;
endTime: Date;
startPrice: number;
endPrice: number;
type: 'support' | 'resistance' | 'channel_upper' | 'channel_lower';
confidence: number;
}
export interface SignalMarker {
time: Date;
price: number;
type: 'buy' | 'sell' | 'hold';
confidence: number;
label: string;
expired: boolean;
}
export interface ZoneOverlay {
priceHigh: number;
priceLow: number;
type: 'accumulation' | 'distribution' | 'liquidity' | 'demand' | 'supply';
startTime: Date;
endTime?: Date;
active: boolean;
label: string;
}
export interface PredictionBand {
time: Date;
upperBound: number;
lowerBound: number;
expectedPrice: number;
confidence: number;
}
export interface ChartOverlay {
symbol: string;
timestamp: Date;
priceLevels: PriceLevel[];
trendLines: TrendLine[];
signalMarkers: SignalMarker[];
zones: ZoneOverlay[];
predictionBands: PredictionBand[];
indicators: IndicatorOverlay;
amdPhase: AMDPhaseOverlay;
}
export interface IndicatorOverlay {
ema: {
ema20: number[];
ema50: number[];
ema200: number[];
};
bollingerBands: {
upper: number[];
middle: number[];
lower: number[];
};
rsi: number[];
macd: {
macdLine: number[];
signalLine: number[];
histogram: number[];
};
volume: {
bars: number[];
average: number;
ratio: number;
};
}
export interface AMDPhaseOverlay {
currentPhase: AMDPhase;
phaseProgress: number;
phaseStartTime: Date;
expectedPhaseDuration: number;
confidence: number;
markers: {
time: Date;
phase: AMDPhase;
label: string;
}[];
}
export interface OverlayConfig {
showPriceLevels: boolean;
showTrendLines: boolean;
showSignalMarkers: boolean;
showZones: boolean;
showPredictionBands: boolean;
showIndicators: boolean;
showAMDPhase: boolean;
indicatorConfig: {
ema: boolean;
bollingerBands: boolean;
rsi: boolean;
macd: boolean;
volume: boolean;
};
}
const DEFAULT_OVERLAY_CONFIG: OverlayConfig = {
showPriceLevels: true,
showTrendLines: true,
showSignalMarkers: true,
showZones: true,
showPredictionBands: false,
showIndicators: true,
showAMDPhase: true,
indicatorConfig: {
ema: true,
bollingerBands: true,
rsi: true,
macd: true,
volume: true,
},
};
// ============================================================================
// ML Overlay Service
// ============================================================================
class MLOverlayService {
private overlayCache: Map<string, { overlay: ChartOverlay; expiresAt: Date }> = new Map();
private readonly CACHE_TTL_MS = 30000; // 30 seconds
/**
* Get complete chart overlay for a symbol
*/
async getChartOverlay(
symbol: string,
config: Partial<OverlayConfig> = {}
): Promise<ChartOverlay> {
const mergedConfig = { ...DEFAULT_OVERLAY_CONFIG, ...config };
// Check cache
const cached = this.overlayCache.get(symbol);
if (cached && cached.expiresAt > new Date()) {
return cached.overlay;
}
// Fetch fresh data
const [signal, indicators, amdPhase] = await Promise.all([
mlIntegrationService.getSignal(symbol),
mlIntegrationService.getIndicators(symbol),
mlIntegrationService.getAMDPhase(symbol),
]);
const overlay = this.buildOverlay(symbol, signal, indicators, amdPhase, mergedConfig);
// Cache the overlay
this.overlayCache.set(symbol, {
overlay,
expiresAt: new Date(Date.now() + this.CACHE_TTL_MS),
});
return overlay;
}
/**
* Get overlays for multiple symbols
*/
async getBatchOverlays(
symbols: string[],
config: Partial<OverlayConfig> = {}
): Promise<Map<string, ChartOverlay>> {
const results = new Map<string, ChartOverlay>();
await Promise.all(
symbols.map(async (symbol) => {
try {
const overlay = await this.getChartOverlay(symbol, config);
results.set(symbol, overlay);
} catch (error) {
console.error(`[MLOverlay] Failed to get overlay for ${symbol}:`, error);
}
})
);
return results;
}
/**
* Get price levels overlay only
*/
async getPriceLevels(symbol: string): Promise<PriceLevel[]> {
const signal = await mlIntegrationService.getSignal(symbol);
return this.buildPriceLevels(signal);
}
/**
* Get signal markers overlay only
*/
async getSignalMarkers(symbol: string, limit: number = 20): Promise<SignalMarker[]> {
const signals = await mlIntegrationService.getHistoricalSignals(symbol, { limit });
return this.buildSignalMarkers(signals);
}
/**
* Get AMD phase overlay only
*/
async getAMDPhaseOverlay(symbol: string): Promise<AMDPhaseOverlay> {
const amdData = await mlIntegrationService.getAMDPhase(symbol);
return this.buildAMDPhaseOverlay(amdData);
}
/**
* Get prediction bands for a symbol
*/
async getPredictionBands(
symbol: string,
horizonMinutes: number = 90,
intervals: number = 10
): Promise<PredictionBand[]> {
const prediction = await mlIntegrationService.getPrediction(symbol, horizonMinutes);
return this.buildPredictionBands(prediction, horizonMinutes, intervals);
}
/**
* Clear cache for a symbol
*/
clearCache(symbol?: string): void {
if (symbol) {
this.overlayCache.delete(symbol);
} else {
this.overlayCache.clear();
}
}
// ==========================================================================
// Private Helper Methods
// ==========================================================================
private buildOverlay(
symbol: string,
signal: MLSignal,
indicators: MLIndicators,
amdData: { phase: AMDPhase; confidence: number },
config: OverlayConfig
): ChartOverlay {
const now = new Date();
return {
symbol,
timestamp: now,
priceLevels: config.showPriceLevels ? this.buildPriceLevels(signal) : [],
trendLines: config.showTrendLines ? this.buildTrendLines(indicators) : [],
signalMarkers: config.showSignalMarkers ? this.buildCurrentSignalMarker(signal) : [],
zones: config.showZones ? this.buildZones(signal, amdData) : [],
predictionBands: [], // Only populated on explicit request
indicators: config.showIndicators ? this.buildIndicatorOverlay(indicators, config) : this.emptyIndicatorOverlay(),
amdPhase: config.showAMDPhase ? this.buildAMDPhaseOverlay(amdData) : this.emptyAMDPhaseOverlay(),
};
}
private buildPriceLevels(signal: MLSignal): PriceLevel[] {
const levels: PriceLevel[] = [];
// Support levels
signal.indicators.supportLevels.forEach((price, index) => {
levels.push({
price,
type: 'support',
strength: index === 0 ? 'strong' : index === 1 ? 'moderate' : 'weak',
label: `S${index + 1}`,
});
});
// Resistance levels
signal.indicators.resistanceLevels.forEach((price, index) => {
levels.push({
price,
type: 'resistance',
strength: index === 0 ? 'strong' : index === 1 ? 'moderate' : 'weak',
label: `R${index + 1}`,
});
});
// Target and stop loss
if (signal.prediction.targetPrice) {
levels.push({
price: signal.prediction.targetPrice,
type: 'target',
strength: 'strong',
label: 'Target',
});
}
if (signal.prediction.stopLoss) {
levels.push({
price: signal.prediction.stopLoss,
type: 'stop_loss',
strength: 'strong',
label: 'Stop Loss',
});
}
if (signal.prediction.takeProfit) {
levels.push({
price: signal.prediction.takeProfit,
type: 'target',
strength: 'moderate',
label: 'Take Profit',
});
}
return levels;
}
private buildTrendLines(indicators: MLIndicators): TrendLine[] {
const lines: TrendLine[] = [];
const now = new Date();
const hourAgo = new Date(now.getTime() - 3600000);
// Bollinger band channels
lines.push({
startTime: hourAgo,
endTime: now,
startPrice: indicators.bollingerBands.upper,
endPrice: indicators.bollingerBands.upper,
type: 'channel_upper',
confidence: 0.8,
});
lines.push({
startTime: hourAgo,
endTime: now,
startPrice: indicators.bollingerBands.lower,
endPrice: indicators.bollingerBands.lower,
type: 'channel_lower',
confidence: 0.8,
});
return lines;
}
private buildCurrentSignalMarker(signal: MLSignal): SignalMarker[] {
return [
{
time: signal.timestamp,
price: signal.prediction.targetPrice,
type: signal.signalType,
confidence: signal.confidence,
label: `${signal.signalType.toUpperCase()} (${Math.round(signal.confidence * 100)}%)`,
expired: signal.expiresAt < new Date(),
},
];
}
private buildSignalMarkers(signals: MLSignal[]): SignalMarker[] {
const now = new Date();
return signals.map((signal) => ({
time: signal.timestamp,
price: signal.prediction.targetPrice,
type: signal.signalType,
confidence: signal.confidence,
label: `${signal.signalType.toUpperCase()}`,
expired: signal.expiresAt < now,
}));
}
private buildZones(
signal: MLSignal,
amdData: { phase: AMDPhase; confidence: number }
): ZoneOverlay[] {
const zones: ZoneOverlay[] = [];
const now = new Date();
// Create zone based on current AMD phase
if (amdData.phase !== 'unknown') {
const zoneType = amdData.phase === 'accumulation'
? 'demand'
: amdData.phase === 'distribution'
? 'supply'
: 'liquidity';
zones.push({
priceHigh: signal.indicators.resistanceLevels[0] || signal.prediction.expectedHigh,
priceLow: signal.indicators.supportLevels[0] || signal.prediction.expectedLow,
type: zoneType,
startTime: new Date(now.getTime() - 3600000),
active: true,
label: `${amdData.phase.charAt(0).toUpperCase() + amdData.phase.slice(1)} Zone`,
});
}
return zones;
}
private buildAMDPhaseOverlay(amdData: { phase: AMDPhase; confidence: number; phaseStartTime?: Date }): AMDPhaseOverlay {
const now = new Date();
const phaseStart = amdData.phaseStartTime || new Date(now.getTime() - 1800000);
return {
currentPhase: amdData.phase,
phaseProgress: 0.5, // Would be calculated from actual data
phaseStartTime: phaseStart,
expectedPhaseDuration: 3600000, // 1 hour default
confidence: amdData.confidence,
markers: [
{
time: phaseStart,
phase: amdData.phase,
label: `${amdData.phase.charAt(0).toUpperCase() + amdData.phase.slice(1)} Start`,
},
],
};
}
private buildPredictionBands(
prediction: { expectedHigh: number; expectedLow: number; targetPrice: number },
horizonMinutes: number,
intervals: number
): PredictionBand[] {
const bands: PredictionBand[] = [];
const now = new Date();
const intervalMs = (horizonMinutes * 60000) / intervals;
for (let i = 0; i <= intervals; i++) {
const time = new Date(now.getTime() + i * intervalMs);
const progress = i / intervals;
// Expand uncertainty over time
const expansion = 1 + progress * 0.5;
const range = prediction.expectedHigh - prediction.expectedLow;
bands.push({
time,
upperBound: prediction.targetPrice + (range / 2) * expansion,
lowerBound: prediction.targetPrice - (range / 2) * expansion,
expectedPrice: prediction.targetPrice,
confidence: Math.max(0.3, 1 - progress * 0.5),
});
}
return bands;
}
private buildIndicatorOverlay(indicators: MLIndicators, config: OverlayConfig): IndicatorOverlay {
const ic = config.indicatorConfig;
return {
ema: ic.ema
? {
ema20: [indicators.ema20],
ema50: [indicators.ema50],
ema200: [indicators.ema200],
}
: { ema20: [], ema50: [], ema200: [] },
bollingerBands: ic.bollingerBands
? {
upper: [indicators.bollingerBands.upper],
middle: [indicators.bollingerBands.middle],
lower: [indicators.bollingerBands.lower],
}
: { upper: [], middle: [], lower: [] },
rsi: ic.rsi ? [indicators.rsi] : [],
macd: ic.macd
? {
macdLine: [indicators.macd.value],
signalLine: [indicators.macd.signal],
histogram: [indicators.macd.histogram],
}
: { macdLine: [], signalLine: [], histogram: [] },
volume: ic.volume
? {
bars: [],
average: 0,
ratio: indicators.volumeRatio,
}
: { bars: [], average: 0, ratio: 0 },
};
}
private emptyIndicatorOverlay(): IndicatorOverlay {
return {
ema: { ema20: [], ema50: [], ema200: [] },
bollingerBands: { upper: [], middle: [], lower: [] },
rsi: [],
macd: { macdLine: [], signalLine: [], histogram: [] },
volume: { bars: [], average: 0, ratio: 0 },
};
}
private emptyAMDPhaseOverlay(): AMDPhaseOverlay {
return {
currentPhase: 'unknown',
phaseProgress: 0,
phaseStartTime: new Date(),
expectedPhaseDuration: 0,
confidence: 0,
markers: [],
};
}
}
// Export singleton instance
export const mlOverlayService = new MLOverlayService();

View File

@ -1,489 +0,0 @@
/**
* Payments Controller
* Handles all payment-related endpoints
*/
import type { Request, Response, NextFunction } from 'express';
import { stripeService } from '../services/stripe.service';
import { walletService } from '../services/wallet.service';
import { subscriptionService } from '../services/subscription.service';
import { enrollmentService } from '../../education/services/enrollment.service';
import { logger } from '../../../shared/utils/logger';
import type { AuthenticatedRequest } from '../../../core/guards/auth.guard';
// ============================================================================
// Plans
// ============================================================================
export async function getPlans(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const plans = await subscriptionService.getPlans();
res.json({ success: true, data: plans });
} catch (error) {
next(error);
}
}
export async function getPlanBySlug(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { slug } = req.params;
const plan = await subscriptionService.getPlanBySlug(slug);
if (!plan) {
res.status(404).json({ success: false, error: 'Plan not found' });
return;
}
res.json({ success: true, data: plan });
} catch (error) {
next(error);
}
}
// ============================================================================
// Subscriptions
// ============================================================================
export async function getMySubscription(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const subscription = await subscriptionService.getSubscriptionByUserId(authReq.user.id);
res.json({ success: true, data: subscription });
} catch (error) {
next(error);
}
}
export async function getSubscriptionHistory(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const subscriptions = await subscriptionService.getSubscriptionHistory(authReq.user.id);
res.json({ success: true, data: subscriptions });
} catch (error) {
next(error);
}
}
export async function cancelSubscription(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { immediately, reason } = req.body;
const subscription = await subscriptionService.cancelSubscription(
authReq.user.id,
immediately === true,
reason
);
res.json({ success: true, data: subscription });
} catch (error) {
next(error);
}
}
export async function resumeSubscription(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const subscription = await subscriptionService.resumeSubscription(authReq.user.id);
res.json({ success: true, data: subscription });
} catch (error) {
next(error);
}
}
export async function changePlan(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { planId, billingCycle } = req.body;
if (!planId) {
res.status(400).json({ success: false, error: 'Plan ID is required' });
return;
}
const subscription = await subscriptionService.changePlan(
authReq.user.id,
planId,
billingCycle
);
res.json({ success: true, data: subscription });
} catch (error) {
next(error);
}
}
export async function getMyPlanFeatures(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const features = await subscriptionService.getUserPlanFeatures(authReq.user.id);
res.json({ success: true, data: features });
} catch (error) {
next(error);
}
}
// ============================================================================
// Checkout & Billing Portal
// ============================================================================
export async function createCheckoutSession(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { planId, courseId, billingCycle, successUrl, cancelUrl, promoCode } = req.body;
if (!successUrl || !cancelUrl) {
res.status(400).json({ success: false, error: 'Success and cancel URLs are required' });
return;
}
if (!planId && !courseId) {
res.status(400).json({ success: false, error: 'Plan ID or course ID is required' });
return;
}
const session = await stripeService.createCheckoutSession({
userId: authReq.user.id,
planId,
courseId,
billingCycle,
successUrl,
cancelUrl,
promoCode,
});
res.json({ success: true, data: session });
} catch (error) {
next(error);
}
}
export async function createBillingPortalSession(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { returnUrl } = req.body;
if (!returnUrl) {
res.status(400).json({ success: false, error: 'Return URL is required' });
return;
}
const session = await stripeService.createBillingPortalSession(authReq.user.id, returnUrl);
res.json({ success: true, data: session });
} catch (error) {
next(error);
}
}
// ============================================================================
// Payment Methods
// ============================================================================
export async function getPaymentMethods(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const methods = await stripeService.listPaymentMethods(authReq.user.id);
res.json({ success: true, data: methods });
} catch (error) {
next(error);
}
}
export async function attachPaymentMethod(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { paymentMethodId } = req.body;
if (!paymentMethodId) {
res.status(400).json({ success: false, error: 'Payment method ID is required' });
return;
}
const method = await stripeService.attachPaymentMethod(authReq.user.id, paymentMethodId);
res.json({ success: true, data: method });
} catch (error) {
next(error);
}
}
export async function detachPaymentMethod(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { paymentMethodId } = req.params;
await stripeService.detachPaymentMethod(paymentMethodId);
res.json({ success: true, message: 'Payment method detached' });
} catch (error) {
next(error);
}
}
export async function setDefaultPaymentMethod(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { paymentMethodId } = req.body;
if (!paymentMethodId) {
res.status(400).json({ success: false, error: 'Payment method ID is required' });
return;
}
await stripeService.updateDefaultPaymentMethod(authReq.user.id, paymentMethodId);
res.json({ success: true, message: 'Default payment method updated' });
} catch (error) {
next(error);
}
}
// ============================================================================
// Wallet
// ============================================================================
export async function getMyWallet(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const currency = (req.query.currency as string) || 'USD';
const wallet = await walletService.getOrCreateWallet(authReq.user.id, currency);
res.json({ success: true, data: wallet });
} catch (error) {
next(error);
}
}
export async function getMyWallets(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const wallets = await walletService.getUserWallets(authReq.user.id);
res.json({ success: true, data: wallets });
} catch (error) {
next(error);
}
}
export async function getWalletTransactions(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const {
walletId,
transactionType,
status,
startDate,
endDate,
page = '1',
pageSize = '20',
} = req.query;
const limit = Math.min(parseInt(pageSize as string, 10) || 20, 100);
const offset = (parseInt(page as string, 10) - 1) * limit;
const result = await walletService.getTransactions(authReq.user.id, {
walletId: walletId as string | undefined,
transactionType: transactionType as 'deposit' | 'withdrawal' | 'fee' | 'refund' | undefined,
status: status as 'pending' | 'processing' | 'failed' | 'cancelled' | undefined,
startDate: startDate ? new Date(startDate as string) : undefined,
endDate: endDate ? new Date(endDate as string) : undefined,
limit,
offset,
});
res.json({
success: true,
data: result.transactions,
meta: {
total: result.total,
page: parseInt(page as string, 10),
pageSize: limit,
totalPages: Math.ceil(result.total / limit),
},
});
} catch (error) {
next(error);
}
}
export async function getWalletStats(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const currency = (req.query.currency as string) || 'USD';
const stats = await walletService.getWalletStats(authReq.user.id, currency);
res.json({ success: true, data: stats });
} catch (error) {
next(error);
}
}
export async function createDeposit(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { amount, currency, description } = req.body;
if (!amount || amount <= 0) {
res.status(400).json({ success: false, error: 'Valid amount is required' });
return;
}
const transaction = await walletService.createDeposit({
userId: authReq.user.id,
amount,
currency,
description,
});
res.json({ success: true, data: transaction });
} catch (error) {
next(error);
}
}
export async function createWithdrawal(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { amount, currency, payoutMethod, destinationDetails } = req.body;
if (!amount || amount <= 0) {
res.status(400).json({ success: false, error: 'Valid amount is required' });
return;
}
if (!payoutMethod || !destinationDetails) {
res.status(400).json({ success: false, error: 'Payout method and destination details are required' });
return;
}
const transaction = await walletService.createWithdrawal({
userId: authReq.user.id,
amount,
currency,
payoutMethod,
destinationDetails,
});
res.json({ success: true, data: transaction });
} catch (error) {
next(error);
}
}
// ============================================================================
// Invoices
// ============================================================================
export async function getInvoices(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const limit = parseInt(req.query.limit as string, 10) || 10;
const customer = await stripeService.getCustomerByUserId(authReq.user.id);
if (!customer) {
res.json({ success: true, data: [] });
return;
}
const invoices = await stripeService.listInvoices(customer.stripeCustomerId, limit);
res.json({ success: true, data: invoices });
} catch (error) {
next(error);
}
}
// ============================================================================
// Webhooks
// ============================================================================
export async function handleStripeWebhook(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const signature = req.headers['stripe-signature'] as string;
if (!signature) {
res.status(400).json({ success: false, error: 'Missing signature' });
return;
}
const event = stripeService.constructWebhookEvent(req.body, signature);
logger.info('[WebhookController] Stripe event received:', { type: event.type });
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as unknown as Record<string, unknown>;
await handleCheckoutComplete(session);
break;
}
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object as unknown as Record<string, unknown>;
await handleSubscriptionUpdate(subscription);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as unknown as Record<string, unknown>;
await subscriptionService.updateSubscriptionFromStripe(subscription.id as string, {
status: 'cancelled',
cancelledAt: new Date(),
});
break;
}
case 'invoice.paid': {
const invoice = event.data.object as unknown as Record<string, unknown>;
logger.info('[WebhookController] Invoice paid:', { invoiceId: invoice.id });
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as unknown as Record<string, unknown>;
logger.warn('[WebhookController] Invoice payment failed:', { invoiceId: invoice.id });
// Could trigger email notification here
break;
}
default:
logger.debug('[WebhookController] Unhandled event type:', { type: event.type });
}
res.json({ received: true });
} catch (error) {
logger.error('[WebhookController] Webhook error:', error);
next(error);
}
}
async function handleCheckoutComplete(session: Record<string, unknown>): Promise<void> {
const metadata = session.metadata as Record<string, unknown> | undefined;
const { userId, planId, courseId, billingCycle } = metadata || {};
if (planId) {
// Create subscription
await subscriptionService.createSubscription({
userId: userId as string,
planId: planId as string,
billingCycle: ((billingCycle as string) || 'monthly') as 'monthly' | 'yearly',
});
}
if (courseId) {
// Create course enrollment
await enrollmentService.createEnrollment({
userId: userId as string,
courseId: courseId as string,
paymentId: session.payment_intent as string,
});
}
}
async function handleSubscriptionUpdate(subscription: Record<string, unknown>): Promise<void> {
const statusMap: Record<string, string> = {
'active': 'active',
'trialing': 'trialing',
'past_due': 'past_due',
'canceled': 'cancelled',
'unpaid': 'unpaid',
'paused': 'paused',
};
await subscriptionService.updateSubscriptionFromStripe(subscription.id as string, {
status: (statusMap[subscription.status as string] || 'active') as 'active' | 'trialing' | 'past_due' | 'cancelled' | 'unpaid' | 'paused',
currentPeriodStart: new Date((subscription.current_period_start as number) * 1000),
currentPeriodEnd: new Date((subscription.current_period_end as number) * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end as boolean,
});
}

View File

@ -1,189 +0,0 @@
/**
* Payments Routes
* Subscription, billing, wallet, and payment management
*/
import { Router, RequestHandler, raw } from 'express';
import * as paymentsController from './controllers/payments.controller';
import { requireAuth } from '../../core/guards/auth.guard';
const router = Router();
// Type cast helper for authenticated routes
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
const authHandler = (fn: Function): RequestHandler => fn as RequestHandler;
// ============================================================================
// Public Routes
// ============================================================================
/**
* GET /api/v1/payments/plans
* Get all available subscription plans
*/
router.get('/plans', paymentsController.getPlans);
/**
* GET /api/v1/payments/plans/:slug
* Get plan details by slug
*/
router.get('/plans/:slug', paymentsController.getPlanBySlug);
// ============================================================================
// Subscription Routes (Authenticated)
// ============================================================================
/**
* GET /api/v1/payments/subscription
* Get current user's subscription with plan details
*/
router.get('/subscription', authHandler(requireAuth), authHandler(paymentsController.getMySubscription));
/**
* GET /api/v1/payments/subscription/history
* Get user's subscription history
*/
router.get('/subscription/history', authHandler(requireAuth), authHandler(paymentsController.getSubscriptionHistory));
/**
* GET /api/v1/payments/subscription/features
* Get user's current plan features
*/
router.get('/subscription/features', authHandler(requireAuth), authHandler(paymentsController.getMyPlanFeatures));
/**
* POST /api/v1/payments/subscription/cancel
* Cancel current subscription
* Body: { immediately?: boolean, reason?: string }
*/
router.post('/subscription/cancel', authHandler(requireAuth), authHandler(paymentsController.cancelSubscription));
/**
* POST /api/v1/payments/subscription/resume
* Resume a cancelled subscription (before period end)
*/
router.post('/subscription/resume', authHandler(requireAuth), authHandler(paymentsController.resumeSubscription));
/**
* POST /api/v1/payments/subscription/change-plan
* Change subscription plan
* Body: { planId: string, billingCycle?: 'monthly' | 'yearly' }
*/
router.post('/subscription/change-plan', authHandler(requireAuth), authHandler(paymentsController.changePlan));
// ============================================================================
// Checkout & Billing Portal Routes
// ============================================================================
/**
* POST /api/v1/payments/checkout
* Create a Stripe checkout session
* Body: { planId?: string, courseId?: string, billingCycle?: string, successUrl: string, cancelUrl: string, promoCode?: string }
*/
router.post('/checkout', authHandler(requireAuth), authHandler(paymentsController.createCheckoutSession));
/**
* POST /api/v1/payments/billing-portal
* Create a Stripe billing portal session
* Body: { returnUrl: string }
*/
router.post('/billing-portal', authHandler(requireAuth), authHandler(paymentsController.createBillingPortalSession));
// ============================================================================
// Payment Methods Routes
// ============================================================================
/**
* GET /api/v1/payments/methods
* Get user's saved payment methods
*/
router.get('/methods', authHandler(requireAuth), authHandler(paymentsController.getPaymentMethods));
/**
* POST /api/v1/payments/methods
* Attach a payment method to user
* Body: { paymentMethodId: string }
*/
router.post('/methods', authHandler(requireAuth), authHandler(paymentsController.attachPaymentMethod));
/**
* DELETE /api/v1/payments/methods/:paymentMethodId
* Detach a payment method
*/
router.delete('/methods/:paymentMethodId', authHandler(requireAuth), authHandler(paymentsController.detachPaymentMethod));
/**
* POST /api/v1/payments/methods/default
* Set default payment method
* Body: { paymentMethodId: string }
*/
router.post('/methods/default', authHandler(requireAuth), authHandler(paymentsController.setDefaultPaymentMethod));
// ============================================================================
// Wallet Routes
// ============================================================================
/**
* GET /api/v1/payments/wallet
* Get user's wallet (creates if doesn't exist)
* Query: { currency?: string }
*/
router.get('/wallet', authHandler(requireAuth), authHandler(paymentsController.getMyWallet));
/**
* GET /api/v1/payments/wallets
* Get all user's wallets
*/
router.get('/wallets', authHandler(requireAuth), authHandler(paymentsController.getMyWallets));
/**
* GET /api/v1/payments/wallet/transactions
* Get wallet transactions
* Query: { walletId?, transactionType?, status?, startDate?, endDate?, page?, pageSize? }
*/
router.get('/wallet/transactions', authHandler(requireAuth), authHandler(paymentsController.getWalletTransactions));
/**
* GET /api/v1/payments/wallet/stats
* Get wallet statistics
* Query: { currency?: string }
*/
router.get('/wallet/stats', authHandler(requireAuth), authHandler(paymentsController.getWalletStats));
/**
* POST /api/v1/payments/wallet/deposit
* Create a deposit request
* Body: { amount: number, currency?: string, description?: string }
*/
router.post('/wallet/deposit', authHandler(requireAuth), authHandler(paymentsController.createDeposit));
/**
* POST /api/v1/payments/wallet/withdraw
* Create a withdrawal request
* Body: { amount: number, currency?: string, payoutMethod: string, destinationDetails: object }
*/
router.post('/wallet/withdraw', authHandler(requireAuth), authHandler(paymentsController.createWithdrawal));
// ============================================================================
// Invoice Routes
// ============================================================================
/**
* GET /api/v1/payments/invoices
* Get user's invoices from Stripe
* Query: { limit?: number }
*/
router.get('/invoices', authHandler(requireAuth), authHandler(paymentsController.getInvoices));
// ============================================================================
// Webhook Route (No Auth - Verified by Stripe Signature)
// ============================================================================
/**
* POST /api/v1/payments/webhook
* Stripe webhook endpoint
* Note: Must use raw body parser for signature verification
*/
router.post('/webhook', raw({ type: 'application/json' }), paymentsController.handleStripeWebhook);
export { router as paymentsRouter };

View File

@ -1,437 +0,0 @@
/**
* Stripe Service
* Handles Stripe API integration
*/
import Stripe from 'stripe';
import { db } from '../../../shared/database';
import { logger } from '../../../shared/utils/logger';
import { config } from '../../../config';
import type {
StripeCustomer,
CheckoutSession,
CreateCheckoutSessionInput,
BillingPortalSession,
SubscriptionPlan,
} from '../types/payments.types';
// ============================================================================
// Stripe Client
// ============================================================================
const stripe = new Stripe(config.stripe?.secretKey || process.env.STRIPE_SECRET_KEY || '', {
apiVersion: '2025-02-24.acacia',
});
// ============================================================================
// Helper Functions
// ============================================================================
function transformPlan(row: Record<string, unknown>): SubscriptionPlan {
return {
id: row.id as string,
name: row.name as string,
slug: row.slug as string,
description: row.description as string | undefined,
priceMonthly: parseFloat(row.price_monthly as string),
priceYearly: row.price_yearly ? parseFloat(row.price_yearly as string) : undefined,
currency: row.currency as string,
stripePriceIdMonthly: row.stripe_price_id_monthly as string | undefined,
stripePriceIdYearly: row.stripe_price_id_yearly as string | undefined,
stripeProductId: row.stripe_product_id as string | undefined,
features: (row.features as SubscriptionPlan['features']) || [],
maxWatchlists: row.max_watchlists as number | undefined,
maxAlerts: row.max_alerts as number | undefined,
mlPredictionsAccess: row.ml_predictions_access as boolean,
signalsAccess: row.signals_access as boolean,
backtestingAccess: row.backtesting_access as boolean,
apiAccess: row.api_access as boolean,
prioritySupport: row.priority_support as boolean,
coursesAccess: row.courses_access as SubscriptionPlan['coursesAccess'],
sortOrder: row.sort_order as number,
isFeatured: row.is_featured as boolean,
isActive: row.is_active as boolean,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
}
// ============================================================================
// Stripe Service Class
// ============================================================================
class StripeService {
// ==========================================================================
// Customer Management
// ==========================================================================
async getOrCreateCustomer(userId: string, email: string): Promise<StripeCustomer> {
// Check if customer exists
const existing = await db.query<Record<string, unknown>>(
`SELECT * FROM financial.stripe_customers WHERE user_id = $1`,
[userId]
);
if (existing.rows.length > 0) {
const row = existing.rows[0];
return {
id: row.id as string,
userId: row.user_id as string,
stripeCustomerId: row.stripe_customer_id as string,
email: row.email as string | undefined,
defaultPaymentMethodId: row.default_payment_method_id as string | undefined,
metadata: row.metadata as Record<string, unknown> | undefined,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
}
// Create Stripe customer
const stripeCustomer = await stripe.customers.create({
email,
metadata: { userId },
});
// Save to database
const result = await db.query<Record<string, unknown>>(
`INSERT INTO financial.stripe_customers (user_id, stripe_customer_id, email)
VALUES ($1, $2, $3)
RETURNING *`,
[userId, stripeCustomer.id, email]
);
logger.info('[StripeService] Customer created:', { userId, stripeCustomerId: stripeCustomer.id });
const row = result.rows[0];
return {
id: row.id as string,
userId: row.user_id as string,
stripeCustomerId: row.stripe_customer_id as string,
email: row.email as string | undefined,
defaultPaymentMethodId: row.default_payment_method_id as string | undefined,
metadata: row.metadata as Record<string, unknown> | undefined,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
}
async getCustomerByUserId(userId: string): Promise<StripeCustomer | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM financial.stripe_customers WHERE user_id = $1`,
[userId]
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
return {
id: row.id as string,
userId: row.user_id as string,
stripeCustomerId: row.stripe_customer_id as string,
email: row.email as string | undefined,
defaultPaymentMethodId: row.default_payment_method_id as string | undefined,
metadata: row.metadata as Record<string, unknown> | undefined,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
}
async updateDefaultPaymentMethod(userId: string, paymentMethodId: string): Promise<void> {
const customer = await this.getCustomerByUserId(userId);
if (!customer) throw new Error('Customer not found');
// Update in Stripe
await stripe.customers.update(customer.stripeCustomerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
// Update in database
await db.query(
`UPDATE financial.stripe_customers SET default_payment_method_id = $1 WHERE user_id = $2`,
[paymentMethodId, userId]
);
}
// ==========================================================================
// Payment Methods
// ==========================================================================
async listPaymentMethods(userId: string): Promise<Stripe.PaymentMethod[]> {
const customer = await this.getCustomerByUserId(userId);
if (!customer) return [];
const paymentMethods = await stripe.paymentMethods.list({
customer: customer.stripeCustomerId,
type: 'card',
});
return paymentMethods.data;
}
async attachPaymentMethod(userId: string, paymentMethodId: string): Promise<Stripe.PaymentMethod> {
const customer = await this.getOrCreateCustomer(userId, '');
const paymentMethod = await stripe.paymentMethods.attach(paymentMethodId, {
customer: customer.stripeCustomerId,
});
return paymentMethod;
}
async detachPaymentMethod(paymentMethodId: string): Promise<void> {
await stripe.paymentMethods.detach(paymentMethodId);
}
// ==========================================================================
// Checkout Sessions
// ==========================================================================
async createCheckoutSession(input: CreateCheckoutSessionInput): Promise<CheckoutSession> {
// Get user email
const userResult = await db.query<{ email: string }>(
`SELECT email FROM public.users WHERE id = $1`,
[input.userId]
);
if (userResult.rows.length === 0) {
throw new Error('User not found');
}
const email = userResult.rows[0].email;
const customer = await this.getOrCreateCustomer(input.userId, email);
const sessionConfig: Stripe.Checkout.SessionCreateParams = {
customer: customer.stripeCustomerId,
success_url: input.successUrl,
cancel_url: input.cancelUrl,
mode: 'subscription',
line_items: [],
metadata: { userId: input.userId },
};
// Handle subscription checkout
if (input.planId) {
const plan = await this.getPlanById(input.planId);
if (!plan) throw new Error('Plan not found');
const priceId = input.billingCycle === 'yearly'
? plan.stripePriceIdYearly
: plan.stripePriceIdMonthly;
if (!priceId) throw new Error('Stripe price not configured for this plan');
sessionConfig.line_items = [{ price: priceId, quantity: 1 }];
sessionConfig.metadata!.planId = input.planId;
sessionConfig.metadata!.billingCycle = input.billingCycle || 'monthly';
}
// Handle course purchase
if (input.courseId) {
sessionConfig.mode = 'payment';
const courseResult = await db.query<{ price: string; title: string }>(
`SELECT price, title FROM education.courses WHERE id = $1`,
[input.courseId]
);
if (courseResult.rows.length === 0) throw new Error('Course not found');
const course = courseResult.rows[0];
sessionConfig.line_items = [{
price_data: {
currency: 'usd',
product_data: { name: course.title },
unit_amount: Math.round(parseFloat(course.price) * 100),
},
quantity: 1,
}];
sessionConfig.metadata!.courseId = input.courseId;
}
// Apply promo code
if (input.promoCode) {
const promo = await this.getStripeCouponByCode(input.promoCode);
if (promo) {
sessionConfig.discounts = [{ coupon: promo }];
}
}
const session = await stripe.checkout.sessions.create(sessionConfig);
logger.info('[StripeService] Checkout session created:', {
sessionId: session.id,
userId: input.userId
});
return {
sessionId: session.id,
url: session.url!,
expiresAt: new Date(session.expires_at * 1000),
};
}
async getStripeCouponByCode(code: string): Promise<string | null> {
try {
const promotionCodes = await stripe.promotionCodes.list({ code, limit: 1 });
if (promotionCodes.data.length > 0 && promotionCodes.data[0].active) {
return promotionCodes.data[0].coupon.id;
}
return null;
} catch {
return null;
}
}
// ==========================================================================
// Billing Portal
// ==========================================================================
async createBillingPortalSession(userId: string, returnUrl: string): Promise<BillingPortalSession> {
const customer = await this.getCustomerByUserId(userId);
if (!customer) throw new Error('Customer not found');
const session = await stripe.billingPortal.sessions.create({
customer: customer.stripeCustomerId,
return_url: returnUrl,
});
return {
url: session.url,
returnUrl,
};
}
// ==========================================================================
// Plans
// ==========================================================================
async getPlans(): Promise<SubscriptionPlan[]> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM financial.subscription_plans WHERE is_active = true ORDER BY sort_order`
);
return result.rows.map(transformPlan);
}
async getPlanById(id: string): Promise<SubscriptionPlan | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM financial.subscription_plans WHERE id = $1`,
[id]
);
if (result.rows.length === 0) return null;
return transformPlan(result.rows[0]);
}
async getPlanBySlug(slug: string): Promise<SubscriptionPlan | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM financial.subscription_plans WHERE slug = $1`,
[slug]
);
if (result.rows.length === 0) return null;
return transformPlan(result.rows[0]);
}
// ==========================================================================
// Payment Intents
// ==========================================================================
async createPaymentIntent(
userId: string,
amount: number,
currency: string = 'usd',
metadata: Record<string, string> = {}
): Promise<Stripe.PaymentIntent> {
const userResult = await db.query<{ email: string }>(
`SELECT email FROM public.users WHERE id = $1`,
[userId]
);
if (userResult.rows.length === 0) throw new Error('User not found');
const customer = await this.getOrCreateCustomer(userId, userResult.rows[0].email);
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100),
currency,
customer: customer.stripeCustomerId,
metadata: { userId, ...metadata },
});
return paymentIntent;
}
async confirmPaymentIntent(paymentIntentId: string, paymentMethodId: string): Promise<Stripe.PaymentIntent> {
return stripe.paymentIntents.confirm(paymentIntentId, {
payment_method: paymentMethodId,
});
}
// ==========================================================================
// Subscriptions
// ==========================================================================
async cancelSubscription(stripeSubscriptionId: string, immediately: boolean = false): Promise<Stripe.Subscription> {
if (immediately) {
return stripe.subscriptions.cancel(stripeSubscriptionId);
} else {
return stripe.subscriptions.update(stripeSubscriptionId, {
cancel_at_period_end: true,
});
}
}
async resumeSubscription(stripeSubscriptionId: string): Promise<Stripe.Subscription> {
return stripe.subscriptions.update(stripeSubscriptionId, {
cancel_at_period_end: false,
});
}
async updateSubscriptionPlan(stripeSubscriptionId: string, newPriceId: string): Promise<Stripe.Subscription> {
const subscription = await stripe.subscriptions.retrieve(stripeSubscriptionId);
return stripe.subscriptions.update(stripeSubscriptionId, {
items: [{
id: subscription.items.data[0].id,
price: newPriceId,
}],
proration_behavior: 'create_prorations',
});
}
// ==========================================================================
// Refunds
// ==========================================================================
async createRefund(chargeId: string, amount?: number, reason?: string): Promise<Stripe.Refund> {
return stripe.refunds.create({
charge: chargeId,
amount: amount ? Math.round(amount * 100) : undefined,
reason: reason as Stripe.RefundCreateParams.Reason,
});
}
// ==========================================================================
// Webhooks
// ==========================================================================
constructWebhookEvent(payload: string | Buffer, signature: string): Stripe.Event {
const webhookSecret = config.stripe?.webhookSecret || process.env.STRIPE_WEBHOOK_SECRET || '';
return stripe.webhooks.constructEvent(payload, signature, webhookSecret);
}
// ==========================================================================
// Invoices
// ==========================================================================
async getInvoice(invoiceId: string): Promise<Stripe.Invoice> {
return stripe.invoices.retrieve(invoiceId);
}
async listInvoices(customerId: string, limit: number = 10): Promise<Stripe.Invoice[]> {
const invoices = await stripe.invoices.list({
customer: customerId,
limit,
});
return invoices.data;
}
}
export const stripeService = new StripeService();

View File

@ -1,514 +0,0 @@
/**
* Subscription Service
* Handles subscription management and billing cycles
*/
import { db } from '../../../shared/database';
import { logger } from '../../../shared/utils/logger';
import { stripeService } from './stripe.service';
import type {
Subscription,
SubscriptionWithPlan,
CreateSubscriptionInput,
SubscriptionStatus,
SubscriptionPlan,
BillingCycle,
} from '../types/payments.types';
// ============================================================================
// Helper Functions
// ============================================================================
function transformSubscription(row: Record<string, unknown>): Subscription {
return {
id: row.id as string,
userId: row.user_id as string,
planId: row.plan_id as string,
stripeSubscriptionId: row.stripe_subscription_id as string | undefined,
stripeCustomerId: row.stripe_customer_id as string | undefined,
status: row.status as SubscriptionStatus,
billingCycle: row.billing_cycle as BillingCycle,
currentPeriodStart: row.current_period_start ? new Date(row.current_period_start as string) : undefined,
currentPeriodEnd: row.current_period_end ? new Date(row.current_period_end as string) : undefined,
trialStart: row.trial_start ? new Date(row.trial_start as string) : undefined,
trialEnd: row.trial_end ? new Date(row.trial_end as string) : undefined,
cancelAtPeriodEnd: row.cancel_at_period_end as boolean,
cancelledAt: row.cancelled_at ? new Date(row.cancelled_at as string) : undefined,
cancellationReason: row.cancellation_reason as string | undefined,
currentPrice: row.current_price ? parseFloat(row.current_price as string) : undefined,
currency: row.currency as string,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
}
function transformPlan(row: Record<string, unknown>): SubscriptionPlan {
return {
id: row.id as string,
name: row.name as string,
slug: row.slug as string,
description: row.description as string | undefined,
priceMonthly: parseFloat(row.price_monthly as string),
priceYearly: row.price_yearly ? parseFloat(row.price_yearly as string) : undefined,
currency: row.currency as string,
stripePriceIdMonthly: row.stripe_price_id_monthly as string | undefined,
stripePriceIdYearly: row.stripe_price_id_yearly as string | undefined,
stripeProductId: row.stripe_product_id as string | undefined,
features: (row.features as SubscriptionPlan['features']) || [],
maxWatchlists: row.max_watchlists as number | undefined,
maxAlerts: row.max_alerts as number | undefined,
mlPredictionsAccess: row.ml_predictions_access as boolean,
signalsAccess: row.signals_access as boolean,
backtestingAccess: row.backtesting_access as boolean,
apiAccess: row.api_access as boolean,
prioritySupport: row.priority_support as boolean,
coursesAccess: row.courses_access as SubscriptionPlan['coursesAccess'],
sortOrder: row.sort_order as number,
isFeatured: row.is_featured as boolean,
isActive: row.is_active as boolean,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
}
// ============================================================================
// Subscription Service Class
// ============================================================================
class SubscriptionService {
// ==========================================================================
// Subscription Queries
// ==========================================================================
async getSubscriptionById(id: string): Promise<Subscription | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM financial.subscriptions WHERE id = $1`,
[id]
);
if (result.rows.length === 0) return null;
return transformSubscription(result.rows[0]);
}
async getSubscriptionByUserId(userId: string): Promise<SubscriptionWithPlan | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT s.*, p.name as plan_name, p.slug as plan_slug, p.description as plan_description,
p.price_monthly, p.price_yearly, p.features, p.max_watchlists, p.max_alerts,
p.ml_predictions_access, p.signals_access, p.backtesting_access, p.api_access,
p.priority_support, p.courses_access, p.sort_order, p.is_featured,
p.stripe_price_id_monthly, p.stripe_price_id_yearly, p.stripe_product_id
FROM financial.subscriptions s
JOIN financial.subscription_plans p ON s.plan_id = p.id
WHERE s.user_id = $1 AND s.status IN ('active', 'trialing', 'past_due')
ORDER BY s.created_at DESC
LIMIT 1`,
[userId]
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
const subscription = transformSubscription(row);
const plan: SubscriptionPlan = {
id: row.plan_id as string,
name: row.plan_name as string,
slug: row.plan_slug as string,
description: row.plan_description as string | undefined,
priceMonthly: parseFloat(row.price_monthly as string),
priceYearly: row.price_yearly ? parseFloat(row.price_yearly as string) : undefined,
currency: subscription.currency,
stripePriceIdMonthly: row.stripe_price_id_monthly as string | undefined,
stripePriceIdYearly: row.stripe_price_id_yearly as string | undefined,
stripeProductId: row.stripe_product_id as string | undefined,
features: (row.features as SubscriptionPlan['features']) || [],
maxWatchlists: row.max_watchlists as number | undefined,
maxAlerts: row.max_alerts as number | undefined,
mlPredictionsAccess: row.ml_predictions_access as boolean,
signalsAccess: row.signals_access as boolean,
backtestingAccess: row.backtesting_access as boolean,
apiAccess: row.api_access as boolean,
prioritySupport: row.priority_support as boolean,
coursesAccess: row.courses_access as SubscriptionPlan['coursesAccess'],
sortOrder: row.sort_order as number,
isFeatured: row.is_featured as boolean,
isActive: true,
createdAt: subscription.createdAt,
updatedAt: subscription.updatedAt,
};
return { ...subscription, plan };
}
async getSubscriptionHistory(userId: string): Promise<Subscription[]> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM financial.subscriptions
WHERE user_id = $1
ORDER BY created_at DESC`,
[userId]
);
return result.rows.map(transformSubscription);
}
async hasActiveSubscription(userId: string): Promise<boolean> {
const result = await db.query<{ exists: boolean }>(
`SELECT EXISTS(
SELECT 1 FROM financial.subscriptions
WHERE user_id = $1 AND status IN ('active', 'trialing')
) as exists`,
[userId]
);
return result.rows[0].exists;
}
// ==========================================================================
// Plan Queries
// ==========================================================================
async getPlans(): Promise<SubscriptionPlan[]> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM financial.subscription_plans WHERE is_active = true ORDER BY sort_order`
);
return result.rows.map(transformPlan);
}
async getPlanById(id: string): Promise<SubscriptionPlan | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM financial.subscription_plans WHERE id = $1`,
[id]
);
if (result.rows.length === 0) return null;
return transformPlan(result.rows[0]);
}
async getPlanBySlug(slug: string): Promise<SubscriptionPlan | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM financial.subscription_plans WHERE slug = $1`,
[slug]
);
if (result.rows.length === 0) return null;
return transformPlan(result.rows[0]);
}
// ==========================================================================
// Subscription Management
// ==========================================================================
async createSubscription(input: CreateSubscriptionInput): Promise<Subscription> {
// Check for existing active subscription
const existing = await this.getSubscriptionByUserId(input.userId);
if (existing && existing.status === 'active') {
throw new Error('User already has an active subscription');
}
// Get plan
const plan = await this.getPlanById(input.planId);
if (!plan) {
throw new Error('Plan not found');
}
const billingCycle = input.billingCycle || 'monthly';
const price = billingCycle === 'yearly' ? (plan.priceYearly || plan.priceMonthly * 12) : plan.priceMonthly;
// Calculate period
const now = new Date();
const periodEnd = new Date(now);
if (billingCycle === 'yearly') {
periodEnd.setFullYear(periodEnd.getFullYear() + 1);
} else {
periodEnd.setMonth(periodEnd.getMonth() + 1);
}
// Create subscription record
const result = await db.query<Record<string, unknown>>(
`INSERT INTO financial.subscriptions (
user_id, plan_id, status, billing_cycle, current_period_start,
current_period_end, current_price, currency
) VALUES ($1, $2, 'active', $3, $4, $5, $6, $7)
RETURNING *`,
[
input.userId,
input.planId,
billingCycle,
now,
periodEnd,
price,
plan.currency,
]
);
logger.info('[SubscriptionService] Subscription created:', {
userId: input.userId,
planId: input.planId,
billingCycle,
});
return transformSubscription(result.rows[0]);
}
async updateSubscriptionFromStripe(
stripeSubscriptionId: string,
data: {
status?: SubscriptionStatus;
currentPeriodStart?: Date;
currentPeriodEnd?: Date;
cancelAtPeriodEnd?: boolean;
cancelledAt?: Date;
}
): Promise<Subscription | null> {
const updates: string[] = [];
const params: (string | Date | boolean)[] = [];
let paramIndex = 1;
if (data.status !== undefined) {
updates.push(`status = $${paramIndex++}`);
params.push(data.status);
}
if (data.currentPeriodStart !== undefined) {
updates.push(`current_period_start = $${paramIndex++}`);
params.push(data.currentPeriodStart);
}
if (data.currentPeriodEnd !== undefined) {
updates.push(`current_period_end = $${paramIndex++}`);
params.push(data.currentPeriodEnd);
}
if (data.cancelAtPeriodEnd !== undefined) {
updates.push(`cancel_at_period_end = $${paramIndex++}`);
params.push(data.cancelAtPeriodEnd);
}
if (data.cancelledAt !== undefined) {
updates.push(`cancelled_at = $${paramIndex++}`);
params.push(data.cancelledAt);
}
if (updates.length === 0) return null;
params.push(stripeSubscriptionId);
const result = await db.query<Record<string, unknown>>(
`UPDATE financial.subscriptions
SET ${updates.join(', ')}
WHERE stripe_subscription_id = $${paramIndex}
RETURNING *`,
params
);
if (result.rows.length === 0) return null;
return transformSubscription(result.rows[0]);
}
async cancelSubscription(
userId: string,
immediately: boolean = false,
reason?: string
): Promise<Subscription | null> {
const subscription = await this.getSubscriptionByUserId(userId);
if (!subscription) {
throw new Error('No active subscription found');
}
// Cancel in Stripe if applicable
if (subscription.stripeSubscriptionId) {
await stripeService.cancelSubscription(subscription.stripeSubscriptionId, immediately);
}
// Update local record
if (immediately) {
const result = await db.query<Record<string, unknown>>(
`UPDATE financial.subscriptions
SET status = 'cancelled',
cancelled_at = CURRENT_TIMESTAMP,
cancellation_reason = $1
WHERE id = $2
RETURNING *`,
[reason, subscription.id]
);
logger.info('[SubscriptionService] Subscription cancelled immediately:', {
subscriptionId: subscription.id,
});
return transformSubscription(result.rows[0]);
} else {
const result = await db.query<Record<string, unknown>>(
`UPDATE financial.subscriptions
SET cancel_at_period_end = true,
cancellation_reason = $1
WHERE id = $2
RETURNING *`,
[reason, subscription.id]
);
logger.info('[SubscriptionService] Subscription set to cancel at period end:', {
subscriptionId: subscription.id,
});
return transformSubscription(result.rows[0]);
}
}
async resumeSubscription(userId: string): Promise<Subscription | null> {
const subscription = await this.getSubscriptionByUserId(userId);
if (!subscription) {
throw new Error('No subscription found');
}
if (!subscription.cancelAtPeriodEnd) {
throw new Error('Subscription is not set to cancel');
}
// Resume in Stripe if applicable
if (subscription.stripeSubscriptionId) {
await stripeService.resumeSubscription(subscription.stripeSubscriptionId);
}
// Update local record
const result = await db.query<Record<string, unknown>>(
`UPDATE financial.subscriptions
SET cancel_at_period_end = false,
cancellation_reason = NULL
WHERE id = $1
RETURNING *`,
[subscription.id]
);
logger.info('[SubscriptionService] Subscription resumed:', {
subscriptionId: subscription.id,
});
return transformSubscription(result.rows[0]);
}
async changePlan(
userId: string,
newPlanId: string,
billingCycle?: BillingCycle
): Promise<Subscription | null> {
const subscription = await this.getSubscriptionByUserId(userId);
if (!subscription) {
throw new Error('No active subscription found');
}
const newPlan = await this.getPlanById(newPlanId);
if (!newPlan) {
throw new Error('Plan not found');
}
const newBillingCycle = billingCycle || subscription.billingCycle;
const priceId = newBillingCycle === 'yearly'
? newPlan.stripePriceIdYearly
: newPlan.stripePriceIdMonthly;
// Update in Stripe if applicable
if (subscription.stripeSubscriptionId && priceId) {
await stripeService.updateSubscriptionPlan(subscription.stripeSubscriptionId, priceId);
}
// Calculate new price
const newPrice = newBillingCycle === 'yearly'
? (newPlan.priceYearly || newPlan.priceMonthly * 12)
: newPlan.priceMonthly;
// Update local record
const result = await db.query<Record<string, unknown>>(
`UPDATE financial.subscriptions
SET plan_id = $1,
billing_cycle = $2,
current_price = $3
WHERE id = $4
RETURNING *`,
[newPlanId, newBillingCycle, newPrice, subscription.id]
);
logger.info('[SubscriptionService] Subscription plan changed:', {
subscriptionId: subscription.id,
oldPlanId: subscription.planId,
newPlanId,
});
return transformSubscription(result.rows[0]);
}
// ==========================================================================
// Feature Access Checks
// ==========================================================================
async hasFeatureAccess(userId: string, feature: keyof SubscriptionPlan): Promise<boolean> {
const subscription = await this.getSubscriptionByUserId(userId);
if (!subscription || !['active', 'trialing'].includes(subscription.status)) {
return false;
}
const value = subscription.plan[feature];
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value > 0;
}
if (typeof value === 'string') {
return value !== 'none';
}
return false;
}
async getUserPlanFeatures(userId: string): Promise<Partial<SubscriptionPlan> | null> {
const subscription = await this.getSubscriptionByUserId(userId);
if (!subscription) return null;
return {
name: subscription.plan.name,
slug: subscription.plan.slug,
maxWatchlists: subscription.plan.maxWatchlists,
maxAlerts: subscription.plan.maxAlerts,
mlPredictionsAccess: subscription.plan.mlPredictionsAccess,
signalsAccess: subscription.plan.signalsAccess,
backtestingAccess: subscription.plan.backtestingAccess,
apiAccess: subscription.plan.apiAccess,
prioritySupport: subscription.plan.prioritySupport,
coursesAccess: subscription.plan.coursesAccess,
};
}
// ==========================================================================
// Statistics
// ==========================================================================
async getSubscriptionStats(): Promise<{
totalActive: number;
totalTrialing: number;
totalCancelled: number;
monthlyRevenue: number;
planDistribution: { planId: string; planName: string; count: number }[];
}> {
const statusResult = await db.query<Record<string, string>>(
`SELECT
COUNT(*) FILTER (WHERE status = 'active') as total_active,
COUNT(*) FILTER (WHERE status = 'trialing') as total_trialing,
COUNT(*) FILTER (WHERE status = 'cancelled') as total_cancelled,
COALESCE(SUM(current_price) FILTER (WHERE status = 'active' AND billing_cycle = 'monthly'), 0) as monthly_revenue
FROM financial.subscriptions`
);
const planResult = await db.query<{ plan_id: string; plan_name: string; count: string }>(
`SELECT s.plan_id, p.name as plan_name, COUNT(*) as count
FROM financial.subscriptions s
JOIN financial.subscription_plans p ON s.plan_id = p.id
WHERE s.status IN ('active', 'trialing')
GROUP BY s.plan_id, p.name
ORDER BY count DESC`
);
const stats = statusResult.rows[0];
return {
totalActive: parseInt(stats.total_active, 10),
totalTrialing: parseInt(stats.total_trialing, 10),
totalCancelled: parseInt(stats.total_cancelled, 10),
monthlyRevenue: parseFloat(stats.monthly_revenue) || 0,
planDistribution: planResult.rows.map((row) => ({
planId: row.plan_id,
planName: row.plan_name,
count: parseInt(row.count, 10),
})),
};
}
}
export const subscriptionService = new SubscriptionService();

View File

@ -1,632 +0,0 @@
/**
* Wallet Service
* Handles internal wallet management, balances, and transactions
*/
import { db } from '../../../shared/database';
import { logger } from '../../../shared/utils/logger';
import type {
Wallet,
WalletTransaction,
CreateWalletDepositInput,
CreateWalletWithdrawalInput,
TransactionType,
PaymentStatus,
} from '../types/payments.types';
// ============================================================================
// Helper Functions
// ============================================================================
function transformWallet(row: Record<string, unknown>): Wallet {
return {
id: row.id as string,
userId: row.user_id as string,
currency: row.currency as string,
balance: parseFloat(row.balance as string) || 0,
availableBalance: parseFloat(row.available_balance as string) || 0,
pendingBalance: parseFloat(row.pending_balance as string) || 0,
isActive: row.is_active as boolean,
dailyWithdrawalLimit: parseFloat(row.daily_withdrawal_limit as string) || 0,
monthlyWithdrawalLimit: parseFloat(row.monthly_withdrawal_limit as string) || 0,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
}
function transformTransaction(row: Record<string, unknown>): WalletTransaction {
return {
id: row.id as string,
walletId: row.wallet_id as string,
userId: row.user_id as string,
transactionType: row.transaction_type as TransactionType,
amount: parseFloat(row.amount as string) || 0,
currency: row.currency as string,
balanceBefore: parseFloat(row.balance_before as string) || 0,
balanceAfter: parseFloat(row.balance_after as string) || 0,
referenceType: row.reference_type as string | undefined,
referenceId: row.reference_id as string | undefined,
externalReference: row.external_reference as string | undefined,
description: row.description as string | undefined,
status: row.status as PaymentStatus,
metadata: row.metadata as Record<string, unknown> | undefined,
createdAt: new Date(row.created_at as string),
};
}
// ============================================================================
// Wallet Service Class
// ============================================================================
class WalletService {
// ==========================================================================
// Wallet Management
// ==========================================================================
async getOrCreateWallet(userId: string, currency: string = 'USD'): Promise<Wallet> {
// Check if wallet exists
const existing = await db.query<Record<string, unknown>>(
`SELECT * FROM financial.wallets WHERE user_id = $1 AND currency = $2`,
[userId, currency]
);
if (existing.rows.length > 0) {
return transformWallet(existing.rows[0]);
}
// Create new wallet
const result = await db.query<Record<string, unknown>>(
`INSERT INTO financial.wallets (user_id, currency)
VALUES ($1, $2)
RETURNING *`,
[userId, currency]
);
logger.info('[WalletService] Wallet created:', { userId, currency });
return transformWallet(result.rows[0]);
}
async getWalletByUserId(userId: string, currency: string = 'USD'): Promise<Wallet | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM financial.wallets WHERE user_id = $1 AND currency = $2`,
[userId, currency]
);
if (result.rows.length === 0) return null;
return transformWallet(result.rows[0]);
}
async getWalletById(id: string): Promise<Wallet | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM financial.wallets WHERE id = $1`,
[id]
);
if (result.rows.length === 0) return null;
return transformWallet(result.rows[0]);
}
async getUserWallets(userId: string): Promise<Wallet[]> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM financial.wallets WHERE user_id = $1 ORDER BY currency`,
[userId]
);
return result.rows.map(transformWallet);
}
async getBalance(userId: string, currency: string = 'USD'): Promise<number> {
const wallet = await this.getWalletByUserId(userId, currency);
return wallet?.availableBalance || 0;
}
// ==========================================================================
// Transactions
// ==========================================================================
async getTransactions(
userId: string,
options: {
walletId?: string;
transactionType?: TransactionType;
status?: PaymentStatus;
startDate?: Date;
endDate?: Date;
limit?: number;
offset?: number;
} = {}
): Promise<{ transactions: WalletTransaction[]; total: number }> {
const conditions: string[] = ['user_id = $1'];
const params: (string | number | Date)[] = [userId];
let paramIndex = 2;
if (options.walletId) {
conditions.push(`wallet_id = $${paramIndex++}`);
params.push(options.walletId);
}
if (options.transactionType) {
conditions.push(`transaction_type = $${paramIndex++}`);
params.push(options.transactionType);
}
if (options.status) {
conditions.push(`status = $${paramIndex++}`);
params.push(options.status);
}
if (options.startDate) {
conditions.push(`created_at >= $${paramIndex++}`);
params.push(options.startDate);
}
if (options.endDate) {
conditions.push(`created_at <= $${paramIndex++}`);
params.push(options.endDate);
}
const whereClause = conditions.join(' AND ');
const limit = options.limit || 50;
const offset = options.offset || 0;
// Get total count
const countResult = await db.query<{ count: string }>(
`SELECT COUNT(*) FROM financial.wallet_transactions WHERE ${whereClause}`,
params
);
// Get transactions
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM financial.wallet_transactions
WHERE ${whereClause}
ORDER BY created_at DESC
LIMIT ${limit} OFFSET ${offset}`,
params
);
return {
transactions: result.rows.map(transformTransaction),
total: parseInt(countResult.rows[0].count, 10),
};
}
async getTransactionById(id: string): Promise<WalletTransaction | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM financial.wallet_transactions WHERE id = $1`,
[id]
);
if (result.rows.length === 0) return null;
return transformTransaction(result.rows[0]);
}
// ==========================================================================
// Deposit Operations
// ==========================================================================
async createDeposit(input: CreateWalletDepositInput): Promise<WalletTransaction> {
const wallet = await this.getOrCreateWallet(input.userId, input.currency || 'USD');
// Use transaction for atomic operation
const client = await db.getClient();
try {
await client.query('BEGIN');
const balanceBefore = wallet.balance;
const balanceAfter = balanceBefore + input.amount;
// Create transaction record
const txResult = await client.query<Record<string, unknown>>(
`INSERT INTO financial.wallet_transactions (
wallet_id, user_id, transaction_type, amount, currency,
balance_before, balance_after, description, status
) VALUES ($1, $2, 'deposit', $3, $4, $5, $6, $7, 'pending')
RETURNING *`,
[
wallet.id,
input.userId,
input.amount,
input.currency || 'USD',
balanceBefore,
balanceAfter,
input.description || 'Wallet deposit',
]
);
// Update wallet pending balance
await client.query(
`UPDATE financial.wallets
SET pending_balance = pending_balance + $1
WHERE id = $2`,
[input.amount, wallet.id]
);
await client.query('COMMIT');
logger.info('[WalletService] Deposit created:', {
userId: input.userId,
amount: input.amount,
transactionId: txResult.rows[0].id,
});
return transformTransaction(txResult.rows[0]);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async confirmDeposit(transactionId: string): Promise<WalletTransaction> {
const transaction = await this.getTransactionById(transactionId);
if (!transaction) {
throw new Error('Transaction not found');
}
if (transaction.status !== 'pending') {
throw new Error('Transaction is not pending');
}
const client = await db.getClient();
try {
await client.query('BEGIN');
// Update transaction status
const txResult = await client.query<Record<string, unknown>>(
`UPDATE financial.wallet_transactions
SET status = 'succeeded'
WHERE id = $1
RETURNING *`,
[transactionId]
);
// Update wallet balances
await client.query(
`UPDATE financial.wallets
SET balance = balance + $1,
available_balance = available_balance + $1,
pending_balance = pending_balance - $1
WHERE id = $2`,
[transaction.amount, transaction.walletId]
);
await client.query('COMMIT');
logger.info('[WalletService] Deposit confirmed:', { transactionId });
return transformTransaction(txResult.rows[0]);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// ==========================================================================
// Withdrawal Operations
// ==========================================================================
async createWithdrawal(input: CreateWalletWithdrawalInput): Promise<WalletTransaction> {
const wallet = await this.getWalletByUserId(input.userId, input.currency || 'USD');
if (!wallet) {
throw new Error('Wallet not found');
}
if (wallet.availableBalance < input.amount) {
throw new Error('Insufficient balance');
}
// Check daily limit
const dailyWithdrawals = await this.getDailyWithdrawalTotal(input.userId, input.currency || 'USD');
if (dailyWithdrawals + input.amount > wallet.dailyWithdrawalLimit) {
throw new Error('Daily withdrawal limit exceeded');
}
// Check monthly limit
const monthlyWithdrawals = await this.getMonthlyWithdrawalTotal(input.userId, input.currency || 'USD');
if (monthlyWithdrawals + input.amount > wallet.monthlyWithdrawalLimit) {
throw new Error('Monthly withdrawal limit exceeded');
}
const client = await db.getClient();
try {
await client.query('BEGIN');
const balanceBefore = wallet.balance;
const balanceAfter = balanceBefore - input.amount;
// Create transaction record
const txResult = await client.query<Record<string, unknown>>(
`INSERT INTO financial.wallet_transactions (
wallet_id, user_id, transaction_type, amount, currency,
balance_before, balance_after, description, status, metadata
) VALUES ($1, $2, 'withdrawal', $3, $4, $5, $6, $7, 'pending', $8)
RETURNING *`,
[
wallet.id,
input.userId,
input.amount,
input.currency || 'USD',
balanceBefore,
balanceAfter,
`Withdrawal via ${input.payoutMethod}`,
JSON.stringify({
payoutMethod: input.payoutMethod,
destinationDetails: input.destinationDetails,
}),
]
);
// Update wallet - reduce available balance
await client.query(
`UPDATE financial.wallets
SET available_balance = available_balance - $1,
pending_balance = pending_balance + $1
WHERE id = $2`,
[input.amount, wallet.id]
);
await client.query('COMMIT');
logger.info('[WalletService] Withdrawal created:', {
userId: input.userId,
amount: input.amount,
transactionId: txResult.rows[0].id,
});
return transformTransaction(txResult.rows[0]);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async confirmWithdrawal(transactionId: string): Promise<WalletTransaction> {
const transaction = await this.getTransactionById(transactionId);
if (!transaction) {
throw new Error('Transaction not found');
}
if (transaction.status !== 'pending') {
throw new Error('Transaction is not pending');
}
const client = await db.getClient();
try {
await client.query('BEGIN');
// Update transaction status
const txResult = await client.query<Record<string, unknown>>(
`UPDATE financial.wallet_transactions
SET status = 'succeeded'
WHERE id = $1
RETURNING *`,
[transactionId]
);
// Update wallet balances
await client.query(
`UPDATE financial.wallets
SET balance = balance - $1,
pending_balance = pending_balance - $1
WHERE id = $2`,
[transaction.amount, transaction.walletId]
);
await client.query('COMMIT');
logger.info('[WalletService] Withdrawal confirmed:', { transactionId });
return transformTransaction(txResult.rows[0]);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async cancelWithdrawal(transactionId: string, reason?: string): Promise<WalletTransaction> {
const transaction = await this.getTransactionById(transactionId);
if (!transaction) {
throw new Error('Transaction not found');
}
if (transaction.status !== 'pending') {
throw new Error('Transaction is not pending');
}
const client = await db.getClient();
try {
await client.query('BEGIN');
// Update transaction status
const txResult = await client.query<Record<string, unknown>>(
`UPDATE financial.wallet_transactions
SET status = 'cancelled',
metadata = jsonb_set(COALESCE(metadata, '{}'), '{cancellationReason}', $1)
WHERE id = $2
RETURNING *`,
[JSON.stringify(reason || 'Cancelled'), transactionId]
);
// Restore available balance
await client.query(
`UPDATE financial.wallets
SET available_balance = available_balance + $1,
pending_balance = pending_balance - $1
WHERE id = $2`,
[transaction.amount, transaction.walletId]
);
await client.query('COMMIT');
logger.info('[WalletService] Withdrawal cancelled:', { transactionId, reason });
return transformTransaction(txResult.rows[0]);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// ==========================================================================
// Internal Transfers
// ==========================================================================
async transfer(
fromUserId: string,
toUserId: string,
amount: number,
currency: string = 'USD',
description?: string
): Promise<{ fromTransaction: WalletTransaction; toTransaction: WalletTransaction }> {
const fromWallet = await this.getWalletByUserId(fromUserId, currency);
if (!fromWallet) {
throw new Error('Source wallet not found');
}
if (fromWallet.availableBalance < amount) {
throw new Error('Insufficient balance');
}
const toWallet = await this.getOrCreateWallet(toUserId, currency);
const client = await db.getClient();
try {
await client.query('BEGIN');
// Create debit transaction
const fromResult = await client.query<Record<string, unknown>>(
`INSERT INTO financial.wallet_transactions (
wallet_id, user_id, transaction_type, amount, currency,
balance_before, balance_after, reference_type, reference_id, description, status
) VALUES ($1, $2, 'transfer', $3, $4, $5, $6, 'user', $7, $8, 'succeeded')
RETURNING *`,
[
fromWallet.id,
fromUserId,
-amount,
currency,
fromWallet.balance,
fromWallet.balance - amount,
toUserId,
description || `Transfer to user ${toUserId}`,
]
);
// Create credit transaction
const toResult = await client.query<Record<string, unknown>>(
`INSERT INTO financial.wallet_transactions (
wallet_id, user_id, transaction_type, amount, currency,
balance_before, balance_after, reference_type, reference_id, description, status
) VALUES ($1, $2, 'transfer', $3, $4, $5, $6, 'user', $7, $8, 'succeeded')
RETURNING *`,
[
toWallet.id,
toUserId,
amount,
currency,
toWallet.balance,
toWallet.balance + amount,
fromUserId,
description || `Transfer from user ${fromUserId}`,
]
);
// Update wallets
await client.query(
`UPDATE financial.wallets
SET balance = balance - $1, available_balance = available_balance - $1
WHERE id = $2`,
[amount, fromWallet.id]
);
await client.query(
`UPDATE financial.wallets
SET balance = balance + $1, available_balance = available_balance + $1
WHERE id = $2`,
[amount, toWallet.id]
);
await client.query('COMMIT');
logger.info('[WalletService] Transfer completed:', {
fromUserId,
toUserId,
amount,
currency,
});
return {
fromTransaction: transformTransaction(fromResult.rows[0]),
toTransaction: transformTransaction(toResult.rows[0]),
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// ==========================================================================
// Limit Checks
// ==========================================================================
private async getDailyWithdrawalTotal(userId: string, currency: string): Promise<number> {
const result = await db.query<{ total: string }>(
`SELECT COALESCE(SUM(ABS(amount)), 0) as total
FROM financial.wallet_transactions
WHERE user_id = $1
AND currency = $2
AND transaction_type = 'withdrawal'
AND status IN ('pending', 'succeeded')
AND created_at >= CURRENT_DATE`,
[userId, currency]
);
return parseFloat(result.rows[0].total) || 0;
}
private async getMonthlyWithdrawalTotal(userId: string, currency: string): Promise<number> {
const result = await db.query<{ total: string }>(
`SELECT COALESCE(SUM(ABS(amount)), 0) as total
FROM financial.wallet_transactions
WHERE user_id = $1
AND currency = $2
AND transaction_type = 'withdrawal'
AND status IN ('pending', 'succeeded')
AND created_at >= DATE_TRUNC('month', CURRENT_DATE)`,
[userId, currency]
);
return parseFloat(result.rows[0].total) || 0;
}
// ==========================================================================
// Statistics
// ==========================================================================
async getWalletStats(userId: string, currency: string = 'USD'): Promise<{
totalDeposits: number;
totalWithdrawals: number;
totalTransfers: number;
transactionCount: number;
lastTransaction?: Date;
}> {
const result = await db.query<Record<string, string>>(
`SELECT
COALESCE(SUM(CASE WHEN transaction_type = 'deposit' AND status = 'succeeded' THEN amount ELSE 0 END), 0) as total_deposits,
COALESCE(SUM(CASE WHEN transaction_type = 'withdrawal' AND status = 'succeeded' THEN ABS(amount) ELSE 0 END), 0) as total_withdrawals,
COALESCE(SUM(CASE WHEN transaction_type = 'transfer' AND status = 'succeeded' THEN ABS(amount) ELSE 0 END), 0) as total_transfers,
COUNT(*) as transaction_count,
MAX(created_at) as last_transaction
FROM financial.wallet_transactions wt
JOIN financial.wallets w ON wt.wallet_id = w.id
WHERE w.user_id = $1 AND w.currency = $2`,
[userId, currency]
);
const stats = result.rows[0];
return {
totalDeposits: parseFloat(stats.total_deposits) || 0,
totalWithdrawals: parseFloat(stats.total_withdrawals) || 0,
totalTransfers: parseFloat(stats.total_transfers) || 0,
transactionCount: parseInt(stats.transaction_count, 10),
lastTransaction: stats.last_transaction ? new Date(stats.last_transaction) : undefined,
};
}
}
export const walletService = new WalletService();

View File

@ -1,324 +0,0 @@
/**
* Payments Module Types
*/
// ============================================================================
// Enums
// ============================================================================
export type SubscriptionStatus = 'trialing' | 'active' | 'past_due' | 'cancelled' | 'unpaid' | 'paused';
export type PaymentStatus = 'pending' | 'processing' | 'succeeded' | 'failed' | 'refunded' | 'cancelled';
export type PaymentMethod = 'card' | 'bank_transfer' | 'paypal' | 'crypto' | 'wallet_balance';
export type TransactionType = 'deposit' | 'withdrawal' | 'transfer' | 'payment' | 'refund' | 'fee' | 'distribution';
export type BillingCycle = 'monthly' | 'yearly';
// ============================================================================
// Subscription Plans
// ============================================================================
export interface PlanFeature {
name: string;
description?: string;
included: boolean;
}
export interface SubscriptionPlan {
id: string;
name: string;
slug: string;
description?: string;
priceMonthly: number;
priceYearly?: number;
currency: string;
stripePriceIdMonthly?: string;
stripePriceIdYearly?: string;
stripeProductId?: string;
features: PlanFeature[];
maxWatchlists?: number;
maxAlerts?: number;
mlPredictionsAccess: boolean;
signalsAccess: boolean;
backtestingAccess: boolean;
apiAccess: boolean;
prioritySupport: boolean;
coursesAccess: 'none' | 'free_only' | 'basic' | 'all';
sortOrder: number;
isFeatured: boolean;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
// ============================================================================
// Stripe Customer
// ============================================================================
export interface StripeCustomer {
id: string;
userId: string;
stripeCustomerId: string;
email?: string;
defaultPaymentMethodId?: string;
metadata?: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
// ============================================================================
// Subscription
// ============================================================================
export interface Subscription {
id: string;
userId: string;
planId: string;
stripeSubscriptionId?: string;
stripeCustomerId?: string;
status: SubscriptionStatus;
billingCycle: BillingCycle;
currentPeriodStart?: Date;
currentPeriodEnd?: Date;
trialStart?: Date;
trialEnd?: Date;
cancelAtPeriodEnd: boolean;
cancelledAt?: Date;
cancellationReason?: string;
currentPrice?: number;
currency: string;
createdAt: Date;
updatedAt: Date;
}
export interface SubscriptionWithPlan extends Subscription {
plan: SubscriptionPlan;
}
export interface CreateSubscriptionInput {
userId: string;
planId: string;
billingCycle?: BillingCycle;
paymentMethodId?: string;
promoCode?: string;
}
// ============================================================================
// Wallet
// ============================================================================
export interface Wallet {
id: string;
userId: string;
currency: string;
balance: number;
availableBalance: number;
pendingBalance: number;
isActive: boolean;
dailyWithdrawalLimit: number;
monthlyWithdrawalLimit: number;
createdAt: Date;
updatedAt: Date;
}
export interface WalletTransaction {
id: string;
walletId: string;
userId: string;
transactionType: TransactionType;
amount: number;
currency: string;
balanceBefore: number;
balanceAfter: number;
referenceType?: string;
referenceId?: string;
externalReference?: string;
description?: string;
status: PaymentStatus;
metadata?: Record<string, unknown>;
createdAt: Date;
}
export interface CreateWalletDepositInput {
userId: string;
amount: number;
currency?: string;
paymentMethodId?: string;
description?: string;
}
export interface CreateWalletWithdrawalInput {
userId: string;
amount: number;
currency?: string;
payoutMethod: 'bank_transfer' | 'paypal' | 'crypto';
destinationDetails: Record<string, unknown>;
}
// ============================================================================
// Payment
// ============================================================================
export interface PaymentMethodDetails {
type: string;
last4?: string;
brand?: string;
expMonth?: number;
expYear?: number;
}
export interface Payment {
id: string;
userId: string;
paymentType: string;
amount: number;
currency: string;
fee: number;
netAmount?: number;
paymentMethod?: PaymentMethod;
paymentMethodDetails?: PaymentMethodDetails;
stripePaymentIntentId?: string;
stripeChargeId?: string;
stripeInvoiceId?: string;
status: PaymentStatus;
failureReason?: string;
referenceType?: string;
referenceId?: string;
description?: string;
metadata?: Record<string, unknown>;
ipAddress?: string;
invoiceUrl?: string;
receiptUrl?: string;
createdAt: Date;
updatedAt: Date;
}
export interface CreatePaymentInput {
userId: string;
paymentType: string;
amount: number;
currency?: string;
paymentMethodId?: string;
referenceType?: string;
referenceId?: string;
description?: string;
metadata?: Record<string, unknown>;
}
// ============================================================================
// Promo Codes
// ============================================================================
export interface PromoCode {
id: string;
code: string;
description?: string;
discountType: 'percentage' | 'fixed_amount';
discountValue: number;
currency: string;
appliesTo: 'all' | 'subscription' | 'course';
applicablePlanIds?: string[];
applicableCourseIds?: string[];
maxUses?: number;
currentUses: number;
maxUsesPerUser: number;
validFrom: Date;
validUntil?: Date;
minPurchaseAmount?: number;
firstTimeOnly: boolean;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface ValidatePromoCodeResult {
valid: boolean;
code?: PromoCode;
discountAmount?: number;
error?: string;
}
// ============================================================================
// Payout Request
// ============================================================================
export interface PayoutRequest {
id: string;
userId: string;
walletId: string;
amount: number;
currency: string;
fee: number;
netAmount?: number;
payoutMethod: string;
destinationDetails: Record<string, unknown>;
status: 'pending' | 'processing' | 'completed' | 'rejected' | 'cancelled';
processedAt?: Date;
completedAt?: Date;
processedBy?: string;
rejectionReason?: string;
externalReference?: string;
walletTransactionId?: string;
createdAt: Date;
updatedAt: Date;
}
// ============================================================================
// Invoice
// ============================================================================
export interface InvoiceLineItem {
description: string;
quantity: number;
unitPrice: number;
amount: number;
}
export interface Invoice {
id: string;
userId: string;
invoiceNumber: string;
stripeInvoiceId?: string;
subtotal: number;
tax: number;
total: number;
amountPaid: number;
amountDue?: number;
currency: string;
status: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible';
dueDate?: Date;
paidAt?: Date;
lineItems: InvoiceLineItem[];
pdfUrl?: string;
hostedInvoiceUrl?: string;
billingDetails?: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
// ============================================================================
// Checkout Session
// ============================================================================
export interface CheckoutSession {
sessionId: string;
url: string;
expiresAt: Date;
}
export interface CreateCheckoutSessionInput {
userId: string;
planId?: string;
courseId?: string;
billingCycle?: BillingCycle;
successUrl: string;
cancelUrl: string;
promoCode?: string;
}
// ============================================================================
// Billing Portal
// ============================================================================
export interface BillingPortalSession {
url: string;
returnUrl: string;
}

View File

@ -1,460 +0,0 @@
/**
* Portfolio Controller
* Handles portfolio management endpoints
*/
import { Request, Response, NextFunction } from 'express';
import { portfolioService, RiskProfile } from '../services/portfolio.service';
// ============================================================================
// Types
// ============================================================================
// Use Request directly - user is already declared globally in auth.middleware.ts
type AuthRequest = Request;
// ============================================================================
// Portfolio Management
// ============================================================================
/**
* Create a new portfolio
*/
export async function createPortfolio(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { name, riskProfile, initialValue } = req.body;
if (!name || !riskProfile) {
res.status(400).json({
success: false,
error: { message: 'Name and risk profile are required', code: 'VALIDATION_ERROR' },
});
return;
}
const validProfiles: RiskProfile[] = ['conservative', 'moderate', 'aggressive'];
if (!validProfiles.includes(riskProfile)) {
res.status(400).json({
success: false,
error: { message: 'Invalid risk profile', code: 'VALIDATION_ERROR' },
});
return;
}
const portfolio = await portfolioService.createPortfolio(
userId,
name,
riskProfile,
initialValue || 0
);
res.status(201).json({
success: true,
data: portfolio,
});
} catch (error) {
next(error);
}
}
/**
* Get user's portfolios
*/
export async function getPortfolios(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const portfolios = await portfolioService.getUserPortfolios(userId);
res.json({
success: true,
data: portfolios,
});
} catch (error) {
next(error);
}
}
/**
* Get portfolio by ID
*/
export async function getPortfolio(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { portfolioId } = req.params;
const portfolio = await portfolioService.getPortfolio(portfolioId);
if (!portfolio) {
res.status(404).json({
success: false,
error: { message: 'Portfolio not found', code: 'NOT_FOUND' },
});
return;
}
if (portfolio.userId !== userId) {
res.status(403).json({
success: false,
error: { message: 'Forbidden', code: 'FORBIDDEN' },
});
return;
}
res.json({
success: true,
data: portfolio,
});
} catch (error) {
next(error);
}
}
/**
* Update portfolio allocations
*/
export async function updateAllocations(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { portfolioId } = req.params;
const { allocations } = req.body;
if (!allocations || !Array.isArray(allocations)) {
res.status(400).json({
success: false,
error: { message: 'Allocations array is required', code: 'VALIDATION_ERROR' },
});
return;
}
const portfolio = await portfolioService.getPortfolio(portfolioId);
if (!portfolio) {
res.status(404).json({
success: false,
error: { message: 'Portfolio not found', code: 'NOT_FOUND' },
});
return;
}
if (portfolio.userId !== userId) {
res.status(403).json({
success: false,
error: { message: 'Forbidden', code: 'FORBIDDEN' },
});
return;
}
const updated = await portfolioService.updateAllocations(portfolioId, allocations);
res.json({
success: true,
data: updated,
});
} catch (error) {
next(error);
}
}
/**
* Get rebalancing recommendations
*/
export async function getRebalanceRecommendations(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { portfolioId } = req.params;
const portfolio = await portfolioService.getPortfolio(portfolioId);
if (!portfolio) {
res.status(404).json({
success: false,
error: { message: 'Portfolio not found', code: 'NOT_FOUND' },
});
return;
}
if (portfolio.userId !== userId) {
res.status(403).json({
success: false,
error: { message: 'Forbidden', code: 'FORBIDDEN' },
});
return;
}
const recommendations = await portfolioService.getRebalanceRecommendations(portfolioId);
res.json({
success: true,
data: recommendations,
});
} catch (error) {
next(error);
}
}
/**
* Execute rebalancing
*/
export async function executeRebalance(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { portfolioId } = req.params;
const portfolio = await portfolioService.getPortfolio(portfolioId);
if (!portfolio) {
res.status(404).json({
success: false,
error: { message: 'Portfolio not found', code: 'NOT_FOUND' },
});
return;
}
if (portfolio.userId !== userId) {
res.status(403).json({
success: false,
error: { message: 'Forbidden', code: 'FORBIDDEN' },
});
return;
}
const rebalanced = await portfolioService.executeRebalance(portfolioId);
res.json({
success: true,
data: rebalanced,
message: 'Portfolio rebalanced successfully',
});
} catch (error) {
next(error);
}
}
/**
* Get portfolio statistics
*/
export async function getPortfolioStats(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { portfolioId } = req.params;
const portfolio = await portfolioService.getPortfolio(portfolioId);
if (!portfolio) {
res.status(404).json({
success: false,
error: { message: 'Portfolio not found', code: 'NOT_FOUND' },
});
return;
}
if (portfolio.userId !== userId) {
res.status(403).json({
success: false,
error: { message: 'Forbidden', code: 'FORBIDDEN' },
});
return;
}
const stats = await portfolioService.getPortfolioStats(portfolioId);
res.json({
success: true,
data: stats,
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Goal Management
// ============================================================================
/**
* Create a financial goal
*/
export async function createGoal(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { name, targetAmount, targetDate, monthlyContribution } = req.body;
if (!name || !targetAmount || !targetDate || !monthlyContribution) {
res.status(400).json({
success: false,
error: { message: 'All fields are required', code: 'VALIDATION_ERROR' },
});
return;
}
const goal = await portfolioService.createGoal(
userId,
name,
Number(targetAmount),
new Date(targetDate),
Number(monthlyContribution)
);
res.status(201).json({
success: true,
data: goal,
});
} catch (error) {
next(error);
}
}
/**
* Get user's goals
*/
export async function getGoals(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const goals = await portfolioService.getUserGoals(userId);
res.json({
success: true,
data: goals,
});
} catch (error) {
next(error);
}
}
/**
* Update goal progress
*/
export async function updateGoalProgress(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { goalId } = req.params;
const { currentAmount } = req.body;
if (currentAmount === undefined) {
res.status(400).json({
success: false,
error: { message: 'Current amount is required', code: 'VALIDATION_ERROR' },
});
return;
}
const goal = await portfolioService.updateGoalProgress(goalId, Number(currentAmount));
res.json({
success: true,
data: goal,
});
} catch (error) {
next(error);
}
}
/**
* Delete a goal
*/
export async function deleteGoal(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { goalId } = req.params;
const deleted = await portfolioService.deleteGoal(goalId);
if (!deleted) {
res.status(404).json({
success: false,
error: { message: 'Goal not found', code: 'NOT_FOUND' },
});
return;
}
res.json({
success: true,
message: 'Goal deleted',
});
} catch (error) {
next(error);
}
}

View File

@ -1,97 +0,0 @@
/**
* Portfolio Routes
* Portfolio management and goal tracking endpoints
*/
import { Router, RequestHandler } from 'express';
import * as portfolioController from './controllers/portfolio.controller';
const router = Router();
// Type cast helper for authenticated routes
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
const authHandler = (fn: Function): RequestHandler => fn as RequestHandler;
// ============================================================================
// Portfolio Management (Authenticated)
// ============================================================================
/**
* POST /api/v1/portfolio
* Create a new portfolio
* Body: { name, riskProfile, initialValue? }
*/
router.post('/', authHandler(portfolioController.createPortfolio));
/**
* GET /api/v1/portfolio
* Get user's portfolios
*/
router.get('/', authHandler(portfolioController.getPortfolios));
/**
* GET /api/v1/portfolio/:portfolioId
* Get portfolio by ID with allocations
*/
router.get('/:portfolioId', authHandler(portfolioController.getPortfolio));
/**
* PUT /api/v1/portfolio/:portfolioId/allocations
* Update portfolio allocations
* Body: { allocations: [{ asset, targetPercent }] }
*/
router.put('/:portfolioId/allocations', authHandler(portfolioController.updateAllocations));
/**
* GET /api/v1/portfolio/:portfolioId/stats
* Get portfolio statistics
*/
router.get('/:portfolioId/stats', authHandler(portfolioController.getPortfolioStats));
// ============================================================================
// Rebalancing (Authenticated)
// ============================================================================
/**
* GET /api/v1/portfolio/:portfolioId/rebalance
* Get rebalancing recommendations
*/
router.get('/:portfolioId/rebalance', authHandler(portfolioController.getRebalanceRecommendations));
/**
* POST /api/v1/portfolio/:portfolioId/rebalance
* Execute rebalancing
*/
router.post('/:portfolioId/rebalance', authHandler(portfolioController.executeRebalance));
// ============================================================================
// Goals (Authenticated)
// ============================================================================
/**
* POST /api/v1/portfolio/goals
* Create a financial goal
* Body: { name, targetAmount, targetDate, monthlyContribution }
*/
router.post('/goals', authHandler(portfolioController.createGoal));
/**
* GET /api/v1/portfolio/goals
* Get user's goals
*/
router.get('/goals', authHandler(portfolioController.getGoals));
/**
* PATCH /api/v1/portfolio/goals/:goalId
* Update goal progress
* Body: { currentAmount }
*/
router.patch('/goals/:goalId', authHandler(portfolioController.updateGoalProgress));
/**
* DELETE /api/v1/portfolio/goals/:goalId
* Delete a goal
*/
router.delete('/goals/:goalId', authHandler(portfolioController.deleteGoal));
export { router as portfolioRouter };

View File

@ -1,585 +0,0 @@
/**
* Portfolio Service Unit Tests
*
* Tests for portfolio service including:
* - Portfolio creation and management
* - Asset allocation and rebalancing
* - Portfolio statistics and goals
* - Performance tracking
*/
import { mockDb, createMockQueryResult, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock';
// Mock database (portfolio service uses in-memory storage, but may use DB in future)
jest.mock('../../../../shared/database', () => ({
db: mockDb,
}));
// Mock logger
jest.mock('../../../../shared/utils/logger', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
// Mock market service
const mockGetPrice = jest.fn();
const mockGetPrices = jest.fn();
jest.mock('../../trading/services/market.service', () => ({
marketService: {
getPrice: mockGetPrice,
getPrices: mockGetPrices,
},
}));
// Import service after mocks
import { portfolioService } from '../portfolio.service';
describe('PortfolioService', () => {
beforeEach(() => {
resetDatabaseMocks();
mockGetPrice.mockReset();
mockGetPrices.mockReset();
// Clear in-memory storage
jest.clearAllMocks();
});
describe('createPortfolio', () => {
it('should create conservative portfolio with default allocations', async () => {
const result = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Conservative Portfolio',
riskProfile: 'conservative',
});
expect(result.userId).toBe('user-123');
expect(result.name).toBe('Conservative Portfolio');
expect(result.riskProfile).toBe('conservative');
expect(result.allocations).toBeDefined();
expect(result.allocations.length).toBeGreaterThan(0);
});
it('should create moderate portfolio with balanced allocations', async () => {
const result = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Moderate Portfolio',
riskProfile: 'moderate',
});
expect(result.riskProfile).toBe('moderate');
expect(result.allocations.length).toBeGreaterThanOrEqual(3);
});
it('should create aggressive portfolio with high-risk allocations', async () => {
const result = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Aggressive Portfolio',
riskProfile: 'aggressive',
});
expect(result.riskProfile).toBe('aggressive');
expect(result.allocations).toBeDefined();
});
it('should create portfolio with custom allocations', async () => {
const customAllocations = [
{ asset: 'BTC', targetPercent: 60 },
{ asset: 'ETH', targetPercent: 30 },
{ asset: 'USDT', targetPercent: 10 },
];
const result = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Custom Portfolio',
riskProfile: 'moderate',
customAllocations,
});
expect(result.allocations.length).toBe(3);
expect(result.allocations[0].asset).toBe('BTC');
expect(result.allocations[0].targetPercent).toBe(60);
});
it('should validate total allocation equals 100%', async () => {
const invalidAllocations = [
{ asset: 'BTC', targetPercent: 60 },
{ asset: 'ETH', targetPercent: 30 },
];
await expect(
portfolioService.createPortfolio({
userId: 'user-123',
name: 'Invalid Portfolio',
riskProfile: 'moderate',
customAllocations: invalidAllocations,
})
).rejects.toThrow('Allocations must total 100%');
});
});
describe('getUserPortfolios', () => {
it('should retrieve all portfolios for a user', async () => {
await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Portfolio 1',
riskProfile: 'conservative',
});
await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Portfolio 2',
riskProfile: 'aggressive',
});
const result = await portfolioService.getUserPortfolios('user-123');
expect(result).toHaveLength(2);
expect(result[0].userId).toBe('user-123');
expect(result[1].userId).toBe('user-123');
});
it('should return empty array for user with no portfolios', async () => {
const result = await portfolioService.getUserPortfolios('user-999');
expect(result).toEqual([]);
});
});
describe('getPortfolioById', () => {
it('should retrieve a specific portfolio by ID', async () => {
const created = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Test Portfolio',
riskProfile: 'moderate',
});
const result = await portfolioService.getPortfolioById(created.id);
expect(result).toBeDefined();
expect(result?.id).toBe(created.id);
expect(result?.name).toBe('Test Portfolio');
});
it('should return null for non-existent portfolio', async () => {
const result = await portfolioService.getPortfolioById('non-existent-id');
expect(result).toBeNull();
});
});
describe('updatePortfolio', () => {
it('should update portfolio name', async () => {
const created = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Original Name',
riskProfile: 'moderate',
});
const result = await portfolioService.updatePortfolio(created.id, {
name: 'Updated Name',
});
expect(result.name).toBe('Updated Name');
});
it('should update risk profile', async () => {
const created = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Test Portfolio',
riskProfile: 'conservative',
});
const result = await portfolioService.updatePortfolio(created.id, {
riskProfile: 'aggressive',
});
expect(result.riskProfile).toBe('aggressive');
});
it('should update allocations', async () => {
const created = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Test Portfolio',
riskProfile: 'moderate',
});
const newAllocations = [
{ asset: 'BTC', targetPercent: 70 },
{ asset: 'ETH', targetPercent: 30 },
];
const result = await portfolioService.updatePortfolio(created.id, {
allocations: newAllocations,
});
expect(result.allocations.length).toBe(2);
expect(result.allocations[0].targetPercent).toBe(70);
});
});
describe('deletePortfolio', () => {
it('should delete a portfolio', async () => {
const created = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'To Delete',
riskProfile: 'moderate',
});
await portfolioService.deletePortfolio(created.id);
const result = await portfolioService.getPortfolioById(created.id);
expect(result).toBeNull();
});
it('should handle deletion of non-existent portfolio', async () => {
await expect(
portfolioService.deletePortfolio('non-existent-id')
).rejects.toThrow('Portfolio not found');
});
});
describe('getPortfolioValue', () => {
it('should calculate total portfolio value', async () => {
mockGetPrices.mockResolvedValueOnce({
BTC: 50000,
ETH: 3000,
USDT: 1,
});
const portfolio = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Test Portfolio',
riskProfile: 'moderate',
});
// Add some holdings
await portfolioService.addHolding(portfolio.id, {
asset: 'BTC',
quantity: 0.5,
cost: 24000,
});
await portfolioService.addHolding(portfolio.id, {
asset: 'ETH',
quantity: 2,
cost: 5800,
});
const result = await portfolioService.getPortfolioValue(portfolio.id);
expect(result.totalValue).toBeGreaterThan(0);
expect(result.totalCost).toBe(29800);
expect(result.unrealizedPnl).toBeDefined();
});
it('should handle portfolio with no holdings', async () => {
const portfolio = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Empty Portfolio',
riskProfile: 'moderate',
});
const result = await portfolioService.getPortfolioValue(portfolio.id);
expect(result.totalValue).toBe(0);
expect(result.totalCost).toBe(0);
});
it('should handle market data fetch errors gracefully', async () => {
mockGetPrices.mockRejectedValueOnce(new Error('Market data unavailable'));
const portfolio = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Test Portfolio',
riskProfile: 'moderate',
});
await expect(
portfolioService.getPortfolioValue(portfolio.id)
).rejects.toThrow('Market data unavailable');
});
});
describe('getRebalanceRecommendations', () => {
it('should recommend rebalancing when allocations deviate', async () => {
mockGetPrices.mockResolvedValueOnce({
BTC: 60000,
ETH: 3500,
USDT: 1,
});
const portfolio = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Test Portfolio',
riskProfile: 'moderate',
customAllocations: [
{ asset: 'BTC', targetPercent: 50 },
{ asset: 'ETH', targetPercent: 30 },
{ asset: 'USDT', targetPercent: 20 },
],
});
// Add holdings that deviate from target
await portfolioService.addHolding(portfolio.id, {
asset: 'BTC',
quantity: 1,
cost: 50000,
});
const result = await portfolioService.getRebalanceRecommendations(portfolio.id);
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toHaveProperty('action');
expect(result[0]).toHaveProperty('amount');
});
it('should not recommend rebalancing when allocations are balanced', async () => {
mockGetPrices.mockResolvedValueOnce({
BTC: 50000,
ETH: 3000,
USDT: 1,
});
const portfolio = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Balanced Portfolio',
riskProfile: 'moderate',
});
const result = await portfolioService.getRebalanceRecommendations(portfolio.id);
expect(result).toBeDefined();
expect(result.filter(r => r.action !== 'hold')).toHaveLength(0);
});
it('should prioritize high-deviation assets', async () => {
mockGetPrices.mockResolvedValueOnce({
BTC: 70000,
ETH: 3000,
USDT: 1,
});
const portfolio = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Test Portfolio',
riskProfile: 'moderate',
});
const result = await portfolioService.getRebalanceRecommendations(portfolio.id);
const highPriority = result.filter(r => r.priority === 'high');
expect(highPriority.length).toBeGreaterThanOrEqual(0);
});
});
describe('createPortfolioGoal', () => {
it('should create a new portfolio goal', async () => {
const portfolio = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Test Portfolio',
riskProfile: 'moderate',
});
const result = await portfolioService.createPortfolioGoal({
userId: 'user-123',
portfolioId: portfolio.id,
name: 'Retirement Fund',
targetAmount: 1000000,
targetDate: new Date('2045-01-01'),
monthlyContribution: 1000,
});
expect(result.name).toBe('Retirement Fund');
expect(result.targetAmount).toBe(1000000);
expect(result.monthlyContribution).toBe(1000);
});
it('should calculate goal progress', async () => {
const portfolio = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Test Portfolio',
riskProfile: 'moderate',
});
const result = await portfolioService.createPortfolioGoal({
userId: 'user-123',
portfolioId: portfolio.id,
name: 'House Down Payment',
targetAmount: 100000,
targetDate: new Date('2026-01-01'),
monthlyContribution: 2000,
currentAmount: 25000,
});
expect(result.progress).toBe(25);
expect(result.status).toBeDefined();
});
});
describe('getPortfolioStats', () => {
it('should calculate portfolio statistics', async () => {
mockGetPrices.mockResolvedValue({
BTC: 50000,
ETH: 3000,
});
const portfolio = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Test Portfolio',
riskProfile: 'moderate',
});
await portfolioService.addHolding(portfolio.id, {
asset: 'BTC',
quantity: 0.5,
cost: 24000,
});
await portfolioService.addHolding(portfolio.id, {
asset: 'ETH',
quantity: 2,
cost: 5800,
});
const result = await portfolioService.getPortfolioStats(portfolio.id);
expect(result.totalValue).toBeGreaterThan(0);
expect(result).toHaveProperty('dayChange');
expect(result).toHaveProperty('weekChange');
expect(result).toHaveProperty('monthChange');
expect(result).toHaveProperty('allTimeChange');
expect(result).toHaveProperty('bestPerformer');
expect(result).toHaveProperty('worstPerformer');
});
it('should handle portfolio with single asset', async () => {
mockGetPrices.mockResolvedValue({ BTC: 50000 });
const portfolio = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'BTC Only',
riskProfile: 'aggressive',
});
await portfolioService.addHolding(portfolio.id, {
asset: 'BTC',
quantity: 1,
cost: 45000,
});
const result = await portfolioService.getPortfolioStats(portfolio.id);
expect(result.totalValue).toBe(50000);
expect(result.bestPerformer.asset).toBe('BTC');
});
});
describe('addHolding', () => {
it('should add a new holding to portfolio', async () => {
const portfolio = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Test Portfolio',
riskProfile: 'moderate',
});
const result = await portfolioService.addHolding(portfolio.id, {
asset: 'BTC',
quantity: 0.5,
cost: 25000,
});
expect(result.asset).toBe('BTC');
expect(result.quantity).toBe(0.5);
expect(result.cost).toBe(25000);
});
it('should update existing holding when adding to same asset', async () => {
const portfolio = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Test Portfolio',
riskProfile: 'moderate',
});
await portfolioService.addHolding(portfolio.id, {
asset: 'BTC',
quantity: 0.5,
cost: 25000,
});
const result = await portfolioService.addHolding(portfolio.id, {
asset: 'BTC',
quantity: 0.3,
cost: 16000,
});
expect(result.quantity).toBe(0.8);
expect(result.cost).toBe(41000);
});
});
describe('removeHolding', () => {
it('should remove a holding from portfolio', async () => {
const portfolio = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Test Portfolio',
riskProfile: 'moderate',
});
await portfolioService.addHolding(portfolio.id, {
asset: 'BTC',
quantity: 0.5,
cost: 25000,
});
await portfolioService.removeHolding(portfolio.id, 'BTC', 0.5);
const updated = await portfolioService.getPortfolioById(portfolio.id);
const btcHolding = updated?.allocations.find(a => a.asset === 'BTC');
expect(btcHolding?.quantity).toBe(0);
});
it('should handle partial removal of holding', async () => {
const portfolio = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Test Portfolio',
riskProfile: 'moderate',
});
await portfolioService.addHolding(portfolio.id, {
asset: 'ETH',
quantity: 5,
cost: 15000,
});
await portfolioService.removeHolding(portfolio.id, 'ETH', 2);
const updated = await portfolioService.getPortfolioById(portfolio.id);
const ethHolding = updated?.allocations.find(a => a.asset === 'ETH');
expect(ethHolding?.quantity).toBe(3);
});
it('should prevent removing more than available quantity', async () => {
const portfolio = await portfolioService.createPortfolio({
userId: 'user-123',
name: 'Test Portfolio',
riskProfile: 'moderate',
});
await portfolioService.addHolding(portfolio.id, {
asset: 'SOL',
quantity: 10,
cost: 1000,
});
await expect(
portfolioService.removeHolding(portfolio.id, 'SOL', 15)
).rejects.toThrow('Insufficient quantity');
});
});
});

View File

@ -1,501 +0,0 @@
/**
* Portfolio Service
* Manages user portfolios, allocations, and rebalancing
*/
import { v4 as uuidv4 } from 'uuid';
import { marketService } from '../../trading/services/market.service';
// ============================================================================
// Types
// ============================================================================
export type RiskProfile = 'conservative' | 'moderate' | 'aggressive';
export interface Portfolio {
id: string;
userId: string;
name: string;
riskProfile: RiskProfile;
allocations: PortfolioAllocation[];
totalValue: number;
totalCost: number;
unrealizedPnl: number;
unrealizedPnlPercent: number;
realizedPnl: number;
lastRebalanced: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface PortfolioAllocation {
id: string;
portfolioId: string;
asset: string;
targetPercent: number;
currentPercent: number;
quantity: number;
value: number;
cost: number;
pnl: number;
pnlPercent: number;
deviation: number;
}
export interface PortfolioGoal {
id: string;
userId: string;
name: string;
targetAmount: number;
currentAmount: number;
targetDate: Date;
monthlyContribution: number;
progress: number;
projectedCompletion: Date | null;
status: 'on_track' | 'at_risk' | 'behind';
createdAt: Date;
updatedAt: Date;
}
export interface RebalanceRecommendation {
asset: string;
currentPercent: number;
targetPercent: number;
action: 'buy' | 'sell' | 'hold';
amount: number;
amountUSD: number;
priority: 'high' | 'medium' | 'low';
}
export interface PortfolioStats {
totalValue: number;
dayChange: number;
dayChangePercent: number;
weekChange: number;
weekChangePercent: number;
monthChange: number;
monthChangePercent: number;
allTimeChange: number;
allTimeChangePercent: number;
bestPerformer: { asset: string; change: number };
worstPerformer: { asset: string; change: number };
}
// ============================================================================
// Default Allocations by Risk Profile
// ============================================================================
const DEFAULT_ALLOCATIONS: Record<RiskProfile, { asset: string; percent: number }[]> = {
conservative: [
{ asset: 'USDT', percent: 50 },
{ asset: 'BTC', percent: 30 },
{ asset: 'ETH', percent: 20 },
],
moderate: [
{ asset: 'USDT', percent: 20 },
{ asset: 'BTC', percent: 40 },
{ asset: 'ETH', percent: 25 },
{ asset: 'SOL', percent: 10 },
{ asset: 'LINK', percent: 5 },
],
aggressive: [
{ asset: 'USDT', percent: 10 },
{ asset: 'BTC', percent: 30 },
{ asset: 'ETH', percent: 25 },
{ asset: 'SOL', percent: 15 },
{ asset: 'LINK', percent: 10 },
{ asset: 'AVAX', percent: 10 },
],
};
// ============================================================================
// In-Memory Storage
// ============================================================================
const portfolios: Map<string, Portfolio> = new Map();
const goals: Map<string, PortfolioGoal> = new Map();
// ============================================================================
// Portfolio Service
// ============================================================================
class PortfolioService {
// ==========================================================================
// Portfolio Management
// ==========================================================================
/**
* Create a new portfolio
*/
async createPortfolio(
userId: string,
name: string,
riskProfile: RiskProfile,
initialValue: number = 0
): Promise<Portfolio> {
const defaultAllocations = DEFAULT_ALLOCATIONS[riskProfile];
const portfolio: Portfolio = {
id: uuidv4(),
userId,
name,
riskProfile,
allocations: defaultAllocations.map((a) => ({
id: uuidv4(),
portfolioId: '',
asset: a.asset,
targetPercent: a.percent,
currentPercent: a.percent,
quantity: 0,
value: (initialValue * a.percent) / 100,
cost: (initialValue * a.percent) / 100,
pnl: 0,
pnlPercent: 0,
deviation: 0,
})),
totalValue: initialValue,
totalCost: initialValue,
unrealizedPnl: 0,
unrealizedPnlPercent: 0,
realizedPnl: 0,
lastRebalanced: null,
createdAt: new Date(),
updatedAt: new Date(),
};
// Set portfolio ID in allocations
portfolio.allocations.forEach((a) => {
a.portfolioId = portfolio.id;
});
portfolios.set(portfolio.id, portfolio);
return portfolio;
}
/**
* Get portfolio by ID
*/
async getPortfolio(portfolioId: string): Promise<Portfolio | null> {
const portfolio = portfolios.get(portfolioId);
if (!portfolio) return null;
// Update current values
await this.updatePortfolioValues(portfolio);
return portfolio;
}
/**
* Get user portfolios
*/
async getUserPortfolios(userId: string): Promise<Portfolio[]> {
const userPortfolios = Array.from(portfolios.values())
.filter((p) => p.userId === userId);
// Update values for all portfolios
await Promise.all(userPortfolios.map((p) => this.updatePortfolioValues(p)));
return userPortfolios;
}
/**
* Update portfolio allocations
*/
async updateAllocations(
portfolioId: string,
allocations: { asset: string; targetPercent: number }[]
): Promise<Portfolio> {
const portfolio = portfolios.get(portfolioId);
if (!portfolio) {
throw new Error(`Portfolio not found: ${portfolioId}`);
}
// Validate total is 100%
const total = allocations.reduce((sum, a) => sum + a.targetPercent, 0);
if (Math.abs(total - 100) > 0.01) {
throw new Error('Allocations must sum to 100%');
}
// Update allocations
portfolio.allocations = allocations.map((a) => {
const existing = portfolio.allocations.find((e) => e.asset === a.asset);
return {
id: existing?.id || uuidv4(),
portfolioId,
asset: a.asset,
targetPercent: a.targetPercent,
currentPercent: existing?.currentPercent || 0,
quantity: existing?.quantity || 0,
value: existing?.value || 0,
cost: existing?.cost || 0,
pnl: existing?.pnl || 0,
pnlPercent: existing?.pnlPercent || 0,
deviation: 0,
};
});
portfolio.updatedAt = new Date();
await this.updatePortfolioValues(portfolio);
return portfolio;
}
/**
* Get rebalancing recommendations
*/
async getRebalanceRecommendations(portfolioId: string): Promise<RebalanceRecommendation[]> {
const portfolio = await this.getPortfolio(portfolioId);
if (!portfolio) {
throw new Error(`Portfolio not found: ${portfolioId}`);
}
const recommendations: RebalanceRecommendation[] = [];
const rebalanceThreshold = 5; // 5% deviation triggers rebalance
for (const allocation of portfolio.allocations) {
const deviation = allocation.currentPercent - allocation.targetPercent;
const absDeviation = Math.abs(deviation);
if (absDeviation < rebalanceThreshold) {
recommendations.push({
asset: allocation.asset,
currentPercent: allocation.currentPercent,
targetPercent: allocation.targetPercent,
action: 'hold',
amount: 0,
amountUSD: 0,
priority: 'low',
});
continue;
}
const targetValue = (portfolio.totalValue * allocation.targetPercent) / 100;
const difference = targetValue - allocation.value;
recommendations.push({
asset: allocation.asset,
currentPercent: allocation.currentPercent,
targetPercent: allocation.targetPercent,
action: difference > 0 ? 'buy' : 'sell',
amount: Math.abs(difference / (allocation.value / allocation.quantity || 1)),
amountUSD: Math.abs(difference),
priority: absDeviation > 10 ? 'high' : 'medium',
});
}
// Sort by priority and absolute deviation
return recommendations.sort((a, b) => {
const priorityOrder = { high: 0, medium: 1, low: 2 };
return priorityOrder[a.priority] - priorityOrder[b.priority];
});
}
/**
* Execute rebalancing
*/
async executeRebalance(portfolioId: string): Promise<Portfolio> {
const portfolio = await this.getPortfolio(portfolioId);
if (!portfolio) {
throw new Error(`Portfolio not found: ${portfolioId}`);
}
// Simulate rebalancing by adjusting allocations to target
for (const allocation of portfolio.allocations) {
allocation.currentPercent = allocation.targetPercent;
allocation.deviation = 0;
}
portfolio.lastRebalanced = new Date();
portfolio.updatedAt = new Date();
return portfolio;
}
/**
* Get portfolio statistics
*/
async getPortfolioStats(portfolioId: string): Promise<PortfolioStats> {
const portfolio = await this.getPortfolio(portfolioId);
if (!portfolio) {
throw new Error(`Portfolio not found: ${portfolioId}`);
}
// Calculate performance (mock data for now)
const dayChange = portfolio.totalValue * 0.02; // 2% daily change
const weekChange = portfolio.totalValue * 0.05;
const monthChange = portfolio.totalValue * 0.12;
// Find best and worst performers
const sortedAllocations = [...portfolio.allocations].sort(
(a, b) => b.pnlPercent - a.pnlPercent
);
return {
totalValue: portfolio.totalValue,
dayChange,
dayChangePercent: (dayChange / portfolio.totalValue) * 100,
weekChange,
weekChangePercent: (weekChange / portfolio.totalValue) * 100,
monthChange,
monthChangePercent: (monthChange / portfolio.totalValue) * 100,
allTimeChange: portfolio.unrealizedPnl + portfolio.realizedPnl,
allTimeChangePercent: portfolio.unrealizedPnlPercent,
bestPerformer: {
asset: sortedAllocations[0]?.asset || 'N/A',
change: sortedAllocations[0]?.pnlPercent || 0,
},
worstPerformer: {
asset: sortedAllocations[sortedAllocations.length - 1]?.asset || 'N/A',
change: sortedAllocations[sortedAllocations.length - 1]?.pnlPercent || 0,
},
};
}
// ==========================================================================
// Goal Management
// ==========================================================================
/**
* Create a financial goal
*/
async createGoal(
userId: string,
name: string,
targetAmount: number,
targetDate: Date,
monthlyContribution: number
): Promise<PortfolioGoal> {
const goal: PortfolioGoal = {
id: uuidv4(),
userId,
name,
targetAmount,
currentAmount: 0,
targetDate,
monthlyContribution,
progress: 0,
projectedCompletion: null,
status: 'on_track',
createdAt: new Date(),
updatedAt: new Date(),
};
this.updateGoalProjection(goal);
goals.set(goal.id, goal);
return goal;
}
/**
* Get user goals
*/
async getUserGoals(userId: string): Promise<PortfolioGoal[]> {
return Array.from(goals.values())
.filter((g) => g.userId === userId)
.map((g) => {
this.updateGoalProjection(g);
return g;
});
}
/**
* Update goal progress
*/
async updateGoalProgress(
goalId: string,
currentAmount: number
): Promise<PortfolioGoal> {
const goal = goals.get(goalId);
if (!goal) {
throw new Error(`Goal not found: ${goalId}`);
}
goal.currentAmount = currentAmount;
goal.progress = (currentAmount / goal.targetAmount) * 100;
goal.updatedAt = new Date();
this.updateGoalProjection(goal);
return goal;
}
/**
* Delete a goal
*/
async deleteGoal(goalId: string): Promise<boolean> {
return goals.delete(goalId);
}
// ==========================================================================
// Private Methods
// ==========================================================================
private async updatePortfolioValues(portfolio: Portfolio): Promise<void> {
let totalValue = 0;
for (const allocation of portfolio.allocations) {
if (allocation.asset === 'USDT') {
// Stablecoin, value = quantity
allocation.value = allocation.quantity;
} else {
try {
const price = await marketService.getPrice(`${allocation.asset}USDT`);
allocation.value = allocation.quantity * price.price;
} catch {
// Keep existing value if price fetch fails
}
}
allocation.pnl = allocation.value - allocation.cost;
allocation.pnlPercent =
allocation.cost > 0 ? (allocation.pnl / allocation.cost) * 100 : 0;
totalValue += allocation.value;
}
portfolio.totalValue = totalValue;
portfolio.unrealizedPnl = totalValue - portfolio.totalCost;
portfolio.unrealizedPnlPercent =
portfolio.totalCost > 0
? (portfolio.unrealizedPnl / portfolio.totalCost) * 100
: 0;
// Update current percentages and deviations
for (const allocation of portfolio.allocations) {
allocation.currentPercent =
totalValue > 0 ? (allocation.value / totalValue) * 100 : 0;
allocation.deviation = allocation.currentPercent - allocation.targetPercent;
}
portfolio.updatedAt = new Date();
}
private updateGoalProjection(goal: PortfolioGoal): void {
const monthsRemaining = Math.max(
0,
(goal.targetDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24 * 30)
);
const amountNeeded = goal.targetAmount - goal.currentAmount;
const projectedFinalAmount =
goal.currentAmount + goal.monthlyContribution * monthsRemaining;
if (projectedFinalAmount >= goal.targetAmount) {
goal.status = 'on_track';
// Calculate when goal will be reached
const monthsToGoal = amountNeeded / goal.monthlyContribution;
goal.projectedCompletion = new Date(
Date.now() + monthsToGoal * 30 * 24 * 60 * 60 * 1000
);
} else if (projectedFinalAmount >= goal.targetAmount * 0.8) {
goal.status = 'at_risk';
goal.projectedCompletion = null;
} else {
goal.status = 'behind';
goal.projectedCompletion = null;
}
}
}
// Export singleton instance
export const portfolioService = new PortfolioService();

View File

@ -1,189 +0,0 @@
/**
* Price Alerts Controller
* Handles price alert endpoints
*/
import type { Request, Response, NextFunction } from 'express';
import { alertsService, AlertCondition } from '../services/alerts.service';
import type { AuthenticatedRequest } from '../../../core/guards/auth.guard';
// ============================================================================
// CRUD Operations
// ============================================================================
export async function createAlert(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { symbol, condition, price, note, notifyEmail, notifyPush, isRecurring } = req.body;
if (!symbol || !condition || price === undefined) {
res.status(400).json({
success: false,
error: 'Symbol, condition, and price are required',
});
return;
}
const validConditions: AlertCondition[] = ['above', 'below', 'crosses_above', 'crosses_below'];
if (!validConditions.includes(condition)) {
res.status(400).json({
success: false,
error: `Invalid condition. Must be one of: ${validConditions.join(', ')}`,
});
return;
}
const alert = await alertsService.createAlert({
userId: authReq.user.id,
symbol,
condition,
price,
note,
notifyEmail,
notifyPush,
isRecurring,
});
res.status(201).json({ success: true, data: alert });
} catch (error) {
next(error);
}
}
export async function getAlerts(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { isActive, symbol, condition } = req.query;
const alerts = await alertsService.getUserAlerts(authReq.user.id, {
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined,
symbol: symbol as string,
condition: condition as AlertCondition,
});
res.json({ success: true, data: alerts });
} catch (error) {
next(error);
}
}
export async function getAlertById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { alertId } = req.params;
const alert = await alertsService.getAlertById(alertId);
if (!alert) {
res.status(404).json({ success: false, error: 'Alert not found' });
return;
}
if (alert.userId !== authReq.user.id) {
res.status(403).json({ success: false, error: 'Unauthorized' });
return;
}
res.json({ success: true, data: alert });
} catch (error) {
next(error);
}
}
export async function updateAlert(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { alertId } = req.params;
const { price, note, notifyEmail, notifyPush, isRecurring, isActive } = req.body;
const alert = await alertsService.updateAlert(alertId, authReq.user.id, {
price,
note,
notifyEmail,
notifyPush,
isRecurring,
isActive,
});
if (!alert) {
res.status(404).json({ success: false, error: 'Alert not found or unauthorized' });
return;
}
res.json({ success: true, data: alert });
} catch (error) {
next(error);
}
}
export async function deleteAlert(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { alertId } = req.params;
const deleted = await alertsService.deleteAlert(alertId, authReq.user.id);
if (!deleted) {
res.status(404).json({ success: false, error: 'Alert not found or unauthorized' });
return;
}
res.json({ success: true, message: 'Alert deleted' });
} catch (error) {
next(error);
}
}
// ============================================================================
// Enable/Disable
// ============================================================================
export async function enableAlert(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { alertId } = req.params;
const alert = await alertsService.enableAlert(alertId, authReq.user.id);
if (!alert) {
res.status(404).json({ success: false, error: 'Alert not found or unauthorized' });
return;
}
res.json({ success: true, data: alert });
} catch (error) {
next(error);
}
}
export async function disableAlert(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { alertId } = req.params;
const alert = await alertsService.disableAlert(alertId, authReq.user.id);
if (!alert) {
res.status(404).json({ success: false, error: 'Alert not found or unauthorized' });
return;
}
res.json({ success: true, data: alert });
} catch (error) {
next(error);
}
}
// ============================================================================
// Statistics
// ============================================================================
export async function getAlertStats(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const stats = await alertsService.getUserAlertStats(authReq.user.id);
res.json({ success: true, data: stats });
} catch (error) {
next(error);
}
}

View File

@ -1,177 +0,0 @@
/**
* Technical Indicators Controller
* Handles indicator calculation endpoints
*/
import type { Request, Response, NextFunction } from 'express';
import { indicatorsService } from '../services/indicators.service';
import type { Interval } from '../services/binance.service';
// ============================================================================
// Moving Averages
// ============================================================================
export async function getSMA(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
const interval = (req.query.interval as Interval) || '1h';
const period = parseInt(req.query.period as string, 10) || 20;
const limit = parseInt(req.query.limit as string, 10) || 100;
const data = await indicatorsService.getSMA({ symbol, interval, period, limit });
res.json({ success: true, data });
} catch (error) {
next(error);
}
}
export async function getEMA(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
const interval = (req.query.interval as Interval) || '1h';
const period = parseInt(req.query.period as string, 10) || 20;
const limit = parseInt(req.query.limit as string, 10) || 100;
const data = await indicatorsService.getEMA({ symbol, interval, period, limit });
res.json({ success: true, data });
} catch (error) {
next(error);
}
}
// ============================================================================
// Oscillators
// ============================================================================
export async function getRSI(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
const interval = (req.query.interval as Interval) || '1h';
const period = parseInt(req.query.period as string, 10) || 14;
const limit = parseInt(req.query.limit as string, 10) || 100;
const data = await indicatorsService.getRSI({ symbol, interval, period, limit });
res.json({ success: true, data });
} catch (error) {
next(error);
}
}
export async function getMACD(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
const interval = (req.query.interval as Interval) || '1h';
const fastPeriod = parseInt(req.query.fastPeriod as string, 10) || 12;
const slowPeriod = parseInt(req.query.slowPeriod as string, 10) || 26;
const signalPeriod = parseInt(req.query.signalPeriod as string, 10) || 9;
const limit = parseInt(req.query.limit as string, 10) || 100;
const data = await indicatorsService.getMACD({
symbol,
interval,
fastPeriod,
slowPeriod,
signalPeriod,
limit,
});
res.json({ success: true, data });
} catch (error) {
next(error);
}
}
export async function getStochastic(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
const interval = (req.query.interval as Interval) || '1h';
const kPeriod = parseInt(req.query.kPeriod as string, 10) || 14;
const dPeriod = parseInt(req.query.dPeriod as string, 10) || 3;
const smoothK = parseInt(req.query.smoothK as string, 10) || 3;
const limit = parseInt(req.query.limit as string, 10) || 100;
const data = await indicatorsService.getStochastic({
symbol,
interval,
kPeriod,
dPeriod,
smoothK,
limit,
});
res.json({ success: true, data });
} catch (error) {
next(error);
}
}
// ============================================================================
// Volatility Indicators
// ============================================================================
export async function getBollingerBands(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
const interval = (req.query.interval as Interval) || '1h';
const period = parseInt(req.query.period as string, 10) || 20;
const stdDev = parseFloat(req.query.stdDev as string) || 2;
const limit = parseInt(req.query.limit as string, 10) || 100;
const data = await indicatorsService.getBollingerBands({
symbol,
interval,
period,
stdDev,
limit,
});
res.json({ success: true, data });
} catch (error) {
next(error);
}
}
export async function getATR(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
const interval = (req.query.interval as Interval) || '1h';
const period = parseInt(req.query.period as string, 10) || 14;
const limit = parseInt(req.query.limit as string, 10) || 100;
const data = await indicatorsService.getATR({ symbol, interval, period, limit });
res.json({ success: true, data });
} catch (error) {
next(error);
}
}
// ============================================================================
// Volume Indicators
// ============================================================================
export async function getVWAP(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
const interval = (req.query.interval as Interval) || '1h';
const limit = parseInt(req.query.limit as string, 10) || 100;
const data = await indicatorsService.getVWAP({ symbol, interval, limit });
res.json({ success: true, data });
} catch (error) {
next(error);
}
}
// ============================================================================
// All-in-One
// ============================================================================
export async function getAllIndicators(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
const interval = (req.query.interval as Interval) || '1h';
const limit = parseInt(req.query.limit as string, 10) || 100;
const data = await indicatorsService.getAllIndicators(symbol, interval, limit);
res.json({ success: true, data });
} catch (error) {
next(error);
}
}

View File

@ -1,253 +0,0 @@
/**
* Paper Trading Controller
* Handles paper trading account and position endpoints
*/
import type { Request, Response, NextFunction } from 'express';
import { paperTradingService, TradeDirection } from '../services/paper-trading.service';
import type { AuthenticatedRequest } from '../../../core/guards/auth.guard';
// ============================================================================
// Account Endpoints
// ============================================================================
export async function getAccount(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const account = await paperTradingService.getOrCreateAccount(authReq.user.id);
res.json({ success: true, data: account });
} catch (error) {
next(error);
}
}
export async function getAccounts(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const accounts = await paperTradingService.getUserAccounts(authReq.user.id);
res.json({ success: true, data: accounts });
} catch (error) {
next(error);
}
}
export async function createAccount(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { name, initialBalance, currency } = req.body;
const account = await paperTradingService.createAccount(authReq.user.id, {
name,
initialBalance,
currency,
});
res.status(201).json({ success: true, data: account });
} catch (error) {
next(error);
}
}
export async function resetAccount(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { accountId } = req.params;
const account = await paperTradingService.resetAccount(accountId, authReq.user.id);
if (!account) {
res.status(404).json({ success: false, error: 'Account not found or unauthorized' });
return;
}
res.json({ success: true, data: account, message: 'Account reset successfully' });
} catch (error) {
next(error);
}
}
export async function getAccountSummary(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { accountId } = req.params;
const summary = await paperTradingService.getAccountSummary(authReq.user.id, accountId);
if (!summary) {
res.status(404).json({ success: false, error: 'Account not found' });
return;
}
res.json({ success: true, data: summary });
} catch (error) {
next(error);
}
}
// ============================================================================
// Position Endpoints
// ============================================================================
export async function openPosition(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { symbol, direction, lotSize, entryPrice, stopLoss, takeProfit } = req.body;
if (!symbol || !direction || !lotSize) {
res.status(400).json({
success: false,
error: 'Symbol, direction, and lotSize are required',
});
return;
}
const validDirections: TradeDirection[] = ['long', 'short'];
if (!validDirections.includes(direction)) {
res.status(400).json({
success: false,
error: `Invalid direction. Must be one of: ${validDirections.join(', ')}`,
});
return;
}
if (lotSize <= 0) {
res.status(400).json({
success: false,
error: 'Lot size must be greater than 0',
});
return;
}
const position = await paperTradingService.openPosition(authReq.user.id, {
symbol,
direction,
lotSize,
entryPrice,
stopLoss,
takeProfit,
});
res.status(201).json({ success: true, data: position });
} catch (error) {
next(error);
}
}
export async function closePosition(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { positionId } = req.params;
const { exitPrice, closeReason } = req.body;
const position = await paperTradingService.closePosition(positionId, authReq.user.id, {
exitPrice,
closeReason,
});
if (!position) {
res.status(404).json({ success: false, error: 'Position not found or already closed' });
return;
}
res.json({ success: true, data: position });
} catch (error) {
next(error);
}
}
export async function getPosition(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { positionId } = req.params;
const position = await paperTradingService.getPosition(positionId, authReq.user.id);
if (!position) {
res.status(404).json({ success: false, error: 'Position not found' });
return;
}
res.json({ success: true, data: position });
} catch (error) {
next(error);
}
}
export async function getPositions(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { accountId, status, symbol, limit } = req.query;
const positions = await paperTradingService.getPositions(authReq.user.id, {
accountId: accountId as string,
status: status as 'open' | 'closed' | 'pending',
symbol: symbol as string,
limit: limit ? parseInt(limit as string, 10) : undefined,
});
res.json({ success: true, data: positions });
} catch (error) {
next(error);
}
}
export async function updatePosition(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { positionId } = req.params;
const { stopLoss, takeProfit } = req.body;
const position = await paperTradingService.updatePosition(positionId, authReq.user.id, {
stopLoss,
takeProfit,
});
if (!position) {
res.status(404).json({ success: false, error: 'Position not found or not open' });
return;
}
res.json({ success: true, data: position });
} catch (error) {
next(error);
}
}
// ============================================================================
// Trade History & Analytics
// ============================================================================
export async function getTradeHistory(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { accountId, symbol, startDate, endDate, limit } = req.query;
const trades = await paperTradingService.getTradeHistory(authReq.user.id, {
accountId: accountId as string,
symbol: symbol as string,
startDate: startDate ? new Date(startDate as string) : undefined,
endDate: endDate ? new Date(endDate as string) : undefined,
limit: limit ? parseInt(limit as string, 10) : undefined,
});
res.json({ success: true, data: trades });
} catch (error) {
next(error);
}
}
export async function getPerformanceStats(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
const { accountId } = req.query;
const stats = await paperTradingService.getPerformanceStats(
authReq.user.id,
accountId as string | undefined
);
res.json({ success: true, data: stats });
} catch (error) {
next(error);
}
}

View File

@ -1,629 +0,0 @@
/**
* Trading Controller
* Handles market data and paper trading endpoints
*/
import { Request, Response, NextFunction } from 'express';
import { marketService } from '../services/market.service';
import { paperTradingService, PositionStatus, TradeDirection } from '../services/paper-trading.service';
import { Interval } from '../services/binance.service';
// ============================================================================
// Types
// ============================================================================
// Use Request directly - user is already declared globally in auth.middleware.ts
type AuthRequest = Request;
// ============================================================================
// Market Data Controllers
// ============================================================================
/**
* Get candlestick/kline data
*/
export async function getKlines(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
const { interval = '1h', startTime, endTime, limit } = req.query;
const klines = await marketService.getKlines(symbol, interval as Interval, {
startTime: startTime ? Number(startTime) : undefined,
endTime: endTime ? Number(endTime) : undefined,
limit: limit ? Number(limit) : undefined,
});
res.json({
success: true,
data: klines,
});
} catch (error) {
next(error);
}
}
/**
* Get current price
*/
export async function getPrice(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
const price = await marketService.getPrice(symbol);
res.json({
success: true,
data: price,
});
} catch (error) {
next(error);
}
}
/**
* Get prices for multiple symbols
*/
export async function getPrices(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbols } = req.query;
const symbolList = symbols ? (symbols as string).split(',') : undefined;
const prices = await marketService.getPrices(symbolList);
res.json({
success: true,
data: prices,
});
} catch (error) {
next(error);
}
}
/**
* Get 24h ticker
*/
export async function getTicker(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
const ticker = await marketService.getTicker(symbol);
res.json({
success: true,
data: ticker,
});
} catch (error) {
next(error);
}
}
/**
* Get tickers for multiple symbols
*/
export async function getTickers(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbols } = req.query;
const symbolList = symbols ? (symbols as string).split(',') : undefined;
const tickers = await marketService.getTickers(symbolList);
res.json({
success: true,
data: tickers,
});
} catch (error) {
next(error);
}
}
/**
* Get order book
*/
export async function getOrderBook(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbol } = req.params;
const { limit = 20 } = req.query;
const orderBook = await marketService.getOrderBook(symbol, Number(limit));
res.json({
success: true,
data: orderBook,
});
} catch (error) {
next(error);
}
}
/**
* Search symbols
*/
export async function searchSymbols(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { query = '', limit = 20 } = req.query;
const symbols = marketService.searchSymbols(query as string, Number(limit));
res.json({
success: true,
data: symbols,
});
} catch (error) {
next(error);
}
}
/**
* Get popular symbols
*/
export async function getPopularSymbols(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const symbols = marketService.getPopularSymbols();
const tickers = await marketService.getTickers(symbols);
res.json({
success: true,
data: tickers,
});
} catch (error) {
next(error);
}
}
/**
* Get watchlist data
*/
export async function getWatchlist(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { symbols } = req.query;
if (!symbols) {
res.status(400).json({
success: false,
error: { message: 'Symbols parameter is required', code: 'MISSING_SYMBOLS' },
});
return;
}
const symbolList = (symbols as string).split(',');
const watchlist = await marketService.getWatchlist(symbolList);
res.json({
success: true,
data: watchlist,
});
} catch (error) {
next(error);
}
}
// ============================================================================
// Paper Trading Controllers
// ============================================================================
/**
* Initialize paper trading account (get or create)
*/
export async function initializePaperAccount(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { initialBalance = 100000, name, currency } = req.body;
const account = await paperTradingService.createAccount(userId, {
name,
initialBalance,
currency,
});
res.json({
success: true,
data: account,
message: 'Paper trading account initialized',
});
} catch (error) {
next(error);
}
}
/**
* Get paper trading account (balances)
*/
export async function getPaperBalances(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const account = await paperTradingService.getOrCreateAccount(userId);
res.json({
success: true,
data: {
currentBalance: account.currentBalance,
initialBalance: account.initialBalance,
currency: account.currency,
totalPnl: account.totalPnl,
},
});
} catch (error) {
next(error);
}
}
/**
* Create paper trading order (open position)
*/
export async function createPaperOrder(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { symbol, side, quantity, price, stopLoss, takeProfit } = req.body;
// Validate required fields
if (!symbol || !side || !quantity) {
res.status(400).json({
success: false,
error: { message: 'Missing required fields: symbol, side, quantity', code: 'VALIDATION_ERROR' },
});
return;
}
// Map side to direction
const direction: TradeDirection = side === 'buy' ? 'long' : 'short';
const position = await paperTradingService.openPosition(userId, {
symbol,
direction,
lotSize: quantity,
entryPrice: price,
stopLoss,
takeProfit,
});
res.status(201).json({
success: true,
data: position,
});
} catch (error) {
next(error);
}
}
/**
* Cancel paper trading order (not applicable in new model, returns error)
*/
export async function cancelPaperOrder(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
// In the new PostgreSQL-based model, we don't have pending orders - positions are opened immediately
res.status(400).json({
success: false,
error: { message: 'Order cancellation not supported. Use close position instead.', code: 'NOT_SUPPORTED' },
});
} catch (error) {
next(error);
}
}
/**
* Get paper trading orders (returns open positions as "orders")
*/
export async function getPaperOrders(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { symbol, limit } = req.query;
// Return open positions as "orders" for backwards compatibility
const positions = await paperTradingService.getPositions(userId, {
status: 'open',
symbol: symbol as string | undefined,
limit: limit ? Number(limit) : undefined,
});
res.json({
success: true,
data: positions,
});
} catch (error) {
next(error);
}
}
/**
* Get paper trading positions
*/
export async function getPaperPositions(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { status, symbol } = req.query;
const positions = await paperTradingService.getPositions(userId, {
status: status as PositionStatus | undefined,
symbol: symbol as string | undefined,
});
res.json({
success: true,
data: positions,
});
} catch (error) {
next(error);
}
}
/**
* Close paper trading position
*/
export async function closePaperPosition(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { positionId } = req.params;
const { exitPrice, closeReason } = req.body;
const position = await paperTradingService.closePosition(positionId, userId, {
exitPrice,
closeReason,
});
if (!position) {
res.status(404).json({
success: false,
error: { message: 'Position not found or already closed', code: 'NOT_FOUND' },
});
return;
}
res.json({
success: true,
data: position,
});
} catch (error) {
next(error);
}
}
/**
* Get paper trading trades history
*/
export async function getPaperTrades(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { symbol, limit, startTime, endTime } = req.query;
const trades = await paperTradingService.getTradeHistory(userId, {
symbol: symbol as string | undefined,
limit: limit ? Number(limit) : undefined,
startDate: startTime ? new Date(startTime as string) : undefined,
endDate: endTime ? new Date(endTime as string) : undefined,
});
res.json({
success: true,
data: trades,
});
} catch (error) {
next(error);
}
}
/**
* Get paper trading portfolio summary
*/
export async function getPaperPortfolio(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const summary = await paperTradingService.getAccountSummary(userId);
if (!summary) {
res.status(404).json({
success: false,
error: { message: 'Account not found', code: 'NOT_FOUND' },
});
return;
}
res.json({
success: true,
data: {
totalEquity: summary.totalEquity,
totalCash: summary.account.currentBalance,
unrealizedPnl: summary.unrealizedPnl,
realizedPnl: summary.account.totalPnl,
todayPnl: summary.todayPnl,
todayPnlPercent: (summary.todayPnl / summary.totalEquity) * 100,
allTimePnl: summary.totalEquity - summary.account.initialBalance,
allTimePnlPercent: ((summary.totalEquity - summary.account.initialBalance) / summary.account.initialBalance) * 100,
openPositions: summary.openPositions,
winRate: summary.winRate,
},
});
} catch (error) {
next(error);
}
}
/**
* Reset paper trading account
*/
export async function resetPaperAccount(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
// Get the user's account first
const account = await paperTradingService.getOrCreateAccount(userId);
const resetAccount = await paperTradingService.resetAccount(account.id, userId);
if (!resetAccount) {
res.status(404).json({
success: false,
error: { message: 'Account not found', code: 'NOT_FOUND' },
});
return;
}
res.json({
success: true,
data: resetAccount,
message: 'Paper trading account reset successfully',
});
} catch (error) {
next(error);
}
}
/**
* Get paper trading settings (returns account info)
*/
export async function getPaperSettings(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const account = await paperTradingService.getOrCreateAccount(userId);
res.json({
success: true,
data: {
initialBalance: account.initialBalance,
currency: account.currency,
name: account.name,
},
});
} catch (error) {
next(error);
}
}
/**
* Update paper trading settings (limited - account name only)
*/
export async function updatePaperSettings(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
// Settings are now part of the account - to change settings, create a new account
res.status(400).json({
success: false,
error: { message: 'Settings update not supported. Reset account or create a new one.', code: 'NOT_SUPPORTED' },
});
} catch (error) {
next(error);
}
}
/**
* Get paper trading performance statistics
*/
export async function getPaperStats(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return;
}
const { accountId } = req.query;
const stats = await paperTradingService.getPerformanceStats(
userId,
accountId as string | undefined
);
res.json({
success: true,
data: stats,
});
} catch (error) {
next(error);
}
}

View File

@ -1,396 +0,0 @@
/**
* Watchlist Controller
* Handles user watchlist CRUD operations
*/
import { Request, Response, NextFunction } from 'express';
import {
watchlistService,
CreateWatchlistInput,
UpdateWatchlistInput,
AddSymbolInput,
UpdateSymbolInput,
} from '../services/watchlist.service';
// ============================================================================
// Types
// ============================================================================
// Use Request directly - user is already declared globally in auth.middleware.ts
type AuthRequest = Request;
// ============================================================================
// Helper
// ============================================================================
function getUserId(req: AuthRequest, res: Response): string | null {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
});
return null;
}
return userId;
}
// ============================================================================
// Watchlist Controllers
// ============================================================================
/**
* Get all watchlists for the authenticated user
*/
export async function getUserWatchlists(
req: AuthRequest,
res: Response,
next: NextFunction
): Promise<void> {
try {
const userId = getUserId(req, res);
if (!userId) return;
const watchlists = await watchlistService.getUserWatchlists(userId);
res.json({
success: true,
data: watchlists,
});
} catch (error) {
next(error);
}
}
/**
* Get a single watchlist with items
*/
export async function getWatchlist(
req: AuthRequest,
res: Response,
next: NextFunction
): Promise<void> {
try {
const userId = getUserId(req, res);
if (!userId) return;
const { watchlistId } = req.params;
const watchlist = await watchlistService.getWatchlist(watchlistId, userId);
if (!watchlist) {
res.status(404).json({
success: false,
error: { message: 'Watchlist not found', code: 'NOT_FOUND' },
});
return;
}
res.json({
success: true,
data: watchlist,
});
} catch (error) {
next(error);
}
}
/**
* Get default watchlist for user
*/
export async function getDefaultWatchlist(
req: AuthRequest,
res: Response,
next: NextFunction
): Promise<void> {
try {
const userId = getUserId(req, res);
if (!userId) return;
const watchlist = await watchlistService.getDefaultWatchlist(userId);
if (!watchlist) {
res.status(404).json({
success: false,
error: { message: 'No default watchlist found', code: 'NOT_FOUND' },
});
return;
}
res.json({
success: true,
data: watchlist,
});
} catch (error) {
next(error);
}
}
/**
* Create a new watchlist
*/
export async function createWatchlist(
req: AuthRequest,
res: Response,
next: NextFunction
): Promise<void> {
try {
const userId = getUserId(req, res);
if (!userId) return;
const input: CreateWatchlistInput = req.body;
if (!input.name) {
res.status(400).json({
success: false,
error: { message: 'Name is required', code: 'VALIDATION_ERROR' },
});
return;
}
const watchlist = await watchlistService.createWatchlist(userId, input);
res.status(201).json({
success: true,
data: watchlist,
});
} catch (error) {
next(error);
}
}
/**
* Update a watchlist
*/
export async function updateWatchlist(
req: AuthRequest,
res: Response,
next: NextFunction
): Promise<void> {
try {
const userId = getUserId(req, res);
if (!userId) return;
const { watchlistId } = req.params;
const input: UpdateWatchlistInput = req.body;
const watchlist = await watchlistService.updateWatchlist(watchlistId, userId, input);
if (!watchlist) {
res.status(404).json({
success: false,
error: { message: 'Watchlist not found', code: 'NOT_FOUND' },
});
return;
}
res.json({
success: true,
data: watchlist,
});
} catch (error) {
next(error);
}
}
/**
* Delete a watchlist
*/
export async function deleteWatchlist(
req: AuthRequest,
res: Response,
next: NextFunction
): Promise<void> {
try {
const userId = getUserId(req, res);
if (!userId) return;
const { watchlistId } = req.params;
const deleted = await watchlistService.deleteWatchlist(watchlistId, userId);
if (!deleted) {
res.status(404).json({
success: false,
error: { message: 'Watchlist not found', code: 'NOT_FOUND' },
});
return;
}
res.json({
success: true,
message: 'Watchlist deleted successfully',
});
} catch (error: unknown) {
if ((error as Error).message === 'Cannot delete default watchlist') {
res.status(400).json({
success: false,
error: { message: 'Cannot delete default watchlist', code: 'CANNOT_DELETE_DEFAULT' },
});
return;
}
next(error);
}
}
// ============================================================================
// Watchlist Items Controllers
// ============================================================================
/**
* Add a symbol to a watchlist
*/
export async function addSymbol(
req: AuthRequest,
res: Response,
next: NextFunction
): Promise<void> {
try {
const userId = getUserId(req, res);
if (!userId) return;
const { watchlistId } = req.params;
const input: AddSymbolInput = req.body;
if (!input.symbol) {
res.status(400).json({
success: false,
error: { message: 'Symbol is required', code: 'VALIDATION_ERROR' },
});
return;
}
const item = await watchlistService.addSymbol(watchlistId, userId, input);
if (!item) {
res.status(404).json({
success: false,
error: { message: 'Watchlist not found', code: 'NOT_FOUND' },
});
return;
}
res.status(201).json({
success: true,
data: item,
});
} catch (error: unknown) {
if ((error as Error).message === 'Symbol already in watchlist') {
res.status(409).json({
success: false,
error: { message: 'Symbol already in watchlist', code: 'DUPLICATE_SYMBOL' },
});
return;
}
next(error);
}
}
/**
* Update a symbol in a watchlist
*/
export async function updateSymbol(
req: AuthRequest,
res: Response,
next: NextFunction
): Promise<void> {
try {
const userId = getUserId(req, res);
if (!userId) return;
const { watchlistId, symbol } = req.params;
const input: UpdateSymbolInput = req.body;
const item = await watchlistService.updateSymbol(watchlistId, symbol, userId, input);
if (!item) {
res.status(404).json({
success: false,
error: { message: 'Symbol not found in watchlist', code: 'NOT_FOUND' },
});
return;
}
res.json({
success: true,
data: item,
});
} catch (error) {
next(error);
}
}
/**
* Remove a symbol from a watchlist
*/
export async function removeSymbol(
req: AuthRequest,
res: Response,
next: NextFunction
): Promise<void> {
try {
const userId = getUserId(req, res);
if (!userId) return;
const { watchlistId, symbol } = req.params;
const removed = await watchlistService.removeSymbol(watchlistId, symbol, userId);
if (!removed) {
res.status(404).json({
success: false,
error: { message: 'Symbol not found in watchlist', code: 'NOT_FOUND' },
});
return;
}
res.json({
success: true,
message: 'Symbol removed from watchlist',
});
} catch (error) {
next(error);
}
}
/**
* Reorder symbols in a watchlist
*/
export async function reorderSymbols(
req: AuthRequest,
res: Response,
next: NextFunction
): Promise<void> {
try {
const userId = getUserId(req, res);
if (!userId) return;
const { watchlistId } = req.params;
const { symbolOrder } = req.body;
if (!Array.isArray(symbolOrder)) {
res.status(400).json({
success: false,
error: { message: 'symbolOrder must be an array', code: 'VALIDATION_ERROR' },
});
return;
}
const success = await watchlistService.reorderSymbols(watchlistId, userId, symbolOrder);
if (!success) {
res.status(404).json({
success: false,
error: { message: 'Watchlist not found', code: 'NOT_FOUND' },
});
return;
}
res.json({
success: true,
message: 'Symbols reordered successfully',
});
} catch (error) {
next(error);
}
}

View File

@ -1,507 +0,0 @@
/**
* Price Alerts Service Unit Tests
*
* Tests for price alerts service including:
* - Alert creation and management
* - Alert triggering logic
* - Notification preferences
* - Alert filtering
*/
import { mockDb, createMockQueryResult, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock';
import type { PriceAlert, AlertCondition } from '../alerts.service';
// Mock database
jest.mock('../../../../shared/database', () => ({
db: mockDb,
}));
// Mock logger
jest.mock('../../../../shared/utils/logger', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
// Import service after mocks
import { alertsService } from '../alerts.service';
describe('AlertsService', () => {
beforeEach(() => {
resetDatabaseMocks();
});
describe('createAlert', () => {
it('should create a price alert with all options', async () => {
const mockAlert: PriceAlert = {
id: 'alert-123',
userId: 'user-123',
symbol: 'BTCUSDT',
condition: 'above',
price: 60000,
note: 'Bitcoin hitting resistance',
isActive: true,
notifyEmail: true,
notifyPush: true,
isRecurring: false,
createdAt: new Date(),
};
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockAlert]));
const result = await alertsService.createAlert({
userId: 'user-123',
symbol: 'BTCUSDT',
condition: 'above',
price: 60000,
note: 'Bitcoin hitting resistance',
notifyEmail: true,
notifyPush: true,
});
expect(result).toEqual(mockAlert);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO trading.price_alerts'),
expect.arrayContaining(['user-123', 'BTCUSDT', 'above', 60000])
);
});
it('should create alert with default notification settings', async () => {
const mockAlert: PriceAlert = {
id: 'alert-124',
userId: 'user-123',
symbol: 'ETHUSDT',
condition: 'below',
price: 2500,
isActive: true,
notifyEmail: false,
notifyPush: true,
isRecurring: false,
createdAt: new Date(),
};
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockAlert]));
const result = await alertsService.createAlert({
userId: 'user-123',
symbol: 'ETHUSDT',
condition: 'below',
price: 2500,
});
expect(result.symbol).toBe('ETHUSDT');
expect(result.condition).toBe('below');
});
it('should normalize symbol to uppercase', async () => {
const mockAlert: PriceAlert = {
id: 'alert-125',
userId: 'user-123',
symbol: 'SOLUSDT',
condition: 'crosses_above',
price: 100,
isActive: true,
notifyEmail: true,
notifyPush: false,
isRecurring: false,
createdAt: new Date(),
};
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockAlert]));
const result = await alertsService.createAlert({
userId: 'user-123',
symbol: 'solusdt',
condition: 'crosses_above',
price: 100,
notifyEmail: true,
});
expect(result.symbol).toBe('SOLUSDT');
});
it('should handle database error during creation', async () => {
mockDb.query.mockRejectedValueOnce(new Error('Database error'));
await expect(
alertsService.createAlert({
userId: 'user-123',
symbol: 'BTCUSDT',
condition: 'above',
price: 60000,
})
).rejects.toThrow('Database error');
});
});
describe('getUserAlerts', () => {
it('should retrieve all alerts for a user', async () => {
const mockAlerts: PriceAlert[] = [
{
id: 'alert-1',
userId: 'user-123',
symbol: 'BTCUSDT',
condition: 'above',
price: 60000,
isActive: true,
notifyEmail: true,
notifyPush: true,
isRecurring: false,
createdAt: new Date(),
},
{
id: 'alert-2',
userId: 'user-123',
symbol: 'ETHUSDT',
condition: 'below',
price: 2500,
isActive: true,
notifyEmail: false,
notifyPush: true,
isRecurring: false,
createdAt: new Date(),
},
];
mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockAlerts));
const result = await alertsService.getUserAlerts('user-123');
expect(result).toHaveLength(2);
expect(result[0].userId).toBe('user-123');
expect(result[1].userId).toBe('user-123');
});
it('should filter alerts by active status', async () => {
const mockAlerts: PriceAlert[] = [
{
id: 'alert-1',
userId: 'user-123',
symbol: 'BTCUSDT',
condition: 'above',
price: 60000,
isActive: true,
notifyEmail: true,
notifyPush: true,
isRecurring: false,
createdAt: new Date(),
},
];
mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockAlerts));
const result = await alertsService.getUserAlerts('user-123', { isActive: true });
expect(result).toHaveLength(1);
expect(result[0].isActive).toBe(true);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('is_active = $2'),
['user-123', true]
);
});
it('should filter alerts by symbol', async () => {
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
await alertsService.getUserAlerts('user-123', { symbol: 'BTCUSDT' });
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('symbol = $2'),
['user-123', 'BTCUSDT']
);
});
it('should filter alerts by condition', async () => {
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
await alertsService.getUserAlerts('user-123', { condition: 'above' });
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('condition = $2'),
['user-123', 'above']
);
});
});
describe('getAlertById', () => {
it('should retrieve a specific alert', async () => {
const mockAlert: PriceAlert = {
id: 'alert-123',
userId: 'user-123',
symbol: 'BTCUSDT',
condition: 'above',
price: 60000,
note: 'Test note',
isActive: true,
notifyEmail: true,
notifyPush: false,
isRecurring: false,
createdAt: new Date(),
};
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockAlert]));
const result = await alertsService.getAlertById('alert-123');
expect(result).toEqual(mockAlert);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT * FROM trading.price_alerts'),
['alert-123']
);
});
it('should return null for non-existent alert', async () => {
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const result = await alertsService.getAlertById('non-existent');
expect(result).toBeNull();
});
});
describe('updateAlert', () => {
it('should update alert price and note', async () => {
const mockUpdatedAlert: PriceAlert = {
id: 'alert-123',
userId: 'user-123',
symbol: 'BTCUSDT',
condition: 'above',
price: 65000,
note: 'Updated target',
isActive: true,
notifyEmail: true,
notifyPush: true,
isRecurring: false,
createdAt: new Date(),
};
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUpdatedAlert]));
const result = await alertsService.updateAlert('alert-123', {
price: 65000,
note: 'Updated target',
});
expect(result.price).toBe(65000);
expect(result.note).toBe('Updated target');
});
it('should update notification preferences', async () => {
const mockUpdatedAlert: PriceAlert = {
id: 'alert-123',
userId: 'user-123',
symbol: 'BTCUSDT',
condition: 'above',
price: 60000,
isActive: true,
notifyEmail: false,
notifyPush: true,
isRecurring: false,
createdAt: new Date(),
};
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUpdatedAlert]));
const result = await alertsService.updateAlert('alert-123', {
notifyEmail: false,
notifyPush: true,
});
expect(result.notifyEmail).toBe(false);
expect(result.notifyPush).toBe(true);
});
it('should deactivate alert', async () => {
const mockUpdatedAlert: PriceAlert = {
id: 'alert-123',
userId: 'user-123',
symbol: 'BTCUSDT',
condition: 'above',
price: 60000,
isActive: false,
notifyEmail: true,
notifyPush: true,
isRecurring: false,
createdAt: new Date(),
};
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUpdatedAlert]));
const result = await alertsService.updateAlert('alert-123', { isActive: false });
expect(result.isActive).toBe(false);
});
});
describe('deleteAlert', () => {
it('should delete an alert', async () => {
mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ id: 'alert-123' }]));
await alertsService.deleteAlert('alert-123');
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM trading.price_alerts'),
['alert-123']
);
});
it('should handle deletion of non-existent alert', async () => {
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
await expect(alertsService.deleteAlert('non-existent')).rejects.toThrow();
});
});
describe('checkAlerts', () => {
it('should trigger alert when condition is met (above)', async () => {
const mockAlerts: PriceAlert[] = [
{
id: 'alert-123',
userId: 'user-123',
symbol: 'BTCUSDT',
condition: 'above',
price: 60000,
isActive: true,
notifyEmail: true,
notifyPush: true,
isRecurring: false,
createdAt: new Date(),
},
];
mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockAlerts));
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const triggeredAlerts = await alertsService.checkAlerts('BTCUSDT', 61000);
expect(triggeredAlerts).toHaveLength(1);
expect(triggeredAlerts[0].id).toBe('alert-123');
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('UPDATE trading.price_alerts'),
expect.arrayContaining([61000])
);
});
it('should trigger alert when condition is met (below)', async () => {
const mockAlerts: PriceAlert[] = [
{
id: 'alert-124',
userId: 'user-123',
symbol: 'ETHUSDT',
condition: 'below',
price: 2500,
isActive: true,
notifyEmail: true,
notifyPush: false,
isRecurring: false,
createdAt: new Date(),
},
];
mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockAlerts));
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const triggeredAlerts = await alertsService.checkAlerts('ETHUSDT', 2400);
expect(triggeredAlerts).toHaveLength(1);
expect(triggeredAlerts[0].condition).toBe('below');
});
it('should not trigger alert when condition is not met', async () => {
const mockAlerts: PriceAlert[] = [
{
id: 'alert-123',
userId: 'user-123',
symbol: 'BTCUSDT',
condition: 'above',
price: 60000,
isActive: true,
notifyEmail: true,
notifyPush: true,
isRecurring: false,
createdAt: new Date(),
},
];
mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockAlerts));
const triggeredAlerts = await alertsService.checkAlerts('BTCUSDT', 59000);
expect(triggeredAlerts).toHaveLength(0);
});
it('should reactivate recurring alert after trigger', async () => {
const mockAlerts: PriceAlert[] = [
{
id: 'alert-125',
userId: 'user-123',
symbol: 'SOLUSDT',
condition: 'above',
price: 100,
isActive: true,
notifyEmail: true,
notifyPush: true,
isRecurring: true,
createdAt: new Date(),
},
];
mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockAlerts));
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
await alertsService.checkAlerts('SOLUSDT', 105);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('UPDATE trading.price_alerts'),
expect.arrayContaining([105])
);
});
});
describe('getTriggeredAlerts', () => {
it('should retrieve triggered alerts for a user', async () => {
const now = new Date();
const mockTriggeredAlerts: PriceAlert[] = [
{
id: 'alert-1',
userId: 'user-123',
symbol: 'BTCUSDT',
condition: 'above',
price: 60000,
isActive: false,
triggeredAt: now,
triggeredPrice: 61000,
notifyEmail: true,
notifyPush: true,
isRecurring: false,
createdAt: new Date(),
},
];
mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockTriggeredAlerts));
const result = await alertsService.getTriggeredAlerts('user-123', { limit: 10 });
expect(result).toHaveLength(1);
expect(result[0].triggeredAt).toBeDefined();
expect(result[0].triggeredPrice).toBe(61000);
});
it('should filter triggered alerts by date range', async () => {
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const startDate = new Date('2024-01-01');
const endDate = new Date('2024-12-31');
await alertsService.getTriggeredAlerts('user-123', { startDate, endDate });
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('triggered_at BETWEEN'),
expect.arrayContaining(['user-123', startDate, endDate])
);
});
});
});

View File

@ -1,473 +0,0 @@
/**
* Paper Trading Service Unit Tests
*
* Tests for paper trading service including:
* - Account creation and management
* - Position opening and closing
* - P&L calculations
* - Account statistics
*/
import { mockDb, createMockQueryResult, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock';
import type { PaperAccount, PaperPosition } from '../paper-trading.service';
// Mock database
jest.mock('../../../../shared/database', () => ({
db: mockDb,
}));
// Mock logger
jest.mock('../../../../shared/utils/logger', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
// Mock market service
const mockGetPrice = jest.fn();
jest.mock('../market.service', () => ({
marketService: {
getPrice: mockGetPrice,
},
}));
// Import service after mocks
import { paperTradingService } from '../paper-trading.service';
describe('PaperTradingService', () => {
beforeEach(() => {
resetDatabaseMocks();
mockGetPrice.mockReset();
});
describe('createAccount', () => {
it('should create a new paper trading account with default values', async () => {
const mockAccount: PaperAccount = {
id: 'account-123',
userId: 'user-123',
name: 'My Trading Account',
initialBalance: 10000,
currentBalance: 10000,
currency: 'USD',
totalTrades: 0,
winningTrades: 0,
totalPnl: 0,
maxDrawdown: 0,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockAccount]));
const result = await paperTradingService.createAccount('user-123', {
name: 'My Trading Account',
});
expect(result).toEqual(mockAccount);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO trading.paper_trading_accounts'),
expect.arrayContaining(['user-123', 'My Trading Account'])
);
});
it('should create account with custom initial balance', async () => {
const mockAccount: PaperAccount = {
id: 'account-123',
userId: 'user-123',
name: 'High Stakes Account',
initialBalance: 100000,
currentBalance: 100000,
currency: 'USD',
totalTrades: 0,
winningTrades: 0,
totalPnl: 0,
maxDrawdown: 0,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockAccount]));
const result = await paperTradingService.createAccount('user-123', {
name: 'High Stakes Account',
initialBalance: 100000,
});
expect(result.initialBalance).toBe(100000);
expect(result.currentBalance).toBe(100000);
});
it('should handle database error during account creation', async () => {
mockDb.query.mockRejectedValueOnce(new Error('Database connection failed'));
await expect(
paperTradingService.createAccount('user-123', { name: 'Test Account' })
).rejects.toThrow('Database connection failed');
});
});
describe('getAccount', () => {
it('should retrieve an existing account', async () => {
const mockAccount: PaperAccount = {
id: 'account-123',
userId: 'user-123',
name: 'My Account',
initialBalance: 10000,
currentBalance: 12500,
currency: 'USD',
totalTrades: 25,
winningTrades: 18,
totalPnl: 2500,
maxDrawdown: -500,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockAccount]));
const result = await paperTradingService.getAccount('account-123');
expect(result).toEqual(mockAccount);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT * FROM trading.paper_trading_accounts'),
['account-123']
);
});
it('should return null for non-existent account', async () => {
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const result = await paperTradingService.getAccount('non-existent');
expect(result).toBeNull();
});
});
describe('openPosition', () => {
it('should open a long position with market price', async () => {
mockGetPrice.mockResolvedValueOnce(50000);
const mockPosition: PaperPosition = {
id: 'position-123',
accountId: 'account-123',
userId: 'user-123',
symbol: 'BTCUSDT',
direction: 'long',
lotSize: 0.1,
entryPrice: 50000,
stopLoss: 49000,
takeProfit: 52000,
status: 'open',
openedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockPosition]));
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const result = await paperTradingService.openPosition('account-123', 'user-123', {
symbol: 'BTCUSDT',
direction: 'long',
lotSize: 0.1,
stopLoss: 49000,
takeProfit: 52000,
});
expect(result).toEqual(mockPosition);
expect(mockGetPrice).toHaveBeenCalledWith('BTCUSDT');
expect(result.entryPrice).toBe(50000);
});
it('should open a short position with specified price', async () => {
const mockPosition: PaperPosition = {
id: 'position-124',
accountId: 'account-123',
userId: 'user-123',
symbol: 'ETHUSDT',
direction: 'short',
lotSize: 1.0,
entryPrice: 3000,
stopLoss: 3100,
takeProfit: 2850,
status: 'open',
openedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockPosition]));
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const result = await paperTradingService.openPosition('account-123', 'user-123', {
symbol: 'ETHUSDT',
direction: 'short',
lotSize: 1.0,
entryPrice: 3000,
stopLoss: 3100,
takeProfit: 2850,
});
expect(result.direction).toBe('short');
expect(result.entryPrice).toBe(3000);
expect(mockGetPrice).not.toHaveBeenCalled();
});
it('should handle insufficient balance error', async () => {
mockGetPrice.mockResolvedValueOnce(50000);
mockDb.query.mockRejectedValueOnce(
new Error('Insufficient balance for position')
);
await expect(
paperTradingService.openPosition('account-123', 'user-123', {
symbol: 'BTCUSDT',
direction: 'long',
lotSize: 100,
})
).rejects.toThrow('Insufficient balance for position');
});
});
describe('closePosition', () => {
it('should close position with profit at market price', async () => {
mockGetPrice.mockResolvedValueOnce(52000);
const mockClosedPosition: PaperPosition = {
id: 'position-123',
accountId: 'account-123',
userId: 'user-123',
symbol: 'BTCUSDT',
direction: 'long',
lotSize: 0.1,
entryPrice: 50000,
exitPrice: 52000,
status: 'closed',
openedAt: new Date(Date.now() - 3600000),
closedAt: new Date(),
closeReason: 'Manual close',
realizedPnl: 200,
createdAt: new Date(),
updatedAt: new Date(),
};
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockClosedPosition]));
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const result = await paperTradingService.closePosition(
'position-123',
'account-123',
{ closeReason: 'Manual close' }
);
expect(result.status).toBe('closed');
expect(result.exitPrice).toBe(52000);
expect(result.realizedPnl).toBe(200);
expect(mockGetPrice).toHaveBeenCalled();
});
it('should close position with loss at specified price', async () => {
const mockClosedPosition: PaperPosition = {
id: 'position-124',
accountId: 'account-123',
userId: 'user-123',
symbol: 'ETHUSDT',
direction: 'long',
lotSize: 1.0,
entryPrice: 3000,
exitPrice: 2900,
status: 'closed',
openedAt: new Date(Date.now() - 3600000),
closedAt: new Date(),
closeReason: 'Stop loss triggered',
realizedPnl: -100,
createdAt: new Date(),
updatedAt: new Date(),
};
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockClosedPosition]));
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const result = await paperTradingService.closePosition(
'position-124',
'account-123',
{ exitPrice: 2900, closeReason: 'Stop loss triggered' }
);
expect(result.exitPrice).toBe(2900);
expect(result.realizedPnl).toBe(-100);
expect(result.closeReason).toBe('Stop loss triggered');
});
it('should handle position not found error', async () => {
mockDb.query.mockRejectedValueOnce(new Error('Position not found'));
await expect(
paperTradingService.closePosition('invalid-id', 'account-123', {})
).rejects.toThrow('Position not found');
});
});
describe('getOpenPositions', () => {
it('should retrieve all open positions for an account', async () => {
const mockPositions: PaperPosition[] = [
{
id: 'pos-1',
accountId: 'account-123',
userId: 'user-123',
symbol: 'BTCUSDT',
direction: 'long',
lotSize: 0.1,
entryPrice: 50000,
status: 'open',
openedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: 'pos-2',
accountId: 'account-123',
userId: 'user-123',
symbol: 'ETHUSDT',
direction: 'short',
lotSize: 1.0,
entryPrice: 3000,
status: 'open',
openedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
},
];
mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockPositions));
const result = await paperTradingService.getOpenPositions('account-123');
expect(result).toHaveLength(2);
expect(result[0].status).toBe('open');
expect(result[1].status).toBe('open');
});
it('should return empty array when no open positions', async () => {
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const result = await paperTradingService.getOpenPositions('account-123');
expect(result).toEqual([]);
});
});
describe('getAccountSummary', () => {
it('should calculate account summary with open positions', async () => {
const mockAccount: PaperAccount = {
id: 'account-123',
userId: 'user-123',
name: 'Test Account',
initialBalance: 10000,
currentBalance: 11500,
currency: 'USD',
totalTrades: 10,
winningTrades: 7,
totalPnl: 1500,
maxDrawdown: -300,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockAccount]));
mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ count: 3 }]));
mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ unrealized_pnl: 500 }]));
mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ today_pnl: 150 }]));
const result = await paperTradingService.getAccountSummary('account-123');
expect(result.account).toEqual(mockAccount);
expect(result.openPositions).toBe(3);
expect(result.unrealizedPnl).toBe(500);
expect(result.todayPnl).toBe(150);
expect(result.winRate).toBe(70);
expect(result.totalEquity).toBe(12000);
});
it('should handle account with no positions', async () => {
const mockAccount: PaperAccount = {
id: 'account-123',
userId: 'user-123',
name: 'Empty Account',
initialBalance: 10000,
currentBalance: 10000,
currency: 'USD',
totalTrades: 0,
winningTrades: 0,
totalPnl: 0,
maxDrawdown: 0,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockAccount]));
mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ count: 0 }]));
mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ unrealized_pnl: 0 }]));
mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ today_pnl: 0 }]));
const result = await paperTradingService.getAccountSummary('account-123');
expect(result.openPositions).toBe(0);
expect(result.winRate).toBe(0);
expect(result.totalEquity).toBe(10000);
});
});
describe('getPositionHistory', () => {
it('should retrieve closed positions history', async () => {
const mockHistory: PaperPosition[] = [
{
id: 'pos-1',
accountId: 'account-123',
userId: 'user-123',
symbol: 'BTCUSDT',
direction: 'long',
lotSize: 0.1,
entryPrice: 50000,
exitPrice: 51000,
status: 'closed',
openedAt: new Date(Date.now() - 86400000),
closedAt: new Date(Date.now() - 43200000),
realizedPnl: 100,
createdAt: new Date(),
updatedAt: new Date(),
},
];
mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockHistory));
const result = await paperTradingService.getPositionHistory('account-123', { limit: 10 });
expect(result).toHaveLength(1);
expect(result[0].status).toBe('closed');
expect(result[0].realizedPnl).toBe(100);
});
it('should filter history by symbol', async () => {
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
await paperTradingService.getPositionHistory('account-123', {
symbol: 'ETHUSDT',
limit: 10,
});
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('symbol = $2'),
expect.arrayContaining(['account-123', 'ETHUSDT'])
);
});
});
});

Some files were not shown because too many files have changed in this diff Show More