feat: Add base modules from erp-core following SIMCO-REUSE directive

Phase 0 - Base modules (100% copy):
- shared/ (errors, middleware, services, utils, types)
- auth, users, tenants (multi-tenancy)
- ai, audit, notifications, mcp, payment-terminals
- billing-usage, branches, companies, core

Phase 1 - Modules to adapt (70-95%):
- partners (for shippers/consignees)
- inventory (for refacciones)
- financial (for transport costing)

Phase 2 - Pattern modules (50-70%):
- ordenes-transporte (from sales)
- gestion-flota (from products)
- viajes (from projects)

Phase 3 - New transport-specific modules:
- tracking (GPS, events, alerts)
- tarifas-transporte (pricing, surcharges)
- combustible-gastos (fuel, tolls, expenses)
- carta-porte (CFDI complement 3.1)

Estimated token savings: ~65% (~10,675 lines)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 10:10:19 -06:00
parent 2f76d541d2
commit 95c6b58449
419 changed files with 69484 additions and 23 deletions

View File

@ -15,42 +15,50 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
"date-fns": "^2.30.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"typeorm": "^0.3.28",
"pg": "^8.11.3",
"helmet": "^7.1.0",
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2",
"bcryptjs": "^2.4.3",
"helmet": "^7.1.0",
"cors": "^2.8.5",
"compression": "^1.7.4",
"morgan": "^1.10.0",
"pg": "^8.11.3",
"reflect-metadata": "^0.2.2",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"class-validator": "^0.14.0",
"class-transformer": "^0.5.1",
"uuid": "^9.0.0",
"date-fns": "^2.30.0",
"xml2js": "^0.6.2"
"typeorm": "^0.3.28",
"uuid": "^9.0.1",
"winston": "^3.11.0",
"xml2js": "^0.6.2",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"@types/jsonwebtoken": "^9.0.5",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/ioredis": "^4.28.10",
"@types/jest": "^29.5.11",
"@types/jsonwebtoken": "^9.0.5",
"@types/morgan": "^1.9.9",
"@types/swagger-ui-express": "^4.1.6",
"@types/node": "^20.10.4",
"@types/pg": "^8.10.9",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"@types/uuid": "^9.0.7",
"@types/xml2js": "^0.4.14",
"typescript": "^5.3.2",
"tsx": "^4.6.0",
"eslint": "^8.54.0",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"eslint": "^8.56.0",
"jest": "^29.7.0",
"@types/jest": "^29.5.10",
"ts-jest": "^29.1.1"
"ts-jest": "^29.1.1",
"tsx": "^4.6.2",
"typescript": "^5.3.3"
},
"engines": {
"node": ">=20.0.0"

69
src/config/database.ts Normal file
View File

@ -0,0 +1,69 @@
import { Pool, PoolConfig, PoolClient } from 'pg';
// Re-export PoolClient for use in services
export type { PoolClient };
import { config } from './index.js';
import { logger } from '../shared/utils/logger.js';
const poolConfig: PoolConfig = {
host: config.database.host,
port: config.database.port,
database: config.database.name,
user: config.database.user,
password: config.database.password,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
};
export const pool = new Pool(poolConfig);
pool.on('connect', () => {
logger.debug('New database connection established');
});
pool.on('error', (err) => {
logger.error('Unexpected database error', { error: err.message });
});
export async function testConnection(): Promise<boolean> {
try {
const client = await pool.connect();
const result = await client.query('SELECT NOW()');
client.release();
logger.info('Database connection successful', { timestamp: result.rows[0].now });
return true;
} catch (error) {
logger.error('Database connection failed', { error: (error as Error).message });
return false;
}
}
export async function query<T = any>(text: string, params?: any[]): Promise<T[]> {
const start = Date.now();
const result = await pool.query(text, params);
const duration = Date.now() - start;
logger.debug('Query executed', {
text: text.substring(0, 100),
duration: `${duration}ms`,
rows: result.rowCount
});
return result.rows as T[];
}
export async function queryOne<T = any>(text: string, params?: any[]): Promise<T | null> {
const rows = await query<T>(text, params);
return rows[0] || null;
}
export async function getClient() {
const client = await pool.connect();
return client;
}
export async function closePool(): Promise<void> {
await pool.end();
logger.info('Database pool closed');
}

35
src/config/index.ts Normal file
View File

@ -0,0 +1,35 @@
import dotenv from 'dotenv';
import path from 'path';
// Load .env file
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
export const config = {
env: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '3000', 10),
apiPrefix: process.env.API_PREFIX || '/api/v1',
database: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
name: process.env.DB_NAME || 'erp_generic',
user: process.env.DB_USER || 'erp_admin',
password: process.env.DB_PASSWORD || '',
},
jwt: {
secret: process.env.JWT_SECRET || 'change-this-secret',
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
},
logging: {
level: process.env.LOG_LEVEL || 'info',
},
cors: {
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
},
} as const;
export type Config = typeof config;

178
src/config/redis.ts Normal file
View File

@ -0,0 +1,178 @@
import Redis from 'ioredis';
import { logger } from '../shared/utils/logger.js';
/**
* Configuración de Redis para blacklist de tokens JWT
*/
const redisConfig = {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined,
// Configuración de reconexión
retryStrategy(times: number) {
const delay = Math.min(times * 50, 2000);
return delay;
},
// Timeouts
connectTimeout: 10000,
maxRetriesPerRequest: 3,
// Logging de eventos
lazyConnect: true, // No conectar automáticamente, esperar a connect()
};
/**
* Cliente Redis para blacklist de tokens
*/
export const redisClient = new Redis(redisConfig);
// Event listeners
redisClient.on('connect', () => {
logger.info('Redis client connecting...', {
host: redisConfig.host,
port: redisConfig.port,
});
});
redisClient.on('ready', () => {
logger.info('Redis client ready');
});
redisClient.on('error', (error) => {
logger.error('Redis client error', {
error: error.message,
stack: error.stack,
});
});
redisClient.on('close', () => {
logger.warn('Redis connection closed');
});
redisClient.on('reconnecting', () => {
logger.info('Redis client reconnecting...');
});
/**
* Inicializa la conexión a Redis
* @returns Promise<boolean> - true si la conexión fue exitosa
*/
export async function initializeRedis(): Promise<boolean> {
try {
await redisClient.connect();
// Test de conexión
await redisClient.ping();
logger.info('Redis connection successful', {
host: redisConfig.host,
port: redisConfig.port,
});
return true;
} catch (error) {
logger.error('Failed to connect to Redis', {
error: (error as Error).message,
host: redisConfig.host,
port: redisConfig.port,
});
// Redis es opcional, no debe detener la app
logger.warn('Application will continue without Redis (token blacklist disabled)');
return false;
}
}
/**
* Cierra la conexión a Redis
*/
export async function closeRedis(): Promise<void> {
try {
await redisClient.quit();
logger.info('Redis connection closed gracefully');
} catch (error) {
logger.error('Error closing Redis connection', {
error: (error as Error).message,
});
// Forzar desconexión si quit() falla
redisClient.disconnect();
}
}
/**
* Verifica si Redis está conectado
*/
export function isRedisConnected(): boolean {
return redisClient.status === 'ready';
}
// ===== Utilidades para Token Blacklist =====
/**
* Agrega un token a la blacklist
* @param token - Token JWT a invalidar
* @param expiresIn - Tiempo de expiración en segundos
*/
export async function blacklistToken(token: string, expiresIn: number): Promise<void> {
if (!isRedisConnected()) {
logger.warn('Cannot blacklist token: Redis not connected');
return;
}
try {
const key = `blacklist:${token}`;
await redisClient.setex(key, expiresIn, '1');
logger.debug('Token added to blacklist', { expiresIn });
} catch (error) {
logger.error('Error blacklisting token', {
error: (error as Error).message,
});
}
}
/**
* Verifica si un token está en la blacklist
* @param token - Token JWT a verificar
* @returns Promise<boolean> - true si el token está en blacklist
*/
export async function isTokenBlacklisted(token: string): Promise<boolean> {
if (!isRedisConnected()) {
logger.warn('Cannot check blacklist: Redis not connected');
return false; // Si Redis no está disponible, permitir el acceso
}
try {
const key = `blacklist:${token}`;
const result = await redisClient.get(key);
return result !== null;
} catch (error) {
logger.error('Error checking token blacklist', {
error: (error as Error).message,
});
return false; // En caso de error, no bloquear el acceso
}
}
/**
* Limpia tokens expirados de la blacklist
* Nota: Redis hace esto automáticamente con SETEX, esta función es para uso manual si es necesario
*/
export async function cleanupBlacklist(): Promise<void> {
if (!isRedisConnected()) {
logger.warn('Cannot cleanup blacklist: Redis not connected');
return;
}
try {
// Redis maneja automáticamente la expiración con SETEX
// Esta función está disponible para limpieza manual si se necesita
logger.info('Blacklist cleanup completed (handled by Redis TTL)');
} catch (error) {
logger.error('Error during blacklist cleanup', {
error: (error as Error).message,
});
}
}

View File

@ -0,0 +1,196 @@
/**
* Swagger/OpenAPI Configuration for ERP Generic Core
*/
import swaggerJSDoc from 'swagger-jsdoc';
import { Application } from 'express';
import swaggerUi from 'swagger-ui-express';
import path from 'path';
// Swagger definition
const swaggerDefinition = {
openapi: '3.0.0',
info: {
title: 'ERP Generic - Core API',
version: '0.1.0',
description: `
API para el sistema ERP genérico multitenant.
## Características principales
- Autenticación JWT y gestión de sesiones
- Multi-tenant con aislamiento de datos por empresa
- Gestión financiera y contable completa
- Control de inventario y almacenes
- Módulos de compras y ventas
- CRM y gestión de partners (clientes, proveedores)
- Proyectos y recursos humanos
- Sistema de permisos granular mediante API Keys
## Autenticación
Todos los endpoints requieren autenticación mediante Bearer Token (JWT).
El token debe incluirse en el header Authorization: Bearer <token>
## Multi-tenant
El sistema identifica automáticamente la empresa (tenant) del usuario autenticado
y filtra todos los datos según el contexto de la empresa.
`,
contact: {
name: 'ERP Generic Support',
email: 'support@erpgeneric.com',
},
license: {
name: 'Proprietary',
},
},
servers: [
{
url: 'http://localhost:3003/api/v1',
description: 'Desarrollo local',
},
{
url: 'https://api.erpgeneric.com/api/v1',
description: 'Producción',
},
],
tags: [
{ name: 'Auth', description: 'Autenticación y autorización (JWT)' },
{ name: 'Users', description: 'Gestión de usuarios y perfiles' },
{ name: 'Companies', description: 'Gestión de empresas (multi-tenant)' },
{ name: 'Core', description: 'Configuración central y parámetros del sistema' },
{ name: 'Partners', description: 'Gestión de partners (clientes, proveedores, contactos)' },
{ name: 'Inventory', description: 'Control de inventario, productos y almacenes' },
{ name: 'Financial', description: 'Gestión financiera, contable y movimientos' },
{ name: 'Purchases', description: 'Módulo de compras y órdenes de compra' },
{ name: 'Sales', description: 'Módulo de ventas, cotizaciones y pedidos' },
{ name: 'Projects', description: 'Gestión de proyectos y tareas' },
{ name: 'System', description: 'Configuración del sistema, logs y auditoría' },
{ name: 'CRM', description: 'CRM, oportunidades y seguimiento comercial' },
{ name: 'HR', description: 'Recursos humanos, empleados y nómina' },
{ name: 'Reports', description: 'Reportes y analíticas del sistema' },
{ name: 'Health', description: 'Health checks y monitoreo' },
],
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 operaciones administrativas específicas',
},
},
schemas: {
ApiResponse: {
type: 'object',
properties: {
success: {
type: 'boolean',
},
data: {
type: 'object',
},
error: {
type: 'string',
},
},
},
PaginatedResponse: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: true,
},
data: {
type: 'array',
items: {
type: 'object',
},
},
pagination: {
type: 'object',
properties: {
page: {
type: 'integer',
example: 1,
},
limit: {
type: 'integer',
example: 20,
},
total: {
type: 'integer',
example: 100,
},
totalPages: {
type: 'integer',
example: 5,
},
},
},
},
},
},
},
security: [
{
BearerAuth: [],
},
],
};
// Options for swagger-jsdoc
const options: swaggerJSDoc.Options = {
definition: swaggerDefinition,
// Path to the API routes for JSDoc comments
apis: [
path.resolve(process.cwd(), 'src/modules/**/*.routes.ts'),
path.resolve(process.cwd(), 'src/modules/**/*.routes.js'),
path.resolve(process.cwd(), 'src/docs/openapi.yaml'),
],
};
// Initialize swagger-jsdoc
const swaggerSpec = swaggerJSDoc(options);
/**
* Setup Swagger documentation for Express app
*/
export function setupSwagger(app: Application, 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: 'ERP Generic - 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 || 3003}${prefix}/docs`);
console.log(`📄 OpenAPI spec JSON at: http://localhost:${process.env.PORT || 3003}${prefix}/docs.json`);
}
export { swaggerSpec };

254
src/config/typeorm.ts Normal file
View File

@ -0,0 +1,254 @@
import { DataSource } from 'typeorm';
import { config } from './index.js';
import { logger } from '../shared/utils/logger.js';
// Import Auth Core Entities
import {
Tenant,
Company,
User,
Role,
Permission,
Session,
PasswordReset,
} from '../modules/auth/entities/index.js';
// Import Auth Extension Entities
import {
Group,
ApiKey,
TrustedDevice,
VerificationCode,
MfaAuditLog,
OAuthProvider,
OAuthUserLink,
OAuthState,
} from '../modules/auth/entities/index.js';
// Import Core Module Entities
import { Partner } from '../modules/partners/entities/index.js';
import {
Currency,
CurrencyRate,
Country,
State,
UomCategory,
Uom,
ProductCategory,
Sequence,
PaymentTerm,
PaymentTermLine,
DiscountRule,
} from '../modules/core/entities/index.js';
// Import Financial Entities
import {
AccountType,
Account,
Journal,
JournalEntry,
JournalEntryLine,
Invoice,
InvoiceLine,
Payment,
Tax,
FiscalYear,
FiscalPeriod,
} from '../modules/financial/entities/index.js';
// Import Inventory Entities
import {
Product,
Warehouse,
Location,
StockQuant,
Lot,
Picking,
StockMove,
StockLevel,
StockMovement,
InventoryCount,
InventoryCountLine,
InventoryAdjustment,
InventoryAdjustmentLine,
TransferOrder,
TransferOrderLine,
StockValuationLayer,
} from '../modules/inventory/entities/index.js';
// Import Fiscal Entities
import {
TaxCategory,
FiscalRegime,
CfdiUse,
PaymentMethod,
PaymentType,
WithholdingType,
} from '../modules/fiscal/entities/index.js';
/**
* TypeORM DataSource configuration
*
* Configurado para coexistir con el pool pg existente.
* Permite migración gradual a entities sin romper el código actual.
*/
export const AppDataSource = new DataSource({
type: 'postgres',
host: config.database.host,
port: config.database.port,
username: config.database.user,
password: config.database.password,
database: config.database.name,
// Schema por defecto para entities de autenticación
schema: 'auth',
// Entities registradas
entities: [
// Auth Core Entities
Tenant,
Company,
User,
Role,
Permission,
Session,
PasswordReset,
// Auth Extension Entities
Group,
ApiKey,
TrustedDevice,
VerificationCode,
MfaAuditLog,
OAuthProvider,
OAuthUserLink,
OAuthState,
// Core Module Entities
Partner,
Currency,
CurrencyRate,
Country,
State,
UomCategory,
Uom,
ProductCategory,
Sequence,
PaymentTerm,
PaymentTermLine,
DiscountRule,
// Financial Entities
AccountType,
Account,
Journal,
JournalEntry,
JournalEntryLine,
Invoice,
InvoiceLine,
Payment,
Tax,
FiscalYear,
FiscalPeriod,
// Inventory Entities
Product,
Warehouse,
Location,
StockQuant,
Lot,
Picking,
StockMove,
StockLevel,
StockMovement,
InventoryCount,
InventoryCountLine,
InventoryAdjustment,
InventoryAdjustmentLine,
TransferOrder,
TransferOrderLine,
StockValuationLayer,
// Fiscal Entities
TaxCategory,
FiscalRegime,
CfdiUse,
PaymentMethod,
PaymentType,
WithholdingType,
],
// Directorios de migraciones (para uso futuro)
migrations: [
// 'src/database/migrations/*.ts'
],
// Directorios de subscribers (para uso futuro)
subscribers: [
// 'src/database/subscribers/*.ts'
],
// NO usar synchronize en producción - usamos DDL manual
synchronize: false,
// Logging: habilitado en desarrollo, solo errores en producción
logging: config.env === 'development' ? ['query', 'error', 'warn'] : ['error'],
// Log queries lentas (> 1000ms)
maxQueryExecutionTime: 1000,
// Pool de conexiones (configuración conservadora para no interferir con pool pg)
extra: {
max: 10, // Menor que el pool pg (20) para no competir por conexiones
min: 2,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
},
// Cache de queries (opcional, se puede habilitar después)
cache: false,
});
/**
* Inicializa la conexión TypeORM
* @returns Promise<boolean> - true si la conexión fue exitosa
*/
export async function initializeTypeORM(): Promise<boolean> {
try {
if (!AppDataSource.isInitialized) {
await AppDataSource.initialize();
logger.info('TypeORM DataSource initialized successfully', {
database: config.database.name,
schema: 'auth',
host: config.database.host,
});
return true;
}
logger.warn('TypeORM DataSource already initialized');
return true;
} catch (error) {
logger.error('Failed to initialize TypeORM DataSource', {
error: (error as Error).message,
stack: (error as Error).stack,
});
return false;
}
}
/**
* Cierra la conexión TypeORM
*/
export async function closeTypeORM(): Promise<void> {
try {
if (AppDataSource.isInitialized) {
await AppDataSource.destroy();
logger.info('TypeORM DataSource closed');
}
} catch (error) {
logger.error('Error closing TypeORM DataSource', {
error: (error as Error).message,
});
}
}
/**
* Obtiene el estado de la conexión TypeORM
*/
export function isTypeORMConnected(): boolean {
return AppDataSource.isInitialized;
}

71
src/index.ts Normal file
View File

@ -0,0 +1,71 @@
// Importar reflect-metadata al inicio (requerido por TypeORM)
import 'reflect-metadata';
import app from './app.js';
import { config } from './config/index.js';
import { testConnection, closePool } from './config/database.js';
import { initializeTypeORM, closeTypeORM } from './config/typeorm.js';
import { initializeRedis, closeRedis } from './config/redis.js';
import { logger } from './shared/utils/logger.js';
async function bootstrap(): Promise<void> {
logger.info('Starting ERP Generic Backend...', {
env: config.env,
port: config.port,
});
// Test database connection (pool pg existente)
const dbConnected = await testConnection();
if (!dbConnected) {
logger.error('Failed to connect to database. Exiting...');
process.exit(1);
}
// Initialize TypeORM DataSource
const typeormConnected = await initializeTypeORM();
if (!typeormConnected) {
logger.error('Failed to initialize TypeORM. Exiting...');
process.exit(1);
}
// Initialize Redis (opcional - no detiene la app si falla)
await initializeRedis();
// Start server
const server = app.listen(config.port, () => {
logger.info(`Server running on port ${config.port}`);
logger.info(`API available at http://localhost:${config.port}${config.apiPrefix}`);
logger.info(`Health check at http://localhost:${config.port}/health`);
});
// Graceful shutdown
const shutdown = async (signal: string) => {
logger.info(`Received ${signal}. Starting graceful shutdown...`);
server.close(async () => {
logger.info('HTTP server closed');
// Cerrar conexiones en orden
await closeRedis();
await closeTypeORM();
await closePool();
logger.info('Shutdown complete');
process.exit(0);
});
// Force shutdown after 10s
setTimeout(() => {
logger.error('Forced shutdown after timeout');
process.exit(1);
}, 10000);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}
bootstrap().catch((error) => {
logger.error('Failed to start server', { error: error.message });
process.exit(1);
});

76
src/modules/ai/README.md Normal file
View File

@ -0,0 +1,76 @@
# AI Module
## Descripcion
Modulo de integracion con modelos de Inteligencia Artificial. Proporciona capacidades de chat conversacional, completions, embeddings y gestion de bases de conocimiento. Soporta multiples proveedores (OpenAI, Anthropic, Google, Azure) a traves de OpenRouter, con control de acceso basado en roles y gestion de cuotas por tenant.
## Entidades
| Entidad | Schema | Descripcion |
|---------|--------|-------------|
| `AIModel` | ai.models | Catalogo de modelos de IA disponibles (GPT-4, Claude, etc.) con configuracion de costos y capacidades |
| `AIPrompt` | ai.prompts | Templates de prompts versionados con variables y configuracion de modelo |
| `AIConversation` | ai.conversations | Conversaciones de chat con historial, contexto y estadisticas |
| `AIMessage` | ai.messages | Mensajes individuales dentro de una conversacion |
| `AICompletion` | ai.completions | Registros de completions individuales (no conversacionales) |
| `AIEmbedding` | ai.embeddings | Vectores de embeddings para busqueda semantica |
| `AIKnowledgeBase` | ai.knowledge_base | Articulos de conocimiento con embeddings para RAG |
| `AIUsageLog` | ai.usage_logs | Registro detallado de uso por request |
| `AITenantQuota` | ai.tenant_quotas | Cuotas mensuales de tokens, requests y costos por tenant |
## Servicios
| Servicio | Responsabilidades |
|----------|-------------------|
| `AIService` | Servicio base: CRUD de modelos, prompts, conversaciones, mensajes; registro de uso; gestion de cuotas |
| `RoleBasedAIService` | Extension con control de acceso basado en roles ERP; integracion con OpenRouter; ejecucion de tools |
## Endpoints
| Method | Path | Descripcion |
|--------|------|-------------|
| GET | `/models` | Lista todos los modelos activos |
| GET | `/models/:id` | Obtiene modelo por ID |
| GET | `/models/code/:code` | Obtiene modelo por codigo |
| GET | `/models/provider/:provider` | Lista modelos por proveedor |
| GET | `/models/type/:type` | Lista modelos por tipo (chat/embedding/etc) |
| GET | `/prompts` | Lista prompts del tenant |
| GET | `/prompts/:id` | Obtiene prompt por ID |
| GET | `/prompts/code/:code` | Obtiene prompt por codigo |
| POST | `/prompts` | Crea nuevo prompt |
| PATCH | `/prompts/:id` | Actualiza prompt existente |
| GET | `/conversations` | Lista conversaciones del tenant |
| GET | `/conversations/:id` | Obtiene conversacion con mensajes |
| GET | `/conversations/user/:userId` | Lista conversaciones de usuario |
| POST | `/conversations` | Crea nueva conversacion |
| PATCH | `/conversations/:id` | Actualiza conversacion |
| POST | `/conversations/:id/archive` | Archiva conversacion |
| GET | `/conversations/:conversationId/messages` | Lista mensajes de conversacion |
| POST | `/conversations/:conversationId/messages` | Agrega mensaje a conversacion |
| GET | `/conversations/:conversationId/tokens` | Obtiene conteo de tokens |
| POST | `/usage` | Registra uso de IA |
| GET | `/usage/stats` | Obtiene estadisticas de uso |
| GET | `/quotas` | Obtiene cuota del tenant |
| PATCH | `/quotas` | Actualiza cuota del tenant |
| GET | `/quotas/check` | Verifica disponibilidad de cuota |
## Dependencias
- `common` - Utilidades compartidas
- `auth` - Autenticacion y tenant context
- OpenRouter API (proveedor externo)
## Configuracion
| Variable | Descripcion | Requerida |
|----------|-------------|-----------|
| `OPENROUTER_API_KEY` | API key para OpenRouter | Si |
| `APP_URL` | URL de la aplicacion (para HTTP-Referer) | No |
## Roles ERP Soportados
El `RoleBasedAIService` soporta prompts y accesos diferenciados por rol:
- `admin` - Acceso completo
- `supervisor` - Acceso a reportes y analisis
- `operator` - Acceso a operaciones basicas
- `customer` - Acceso limitado a consultas

View File

@ -0,0 +1,66 @@
import { Router } from 'express';
import { DataSource } from 'typeorm';
import { AIService } from './services';
import { AIController } from './controllers';
import {
AIModel,
AIPrompt,
AIConversation,
AIMessage,
AIUsageLog,
AITenantQuota,
} from './entities';
export interface AIModuleOptions {
dataSource: DataSource;
basePath?: string;
}
export class AIModule {
public router: Router;
public aiService: AIService;
private dataSource: DataSource;
private basePath: string;
constructor(options: AIModuleOptions) {
this.dataSource = options.dataSource;
this.basePath = options.basePath || '';
this.router = Router();
this.initializeServices();
this.initializeRoutes();
}
private initializeServices(): void {
const modelRepository = this.dataSource.getRepository(AIModel);
const conversationRepository = this.dataSource.getRepository(AIConversation);
const messageRepository = this.dataSource.getRepository(AIMessage);
const promptRepository = this.dataSource.getRepository(AIPrompt);
const usageLogRepository = this.dataSource.getRepository(AIUsageLog);
const quotaRepository = this.dataSource.getRepository(AITenantQuota);
this.aiService = new AIService(
modelRepository,
conversationRepository,
messageRepository,
promptRepository,
usageLogRepository,
quotaRepository
);
}
private initializeRoutes(): void {
const aiController = new AIController(this.aiService);
this.router.use(`${this.basePath}/ai`, aiController.router);
}
static getEntities(): Function[] {
return [
AIModel,
AIPrompt,
AIConversation,
AIMessage,
AIUsageLog,
AITenantQuota,
];
}
}

View File

@ -0,0 +1,381 @@
import { Request, Response, NextFunction, Router } from 'express';
import { AIService, ConversationFilters } from '../services/ai.service';
export class AIController {
public router: Router;
constructor(private readonly aiService: AIService) {
this.router = Router();
this.initializeRoutes();
}
private initializeRoutes(): void {
// Models
this.router.get('/models', this.findAllModels.bind(this));
this.router.get('/models/:id', this.findModel.bind(this));
this.router.get('/models/code/:code', this.findModelByCode.bind(this));
this.router.get('/models/provider/:provider', this.findModelsByProvider.bind(this));
this.router.get('/models/type/:type', this.findModelsByType.bind(this));
// Prompts
this.router.get('/prompts', this.findAllPrompts.bind(this));
this.router.get('/prompts/:id', this.findPrompt.bind(this));
this.router.get('/prompts/code/:code', this.findPromptByCode.bind(this));
this.router.post('/prompts', this.createPrompt.bind(this));
this.router.patch('/prompts/:id', this.updatePrompt.bind(this));
// Conversations
this.router.get('/conversations', this.findConversations.bind(this));
this.router.get('/conversations/user/:userId', this.findUserConversations.bind(this));
this.router.get('/conversations/:id', this.findConversation.bind(this));
this.router.post('/conversations', this.createConversation.bind(this));
this.router.patch('/conversations/:id', this.updateConversation.bind(this));
this.router.post('/conversations/:id/archive', this.archiveConversation.bind(this));
// Messages
this.router.get('/conversations/:conversationId/messages', this.findMessages.bind(this));
this.router.post('/conversations/:conversationId/messages', this.addMessage.bind(this));
this.router.get('/conversations/:conversationId/tokens', this.getConversationTokenCount.bind(this));
// Usage & Quotas
this.router.post('/usage', this.logUsage.bind(this));
this.router.get('/usage/stats', this.getUsageStats.bind(this));
this.router.get('/quotas', this.getTenantQuota.bind(this));
this.router.patch('/quotas', this.updateTenantQuota.bind(this));
this.router.get('/quotas/check', this.checkQuotaAvailable.bind(this));
}
// ============================================
// MODELS
// ============================================
private async findAllModels(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const models = await this.aiService.findAllModels();
res.json({ data: models, total: models.length });
} catch (error) {
next(error);
}
}
private async findModel(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const model = await this.aiService.findModel(id);
if (!model) {
res.status(404).json({ error: 'Model not found' });
return;
}
res.json({ data: model });
} catch (error) {
next(error);
}
}
private async findModelByCode(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { code } = req.params;
const model = await this.aiService.findModelByCode(code);
if (!model) {
res.status(404).json({ error: 'Model not found' });
return;
}
res.json({ data: model });
} catch (error) {
next(error);
}
}
private async findModelsByProvider(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { provider } = req.params;
const models = await this.aiService.findModelsByProvider(provider);
res.json({ data: models, total: models.length });
} catch (error) {
next(error);
}
}
private async findModelsByType(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { type } = req.params;
const models = await this.aiService.findModelsByType(type);
res.json({ data: models, total: models.length });
} catch (error) {
next(error);
}
}
// ============================================
// PROMPTS
// ============================================
private async findAllPrompts(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const prompts = await this.aiService.findAllPrompts(tenantId);
res.json({ data: prompts, total: prompts.length });
} catch (error) {
next(error);
}
}
private async findPrompt(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const prompt = await this.aiService.findPrompt(id);
if (!prompt) {
res.status(404).json({ error: 'Prompt not found' });
return;
}
res.json({ data: prompt });
} catch (error) {
next(error);
}
}
private async findPromptByCode(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { code } = req.params;
const tenantId = req.headers['x-tenant-id'] as string;
const prompt = await this.aiService.findPromptByCode(code, tenantId);
if (!prompt) {
res.status(404).json({ error: 'Prompt not found' });
return;
}
// Increment usage count
await this.aiService.incrementPromptUsage(prompt.id);
res.json({ data: prompt });
} catch (error) {
next(error);
}
}
private async createPrompt(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const prompt = await this.aiService.createPrompt(tenantId, req.body, userId);
res.status(201).json({ data: prompt });
} catch (error) {
next(error);
}
}
private async updatePrompt(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const userId = req.headers['x-user-id'] as string;
const prompt = await this.aiService.updatePrompt(id, req.body, userId);
if (!prompt) {
res.status(404).json({ error: 'Prompt not found' });
return;
}
res.json({ data: prompt });
} catch (error) {
next(error);
}
}
// ============================================
// CONVERSATIONS
// ============================================
private async findConversations(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const filters: ConversationFilters = {
userId: req.query.userId as string,
modelId: req.query.modelId as string,
status: req.query.status as string,
};
if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string);
if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string);
const limit = parseInt(req.query.limit as string) || 50;
const conversations = await this.aiService.findConversations(tenantId, filters, limit);
res.json({ data: conversations, total: conversations.length });
} catch (error) {
next(error);
}
}
private async findUserConversations(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { userId } = req.params;
const limit = parseInt(req.query.limit as string) || 20;
const conversations = await this.aiService.findUserConversations(tenantId, userId, limit);
res.json({ data: conversations, total: conversations.length });
} catch (error) {
next(error);
}
}
private async findConversation(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const conversation = await this.aiService.findConversation(id);
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
res.json({ data: conversation });
} catch (error) {
next(error);
}
}
private async createConversation(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const conversation = await this.aiService.createConversation(tenantId, userId, req.body);
res.status(201).json({ data: conversation });
} catch (error) {
next(error);
}
}
private async updateConversation(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const conversation = await this.aiService.updateConversation(id, req.body);
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
res.json({ data: conversation });
} catch (error) {
next(error);
}
}
private async archiveConversation(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const archived = await this.aiService.archiveConversation(id);
if (!archived) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
res.json({ data: { success: true } });
} catch (error) {
next(error);
}
}
// ============================================
// MESSAGES
// ============================================
private async findMessages(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { conversationId } = req.params;
const messages = await this.aiService.findMessages(conversationId);
res.json({ data: messages, total: messages.length });
} catch (error) {
next(error);
}
}
private async addMessage(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { conversationId } = req.params;
const message = await this.aiService.addMessage(conversationId, req.body);
res.status(201).json({ data: message });
} catch (error) {
next(error);
}
}
private async getConversationTokenCount(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { conversationId } = req.params;
const tokenCount = await this.aiService.getConversationTokenCount(conversationId);
res.json({ data: { tokenCount } });
} catch (error) {
next(error);
}
}
// ============================================
// USAGE & QUOTAS
// ============================================
private async logUsage(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const log = await this.aiService.logUsage(tenantId, req.body);
res.status(201).json({ data: log });
} catch (error) {
next(error);
}
}
private async getUsageStats(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const startDate = new Date(req.query.startDate as string || Date.now() - 30 * 24 * 60 * 60 * 1000);
const endDate = new Date(req.query.endDate as string || Date.now());
const stats = await this.aiService.getUsageStats(tenantId, startDate, endDate);
res.json({ data: stats });
} catch (error) {
next(error);
}
}
private async getTenantQuota(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const quota = await this.aiService.getTenantQuota(tenantId);
res.json({ data: quota });
} catch (error) {
next(error);
}
}
private async updateTenantQuota(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const quota = await this.aiService.updateTenantQuota(tenantId, req.body);
res.json({ data: quota });
} catch (error) {
next(error);
}
}
private async checkQuotaAvailable(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const result = await this.aiService.checkQuotaAvailable(tenantId);
res.json({ data: result });
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1 @@
export { AIController } from './ai.controller';

View File

@ -0,0 +1,343 @@
import {
IsString,
IsOptional,
IsBoolean,
IsNumber,
IsArray,
IsObject,
IsUUID,
MaxLength,
MinLength,
Min,
Max,
} from 'class-validator';
// ============================================
// PROMPT DTOs
// ============================================
export class CreatePromptDto {
@IsString()
@MinLength(2)
@MaxLength(50)
code: string;
@IsString()
@MinLength(2)
@MaxLength(100)
name: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
@MaxLength(30)
category?: string;
@IsString()
systemPrompt: string;
@IsOptional()
@IsString()
userPromptTemplate?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
variables?: string[];
@IsOptional()
@IsNumber()
@Min(0)
@Max(2)
temperature?: number;
@IsOptional()
@IsNumber()
@Min(1)
maxTokens?: number;
@IsOptional()
@IsArray()
@IsString({ each: true })
stopSequences?: string[];
@IsOptional()
@IsObject()
modelParameters?: Record<string, any>;
@IsOptional()
@IsArray()
@IsString({ each: true })
allowedModels?: string[];
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
export class UpdatePromptDto {
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
@MaxLength(30)
category?: string;
@IsOptional()
@IsString()
systemPrompt?: string;
@IsOptional()
@IsString()
userPromptTemplate?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
variables?: string[];
@IsOptional()
@IsNumber()
@Min(0)
@Max(2)
temperature?: number;
@IsOptional()
@IsNumber()
@Min(1)
maxTokens?: number;
@IsOptional()
@IsArray()
@IsString({ each: true })
stopSequences?: string[];
@IsOptional()
@IsObject()
modelParameters?: Record<string, any>;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
// ============================================
// CONVERSATION DTOs
// ============================================
export class CreateConversationDto {
@IsOptional()
@IsUUID()
modelId?: string;
@IsOptional()
@IsUUID()
promptId?: string;
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
systemPrompt?: string;
@IsOptional()
@IsNumber()
@Min(0)
@Max(2)
temperature?: number;
@IsOptional()
@IsNumber()
@Min(1)
maxTokens?: number;
@IsOptional()
@IsObject()
context?: Record<string, any>;
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
export class UpdateConversationDto {
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
systemPrompt?: string;
@IsOptional()
@IsNumber()
@Min(0)
@Max(2)
temperature?: number;
@IsOptional()
@IsNumber()
@Min(1)
maxTokens?: number;
@IsOptional()
@IsObject()
context?: Record<string, any>;
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
// ============================================
// MESSAGE DTOs
// ============================================
export class AddMessageDto {
@IsString()
@MaxLength(20)
role: string;
@IsString()
content: string;
@IsOptional()
@IsString()
@MaxLength(50)
modelCode?: string;
@IsOptional()
@IsNumber()
@Min(0)
promptTokens?: number;
@IsOptional()
@IsNumber()
@Min(0)
completionTokens?: number;
@IsOptional()
@IsNumber()
@Min(0)
totalTokens?: number;
@IsOptional()
@IsString()
@MaxLength(30)
finishReason?: string;
@IsOptional()
@IsNumber()
@Min(0)
latencyMs?: number;
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
// ============================================
// USAGE DTOs
// ============================================
export class LogUsageDto {
@IsOptional()
@IsUUID()
userId?: string;
@IsOptional()
@IsUUID()
conversationId?: string;
@IsUUID()
modelId: string;
@IsString()
@MaxLength(20)
usageType: string;
@IsNumber()
@Min(0)
inputTokens: number;
@IsNumber()
@Min(0)
outputTokens: number;
@IsOptional()
@IsNumber()
@Min(0)
costUsd?: number;
@IsOptional()
@IsNumber()
@Min(0)
latencyMs?: number;
@IsOptional()
@IsBoolean()
wasSuccessful?: boolean;
@IsOptional()
@IsString()
errorMessage?: string;
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
// ============================================
// QUOTA DTOs
// ============================================
export class UpdateQuotaDto {
@IsOptional()
@IsNumber()
@Min(0)
maxRequestsPerMonth?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxTokensPerMonth?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxSpendPerMonth?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxRequestsPerDay?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxTokensPerDay?: number;
@IsOptional()
@IsArray()
@IsString({ each: true })
allowedModels?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
blockedModels?: string[];
}

View File

@ -0,0 +1,9 @@
export {
CreatePromptDto,
UpdatePromptDto,
CreateConversationDto,
UpdateConversationDto,
AddMessageDto,
LogUsageDto,
UpdateQuotaDto,
} from './ai.dto';

View File

@ -0,0 +1,92 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { AIModel } from './model.entity';
import { AIPrompt } from './prompt.entity';
export type CompletionStatus = 'pending' | 'processing' | 'completed' | 'failed';
@Entity({ name: 'completions', schema: 'ai' })
export class AICompletion {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string;
@Index()
@Column({ name: 'prompt_id', type: 'uuid', nullable: true })
promptId: string;
@Column({ name: 'prompt_code', type: 'varchar', length: 100, nullable: true })
promptCode: string;
@Column({ name: 'model_id', type: 'uuid', nullable: true })
modelId: string;
@Column({ name: 'input_text', type: 'text' })
inputText: string;
@Column({ name: 'input_variables', type: 'jsonb', default: {} })
inputVariables: Record<string, any>;
@Column({ name: 'output_text', type: 'text', nullable: true })
outputText: string;
@Column({ name: 'prompt_tokens', type: 'int', nullable: true })
promptTokens: number;
@Column({ name: 'completion_tokens', type: 'int', nullable: true })
completionTokens: number;
@Column({ name: 'total_tokens', type: 'int', nullable: true })
totalTokens: number;
@Column({ name: 'cost', type: 'decimal', precision: 10, scale: 6, nullable: true })
cost: number;
@Column({ name: 'latency_ms', type: 'int', nullable: true })
latencyMs: number;
@Column({ name: 'finish_reason', type: 'varchar', length: 30, nullable: true })
finishReason: string;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' })
status: CompletionStatus;
@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage: string;
@Index()
@Column({ name: 'context_type', type: 'varchar', length: 50, nullable: true })
contextType: string;
@Column({ name: 'context_id', type: 'uuid', nullable: true })
contextId: string;
@Column({ name: 'metadata', type: 'jsonb', default: {} })
metadata: Record<string, any>;
@Index()
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@ManyToOne(() => AIModel, { nullable: true })
@JoinColumn({ name: 'model_id' })
model: AIModel;
@ManyToOne(() => AIPrompt, { nullable: true })
@JoinColumn({ name: 'prompt_id' })
prompt: AIPrompt;
}

View File

@ -0,0 +1,160 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
import { AIModel } from './model.entity';
export type ConversationStatus = 'active' | 'archived' | 'deleted';
export type MessageRole = 'system' | 'user' | 'assistant' | 'function';
export type FinishReason = 'stop' | 'length' | 'function_call' | 'content_filter';
@Entity({ name: 'conversations', schema: 'ai' })
export class AIConversation {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ name: 'title', type: 'varchar', length: 255, nullable: true })
title: string;
@Column({ name: 'summary', type: 'text', nullable: true })
summary: string;
@Column({ name: 'context_type', type: 'varchar', length: 50, nullable: true })
contextType: string;
@Column({ name: 'context_data', type: 'jsonb', default: {} })
contextData: Record<string, any>;
@Column({ name: 'model_id', type: 'uuid', nullable: true })
modelId: string;
@Column({ name: 'prompt_id', type: 'uuid', nullable: true })
promptId: string;
@Index()
@Column({ name: 'status', type: 'varchar', length: 20, default: 'active' })
status: ConversationStatus;
@Column({ name: 'is_pinned', type: 'boolean', default: false })
isPinned: boolean;
@Column({ name: 'message_count', type: 'int', default: 0 })
messageCount: number;
@Column({ name: 'total_tokens', type: 'int', default: 0 })
totalTokens: number;
@Column({ name: 'total_cost', type: 'decimal', precision: 10, scale: 4, default: 0 })
totalCost: number;
@Column({ name: 'metadata', type: 'jsonb', default: {} })
metadata: Record<string, any>;
@Column({ name: 'tags', type: 'text', array: true, default: [] })
tags: string[];
@Column({ name: 'last_message_at', type: 'timestamptz', nullable: true })
lastMessageAt: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@ManyToOne(() => AIModel, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'model_id' })
model: AIModel;
@OneToMany(() => AIMessage, (message) => message.conversation)
messages: AIMessage[];
}
@Entity({ name: 'messages', schema: 'ai' })
export class AIMessage {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'conversation_id', type: 'uuid' })
conversationId: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'role', type: 'varchar', length: 20 })
role: MessageRole;
@Column({ name: 'content', type: 'text' })
content: string;
@Column({ name: 'function_name', type: 'varchar', length: 100, nullable: true })
functionName: string;
@Column({ name: 'function_arguments', type: 'jsonb', nullable: true })
functionArguments: Record<string, any>;
@Column({ name: 'function_result', type: 'jsonb', nullable: true })
functionResult: Record<string, any>;
@Column({ name: 'model_id', type: 'uuid', nullable: true })
modelId: string;
@Column({ name: 'model_response_id', type: 'varchar', length: 255, nullable: true })
modelResponseId: string;
@Column({ name: 'prompt_tokens', type: 'int', nullable: true })
promptTokens: number;
@Column({ name: 'completion_tokens', type: 'int', nullable: true })
completionTokens: number;
@Column({ name: 'total_tokens', type: 'int', nullable: true })
totalTokens: number;
@Column({ name: 'cost', type: 'decimal', precision: 10, scale: 6, nullable: true })
cost: number;
@Column({ name: 'latency_ms', type: 'int', nullable: true })
latencyMs: number;
@Column({ name: 'finish_reason', type: 'varchar', length: 30, nullable: true })
finishReason: FinishReason;
@Column({ name: 'metadata', type: 'jsonb', default: {} })
metadata: Record<string, any>;
@Column({ name: 'feedback_rating', type: 'int', nullable: true })
feedbackRating: number;
@Column({ name: 'feedback_text', type: 'text', nullable: true })
feedbackText: string;
@Index()
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@ManyToOne(() => AIConversation, (conversation) => conversation.messages, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'conversation_id' })
conversation: AIConversation;
@ManyToOne(() => AIModel, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'model_id' })
model: AIModel;
}

View File

@ -0,0 +1,77 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { AIModel } from './model.entity';
@Entity({ name: 'embeddings', schema: 'ai' })
export class AIEmbedding {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'content', type: 'text' })
content: string;
@Index()
@Column({ name: 'content_hash', type: 'varchar', length: 64, nullable: true })
contentHash: string;
// Note: If pgvector is enabled, use 'vector' type instead of 'jsonb'
@Column({ name: 'embedding_json', type: 'jsonb', nullable: true })
embeddingJson: number[];
@Column({ name: 'model_id', type: 'uuid', nullable: true })
modelId: string;
@Column({ name: 'model_name', type: 'varchar', length: 100, nullable: true })
modelName: string;
@Column({ name: 'dimensions', type: 'int', nullable: true })
dimensions: number;
@Index()
@Column({ name: 'entity_type', type: 'varchar', length: 100, nullable: true })
entityType: string;
@Column({ name: 'entity_id', type: 'uuid', nullable: true })
entityId: string;
@Column({ name: 'metadata', type: 'jsonb', default: {} })
metadata: Record<string, any>;
@Column({ name: 'tags', type: 'text', array: true, default: [] })
tags: string[];
@Column({ name: 'chunk_index', type: 'int', nullable: true })
chunkIndex: number;
@Column({ name: 'chunk_total', type: 'int', nullable: true })
chunkTotal: number;
@Column({ name: 'parent_embedding_id', type: 'uuid', nullable: true })
parentEmbeddingId: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@ManyToOne(() => AIModel, { nullable: true })
@JoinColumn({ name: 'model_id' })
model: AIModel;
@ManyToOne(() => AIEmbedding, { nullable: true })
@JoinColumn({ name: 'parent_embedding_id' })
parentEmbedding: AIEmbedding;
}

View File

@ -0,0 +1,7 @@
export { AIModel, AIProvider, ModelType } from './model.entity';
export { AIConversation, AIMessage, ConversationStatus, MessageRole, FinishReason } from './conversation.entity';
export { AIPrompt, PromptCategory } from './prompt.entity';
export { AIUsageLog, AITenantQuota, UsageType } from './usage.entity';
export { AICompletion, CompletionStatus } from './completion.entity';
export { AIEmbedding } from './embedding.entity';
export { AIKnowledgeBase, KnowledgeSourceType, KnowledgeContentType } from './knowledge-base.entity';

View File

@ -0,0 +1,98 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';
import { AIEmbedding } from './embedding.entity';
export type KnowledgeSourceType = 'manual' | 'document' | 'website' | 'api';
export type KnowledgeContentType = 'faq' | 'documentation' | 'policy' | 'procedure';
@Entity({ name: 'knowledge_base', schema: 'ai' })
@Unique(['tenantId', 'code'])
export class AIKnowledgeBase {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
tenantId: string;
@Column({ name: 'code', type: 'varchar', length: 100 })
code: string;
@Column({ name: 'name', type: 'varchar', length: 200 })
name: string;
@Column({ name: 'description', type: 'text', nullable: true })
description: string;
@Column({ name: 'source_type', type: 'varchar', length: 30, nullable: true })
sourceType: KnowledgeSourceType;
@Column({ name: 'source_url', type: 'text', nullable: true })
sourceUrl: string;
@Column({ name: 'source_file_id', type: 'uuid', nullable: true })
sourceFileId: string;
@Column({ name: 'content', type: 'text' })
content: string;
@Column({ name: 'content_type', type: 'varchar', length: 50, nullable: true })
contentType: KnowledgeContentType;
@Index()
@Column({ name: 'category', type: 'varchar', length: 100, nullable: true })
category: string;
@Column({ name: 'subcategory', type: 'varchar', length: 100, nullable: true })
subcategory: string;
@Column({ name: 'tags', type: 'text', array: true, default: [] })
tags: string[];
@Column({ name: 'embedding_id', type: 'uuid', nullable: true })
embeddingId: string;
@Column({ name: 'priority', type: 'int', default: 0 })
priority: number;
@Column({ name: 'relevance_score', type: 'decimal', precision: 5, scale: 4, nullable: true })
relevanceScore: number;
@Index()
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'is_verified', type: 'boolean', default: false })
isVerified: boolean;
@Column({ name: 'verified_by', type: 'uuid', nullable: true })
verifiedBy: string;
@Column({ name: 'verified_at', type: 'timestamptz', nullable: true })
verifiedAt: Date;
@Column({ name: 'metadata', type: 'jsonb', default: {} })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@ManyToOne(() => AIEmbedding, { nullable: true })
@JoinColumn({ name: 'embedding_id' })
embedding: AIEmbedding;
}

View File

@ -0,0 +1,78 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export type AIProvider = 'openai' | 'anthropic' | 'google' | 'azure' | 'local';
export type ModelType = 'chat' | 'completion' | 'embedding' | 'image' | 'audio';
@Entity({ name: 'models', schema: 'ai' })
export class AIModel {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index({ unique: true })
@Column({ name: 'code', type: 'varchar', length: 100 })
code: string;
@Column({ name: 'name', type: 'varchar', length: 200 })
name: string;
@Column({ name: 'description', type: 'text', nullable: true })
description: string;
@Index()
@Column({ name: 'provider', type: 'varchar', length: 50 })
provider: AIProvider;
@Column({ name: 'model_id', type: 'varchar', length: 100 })
modelId: string;
@Index()
@Column({ name: 'model_type', type: 'varchar', length: 30 })
modelType: ModelType;
@Column({ name: 'max_tokens', type: 'int', nullable: true })
maxTokens: number;
@Column({ name: 'supports_functions', type: 'boolean', default: false })
supportsFunctions: boolean;
@Column({ name: 'supports_vision', type: 'boolean', default: false })
supportsVision: boolean;
@Column({ name: 'supports_streaming', type: 'boolean', default: true })
supportsStreaming: boolean;
@Column({ name: 'input_cost_per_1k', type: 'decimal', precision: 10, scale: 6, nullable: true })
inputCostPer1k: number;
@Column({ name: 'output_cost_per_1k', type: 'decimal', precision: 10, scale: 6, nullable: true })
outputCostPer1k: number;
@Column({ name: 'rate_limit_rpm', type: 'int', nullable: true })
rateLimitRpm: number;
@Column({ name: 'rate_limit_tpm', type: 'int', nullable: true })
rateLimitTpm: number;
@Index()
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'is_default', type: 'boolean', default: false })
isDefault: boolean;
@Column({ name: 'metadata', type: 'jsonb', default: {} })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,110 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';
import { AIModel } from './model.entity';
export type PromptCategory = 'assistant' | 'analysis' | 'generation' | 'extraction';
@Entity({ name: 'prompts', schema: 'ai' })
@Unique(['tenantId', 'code', 'version'])
export class AIPrompt {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
tenantId: string;
@Index()
@Column({ name: 'code', type: 'varchar', length: 100 })
code: string;
@Column({ name: 'name', type: 'varchar', length: 200 })
name: string;
@Column({ name: 'description', type: 'text', nullable: true })
description: string;
@Index()
@Column({ name: 'category', type: 'varchar', length: 50, nullable: true })
category: PromptCategory;
@Column({ name: 'system_prompt', type: 'text', nullable: true })
systemPrompt: string;
@Column({ name: 'user_prompt_template', type: 'text' })
userPromptTemplate: string;
@Column({ name: 'model_id', type: 'uuid', nullable: true })
modelId: string;
@Column({ name: 'temperature', type: 'decimal', precision: 3, scale: 2, default: 0.7 })
temperature: number;
@Column({ name: 'max_tokens', type: 'int', nullable: true })
maxTokens: number;
@Column({ name: 'top_p', type: 'decimal', precision: 3, scale: 2, nullable: true })
topP: number;
@Column({ name: 'frequency_penalty', type: 'decimal', precision: 3, scale: 2, nullable: true })
frequencyPenalty: number;
@Column({ name: 'presence_penalty', type: 'decimal', precision: 3, scale: 2, nullable: true })
presencePenalty: number;
@Column({ name: 'required_variables', type: 'text', array: true, default: [] })
requiredVariables: string[];
@Column({ name: 'variable_schema', type: 'jsonb', default: {} })
variableSchema: Record<string, any>;
@Column({ name: 'functions', type: 'jsonb', default: [] })
functions: Record<string, any>[];
@Column({ name: 'version', type: 'int', default: 1 })
version: number;
@Column({ name: 'is_latest', type: 'boolean', default: true })
isLatest: boolean;
@Column({ name: 'parent_version_id', type: 'uuid', nullable: true })
parentVersionId: string;
@Index()
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'is_system', type: 'boolean', default: false })
isSystem: boolean;
@Column({ name: 'usage_count', type: 'int', default: 0 })
usageCount: number;
@Column({ name: 'avg_tokens_used', type: 'int', nullable: true })
avgTokensUsed: number;
@Column({ name: 'avg_latency_ms', type: 'int', nullable: true })
avgLatencyMs: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@ManyToOne(() => AIModel, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'model_id' })
model: AIModel;
}

View File

@ -0,0 +1,120 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
Unique,
} from 'typeorm';
export type UsageType = 'chat' | 'completion' | 'embedding' | 'image';
@Entity({ name: 'usage_logs', schema: 'ai' })
export class AIUsageLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string;
@Index()
@Column({ name: 'model_id', type: 'uuid', nullable: true })
modelId: string;
@Column({ name: 'model_name', type: 'varchar', length: 100, nullable: true })
modelName: string;
@Column({ name: 'provider', type: 'varchar', length: 50, nullable: true })
provider: string;
@Column({ name: 'usage_type', type: 'varchar', length: 30 })
usageType: UsageType;
@Column({ name: 'prompt_tokens', type: 'int', default: 0 })
promptTokens: number;
@Column({ name: 'completion_tokens', type: 'int', default: 0 })
completionTokens: number;
@Column({ name: 'total_tokens', type: 'int', default: 0 })
totalTokens: number;
@Column({ name: 'cost', type: 'decimal', precision: 10, scale: 6, default: 0 })
cost: number;
@Column({ name: 'conversation_id', type: 'uuid', nullable: true })
conversationId: string;
@Column({ name: 'completion_id', type: 'uuid', nullable: true })
completionId: string;
@Column({ name: 'request_id', type: 'varchar', length: 255, nullable: true })
requestId: string;
@Index()
@Column({ name: 'usage_date', type: 'date', default: () => 'CURRENT_DATE' })
usageDate: Date;
@Index()
@Column({ name: 'usage_month', type: 'varchar', length: 7, nullable: true })
usageMonth: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}
@Entity({ name: 'tenant_quotas', schema: 'ai' })
@Unique(['tenantId', 'quotaMonth'])
export class AITenantQuota {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'monthly_token_limit', type: 'int', nullable: true })
monthlyTokenLimit: number;
@Column({ name: 'monthly_request_limit', type: 'int', nullable: true })
monthlyRequestLimit: number;
@Column({ name: 'monthly_cost_limit', type: 'decimal', precision: 10, scale: 2, nullable: true })
monthlyCostLimit: number;
@Column({ name: 'current_tokens', type: 'int', default: 0 })
currentTokens: number;
@Column({ name: 'current_requests', type: 'int', default: 0 })
currentRequests: number;
@Column({ name: 'current_cost', type: 'decimal', precision: 10, scale: 4, default: 0 })
currentCost: number;
@Index()
@Column({ name: 'quota_month', type: 'varchar', length: 7 })
quotaMonth: string;
@Column({ name: 'is_exceeded', type: 'boolean', default: false })
isExceeded: boolean;
@Column({ name: 'exceeded_at', type: 'timestamptz', nullable: true })
exceededAt: Date;
@Column({ name: 'alert_threshold_percent', type: 'int', default: 80 })
alertThresholdPercent: number;
@Column({ name: 'alert_sent_at', type: 'timestamptz', nullable: true })
alertSentAt: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

5
src/modules/ai/index.ts Normal file
View File

@ -0,0 +1,5 @@
export { AIModule, AIModuleOptions } from './ai.module';
export * from './entities';
export * from './services';
export * from './controllers';
export * from './dto';

View File

@ -0,0 +1,86 @@
/**
* System Prompt - Administrador
*
* Prompt para el rol de administrador con acceso completo al ERP
*/
export const ADMIN_SYSTEM_PROMPT = `Eres el asistente de inteligencia artificial de {business_name}, un sistema ERP empresarial.
## Tu Rol
Eres un asistente ejecutivo con acceso COMPLETO a todas las operaciones del sistema. Ayudas a los administradores a gestionar el negocio de manera eficiente.
## Capacidades
### Ventas y Comercial
- Consultar resúmenes y reportes de ventas (diarios, semanales, mensuales)
- Ver productos más vendidos y clientes principales
- Analizar ventas por sucursal
- Crear y anular ventas
- Generar reportes personalizados
### Inventario
- Ver estado del inventario en tiempo real
- Identificar productos con stock bajo
- Calcular valor del inventario
- Realizar ajustes de inventario
- Transferir productos entre sucursales
### Compras y Proveedores
- Ver órdenes de compra pendientes
- Consultar información de proveedores
- Crear órdenes de compra
- Aprobar compras
### Finanzas
- Ver reportes financieros
- Consultar cuentas por cobrar y pagar
- Analizar flujo de caja
- Ver KPIs del negocio
### Administración
- Gestionar usuarios y permisos
- Ver logs de auditoría
- Configurar parámetros del sistema
- Gestionar sucursales
## Instrucciones
1. **Responde siempre en español** de forma profesional y concisa
2. **Usa datos reales** del sistema, nunca inventes información
3. **Formatea números** con separadores de miles y el símbolo $ para montos en MXN
4. **Incluye contexto** cuando presentes datos (fechas, períodos, filtros aplicados)
5. **Sugiere acciones** cuando detectes problemas o oportunidades
6. **Confirma acciones destructivas** antes de ejecutarlas (anular ventas, eliminar registros)
## Restricciones
- NO puedes acceder a información de otros tenants
- NO puedes modificar credenciales de integración
- NO puedes ejecutar operaciones que requieran aprobación de otro nivel
- Ante dudas sobre permisos, consulta antes de actuar
## Formato de Respuesta
Cuando presentes datos:
- Usa tablas para listados
- Usa listas para resúmenes
- Incluye totales cuando sea relevante
- Destaca valores importantes (alertas, anomalías)
Fecha actual: {current_date}
Sucursal actual: {current_branch}
`;
/**
* Generar prompt con variables
*/
export function generateAdminPrompt(variables: {
businessName: string;
currentDate: string;
currentBranch: string;
}): string {
return ADMIN_SYSTEM_PROMPT
.replace('{business_name}', variables.businessName)
.replace('{current_date}', variables.currentDate)
.replace('{current_branch}', variables.currentBranch || 'Todas');
}

View File

@ -0,0 +1,67 @@
/**
* System Prompt - Cliente
*
* Prompt para clientes externos (si se expone chatbot)
*/
export const CUSTOMER_SYSTEM_PROMPT = `Eres el asistente virtual de {business_name}.
## Tu Rol
Ayudas a los clientes a consultar productos, revisar sus pedidos y obtener información del negocio.
## Capacidades
### Catálogo
- Ver productos disponibles
- Buscar por nombre o categoría
- Consultar disponibilidad
### Mis Pedidos
- Ver estado de mis pedidos
- Rastrear entregas
### Mi Cuenta
- Consultar mi saldo
- Ver historial de compras
### Información
- Horarios de tienda
- Ubicación
- Promociones activas
- Contacto de soporte
## Instrucciones
1. ** amable y servicial**
2. **Responde en español**
3. **Protege la privacidad** - solo muestra información del cliente autenticado
4. **Ofrece ayuda adicional** cuando sea apropiado
5. **Escala a soporte** si no puedes resolver la consulta
## Restricciones
- SOLO puedes ver información del cliente autenticado
- NO puedes ver información de otros clientes
- NO puedes modificar pedidos
- NO puedes procesar pagos
- NO puedes acceder a datos internos del negocio
## Formato de Respuesta
amigable pero profesional:
- Saluda al cliente por nombre si está disponible
- Usa emojis con moderación
- Ofrece opciones claras
- Despídete cordialmente
Horario de atención: {store_hours}
`;
export function generateCustomerPrompt(variables: {
businessName: string;
storeHours?: string;
}): string {
return CUSTOMER_SYSTEM_PROMPT
.replace('{business_name}', variables.businessName)
.replace('{store_hours}', variables.storeHours || 'Lun-Sáb 9:00-20:00');
}

View File

@ -0,0 +1,48 @@
/**
* System Prompts Index
*/
export { ADMIN_SYSTEM_PROMPT, generateAdminPrompt } from './admin-system-prompt';
export { SUPERVISOR_SYSTEM_PROMPT, generateSupervisorPrompt } from './supervisor-system-prompt';
export { OPERATOR_SYSTEM_PROMPT, generateOperatorPrompt } from './operator-system-prompt';
export { CUSTOMER_SYSTEM_PROMPT, generateCustomerPrompt } from './customer-system-prompt';
import { ERPRole } from '../roles/erp-roles.config';
import { generateAdminPrompt } from './admin-system-prompt';
import { generateSupervisorPrompt } from './supervisor-system-prompt';
import { generateOperatorPrompt } from './operator-system-prompt';
import { generateCustomerPrompt } from './customer-system-prompt';
export interface PromptVariables {
businessName: string;
currentDate?: string;
currentBranch?: string;
maxDiscount?: number;
storeHours?: string;
}
/**
* Generar system prompt para un rol
*/
export function generateSystemPrompt(role: ERPRole, variables: PromptVariables): string {
const baseVars = {
businessName: variables.businessName,
currentDate: variables.currentDate || new Date().toLocaleDateString('es-MX'),
currentBranch: variables.currentBranch || 'Principal',
maxDiscount: variables.maxDiscount,
storeHours: variables.storeHours,
};
switch (role) {
case 'ADMIN':
return generateAdminPrompt(baseVars);
case 'SUPERVISOR':
return generateSupervisorPrompt(baseVars);
case 'OPERATOR':
return generateOperatorPrompt(baseVars);
case 'CUSTOMER':
return generateCustomerPrompt(baseVars);
default:
return generateCustomerPrompt(baseVars);
}
}

View File

@ -0,0 +1,70 @@
/**
* System Prompt - Operador
*
* Prompt para operadores de punto de venta
*/
export const OPERATOR_SYSTEM_PROMPT = `Eres el asistente de {business_name} para punto de venta.
## Tu Rol
Ayudas a los vendedores y cajeros a realizar sus operaciones de forma rápida y eficiente. Tu objetivo es agilizar las ventas y resolver consultas comunes.
## Capacidades
### Productos
- Buscar productos por nombre, código o categoría
- Consultar precios
- Verificar disponibilidad en inventario
### Ventas
- Registrar ventas
- Ver tus ventas del día
- Aplicar descuentos (hasta tu límite)
### Clientes
- Buscar clientes
- Consultar saldo de cuenta (fiado)
- Registrar pagos
### Información
- Consultar horarios de la tienda
- Ver promociones activas
## Instrucciones
1. **Responde rápido** - los clientes están esperando
2. ** conciso** - ve al punto
3. **Confirma precios** antes de una venta
4. **Alerta si no hay stock** suficiente
## Restricciones
- NO puedes ver reportes financieros
- NO puedes modificar precios
- NO puedes aprobar descuentos mayores a {max_discount}%
- NO puedes ver información de otras sucursales
- NO puedes anular ventas sin autorización
## Formato de Respuesta
Respuestas cortas y claras:
- "Producto X - $150.00 - 5 en stock"
- "Cliente tiene saldo de $500.00 pendiente"
- "Descuento aplicado: 10%"
Fecha: {current_date}
Sucursal: {current_branch}
`;
export function generateOperatorPrompt(variables: {
businessName: string;
currentDate: string;
currentBranch: string;
maxDiscount?: number;
}): string {
return OPERATOR_SYSTEM_PROMPT
.replace('{business_name}', variables.businessName)
.replace('{current_date}', variables.currentDate)
.replace('{current_branch}', variables.currentBranch)
.replace('{max_discount}', String(variables.maxDiscount || 10));
}

View File

@ -0,0 +1,78 @@
/**
* System Prompt - Supervisor
*
* Prompt para supervisores con acceso a su equipo y sucursal
*/
export const SUPERVISOR_SYSTEM_PROMPT = `Eres el asistente de inteligencia artificial de {business_name}.
## Tu Rol
Eres un asistente para supervisores y gerentes de sucursal. Ayudas a gestionar equipos, monitorear operaciones y tomar decisiones a nivel de sucursal.
## Capacidades
### Ventas
- Consultar resúmenes de ventas de tu sucursal
- Ver reportes de desempeño del equipo
- Identificar productos más vendidos
- Registrar ventas
### Inventario
- Ver estado del inventario de tu sucursal
- Identificar productos con stock bajo
- Realizar ajustes menores de inventario
### Equipo
- Ver desempeño de vendedores
- Consultar horarios de empleados
- Gestionar turnos y asignaciones
### Aprobaciones
- Aprobar descuentos (hasta tu límite autorizado)
- Aprobar anulaciones de ventas
- Aprobar reembolsos
### Clientes
- Consultar información de clientes
- Ver saldos pendientes
- Revisar historial de compras
## Instrucciones
1. **Responde en español** de forma clara y práctica
2. **Enfócate en tu sucursal** - solo tienes acceso a datos de tu ubicación
3. **Usa datos reales** del sistema
4. **Prioriza la eficiencia** en tus respuestas
5. **Alerta sobre problemas** que requieran atención inmediata
## Restricciones
- NO puedes ver ventas de otras sucursales en detalle
- NO puedes modificar configuración del sistema
- NO puedes aprobar operaciones fuera de tus límites
- NO puedes gestionar usuarios de otras sucursales
- Descuentos máximos: {max_discount}%
## Formato de Respuesta
- directo y orientado a la acción
- Usa tablas para comparativos
- Destaca anomalías o valores fuera de rango
- Sugiere acciones concretas
Fecha actual: {current_date}
Sucursal: {current_branch}
`;
export function generateSupervisorPrompt(variables: {
businessName: string;
currentDate: string;
currentBranch: string;
maxDiscount?: number;
}): string {
return SUPERVISOR_SYSTEM_PROMPT
.replace('{business_name}', variables.businessName)
.replace('{current_date}', variables.currentDate)
.replace('{current_branch}', variables.currentBranch)
.replace('{max_discount}', String(variables.maxDiscount || 15));
}

View File

@ -0,0 +1,252 @@
/**
* ERP Roles Configuration
*
* Define roles, tools permitidos, y system prompts para cada rol en el ERP.
* Basado en: michangarrito MCH-012/MCH-013 (role-based chatbot)
*
* Roles disponibles:
* - ADMIN: Acceso completo a todas las operaciones
* - SUPERVISOR: Gestión de equipos y reportes de sucursal
* - OPERATOR: Operaciones de punto de venta
* - CUSTOMER: Acceso limitado para clientes (si se expone chatbot)
*/
export type ERPRole = 'ADMIN' | 'SUPERVISOR' | 'OPERATOR' | 'CUSTOMER';
export interface ERPRoleConfig {
name: string;
description: string;
tools: string[];
systemPromptFile: string;
maxConversationHistory: number;
allowedModels?: string[]; // Si vacío, usa el default del tenant
rateLimit: {
requestsPerMinute: number;
tokensPerMinute: number;
};
}
/**
* Configuración de roles ERP
*/
export const ERP_ROLES: Record<ERPRole, ERPRoleConfig> = {
ADMIN: {
name: 'Administrador',
description: 'Acceso completo a todas las operaciones del sistema ERP',
tools: [
// Ventas
'get_sales_summary',
'get_sales_report',
'get_top_products',
'get_top_customers',
'get_sales_by_branch',
'create_sale',
'void_sale',
// Inventario
'get_inventory_status',
'get_low_stock_products',
'get_inventory_value',
'adjust_inventory',
'transfer_inventory',
// Compras
'get_pending_orders',
'get_supplier_info',
'create_purchase_order',
'approve_purchase',
// Finanzas
'get_financial_report',
'get_accounts_receivable',
'get_accounts_payable',
'get_cash_flow',
// Usuarios y configuración
'manage_users',
'view_audit_logs',
'update_settings',
'get_branch_info',
'manage_branches',
// Reportes avanzados
'generate_report',
'export_data',
'get_kpis',
],
systemPromptFile: 'admin-system-prompt',
maxConversationHistory: 50,
rateLimit: {
requestsPerMinute: 100,
tokensPerMinute: 50000,
},
},
SUPERVISOR: {
name: 'Supervisor',
description: 'Gestión de equipos, reportes de sucursal y aprobaciones',
tools: [
// Ventas (lectura + acciones limitadas)
'get_sales_summary',
'get_sales_report',
'get_top_products',
'get_sales_by_branch',
'create_sale',
// Inventario (lectura + ajustes)
'get_inventory_status',
'get_low_stock_products',
'adjust_inventory',
// Equipo
'get_team_performance',
'get_employee_schedule',
'manage_schedules',
// Aprobaciones
'approve_discounts',
'approve_voids',
'approve_refunds',
// Sucursal
'get_branch_info',
'get_branch_report',
// Clientes
'get_customer_info',
'get_customer_balance',
],
systemPromptFile: 'supervisor-system-prompt',
maxConversationHistory: 30,
rateLimit: {
requestsPerMinute: 60,
tokensPerMinute: 30000,
},
},
OPERATOR: {
name: 'Operador',
description: 'Operaciones de punto de venta y consultas básicas',
tools: [
// Productos
'search_products',
'get_product_price',
'check_product_availability',
// Ventas
'create_sale',
'get_my_sales',
'apply_discount', // Con límite
// Clientes
'search_customers',
'get_customer_balance',
'register_payment',
// Inventario (solo lectura)
'check_stock',
// Información
'get_branch_hours',
'get_promotions',
],
systemPromptFile: 'operator-system-prompt',
maxConversationHistory: 20,
rateLimit: {
requestsPerMinute: 30,
tokensPerMinute: 15000,
},
},
CUSTOMER: {
name: 'Cliente',
description: 'Acceso limitado para clientes externos',
tools: [
// Catálogo
'view_catalog',
'search_products',
'check_availability',
// Pedidos
'get_my_orders',
'track_order',
// Cuenta
'get_my_balance',
'get_my_history',
// Soporte
'contact_support',
'get_store_info',
'get_promotions',
],
systemPromptFile: 'customer-system-prompt',
maxConversationHistory: 10,
rateLimit: {
requestsPerMinute: 10,
tokensPerMinute: 5000,
},
},
};
/**
* Mapeo de rol de base de datos a ERPRole
*/
export const DB_ROLE_MAPPING: Record<string, ERPRole> = {
// Roles típicos de sistema
admin: 'ADMIN',
administrator: 'ADMIN',
superadmin: 'ADMIN',
owner: 'ADMIN',
// Supervisores
supervisor: 'SUPERVISOR',
manager: 'SUPERVISOR',
branch_manager: 'SUPERVISOR',
store_manager: 'SUPERVISOR',
// Operadores
operator: 'OPERATOR',
cashier: 'OPERATOR',
sales: 'OPERATOR',
employee: 'OPERATOR',
staff: 'OPERATOR',
// Clientes
customer: 'CUSTOMER',
client: 'CUSTOMER',
guest: 'CUSTOMER',
};
/**
* Obtener rol ERP desde rol de base de datos
*/
export function getERPRole(dbRole: string | undefined): ERPRole {
if (!dbRole) return 'CUSTOMER'; // Default para roles no mapeados
const normalized = dbRole.toLowerCase().trim();
return DB_ROLE_MAPPING[normalized] || 'CUSTOMER';
}
/**
* Verificar si un rol tiene acceso a un tool
*/
export function hasToolAccess(role: ERPRole, toolName: string): boolean {
const roleConfig = ERP_ROLES[role];
if (!roleConfig) return false;
return roleConfig.tools.includes(toolName);
}
/**
* Obtener todos los tools para un rol
*/
export function getToolsForRole(role: ERPRole): string[] {
const roleConfig = ERP_ROLES[role];
return roleConfig?.tools || [];
}
/**
* Obtener configuración completa de un rol
*/
export function getRoleConfig(role: ERPRole): ERPRoleConfig | null {
return ERP_ROLES[role] || null;
}

View File

@ -0,0 +1,14 @@
/**
* ERP Roles Index
*/
export {
ERPRole,
ERPRoleConfig,
ERP_ROLES,
DB_ROLE_MAPPING,
getERPRole,
hasToolAccess,
getToolsForRole,
getRoleConfig,
} from './erp-roles.config';

View File

@ -0,0 +1,382 @@
import { Repository, FindOptionsWhere, LessThan, MoreThanOrEqual } from 'typeorm';
import { AIModel, AIConversation, AIMessage, AIPrompt, AIUsageLog, AITenantQuota } from '../entities';
export interface ConversationFilters {
userId?: string;
modelId?: string;
status?: string;
startDate?: Date;
endDate?: Date;
}
export class AIService {
constructor(
private readonly modelRepository: Repository<AIModel>,
private readonly conversationRepository: Repository<AIConversation>,
private readonly messageRepository: Repository<AIMessage>,
private readonly promptRepository: Repository<AIPrompt>,
private readonly usageLogRepository: Repository<AIUsageLog>,
private readonly quotaRepository: Repository<AITenantQuota>
) {}
// ============================================
// MODELS
// ============================================
async findAllModels(): Promise<AIModel[]> {
return this.modelRepository.find({
where: { isActive: true },
order: { provider: 'ASC', name: 'ASC' },
});
}
async findModel(id: string): Promise<AIModel | null> {
return this.modelRepository.findOne({ where: { id } });
}
async findModelByCode(code: string): Promise<AIModel | null> {
return this.modelRepository.findOne({ where: { code } });
}
async findModelsByProvider(provider: string): Promise<AIModel[]> {
return this.modelRepository.find({
where: { provider: provider as any, isActive: true },
order: { name: 'ASC' },
});
}
async findModelsByType(modelType: string): Promise<AIModel[]> {
return this.modelRepository.find({
where: { modelType: modelType as any, isActive: true },
order: { name: 'ASC' },
});
}
// ============================================
// PROMPTS
// ============================================
async findAllPrompts(tenantId?: string): Promise<AIPrompt[]> {
if (tenantId) {
return this.promptRepository.find({
where: [{ tenantId, isActive: true }, { isSystem: true, isActive: true }],
order: { category: 'ASC', name: 'ASC' },
});
}
return this.promptRepository.find({
where: { isActive: true },
order: { category: 'ASC', name: 'ASC' },
});
}
async findPrompt(id: string): Promise<AIPrompt | null> {
return this.promptRepository.findOne({ where: { id } });
}
async findPromptByCode(code: string, tenantId?: string): Promise<AIPrompt | null> {
if (tenantId) {
// Try tenant-specific first, then system prompt
const tenantPrompt = await this.promptRepository.findOne({
where: { code, tenantId, isActive: true },
});
if (tenantPrompt) return tenantPrompt;
return this.promptRepository.findOne({
where: { code, isSystem: true, isActive: true },
});
}
return this.promptRepository.findOne({ where: { code, isActive: true } });
}
async createPrompt(
tenantId: string,
data: Partial<AIPrompt>,
createdBy?: string
): Promise<AIPrompt> {
const prompt = this.promptRepository.create({
...data,
tenantId,
createdBy,
version: 1,
});
return this.promptRepository.save(prompt);
}
async updatePrompt(
id: string,
data: Partial<AIPrompt>,
updatedBy?: string
): Promise<AIPrompt | null> {
const prompt = await this.findPrompt(id);
if (!prompt) return null;
if (prompt.isSystem) {
throw new Error('Cannot update system prompts');
}
Object.assign(prompt, data, { updatedBy, version: prompt.version + 1 });
return this.promptRepository.save(prompt);
}
async incrementPromptUsage(id: string): Promise<void> {
await this.promptRepository
.createQueryBuilder()
.update()
.set({
usageCount: () => 'usage_count + 1',
})
.where('id = :id', { id })
.execute();
}
// ============================================
// CONVERSATIONS
// ============================================
async findConversations(
tenantId: string,
filters: ConversationFilters = {},
limit: number = 50
): Promise<AIConversation[]> {
const where: FindOptionsWhere<AIConversation> = { tenantId };
if (filters.userId) where.userId = filters.userId;
if (filters.modelId) where.modelId = filters.modelId;
if (filters.status) where.status = filters.status as any;
return this.conversationRepository.find({
where,
order: { updatedAt: 'DESC' },
take: limit,
});
}
async findConversation(id: string): Promise<AIConversation | null> {
return this.conversationRepository.findOne({
where: { id },
relations: ['messages'],
});
}
async findUserConversations(
tenantId: string,
userId: string,
limit: number = 20
): Promise<AIConversation[]> {
return this.conversationRepository.find({
where: { tenantId, userId },
order: { updatedAt: 'DESC' },
take: limit,
});
}
async createConversation(
tenantId: string,
userId: string,
data: Partial<AIConversation>
): Promise<AIConversation> {
const conversation = this.conversationRepository.create({
...data,
tenantId,
userId,
status: 'active',
});
return this.conversationRepository.save(conversation);
}
async updateConversation(
id: string,
data: Partial<AIConversation>
): Promise<AIConversation | null> {
const conversation = await this.conversationRepository.findOne({ where: { id } });
if (!conversation) return null;
Object.assign(conversation, data);
return this.conversationRepository.save(conversation);
}
async archiveConversation(id: string): Promise<boolean> {
const result = await this.conversationRepository.update(id, { status: 'archived' });
return (result.affected ?? 0) > 0;
}
// ============================================
// MESSAGES
// ============================================
async findMessages(conversationId: string): Promise<AIMessage[]> {
return this.messageRepository.find({
where: { conversationId },
order: { createdAt: 'ASC' },
});
}
async addMessage(conversationId: string, data: Partial<AIMessage>): Promise<AIMessage> {
const message = this.messageRepository.create({
...data,
conversationId,
});
const savedMessage = await this.messageRepository.save(message);
// Update conversation
await this.conversationRepository
.createQueryBuilder()
.update()
.set({
messageCount: () => 'message_count + 1',
totalTokens: () => `total_tokens + ${data.totalTokens || 0}`,
updatedAt: new Date(),
})
.where('id = :id', { id: conversationId })
.execute();
return savedMessage;
}
async getConversationTokenCount(conversationId: string): Promise<number> {
const result = await this.messageRepository
.createQueryBuilder('message')
.select('SUM(message.total_tokens)', 'total')
.where('message.conversation_id = :conversationId', { conversationId })
.getRawOne();
return parseInt(result?.total) || 0;
}
// ============================================
// USAGE & QUOTAS
// ============================================
async logUsage(tenantId: string, data: Partial<AIUsageLog>): Promise<AIUsageLog> {
const log = this.usageLogRepository.create({
...data,
tenantId,
});
return this.usageLogRepository.save(log);
}
async getUsageStats(
tenantId: string,
startDate: Date,
endDate: Date
): Promise<{
totalRequests: number;
totalInputTokens: number;
totalOutputTokens: number;
totalCost: number;
byModel: Record<string, { requests: number; tokens: number; cost: number }>;
}> {
const stats = await this.usageLogRepository
.createQueryBuilder('log')
.select('COUNT(*)', 'totalRequests')
.addSelect('SUM(log.input_tokens)', 'totalInputTokens')
.addSelect('SUM(log.output_tokens)', 'totalOutputTokens')
.addSelect('SUM(log.cost_usd)', 'totalCost')
.where('log.tenant_id = :tenantId', { tenantId })
.andWhere('log.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.getRawOne();
const byModelStats = await this.usageLogRepository
.createQueryBuilder('log')
.select('log.model_id', 'modelId')
.addSelect('COUNT(*)', 'requests')
.addSelect('SUM(log.input_tokens + log.output_tokens)', 'tokens')
.addSelect('SUM(log.cost_usd)', 'cost')
.where('log.tenant_id = :tenantId', { tenantId })
.andWhere('log.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.groupBy('log.model_id')
.getRawMany();
const byModel: Record<string, { requests: number; tokens: number; cost: number }> = {};
for (const stat of byModelStats) {
byModel[stat.modelId] = {
requests: parseInt(stat.requests) || 0,
tokens: parseInt(stat.tokens) || 0,
cost: parseFloat(stat.cost) || 0,
};
}
return {
totalRequests: parseInt(stats?.totalRequests) || 0,
totalInputTokens: parseInt(stats?.totalInputTokens) || 0,
totalOutputTokens: parseInt(stats?.totalOutputTokens) || 0,
totalCost: parseFloat(stats?.totalCost) || 0,
byModel,
};
}
async getTenantQuota(tenantId: string): Promise<AITenantQuota | null> {
return this.quotaRepository.findOne({ where: { tenantId } });
}
async updateTenantQuota(
tenantId: string,
data: Partial<AITenantQuota>
): Promise<AITenantQuota> {
let quota = await this.getTenantQuota(tenantId);
if (!quota) {
quota = this.quotaRepository.create({
tenantId,
...data,
});
} else {
Object.assign(quota, data);
}
return this.quotaRepository.save(quota);
}
async incrementQuotaUsage(
tenantId: string,
requestCount: number,
tokenCount: number,
costUsd: number
): Promise<void> {
await this.quotaRepository
.createQueryBuilder()
.update()
.set({
currentRequests: () => `current_requests + ${requestCount}`,
currentTokens: () => `current_tokens + ${tokenCount}`,
currentCost: () => `current_cost + ${costUsd}`,
})
.where('tenant_id = :tenantId', { tenantId })
.execute();
}
async checkQuotaAvailable(tenantId: string): Promise<{
available: boolean;
reason?: string;
}> {
const quota = await this.getTenantQuota(tenantId);
if (!quota) return { available: true };
if (quota.monthlyRequestLimit && quota.currentRequests >= quota.monthlyRequestLimit) {
return { available: false, reason: 'Monthly request limit reached' };
}
if (quota.monthlyTokenLimit && quota.currentTokens >= quota.monthlyTokenLimit) {
return { available: false, reason: 'Monthly token limit reached' };
}
if (quota.monthlyCostLimit && quota.currentCost >= quota.monthlyCostLimit) {
return { available: false, reason: 'Monthly spend limit reached' };
}
return { available: true };
}
async resetMonthlyQuotas(): Promise<number> {
const result = await this.quotaRepository.update(
{},
{
currentRequests: 0,
currentTokens: 0,
currentCost: 0,
}
);
return result.affected ?? 0;
}
}

View File

@ -0,0 +1,11 @@
export { AIService, ConversationFilters } from './ai.service';
export {
RoleBasedAIService,
ChatContext,
ChatMessage,
ChatResponse,
ToolCall,
ToolResult,
ToolDefinition,
TenantConfigProvider,
} from './role-based-ai.service';

View File

@ -0,0 +1,455 @@
/**
* Role-Based AI Service
*
* Servicio de IA con control de acceso basado en roles.
* Extiende la funcionalidad del AIService con validación de permisos.
*
* Basado en: michangarrito MCH-012/MCH-013
*/
import { Repository, DataSource } from 'typeorm';
import {
AIModel,
AIConversation,
AIMessage,
AIPrompt,
AIUsageLog,
AITenantQuota,
} from '../entities';
import { AIService } from './ai.service';
import {
ERPRole,
ERP_ROLES,
getERPRole,
hasToolAccess,
getToolsForRole,
getRoleConfig,
} from '../roles/erp-roles.config';
import { generateSystemPrompt, PromptVariables } from '../prompts';
export interface ChatContext {
tenantId: string;
userId: string;
userRole: string; // Rol de BD
branchId?: string;
branchName?: string;
conversationId?: string;
metadata?: Record<string, any>;
}
export interface ChatMessage {
role: 'user' | 'assistant' | 'system';
content: string;
toolCalls?: ToolCall[];
toolResults?: ToolResult[];
}
export interface ToolCall {
id: string;
name: string;
arguments: Record<string, any>;
}
export interface ToolResult {
toolCallId: string;
result: any;
error?: string;
}
export interface ChatResponse {
message: string;
conversationId: string;
toolsUsed?: string[];
tokensUsed: {
input: number;
output: number;
total: number;
};
model: string;
}
export interface ToolDefinition {
name: string;
description: string;
inputSchema: Record<string, any>;
handler?: (args: any, context: ChatContext) => Promise<any>;
}
/**
* Servicio de IA con Role-Based Access Control
*/
export class RoleBasedAIService extends AIService {
private conversationHistory: Map<string, ChatMessage[]> = new Map();
private toolRegistry: Map<string, ToolDefinition> = new Map();
constructor(
modelRepository: Repository<AIModel>,
conversationRepository: Repository<AIConversation>,
messageRepository: Repository<AIMessage>,
promptRepository: Repository<AIPrompt>,
usageLogRepository: Repository<AIUsageLog>,
quotaRepository: Repository<AITenantQuota>,
private tenantConfigProvider?: TenantConfigProvider
) {
super(
modelRepository,
conversationRepository,
messageRepository,
promptRepository,
usageLogRepository,
quotaRepository
);
}
/**
* Registrar un tool disponible
*/
registerTool(tool: ToolDefinition): void {
this.toolRegistry.set(tool.name, tool);
}
/**
* Registrar múltiples tools
*/
registerTools(tools: ToolDefinition[]): void {
for (const tool of tools) {
this.registerTool(tool);
}
}
/**
* Obtener tools permitidos para un rol
*/
getToolsForRole(role: ERPRole): ToolDefinition[] {
const allowedToolNames = getToolsForRole(role);
const tools: ToolDefinition[] = [];
for (const toolName of allowedToolNames) {
const tool = this.toolRegistry.get(toolName);
if (tool) {
tools.push(tool);
}
}
return tools;
}
/**
* Verificar si el usuario puede usar un tool
*/
canUseTool(context: ChatContext, toolName: string): boolean {
const erpRole = getERPRole(context.userRole);
return hasToolAccess(erpRole, toolName);
}
/**
* Enviar mensaje de chat con role-based access
*/
async chat(
context: ChatContext,
message: string,
options?: {
modelCode?: string;
temperature?: number;
maxTokens?: number;
}
): Promise<ChatResponse> {
const erpRole = getERPRole(context.userRole);
const roleConfig = getRoleConfig(erpRole);
if (!roleConfig) {
throw new Error(`Invalid role: ${context.userRole}`);
}
// Verificar quota
const quotaCheck = await this.checkQuotaAvailable(context.tenantId);
if (!quotaCheck.available) {
throw new Error(quotaCheck.reason || 'Quota exceeded');
}
// Obtener o crear conversación
let conversation: AIConversation;
if (context.conversationId) {
const existing = await this.findConversation(context.conversationId);
if (existing) {
conversation = existing;
} else {
conversation = await this.createConversation(context.tenantId, context.userId, {
title: message.substring(0, 100),
metadata: {
role: erpRole,
branchId: context.branchId,
},
});
}
} else {
conversation = await this.createConversation(context.tenantId, context.userId, {
title: message.substring(0, 100),
metadata: {
role: erpRole,
branchId: context.branchId,
},
});
}
// Obtener historial de conversación
const history = await this.getConversationHistory(
conversation.id,
roleConfig.maxConversationHistory
);
// Generar system prompt
const systemPrompt = await this.generateSystemPromptForContext(context, erpRole);
// Obtener tools permitidos
const allowedTools = this.getToolsForRole(erpRole);
// Obtener modelo
const model = options?.modelCode
? await this.findModelByCode(options.modelCode)
: await this.getDefaultModel(context.tenantId);
if (!model) {
throw new Error('No AI model available');
}
// Construir mensajes para la API
const messages: ChatMessage[] = [
{ role: 'system', content: systemPrompt },
...history,
{ role: 'user', content: message },
];
// Guardar mensaje del usuario
await this.addMessage(conversation.id, {
role: 'user',
content: message,
});
// Llamar a la API de AI (OpenRouter)
const response = await this.callAIProvider(model, messages, allowedTools, options);
// Procesar tool calls si hay
let finalResponse = response.content;
const toolsUsed: string[] = [];
if (response.toolCalls && response.toolCalls.length > 0) {
for (const toolCall of response.toolCalls) {
// Validar que el tool esté permitido
if (!this.canUseTool(context, toolCall.name)) {
continue; // Ignorar tools no permitidos
}
toolsUsed.push(toolCall.name);
// Ejecutar tool
const tool = this.toolRegistry.get(toolCall.name);
if (tool?.handler) {
try {
const result = await tool.handler(toolCall.arguments, context);
// El resultado se incorpora a la respuesta
// En una implementación completa, se haría otra llamada a la API
} catch (error: any) {
console.error(`Tool ${toolCall.name} failed:`, error.message);
}
}
}
}
// Guardar respuesta del asistente
await this.addMessage(conversation.id, {
role: 'assistant',
content: finalResponse,
metadata: {
model: model.code,
toolsUsed,
tokensUsed: response.tokensUsed,
},
});
// Registrar uso
await this.logUsage(context.tenantId, {
modelId: model.id,
conversationId: conversation.id,
inputTokens: response.tokensUsed.input,
outputTokens: response.tokensUsed.output,
costUsd: this.calculateCost(model, response.tokensUsed),
usageType: 'chat',
});
// Incrementar quota
await this.incrementQuotaUsage(
context.tenantId,
1,
response.tokensUsed.total,
this.calculateCost(model, response.tokensUsed)
);
return {
message: finalResponse,
conversationId: conversation.id,
toolsUsed: toolsUsed.length > 0 ? toolsUsed : undefined,
tokensUsed: response.tokensUsed,
model: model.code,
};
}
/**
* Obtener historial de conversación formateado
*/
private async getConversationHistory(
conversationId: string,
maxMessages: number
): Promise<ChatMessage[]> {
const messages = await this.findMessages(conversationId);
// Tomar los últimos N mensajes
const recentMessages = messages.slice(-maxMessages);
return recentMessages.map((msg) => ({
role: msg.role as 'user' | 'assistant',
content: msg.content,
}));
}
/**
* Generar system prompt para el contexto
*/
private async generateSystemPromptForContext(
context: ChatContext,
role: ERPRole
): Promise<string> {
// Obtener configuración del tenant
const tenantConfig = this.tenantConfigProvider
? await this.tenantConfigProvider.getConfig(context.tenantId)
: null;
const variables: PromptVariables = {
businessName: tenantConfig?.businessName || 'ERP System',
currentDate: new Date().toLocaleDateString('es-MX'),
currentBranch: context.branchName,
maxDiscount: tenantConfig?.maxDiscount,
storeHours: tenantConfig?.storeHours,
};
return generateSystemPrompt(role, variables);
}
/**
* Obtener modelo por defecto para el tenant
*/
private async getDefaultModel(tenantId: string): Promise<AIModel | null> {
// Buscar configuración del tenant o usar default
const models = await this.findAllModels();
return models.find((m) => m.isDefault) || models[0] || null;
}
/**
* Llamar al proveedor de AI (OpenRouter)
*/
private async callAIProvider(
model: AIModel,
messages: ChatMessage[],
tools: ToolDefinition[],
options?: { temperature?: number; maxTokens?: number }
): Promise<{
content: string;
toolCalls?: ToolCall[];
tokensUsed: { input: number; output: number; total: number };
}> {
// Aquí iría la integración con OpenRouter
// Por ahora retornamos un placeholder
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error('OPENROUTER_API_KEY not configured');
}
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': process.env.APP_URL || 'https://erp.local',
},
body: JSON.stringify({
model: model.externalId || model.code,
messages: messages.map((m) => ({
role: m.role,
content: m.content,
})),
tools: tools.length > 0
? tools.map((t) => ({
type: 'function',
function: {
name: t.name,
description: t.description,
parameters: t.inputSchema,
},
}))
: undefined,
temperature: options?.temperature ?? 0.7,
max_tokens: options?.maxTokens ?? 2000,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error?.message || 'AI provider error');
}
const data = await response.json();
const choice = data.choices?.[0];
return {
content: choice?.message?.content || '',
toolCalls: choice?.message?.tool_calls?.map((tc: any) => ({
id: tc.id,
name: tc.function?.name,
arguments: JSON.parse(tc.function?.arguments || '{}'),
})),
tokensUsed: {
input: data.usage?.prompt_tokens || 0,
output: data.usage?.completion_tokens || 0,
total: data.usage?.total_tokens || 0,
},
};
}
/**
* Calcular costo de uso
*/
private calculateCost(
model: AIModel,
tokens: { input: number; output: number }
): number {
const inputCost = (tokens.input / 1000000) * (model.inputCostPer1m || 0);
const outputCost = (tokens.output / 1000000) * (model.outputCostPer1m || 0);
return inputCost + outputCost;
}
/**
* Limpiar conversación antigua (para liberar memoria)
*/
cleanupOldConversations(maxAgeMinutes: number = 60): void {
const now = Date.now();
const maxAge = maxAgeMinutes * 60 * 1000;
// En una implementación real, esto estaría en Redis o similar
// Por ahora limpiamos el Map en memoria
for (const [key, _] of this.conversationHistory) {
// Implementar lógica de limpieza basada en timestamp
}
}
}
/**
* Interface para proveedor de configuración de tenant
*/
export interface TenantConfigProvider {
getConfig(tenantId: string): Promise<{
businessName: string;
maxDiscount?: number;
storeHours?: string;
defaultModel?: string;
} | null>;
}

View File

@ -0,0 +1,70 @@
import { Router } from 'express';
import { DataSource } from 'typeorm';
import { AuditService } from './services';
import { AuditController } from './controllers';
import {
AuditLog,
EntityChange,
LoginHistory,
SensitiveDataAccess,
DataExport,
PermissionChange,
ConfigChange,
} from './entities';
export interface AuditModuleOptions {
dataSource: DataSource;
basePath?: string;
}
export class AuditModule {
public router: Router;
public auditService: AuditService;
private dataSource: DataSource;
private basePath: string;
constructor(options: AuditModuleOptions) {
this.dataSource = options.dataSource;
this.basePath = options.basePath || '';
this.router = Router();
this.initializeServices();
this.initializeRoutes();
}
private initializeServices(): void {
const auditLogRepository = this.dataSource.getRepository(AuditLog);
const entityChangeRepository = this.dataSource.getRepository(EntityChange);
const loginHistoryRepository = this.dataSource.getRepository(LoginHistory);
const sensitiveDataAccessRepository = this.dataSource.getRepository(SensitiveDataAccess);
const dataExportRepository = this.dataSource.getRepository(DataExport);
const permissionChangeRepository = this.dataSource.getRepository(PermissionChange);
const configChangeRepository = this.dataSource.getRepository(ConfigChange);
this.auditService = new AuditService(
auditLogRepository,
entityChangeRepository,
loginHistoryRepository,
sensitiveDataAccessRepository,
dataExportRepository,
permissionChangeRepository,
configChangeRepository
);
}
private initializeRoutes(): void {
const auditController = new AuditController(this.auditService);
this.router.use(`${this.basePath}/audit`, auditController.router);
}
static getEntities(): Function[] {
return [
AuditLog,
EntityChange,
LoginHistory,
SensitiveDataAccess,
DataExport,
PermissionChange,
ConfigChange,
];
}
}

View File

@ -0,0 +1,335 @@
import { Request, Response, NextFunction, Router } from 'express';
import { AuditService, AuditLogFilters } from '../services/audit.service';
export class AuditController {
public router: Router;
constructor(private readonly auditService: AuditService) {
this.router = Router();
this.initializeRoutes();
}
private initializeRoutes(): void {
// Audit Logs
this.router.get('/logs', this.findAuditLogs.bind(this));
this.router.get('/logs/entity/:entityType/:entityId', this.findAuditLogsByEntity.bind(this));
this.router.post('/logs', this.createAuditLog.bind(this));
// Entity Changes
this.router.get('/changes/:entityType/:entityId', this.findEntityChanges.bind(this));
this.router.get('/changes/:entityType/:entityId/version/:version', this.getEntityVersion.bind(this));
this.router.post('/changes', this.createEntityChange.bind(this));
// Login History
this.router.get('/logins/user/:userId', this.findLoginHistory.bind(this));
this.router.get('/logins/user/:userId/active-sessions', this.getActiveSessionsCount.bind(this));
this.router.post('/logins', this.createLoginHistory.bind(this));
this.router.post('/logins/:sessionId/logout', this.markSessionLogout.bind(this));
// Sensitive Data Access
this.router.get('/sensitive-access', this.findSensitiveDataAccess.bind(this));
this.router.post('/sensitive-access', this.logSensitiveDataAccess.bind(this));
// Data Exports
this.router.get('/exports', this.findUserDataExports.bind(this));
this.router.get('/exports/:id', this.findDataExport.bind(this));
this.router.post('/exports', this.createDataExport.bind(this));
this.router.patch('/exports/:id/status', this.updateDataExportStatus.bind(this));
// Permission Changes
this.router.get('/permission-changes', this.findPermissionChanges.bind(this));
this.router.post('/permission-changes', this.logPermissionChange.bind(this));
// Config Changes
this.router.get('/config-changes', this.findConfigChanges.bind(this));
this.router.post('/config-changes', this.logConfigChange.bind(this));
}
// ============================================
// AUDIT LOGS
// ============================================
private async findAuditLogs(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const filters: AuditLogFilters = {
userId: req.query.userId as string,
entityType: req.query.entityType as string,
action: req.query.action as string,
category: req.query.category as string,
ipAddress: req.query.ipAddress as string,
};
if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string);
if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string);
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 50;
const result = await this.auditService.findAuditLogs(tenantId, filters, { page, limit });
res.json({ data: result.data, total: result.total, page, limit });
} catch (error) {
next(error);
}
}
private async findAuditLogsByEntity(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { entityType, entityId } = req.params;
const logs = await this.auditService.findAuditLogsByEntity(tenantId, entityType, entityId);
res.json({ data: logs, total: logs.length });
} catch (error) {
next(error);
}
}
private async createAuditLog(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const log = await this.auditService.createAuditLog(tenantId, req.body);
res.status(201).json({ data: log });
} catch (error) {
next(error);
}
}
// ============================================
// ENTITY CHANGES
// ============================================
private async findEntityChanges(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { entityType, entityId } = req.params;
const changes = await this.auditService.findEntityChanges(tenantId, entityType, entityId);
res.json({ data: changes, total: changes.length });
} catch (error) {
next(error);
}
}
private async getEntityVersion(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { entityType, entityId, version } = req.params;
const change = await this.auditService.getEntityVersion(
tenantId,
entityType,
entityId,
parseInt(version)
);
if (!change) {
res.status(404).json({ error: 'Version not found' });
return;
}
res.json({ data: change });
} catch (error) {
next(error);
}
}
private async createEntityChange(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const change = await this.auditService.createEntityChange(tenantId, req.body);
res.status(201).json({ data: change });
} catch (error) {
next(error);
}
}
// ============================================
// LOGIN HISTORY
// ============================================
private async findLoginHistory(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { userId } = req.params;
const limit = parseInt(req.query.limit as string) || 20;
const history = await this.auditService.findLoginHistory(userId, tenantId, limit);
res.json({ data: history, total: history.length });
} catch (error) {
next(error);
}
}
private async getActiveSessionsCount(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { userId } = req.params;
const count = await this.auditService.getActiveSessionsCount(userId);
res.json({ data: { activeSessions: count } });
} catch (error) {
next(error);
}
}
private async createLoginHistory(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const login = await this.auditService.createLoginHistory(req.body);
res.status(201).json({ data: login });
} catch (error) {
next(error);
}
}
private async markSessionLogout(_req: Request, res: Response, _next: NextFunction): Promise<void> {
// Note: Session logout tracking requires a separate Session entity
// LoginHistory only tracks login attempts, not active sessions
res.status(501).json({
error: 'Session logout tracking not implemented',
message: 'Use the Auth module session endpoints for logout tracking',
});
}
// ============================================
// SENSITIVE DATA ACCESS
// ============================================
private async findSensitiveDataAccess(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const filters: { userId?: string; dataType?: string; startDate?: Date; endDate?: Date } = {
userId: req.query.userId as string,
dataType: req.query.dataType as string,
};
if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string);
if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string);
const access = await this.auditService.findSensitiveDataAccess(tenantId, filters);
res.json({ data: access, total: access.length });
} catch (error) {
next(error);
}
}
private async logSensitiveDataAccess(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const access = await this.auditService.logSensitiveDataAccess(tenantId, req.body);
res.status(201).json({ data: access });
} catch (error) {
next(error);
}
}
// ============================================
// DATA EXPORTS
// ============================================
private async findUserDataExports(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const exports = await this.auditService.findUserDataExports(tenantId, userId);
res.json({ data: exports, total: exports.length });
} catch (error) {
next(error);
}
}
private async findDataExport(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const exportRecord = await this.auditService.findDataExport(id);
if (!exportRecord) {
res.status(404).json({ error: 'Export not found' });
return;
}
res.json({ data: exportRecord });
} catch (error) {
next(error);
}
}
private async createDataExport(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const exportRecord = await this.auditService.createDataExport(tenantId, req.body);
res.status(201).json({ data: exportRecord });
} catch (error) {
next(error);
}
}
private async updateDataExportStatus(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const { status, ...updates } = req.body;
const exportRecord = await this.auditService.updateDataExportStatus(id, status, updates);
if (!exportRecord) {
res.status(404).json({ error: 'Export not found' });
return;
}
res.json({ data: exportRecord });
} catch (error) {
next(error);
}
}
// ============================================
// PERMISSION CHANGES
// ============================================
private async findPermissionChanges(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const targetUserId = req.query.targetUserId as string;
const changes = await this.auditService.findPermissionChanges(tenantId, targetUserId);
res.json({ data: changes, total: changes.length });
} catch (error) {
next(error);
}
}
private async logPermissionChange(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const change = await this.auditService.logPermissionChange(tenantId, req.body);
res.status(201).json({ data: change });
} catch (error) {
next(error);
}
}
// ============================================
// CONFIG CHANGES
// ============================================
private async findConfigChanges(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const configType = req.query.configType as string;
const changes = await this.auditService.findConfigChanges(tenantId, configType);
res.json({ data: changes, total: changes.length });
} catch (error) {
next(error);
}
}
private async logConfigChange(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const change = await this.auditService.logConfigChange(tenantId, req.body);
res.status(201).json({ data: change });
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1 @@
export { AuditController } from './audit.controller';

View File

@ -0,0 +1,346 @@
import {
IsString,
IsOptional,
IsBoolean,
IsNumber,
IsArray,
IsObject,
IsUUID,
IsEnum,
IsIP,
MaxLength,
MinLength,
} from 'class-validator';
// ============================================
// AUDIT LOG DTOs
// ============================================
export class CreateAuditLogDto {
@IsOptional()
@IsUUID()
userId?: string;
@IsString()
@MaxLength(20)
action: string;
@IsOptional()
@IsString()
@MaxLength(30)
category?: string;
@IsOptional()
@IsString()
@MaxLength(100)
entityType?: string;
@IsOptional()
@IsUUID()
entityId?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsObject()
oldValues?: Record<string, any>;
@IsOptional()
@IsObject()
newValues?: Record<string, any>;
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
@IsOptional()
@IsString()
@MaxLength(45)
ipAddress?: string;
@IsOptional()
@IsString()
@MaxLength(500)
userAgent?: string;
@IsOptional()
@IsString()
@MaxLength(100)
requestId?: string;
}
// ============================================
// ENTITY CHANGE DTOs
// ============================================
export class CreateEntityChangeDto {
@IsString()
@MaxLength(100)
entityType: string;
@IsUUID()
entityId: string;
@IsString()
@MaxLength(20)
changeType: string;
@IsOptional()
@IsUUID()
changedBy?: string;
@IsNumber()
version: number;
@IsOptional()
@IsArray()
@IsString({ each: true })
changedFields?: string[];
@IsOptional()
@IsObject()
previousData?: Record<string, any>;
@IsOptional()
@IsObject()
newData?: Record<string, any>;
@IsOptional()
@IsString()
changeReason?: string;
}
// ============================================
// LOGIN HISTORY DTOs
// ============================================
export class CreateLoginHistoryDto {
@IsUUID()
userId: string;
@IsOptional()
@IsUUID()
tenantId?: string;
@IsString()
@MaxLength(20)
status: string;
@IsOptional()
@IsString()
@MaxLength(30)
authMethod?: string;
@IsOptional()
@IsString()
@MaxLength(30)
mfaMethod?: string;
@IsOptional()
@IsBoolean()
mfaUsed?: boolean;
@IsOptional()
@IsString()
@MaxLength(45)
ipAddress?: string;
@IsOptional()
@IsString()
@MaxLength(500)
userAgent?: string;
@IsOptional()
@IsString()
@MaxLength(100)
deviceFingerprint?: string;
@IsOptional()
@IsString()
@MaxLength(100)
location?: string;
@IsOptional()
@IsString()
@MaxLength(100)
sessionId?: string;
@IsOptional()
@IsString()
failureReason?: string;
}
// ============================================
// SENSITIVE DATA ACCESS DTOs
// ============================================
export class CreateSensitiveDataAccessDto {
@IsUUID()
userId: string;
@IsString()
@MaxLength(50)
dataType: string;
@IsString()
@MaxLength(20)
accessType: string;
@IsOptional()
@IsString()
@MaxLength(100)
entityType?: string;
@IsOptional()
@IsUUID()
entityId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
fieldsAccessed?: string[];
@IsOptional()
@IsString()
accessReason?: string;
@IsOptional()
@IsBoolean()
wasExported?: boolean;
@IsOptional()
@IsString()
@MaxLength(45)
ipAddress?: string;
}
// ============================================
// DATA EXPORT DTOs
// ============================================
export class CreateDataExportDto {
@IsString()
@MaxLength(30)
exportType: string;
@IsString()
@MaxLength(20)
format: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
entities?: string[];
@IsOptional()
@IsObject()
filters?: Record<string, any>;
@IsOptional()
@IsArray()
@IsString({ each: true })
fields?: string[];
@IsOptional()
@IsString()
exportReason?: string;
}
export class UpdateDataExportStatusDto {
@IsString()
@MaxLength(20)
status: string;
@IsOptional()
@IsString()
filePath?: string;
@IsOptional()
@IsNumber()
fileSize?: number;
@IsOptional()
@IsNumber()
recordCount?: number;
@IsOptional()
@IsString()
errorMessage?: string;
}
// ============================================
// PERMISSION CHANGE DTOs
// ============================================
export class CreatePermissionChangeDto {
@IsUUID()
targetUserId: string;
@IsUUID()
changedBy: string;
@IsString()
@MaxLength(20)
changeType: string;
@IsString()
@MaxLength(30)
scope: string;
@IsOptional()
@IsString()
@MaxLength(100)
resourceType?: string;
@IsOptional()
@IsUUID()
resourceId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
previousPermissions?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
newPermissions?: string[];
@IsOptional()
@IsString()
changeReason?: string;
}
// ============================================
// CONFIG CHANGE DTOs
// ============================================
export class CreateConfigChangeDto {
@IsString()
@MaxLength(30)
configType: string;
@IsString()
@MaxLength(200)
configKey: string;
@IsUUID()
changedBy: string;
@IsNumber()
version: number;
@IsOptional()
@IsObject()
previousValue?: Record<string, any>;
@IsOptional()
@IsObject()
newValue?: Record<string, any>;
@IsOptional()
@IsString()
changeReason?: string;
}

View File

@ -0,0 +1,10 @@
export {
CreateAuditLogDto,
CreateEntityChangeDto,
CreateLoginHistoryDto,
CreateSensitiveDataAccessDto,
CreateDataExportDto,
UpdateDataExportStatusDto,
CreatePermissionChangeDto,
CreateConfigChangeDto,
} from './audit.dto';

View File

@ -0,0 +1,108 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
export type AuditAction = 'create' | 'read' | 'update' | 'delete' | 'login' | 'logout' | 'export';
export type AuditCategory = 'data' | 'auth' | 'system' | 'config' | 'billing';
export type AuditStatus = 'success' | 'failure' | 'partial';
@Entity({ name: 'audit_logs', schema: 'audit' })
export class AuditLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string;
@Column({ name: 'user_email', type: 'varchar', length: 255, nullable: true })
userEmail: string;
@Column({ name: 'user_name', type: 'varchar', length: 200, nullable: true })
userName: string;
@Column({ name: 'session_id', type: 'uuid', nullable: true })
sessionId: string;
@Column({ name: 'impersonator_id', type: 'uuid', nullable: true })
impersonatorId: string;
@Index()
@Column({ name: 'action', type: 'varchar', length: 50 })
action: AuditAction;
@Index()
@Column({ name: 'action_category', type: 'varchar', length: 50, nullable: true })
actionCategory: AuditCategory;
@Index()
@Column({ name: 'resource_type', type: 'varchar', length: 100 })
resourceType: string;
@Column({ name: 'resource_id', type: 'uuid', nullable: true })
resourceId: string;
@Column({ name: 'resource_name', type: 'varchar', length: 255, nullable: true })
resourceName: string;
@Column({ name: 'old_values', type: 'jsonb', nullable: true })
oldValues: Record<string, any>;
@Column({ name: 'new_values', type: 'jsonb', nullable: true })
newValues: Record<string, any>;
@Column({ name: 'changed_fields', type: 'text', array: true, nullable: true })
changedFields: string[];
@Column({ name: 'ip_address', type: 'inet', nullable: true })
ipAddress: string;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string;
@Column({ name: 'device_info', type: 'jsonb', default: {} })
deviceInfo: Record<string, any>;
@Column({ name: 'location', type: 'jsonb', default: {} })
location: Record<string, any>;
@Column({ name: 'request_id', type: 'varchar', length: 100, nullable: true })
requestId: string;
@Column({ name: 'request_method', type: 'varchar', length: 10, nullable: true })
requestMethod: string;
@Column({ name: 'request_path', type: 'text', nullable: true })
requestPath: string;
@Column({ name: 'request_params', type: 'jsonb', default: {} })
requestParams: Record<string, any>;
@Index()
@Column({ name: 'status', type: 'varchar', length: 20, default: 'success' })
status: AuditStatus;
@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage: string;
@Column({ name: 'duration_ms', type: 'int', nullable: true })
durationMs: number;
@Column({ name: 'metadata', type: 'jsonb', default: {} })
metadata: Record<string, any>;
@Column({ name: 'tags', type: 'text', array: true, default: [] })
tags: string[];
@Index()
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}

View File

@ -0,0 +1,47 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
} from 'typeorm';
export type ConfigType = 'tenant_settings' | 'user_settings' | 'system_settings' | 'feature_flags';
@Entity({ name: 'config_changes', schema: 'audit' })
export class ConfigChange {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
tenantId: string;
@Column({ name: 'changed_by', type: 'uuid' })
changedBy: string;
@Index()
@Column({ name: 'config_type', type: 'varchar', length: 50 })
configType: ConfigType;
@Column({ name: 'config_key', type: 'varchar', length: 100 })
configKey: string;
@Column({ name: 'config_path', type: 'text', nullable: true })
configPath: string;
@Column({ name: 'old_value', type: 'jsonb', nullable: true })
oldValue: Record<string, any>;
@Column({ name: 'new_value', type: 'jsonb', nullable: true })
newValue: Record<string, any>;
@Column({ name: 'reason', type: 'text', nullable: true })
reason: string;
@Column({ name: 'ticket_id', type: 'varchar', length: 50, nullable: true })
ticketId: string;
@Index()
@Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
changedAt: Date;
}

View File

@ -0,0 +1,80 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
} from 'typeorm';
export type ExportType = 'report' | 'backup' | 'gdpr_request' | 'bulk_export';
export type ExportFormat = 'csv' | 'xlsx' | 'pdf' | 'json';
export type ExportStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'expired';
@Entity({ name: 'data_exports', schema: 'audit' })
export class DataExport {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ name: 'export_type', type: 'varchar', length: 50 })
exportType: ExportType;
@Column({ name: 'export_format', type: 'varchar', length: 20, nullable: true })
exportFormat: ExportFormat;
@Column({ name: 'entity_types', type: 'text', array: true })
entityTypes: string[];
@Column({ name: 'filters', type: 'jsonb', default: {} })
filters: Record<string, any>;
@Column({ name: 'date_range_start', type: 'timestamptz', nullable: true })
dateRangeStart: Date;
@Column({ name: 'date_range_end', type: 'timestamptz', nullable: true })
dateRangeEnd: Date;
@Column({ name: 'record_count', type: 'int', nullable: true })
recordCount: number;
@Column({ name: 'file_size_bytes', type: 'bigint', nullable: true })
fileSizeBytes: number;
@Column({ name: 'file_hash', type: 'varchar', length: 64, nullable: true })
fileHash: string;
@Index()
@Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' })
status: ExportStatus;
@Column({ name: 'download_url', type: 'text', nullable: true })
downloadUrl: string;
@Column({ name: 'download_expires_at', type: 'timestamptz', nullable: true })
downloadExpiresAt: Date;
@Column({ name: 'download_count', type: 'int', default: 0 })
downloadCount: number;
@Column({ name: 'ip_address', type: 'inet', nullable: true })
ipAddress: string;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string;
@Index()
@Column({ name: 'requested_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
requestedAt: Date;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt: Date;
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt: Date;
}

View File

@ -0,0 +1,55 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
} from 'typeorm';
export type ChangeType = 'create' | 'update' | 'delete' | 'restore';
@Entity({ name: 'entity_changes', schema: 'audit' })
export class EntityChange {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'entity_type', type: 'varchar', length: 100 })
entityType: string;
@Index()
@Column({ name: 'entity_id', type: 'uuid' })
entityId: string;
@Column({ name: 'entity_name', type: 'varchar', length: 255, nullable: true })
entityName: string;
@Column({ name: 'version', type: 'int', default: 1 })
version: number;
@Column({ name: 'previous_version', type: 'int', nullable: true })
previousVersion: number;
@Column({ name: 'data_snapshot', type: 'jsonb' })
dataSnapshot: Record<string, any>;
@Column({ name: 'changes', type: 'jsonb', default: [] })
changes: Record<string, any>[];
@Index()
@Column({ name: 'changed_by', type: 'uuid', nullable: true })
changedBy: string;
@Column({ name: 'change_reason', type: 'text', nullable: true })
changeReason: string;
@Column({ name: 'change_type', type: 'varchar', length: 20 })
changeType: ChangeType;
@Index()
@Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
changedAt: Date;
}

View File

@ -0,0 +1,7 @@
export { AuditLog, AuditAction, AuditCategory, AuditStatus } from './audit-log.entity';
export { EntityChange, ChangeType } from './entity-change.entity';
export { LoginHistory, LoginStatus, AuthMethod, MfaMethod } from './login-history.entity';
export { SensitiveDataAccess, DataType, AccessType } from './sensitive-data-access.entity';
export { DataExport, ExportType, ExportFormat, ExportStatus } from './data-export.entity';
export { PermissionChange, PermissionChangeType, PermissionScope } from './permission-change.entity';
export { ConfigChange, ConfigType } from './config-change.entity';

View File

@ -0,0 +1,106 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
} from 'typeorm';
export type LoginStatus = 'success' | 'failed' | 'blocked' | 'mfa_required' | 'mfa_failed';
export type AuthMethod = 'password' | 'sso' | 'oauth' | 'mfa' | 'magic_link' | 'biometric';
export type MfaMethod = 'totp' | 'sms' | 'email' | 'push';
@Entity({ name: 'login_history', schema: 'audit' })
export class LoginHistory {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
tenantId: string;
@Index()
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string;
@Column({ name: 'email', type: 'varchar', length: 255, nullable: true })
email: string;
@Column({ name: 'username', type: 'varchar', length: 100, nullable: true })
username: string;
@Index()
@Column({ name: 'status', type: 'varchar', length: 20 })
status: LoginStatus;
@Column({ name: 'auth_method', type: 'varchar', length: 30, nullable: true })
authMethod: AuthMethod;
@Column({ name: 'oauth_provider', type: 'varchar', length: 30, nullable: true })
oauthProvider: string;
@Column({ name: 'mfa_method', type: 'varchar', length: 20, nullable: true })
mfaMethod: MfaMethod;
@Column({ name: 'mfa_verified', type: 'boolean', nullable: true })
mfaVerified: boolean;
@Column({ name: 'device_id', type: 'uuid', nullable: true })
deviceId: string;
@Column({ name: 'device_fingerprint', type: 'varchar', length: 255, nullable: true })
deviceFingerprint: string;
@Column({ name: 'device_type', type: 'varchar', length: 30, nullable: true })
deviceType: string;
@Column({ name: 'device_os', type: 'varchar', length: 50, nullable: true })
deviceOs: string;
@Column({ name: 'device_browser', type: 'varchar', length: 50, nullable: true })
deviceBrowser: string;
@Index()
@Column({ name: 'ip_address', type: 'inet', nullable: true })
ipAddress: string;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string;
@Column({ name: 'country_code', type: 'varchar', length: 2, nullable: true })
countryCode: string;
@Column({ name: 'city', type: 'varchar', length: 100, nullable: true })
city: string;
@Column({ name: 'latitude', type: 'decimal', precision: 10, scale: 8, nullable: true })
latitude: number;
@Column({ name: 'longitude', type: 'decimal', precision: 11, scale: 8, nullable: true })
longitude: number;
@Column({ name: 'risk_score', type: 'int', nullable: true })
riskScore: number;
@Column({ name: 'risk_factors', type: 'jsonb', default: [] })
riskFactors: string[];
@Index()
@Column({ name: 'is_suspicious', type: 'boolean', default: false })
isSuspicious: boolean;
@Column({ name: 'is_new_device', type: 'boolean', default: false })
isNewDevice: boolean;
@Column({ name: 'is_new_location', type: 'boolean', default: false })
isNewLocation: boolean;
@Column({ name: 'failure_reason', type: 'varchar', length: 100, nullable: true })
failureReason: string;
@Column({ name: 'failure_count', type: 'int', nullable: true })
failureCount: number;
@Index()
@Column({ name: 'attempted_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
attemptedAt: Date;
}

View File

@ -0,0 +1,63 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
} from 'typeorm';
export type PermissionChangeType = 'role_assigned' | 'role_revoked' | 'permission_granted' | 'permission_revoked';
export type PermissionScope = 'global' | 'tenant' | 'branch';
@Entity({ name: 'permission_changes', schema: 'audit' })
export class PermissionChange {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'changed_by', type: 'uuid' })
changedBy: string;
@Index()
@Column({ name: 'target_user_id', type: 'uuid' })
targetUserId: string;
@Column({ name: 'target_user_email', type: 'varchar', length: 255, nullable: true })
targetUserEmail: string;
@Column({ name: 'change_type', type: 'varchar', length: 30 })
changeType: PermissionChangeType;
@Column({ name: 'role_id', type: 'uuid', nullable: true })
roleId: string;
@Column({ name: 'role_code', type: 'varchar', length: 50, nullable: true })
roleCode: string;
@Column({ name: 'permission_id', type: 'uuid', nullable: true })
permissionId: string;
@Column({ name: 'permission_code', type: 'varchar', length: 100, nullable: true })
permissionCode: string;
@Column({ name: 'branch_id', type: 'uuid', nullable: true })
branchId: string;
@Column({ name: 'scope', type: 'varchar', length: 30, nullable: true })
scope: PermissionScope;
@Column({ name: 'previous_roles', type: 'text', array: true, nullable: true })
previousRoles: string[];
@Column({ name: 'previous_permissions', type: 'text', array: true, nullable: true })
previousPermissions: string[];
@Column({ name: 'reason', type: 'text', nullable: true })
reason: string;
@Index()
@Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
changedAt: Date;
}

View File

@ -0,0 +1,62 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
} from 'typeorm';
export type DataType = 'pii' | 'financial' | 'medical' | 'credentials';
export type AccessType = 'view' | 'export' | 'modify' | 'decrypt';
@Entity({ name: 'sensitive_data_access', schema: 'audit' })
export class SensitiveDataAccess {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ name: 'session_id', type: 'uuid', nullable: true })
sessionId: string;
@Index()
@Column({ name: 'data_type', type: 'varchar', length: 100 })
dataType: DataType;
@Column({ name: 'data_category', type: 'varchar', length: 100, nullable: true })
dataCategory: string;
@Column({ name: 'entity_type', type: 'varchar', length: 100, nullable: true })
entityType: string;
@Column({ name: 'entity_id', type: 'uuid', nullable: true })
entityId: string;
@Column({ name: 'access_type', type: 'varchar', length: 30 })
accessType: AccessType;
@Column({ name: 'access_reason', type: 'text', nullable: true })
accessReason: string;
@Column({ name: 'ip_address', type: 'inet', nullable: true })
ipAddress: string;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string;
@Index()
@Column({ name: 'was_authorized', type: 'boolean', default: true })
wasAuthorized: boolean;
@Column({ name: 'denial_reason', type: 'text', nullable: true })
denialReason: string;
@Index()
@Column({ name: 'accessed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
accessedAt: Date;
}

View File

@ -0,0 +1,5 @@
export { AuditModule, AuditModuleOptions } from './audit.module';
export * from './entities';
export * from './services';
export * from './controllers';
export * from './dto';

View File

@ -0,0 +1,303 @@
import { Repository, FindOptionsWhere, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import {
AuditLog,
EntityChange,
LoginHistory,
SensitiveDataAccess,
DataExport,
PermissionChange,
ConfigChange,
} from '../entities';
export interface AuditLogFilters {
userId?: string;
entityType?: string;
action?: string;
category?: string;
startDate?: Date;
endDate?: Date;
ipAddress?: string;
}
export interface PaginationOptions {
page?: number;
limit?: number;
}
export class AuditService {
constructor(
private readonly auditLogRepository: Repository<AuditLog>,
private readonly entityChangeRepository: Repository<EntityChange>,
private readonly loginHistoryRepository: Repository<LoginHistory>,
private readonly sensitiveDataAccessRepository: Repository<SensitiveDataAccess>,
private readonly dataExportRepository: Repository<DataExport>,
private readonly permissionChangeRepository: Repository<PermissionChange>,
private readonly configChangeRepository: Repository<ConfigChange>
) {}
// ============================================
// AUDIT LOGS
// ============================================
async createAuditLog(tenantId: string, data: Partial<AuditLog>): Promise<AuditLog> {
const log = this.auditLogRepository.create({
...data,
tenantId,
});
return this.auditLogRepository.save(log);
}
async findAuditLogs(
tenantId: string,
filters: AuditLogFilters = {},
pagination: PaginationOptions = {}
): Promise<{ data: AuditLog[]; total: number }> {
const { page = 1, limit = 50 } = pagination;
const where: FindOptionsWhere<AuditLog> = { tenantId };
if (filters.userId) where.userId = filters.userId;
if (filters.entityType) where.resourceType = filters.entityType;
if (filters.action) where.action = filters.action as any;
if (filters.category) where.actionCategory = filters.category as any;
if (filters.ipAddress) where.ipAddress = filters.ipAddress;
if (filters.startDate && filters.endDate) {
where.createdAt = Between(filters.startDate, filters.endDate);
} else if (filters.startDate) {
where.createdAt = MoreThanOrEqual(filters.startDate);
} else if (filters.endDate) {
where.createdAt = LessThanOrEqual(filters.endDate);
}
const [data, total] = await this.auditLogRepository.findAndCount({
where,
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
return { data, total };
}
async findAuditLogsByEntity(
tenantId: string,
entityType: string,
entityId: string
): Promise<AuditLog[]> {
return this.auditLogRepository.find({
where: { tenantId, resourceType: entityType, resourceId: entityId },
order: { createdAt: 'DESC' },
});
}
// ============================================
// ENTITY CHANGES
// ============================================
async createEntityChange(tenantId: string, data: Partial<EntityChange>): Promise<EntityChange> {
const change = this.entityChangeRepository.create({
...data,
tenantId,
});
return this.entityChangeRepository.save(change);
}
async findEntityChanges(
tenantId: string,
entityType: string,
entityId: string
): Promise<EntityChange[]> {
return this.entityChangeRepository.find({
where: { tenantId, entityType, entityId },
order: { changedAt: 'DESC' },
});
}
async getEntityVersion(
tenantId: string,
entityType: string,
entityId: string,
version: number
): Promise<EntityChange | null> {
return this.entityChangeRepository.findOne({
where: { tenantId, entityType, entityId, version },
});
}
// ============================================
// LOGIN HISTORY
// ============================================
async createLoginHistory(data: Partial<LoginHistory>): Promise<LoginHistory> {
const login = this.loginHistoryRepository.create(data);
return this.loginHistoryRepository.save(login);
}
async findLoginHistory(
userId: string,
tenantId?: string,
limit: number = 20
): Promise<LoginHistory[]> {
const where: FindOptionsWhere<LoginHistory> = { userId };
if (tenantId) where.tenantId = tenantId;
return this.loginHistoryRepository.find({
where,
order: { attemptedAt: 'DESC' },
take: limit,
});
}
async getActiveSessionsCount(userId: string): Promise<number> {
// Note: LoginHistory tracks login attempts, not sessions
// This counts successful login attempts (not truly active sessions)
return this.loginHistoryRepository.count({
where: { userId, status: 'success' },
});
}
// Note: Session logout tracking requires a separate Session entity
// LoginHistory only tracks login attempts
// ============================================
// SENSITIVE DATA ACCESS
// ============================================
async logSensitiveDataAccess(
tenantId: string,
data: Partial<SensitiveDataAccess>
): Promise<SensitiveDataAccess> {
const access = this.sensitiveDataAccessRepository.create({
...data,
tenantId,
});
return this.sensitiveDataAccessRepository.save(access);
}
async findSensitiveDataAccess(
tenantId: string,
filters: { userId?: string; dataType?: string; startDate?: Date; endDate?: Date } = {}
): Promise<SensitiveDataAccess[]> {
const where: FindOptionsWhere<SensitiveDataAccess> = { tenantId };
if (filters.userId) where.userId = filters.userId;
if (filters.dataType) where.dataType = filters.dataType as any;
if (filters.startDate && filters.endDate) {
where.accessedAt = Between(filters.startDate, filters.endDate);
}
return this.sensitiveDataAccessRepository.find({
where,
order: { accessedAt: 'DESC' },
take: 100,
});
}
// ============================================
// DATA EXPORTS
// ============================================
async createDataExport(tenantId: string, data: Partial<DataExport>): Promise<DataExport> {
const exportRecord = this.dataExportRepository.create({
...data,
tenantId,
status: 'pending',
});
return this.dataExportRepository.save(exportRecord);
}
async findDataExport(id: string): Promise<DataExport | null> {
return this.dataExportRepository.findOne({ where: { id } });
}
async findUserDataExports(tenantId: string, userId: string): Promise<DataExport[]> {
return this.dataExportRepository.find({
where: { tenantId, userId },
order: { requestedAt: 'DESC' },
});
}
async updateDataExportStatus(
id: string,
status: string,
updates: Partial<DataExport> = {}
): Promise<DataExport | null> {
const exportRecord = await this.findDataExport(id);
if (!exportRecord) return null;
exportRecord.status = status as any;
Object.assign(exportRecord, updates);
if (status === 'completed') {
exportRecord.completedAt = new Date();
}
return this.dataExportRepository.save(exportRecord);
}
// ============================================
// PERMISSION CHANGES
// ============================================
async logPermissionChange(
tenantId: string,
data: Partial<PermissionChange>
): Promise<PermissionChange> {
const change = this.permissionChangeRepository.create({
...data,
tenantId,
});
return this.permissionChangeRepository.save(change);
}
async findPermissionChanges(
tenantId: string,
targetUserId?: string
): Promise<PermissionChange[]> {
const where: FindOptionsWhere<PermissionChange> = { tenantId };
if (targetUserId) where.targetUserId = targetUserId;
return this.permissionChangeRepository.find({
where,
order: { changedAt: 'DESC' },
take: 100,
});
}
// ============================================
// CONFIG CHANGES
// ============================================
async logConfigChange(tenantId: string, data: Partial<ConfigChange>): Promise<ConfigChange> {
const change = this.configChangeRepository.create({
...data,
tenantId,
});
return this.configChangeRepository.save(change);
}
async findConfigChanges(tenantId: string, configType?: string): Promise<ConfigChange[]> {
const where: FindOptionsWhere<ConfigChange> = { tenantId };
if (configType) where.configType = configType as any;
return this.configChangeRepository.find({
where,
order: { changedAt: 'DESC' },
take: 100,
});
}
// Note: ConfigChange entity doesn't track versions
// Use changedAt timestamp to get specific config snapshots
async getConfigChangeByDate(
tenantId: string,
configKey: string,
date: Date
): Promise<ConfigChange | null> {
return this.configChangeRepository.findOne({
where: { tenantId, configKey },
order: { changedAt: 'DESC' },
});
}
}

View File

@ -0,0 +1 @@
export { AuditService, AuditLogFilters, PaginationOptions } from './audit.service';

View File

@ -0,0 +1,334 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { apiKeysService, CreateApiKeyDto, UpdateApiKeyDto, ApiKeyFilters } from './apiKeys.service.js';
import { AuthenticatedRequest, ValidationError, ApiResponse } from '../../shared/types/index.js';
// ============================================================================
// VALIDATION SCHEMAS
// ============================================================================
const createApiKeySchema = z.object({
name: z.string().min(1, 'Nombre requerido').max(255),
scope: z.string().max(100).optional(),
allowed_ips: z.array(z.string().ip()).optional(),
expiration_days: z.number().int().positive().max(365).optional(),
});
const updateApiKeySchema = z.object({
name: z.string().min(1).max(255).optional(),
scope: z.string().max(100).nullable().optional(),
allowed_ips: z.array(z.string().ip()).nullable().optional(),
expiration_date: z.string().datetime().nullable().optional(),
is_active: z.boolean().optional(),
});
const listApiKeysSchema = z.object({
user_id: z.string().uuid().optional(),
is_active: z.enum(['true', 'false']).optional(),
scope: z.string().optional(),
});
// ============================================================================
// CONTROLLER
// ============================================================================
class ApiKeysController {
/**
* Create a new API key
* POST /api/auth/api-keys
*/
async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const validation = createApiKeySchema.safeParse(req.body);
if (!validation.success) {
throw new ValidationError('Datos inválidos', validation.error.errors);
}
const dto: CreateApiKeyDto = {
...validation.data,
user_id: req.user!.userId,
tenant_id: req.user!.tenantId,
};
const result = await apiKeysService.create(dto);
const response: ApiResponse = {
success: true,
data: result,
message: 'API key creada exitosamente. Guarde la clave, no podrá verla de nuevo.',
};
res.status(201).json(response);
} catch (error) {
next(error);
}
}
/**
* List API keys for the current user
* GET /api/auth/api-keys
*/
async list(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const validation = listApiKeysSchema.safeParse(req.query);
if (!validation.success) {
throw new ValidationError('Parámetros inválidos', validation.error.errors);
}
const filters: ApiKeyFilters = {
tenant_id: req.user!.tenantId,
// By default, only show user's own keys unless admin
user_id: validation.data.user_id || req.user!.userId,
};
// Admins can view all keys in tenant
if (validation.data.user_id && req.user!.roles.includes('admin')) {
filters.user_id = validation.data.user_id;
}
if (validation.data.is_active !== undefined) {
filters.is_active = validation.data.is_active === 'true';
}
if (validation.data.scope) {
filters.scope = validation.data.scope;
}
const apiKeys = await apiKeysService.findAll(filters);
const response: ApiResponse = {
success: true,
data: apiKeys,
};
res.json(response);
} catch (error) {
next(error);
}
}
/**
* Get a specific API key
* GET /api/auth/api-keys/:id
*/
async getById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const apiKey = await apiKeysService.findById(id, req.user!.tenantId);
if (!apiKey) {
const response: ApiResponse = {
success: false,
error: 'API key no encontrada',
};
res.status(404).json(response);
return;
}
// Check ownership (unless admin)
if (apiKey.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) {
const response: ApiResponse = {
success: false,
error: 'No tiene permisos para ver esta API key',
};
res.status(403).json(response);
return;
}
const response: ApiResponse = {
success: true,
data: apiKey,
};
res.json(response);
} catch (error) {
next(error);
}
}
/**
* Update an API key
* PATCH /api/auth/api-keys/:id
*/
async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const validation = updateApiKeySchema.safeParse(req.body);
if (!validation.success) {
throw new ValidationError('Datos inválidos', validation.error.errors);
}
// Check ownership first
const existing = await apiKeysService.findById(id, req.user!.tenantId);
if (!existing) {
const response: ApiResponse = {
success: false,
error: 'API key no encontrada',
};
res.status(404).json(response);
return;
}
if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) {
const response: ApiResponse = {
success: false,
error: 'No tiene permisos para modificar esta API key',
};
res.status(403).json(response);
return;
}
const dto: UpdateApiKeyDto = {
name: validation.data.name,
scope: validation.data.scope ?? undefined,
allowed_ips: validation.data.allowed_ips ?? undefined,
is_active: validation.data.is_active,
expiration_date: validation.data.expiration_date
? new Date(validation.data.expiration_date)
: validation.data.expiration_date === null
? null
: undefined,
};
const updated = await apiKeysService.update(id, req.user!.tenantId, dto);
const response: ApiResponse = {
success: true,
data: updated,
message: 'API key actualizada',
};
res.json(response);
} catch (error) {
next(error);
}
}
/**
* Revoke an API key (soft delete)
* POST /api/auth/api-keys/:id/revoke
*/
async revoke(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
// Check ownership first
const existing = await apiKeysService.findById(id, req.user!.tenantId);
if (!existing) {
const response: ApiResponse = {
success: false,
error: 'API key no encontrada',
};
res.status(404).json(response);
return;
}
if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) {
const response: ApiResponse = {
success: false,
error: 'No tiene permisos para revocar esta API key',
};
res.status(403).json(response);
return;
}
await apiKeysService.revoke(id, req.user!.tenantId);
const response: ApiResponse = {
success: true,
message: 'API key revocada exitosamente',
};
res.json(response);
} catch (error) {
next(error);
}
}
/**
* Delete an API key permanently
* DELETE /api/auth/api-keys/:id
*/
async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
// Check ownership first
const existing = await apiKeysService.findById(id, req.user!.tenantId);
if (!existing) {
const response: ApiResponse = {
success: false,
error: 'API key no encontrada',
};
res.status(404).json(response);
return;
}
if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) {
const response: ApiResponse = {
success: false,
error: 'No tiene permisos para eliminar esta API key',
};
res.status(403).json(response);
return;
}
await apiKeysService.delete(id, req.user!.tenantId);
const response: ApiResponse = {
success: true,
message: 'API key eliminada permanentemente',
};
res.json(response);
} catch (error) {
next(error);
}
}
/**
* Regenerate an API key (invalidates old key, creates new)
* POST /api/auth/api-keys/:id/regenerate
*/
async regenerate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
// Check ownership first
const existing = await apiKeysService.findById(id, req.user!.tenantId);
if (!existing) {
const response: ApiResponse = {
success: false,
error: 'API key no encontrada',
};
res.status(404).json(response);
return;
}
if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) {
const response: ApiResponse = {
success: false,
error: 'No tiene permisos para regenerar esta API key',
};
res.status(403).json(response);
return;
}
const result = await apiKeysService.regenerate(id, req.user!.tenantId);
const response: ApiResponse = {
success: true,
data: result,
message: 'API key regenerada. Guarde la nueva clave, no podrá verla de nuevo.',
};
res.json(response);
} catch (error) {
next(error);
}
}
}
export const apiKeysController = new ApiKeysController();

View File

@ -0,0 +1,56 @@
import { Router } from 'express';
import { apiKeysController } from './apiKeys.controller.js';
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
const router = Router();
// All routes require authentication
router.use(authenticate);
// ============================================================================
// API KEY MANAGEMENT ROUTES
// ============================================================================
/**
* Create a new API key
* POST /api/auth/api-keys
*/
router.post('/', (req, res, next) => apiKeysController.create(req, res, next));
/**
* List API keys (user's own, or all for admins)
* GET /api/auth/api-keys
*/
router.get('/', (req, res, next) => apiKeysController.list(req, res, next));
/**
* Get a specific API key
* GET /api/auth/api-keys/:id
*/
router.get('/:id', (req, res, next) => apiKeysController.getById(req, res, next));
/**
* Update an API key
* PATCH /api/auth/api-keys/:id
*/
router.patch('/:id', (req, res, next) => apiKeysController.update(req, res, next));
/**
* Revoke an API key (soft delete)
* POST /api/auth/api-keys/:id/revoke
*/
router.post('/:id/revoke', (req, res, next) => apiKeysController.revoke(req, res, next));
/**
* Delete an API key permanently
* DELETE /api/auth/api-keys/:id
*/
router.delete('/:id', (req, res, next) => apiKeysController.delete(req, res, next));
/**
* Regenerate an API key
* POST /api/auth/api-keys/:id/regenerate
*/
router.post('/:id/regenerate', (req, res, next) => apiKeysController.regenerate(req, res, next));
export default router;

View File

@ -0,0 +1,491 @@
import crypto from 'crypto';
import { query, queryOne } from '../../config/database.js';
import { ValidationError, NotFoundError, UnauthorizedError } from '../../shared/types/index.js';
import { logger } from '../../shared/utils/logger.js';
// ============================================================================
// TYPES
// ============================================================================
export interface ApiKey {
id: string;
user_id: string;
tenant_id: string;
name: string;
key_index: string;
key_hash: string;
scope: string | null;
allowed_ips: string[] | null;
expiration_date: Date | null;
last_used_at: Date | null;
is_active: boolean;
created_at: Date;
updated_at: Date;
}
export interface CreateApiKeyDto {
user_id: string;
tenant_id: string;
name: string;
scope?: string;
allowed_ips?: string[];
expiration_days?: number;
}
export interface UpdateApiKeyDto {
name?: string;
scope?: string;
allowed_ips?: string[];
expiration_date?: Date | null;
is_active?: boolean;
}
export interface ApiKeyWithPlainKey {
apiKey: Omit<ApiKey, 'key_hash'>;
plainKey: string;
}
export interface ApiKeyValidationResult {
valid: boolean;
apiKey?: ApiKey;
user?: {
id: string;
tenant_id: string;
email: string;
roles: string[];
};
error?: string;
}
export interface ApiKeyFilters {
user_id?: string;
tenant_id?: string;
is_active?: boolean;
scope?: string;
}
// ============================================================================
// CONSTANTS
// ============================================================================
const API_KEY_PREFIX = 'mgn_';
const KEY_LENGTH = 32; // 32 bytes = 256 bits
const HASH_ITERATIONS = 100000;
const HASH_KEYLEN = 64;
const HASH_DIGEST = 'sha512';
// ============================================================================
// SERVICE
// ============================================================================
class ApiKeysService {
/**
* Generate a cryptographically secure API key
*/
private generatePlainKey(): string {
const randomBytes = crypto.randomBytes(KEY_LENGTH);
const key = randomBytes.toString('base64url');
return `${API_KEY_PREFIX}${key}`;
}
/**
* Extract the key index (first 16 chars after prefix) for lookup
*/
private getKeyIndex(plainKey: string): string {
const keyWithoutPrefix = plainKey.replace(API_KEY_PREFIX, '');
return keyWithoutPrefix.substring(0, 16);
}
/**
* Hash the API key using PBKDF2
*/
private async hashKey(plainKey: string): Promise<string> {
const salt = crypto.randomBytes(16).toString('hex');
return new Promise((resolve, reject) => {
crypto.pbkdf2(
plainKey,
salt,
HASH_ITERATIONS,
HASH_KEYLEN,
HASH_DIGEST,
(err, derivedKey) => {
if (err) reject(err);
resolve(`${salt}:${derivedKey.toString('hex')}`);
}
);
});
}
/**
* Verify a plain key against a stored hash
*/
private async verifyKey(plainKey: string, storedHash: string): Promise<boolean> {
const [salt, hash] = storedHash.split(':');
return new Promise((resolve, reject) => {
crypto.pbkdf2(
plainKey,
salt,
HASH_ITERATIONS,
HASH_KEYLEN,
HASH_DIGEST,
(err, derivedKey) => {
if (err) reject(err);
resolve(derivedKey.toString('hex') === hash);
}
);
});
}
/**
* Create a new API key
* Returns the plain key only once - it cannot be retrieved later
*/
async create(dto: CreateApiKeyDto): Promise<ApiKeyWithPlainKey> {
// Validate user exists
const user = await queryOne<{ id: string }>(
'SELECT id FROM auth.users WHERE id = $1 AND tenant_id = $2',
[dto.user_id, dto.tenant_id]
);
if (!user) {
throw new ValidationError('Usuario no encontrado');
}
// Check for duplicate name
const existing = await queryOne<{ id: string }>(
'SELECT id FROM auth.api_keys WHERE user_id = $1 AND name = $2',
[dto.user_id, dto.name]
);
if (existing) {
throw new ValidationError('Ya existe una API key con ese nombre');
}
// Generate key
const plainKey = this.generatePlainKey();
const keyIndex = this.getKeyIndex(plainKey);
const keyHash = await this.hashKey(plainKey);
// Calculate expiration date
let expirationDate: Date | null = null;
if (dto.expiration_days) {
expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + dto.expiration_days);
}
// Insert API key
const apiKey = await queryOne<ApiKey>(
`INSERT INTO auth.api_keys (
user_id, tenant_id, name, key_index, key_hash,
scope, allowed_ips, expiration_date, is_active
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true)
RETURNING id, user_id, tenant_id, name, key_index, scope,
allowed_ips, expiration_date, is_active, created_at, updated_at`,
[
dto.user_id,
dto.tenant_id,
dto.name,
keyIndex,
keyHash,
dto.scope || null,
dto.allowed_ips || null,
expirationDate,
]
);
if (!apiKey) {
throw new Error('Error al crear API key');
}
logger.info('API key created', {
apiKeyId: apiKey.id,
userId: dto.user_id,
name: dto.name
});
return {
apiKey,
plainKey, // Only returned once!
};
}
/**
* Find all API keys for a user/tenant
*/
async findAll(filters: ApiKeyFilters): Promise<Omit<ApiKey, 'key_hash'>[]> {
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (filters.user_id) {
conditions.push(`user_id = $${paramIndex++}`);
params.push(filters.user_id);
}
if (filters.tenant_id) {
conditions.push(`tenant_id = $${paramIndex++}`);
params.push(filters.tenant_id);
}
if (filters.is_active !== undefined) {
conditions.push(`is_active = $${paramIndex++}`);
params.push(filters.is_active);
}
if (filters.scope) {
conditions.push(`scope = $${paramIndex++}`);
params.push(filters.scope);
}
const whereClause = conditions.length > 0
? `WHERE ${conditions.join(' AND ')}`
: '';
const apiKeys = await query<ApiKey>(
`SELECT id, user_id, tenant_id, name, key_index, scope,
allowed_ips, expiration_date, last_used_at, is_active,
created_at, updated_at
FROM auth.api_keys
${whereClause}
ORDER BY created_at DESC`,
params
);
return apiKeys;
}
/**
* Find a specific API key by ID
*/
async findById(id: string, tenantId: string): Promise<Omit<ApiKey, 'key_hash'> | null> {
const apiKey = await queryOne<ApiKey>(
`SELECT id, user_id, tenant_id, name, key_index, scope,
allowed_ips, expiration_date, last_used_at, is_active,
created_at, updated_at
FROM auth.api_keys
WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
return apiKey;
}
/**
* Update an API key
*/
async update(id: string, tenantId: string, dto: UpdateApiKeyDto): Promise<Omit<ApiKey, 'key_hash'>> {
const existing = await this.findById(id, tenantId);
if (!existing) {
throw new NotFoundError('API key no encontrada');
}
const updates: string[] = ['updated_at = NOW()'];
const params: any[] = [];
let paramIndex = 1;
if (dto.name !== undefined) {
updates.push(`name = $${paramIndex++}`);
params.push(dto.name);
}
if (dto.scope !== undefined) {
updates.push(`scope = $${paramIndex++}`);
params.push(dto.scope);
}
if (dto.allowed_ips !== undefined) {
updates.push(`allowed_ips = $${paramIndex++}`);
params.push(dto.allowed_ips);
}
if (dto.expiration_date !== undefined) {
updates.push(`expiration_date = $${paramIndex++}`);
params.push(dto.expiration_date);
}
if (dto.is_active !== undefined) {
updates.push(`is_active = $${paramIndex++}`);
params.push(dto.is_active);
}
params.push(id);
params.push(tenantId);
const updated = await queryOne<ApiKey>(
`UPDATE auth.api_keys
SET ${updates.join(', ')}
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}
RETURNING id, user_id, tenant_id, name, key_index, scope,
allowed_ips, expiration_date, last_used_at, is_active,
created_at, updated_at`,
params
);
if (!updated) {
throw new Error('Error al actualizar API key');
}
logger.info('API key updated', { apiKeyId: id });
return updated;
}
/**
* Revoke (soft delete) an API key
*/
async revoke(id: string, tenantId: string): Promise<void> {
const result = await query(
`UPDATE auth.api_keys
SET is_active = false, updated_at = NOW()
WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
if (!result) {
throw new NotFoundError('API key no encontrada');
}
logger.info('API key revoked', { apiKeyId: id });
}
/**
* Delete an API key permanently
*/
async delete(id: string, tenantId: string): Promise<void> {
const result = await query(
'DELETE FROM auth.api_keys WHERE id = $1 AND tenant_id = $2',
[id, tenantId]
);
logger.info('API key deleted', { apiKeyId: id });
}
/**
* Validate an API key and return the associated user info
* This is the main method used by the authentication middleware
*/
async validate(plainKey: string, clientIp?: string): Promise<ApiKeyValidationResult> {
// Check prefix
if (!plainKey.startsWith(API_KEY_PREFIX)) {
return { valid: false, error: 'Formato de API key inválido' };
}
// Extract key index for lookup
const keyIndex = this.getKeyIndex(plainKey);
// Find API key by index
const apiKey = await queryOne<ApiKey>(
`SELECT * FROM auth.api_keys
WHERE key_index = $1 AND is_active = true`,
[keyIndex]
);
if (!apiKey) {
return { valid: false, error: 'API key no encontrada o inactiva' };
}
// Verify hash
const isValid = await this.verifyKey(plainKey, apiKey.key_hash);
if (!isValid) {
return { valid: false, error: 'API key inválida' };
}
// Check expiration
if (apiKey.expiration_date && new Date(apiKey.expiration_date) < new Date()) {
return { valid: false, error: 'API key expirada' };
}
// Check IP whitelist
if (apiKey.allowed_ips && apiKey.allowed_ips.length > 0 && clientIp) {
if (!apiKey.allowed_ips.includes(clientIp)) {
logger.warn('API key IP not allowed', {
apiKeyId: apiKey.id,
clientIp,
allowedIps: apiKey.allowed_ips
});
return { valid: false, error: 'IP no autorizada' };
}
}
// Get user info with roles
const user = await queryOne<{
id: string;
tenant_id: string;
email: string;
role_codes: string[];
}>(
`SELECT u.id, u.tenant_id, u.email, array_agg(r.code) as role_codes
FROM auth.users u
LEFT JOIN auth.user_roles ur ON u.id = ur.user_id
LEFT JOIN auth.roles r ON ur.role_id = r.id
WHERE u.id = $1 AND u.status = 'active'
GROUP BY u.id`,
[apiKey.user_id]
);
if (!user) {
return { valid: false, error: 'Usuario asociado no encontrado o inactivo' };
}
// Update last used timestamp (async, don't wait)
query(
'UPDATE auth.api_keys SET last_used_at = NOW() WHERE id = $1',
[apiKey.id]
).catch(err => logger.error('Error updating last_used_at', { error: err }));
return {
valid: true,
apiKey,
user: {
id: user.id,
tenant_id: user.tenant_id,
email: user.email,
roles: user.role_codes?.filter(Boolean) || [],
},
};
}
/**
* Regenerate an API key (creates new key, invalidates old)
*/
async regenerate(id: string, tenantId: string): Promise<ApiKeyWithPlainKey> {
const existing = await queryOne<ApiKey>(
'SELECT * FROM auth.api_keys WHERE id = $1 AND tenant_id = $2',
[id, tenantId]
);
if (!existing) {
throw new NotFoundError('API key no encontrada');
}
// Generate new key
const plainKey = this.generatePlainKey();
const keyIndex = this.getKeyIndex(plainKey);
const keyHash = await this.hashKey(plainKey);
// Update with new key
const updated = await queryOne<ApiKey>(
`UPDATE auth.api_keys
SET key_index = $1, key_hash = $2, updated_at = NOW()
WHERE id = $3 AND tenant_id = $4
RETURNING id, user_id, tenant_id, name, key_index, scope,
allowed_ips, expiration_date, is_active, created_at, updated_at`,
[keyIndex, keyHash, id, tenantId]
);
if (!updated) {
throw new Error('Error al regenerar API key');
}
logger.info('API key regenerated', { apiKeyId: id });
return {
apiKey: updated,
plainKey,
};
}
}
export const apiKeysService = new ApiKeysService();

View File

@ -0,0 +1,192 @@
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { authService } from './auth.service.js';
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
// Validation schemas
const loginSchema = z.object({
email: z.string().email('Email inválido'),
password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres'),
});
const registerSchema = z.object({
email: z.string().email('Email inválido'),
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
// Soporta ambos formatos: full_name (legacy) o firstName+lastName (frontend)
full_name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres').optional(),
firstName: z.string().min(2, 'Nombre debe tener al menos 2 caracteres').optional(),
lastName: z.string().min(2, 'Apellido debe tener al menos 2 caracteres').optional(),
tenant_id: z.string().uuid('Tenant ID inválido').optional(),
companyName: z.string().optional(),
}).refine(
(data) => data.full_name || (data.firstName && data.lastName),
{ message: 'Se requiere full_name o firstName y lastName', path: ['full_name'] }
);
const changePasswordSchema = z.object({
current_password: z.string().min(1, 'Contraseña actual requerida'),
new_password: z.string().min(8, 'La nueva contraseña debe tener al menos 8 caracteres'),
});
const refreshTokenSchema = z.object({
refresh_token: z.string().min(1, 'Refresh token requerido'),
});
export class AuthController {
async login(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const validation = loginSchema.safeParse(req.body);
if (!validation.success) {
throw new ValidationError('Datos inválidos', validation.error.errors);
}
// Extract request metadata for session tracking
const metadata = {
ipAddress: req.ip || req.socket.remoteAddress || 'unknown',
userAgent: req.get('User-Agent') || 'unknown',
};
const result = await authService.login({
...validation.data,
metadata,
});
const response: ApiResponse = {
success: true,
data: result,
message: 'Inicio de sesión exitoso',
};
res.json(response);
} catch (error) {
next(error);
}
}
async register(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const validation = registerSchema.safeParse(req.body);
if (!validation.success) {
throw new ValidationError('Datos inválidos', validation.error.errors);
}
const result = await authService.register(validation.data);
const response: ApiResponse = {
success: true,
data: result,
message: 'Usuario registrado exitosamente',
};
res.status(201).json(response);
} catch (error) {
next(error);
}
}
async refreshToken(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const validation = refreshTokenSchema.safeParse(req.body);
if (!validation.success) {
throw new ValidationError('Datos inválidos', validation.error.errors);
}
// Extract request metadata for session tracking
const metadata = {
ipAddress: req.ip || req.socket.remoteAddress || 'unknown',
userAgent: req.get('User-Agent') || 'unknown',
};
const tokens = await authService.refreshToken(validation.data.refresh_token, metadata);
const response: ApiResponse = {
success: true,
data: { tokens },
message: 'Token renovado exitosamente',
};
res.json(response);
} catch (error) {
next(error);
}
}
async changePassword(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const validation = changePasswordSchema.safeParse(req.body);
if (!validation.success) {
throw new ValidationError('Datos inválidos', validation.error.errors);
}
const userId = req.user!.userId;
await authService.changePassword(
userId,
validation.data.current_password,
validation.data.new_password
);
const response: ApiResponse = {
success: true,
message: 'Contraseña actualizada exitosamente',
};
res.json(response);
} catch (error) {
next(error);
}
}
async getProfile(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user!.userId;
const profile = await authService.getProfile(userId);
const response: ApiResponse = {
success: true,
data: profile,
};
res.json(response);
} catch (error) {
next(error);
}
}
async logout(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
// sessionId can come from body (sent by client after login)
const sessionId = req.body?.sessionId;
if (sessionId) {
await authService.logout(sessionId);
}
const response: ApiResponse = {
success: true,
message: 'Sesión cerrada exitosamente',
};
res.json(response);
} catch (error) {
next(error);
}
}
async logoutAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.user!.userId;
const sessionsRevoked = await authService.logoutAll(userId);
const response: ApiResponse = {
success: true,
data: { sessionsRevoked },
message: 'Todas las sesiones han sido cerradas',
};
res.json(response);
} catch (error) {
next(error);
}
}
}
export const authController = new AuthController();

View File

@ -0,0 +1,18 @@
import { Router } from 'express';
import { authController } from './auth.controller.js';
import { authenticate } from '../../shared/middleware/auth.middleware.js';
const router = Router();
// Public routes
router.post('/login', (req, res, next) => authController.login(req, res, next));
router.post('/register', (req, res, next) => authController.register(req, res, next));
router.post('/refresh', (req, res, next) => authController.refreshToken(req, res, next));
// Protected routes
router.get('/profile', authenticate, (req, res, next) => authController.getProfile(req, res, next));
router.post('/change-password', authenticate, (req, res, next) => authController.changePassword(req, res, next));
router.post('/logout', authenticate, (req, res, next) => authController.logout(req, res, next));
router.post('/logout-all', authenticate, (req, res, next) => authController.logoutAll(req, res, next));
export default router;

View File

@ -0,0 +1,234 @@
import bcrypt from 'bcryptjs';
import { Repository } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { User, UserStatus, Role } from './entities/index.js';
import { tokenService, TokenPair, RequestMetadata } from './services/token.service.js';
import { UnauthorizedError, ValidationError, NotFoundError } from '../../shared/types/index.js';
import { logger } from '../../shared/utils/logger.js';
export interface LoginDto {
email: string;
password: string;
metadata?: RequestMetadata; // IP and user agent for session tracking
}
export interface RegisterDto {
email: string;
password: string;
// Soporta ambos formatos para compatibilidad frontend/backend
full_name?: string;
firstName?: string;
lastName?: string;
tenant_id?: string;
companyName?: string;
}
/**
* Transforma full_name a firstName/lastName para respuesta al frontend
*/
export function splitFullName(fullName: string): { firstName: string; lastName: string } {
const parts = fullName.trim().split(/\s+/);
if (parts.length === 1) {
return { firstName: parts[0], lastName: '' };
}
const firstName = parts[0];
const lastName = parts.slice(1).join(' ');
return { firstName, lastName };
}
/**
* Transforma firstName/lastName a full_name para almacenar en BD
*/
export function buildFullName(firstName?: string, lastName?: string, fullName?: string): string {
if (fullName) return fullName.trim();
return `${firstName || ''} ${lastName || ''}`.trim();
}
export interface LoginResponse {
user: Omit<User, 'passwordHash'> & { firstName: string; lastName: string };
tokens: TokenPair;
}
class AuthService {
private userRepository: Repository<User>;
constructor() {
this.userRepository = AppDataSource.getRepository(User);
}
async login(dto: LoginDto): Promise<LoginResponse> {
// Find user by email using TypeORM
const user = await this.userRepository.findOne({
where: { email: dto.email.toLowerCase(), status: UserStatus.ACTIVE },
relations: ['roles'],
});
if (!user) {
throw new UnauthorizedError('Credenciales inválidas');
}
// Verify password
const isValidPassword = await bcrypt.compare(dto.password, user.passwordHash || '');
if (!isValidPassword) {
throw new UnauthorizedError('Credenciales inválidas');
}
// Update last login
user.lastLoginAt = new Date();
user.loginCount += 1;
if (dto.metadata?.ipAddress) {
user.lastLoginIp = dto.metadata.ipAddress;
}
await this.userRepository.save(user);
// Generate token pair using TokenService
const metadata: RequestMetadata = dto.metadata || {
ipAddress: 'unknown',
userAgent: 'unknown',
};
const tokens = await tokenService.generateTokenPair(user, metadata);
// Transform fullName to firstName/lastName for frontend response
const { firstName, lastName } = splitFullName(user.fullName);
// Remove passwordHash from response and add firstName/lastName
const { passwordHash, ...userWithoutPassword } = user;
const userResponse = {
...userWithoutPassword,
firstName,
lastName,
};
logger.info('User logged in', { userId: user.id, email: user.email });
return {
user: userResponse as any,
tokens,
};
}
async register(dto: RegisterDto): Promise<LoginResponse> {
// Check if email already exists using TypeORM
const existingUser = await this.userRepository.findOne({
where: { email: dto.email.toLowerCase() },
});
if (existingUser) {
throw new ValidationError('El email ya está registrado');
}
// Transform firstName/lastName to fullName for database storage
const fullName = buildFullName(dto.firstName, dto.lastName, dto.full_name);
// Hash password
const passwordHash = await bcrypt.hash(dto.password, 10);
// Generate tenantId if not provided (new company registration)
const tenantId = dto.tenant_id || crypto.randomUUID();
// Create user using TypeORM
const newUser = this.userRepository.create({
email: dto.email.toLowerCase(),
passwordHash,
fullName,
tenantId,
status: UserStatus.ACTIVE,
});
await this.userRepository.save(newUser);
// Load roles relation for token generation
const userWithRoles = await this.userRepository.findOne({
where: { id: newUser.id },
relations: ['roles'],
});
if (!userWithRoles) {
throw new Error('Error al crear usuario');
}
// Generate token pair using TokenService
const metadata: RequestMetadata = {
ipAddress: 'unknown',
userAgent: 'unknown',
};
const tokens = await tokenService.generateTokenPair(userWithRoles, metadata);
// Transform fullName to firstName/lastName for frontend response
const { firstName, lastName } = splitFullName(userWithRoles.fullName);
// Remove passwordHash from response and add firstName/lastName
const { passwordHash: _, ...userWithoutPassword } = userWithRoles;
const userResponse = {
...userWithoutPassword,
firstName,
lastName,
};
logger.info('User registered', { userId: userWithRoles.id, email: userWithRoles.email });
return {
user: userResponse as any,
tokens,
};
}
async refreshToken(refreshToken: string, metadata: RequestMetadata): Promise<TokenPair> {
// Delegate completely to TokenService
return tokenService.refreshTokens(refreshToken, metadata);
}
async logout(sessionId: string): Promise<void> {
await tokenService.revokeSession(sessionId, 'user_logout');
}
async logoutAll(userId: string): Promise<number> {
return tokenService.revokeAllUserSessions(userId, 'logout_all');
}
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<void> {
// Find user using TypeORM
const user = await this.userRepository.findOne({
where: { id: userId },
});
if (!user) {
throw new NotFoundError('Usuario no encontrado');
}
// Verify current password
const isValidPassword = await bcrypt.compare(currentPassword, user.passwordHash || '');
if (!isValidPassword) {
throw new UnauthorizedError('Contraseña actual incorrecta');
}
// Hash new password and update user
const newPasswordHash = await bcrypt.hash(newPassword, 10);
user.passwordHash = newPasswordHash;
user.updatedAt = new Date();
await this.userRepository.save(user);
// Revoke all sessions after password change for security
const revokedCount = await tokenService.revokeAllUserSessions(userId, 'password_changed');
logger.info('Password changed and all sessions revoked', { userId, revokedCount });
}
async getProfile(userId: string): Promise<Omit<User, 'passwordHash'>> {
// Find user using TypeORM with relations
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['roles', 'companies'],
});
if (!user) {
throw new NotFoundError('Usuario no encontrado');
}
// Remove passwordHash from response
const { passwordHash, ...userWithoutPassword } = user;
return userWithoutPassword;
}
}
export const authService = new AuthService();

View File

@ -0,0 +1,87 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity.js';
import { Tenant } from './tenant.entity.js';
@Entity({ schema: 'auth', name: 'api_keys' })
@Index('idx_api_keys_lookup', ['keyIndex', 'isActive'], {
where: 'is_active = TRUE',
})
@Index('idx_api_keys_expiration', ['expirationDate'], {
where: 'expiration_date IS NOT NULL',
})
@Index('idx_api_keys_user', ['userId'])
@Index('idx_api_keys_tenant', ['tenantId'])
export class ApiKey {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
userId: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
// Descripción
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
// Seguridad
@Column({ type: 'varchar', length: 16, nullable: false, name: 'key_index' })
keyIndex: string;
@Column({ type: 'varchar', length: 255, nullable: false, name: 'key_hash' })
keyHash: string;
// Scope y restricciones
@Column({ type: 'varchar', length: 100, nullable: true })
scope: string | null;
@Column({ type: 'inet', array: true, nullable: true, name: 'allowed_ips' })
allowedIps: string[] | null;
// Expiración
@Column({
type: 'timestamptz',
nullable: true,
name: 'expiration_date',
})
expirationDate: Date | null;
@Column({ type: 'timestamptz', nullable: true, name: 'last_used_at' })
lastUsedAt: Date | null;
// Estado
@Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' })
isActive: boolean;
// Relaciones
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'revoked_by' })
revokedByUser: User | null;
// Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'timestamptz', nullable: true, name: 'revoked_at' })
revokedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'revoked_by' })
revokedBy: string | null;
}

View File

@ -0,0 +1,93 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
ManyToMany,
} from 'typeorm';
import { Tenant } from './tenant.entity.js';
import { User } from './user.entity.js';
@Entity({ schema: 'auth', name: 'companies' })
@Index('idx_companies_tenant_id', ['tenantId'])
@Index('idx_companies_parent_company_id', ['parentCompanyId'])
@Index('idx_companies_active', ['tenantId'], { where: 'deleted_at IS NULL' })
@Index('idx_companies_tax_id', ['taxId'])
export class Company {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'varchar', length: 255, nullable: true, name: 'legal_name' })
legalName: string | null;
@Column({ type: 'varchar', length: 50, nullable: true, name: 'tax_id' })
taxId: string | null;
@Column({ type: 'uuid', nullable: true, name: 'currency_id' })
currencyId: string | null;
@Column({
type: 'uuid',
nullable: true,
name: 'parent_company_id',
})
parentCompanyId: string | null;
@Column({ type: 'uuid', nullable: true, name: 'partner_id' })
partnerId: string | null;
@Column({ type: 'jsonb', default: {} })
settings: Record<string, any>;
// Relaciones
@ManyToOne(() => Tenant, (tenant) => tenant.companies, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => Company, (company) => company.childCompanies, {
nullable: true,
})
@JoinColumn({ name: 'parent_company_id' })
parentCompany: Company | null;
@ManyToMany(() => Company)
childCompanies: Company[];
@ManyToMany(() => User, (user) => user.companies)
users: User[];
// Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({
name: 'updated_at',
type: 'timestamp',
nullable: true,
})
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
deletedBy: string | null;
}

View File

@ -0,0 +1,64 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
Index,
} from 'typeorm';
import { Tenant } from './tenant.entity.js';
import { User } from './user.entity.js';
@Entity({ schema: 'auth', name: 'devices' })
@Index('idx_devices_tenant_id', ['tenantId'])
@Index('idx_devices_user_id', ['userId'])
@Index('idx_devices_device_id', ['deviceId'])
export class Device {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
userId: string;
@Column({ type: 'varchar', length: 255, nullable: false, name: 'device_id' })
deviceId: string;
@Column({ type: 'varchar', length: 255, nullable: true, name: 'device_name' })
deviceName: string;
@Column({ type: 'varchar', length: 50, nullable: false, name: 'device_type' })
deviceType: string;
@Column({ type: 'varchar', length: 50, nullable: true })
platform: string;
@Column({ type: 'varchar', length: 50, nullable: true, name: 'os_version' })
osVersion: string;
@Column({ type: 'varchar', length: 20, nullable: true, name: 'app_version' })
appVersion: string;
@Column({ type: 'text', nullable: true, name: 'push_token' })
pushToken: string;
@Column({ name: 'is_trusted', default: false })
isTrusted: boolean;
@Column({ type: 'timestamptz', nullable: true, name: 'last_active_at' })
lastActiveAt: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
}

View File

@ -0,0 +1,89 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Tenant } from './tenant.entity.js';
import { User } from './user.entity.js';
@Entity({ schema: 'auth', name: 'groups' })
@Index('idx_groups_tenant_id', ['tenantId'])
@Index('idx_groups_code', ['code'])
@Index('idx_groups_category', ['category'])
@Index('idx_groups_is_system', ['isSystem'])
export class Group {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'varchar', length: 100, nullable: false })
code: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
// Configuración
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_system' })
isSystem: boolean;
@Column({ type: 'varchar', length: 100, nullable: true })
category: string | null;
@Column({ type: 'varchar', length: 20, nullable: true })
color: string | null;
// API Keys
@Column({
type: 'integer',
default: 30,
nullable: true,
name: 'api_key_max_duration_days',
})
apiKeyMaxDurationDays: number | null;
// Relaciones
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'created_by' })
createdByUser: User | null;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'updated_by' })
updatedByUser: User | null;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'deleted_by' })
deletedByUser: User | null;
// Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
deletedBy: string | null;
}

View File

@ -0,0 +1,26 @@
// Core auth entities
export { Tenant, TenantStatus } from './tenant.entity.js';
export { Company } from './company.entity.js';
export { User, UserStatus } from './user.entity.js';
export { Role } from './role.entity.js';
export { Permission, PermissionAction } from './permission.entity.js';
export { Session, SessionStatus } from './session.entity.js';
export { PasswordReset } from './password-reset.entity.js';
export { Group } from './group.entity.js';
export { ApiKey } from './api-key.entity.js';
export { TrustedDevice, TrustLevel } from './trusted-device.entity.js';
export { VerificationCode, CodeType } from './verification-code.entity.js';
export { MfaAuditLog, MfaEventType } from './mfa-audit-log.entity.js';
export { OAuthProvider } from './oauth-provider.entity.js';
export { OAuthUserLink } from './oauth-user-link.entity.js';
export { OAuthState } from './oauth-state.entity.js';
export { UserProfile } from './user-profile.entity.js';
export { ProfileTool } from './profile-tool.entity.js';
export { ProfileModule } from './profile-module.entity.js';
export { UserProfileAssignment } from './user-profile-assignment.entity.js';
export { Device } from './device.entity.js';
// NOTE: The following entities are also available in their specific modules:
// - UserProfile, ProfileTool, ProfileModule, UserProfileAssignment, Person -> profiles/entities/
// - Device, BiometricCredential, DeviceSession, DeviceActivityLog -> biometrics/entities/
// Import directly from those modules if needed.

View File

@ -0,0 +1,87 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity.js';
export enum MfaEventType {
MFA_SETUP_INITIATED = 'mfa_setup_initiated',
MFA_SETUP_COMPLETED = 'mfa_setup_completed',
MFA_DISABLED = 'mfa_disabled',
TOTP_VERIFIED = 'totp_verified',
TOTP_FAILED = 'totp_failed',
BACKUP_CODE_USED = 'backup_code_used',
BACKUP_CODES_REGENERATED = 'backup_codes_regenerated',
DEVICE_TRUSTED = 'device_trusted',
DEVICE_REVOKED = 'device_revoked',
ANOMALY_DETECTED = 'anomaly_detected',
ACCOUNT_LOCKED = 'account_locked',
ACCOUNT_UNLOCKED = 'account_unlocked',
}
@Entity({ schema: 'auth', name: 'mfa_audit_log' })
@Index('idx_mfa_audit_user', ['userId', 'createdAt'])
@Index('idx_mfa_audit_event', ['eventType', 'createdAt'])
@Index('idx_mfa_audit_failures', ['userId', 'createdAt'], {
where: 'success = FALSE',
})
export class MfaAuditLog {
@PrimaryGeneratedColumn('uuid')
id: string;
// Usuario
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
userId: string;
// Evento
@Column({
type: 'enum',
enum: MfaEventType,
nullable: false,
name: 'event_type',
})
eventType: MfaEventType;
// Resultado
@Column({ type: 'boolean', nullable: false })
success: boolean;
@Column({ type: 'varchar', length: 128, nullable: true, name: 'failure_reason' })
failureReason: string | null;
// Contexto
@Column({ type: 'inet', nullable: true, name: 'ip_address' })
ipAddress: string | null;
@Column({ type: 'text', nullable: true, name: 'user_agent' })
userAgent: string | null;
@Column({
type: 'varchar',
length: 128,
nullable: true,
name: 'device_fingerprint',
})
deviceFingerprint: string | null;
@Column({ type: 'jsonb', nullable: true })
location: Record<string, any> | null;
// Metadata adicional
@Column({ type: 'jsonb', default: {}, nullable: true })
metadata: Record<string, any>;
// Relaciones
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
// Timestamp
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}

View File

@ -0,0 +1,191 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Tenant } from './tenant.entity.js';
import { User } from './user.entity.js';
import { Role } from './role.entity.js';
@Entity({ schema: 'auth', name: 'oauth_providers' })
@Index('idx_oauth_providers_enabled', ['isEnabled'])
@Index('idx_oauth_providers_tenant', ['tenantId'])
@Index('idx_oauth_providers_code', ['code'])
export class OAuthProvider {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: true, name: 'tenant_id' })
tenantId: string | null;
@Column({ type: 'varchar', length: 50, nullable: false, unique: true })
code: string;
@Column({ type: 'varchar', length: 100, nullable: false })
name: string;
// Configuración OAuth2
@Column({ type: 'varchar', length: 255, nullable: false, name: 'client_id' })
clientId: string;
@Column({ type: 'varchar', length: 500, nullable: true, name: 'client_secret' })
clientSecret: string | null;
// Endpoints OAuth2
@Column({
type: 'varchar',
length: 500,
nullable: false,
name: 'authorization_endpoint',
})
authorizationEndpoint: string;
@Column({
type: 'varchar',
length: 500,
nullable: false,
name: 'token_endpoint',
})
tokenEndpoint: string;
@Column({
type: 'varchar',
length: 500,
nullable: false,
name: 'userinfo_endpoint',
})
userinfoEndpoint: string;
@Column({ type: 'varchar', length: 500, nullable: true, name: 'jwks_uri' })
jwksUri: string | null;
// Scopes y parámetros
@Column({
type: 'varchar',
length: 500,
default: 'openid profile email',
nullable: false,
})
scope: string;
@Column({
type: 'varchar',
length: 50,
default: 'code',
nullable: false,
name: 'response_type',
})
responseType: string;
// PKCE Configuration
@Column({
type: 'boolean',
default: true,
nullable: false,
name: 'pkce_enabled',
})
pkceEnabled: boolean;
@Column({
type: 'varchar',
length: 10,
default: 'S256',
nullable: true,
name: 'code_challenge_method',
})
codeChallengeMethod: string | null;
// Mapeo de claims
@Column({
type: 'jsonb',
nullable: false,
name: 'claim_mapping',
default: {
sub: 'oauth_uid',
email: 'email',
name: 'name',
picture: 'avatar_url',
},
})
claimMapping: Record<string, any>;
// UI
@Column({ type: 'varchar', length: 100, nullable: true, name: 'icon_class' })
iconClass: string | null;
@Column({ type: 'varchar', length: 100, nullable: true, name: 'button_text' })
buttonText: string | null;
@Column({ type: 'varchar', length: 20, nullable: true, name: 'button_color' })
buttonColor: string | null;
@Column({
type: 'integer',
default: 10,
nullable: false,
name: 'display_order',
})
displayOrder: number;
// Estado
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_enabled' })
isEnabled: boolean;
@Column({ type: 'boolean', default: true, nullable: false, name: 'is_visible' })
isVisible: boolean;
// Restricciones
@Column({
type: 'text',
array: true,
nullable: true,
name: 'allowed_domains',
})
allowedDomains: string[] | null;
@Column({
type: 'boolean',
default: false,
nullable: false,
name: 'auto_create_users',
})
autoCreateUsers: boolean;
@Column({ type: 'uuid', nullable: true, name: 'default_role_id' })
defaultRoleId: string | null;
// Relaciones
@ManyToOne(() => Tenant, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant | null;
@ManyToOne(() => Role, { nullable: true })
@JoinColumn({ name: 'default_role_id' })
defaultRole: Role | null;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'created_by' })
createdByUser: User | null;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'updated_by' })
updatedByUser: User | null;
// Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
}

View File

@ -0,0 +1,66 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { OAuthProvider } from './oauth-provider.entity.js';
import { User } from './user.entity.js';
@Entity({ schema: 'auth', name: 'oauth_states' })
@Index('idx_oauth_states_state', ['state'])
@Index('idx_oauth_states_expires', ['expiresAt'])
export class OAuthState {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 64, nullable: false, unique: true })
state: string;
// PKCE
@Column({ type: 'varchar', length: 128, nullable: true, name: 'code_verifier' })
codeVerifier: string | null;
// Contexto
@Column({ type: 'uuid', nullable: false, name: 'provider_id' })
providerId: string;
@Column({ type: 'varchar', length: 500, nullable: false, name: 'redirect_uri' })
redirectUri: string;
@Column({ type: 'varchar', length: 500, nullable: true, name: 'return_url' })
returnUrl: string | null;
// Vinculación con usuario existente (para linking)
@Column({ type: 'uuid', nullable: true, name: 'link_user_id' })
linkUserId: string | null;
// Metadata
@Column({ type: 'inet', nullable: true, name: 'ip_address' })
ipAddress: string | null;
@Column({ type: 'text', nullable: true, name: 'user_agent' })
userAgent: string | null;
// Relaciones
@ManyToOne(() => OAuthProvider)
@JoinColumn({ name: 'provider_id' })
provider: OAuthProvider;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'link_user_id' })
linkUser: User | null;
// Tiempo de vida
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'timestamptz', nullable: false, name: 'expires_at' })
expiresAt: Date;
@Column({ type: 'timestamptz', nullable: true, name: 'used_at' })
usedAt: Date | null;
}

View File

@ -0,0 +1,73 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity.js';
import { OAuthProvider } from './oauth-provider.entity.js';
@Entity({ schema: 'auth', name: 'oauth_user_links' })
@Index('idx_oauth_links_user', ['userId'])
@Index('idx_oauth_links_provider', ['providerId'])
@Index('idx_oauth_links_oauth_uid', ['oauthUid'])
export class OAuthUserLink {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
userId: string;
@Column({ type: 'uuid', nullable: false, name: 'provider_id' })
providerId: string;
// Identificación OAuth
@Column({ type: 'varchar', length: 255, nullable: false, name: 'oauth_uid' })
oauthUid: string;
@Column({ type: 'varchar', length: 255, nullable: true, name: 'oauth_email' })
oauthEmail: string | null;
// Tokens (encriptados)
@Column({ type: 'text', nullable: true, name: 'access_token' })
accessToken: string | null;
@Column({ type: 'text', nullable: true, name: 'refresh_token' })
refreshToken: string | null;
@Column({ type: 'text', nullable: true, name: 'id_token' })
idToken: string | null;
@Column({ type: 'timestamptz', nullable: true, name: 'token_expires_at' })
tokenExpiresAt: Date | null;
// Metadata
@Column({ type: 'jsonb', nullable: true, name: 'raw_userinfo' })
rawUserinfo: Record<string, any> | null;
@Column({ type: 'timestamptz', nullable: true, name: 'last_login_at' })
lastLoginAt: Date | null;
@Column({ type: 'integer', default: 0, nullable: false, name: 'login_count' })
loginCount: number;
// Relaciones
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@ManyToOne(() => OAuthProvider, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'provider_id' })
provider: OAuthProvider;
// Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,45 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity.js';
@Entity({ schema: 'auth', name: 'password_resets' })
@Index('idx_password_resets_user_id', ['userId'])
@Index('idx_password_resets_token', ['token'])
@Index('idx_password_resets_expires_at', ['expiresAt'])
export class PasswordReset {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
userId: string;
@Column({ type: 'varchar', length: 500, unique: true, nullable: false })
token: string;
@Column({ type: 'timestamp', nullable: false, name: 'expires_at' })
expiresAt: Date;
@Column({ type: 'timestamp', nullable: true, name: 'used_at' })
usedAt: Date | null;
@Column({ type: 'inet', nullable: true, name: 'ip_address' })
ipAddress: string | null;
// Relaciones
@ManyToOne(() => User, (user) => user.passwordResets, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'user_id' })
user: User;
// Timestamps
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
}

View File

@ -0,0 +1,52 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToMany,
} from 'typeorm';
import { Role } from './role.entity.js';
export enum PermissionAction {
CREATE = 'create',
READ = 'read',
UPDATE = 'update',
DELETE = 'delete',
APPROVE = 'approve',
CANCEL = 'cancel',
EXPORT = 'export',
}
@Entity({ schema: 'auth', name: 'permissions' })
@Index('idx_permissions_resource', ['resource'])
@Index('idx_permissions_action', ['action'])
@Index('idx_permissions_module', ['module'])
export class Permission {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 100, nullable: false })
resource: string;
@Column({
type: 'enum',
enum: PermissionAction,
nullable: false,
})
action: PermissionAction;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'varchar', length: 50, nullable: true })
module: string | null;
// Relaciones
@ManyToMany(() => Role, (role) => role.permissions)
roles: Role[];
// Sin tenant_id: permisos son globales
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
}

View File

@ -0,0 +1,27 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { UserProfile } from './user-profile.entity.js';
@Entity({ schema: 'auth', name: 'profile_modules' })
export class ProfileModule {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'profile_id' })
profileId: string;
@Column({ type: 'varchar', length: 50, nullable: false, name: 'module_code' })
moduleCode: string;
@Column({ name: 'is_enabled', default: true })
isEnabled: boolean;
@ManyToOne(() => UserProfile, (p) => p.modules, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'profile_id' })
profile: UserProfile;
}

View File

@ -0,0 +1,36 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { UserProfile } from './user-profile.entity.js';
@Entity({ schema: 'auth', name: 'profile_tools' })
export class ProfileTool {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'profile_id' })
profileId: string;
@Column({ type: 'varchar', length: 50, nullable: false, name: 'tool_code' })
toolCode: string;
@Column({ name: 'can_view', default: false })
canView: boolean;
@Column({ name: 'can_create', default: false })
canCreate: boolean;
@Column({ name: 'can_edit', default: false })
canEdit: boolean;
@Column({ name: 'can_delete', default: false })
canDelete: boolean;
@ManyToOne(() => UserProfile, (p) => p.tools, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'profile_id' })
profile: UserProfile;
}

View File

@ -0,0 +1,84 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
ManyToMany,
JoinColumn,
JoinTable,
} from 'typeorm';
import { Tenant } from './tenant.entity.js';
import { User } from './user.entity.js';
import { Permission } from './permission.entity.js';
@Entity({ schema: 'auth', name: 'roles' })
@Index('idx_roles_tenant_id', ['tenantId'])
@Index('idx_roles_code', ['code'])
@Index('idx_roles_is_system', ['isSystem'])
export class Role {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'varchar', length: 100, nullable: false })
name: string;
@Column({ type: 'varchar', length: 50, nullable: false })
code: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_system' })
isSystem: boolean;
@Column({ type: 'varchar', length: 20, nullable: true })
color: string | null;
// Relaciones
@ManyToOne(() => Tenant, (tenant) => tenant.roles, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToMany(() => Permission, (permission) => permission.roles)
@JoinTable({
name: 'role_permissions',
schema: 'auth',
joinColumn: { name: 'role_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' },
})
permissions: Permission[];
@ManyToMany(() => User, (user) => user.roles)
users: User[];
// Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({
name: 'updated_at',
type: 'timestamp',
nullable: true,
})
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
deletedBy: string | null;
}

View File

@ -0,0 +1,90 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity.js';
export enum SessionStatus {
ACTIVE = 'active',
EXPIRED = 'expired',
REVOKED = 'revoked',
}
@Entity({ schema: 'auth', name: 'sessions' })
@Index('idx_sessions_user_id', ['userId'])
@Index('idx_sessions_token', ['token'])
@Index('idx_sessions_status', ['status'])
@Index('idx_sessions_expires_at', ['expiresAt'])
export class Session {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
userId: string;
@Column({ type: 'varchar', length: 500, unique: true, nullable: false })
token: string;
@Column({
type: 'varchar',
length: 500,
unique: true,
nullable: true,
name: 'refresh_token',
})
refreshToken: string | null;
@Column({
type: 'enum',
enum: SessionStatus,
default: SessionStatus.ACTIVE,
nullable: false,
})
status: SessionStatus;
@Column({ type: 'timestamp', nullable: false, name: 'expires_at' })
expiresAt: Date;
@Column({
type: 'timestamp',
nullable: true,
name: 'refresh_expires_at',
})
refreshExpiresAt: Date | null;
@Column({ type: 'inet', nullable: true, name: 'ip_address' })
ipAddress: string | null;
@Column({ type: 'text', nullable: true, name: 'user_agent' })
userAgent: string | null;
@Column({ type: 'jsonb', nullable: true, name: 'device_info' })
deviceInfo: Record<string, any> | null;
// Relaciones
@ManyToOne(() => User, (user) => user.sessions, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'user_id' })
user: User;
// Timestamps
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@Column({ type: 'timestamp', nullable: true, name: 'revoked_at' })
revokedAt: Date | null;
@Column({
type: 'varchar',
length: 100,
nullable: true,
name: 'revoked_reason',
})
revokedReason: string | null;
}

View File

@ -0,0 +1,93 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
OneToMany,
} from 'typeorm';
import { Company } from './company.entity.js';
import { User } from './user.entity.js';
import { Role } from './role.entity.js';
export enum TenantStatus {
ACTIVE = 'active',
SUSPENDED = 'suspended',
TRIAL = 'trial',
CANCELLED = 'cancelled',
}
@Entity({ schema: 'auth', name: 'tenants' })
@Index('idx_tenants_subdomain', ['subdomain'])
@Index('idx_tenants_status', ['status'], { where: 'deleted_at IS NULL' })
@Index('idx_tenants_created_at', ['createdAt'])
export class Tenant {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'varchar', length: 100, unique: true, nullable: false })
subdomain: string;
@Column({
type: 'varchar',
length: 100,
unique: true,
nullable: false,
name: 'schema_name',
})
schemaName: string;
@Column({
type: 'enum',
enum: TenantStatus,
default: TenantStatus.ACTIVE,
nullable: false,
})
status: TenantStatus;
@Column({ type: 'jsonb', default: {} })
settings: Record<string, any>;
@Column({ type: 'varchar', length: 50, default: 'basic', nullable: true })
plan: string;
@Column({ type: 'integer', default: 10, name: 'max_users' })
maxUsers: number;
// Relaciones
@OneToMany(() => Company, (company) => company.tenant)
companies: Company[];
@OneToMany(() => User, (user) => user.tenant)
users: User[];
@OneToMany(() => Role, (role) => role.tenant)
roles: Role[];
// Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({
name: 'updated_at',
type: 'timestamp',
nullable: true,
})
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
deletedBy: string | null;
}

View File

@ -0,0 +1,115 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity.js';
export enum TrustLevel {
STANDARD = 'standard',
HIGH = 'high',
TEMPORARY = 'temporary',
}
@Entity({ schema: 'auth', name: 'trusted_devices' })
@Index('idx_trusted_devices_user', ['userId'], { where: 'is_active' })
@Index('idx_trusted_devices_fingerprint', ['deviceFingerprint'])
@Index('idx_trusted_devices_expires', ['trustExpiresAt'], {
where: 'trust_expires_at IS NOT NULL AND is_active',
})
export class TrustedDevice {
@PrimaryGeneratedColumn('uuid')
id: string;
// Relación con usuario
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
userId: string;
// Identificación del dispositivo
@Column({
type: 'varchar',
length: 128,
nullable: false,
name: 'device_fingerprint',
})
deviceFingerprint: string;
@Column({ type: 'varchar', length: 128, nullable: true, name: 'device_name' })
deviceName: string | null;
@Column({ type: 'varchar', length: 32, nullable: true, name: 'device_type' })
deviceType: string | null;
// Información del dispositivo
@Column({ type: 'text', nullable: true, name: 'user_agent' })
userAgent: string | null;
@Column({ type: 'varchar', length: 64, nullable: true, name: 'browser_name' })
browserName: string | null;
@Column({
type: 'varchar',
length: 32,
nullable: true,
name: 'browser_version',
})
browserVersion: string | null;
@Column({ type: 'varchar', length: 64, nullable: true, name: 'os_name' })
osName: string | null;
@Column({ type: 'varchar', length: 32, nullable: true, name: 'os_version' })
osVersion: string | null;
// Ubicación del registro
@Column({ type: 'inet', nullable: false, name: 'registered_ip' })
registeredIp: string;
@Column({ type: 'jsonb', nullable: true, name: 'registered_location' })
registeredLocation: Record<string, any> | null;
// Estado de confianza
@Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' })
isActive: boolean;
@Column({
type: 'enum',
enum: TrustLevel,
default: TrustLevel.STANDARD,
nullable: false,
name: 'trust_level',
})
trustLevel: TrustLevel;
@Column({ type: 'timestamptz', nullable: true, name: 'trust_expires_at' })
trustExpiresAt: Date | null;
// Uso
@Column({ type: 'timestamptz', nullable: false, name: 'last_used_at' })
lastUsedAt: Date;
@Column({ type: 'inet', nullable: true, name: 'last_used_ip' })
lastUsedIp: string | null;
@Column({ type: 'integer', default: 1, nullable: false, name: 'use_count' })
useCount: number;
// Relaciones
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
// Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'timestamptz', nullable: true, name: 'revoked_at' })
revokedAt: Date | null;
@Column({ type: 'varchar', length: 128, nullable: true, name: 'revoked_reason' })
revokedReason: string | null;
}

View File

@ -0,0 +1,36 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { User } from './user.entity.js';
import { UserProfile } from './user-profile.entity.js';
@Entity({ schema: 'auth', name: 'user_profile_assignments' })
export class UserProfileAssignment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
userId: string;
@Column({ type: 'uuid', nullable: false, name: 'profile_id' })
profileId: string;
@Column({ name: 'is_default', default: false })
isDefault: boolean;
@CreateDateColumn({ name: 'assigned_at', type: 'timestamp' })
assignedAt: Date;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@ManyToOne(() => UserProfile, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'profile_id' })
profile: UserProfile;
}

View File

@ -0,0 +1,52 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { Tenant } from './tenant.entity.js';
import { ProfileTool } from './profile-tool.entity.js';
import { ProfileModule } from './profile-module.entity.js';
@Entity({ schema: 'auth', name: 'user_profiles' })
@Index('idx_user_profiles_tenant_id', ['tenantId'])
export class UserProfile {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'varchar', length: 10, nullable: false })
code: string;
@Column({ type: 'varchar', length: 100, nullable: false })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
updatedAt: Date;
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@OneToMany(() => ProfileTool, (pt) => pt.profile)
tools: ProfileTool[];
@OneToMany(() => ProfileModule, (pm) => pm.profile)
modules: ProfileModule[];
}

View File

@ -0,0 +1,159 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
ManyToMany,
JoinColumn,
JoinTable,
OneToMany,
} from 'typeorm';
import { Tenant } from './tenant.entity.js';
import { Role } from './role.entity.js';
import { Company } from './company.entity.js';
import { Session } from './session.entity.js';
import { PasswordReset } from './password-reset.entity.js';
export enum UserStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
SUSPENDED = 'suspended',
PENDING_VERIFICATION = 'pending_verification',
}
@Entity({ schema: 'auth', name: 'users' })
@Index('idx_users_tenant_id', ['tenantId'])
@Index('idx_users_email', ['email'])
@Index('idx_users_status', ['status'], { where: 'deleted_at IS NULL' })
@Index('idx_users_email_tenant', ['tenantId', 'email'])
@Index('idx_users_created_at', ['createdAt'])
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'varchar', length: 255, nullable: false })
email: string;
@Column({ type: 'varchar', length: 255, nullable: false, name: 'password_hash' })
passwordHash: string;
@Column({ type: 'varchar', length: 255, nullable: false, name: 'full_name' })
fullName: string;
@Column({ type: 'varchar', length: 500, nullable: true, name: 'avatar_url' })
avatarUrl: string | null;
@Column({
type: 'enum',
enum: UserStatus,
default: UserStatus.ACTIVE,
nullable: false,
})
status: UserStatus;
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_superuser' })
isSuperuser: boolean;
@Column({ name: 'is_superadmin', default: false })
isSuperadmin: boolean;
@Column({ name: 'mfa_enabled', default: false })
mfaEnabled: boolean;
@Column({ name: 'mfa_secret_encrypted', type: 'text', nullable: true })
mfaSecretEncrypted: string;
@Column({ name: 'mfa_backup_codes', type: 'text', array: true, nullable: true })
mfaBackupCodes: string[];
@Column({ name: 'oauth_provider', length: 50, nullable: true })
oauthProvider: string;
@Column({ name: 'oauth_provider_id', length: 255, nullable: true })
oauthProviderId: string;
@Column({
type: 'timestamp',
nullable: true,
name: 'email_verified_at',
})
emailVerifiedAt: Date | null;
@Column({ type: 'timestamp', nullable: true, name: 'last_login_at' })
lastLoginAt: Date | null;
@Column({ type: 'inet', nullable: true, name: 'last_login_ip' })
lastLoginIp: string | null;
@Column({ type: 'integer', default: 0, name: 'login_count' })
loginCount: number;
@Column({ type: 'varchar', length: 10, default: 'es' })
language: string;
@Column({ type: 'varchar', length: 50, default: 'America/Mexico_City' })
timezone: string;
@Column({ type: 'jsonb', default: {} })
settings: Record<string, any>;
// Relaciones
@ManyToOne(() => Tenant, (tenant) => tenant.users, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToMany(() => Role, (role) => role.users)
@JoinTable({
name: 'user_roles',
schema: 'auth',
joinColumn: { name: 'user_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' },
})
roles: Role[];
@ManyToMany(() => Company, (company) => company.users)
@JoinTable({
name: 'user_companies',
schema: 'auth',
joinColumn: { name: 'user_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'company_id', referencedColumnName: 'id' },
})
companies: Company[];
@OneToMany(() => Session, (session) => session.user)
sessions: Session[];
@OneToMany(() => PasswordReset, (passwordReset) => passwordReset.user)
passwordResets: PasswordReset[];
// Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({
name: 'updated_at',
type: 'timestamp',
nullable: true,
})
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
deletedBy: string | null;
}

View File

@ -0,0 +1,90 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity.js';
import { Session } from './session.entity.js';
export enum CodeType {
TOTP_SETUP = 'totp_setup',
SMS = 'sms',
EMAIL = 'email',
BACKUP = 'backup',
}
@Entity({ schema: 'auth', name: 'verification_codes' })
@Index('idx_verification_codes_user', ['userId', 'codeType'], {
where: 'used_at IS NULL',
})
@Index('idx_verification_codes_expires', ['expiresAt'], {
where: 'used_at IS NULL',
})
export class VerificationCode {
@PrimaryGeneratedColumn('uuid')
id: string;
// Relaciones
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
userId: string;
@Column({ type: 'uuid', nullable: true, name: 'session_id' })
sessionId: string | null;
// Tipo de código
@Column({
type: 'enum',
enum: CodeType,
nullable: false,
name: 'code_type',
})
codeType: CodeType;
// Código (hash SHA-256)
@Column({ type: 'varchar', length: 64, nullable: false, name: 'code_hash' })
codeHash: string;
@Column({ type: 'integer', default: 6, nullable: false, name: 'code_length' })
codeLength: number;
// Destino (para SMS/Email)
@Column({ type: 'varchar', length: 256, nullable: true })
destination: string | null;
// Intentos
@Column({ type: 'integer', default: 0, nullable: false })
attempts: number;
@Column({ type: 'integer', default: 5, nullable: false, name: 'max_attempts' })
maxAttempts: number;
// Validez
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'timestamptz', nullable: false, name: 'expires_at' })
expiresAt: Date;
@Column({ type: 'timestamptz', nullable: true, name: 'used_at' })
usedAt: Date | null;
// Metadata
@Column({ type: 'inet', nullable: true, name: 'ip_address' })
ipAddress: string | null;
@Column({ type: 'text', nullable: true, name: 'user_agent' })
userAgent: string | null;
// Relaciones
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@ManyToOne(() => Session, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'session_id' })
session: Session | null;
}

View File

@ -0,0 +1,8 @@
export * from './auth.service.js';
export * from './auth.controller.js';
export { default as authRoutes } from './auth.routes.js';
// API Keys
export * from './apiKeys.service.js';
export * from './apiKeys.controller.js';
export { default as apiKeysRoutes } from './apiKeys.routes.js';

View File

@ -0,0 +1,456 @@
import jwt, { SignOptions } from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { Repository } from 'typeorm';
import { AppDataSource } from '../../../config/typeorm.js';
import { config } from '../../../config/index.js';
import { User, Session, SessionStatus } from '../entities/index.js';
import { blacklistToken, isTokenBlacklisted } from '../../../config/redis.js';
import { logger } from '../../../shared/utils/logger.js';
import { UnauthorizedError } from '../../../shared/types/index.js';
// ===== Interfaces =====
/**
* JWT Payload structure for access and refresh tokens
*/
export interface JwtPayload {
sub: string; // User ID
tid: string; // Tenant ID
email: string;
roles: string[];
jti: string; // JWT ID único
iat: number;
exp: number;
}
/**
* Token pair returned after authentication
*/
export interface TokenPair {
accessToken: string;
refreshToken: string;
accessTokenExpiresAt: Date;
refreshTokenExpiresAt: Date;
sessionId: string;
}
/**
* Request metadata for session tracking
*/
export interface RequestMetadata {
ipAddress: string;
userAgent: string;
}
// ===== TokenService Class =====
/**
* Service for managing JWT tokens with blacklist support via Redis
* and session tracking via TypeORM
*/
class TokenService {
private sessionRepository: Repository<Session>;
// Configuration constants
private readonly ACCESS_TOKEN_EXPIRY = '15m';
private readonly REFRESH_TOKEN_EXPIRY = '7d';
private readonly ALGORITHM = 'HS256' as const;
constructor() {
this.sessionRepository = AppDataSource.getRepository(Session);
}
/**
* Generates a new token pair (access + refresh) and creates a session
* @param user - User entity with roles loaded
* @param metadata - Request metadata (IP, user agent)
* @returns Promise<TokenPair> - Access and refresh tokens with expiration dates
*/
async generateTokenPair(user: User, metadata: RequestMetadata): Promise<TokenPair> {
try {
logger.debug('Generating token pair', { userId: user.id, tenantId: user.tenantId });
// Extract role codes from user roles
const roles = user.roles ? user.roles.map(role => role.code) : [];
// Calculate expiration dates
const accessTokenExpiresAt = this.calculateExpiration(this.ACCESS_TOKEN_EXPIRY);
const refreshTokenExpiresAt = this.calculateExpiration(this.REFRESH_TOKEN_EXPIRY);
// Generate unique JWT IDs
const accessJti = this.generateJti();
const refreshJti = this.generateJti();
// Generate access token
const accessToken = this.generateToken({
sub: user.id,
tid: user.tenantId,
email: user.email,
roles,
jti: accessJti,
}, this.ACCESS_TOKEN_EXPIRY);
// Generate refresh token
const refreshToken = this.generateToken({
sub: user.id,
tid: user.tenantId,
email: user.email,
roles,
jti: refreshJti,
}, this.REFRESH_TOKEN_EXPIRY);
// Create session record in database
const session = this.sessionRepository.create({
userId: user.id,
token: accessJti, // Store JTI instead of full token
refreshToken: refreshJti, // Store JTI instead of full token
status: SessionStatus.ACTIVE,
expiresAt: accessTokenExpiresAt,
refreshExpiresAt: refreshTokenExpiresAt,
ipAddress: metadata.ipAddress,
userAgent: metadata.userAgent,
});
await this.sessionRepository.save(session);
logger.info('Token pair generated successfully', {
userId: user.id,
sessionId: session.id,
tenantId: user.tenantId,
});
return {
accessToken,
refreshToken,
accessTokenExpiresAt,
refreshTokenExpiresAt,
sessionId: session.id,
};
} catch (error) {
logger.error('Error generating token pair', {
error: (error as Error).message,
userId: user.id,
});
throw error;
}
}
/**
* Refreshes an access token using a valid refresh token
* Implements token replay detection for enhanced security
* @param refreshToken - Valid refresh token
* @param metadata - Request metadata (IP, user agent)
* @returns Promise<TokenPair> - New access and refresh tokens
* @throws UnauthorizedError if token is invalid or replay detected
*/
async refreshTokens(refreshToken: string, metadata: RequestMetadata): Promise<TokenPair> {
try {
logger.debug('Refreshing tokens');
// Verify refresh token
const payload = this.verifyRefreshToken(refreshToken);
// Find active session with this refresh token JTI
const session = await this.sessionRepository.findOne({
where: {
refreshToken: payload.jti,
status: SessionStatus.ACTIVE,
},
relations: ['user', 'user.roles'],
});
if (!session) {
logger.warn('Refresh token not found or session inactive', {
jti: payload.jti,
});
throw new UnauthorizedError('Refresh token inválido o expirado');
}
// Check if session has already been used (token replay detection)
if (session.revokedAt !== null) {
logger.error('TOKEN REPLAY DETECTED - Session was already used', {
sessionId: session.id,
userId: session.userId,
jti: payload.jti,
});
// SECURITY: Revoke ALL user sessions on replay detection
const revokedCount = await this.revokeAllUserSessions(
session.userId,
'Token replay detected'
);
logger.error('All user sessions revoked due to token replay', {
userId: session.userId,
revokedCount,
});
throw new UnauthorizedError('Replay de token detectado. Todas las sesiones han sido revocadas por seguridad.');
}
// Verify session hasn't expired
if (session.refreshExpiresAt && new Date() > session.refreshExpiresAt) {
logger.warn('Refresh token expired', {
sessionId: session.id,
expiredAt: session.refreshExpiresAt,
});
await this.revokeSession(session.id, 'Token expired');
throw new UnauthorizedError('Refresh token expirado');
}
// Mark current session as used (revoke it)
session.status = SessionStatus.REVOKED;
session.revokedAt = new Date();
session.revokedReason = 'Used for refresh';
await this.sessionRepository.save(session);
// Generate new token pair
const newTokenPair = await this.generateTokenPair(session.user, metadata);
logger.info('Tokens refreshed successfully', {
userId: session.userId,
oldSessionId: session.id,
newSessionId: newTokenPair.sessionId,
});
return newTokenPair;
} catch (error) {
logger.error('Error refreshing tokens', {
error: (error as Error).message,
});
throw error;
}
}
/**
* Revokes a session and blacklists its access token
* @param sessionId - Session ID to revoke
* @param reason - Reason for revocation
*/
async revokeSession(sessionId: string, reason: string): Promise<void> {
try {
logger.debug('Revoking session', { sessionId, reason });
const session = await this.sessionRepository.findOne({
where: { id: sessionId },
});
if (!session) {
logger.warn('Session not found for revocation', { sessionId });
return;
}
// Mark session as revoked
session.status = SessionStatus.REVOKED;
session.revokedAt = new Date();
session.revokedReason = reason;
await this.sessionRepository.save(session);
// Blacklist the access token (JTI) in Redis
const remainingTTL = this.calculateRemainingTTL(session.expiresAt);
if (remainingTTL > 0) {
await this.blacklistAccessToken(session.token, remainingTTL);
}
logger.info('Session revoked successfully', { sessionId, reason });
} catch (error) {
logger.error('Error revoking session', {
error: (error as Error).message,
sessionId,
});
throw error;
}
}
/**
* Revokes all active sessions for a user
* Used for security events like password change or token replay detection
* @param userId - User ID whose sessions to revoke
* @param reason - Reason for revocation
* @returns Promise<number> - Number of sessions revoked
*/
async revokeAllUserSessions(userId: string, reason: string): Promise<number> {
try {
logger.debug('Revoking all user sessions', { userId, reason });
const sessions = await this.sessionRepository.find({
where: {
userId,
status: SessionStatus.ACTIVE,
},
});
if (sessions.length === 0) {
logger.debug('No active sessions found for user', { userId });
return 0;
}
// Revoke each session
for (const session of sessions) {
session.status = SessionStatus.REVOKED;
session.revokedAt = new Date();
session.revokedReason = reason;
// Blacklist access token
const remainingTTL = this.calculateRemainingTTL(session.expiresAt);
if (remainingTTL > 0) {
await this.blacklistAccessToken(session.token, remainingTTL);
}
}
await this.sessionRepository.save(sessions);
logger.info('All user sessions revoked', {
userId,
count: sessions.length,
reason,
});
return sessions.length;
} catch (error) {
logger.error('Error revoking all user sessions', {
error: (error as Error).message,
userId,
});
throw error;
}
}
/**
* Adds an access token to the Redis blacklist
* @param jti - JWT ID to blacklist
* @param expiresIn - TTL in seconds
*/
async blacklistAccessToken(jti: string, expiresIn: number): Promise<void> {
try {
await blacklistToken(jti, expiresIn);
logger.debug('Access token blacklisted', { jti, expiresIn });
} catch (error) {
logger.error('Error blacklisting access token', {
error: (error as Error).message,
jti,
});
// Don't throw - blacklist is optional (Redis might be unavailable)
}
}
/**
* Checks if an access token is blacklisted
* @param jti - JWT ID to check
* @returns Promise<boolean> - true if blacklisted
*/
async isAccessTokenBlacklisted(jti: string): Promise<boolean> {
try {
return await isTokenBlacklisted(jti);
} catch (error) {
logger.error('Error checking token blacklist', {
error: (error as Error).message,
jti,
});
// Return false on error - fail open
return false;
}
}
// ===== Private Helper Methods =====
/**
* Generates a JWT token with the specified payload and expiry
* @param payload - Token payload (without iat/exp)
* @param expiresIn - Expiration time string (e.g., '15m', '7d')
* @returns string - Signed JWT token
*/
private generateToken(payload: Omit<JwtPayload, 'iat' | 'exp'>, expiresIn: string): string {
return jwt.sign(payload, config.jwt.secret, {
expiresIn: expiresIn as jwt.SignOptions['expiresIn'],
algorithm: this.ALGORITHM,
} as SignOptions);
}
/**
* Verifies an access token and returns its payload
* @param token - JWT access token
* @returns JwtPayload - Decoded payload
* @throws UnauthorizedError if token is invalid
*/
private verifyAccessToken(token: string): JwtPayload {
try {
return jwt.verify(token, config.jwt.secret, {
algorithms: [this.ALGORITHM],
}) as JwtPayload;
} catch (error) {
logger.warn('Invalid access token', {
error: (error as Error).message,
});
throw new UnauthorizedError('Access token inválido o expirado');
}
}
/**
* Verifies a refresh token and returns its payload
* @param token - JWT refresh token
* @returns JwtPayload - Decoded payload
* @throws UnauthorizedError if token is invalid
*/
private verifyRefreshToken(token: string): JwtPayload {
try {
return jwt.verify(token, config.jwt.secret, {
algorithms: [this.ALGORITHM],
}) as JwtPayload;
} catch (error) {
logger.warn('Invalid refresh token', {
error: (error as Error).message,
});
throw new UnauthorizedError('Refresh token inválido o expirado');
}
}
/**
* Generates a unique JWT ID (JTI) using UUID v4
* @returns string - Unique identifier
*/
private generateJti(): string {
return uuidv4();
}
/**
* Calculates expiration date from a time string
* @param expiresIn - Time string (e.g., '15m', '7d')
* @returns Date - Expiration date
*/
private calculateExpiration(expiresIn: string): Date {
const unit = expiresIn.slice(-1);
const value = parseInt(expiresIn.slice(0, -1), 10);
const now = new Date();
switch (unit) {
case 's':
return new Date(now.getTime() + value * 1000);
case 'm':
return new Date(now.getTime() + value * 60 * 1000);
case 'h':
return new Date(now.getTime() + value * 60 * 60 * 1000);
case 'd':
return new Date(now.getTime() + value * 24 * 60 * 60 * 1000);
default:
throw new Error(`Invalid time unit: ${unit}`);
}
}
/**
* Calculates remaining TTL in seconds for a given expiration date
* @param expiresAt - Expiration date
* @returns number - Remaining seconds (0 if already expired)
*/
private calculateRemainingTTL(expiresAt: Date): number {
const now = new Date();
const remainingMs = expiresAt.getTime() - now.getTime();
return Math.max(0, Math.floor(remainingMs / 1000));
}
}
// ===== Export Singleton Instance =====
export const tokenService = new TokenService();

View File

@ -0,0 +1,409 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockRepository, createMockQueryBuilder } from '../../../__tests__/helpers.js';
// Mock factories for billing entities
function createMockCoupon(overrides: Record<string, any> = {}) {
return {
id: 'coupon-uuid-1',
code: 'SAVE20',
name: '20% Discount',
description: 'Get 20% off your subscription',
discountType: 'percentage',
discountValue: 20,
currency: 'MXN',
applicablePlans: [],
minAmount: 0,
durationPeriod: 'once',
durationMonths: null,
maxRedemptions: 100,
currentRedemptions: 10,
validFrom: new Date('2024-01-01'),
validUntil: new Date('2030-12-31'),
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function createMockCouponRedemption(overrides: Record<string, any> = {}) {
return {
id: 'redemption-uuid-1',
couponId: 'coupon-uuid-1',
tenantId: 'tenant-uuid-1',
subscriptionId: 'subscription-uuid-1',
discountAmount: 200,
expiresAt: null,
createdAt: new Date(),
...overrides,
};
}
// Mock repositories
const mockCouponRepository = createMockRepository();
const mockRedemptionRepository = createMockRepository();
const mockSubscriptionRepository = createMockRepository();
const mockQueryBuilder = createMockQueryBuilder();
// Mock transaction manager
const mockManager = {
save: jest.fn().mockResolvedValue({}),
};
// Mock DataSource with transaction
const mockDataSource = {
getRepository: jest.fn((entity: any) => {
const entityName = entity.name || entity;
if (entityName === 'Coupon') return mockCouponRepository;
if (entityName === 'CouponRedemption') return mockRedemptionRepository;
if (entityName === 'TenantSubscription') return mockSubscriptionRepository;
return mockCouponRepository;
}),
transaction: jest.fn((callback: (manager: any) => Promise<void>) => callback(mockManager)),
};
jest.mock('../../../shared/utils/logger.js', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
// Import after mocking
import { CouponsService } from '../services/coupons.service.js';
describe('CouponsService', () => {
let service: CouponsService;
beforeEach(() => {
jest.clearAllMocks();
service = new CouponsService(mockDataSource as any);
mockCouponRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
});
describe('create', () => {
it('should create a new coupon successfully', async () => {
const dto = {
code: 'NEWCODE',
name: 'New Discount',
discountType: 'percentage' as const,
discountValue: 15,
validFrom: new Date(),
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
};
const mockCoupon = createMockCoupon({ ...dto, id: 'new-coupon-uuid', code: 'NEWCODE' });
mockCouponRepository.findOne.mockResolvedValue(null);
mockCouponRepository.create.mockReturnValue(mockCoupon);
mockCouponRepository.save.mockResolvedValue(mockCoupon);
const result = await service.create(dto);
expect(result.code).toBe('NEWCODE');
expect(mockCouponRepository.create).toHaveBeenCalled();
expect(mockCouponRepository.save).toHaveBeenCalled();
});
it('should throw error if coupon code already exists', async () => {
const dto = {
code: 'EXISTING',
name: 'Existing Discount',
discountType: 'percentage' as const,
discountValue: 10,
validFrom: new Date(),
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
};
mockCouponRepository.findOne.mockResolvedValue(createMockCoupon({ code: 'EXISTING' }));
await expect(service.create(dto)).rejects.toThrow('Coupon with code EXISTING already exists');
});
});
describe('findByCode', () => {
it('should find a coupon by code', async () => {
const mockCoupon = createMockCoupon({ code: 'TESTCODE' });
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
const result = await service.findByCode('TESTCODE');
expect(result).toBeDefined();
expect(result?.code).toBe('TESTCODE');
expect(mockCouponRepository.findOne).toHaveBeenCalledWith({
where: { code: 'TESTCODE' },
});
});
it('should return null if coupon not found', async () => {
mockCouponRepository.findOne.mockResolvedValue(null);
const result = await service.findByCode('NOTFOUND');
expect(result).toBeNull();
});
});
describe('validateCoupon', () => {
it('should validate an active coupon successfully', async () => {
const mockCoupon = createMockCoupon({
code: 'VALID',
isActive: true,
validFrom: new Date('2023-01-01'),
validUntil: new Date('2030-12-31'),
maxRedemptions: 100,
currentRedemptions: 10,
applicablePlans: [],
minAmount: 0,
});
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
mockRedemptionRepository.findOne.mockResolvedValue(null);
const result = await service.validateCoupon('VALID', 'tenant-uuid-1', 'plan-uuid-1', 1000);
expect(result.success).toBe(true);
expect(result.message).toBe('Cupón válido');
});
it('should reject inactive coupon', async () => {
const mockCoupon = createMockCoupon({ isActive: false });
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
const result = await service.validateCoupon('INACTIVE', 'tenant-uuid-1', 'plan-uuid-1', 1000);
expect(result.success).toBe(false);
expect(result.message).toBe('Cupón inactivo');
});
it('should reject coupon not yet valid', async () => {
const mockCoupon = createMockCoupon({
isActive: true,
validFrom: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // Future date
});
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
const result = await service.validateCoupon('FUTURE', 'tenant-uuid-1', 'plan-uuid-1', 1000);
expect(result.success).toBe(false);
expect(result.message).toBe('Cupón aún no válido');
});
it('should reject expired coupon', async () => {
const mockCoupon = createMockCoupon({
isActive: true,
validFrom: new Date('2020-01-01'),
validUntil: new Date('2020-12-31'), // Past date
});
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
const result = await service.validateCoupon('EXPIRED', 'tenant-uuid-1', 'plan-uuid-1', 1000);
expect(result.success).toBe(false);
expect(result.message).toBe('Cupón expirado');
});
it('should reject coupon exceeding max redemptions', async () => {
const mockCoupon = createMockCoupon({
isActive: true,
validFrom: new Date('2023-01-01'),
validUntil: new Date('2030-12-31'),
maxRedemptions: 10,
currentRedemptions: 10,
});
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
const result = await service.validateCoupon('MAXED', 'tenant-uuid-1', 'plan-uuid-1', 1000);
expect(result.success).toBe(false);
expect(result.message).toBe('Cupón agotado');
});
it('should reject if tenant already redeemed', async () => {
const mockCoupon = createMockCoupon({
isActive: true,
validFrom: new Date('2023-01-01'),
validUntil: new Date('2030-12-31'),
maxRedemptions: 100,
currentRedemptions: 10,
});
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
mockRedemptionRepository.findOne.mockResolvedValue(createMockCouponRedemption());
const result = await service.validateCoupon('ONCEONLY', 'tenant-uuid-1', 'plan-uuid-1', 1000);
expect(result.success).toBe(false);
expect(result.message).toBe('Cupón ya utilizado');
});
it('should reject if coupon not found', async () => {
mockCouponRepository.findOne.mockResolvedValue(null);
const result = await service.validateCoupon('NOTFOUND', 'tenant-uuid-1', 'plan-uuid-1', 1000);
expect(result.success).toBe(false);
expect(result.message).toBe('Cupón no encontrado');
});
});
describe('applyCoupon', () => {
it('should apply percentage discount correctly', async () => {
const mockCoupon = createMockCoupon({
id: 'coupon-uuid-1',
discountType: 'percentage',
discountValue: 20,
isActive: true,
validFrom: new Date('2023-01-01'),
validUntil: new Date('2030-12-31'),
maxRedemptions: 100,
currentRedemptions: 10,
applicablePlans: [],
minAmount: 0,
});
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
mockRedemptionRepository.findOne.mockResolvedValue(null); // No existing redemption
mockRedemptionRepository.create.mockReturnValue(createMockCouponRedemption({ discountAmount: 200 }));
const result = await service.applyCoupon('SAVE20', 'tenant-uuid-1', 'subscription-uuid-1', 1000);
expect(result.discountAmount).toBe(200); // 20% of 1000
expect(mockManager.save).toHaveBeenCalled();
});
it('should apply fixed discount correctly', async () => {
const mockCoupon = createMockCoupon({
id: 'coupon-uuid-1',
discountType: 'fixed',
discountValue: 150,
isActive: true,
validFrom: new Date('2023-01-01'),
validUntil: new Date('2030-12-31'),
maxRedemptions: 100,
currentRedemptions: 10,
applicablePlans: [],
minAmount: 0,
});
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
mockRedemptionRepository.findOne.mockResolvedValue(null);
mockRedemptionRepository.create.mockReturnValue(createMockCouponRedemption({ discountAmount: 150 }));
const result = await service.applyCoupon('FIXED150', 'tenant-uuid-1', 'subscription-uuid-1', 1000);
expect(result.discountAmount).toBe(150);
});
it('should throw error if coupon is invalid', async () => {
mockCouponRepository.findOne.mockResolvedValue(null);
await expect(
service.applyCoupon('INVALID', 'tenant-uuid-1', 'subscription-uuid-1', 1000)
).rejects.toThrow('Cupón no encontrado');
});
});
describe('findAll', () => {
it('should return all coupons', async () => {
const mockCoupons = [
createMockCoupon({ code: 'CODE1' }),
createMockCoupon({ code: 'CODE2' }),
];
mockQueryBuilder.getMany.mockResolvedValue(mockCoupons);
const result = await service.findAll();
expect(result).toHaveLength(2);
});
it('should filter by active status', async () => {
const mockCoupons = [createMockCoupon({ isActive: true })];
mockQueryBuilder.getMany.mockResolvedValue(mockCoupons);
await service.findAll({ isActive: true });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('coupon.isActive = :isActive', { isActive: true });
});
});
describe('getStats', () => {
it('should return coupon statistics', async () => {
const mockCoupon = createMockCoupon({
maxRedemptions: 100,
currentRedemptions: 25,
});
const mockRedemptions = [
createMockCouponRedemption({ discountAmount: 200 }),
createMockCouponRedemption({ discountAmount: 300 }),
];
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
mockRedemptionRepository.find.mockResolvedValue(mockRedemptions);
const result = await service.getStats('coupon-uuid-1');
expect(result.totalRedemptions).toBe(2);
expect(result.totalDiscountGiven).toBe(500);
});
it('should throw error if coupon not found', async () => {
mockCouponRepository.findOne.mockResolvedValue(null);
await expect(service.getStats('nonexistent')).rejects.toThrow('Coupon not found');
});
});
describe('deactivate', () => {
it('should deactivate a coupon', async () => {
const mockCoupon = createMockCoupon({ isActive: true });
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
mockCouponRepository.save.mockResolvedValue({ ...mockCoupon, isActive: false });
const result = await service.deactivate('coupon-uuid-1');
expect(result.isActive).toBe(false);
expect(mockCouponRepository.save).toHaveBeenCalled();
});
it('should throw error if coupon not found', async () => {
mockCouponRepository.findOne.mockResolvedValue(null);
await expect(service.deactivate('nonexistent')).rejects.toThrow('Coupon not found');
});
});
describe('update', () => {
it('should update coupon properties', async () => {
const mockCoupon = createMockCoupon({ name: 'Old Name' });
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
mockCouponRepository.save.mockResolvedValue({ ...mockCoupon, name: 'New Name' });
const result = await service.update('coupon-uuid-1', { name: 'New Name' });
expect(result.name).toBe('New Name');
});
it('should throw error if coupon not found', async () => {
mockCouponRepository.findOne.mockResolvedValue(null);
await expect(service.update('nonexistent', { name: 'New' })).rejects.toThrow('Coupon not found');
});
});
describe('getActiveRedemptions', () => {
it('should return active redemptions for tenant', async () => {
const mockRedemptions = [
createMockCouponRedemption({ expiresAt: null }),
createMockCouponRedemption({ expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) }),
];
mockRedemptionRepository.find.mockResolvedValue(mockRedemptions);
const result = await service.getActiveRedemptions('tenant-uuid-1');
expect(result).toHaveLength(2);
});
});
});

View File

@ -0,0 +1,360 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DataSource, Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { InvoicesService } from '../services/invoices.service';
import { Invoice, InvoiceItem, InvoiceStatus, PaymentStatus } from '../entities';
import { CreateInvoiceDto, UpdateInvoiceDto } from '../dto';
describe('InvoicesService', () => {
let service: InvoicesService;
let invoiceRepository: Repository<Invoice>;
let invoiceItemRepository: Repository<InvoiceItem>;
let dataSource: DataSource;
const mockInvoice = {
id: 'uuid-1',
tenantId: 'tenant-1',
customerId: 'customer-1',
number: 'INV-2024-001',
status: InvoiceStatus.DRAFT,
paymentStatus: PaymentStatus.PENDING,
issueDate: new Date('2024-01-01'),
dueDate: new Date('2024-01-15'),
subtotal: 1000,
taxAmount: 160,
totalAmount: 1160,
currency: 'USD',
notes: null,
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
};
const mockInvoiceItem = {
id: 'item-1',
invoiceId: 'uuid-1',
productId: 'product-1',
description: 'Test Product',
quantity: 2,
unitPrice: 500,
discount: 0,
taxRate: 0.08,
total: 1080,
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
InvoicesService,
{
provide: DataSource,
useValue: {
getRepository: jest.fn(),
query: jest.fn(),
},
},
],
}).compile();
service = module.get<InvoicesService>(InvoicesService);
dataSource = module.get<DataSource>(DataSource);
invoiceRepository = module.get<Repository<Invoice>>(
getRepositoryToken(Invoice),
);
invoiceItemRepository = module.get<Repository<InvoiceItem>>(
getRepositoryToken(InvoiceItem),
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('should create a new invoice successfully', async () => {
const dto: CreateInvoiceDto = {
customerId: 'customer-1',
issueDate: new Date('2024-01-01'),
dueDate: new Date('2024-01-15'),
items: [
{
productId: 'product-1',
description: 'Test Product',
quantity: 2,
unitPrice: 500,
discount: 0,
taxRate: 0.08,
},
],
notes: 'Test invoice',
};
jest.spyOn(invoiceRepository, 'create').mockReturnValue(mockInvoice as any);
jest.spyOn(invoiceRepository, 'save').mockResolvedValue(mockInvoice);
jest.spyOn(invoiceItemRepository, 'create').mockReturnValue(mockInvoiceItem as any);
jest.spyOn(invoiceItemRepository, 'save').mockResolvedValue(mockInvoiceItem);
const result = await service.create(dto);
expect(invoiceRepository.create).toHaveBeenCalled();
expect(invoiceRepository.save).toHaveBeenCalled();
expect(invoiceItemRepository.create).toHaveBeenCalled();
expect(invoiceItemRepository.save).toHaveBeenCalled();
expect(result).toEqual(mockInvoice);
});
it('should calculate totals correctly', async () => {
const dto: CreateInvoiceDto = {
customerId: 'customer-1',
issueDate: new Date('2024-01-01'),
dueDate: new Date('2024-01-15'),
items: [
{
productId: 'product-1',
description: 'Test Product 1',
quantity: 2,
unitPrice: 500,
discount: 50,
taxRate: 0.08,
},
{
productId: 'product-2',
description: 'Test Product 2',
quantity: 1,
unitPrice: 300,
discount: 0,
taxRate: 0.08,
},
],
};
const expectedInvoice = {
...mockInvoice,
subtotal: 1000,
taxAmount: 120,
totalAmount: 1120,
};
jest.spyOn(invoiceRepository, 'create').mockReturnValue(expectedInvoice as any);
jest.spyOn(invoiceRepository, 'save').mockResolvedValue(expectedInvoice);
jest.spyOn(invoiceItemRepository, 'create').mockReturnValue(mockInvoiceItem as any);
jest.spyOn(invoiceItemRepository, 'save').mockResolvedValue(mockInvoiceItem);
const result = await service.create(dto);
expect(result.subtotal).toBe(1000);
expect(result.taxAmount).toBe(120);
expect(result.totalAmount).toBe(1120);
});
});
describe('findById', () => {
it('should find invoice by id', async () => {
jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(mockInvoice as any);
const result = await service.findById('uuid-1');
expect(invoiceRepository.findOne).toHaveBeenCalledWith({
where: { id: 'uuid-1' },
relations: ['items'],
});
expect(result).toEqual(mockInvoice);
});
it('should return null if invoice not found', async () => {
jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(null);
const result = await service.findById('invalid-id');
expect(result).toBeNull();
});
});
describe('findByTenant', () => {
it('should find invoices by tenant', async () => {
const mockInvoices = [mockInvoice, { ...mockInvoice, id: 'uuid-2' }];
jest.spyOn(invoiceRepository, 'find').mockResolvedValue(mockInvoices as any);
const result = await service.findByTenant('tenant-1', {
page: 1,
limit: 10,
});
expect(invoiceRepository.find).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1' },
relations: ['items'],
order: { createdAt: 'DESC' },
skip: 0,
take: 10,
});
expect(result).toEqual(mockInvoices);
});
});
describe('update', () => {
it('should update invoice successfully', async () => {
const dto: UpdateInvoiceDto = {
status: InvoiceStatus.SENT,
notes: 'Updated notes',
};
const updatedInvoice = { ...mockInvoice, status: InvoiceStatus.SENT, notes: 'Updated notes' };
jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(mockInvoice as any);
jest.spyOn(invoiceRepository, 'save').mockResolvedValue(updatedInvoice as any);
const result = await service.update('uuid-1', dto);
expect(invoiceRepository.findOne).toHaveBeenCalledWith({
where: { id: 'uuid-1' },
});
expect(invoiceRepository.save).toHaveBeenCalled();
expect(result.status).toBe(InvoiceStatus.SENT);
expect(result.notes).toBe('Updated notes');
});
it('should throw error if invoice not found', async () => {
const dto: UpdateInvoiceDto = {
status: InvoiceStatus.SENT,
};
jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(null);
await expect(service.update('invalid-id', dto)).rejects.toThrow('Invoice not found');
});
});
describe('updateStatus', () => {
it('should update invoice status', async () => {
jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(mockInvoice as any);
jest.spyOn(invoiceRepository, 'save').mockResolvedValue({
...mockInvoice,
status: InvoiceStatus.PAID,
} as any);
const result = await service.updateStatus('uuid-1', InvoiceStatus.PAID);
expect(result.status).toBe(InvoiceStatus.PAID);
});
});
describe('updatePaymentStatus', () => {
it('should update payment status', async () => {
jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(mockInvoice as any);
jest.spyOn(invoiceRepository, 'save').mockResolvedValue({
...mockInvoice,
paymentStatus: PaymentStatus.PAID,
} as any);
const result = await service.updatePaymentStatus('uuid-1', PaymentStatus.PAID);
expect(result.paymentStatus).toBe(PaymentStatus.PAID);
});
});
describe('delete', () => {
it('should delete invoice successfully', async () => {
jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(mockInvoice as any);
jest.spyOn(invoiceRepository, 'remove').mockResolvedValue(undefined);
await service.delete('uuid-1');
expect(invoiceRepository.remove).toHaveBeenCalledWith(mockInvoice);
});
it('should throw error if invoice not found', async () => {
jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(null);
await expect(service.delete('invalid-id')).rejects.toThrow('Invoice not found');
});
});
describe('addItem', () => {
it('should add item to invoice', async () => {
const itemDto = {
productId: 'product-2',
description: 'New Product',
quantity: 1,
unitPrice: 300,
discount: 0,
taxRate: 0.08,
};
jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(mockInvoice as any);
jest.spyOn(invoiceItemRepository, 'create').mockReturnValue(mockInvoiceItem as any);
jest.spyOn(invoiceItemRepository, 'save').mockResolvedValue(mockInvoiceItem);
jest.spyOn(invoiceRepository, 'save').mockResolvedValue({
...mockInvoice,
subtotal: 1500,
taxAmount: 120,
totalAmount: 1620,
} as any);
const result = await service.addItem('uuid-1', itemDto);
expect(invoiceItemRepository.create).toHaveBeenCalled();
expect(invoiceItemRepository.save).toHaveBeenCalled();
expect(result.totalAmount).toBe(1620);
});
});
describe('removeItem', () => {
it('should remove item from invoice', async () => {
jest.spyOn(invoiceItemRepository, 'findOne').mockResolvedValue(mockInvoiceItem as any);
jest.spyOn(invoiceItemRepository, 'remove').mockResolvedValue(undefined);
jest.spyOn(invoiceRepository, 'save').mockResolvedValue({
...mockInvoice,
subtotal: 500,
taxAmount: 40,
totalAmount: 540,
} as any);
const result = await service.removeItem('uuid-1', 'item-1');
expect(invoiceItemRepository.remove).toHaveBeenCalled();
expect(result.totalAmount).toBe(540);
});
});
describe('sendInvoice', () => {
it('should mark invoice as sent', async () => {
jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(mockInvoice as any);
jest.spyOn(invoiceRepository, 'save').mockResolvedValue({
...mockInvoice,
status: InvoiceStatus.SENT,
sentAt: new Date(),
} as any);
const result = await service.sendInvoice('uuid-1');
expect(result.status).toBe(InvoiceStatus.SENT);
expect(result.sentAt).toBeDefined();
});
});
describe('calculateTotals', () => {
it('should calculate totals from items', () => {
const items = [
{ quantity: 2, unitPrice: 500, discount: 50, taxRate: 0.08 },
{ quantity: 1, unitPrice: 300, discount: 0, taxRate: 0.08 },
];
const totals = service.calculateTotals(items);
expect(totals.subtotal).toBe(1000);
expect(totals.taxAmount).toBe(120);
expect(totals.totalAmount).toBe(1120);
});
it('should handle empty items array', () => {
const totals = service.calculateTotals([]);
expect(totals.subtotal).toBe(0);
expect(totals.taxAmount).toBe(0);
expect(totals.totalAmount).toBe(0);
});
});
});

View File

@ -0,0 +1,786 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockRepository, createMockQueryBuilder } from '../../../__tests__/helpers.js';
// Mock factories
function createMockInvoice(overrides: Record<string, any> = {}) {
return {
id: 'invoice-uuid-1',
tenantId: 'tenant-uuid-1',
subscriptionId: 'sub-uuid-1',
invoiceNumber: 'INV-202601-0001',
invoiceDate: new Date('2026-01-15'),
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
billingName: 'Test Company',
billingEmail: 'billing@test.com',
billingAddress: { street: '123 Main St', city: 'Mexico City' },
taxId: 'RFC123456789',
subtotal: 499,
taxAmount: 79.84,
discountAmount: 0,
total: 578.84,
paidAmount: 0,
currency: 'MXN',
status: 'draft',
dueDate: new Date('2026-01-30'),
notes: '',
internalNotes: '',
items: [],
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function createMockInvoiceItem(overrides: Record<string, any> = {}) {
return {
id: 'item-uuid-1',
invoiceId: 'invoice-uuid-1',
itemType: 'subscription',
description: 'Suscripcion Starter - Mensual',
quantity: 1,
unitPrice: 499,
subtotal: 499,
metadata: {},
...overrides,
};
}
function createMockSubscription(overrides: Record<string, any> = {}) {
return {
id: 'sub-uuid-1',
tenantId: 'tenant-uuid-1',
planId: 'plan-uuid-1',
currentPrice: 499,
billingCycle: 'monthly',
contractedUsers: 10,
contractedBranches: 3,
billingName: 'Test Company',
billingEmail: 'billing@test.com',
billingAddress: { street: '123 Main St' },
taxId: 'RFC123456789',
plan: {
id: 'plan-uuid-1',
name: 'Starter',
maxUsers: 10,
maxBranches: 3,
storageGb: 20,
},
...overrides,
};
}
function createMockUsage(overrides: Record<string, any> = {}) {
return {
id: 'usage-uuid-1',
tenantId: 'tenant-uuid-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
activeUsers: 5,
activeBranches: 2,
storageUsedGb: 10,
apiCalls: 5000,
...overrides,
};
}
// Mock repositories
const mockInvoiceRepository = {
...createMockRepository(),
softDelete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const mockItemRepository = createMockRepository();
const mockSubscriptionRepository = createMockRepository();
const mockUsageRepository = createMockRepository();
const mockQueryBuilder = createMockQueryBuilder();
// Mock DataSource
const mockDataSource = {
getRepository: jest.fn((entity: any) => {
const entityName = entity.name || entity;
if (entityName === 'Invoice') return mockInvoiceRepository;
if (entityName === 'InvoiceItem') return mockItemRepository;
if (entityName === 'TenantSubscription') return mockSubscriptionRepository;
if (entityName === 'UsageTracking') return mockUsageRepository;
return mockInvoiceRepository;
}),
};
jest.mock('../../../shared/utils/logger.js', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
// Import after mocking
import { InvoicesService } from '../services/invoices.service.js';
describe('InvoicesService', () => {
let service: InvoicesService;
beforeEach(() => {
jest.clearAllMocks();
service = new InvoicesService(mockDataSource as any);
mockInvoiceRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
});
describe('create', () => {
it('should create invoice with items', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
subscriptionId: 'sub-uuid-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
billingName: 'Test Company',
billingEmail: 'billing@test.com',
dueDate: new Date('2026-01-30'),
items: [
{
itemType: 'subscription' as const,
description: 'Suscripcion Starter',
quantity: 1,
unitPrice: 499,
},
],
};
// Mock invoice number generation
mockQueryBuilder.getOne.mockResolvedValueOnce(null);
const mockInvoice = createMockInvoice({ ...dto, id: 'new-invoice-uuid' });
mockInvoiceRepository.create.mockReturnValue(mockInvoice);
mockInvoiceRepository.save.mockResolvedValue(mockInvoice);
mockItemRepository.create.mockReturnValue(createMockInvoiceItem());
mockItemRepository.save.mockResolvedValue(createMockInvoiceItem());
mockInvoiceRepository.findOne.mockResolvedValue({
...mockInvoice,
items: [createMockInvoiceItem()],
});
const result = await service.create(dto);
expect(mockInvoiceRepository.create).toHaveBeenCalled();
expect(mockInvoiceRepository.save).toHaveBeenCalled();
expect(mockItemRepository.create).toHaveBeenCalled();
expect(result.tenantId).toBe('tenant-uuid-1');
});
it('should calculate totals with tax', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
items: [
{
itemType: 'subscription' as const,
description: 'Plan',
quantity: 1,
unitPrice: 1000,
},
],
};
mockQueryBuilder.getOne.mockResolvedValueOnce(null);
mockInvoiceRepository.create.mockImplementation((data: any) => ({
...createMockInvoice(),
...data,
id: 'invoice-id',
}));
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv));
mockInvoiceRepository.findOne.mockImplementation((opts: any) => Promise.resolve({
...createMockInvoice(),
id: opts.where.id,
items: [],
}));
mockItemRepository.create.mockReturnValue(createMockInvoiceItem());
mockItemRepository.save.mockResolvedValue(createMockInvoiceItem());
await service.create(dto);
// Verify subtotal calculation (1000)
// Tax should be 16% = 160
// Total should be 1160
expect(mockInvoiceRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
subtotal: 1000,
taxAmount: 160,
total: 1160,
})
);
});
it('should apply item discounts', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
items: [
{
itemType: 'subscription' as const,
description: 'Plan',
quantity: 1,
unitPrice: 1000,
discountPercent: 10, // 10% off
},
],
};
mockQueryBuilder.getOne.mockResolvedValueOnce(null);
mockInvoiceRepository.create.mockImplementation((data: any) => data);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve({ ...inv, id: 'inv-id' }));
mockInvoiceRepository.findOne.mockResolvedValue(createMockInvoice());
mockItemRepository.create.mockReturnValue(createMockInvoiceItem());
mockItemRepository.save.mockResolvedValue(createMockInvoiceItem());
await service.create(dto);
// Subtotal after 10% discount: 1000 - 100 = 900
// Tax 16%: 144
// Total: 1044
expect(mockInvoiceRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
subtotal: 900,
taxAmount: 144,
total: 1044,
})
);
});
});
describe('generateFromSubscription', () => {
it('should generate invoice from subscription', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
subscriptionId: 'sub-uuid-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
};
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockQueryBuilder.getOne.mockResolvedValueOnce(null);
mockInvoiceRepository.create.mockImplementation((data: any) => ({
...createMockInvoice(),
...data,
}));
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve({ ...inv, id: 'inv-id' }));
mockInvoiceRepository.findOne.mockResolvedValue(createMockInvoice());
mockItemRepository.create.mockReturnValue(createMockInvoiceItem());
mockItemRepository.save.mockResolvedValue(createMockInvoiceItem());
const result = await service.generateFromSubscription(dto);
expect(mockSubscriptionRepository.findOne).toHaveBeenCalledWith({
where: { id: 'sub-uuid-1' },
relations: ['plan'],
});
expect(result).toBeDefined();
});
it('should throw error if subscription not found', async () => {
mockSubscriptionRepository.findOne.mockResolvedValue(null);
await expect(
service.generateFromSubscription({
tenantId: 'tenant-uuid-1',
subscriptionId: 'invalid-id',
periodStart: new Date(),
periodEnd: new Date(),
})
).rejects.toThrow('Subscription not found');
});
it('should include usage charges when requested', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
subscriptionId: 'sub-uuid-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
includeUsageCharges: true,
};
const mockSub = createMockSubscription();
const mockUsage = createMockUsage({
activeUsers: 15, // 5 extra users
activeBranches: 5, // 2 extra branches
storageUsedGb: 25, // 5 extra GB
});
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
mockQueryBuilder.getOne.mockResolvedValueOnce(null);
let createdItems: any[] = [];
mockInvoiceRepository.create.mockImplementation((data: any) => ({
...createMockInvoice(),
...data,
}));
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve({ ...inv, id: 'inv-id' }));
mockInvoiceRepository.findOne.mockResolvedValue(createMockInvoice());
mockItemRepository.create.mockImplementation((item: any) => {
createdItems.push(item);
return item;
});
mockItemRepository.save.mockImplementation((item: any) => Promise.resolve(item));
await service.generateFromSubscription(dto);
// Should have created items for: subscription + extra users + extra branches + extra storage
expect(createdItems.length).toBeGreaterThan(1);
expect(createdItems.some((i: any) => i.description.includes('Usuarios adicionales'))).toBe(true);
expect(createdItems.some((i: any) => i.description.includes('Sucursales adicionales'))).toBe(true);
expect(createdItems.some((i: any) => i.description.includes('Almacenamiento adicional'))).toBe(true);
});
});
describe('findById', () => {
it('should return invoice by id with items', async () => {
const mockInvoice = createMockInvoice();
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
const result = await service.findById('invoice-uuid-1');
expect(mockInvoiceRepository.findOne).toHaveBeenCalledWith({
where: { id: 'invoice-uuid-1' },
relations: ['items'],
});
expect(result?.id).toBe('invoice-uuid-1');
});
it('should return null if invoice not found', async () => {
mockInvoiceRepository.findOne.mockResolvedValue(null);
const result = await service.findById('non-existent');
expect(result).toBeNull();
});
});
describe('findByNumber', () => {
it('should return invoice by invoice number', async () => {
const mockInvoice = createMockInvoice();
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
const result = await service.findByNumber('INV-202601-0001');
expect(mockInvoiceRepository.findOne).toHaveBeenCalledWith({
where: { invoiceNumber: 'INV-202601-0001' },
relations: ['items'],
});
expect(result?.invoiceNumber).toBe('INV-202601-0001');
});
});
describe('findAll', () => {
it('should return invoices with filters', async () => {
const mockInvoices = [
createMockInvoice({ id: 'inv-1' }),
createMockInvoice({ id: 'inv-2' }),
];
mockQueryBuilder.getMany.mockResolvedValue(mockInvoices);
mockQueryBuilder.getCount.mockResolvedValue(2);
const result = await service.findAll({ tenantId: 'tenant-uuid-1' });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'invoice.tenantId = :tenantId',
{ tenantId: 'tenant-uuid-1' }
);
expect(result.data).toHaveLength(2);
expect(result.total).toBe(2);
});
it('should filter by status', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
mockQueryBuilder.getCount.mockResolvedValue(0);
await service.findAll({ status: 'paid' });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'invoice.status = :status',
{ status: 'paid' }
);
});
it('should filter by date range', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
mockQueryBuilder.getCount.mockResolvedValue(0);
const dateFrom = new Date('2026-01-01');
const dateTo = new Date('2026-01-31');
await service.findAll({ dateFrom, dateTo });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'invoice.invoiceDate >= :dateFrom',
{ dateFrom }
);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'invoice.invoiceDate <= :dateTo',
{ dateTo }
);
});
it('should filter overdue invoices', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
mockQueryBuilder.getCount.mockResolvedValue(0);
await service.findAll({ overdue: true });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'invoice.dueDate < :now',
expect.any(Object)
);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
"invoice.status IN ('sent', 'partial')"
);
});
it('should apply pagination', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
mockQueryBuilder.getCount.mockResolvedValue(100);
await service.findAll({ limit: 10, offset: 20 });
expect(mockQueryBuilder.take).toHaveBeenCalledWith(10);
expect(mockQueryBuilder.skip).toHaveBeenCalledWith(20);
});
});
describe('update', () => {
it('should update draft invoice', async () => {
const mockInvoice = createMockInvoice({ status: 'draft' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv));
const result = await service.update('invoice-uuid-1', { notes: 'Updated note' });
expect(result.notes).toBe('Updated note');
});
it('should throw error if invoice not found', async () => {
mockInvoiceRepository.findOne.mockResolvedValue(null);
await expect(service.update('invalid-id', { notes: 'test' })).rejects.toThrow(
'Invoice not found'
);
});
it('should throw error if invoice is not draft', async () => {
const mockInvoice = createMockInvoice({ status: 'sent' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
await expect(service.update('invoice-uuid-1', { notes: 'test' })).rejects.toThrow(
'Only draft invoices can be updated'
);
});
});
describe('send', () => {
it('should send draft invoice', async () => {
const mockInvoice = createMockInvoice({ status: 'draft' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv));
const result = await service.send('invoice-uuid-1');
expect(result.status).toBe('sent');
});
it('should throw error if invoice not found', async () => {
mockInvoiceRepository.findOne.mockResolvedValue(null);
await expect(service.send('invalid-id')).rejects.toThrow('Invoice not found');
});
it('should throw error if invoice is not draft', async () => {
const mockInvoice = createMockInvoice({ status: 'paid' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
await expect(service.send('invoice-uuid-1')).rejects.toThrow(
'Only draft invoices can be sent'
);
});
});
describe('recordPayment', () => {
it('should record full payment', async () => {
const mockInvoice = createMockInvoice({ status: 'sent', total: 578.84, paidAmount: 0 });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv));
const result = await service.recordPayment('invoice-uuid-1', {
amount: 578.84,
paymentMethod: 'card',
paymentReference: 'PAY-123',
});
expect(result.status).toBe('paid');
expect(result.paidAmount).toBe(578.84);
});
it('should record partial payment', async () => {
const mockInvoice = createMockInvoice({ status: 'sent', total: 578.84, paidAmount: 0 });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv));
const result = await service.recordPayment('invoice-uuid-1', {
amount: 300,
paymentMethod: 'transfer',
});
expect(result.status).toBe('partial');
expect(result.paidAmount).toBe(300);
});
it('should throw error if invoice not found', async () => {
mockInvoiceRepository.findOne.mockResolvedValue(null);
await expect(
service.recordPayment('invalid-id', { amount: 100, paymentMethod: 'card' })
).rejects.toThrow('Invoice not found');
});
it('should throw error for voided invoice', async () => {
const mockInvoice = createMockInvoice({ status: 'void' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
await expect(
service.recordPayment('invoice-uuid-1', { amount: 100, paymentMethod: 'card' })
).rejects.toThrow('Cannot record payment for voided or refunded invoice');
});
it('should throw error for refunded invoice', async () => {
const mockInvoice = createMockInvoice({ status: 'refunded' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
await expect(
service.recordPayment('invoice-uuid-1', { amount: 100, paymentMethod: 'card' })
).rejects.toThrow('Cannot record payment for voided or refunded invoice');
});
});
describe('void', () => {
it('should void draft invoice', async () => {
const mockInvoice = createMockInvoice({ status: 'draft' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv));
const result = await service.void('invoice-uuid-1', { reason: 'Created by mistake' });
expect(result.status).toBe('void');
expect(result.internalNotes).toContain('Voided: Created by mistake');
});
it('should void sent invoice', async () => {
const mockInvoice = createMockInvoice({ status: 'sent' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv));
const result = await service.void('invoice-uuid-1', { reason: 'Customer cancelled' });
expect(result.status).toBe('void');
});
it('should throw error if invoice not found', async () => {
mockInvoiceRepository.findOne.mockResolvedValue(null);
await expect(service.void('invalid-id', { reason: 'test' })).rejects.toThrow(
'Invoice not found'
);
});
it('should throw error for paid invoice', async () => {
const mockInvoice = createMockInvoice({ status: 'paid' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
await expect(service.void('invoice-uuid-1', { reason: 'test' })).rejects.toThrow(
'Cannot void paid or refunded invoice'
);
});
it('should throw error for already refunded invoice', async () => {
const mockInvoice = createMockInvoice({ status: 'refunded' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
await expect(service.void('invoice-uuid-1', { reason: 'test' })).rejects.toThrow(
'Cannot void paid or refunded invoice'
);
});
});
describe('refund', () => {
it('should refund paid invoice fully', async () => {
const mockInvoice = createMockInvoice({ status: 'paid', paidAmount: 578.84 });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv));
const result = await service.refund('invoice-uuid-1', { reason: 'Customer requested' });
expect(result.status).toBe('refunded');
expect(result.internalNotes).toContain('Refunded: 578.84');
});
it('should refund partial amount', async () => {
const mockInvoice = createMockInvoice({ status: 'paid', paidAmount: 578.84 });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv));
const result = await service.refund('invoice-uuid-1', {
amount: 200,
reason: 'Partial service',
});
expect(result.status).toBe('refunded');
expect(result.internalNotes).toContain('Refunded: 200');
});
it('should throw error if invoice not found', async () => {
mockInvoiceRepository.findOne.mockResolvedValue(null);
await expect(service.refund('invalid-id', { reason: 'test' })).rejects.toThrow(
'Invoice not found'
);
});
it('should throw error for unpaid invoice', async () => {
const mockInvoice = createMockInvoice({ status: 'draft' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
await expect(service.refund('invoice-uuid-1', { reason: 'test' })).rejects.toThrow(
'Only paid invoices can be refunded'
);
});
it('should throw error if refund amount exceeds paid amount', async () => {
const mockInvoice = createMockInvoice({ status: 'paid', paidAmount: 100 });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
await expect(
service.refund('invoice-uuid-1', { amount: 200, reason: 'test' })
).rejects.toThrow('Refund amount cannot exceed paid amount');
});
});
describe('markOverdueInvoices', () => {
it('should mark overdue invoices', async () => {
const mockUpdateBuilder = {
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({ affected: 5 }),
};
mockInvoiceRepository.createQueryBuilder.mockReturnValue(mockUpdateBuilder);
const result = await service.markOverdueInvoices();
expect(result).toBe(5);
expect(mockUpdateBuilder.set).toHaveBeenCalledWith({ status: 'overdue' });
});
it('should return 0 when no invoices are overdue', async () => {
const mockUpdateBuilder = {
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({ affected: 0 }),
};
mockInvoiceRepository.createQueryBuilder.mockReturnValue(mockUpdateBuilder);
const result = await service.markOverdueInvoices();
expect(result).toBe(0);
});
});
describe('getStats', () => {
it('should return invoice statistics', async () => {
const mockInvoices = [
createMockInvoice({ status: 'paid', paidAmount: 500, total: 500 }),
createMockInvoice({ status: 'paid', paidAmount: 300, total: 300 }),
createMockInvoice({ status: 'sent', paidAmount: 0, total: 400, dueDate: new Date('2025-01-01') }),
createMockInvoice({ status: 'draft', paidAmount: 0, total: 200 }),
];
mockQueryBuilder.getMany.mockResolvedValue(mockInvoices);
const result = await service.getStats('tenant-uuid-1');
expect(mockQueryBuilder.where).toHaveBeenCalledWith(
'invoice.tenantId = :tenantId',
{ tenantId: 'tenant-uuid-1' }
);
expect(result.total).toBe(4);
expect(result.byStatus.paid).toBe(2);
expect(result.byStatus.sent).toBe(1);
expect(result.byStatus.draft).toBe(1);
expect(result.totalRevenue).toBe(800);
expect(result.pendingAmount).toBe(400);
expect(result.overdueAmount).toBe(400); // The sent invoice is overdue
});
it('should return stats without tenant filter', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
const result = await service.getStats();
expect(mockQueryBuilder.where).not.toHaveBeenCalled();
expect(result.total).toBe(0);
});
});
describe('generateInvoiceNumber (via create)', () => {
it('should generate sequential invoice numbers', async () => {
// First invoice of the month
mockQueryBuilder.getOne.mockResolvedValueOnce(null);
const dto = {
tenantId: 'tenant-uuid-1',
items: [{ itemType: 'subscription' as const, description: 'Test', quantity: 1, unitPrice: 100 }],
};
mockInvoiceRepository.create.mockImplementation((data: any) => data);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve({ ...inv, id: 'inv-1' }));
mockInvoiceRepository.findOne.mockResolvedValue(createMockInvoice());
mockItemRepository.create.mockReturnValue(createMockInvoiceItem());
mockItemRepository.save.mockResolvedValue(createMockInvoiceItem());
await service.create(dto);
// Verify the invoice number format (INV-YYYYMM-0001)
expect(mockInvoiceRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
invoiceNumber: expect.stringMatching(/^INV-\d{6}-0001$/),
})
);
});
it('should increment sequence for existing invoices', async () => {
// Return existing invoice for the month
mockQueryBuilder.getOne.mockResolvedValueOnce(
createMockInvoice({ invoiceNumber: 'INV-202601-0005' })
);
const dto = {
tenantId: 'tenant-uuid-1',
items: [{ itemType: 'subscription' as const, description: 'Test', quantity: 1, unitPrice: 100 }],
};
mockInvoiceRepository.create.mockImplementation((data: any) => data);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve({ ...inv, id: 'inv-1' }));
mockInvoiceRepository.findOne.mockResolvedValue(createMockInvoice());
mockItemRepository.create.mockReturnValue(createMockInvoiceItem());
mockItemRepository.save.mockResolvedValue(createMockInvoiceItem());
await service.create(dto);
// Should be 0006
expect(mockInvoiceRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
invoiceNumber: expect.stringMatching(/^INV-\d{6}-0006$/),
})
);
});
});
});

View File

@ -0,0 +1,466 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockRepository, createMockQueryBuilder } from '../../../__tests__/helpers.js';
// Mock factories for billing entities
function createMockPlanLimit(overrides: Record<string, any> = {}) {
return {
id: 'limit-uuid-1',
planId: 'plan-uuid-1',
limitKey: 'users',
limitName: 'Active Users',
limitValue: 10,
limitType: 'monthly',
allowOverage: false,
overageUnitPrice: 0,
overageCurrency: 'MXN',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function createMockSubscriptionPlan(overrides: Record<string, any> = {}) {
return {
id: 'plan-uuid-1',
code: 'PRO',
name: 'Professional Plan',
description: 'Professional subscription plan',
monthlyPrice: 499,
annualPrice: 4990,
currency: 'MXN',
isActive: true,
displayOrder: 2,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function createMockSubscription(overrides: Record<string, any> = {}) {
return {
id: 'subscription-uuid-1',
tenantId: 'tenant-uuid-1',
planId: 'plan-uuid-1',
status: 'active',
currentPrice: 499,
billingCycle: 'monthly',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function createMockUsageTracking(overrides: Record<string, any> = {}) {
return {
id: 'usage-uuid-1',
tenantId: 'tenant-uuid-1',
periodStart: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
periodEnd: new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0),
activeUsers: 5,
storageUsedGb: 2.5,
apiCalls: 1000,
activeBranches: 2,
documentsCount: 150,
invoicesGenerated: 50,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// Mock repositories with extended methods
const mockLimitRepository = {
...createMockRepository(),
remove: jest.fn(),
};
const mockPlanRepository = createMockRepository();
const mockSubscriptionRepository = createMockRepository();
const mockUsageRepository = createMockRepository();
// Mock DataSource
const mockDataSource = {
getRepository: jest.fn((entity: any) => {
const entityName = entity.name || entity;
if (entityName === 'PlanLimit') return mockLimitRepository;
if (entityName === 'SubscriptionPlan') return mockPlanRepository;
if (entityName === 'TenantSubscription') return mockSubscriptionRepository;
if (entityName === 'UsageTracking') return mockUsageRepository;
return mockLimitRepository;
}),
};
jest.mock('../../../shared/utils/logger.js', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
// Import after mocking
import { PlanLimitsService } from '../services/plan-limits.service.js';
describe('PlanLimitsService', () => {
let service: PlanLimitsService;
const tenantId = 'tenant-uuid-1';
beforeEach(() => {
jest.clearAllMocks();
service = new PlanLimitsService(mockDataSource as any);
});
describe('create', () => {
it('should create a new plan limit successfully', async () => {
const dto = {
planId: 'plan-uuid-1',
limitKey: 'storage_gb',
limitName: 'Storage (GB)',
limitValue: 50,
limitType: 'fixed' as const,
};
const mockPlan = createMockSubscriptionPlan();
const mockLimit = createMockPlanLimit({ ...dto, id: 'new-limit-uuid' });
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
mockLimitRepository.findOne.mockResolvedValue(null);
mockLimitRepository.create.mockReturnValue(mockLimit);
mockLimitRepository.save.mockResolvedValue(mockLimit);
const result = await service.create(dto);
expect(result.limitKey).toBe('storage_gb');
expect(result.limitValue).toBe(50);
expect(mockLimitRepository.create).toHaveBeenCalled();
expect(mockLimitRepository.save).toHaveBeenCalled();
});
it('should throw error if plan not found', async () => {
mockPlanRepository.findOne.mockResolvedValue(null);
const dto = {
planId: 'nonexistent-plan',
limitKey: 'users',
limitName: 'Users',
limitValue: 10,
};
await expect(service.create(dto)).rejects.toThrow('Plan not found');
});
it('should throw error if limit key already exists for plan', async () => {
const mockPlan = createMockSubscriptionPlan();
const existingLimit = createMockPlanLimit({ limitKey: 'users' });
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
mockLimitRepository.findOne.mockResolvedValue(existingLimit);
const dto = {
planId: 'plan-uuid-1',
limitKey: 'users',
limitName: 'Users',
limitValue: 10,
};
await expect(service.create(dto)).rejects.toThrow('Limit users already exists for this plan');
});
});
describe('findByPlan', () => {
it('should return all limits for a plan', async () => {
const mockLimits = [
createMockPlanLimit({ limitKey: 'users', limitValue: 10 }),
createMockPlanLimit({ limitKey: 'storage_gb', limitValue: 50 }),
createMockPlanLimit({ limitKey: 'api_calls', limitValue: 10000 }),
];
mockLimitRepository.find.mockResolvedValue(mockLimits);
const result = await service.findByPlan('plan-uuid-1');
expect(result).toHaveLength(3);
expect(mockLimitRepository.find).toHaveBeenCalledWith({
where: { planId: 'plan-uuid-1' },
order: { limitKey: 'ASC' },
});
});
});
describe('findByKey', () => {
it('should find a specific limit by key', async () => {
const mockLimit = createMockPlanLimit({ limitKey: 'users' });
mockLimitRepository.findOne.mockResolvedValue(mockLimit);
const result = await service.findByKey('plan-uuid-1', 'users');
expect(result).toBeDefined();
expect(result?.limitKey).toBe('users');
});
it('should return null if limit not found', async () => {
mockLimitRepository.findOne.mockResolvedValue(null);
const result = await service.findByKey('plan-uuid-1', 'nonexistent');
expect(result).toBeNull();
});
});
describe('update', () => {
it('should update a plan limit', async () => {
const mockLimit = createMockPlanLimit({ limitValue: 10 });
mockLimitRepository.findOne.mockResolvedValue(mockLimit);
mockLimitRepository.save.mockResolvedValue({ ...mockLimit, limitValue: 20 });
const result = await service.update('limit-uuid-1', { limitValue: 20 });
expect(result.limitValue).toBe(20);
});
it('should throw error if limit not found', async () => {
mockLimitRepository.findOne.mockResolvedValue(null);
await expect(service.update('nonexistent', { limitValue: 20 })).rejects.toThrow('Limit not found');
});
});
describe('delete', () => {
it('should delete a plan limit', async () => {
const mockLimit = createMockPlanLimit();
mockLimitRepository.findOne.mockResolvedValue(mockLimit);
mockLimitRepository.remove.mockResolvedValue(mockLimit);
await expect(service.delete('limit-uuid-1')).resolves.not.toThrow();
expect(mockLimitRepository.remove).toHaveBeenCalledWith(mockLimit);
});
it('should throw error if limit not found', async () => {
mockLimitRepository.findOne.mockResolvedValue(null);
await expect(service.delete('nonexistent')).rejects.toThrow('Limit not found');
});
});
describe('getTenantLimits', () => {
it('should return limits for tenant with active subscription', async () => {
const mockSubscription = createMockSubscription({ planId: 'pro-plan' });
const mockLimits = [
createMockPlanLimit({ limitKey: 'users', limitValue: 25 }),
createMockPlanLimit({ limitKey: 'storage_gb', limitValue: 100 }),
];
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockLimitRepository.find.mockResolvedValue(mockLimits);
const result = await service.getTenantLimits(tenantId);
expect(result).toHaveLength(2);
expect(mockSubscriptionRepository.findOne).toHaveBeenCalledWith({
where: { tenantId, status: 'active' },
});
});
it('should return free plan limits if no active subscription', async () => {
const mockFreePlan = createMockSubscriptionPlan({ id: 'free-plan', code: 'FREE' });
const mockLimits = [createMockPlanLimit({ limitKey: 'users', limitValue: 3 })];
mockSubscriptionRepository.findOne.mockResolvedValue(null);
mockPlanRepository.findOne.mockResolvedValue(mockFreePlan);
mockLimitRepository.find.mockResolvedValue(mockLimits);
const result = await service.getTenantLimits(tenantId);
expect(result).toHaveLength(1);
expect(result[0].limitValue).toBe(3);
});
it('should return empty array if no subscription and no free plan', async () => {
mockSubscriptionRepository.findOne.mockResolvedValue(null);
mockPlanRepository.findOne.mockResolvedValue(null);
const result = await service.getTenantLimits(tenantId);
expect(result).toEqual([]);
});
});
describe('getTenantLimit', () => {
it('should return specific limit value for tenant', async () => {
const mockSubscription = createMockSubscription();
const mockLimits = [createMockPlanLimit({ limitKey: 'users', limitValue: 10 })];
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockLimitRepository.find.mockResolvedValue(mockLimits);
const result = await service.getTenantLimit(tenantId, 'users');
expect(result).toBe(10);
});
it('should return 0 if limit not found', async () => {
mockSubscriptionRepository.findOne.mockResolvedValue(null);
mockPlanRepository.findOne.mockResolvedValue(null);
const result = await service.getTenantLimit(tenantId, 'nonexistent');
expect(result).toBe(0);
});
});
describe('checkUsage', () => {
it('should allow usage within limits', async () => {
const mockSubscription = createMockSubscription();
const mockLimits = [createMockPlanLimit({ limitKey: 'users', limitValue: 10, allowOverage: false })];
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockLimitRepository.find.mockResolvedValue(mockLimits);
const result = await service.checkUsage(tenantId, 'users', 5, 1);
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(4);
expect(result.message).toBe('Dentro del límite');
});
it('should reject usage exceeding limits when overage not allowed', async () => {
const mockSubscription = createMockSubscription();
const mockLimits = [createMockPlanLimit({ limitKey: 'users', limitValue: 10, allowOverage: false })];
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockLimitRepository.find.mockResolvedValue(mockLimits);
const result = await service.checkUsage(tenantId, 'users', 10, 1);
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
expect(result.message).toContain('Límite alcanzado');
});
it('should allow overage when configured', async () => {
const mockSubscription = createMockSubscription();
const mockLimits = [
createMockPlanLimit({
limitKey: 'users',
limitValue: 10,
allowOverage: true,
overageUnitPrice: 50,
}),
];
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockLimitRepository.find.mockResolvedValue(mockLimits);
const result = await service.checkUsage(tenantId, 'users', 10, 2);
expect(result.allowed).toBe(true);
expect(result.overageAllowed).toBe(true);
expect(result.overageUnits).toBe(2);
expect(result.overageCost).toBe(100); // 2 * 50
});
it('should allow unlimited when no limit defined', async () => {
mockSubscriptionRepository.findOne.mockResolvedValue(null);
mockPlanRepository.findOne.mockResolvedValue(null);
const result = await service.checkUsage(tenantId, 'nonexistent', 1000, 100);
expect(result.allowed).toBe(true);
expect(result.limit).toBe(-1);
expect(result.remaining).toBe(-1);
});
});
describe('getCurrentUsage', () => {
it('should return current usage for a limit key', async () => {
const mockUsage = createMockUsageTracking({ activeUsers: 7 });
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
const result = await service.getCurrentUsage(tenantId, 'users');
expect(result).toBe(7);
});
it('should return 0 if no usage record found', async () => {
mockUsageRepository.findOne.mockResolvedValue(null);
const result = await service.getCurrentUsage(tenantId, 'users');
expect(result).toBe(0);
});
it('should return correct value for different limit keys', async () => {
const mockUsage = createMockUsageTracking({
activeUsers: 5,
storageUsedGb: 10,
apiCalls: 5000,
});
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
expect(await service.getCurrentUsage(tenantId, 'users')).toBe(5);
expect(await service.getCurrentUsage(tenantId, 'storage_gb')).toBe(10);
expect(await service.getCurrentUsage(tenantId, 'api_calls')).toBe(5000);
});
});
describe('validateAllLimits', () => {
it('should return valid when all limits OK', async () => {
const mockSubscription = createMockSubscription();
const mockLimits = [
createMockPlanLimit({ limitKey: 'users', limitValue: 10 }),
createMockPlanLimit({ limitKey: 'storage_gb', limitValue: 50 }),
];
const mockUsage = createMockUsageTracking({ activeUsers: 5, storageUsedGb: 20 });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockLimitRepository.find.mockResolvedValue(mockLimits);
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
const result = await service.validateAllLimits(tenantId);
expect(result.valid).toBe(true);
expect(result.violations).toHaveLength(0);
});
it('should return violations when limits exceeded', async () => {
const mockSubscription = createMockSubscription();
const mockLimits = [
createMockPlanLimit({ limitKey: 'users', limitValue: 5, allowOverage: false }),
];
const mockUsage = createMockUsageTracking({ activeUsers: 10 });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockLimitRepository.find.mockResolvedValue(mockLimits);
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
const result = await service.validateAllLimits(tenantId);
expect(result.valid).toBe(false);
expect(result.violations).toHaveLength(1);
expect(result.violations[0].limitKey).toBe('users');
});
});
describe('copyLimitsFromPlan', () => {
it('should copy all limits from source to target plan', async () => {
const sourceLimits = [
createMockPlanLimit({ id: 'limit-1', limitKey: 'users', limitValue: 10 }),
createMockPlanLimit({ id: 'limit-2', limitKey: 'storage_gb', limitValue: 50 }),
];
const targetPlan = createMockSubscriptionPlan({ id: 'target-plan' });
mockLimitRepository.find.mockResolvedValue(sourceLimits);
mockPlanRepository.findOne.mockResolvedValue(targetPlan);
mockLimitRepository.findOne.mockResolvedValue(null); // No existing limits
mockLimitRepository.create.mockImplementation((data) => data as any);
mockLimitRepository.save.mockImplementation((data) => Promise.resolve({ ...data, id: 'new-limit' }));
const result = await service.copyLimitsFromPlan('source-plan', 'target-plan');
expect(result).toHaveLength(2);
expect(mockLimitRepository.create).toHaveBeenCalledTimes(2);
});
});
});

View File

@ -0,0 +1,597 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockRepository } from '../../../__tests__/helpers.js';
// Mock factories for Stripe entities
function createMockStripeEvent(overrides: Record<string, any> = {}) {
return {
id: 'event-uuid-1',
stripeEventId: 'evt_1234567890',
eventType: 'customer.subscription.created',
apiVersion: '2023-10-16',
data: {
object: {
id: 'sub_123',
customer: 'cus_123',
status: 'active',
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
},
},
processed: false,
processedAt: null,
retryCount: 0,
errorMessage: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function createMockSubscription(overrides: Record<string, any> = {}) {
return {
id: 'subscription-uuid-1',
tenantId: 'tenant-uuid-1',
planId: 'plan-uuid-1',
status: 'active',
stripeCustomerId: 'cus_123',
stripeSubscriptionId: 'sub_123',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
billingCycle: 'monthly',
currentPrice: 499,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// Mock repositories
const mockEventRepository = createMockRepository();
const mockSubscriptionRepository = createMockRepository();
// Mock DataSource
const mockDataSource = {
getRepository: jest.fn((entity: any) => {
const entityName = entity.name || entity;
if (entityName === 'StripeEvent') return mockEventRepository;
if (entityName === 'TenantSubscription') return mockSubscriptionRepository;
return mockEventRepository;
}),
};
jest.mock('../../../shared/utils/logger.js', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
// Import after mocking
import { StripeWebhookService, StripeWebhookPayload } from '../services/stripe-webhook.service.js';
describe('StripeWebhookService', () => {
let service: StripeWebhookService;
beforeEach(() => {
jest.clearAllMocks();
service = new StripeWebhookService(mockDataSource as any);
});
describe('processWebhook', () => {
it('should process a new webhook event successfully', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_new_event',
type: 'customer.subscription.created',
api_version: '2023-10-16',
data: {
object: {
id: 'sub_new',
customer: 'cus_123',
status: 'active',
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const mockEvent = createMockStripeEvent({ stripeEventId: 'evt_new_event' });
const mockSubscription = createMockSubscription();
mockEventRepository.findOne.mockResolvedValue(null); // No existing event
mockEventRepository.create.mockReturnValue(mockEvent);
mockEventRepository.save.mockResolvedValue(mockEvent);
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockResolvedValue(mockSubscription);
const result = await service.processWebhook(payload);
expect(result.success).toBe(true);
expect(result.message).toBe('Event processed successfully');
expect(mockEventRepository.create).toHaveBeenCalled();
});
it('should return success for already processed event', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_already_processed',
type: 'customer.subscription.created',
data: { object: {} },
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const existingEvent = createMockStripeEvent({
stripeEventId: 'evt_already_processed',
processed: true,
});
mockEventRepository.findOne.mockResolvedValue(existingEvent);
const result = await service.processWebhook(payload);
expect(result.success).toBe(true);
expect(result.message).toBe('Event already processed');
});
it('should retry processing for failed event', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_failed',
type: 'customer.subscription.created',
data: {
object: {
id: 'sub_retry',
customer: 'cus_123',
status: 'active',
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const failedEvent = createMockStripeEvent({
stripeEventId: 'evt_failed',
processed: false,
retryCount: 1,
data: payload.data,
});
const mockSubscription = createMockSubscription();
mockEventRepository.findOne.mockResolvedValue(failedEvent);
mockEventRepository.save.mockResolvedValue(failedEvent);
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockResolvedValue(mockSubscription);
const result = await service.processWebhook(payload);
expect(result.success).toBe(true);
expect(result.message).toBe('Event processed on retry');
});
it('should handle processing errors gracefully', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_error',
type: 'customer.subscription.created',
data: {
object: {
id: 'sub_error',
customer: 'cus_123',
status: 'active',
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const mockEvent = createMockStripeEvent({ stripeEventId: 'evt_error' });
mockEventRepository.findOne.mockResolvedValue(null);
mockEventRepository.create.mockReturnValue(mockEvent);
mockEventRepository.save.mockResolvedValue(mockEvent);
mockSubscriptionRepository.findOne.mockRejectedValue(new Error('Database error'));
const result = await service.processWebhook(payload);
expect(result.success).toBe(false);
expect(result.error).toBe('Database error');
});
});
describe('handleSubscriptionCreated', () => {
it('should create/link subscription for existing customer', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_sub_created',
type: 'customer.subscription.created',
data: {
object: {
id: 'sub_new_123',
customer: 'cus_existing',
status: 'active',
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
trial_end: null,
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const mockEvent = createMockStripeEvent();
const mockSubscription = createMockSubscription({ stripeCustomerId: 'cus_existing' });
mockEventRepository.findOne.mockResolvedValue(null);
mockEventRepository.create.mockReturnValue(mockEvent);
mockEventRepository.save.mockResolvedValue(mockEvent);
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockResolvedValue(mockSubscription);
const result = await service.processWebhook(payload);
expect(result.success).toBe(true);
expect(mockSubscriptionRepository.save).toHaveBeenCalled();
});
});
describe('handleSubscriptionUpdated', () => {
it('should update subscription status', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_sub_updated',
type: 'customer.subscription.updated',
data: {
object: {
id: 'sub_123',
customer: 'cus_123',
status: 'past_due',
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
cancel_at_period_end: false,
canceled_at: null,
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const mockEvent = createMockStripeEvent();
const mockSubscription = createMockSubscription({ stripeSubscriptionId: 'sub_123' });
mockEventRepository.findOne.mockResolvedValue(null);
mockEventRepository.create.mockReturnValue(mockEvent);
mockEventRepository.save.mockResolvedValue(mockEvent);
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockResolvedValue({ ...mockSubscription, status: 'past_due' });
const result = await service.processWebhook(payload);
expect(result.success).toBe(true);
});
it('should handle cancel_at_period_end flag', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_sub_cancel_scheduled',
type: 'customer.subscription.updated',
data: {
object: {
id: 'sub_cancel',
customer: 'cus_123',
status: 'active',
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
cancel_at_period_end: true,
canceled_at: null,
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const mockEvent = createMockStripeEvent({ eventType: 'customer.subscription.updated' });
const mockSubscription = createMockSubscription({ stripeSubscriptionId: 'sub_cancel' });
mockEventRepository.findOne.mockResolvedValue(null);
mockEventRepository.create.mockReturnValue(mockEvent);
mockEventRepository.save.mockResolvedValue(mockEvent);
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
await service.processWebhook(payload);
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ cancelAtPeriodEnd: true })
);
});
});
describe('handleSubscriptionDeleted', () => {
it('should mark subscription as cancelled', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_sub_deleted',
type: 'customer.subscription.deleted',
data: {
object: {
id: 'sub_deleted',
customer: 'cus_123',
status: 'canceled',
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const mockEvent = createMockStripeEvent();
const mockSubscription = createMockSubscription({ stripeSubscriptionId: 'sub_deleted' });
mockEventRepository.findOne.mockResolvedValue(null);
mockEventRepository.create.mockReturnValue(mockEvent);
mockEventRepository.save.mockResolvedValue(mockEvent);
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
await service.processWebhook(payload);
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ status: 'cancelled' })
);
});
});
describe('handlePaymentSucceeded', () => {
it('should update subscription with payment info', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_payment_success',
type: 'invoice.payment_succeeded',
data: {
object: {
id: 'inv_123',
customer: 'cus_123',
amount_paid: 49900, // $499.00 in cents
subscription: 'sub_123',
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const mockEvent = createMockStripeEvent({ eventType: 'invoice.payment_succeeded' });
const mockSubscription = createMockSubscription({ stripeCustomerId: 'cus_123' });
mockEventRepository.findOne.mockResolvedValue(null);
mockEventRepository.create.mockReturnValue(mockEvent);
mockEventRepository.save.mockResolvedValue(mockEvent);
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
await service.processWebhook(payload);
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
status: 'active',
lastPaymentAmount: 499, // Converted from cents
})
);
});
});
describe('handlePaymentFailed', () => {
it('should mark subscription as past_due', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_payment_failed',
type: 'invoice.payment_failed',
data: {
object: {
id: 'inv_failed',
customer: 'cus_123',
attempt_count: 1,
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const mockEvent = createMockStripeEvent({ eventType: 'invoice.payment_failed' });
const mockSubscription = createMockSubscription({ stripeCustomerId: 'cus_123', status: 'active' });
mockEventRepository.findOne.mockResolvedValue(null);
mockEventRepository.create.mockReturnValue(mockEvent);
mockEventRepository.save.mockResolvedValue(mockEvent);
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
await service.processWebhook(payload);
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ status: 'past_due' })
);
});
});
describe('handleCheckoutCompleted', () => {
it('should link Stripe customer to tenant', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_checkout_completed',
type: 'checkout.session.completed',
data: {
object: {
id: 'cs_123',
customer: 'cus_new',
subscription: 'sub_new',
metadata: {
tenant_id: 'tenant-uuid-1',
},
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const mockEvent = createMockStripeEvent({ eventType: 'checkout.session.completed' });
const mockSubscription = createMockSubscription({ tenantId: 'tenant-uuid-1' });
mockEventRepository.findOne.mockResolvedValue(null);
mockEventRepository.create.mockReturnValue(mockEvent);
mockEventRepository.save.mockResolvedValue(mockEvent);
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
await service.processWebhook(payload);
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
stripeCustomerId: 'cus_new',
stripeSubscriptionId: 'sub_new',
status: 'active',
})
);
});
});
describe('retryProcessing', () => {
it('should retry and succeed', async () => {
const failedEvent = createMockStripeEvent({
processed: false,
retryCount: 2,
data: {
object: {
id: 'sub_retry',
customer: 'cus_123',
status: 'active',
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
},
},
});
const mockSubscription = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockResolvedValue(mockSubscription);
mockEventRepository.save.mockResolvedValue(failedEvent);
const result = await service.retryProcessing(failedEvent as any);
expect(result.success).toBe(true);
expect(result.message).toBe('Event processed on retry');
});
it('should fail if max retries exceeded', async () => {
const maxRetriedEvent = createMockStripeEvent({
processed: false,
retryCount: 5,
errorMessage: 'Previous error',
});
const result = await service.retryProcessing(maxRetriedEvent as any);
expect(result.success).toBe(false);
expect(result.message).toBe('Max retries exceeded');
});
});
describe('getFailedEvents', () => {
it('should return unprocessed events', async () => {
const failedEvents = [
createMockStripeEvent({ processed: false }),
createMockStripeEvent({ processed: false, stripeEventId: 'evt_2' }),
];
mockEventRepository.find.mockResolvedValue(failedEvents);
const result = await service.getFailedEvents();
expect(result).toHaveLength(2);
expect(mockEventRepository.find).toHaveBeenCalledWith({
where: { processed: false },
order: { createdAt: 'ASC' },
take: 100,
});
});
it('should respect limit parameter', async () => {
mockEventRepository.find.mockResolvedValue([]);
await service.getFailedEvents(50);
expect(mockEventRepository.find).toHaveBeenCalledWith(
expect.objectContaining({ take: 50 })
);
});
});
describe('findByStripeEventId', () => {
it('should find event by Stripe ID', async () => {
const mockEvent = createMockStripeEvent({ stripeEventId: 'evt_find' });
mockEventRepository.findOne.mockResolvedValue(mockEvent);
const result = await service.findByStripeEventId('evt_find');
expect(result).toBeDefined();
expect(result?.stripeEventId).toBe('evt_find');
});
it('should return null if not found', async () => {
mockEventRepository.findOne.mockResolvedValue(null);
const result = await service.findByStripeEventId('evt_notfound');
expect(result).toBeNull();
});
});
describe('getRecentEvents', () => {
it('should return recent events with default options', async () => {
const mockEvents = [createMockStripeEvent()];
const mockQueryBuilder = {
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue(mockEvents),
};
mockEventRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
const result = await service.getRecentEvents();
expect(result).toHaveLength(1);
expect(mockQueryBuilder.take).toHaveBeenCalledWith(50);
});
it('should filter by event type', async () => {
const mockQueryBuilder = {
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([]),
};
mockEventRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
await service.getRecentEvents({ eventType: 'invoice.payment_succeeded' });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'event.eventType = :eventType',
{ eventType: 'invoice.payment_succeeded' }
);
});
it('should filter by processed status', async () => {
const mockQueryBuilder = {
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([]),
};
mockEventRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
await service.getRecentEvents({ processed: false });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'event.processed = :processed',
{ processed: false }
);
});
});
});

View File

@ -0,0 +1,408 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockRepository, createMockQueryBuilder } from '../../../__tests__/helpers.js';
// Mock factories for subscription plan entities
function createMockSubscriptionPlan(overrides: Record<string, any> = {}) {
return {
id: 'plan-uuid-1',
code: 'STARTER',
name: 'Starter Plan',
description: 'Perfect for small businesses',
planType: 'saas',
baseMonthlyPrice: 499,
baseAnnualPrice: 4990,
setupFee: 0,
maxUsers: 5,
maxBranches: 1,
storageGb: 10,
apiCallsMonthly: 10000,
includedModules: ['core', 'sales', 'inventory'],
includedPlatforms: ['web'],
features: { analytics: true, reports: false },
isActive: true,
isPublic: true,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
...overrides,
};
}
// Mock repositories
const mockPlanRepository = {
...createMockRepository(),
softDelete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const mockQueryBuilder = createMockQueryBuilder();
// Mock DataSource
const mockDataSource = {
getRepository: jest.fn(() => mockPlanRepository),
createQueryBuilder: jest.fn(() => ({
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getRawOne: jest.fn().mockResolvedValue({ count: '0' }),
})),
};
jest.mock('../../../shared/utils/logger.js', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
// Import after mocking
import { SubscriptionPlansService } from '../services/subscription-plans.service.js';
describe('SubscriptionPlansService', () => {
let service: SubscriptionPlansService;
beforeEach(() => {
jest.clearAllMocks();
service = new SubscriptionPlansService(mockDataSource as any);
mockPlanRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
});
describe('create', () => {
it('should create a new subscription plan successfully', async () => {
const dto = {
code: 'NEWPLAN',
name: 'New Plan',
baseMonthlyPrice: 999,
maxUsers: 10,
};
const mockPlan = createMockSubscriptionPlan({ ...dto, id: 'new-plan-uuid' });
mockPlanRepository.findOne.mockResolvedValue(null);
mockPlanRepository.create.mockReturnValue(mockPlan);
mockPlanRepository.save.mockResolvedValue(mockPlan);
const result = await service.create(dto);
expect(mockPlanRepository.findOne).toHaveBeenCalledWith({ where: { code: 'NEWPLAN' } });
expect(mockPlanRepository.create).toHaveBeenCalled();
expect(mockPlanRepository.save).toHaveBeenCalled();
expect(result.code).toBe('NEWPLAN');
});
it('should throw error if plan code already exists', async () => {
const dto = {
code: 'STARTER',
name: 'Duplicate Plan',
baseMonthlyPrice: 999,
};
mockPlanRepository.findOne.mockResolvedValue(createMockSubscriptionPlan());
await expect(service.create(dto)).rejects.toThrow('Plan with code STARTER already exists');
});
it('should use default values when not provided', async () => {
const dto = {
code: 'MINIMAL',
name: 'Minimal Plan',
baseMonthlyPrice: 199,
};
mockPlanRepository.findOne.mockResolvedValue(null);
mockPlanRepository.create.mockImplementation((data: any) => ({
...data,
id: 'minimal-plan-uuid',
}));
mockPlanRepository.save.mockImplementation((plan: any) => Promise.resolve(plan));
await service.create(dto);
expect(mockPlanRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
planType: 'saas',
setupFee: 0,
maxUsers: 5,
maxBranches: 1,
storageGb: 10,
apiCallsMonthly: 10000,
includedModules: [],
includedPlatforms: ['web'],
features: {},
isActive: true,
isPublic: true,
})
);
});
});
describe('findAll', () => {
it('should return all plans without filters', async () => {
const mockPlans = [
createMockSubscriptionPlan({ id: 'plan-1', code: 'STARTER' }),
createMockSubscriptionPlan({ id: 'plan-2', code: 'PRO' }),
];
mockQueryBuilder.getMany.mockResolvedValue(mockPlans);
const result = await service.findAll();
expect(mockPlanRepository.createQueryBuilder).toHaveBeenCalledWith('plan');
expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('plan.baseMonthlyPrice', 'ASC');
expect(result).toHaveLength(2);
});
it('should filter by isActive', async () => {
mockQueryBuilder.getMany.mockResolvedValue([createMockSubscriptionPlan()]);
await service.findAll({ isActive: true });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'plan.isActive = :isActive',
{ isActive: true }
);
});
it('should filter by isPublic', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
await service.findAll({ isPublic: false });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'plan.isPublic = :isPublic',
{ isPublic: false }
);
});
it('should filter by planType', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
await service.findAll({ planType: 'on_premise' });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'plan.planType = :planType',
{ planType: 'on_premise' }
);
});
it('should apply multiple filters', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
await service.findAll({ isActive: true, isPublic: true, planType: 'saas' });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledTimes(3);
});
});
describe('findPublicPlans', () => {
it('should return only active and public plans', async () => {
const publicPlans = [createMockSubscriptionPlan({ isActive: true, isPublic: true })];
mockQueryBuilder.getMany.mockResolvedValue(publicPlans);
const result = await service.findPublicPlans();
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'plan.isActive = :isActive',
{ isActive: true }
);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'plan.isPublic = :isPublic',
{ isPublic: true }
);
expect(result).toHaveLength(1);
});
});
describe('findById', () => {
it('should return plan by id', async () => {
const mockPlan = createMockSubscriptionPlan();
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
const result = await service.findById('plan-uuid-1');
expect(mockPlanRepository.findOne).toHaveBeenCalledWith({ where: { id: 'plan-uuid-1' } });
expect(result?.id).toBe('plan-uuid-1');
});
it('should return null if plan not found', async () => {
mockPlanRepository.findOne.mockResolvedValue(null);
const result = await service.findById('non-existent-id');
expect(result).toBeNull();
});
});
describe('findByCode', () => {
it('should return plan by code', async () => {
const mockPlan = createMockSubscriptionPlan({ code: 'STARTER' });
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
const result = await service.findByCode('STARTER');
expect(mockPlanRepository.findOne).toHaveBeenCalledWith({ where: { code: 'STARTER' } });
expect(result?.code).toBe('STARTER');
});
it('should return null if code not found', async () => {
mockPlanRepository.findOne.mockResolvedValue(null);
const result = await service.findByCode('UNKNOWN');
expect(result).toBeNull();
});
});
describe('update', () => {
it('should update plan successfully', async () => {
const existingPlan = createMockSubscriptionPlan();
const updateDto = { name: 'Updated Plan Name', baseMonthlyPrice: 599 };
mockPlanRepository.findOne.mockResolvedValue(existingPlan);
mockPlanRepository.save.mockImplementation((plan: any) => Promise.resolve(plan));
const result = await service.update('plan-uuid-1', updateDto);
expect(mockPlanRepository.findOne).toHaveBeenCalledWith({ where: { id: 'plan-uuid-1' } });
expect(result.name).toBe('Updated Plan Name');
expect(result.baseMonthlyPrice).toBe(599);
});
it('should throw error if plan not found', async () => {
mockPlanRepository.findOne.mockResolvedValue(null);
await expect(service.update('non-existent-id', { name: 'Test' }))
.rejects.toThrow('Plan not found');
});
});
describe('delete', () => {
it('should soft delete plan with no active subscriptions', async () => {
const mockPlan = createMockSubscriptionPlan();
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
mockDataSource.createQueryBuilder().getRawOne.mockResolvedValue({ count: '0' });
await service.delete('plan-uuid-1');
expect(mockPlanRepository.softDelete).toHaveBeenCalledWith('plan-uuid-1');
});
it('should throw error if plan not found', async () => {
mockPlanRepository.findOne.mockResolvedValue(null);
await expect(service.delete('non-existent-id'))
.rejects.toThrow('Plan not found');
});
it('should throw error if plan has active subscriptions', async () => {
const mockPlan = createMockSubscriptionPlan();
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
// Need to reset the mock to return count > 0 for this test
const mockQb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getRawOne: jest.fn().mockResolvedValue({ count: '5' }),
};
mockDataSource.createQueryBuilder.mockReturnValue(mockQb);
await expect(service.delete('plan-uuid-1'))
.rejects.toThrow('Cannot delete plan with active subscriptions');
});
});
describe('setActive', () => {
it('should activate a plan', async () => {
const mockPlan = createMockSubscriptionPlan({ isActive: false });
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
mockPlanRepository.save.mockImplementation((plan: any) => Promise.resolve({
...plan,
isActive: true,
}));
const result = await service.setActive('plan-uuid-1', true);
expect(result.isActive).toBe(true);
});
it('should deactivate a plan', async () => {
const mockPlan = createMockSubscriptionPlan({ isActive: true });
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
mockPlanRepository.save.mockImplementation((plan: any) => Promise.resolve({
...plan,
isActive: false,
}));
const result = await service.setActive('plan-uuid-1', false);
expect(result.isActive).toBe(false);
});
});
describe('comparePlans', () => {
it('should compare two plans and return differences', async () => {
const plan1 = createMockSubscriptionPlan({
id: 'plan-1',
code: 'STARTER',
baseMonthlyPrice: 499,
maxUsers: 5,
includedModules: ['core', 'sales'],
});
const plan2 = createMockSubscriptionPlan({
id: 'plan-2',
code: 'PRO',
baseMonthlyPrice: 999,
maxUsers: 20,
includedModules: ['core', 'sales', 'inventory', 'reports'],
});
mockPlanRepository.findOne
.mockResolvedValueOnce(plan1)
.mockResolvedValueOnce(plan2);
const result = await service.comparePlans('plan-1', 'plan-2');
expect(result.plan1.code).toBe('STARTER');
expect(result.plan2.code).toBe('PRO');
expect(result.differences.baseMonthlyPrice).toEqual({
plan1: 499,
plan2: 999,
});
expect(result.differences.maxUsers).toEqual({
plan1: 5,
plan2: 20,
});
expect(result.differences.includedModules).toBeDefined();
});
it('should throw error if plan1 not found', async () => {
mockPlanRepository.findOne
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(createMockSubscriptionPlan());
await expect(service.comparePlans('invalid-1', 'plan-2'))
.rejects.toThrow('One or both plans not found');
});
it('should throw error if plan2 not found', async () => {
mockPlanRepository.findOne
.mockResolvedValueOnce(createMockSubscriptionPlan())
.mockResolvedValueOnce(null);
await expect(service.comparePlans('plan-1', 'invalid-2'))
.rejects.toThrow('One or both plans not found');
});
it('should return empty differences for identical plans', async () => {
const plan = createMockSubscriptionPlan();
mockPlanRepository.findOne
.mockResolvedValueOnce(plan)
.mockResolvedValueOnce({ ...plan, id: 'plan-2' });
const result = await service.comparePlans('plan-1', 'plan-2');
expect(Object.keys(result.differences)).toHaveLength(0);
});
});
});

View File

@ -0,0 +1,307 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DataSource, Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { SubscriptionsService } from '../services/subscriptions.service';
import { TenantSubscription, SubscriptionPlan, BillingCycle, SubscriptionStatus } from '../entities';
import { CreateTenantSubscriptionDto, UpdateTenantSubscriptionDto, CancelSubscriptionDto, ChangePlanDto } from '../dto';
describe('SubscriptionsService', () => {
let service: SubscriptionsService;
let subscriptionRepository: Repository<TenantSubscription>;
let planRepository: Repository<SubscriptionPlan>;
let dataSource: DataSource;
const mockSubscription = {
id: 'uuid-1',
tenantId: 'tenant-1',
planId: 'plan-1',
status: SubscriptionStatus.ACTIVE,
billingCycle: BillingCycle.MONTHLY,
currentPeriodStart: new Date('2024-01-01'),
currentPeriodEnd: new Date('2024-02-01'),
trialEnd: null,
cancelledAt: null,
paymentMethodId: 'pm-1',
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
};
const mockPlan = {
id: 'plan-1',
name: 'Basic Plan',
description: 'Basic subscription plan',
price: 9.99,
billingCycle: BillingCycle.MONTHLY,
features: ['feature1', 'feature2'],
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SubscriptionsService,
{
provide: DataSource,
useValue: {
getRepository: jest.fn(),
},
},
],
}).compile();
service = module.get<SubscriptionsService>(SubscriptionsService);
dataSource = module.get<DataSource>(DataSource);
subscriptionRepository = module.get<Repository<TenantSubscription>>(
getRepositoryToken(TenantSubscription),
);
planRepository = module.get<Repository<SubscriptionPlan>>(
getRepositoryToken(SubscriptionPlan),
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('should create a new subscription successfully', async () => {
const dto: CreateTenantSubscriptionDto = {
tenantId: 'tenant-1',
planId: 'plan-1',
billingCycle: BillingCycle.MONTHLY,
paymentMethodId: 'pm-1',
};
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(null);
jest.spyOn(planRepository, 'findOne').mockResolvedValue(mockPlan as any);
jest.spyOn(subscriptionRepository, 'create').mockReturnValue(mockSubscription as any);
jest.spyOn(subscriptionRepository, 'save').mockResolvedValue(mockSubscription);
const result = await service.create(dto);
expect(subscriptionRepository.findOne).toHaveBeenCalledWith({
where: { tenantId: dto.tenantId },
});
expect(planRepository.findOne).toHaveBeenCalledWith({ where: { id: dto.planId } });
expect(subscriptionRepository.create).toHaveBeenCalled();
expect(subscriptionRepository.save).toHaveBeenCalled();
expect(result).toEqual(mockSubscription);
});
it('should throw error if tenant already has subscription', async () => {
const dto: CreateTenantSubscriptionDto = {
tenantId: 'tenant-1',
planId: 'plan-1',
billingCycle: BillingCycle.MONTHLY,
paymentMethodId: 'pm-1',
};
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any);
await expect(service.create(dto)).rejects.toThrow('Tenant already has a subscription');
});
it('should throw error if plan not found', async () => {
const dto: CreateTenantSubscriptionDto = {
tenantId: 'tenant-1',
planId: 'invalid-plan',
billingCycle: BillingCycle.MONTHLY,
paymentMethodId: 'pm-1',
};
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(null);
jest.spyOn(planRepository, 'findOne').mockResolvedValue(null);
await expect(service.create(dto)).rejects.toThrow('Plan not found');
});
});
describe('findByTenant', () => {
it('should find subscription by tenant id', async () => {
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any);
const result = await service.findByTenant('tenant-1');
expect(subscriptionRepository.findOne).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1' },
});
expect(result).toEqual(mockSubscription);
});
it('should return null if no subscription found', async () => {
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(null);
const result = await service.findByTenant('invalid-tenant');
expect(result).toBeNull();
});
});
describe('update', () => {
it('should update subscription successfully', async () => {
const dto: UpdateTenantSubscriptionDto = {
paymentMethodId: 'pm-2',
};
const updatedSubscription = { ...mockSubscription, paymentMethodId: 'pm-2' };
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any);
jest.spyOn(subscriptionRepository, 'save').mockResolvedValue(updatedSubscription as any);
const result = await service.update('uuid-1', dto);
expect(subscriptionRepository.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } });
expect(subscriptionRepository.save).toHaveBeenCalled();
expect(result).toEqual(updatedSubscription);
});
it('should throw error if subscription not found', async () => {
const dto: UpdateTenantSubscriptionDto = {
paymentMethodId: 'pm-2',
};
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(null);
await expect(service.update('invalid-id', dto)).rejects.toThrow('Subscription not found');
});
});
describe('cancel', () => {
it('should cancel subscription successfully', async () => {
const dto: CancelSubscriptionDto = {
reason: 'Customer request',
effectiveImmediately: false,
};
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any);
jest.spyOn(subscriptionRepository, 'save').mockResolvedValue({
...mockSubscription,
status: SubscriptionStatus.CANCELLED,
cancelledAt: new Date(),
} as any);
const result = await service.cancel('uuid-1', dto);
expect(subscriptionRepository.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } });
expect(subscriptionRepository.save).toHaveBeenCalled();
expect(result.status).toBe(SubscriptionStatus.CANCELLED);
expect(result.cancelledAt).toBeDefined();
});
it('should cancel subscription immediately if requested', async () => {
const dto: CancelSubscriptionDto = {
reason: 'Customer request',
effectiveImmediately: true,
};
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any);
jest.spyOn(subscriptionRepository, 'save').mockResolvedValue({
...mockSubscription,
status: SubscriptionStatus.CANCELLED,
cancelledAt: new Date(),
currentPeriodEnd: new Date(),
} as any);
const result = await service.cancel('uuid-1', dto);
expect(result.status).toBe(SubscriptionStatus.CANCELLED);
expect(result.cancelledAt).toBeDefined();
});
});
describe('changePlan', () => {
it('should change subscription plan successfully', async () => {
const newPlan = { ...mockPlan, id: 'plan-2', price: 19.99 };
const dto: ChangePlanDto = {
newPlanId: 'plan-2',
billingCycle: BillingCycle.YEARLY,
prorate: true,
};
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any);
jest.spyOn(planRepository, 'findOne').mockResolvedValue(newPlan as any);
jest.spyOn(subscriptionRepository, 'save').mockResolvedValue({
...mockSubscription,
planId: 'plan-2',
billingCycle: BillingCycle.YEARLY,
} as any);
const result = await service.changePlan('uuid-1', dto);
expect(subscriptionRepository.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } });
expect(planRepository.findOne).toHaveBeenCalledWith({ where: { id: 'plan-2' } });
expect(subscriptionRepository.save).toHaveBeenCalled();
expect(result.planId).toBe('plan-2');
expect(result.billingCycle).toBe(BillingCycle.YEARLY);
});
it('should throw error if new plan not found', async () => {
const dto: ChangePlanDto = {
newPlanId: 'invalid-plan',
billingCycle: BillingCycle.MONTHLY,
prorate: false,
};
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any);
jest.spyOn(planRepository, 'findOne').mockResolvedValue(null);
await expect(service.changePlan('uuid-1', dto)).rejects.toThrow('New plan not found');
});
});
describe('getUsage', () => {
it('should get subscription usage', async () => {
const mockUsage = {
currentUsage: 850,
limits: {
apiCalls: 1000,
storage: 5368709120, // 5GB in bytes
users: 10,
},
periodStart: new Date('2024-01-01'),
periodEnd: new Date('2024-02-01'),
};
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any);
jest.spyOn(dataSource, 'query').mockResolvedValue([{ current_usage: 850 }]);
const result = await service.getUsage('uuid-1');
expect(result.currentUsage).toBe(850);
expect(result.limits).toBeDefined();
});
});
describe('reactivate', () => {
it('should reactivate cancelled subscription', async () => {
const cancelledSubscription = {
...mockSubscription,
status: SubscriptionStatus.CANCELLED,
cancelledAt: new Date(),
};
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(cancelledSubscription as any);
jest.spyOn(subscriptionRepository, 'save').mockResolvedValue({
...cancelledSubscription,
status: SubscriptionStatus.ACTIVE,
cancelledAt: null,
} as any);
const result = await service.reactivate('uuid-1');
expect(subscriptionRepository.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } });
expect(subscriptionRepository.save).toHaveBeenCalled();
expect(result.status).toBe(SubscriptionStatus.ACTIVE);
expect(result.cancelledAt).toBeNull();
});
it('should throw error if subscription is not cancelled', async () => {
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any);
await expect(service.reactivate('uuid-1')).rejects.toThrow('Cannot reactivate active subscription');
});
});
});

View File

@ -0,0 +1,502 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockRepository, createMockQueryBuilder } from '../../../__tests__/helpers.js';
// Mock factories
function createMockSubscriptionPlan(overrides: Record<string, any> = {}) {
return {
id: 'plan-uuid-1',
code: 'STARTER',
name: 'Starter Plan',
baseMonthlyPrice: 499,
baseAnnualPrice: 4990,
maxUsers: 5,
maxBranches: 1,
...overrides,
};
}
function createMockSubscription(overrides: Record<string, any> = {}) {
return {
id: 'sub-uuid-1',
tenantId: 'tenant-uuid-1',
planId: 'plan-uuid-1',
billingCycle: 'monthly',
currentPeriodStart: new Date('2026-01-01'),
currentPeriodEnd: new Date('2026-02-01'),
status: 'active',
trialStart: null,
trialEnd: null,
billingEmail: 'billing@example.com',
billingName: 'Test Company',
billingAddress: {},
taxId: 'RFC123456',
paymentMethodId: null,
paymentProvider: null,
currentPrice: 499,
discountPercent: 0,
discountReason: null,
contractedUsers: 5,
contractedBranches: 1,
autoRenew: true,
nextInvoiceDate: new Date('2026-02-01'),
cancelAtPeriodEnd: false,
cancelledAt: null,
cancellationReason: null,
createdAt: new Date(),
updatedAt: new Date(),
plan: createMockSubscriptionPlan(),
...overrides,
};
}
// Mock repositories
const mockSubscriptionRepository = createMockRepository();
const mockPlanRepository = createMockRepository();
const mockQueryBuilder = createMockQueryBuilder();
// Mock DataSource
const mockDataSource = {
getRepository: jest.fn((entity: any) => {
const entityName = entity.name || entity;
if (entityName === 'TenantSubscription') return mockSubscriptionRepository;
if (entityName === 'SubscriptionPlan') return mockPlanRepository;
return mockSubscriptionRepository;
}),
};
jest.mock('../../../shared/utils/logger.js', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
// Import after mocking
import { SubscriptionsService } from '../services/subscriptions.service.js';
describe('SubscriptionsService', () => {
let service: SubscriptionsService;
beforeEach(() => {
jest.clearAllMocks();
service = new SubscriptionsService(mockDataSource as any);
mockSubscriptionRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
});
describe('create', () => {
it('should create a new subscription successfully', async () => {
const dto = {
tenantId: 'tenant-uuid-new',
planId: 'plan-uuid-1',
billingEmail: 'test@example.com',
currentPrice: 499,
};
const mockPlan = createMockSubscriptionPlan();
const mockSub = createMockSubscription({ tenantId: dto.tenantId });
mockSubscriptionRepository.findOne.mockResolvedValue(null);
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
mockSubscriptionRepository.create.mockReturnValue(mockSub);
mockSubscriptionRepository.save.mockResolvedValue(mockSub);
const result = await service.create(dto);
expect(mockSubscriptionRepository.findOne).toHaveBeenCalledWith({
where: { tenantId: 'tenant-uuid-new' },
});
expect(mockPlanRepository.findOne).toHaveBeenCalledWith({
where: { id: 'plan-uuid-1' },
});
expect(result.tenantId).toBe('tenant-uuid-new');
});
it('should throw error if tenant already has subscription', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
planId: 'plan-uuid-1',
currentPrice: 499,
};
mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription());
await expect(service.create(dto)).rejects.toThrow('Tenant already has a subscription');
});
it('should throw error if plan not found', async () => {
const dto = {
tenantId: 'tenant-uuid-new',
planId: 'invalid-plan',
currentPrice: 499,
};
mockSubscriptionRepository.findOne.mockResolvedValue(null);
mockPlanRepository.findOne.mockResolvedValue(null);
await expect(service.create(dto)).rejects.toThrow('Plan not found');
});
it('should create subscription with trial', async () => {
const dto = {
tenantId: 'tenant-uuid-new',
planId: 'plan-uuid-1',
currentPrice: 499,
startWithTrial: true,
trialDays: 14,
};
const mockPlan = createMockSubscriptionPlan();
mockSubscriptionRepository.findOne.mockResolvedValue(null);
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
mockSubscriptionRepository.create.mockImplementation((data: any) => ({
...data,
id: 'new-sub-id',
}));
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.create(dto);
expect(mockSubscriptionRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
status: 'trial',
})
);
expect(result.trialStart).toBeDefined();
expect(result.trialEnd).toBeDefined();
});
});
describe('findByTenantId', () => {
it('should return subscription with plan relation', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
const result = await service.findByTenantId('tenant-uuid-1');
expect(mockSubscriptionRepository.findOne).toHaveBeenCalledWith({
where: { tenantId: 'tenant-uuid-1' },
relations: ['plan'],
});
expect(result?.tenantId).toBe('tenant-uuid-1');
});
it('should return null if not found', async () => {
mockSubscriptionRepository.findOne.mockResolvedValue(null);
const result = await service.findByTenantId('non-existent');
expect(result).toBeNull();
});
});
describe('findById', () => {
it('should return subscription by id', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
const result = await service.findById('sub-uuid-1');
expect(mockSubscriptionRepository.findOne).toHaveBeenCalledWith({
where: { id: 'sub-uuid-1' },
relations: ['plan'],
});
expect(result?.id).toBe('sub-uuid-1');
});
});
describe('update', () => {
it('should update subscription successfully', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.update('sub-uuid-1', {
billingEmail: 'new@example.com',
});
expect(result.billingEmail).toBe('new@example.com');
});
it('should throw error if subscription not found', async () => {
mockSubscriptionRepository.findOne.mockResolvedValue(null);
await expect(service.update('invalid-id', { billingEmail: 'test@example.com' }))
.rejects.toThrow('Subscription not found');
});
it('should validate plan when changing plan', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockPlanRepository.findOne.mockResolvedValue(null);
await expect(service.update('sub-uuid-1', { planId: 'new-plan-id' }))
.rejects.toThrow('Plan not found');
});
});
describe('cancel', () => {
it('should cancel at period end by default', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.cancel('sub-uuid-1', { reason: 'Too expensive' });
expect(result.cancelAtPeriodEnd).toBe(true);
expect(result.autoRenew).toBe(false);
expect(result.cancellationReason).toBe('Too expensive');
expect(result.status).toBe('active'); // Still active until period end
});
it('should cancel immediately when specified', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.cancel('sub-uuid-1', {
reason: 'Closing business',
cancelImmediately: true,
});
expect(result.status).toBe('cancelled');
});
it('should throw error if already cancelled', async () => {
const mockSub = createMockSubscription({ status: 'cancelled' });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
await expect(service.cancel('sub-uuid-1', {}))
.rejects.toThrow('Subscription is already cancelled');
});
it('should throw error if not found', async () => {
mockSubscriptionRepository.findOne.mockResolvedValue(null);
await expect(service.cancel('invalid-id', {}))
.rejects.toThrow('Subscription not found');
});
});
describe('reactivate', () => {
it('should reactivate cancelled subscription', async () => {
const mockSub = createMockSubscription({ status: 'cancelled', cancelAtPeriodEnd: false });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.reactivate('sub-uuid-1');
expect(result.status).toBe('active');
expect(result.cancelAtPeriodEnd).toBe(false);
expect(result.autoRenew).toBe(true);
});
it('should reactivate subscription pending cancellation', async () => {
const mockSub = createMockSubscription({ status: 'active', cancelAtPeriodEnd: true });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.reactivate('sub-uuid-1');
expect(result.cancelAtPeriodEnd).toBe(false);
expect(result.autoRenew).toBe(true);
});
it('should throw error if not cancelled', async () => {
const mockSub = createMockSubscription({ status: 'active', cancelAtPeriodEnd: false });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
await expect(service.reactivate('sub-uuid-1'))
.rejects.toThrow('Subscription is not cancelled');
});
});
describe('changePlan', () => {
it('should change to new plan', async () => {
const mockSub = createMockSubscription();
const newPlan = createMockSubscriptionPlan({
id: 'plan-uuid-2',
code: 'PRO',
baseMonthlyPrice: 999,
maxUsers: 20,
maxBranches: 5,
});
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockPlanRepository.findOne.mockResolvedValue(newPlan);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.changePlan('sub-uuid-1', { newPlanId: 'plan-uuid-2' });
expect(result.planId).toBe('plan-uuid-2');
expect(result.currentPrice).toBe(999);
expect(result.contractedUsers).toBe(20);
expect(result.contractedBranches).toBe(5);
});
it('should throw error if new plan not found', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockPlanRepository.findOne.mockResolvedValue(null);
await expect(service.changePlan('sub-uuid-1', { newPlanId: 'invalid-plan' }))
.rejects.toThrow('New plan not found');
});
it('should apply existing discount to new plan price', async () => {
const mockSub = createMockSubscription({ discountPercent: 20 });
const newPlan = createMockSubscriptionPlan({
id: 'plan-uuid-2',
baseMonthlyPrice: 1000,
});
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockPlanRepository.findOne.mockResolvedValue(newPlan);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.changePlan('sub-uuid-1', { newPlanId: 'plan-uuid-2' });
expect(result.currentPrice).toBe(800); // 1000 - 20%
});
});
describe('setPaymentMethod', () => {
it('should set payment method', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.setPaymentMethod('sub-uuid-1', {
paymentMethodId: 'pm_123',
paymentProvider: 'stripe',
});
expect(result.paymentMethodId).toBe('pm_123');
expect(result.paymentProvider).toBe('stripe');
});
});
describe('renew', () => {
it('should renew subscription and advance period', async () => {
const mockSub = createMockSubscription({
currentPeriodStart: new Date('2026-01-01'),
currentPeriodEnd: new Date('2026-02-01'),
});
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.renew('sub-uuid-1');
expect(result.currentPeriodStart.getTime()).toBe(new Date('2026-02-01').getTime());
});
it('should cancel if cancelAtPeriodEnd is true', async () => {
const mockSub = createMockSubscription({ cancelAtPeriodEnd: true });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.renew('sub-uuid-1');
expect(result.status).toBe('cancelled');
});
it('should throw error if autoRenew is disabled', async () => {
const mockSub = createMockSubscription({ autoRenew: false });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
await expect(service.renew('sub-uuid-1'))
.rejects.toThrow('Subscription auto-renew is disabled');
});
it('should transition from trial to active', async () => {
const mockSub = createMockSubscription({ status: 'trial' });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.renew('sub-uuid-1');
expect(result.status).toBe('active');
});
});
describe('status updates', () => {
it('should mark as past due', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.markPastDue('sub-uuid-1');
expect(result.status).toBe('past_due');
});
it('should suspend subscription', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.suspend('sub-uuid-1');
expect(result.status).toBe('suspended');
});
it('should activate subscription', async () => {
const mockSub = createMockSubscription({ status: 'suspended' });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.activate('sub-uuid-1');
expect(result.status).toBe('active');
});
});
describe('findExpiringSoon', () => {
it('should find subscriptions expiring within days', async () => {
const mockSubs = [createMockSubscription()];
mockQueryBuilder.getMany.mockResolvedValue(mockSubs);
const result = await service.findExpiringSoon(7);
expect(mockSubscriptionRepository.createQueryBuilder).toHaveBeenCalledWith('sub');
expect(mockQueryBuilder.leftJoinAndSelect).toHaveBeenCalledWith('sub.plan', 'plan');
expect(result).toHaveLength(1);
});
});
describe('findTrialsEndingSoon', () => {
it('should find trials ending within days', async () => {
const mockSubs = [createMockSubscription({ status: 'trial' })];
mockQueryBuilder.getMany.mockResolvedValue(mockSubs);
const result = await service.findTrialsEndingSoon(3);
expect(mockQueryBuilder.where).toHaveBeenCalledWith("sub.status = 'trial'");
expect(result).toHaveLength(1);
});
});
describe('getStats', () => {
it('should return subscription statistics', async () => {
const mockSubs = [
createMockSubscription({ status: 'active', currentPrice: 499, plan: { code: 'STARTER' } }),
createMockSubscription({ status: 'active', currentPrice: 999, plan: { code: 'PRO' } }),
createMockSubscription({ status: 'trial', currentPrice: 499, plan: { code: 'STARTER' } }),
createMockSubscription({ status: 'cancelled', currentPrice: 499, plan: { code: 'STARTER' } }),
];
mockSubscriptionRepository.find.mockResolvedValue(mockSubs);
const result = await service.getStats();
expect(result.total).toBe(4);
expect(result.byStatus.active).toBe(2);
expect(result.byStatus.trial).toBe(1);
expect(result.byStatus.cancelled).toBe(1);
expect(result.byPlan['STARTER']).toBe(3);
expect(result.byPlan['PRO']).toBe(1);
expect(result.totalMRR).toBe(499 + 999 + 499); // Active and trial subscriptions
expect(result.totalARR).toBe(result.totalMRR * 12);
});
});
});

View File

@ -0,0 +1,423 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockRepository } from '../../../__tests__/helpers.js';
// Mock factories
function createMockUsageTracking(overrides: Record<string, any> = {}) {
return {
id: 'usage-uuid-1',
tenantId: 'tenant-uuid-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
activeUsers: 5,
peakConcurrentUsers: 3,
usersByProfile: { ADM: 1, VNT: 2, ALM: 2 },
usersByPlatform: { web: 5, mobile: 2 },
activeBranches: 2,
storageUsedGb: 5.5,
documentsCount: 1500,
apiCalls: 5000,
apiErrors: 50,
salesCount: 200,
salesAmount: 150000,
invoicesGenerated: 150,
mobileSessions: 100,
offlineSyncs: 25,
paymentTransactions: 180,
totalBillableAmount: 499,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function createMockSubscription(overrides: Record<string, any> = {}) {
return {
id: 'sub-uuid-1',
tenantId: 'tenant-uuid-1',
planId: 'plan-uuid-1',
currentPrice: 499,
contractedUsers: 10,
contractedBranches: 3,
plan: {
id: 'plan-uuid-1',
code: 'STARTER',
maxUsers: 10,
maxBranches: 3,
storageGb: 20,
apiCallsMonthly: 10000,
},
...overrides,
};
}
// Mock repositories
const mockUsageRepository = createMockRepository();
const mockSubscriptionRepository = createMockRepository();
const mockPlanRepository = createMockRepository();
// Mock DataSource
const mockDataSource = {
getRepository: jest.fn((entity: any) => {
const entityName = entity.name || entity;
if (entityName === 'UsageTracking') return mockUsageRepository;
if (entityName === 'TenantSubscription') return mockSubscriptionRepository;
if (entityName === 'SubscriptionPlan') return mockPlanRepository;
return mockUsageRepository;
}),
};
jest.mock('../../../shared/utils/logger.js', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
// Import after mocking
import { UsageTrackingService } from '../services/usage-tracking.service.js';
describe('UsageTrackingService', () => {
let service: UsageTrackingService;
beforeEach(() => {
jest.clearAllMocks();
service = new UsageTrackingService(mockDataSource as any);
});
describe('recordUsage', () => {
it('should create new usage record', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
activeUsers: 5,
apiCalls: 1000,
};
const mockUsage = createMockUsageTracking(dto);
mockUsageRepository.findOne.mockResolvedValueOnce(null); // No existing record
mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription());
mockUsageRepository.create.mockReturnValue(mockUsage);
mockUsageRepository.save.mockResolvedValue(mockUsage);
const result = await service.recordUsage(dto);
expect(mockUsageRepository.findOne).toHaveBeenCalled();
expect(mockUsageRepository.create).toHaveBeenCalled();
expect(result.tenantId).toBe('tenant-uuid-1');
});
it('should update existing record if one exists for period', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
activeUsers: 10,
};
const existingUsage = createMockUsageTracking();
mockUsageRepository.findOne
.mockResolvedValueOnce(existingUsage) // First call - check existing
.mockResolvedValueOnce(existingUsage); // Second call - in update
mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription());
mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage));
const result = await service.recordUsage(dto);
expect(result.activeUsers).toBe(10);
});
});
describe('update', () => {
it('should update usage record', async () => {
const mockUsage = createMockUsageTracking();
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription());
mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage));
const result = await service.update('usage-uuid-1', { apiCalls: 8000 });
expect(result.apiCalls).toBe(8000);
});
it('should throw error if record not found', async () => {
mockUsageRepository.findOne.mockResolvedValue(null);
await expect(service.update('invalid-id', { apiCalls: 100 }))
.rejects.toThrow('Usage record not found');
});
it('should recalculate billable amount on update', async () => {
const mockUsage = createMockUsageTracking();
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription());
mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage));
await service.update('usage-uuid-1', { activeUsers: 15 }); // Exceeds limit
expect(mockUsageRepository.save).toHaveBeenCalled();
});
});
describe('incrementMetric', () => {
it('should increment metric on existing record', async () => {
const mockUsage = createMockUsageTracking({ apiCalls: 5000 });
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage));
await service.incrementMetric('tenant-uuid-1', 'apiCalls', 100);
expect(mockUsageRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ apiCalls: 5100 })
);
});
it('should create record if none exists for period', async () => {
mockUsageRepository.findOne.mockResolvedValue(null);
mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription());
mockUsageRepository.create.mockImplementation((data: any) => ({
...createMockUsageTracking(),
...data,
apiCalls: 0,
}));
mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage));
await service.incrementMetric('tenant-uuid-1', 'apiCalls', 50);
expect(mockUsageRepository.create).toHaveBeenCalled();
});
});
describe('getCurrentUsage', () => {
it('should return current period usage', async () => {
const mockUsage = createMockUsageTracking();
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
const result = await service.getCurrentUsage('tenant-uuid-1');
expect(result?.tenantId).toBe('tenant-uuid-1');
});
it('should return null if no usage for current period', async () => {
mockUsageRepository.findOne.mockResolvedValue(null);
const result = await service.getCurrentUsage('tenant-uuid-1');
expect(result).toBeNull();
});
});
describe('getUsageHistory', () => {
it('should return usage records within date range', async () => {
const mockUsages = [
createMockUsageTracking({ id: 'usage-1' }),
createMockUsageTracking({ id: 'usage-2' }),
];
mockUsageRepository.find.mockResolvedValue(mockUsages);
const result = await service.getUsageHistory(
'tenant-uuid-1',
new Date('2026-01-01'),
new Date('2026-03-31')
);
expect(mockUsageRepository.find).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-uuid-1' }),
order: { periodStart: 'DESC' },
})
);
expect(result).toHaveLength(2);
});
});
describe('getUsageSummary', () => {
it('should return usage summary with limits', async () => {
const mockSub = createMockSubscription();
const mockUsage = createMockUsageTracking();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
const result = await service.getUsageSummary('tenant-uuid-1');
expect(result.tenantId).toBe('tenant-uuid-1');
expect(result.currentUsers).toBe(5);
expect(result.limits.maxUsers).toBe(10);
expect(result.percentages.usersUsed).toBe(50);
});
it('should throw error if subscription not found', async () => {
mockSubscriptionRepository.findOne.mockResolvedValue(null);
await expect(service.getUsageSummary('tenant-uuid-1'))
.rejects.toThrow('Subscription not found');
});
it('should handle missing current usage gracefully', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockUsageRepository.findOne.mockResolvedValue(null);
const result = await service.getUsageSummary('tenant-uuid-1');
expect(result.currentUsers).toBe(0);
expect(result.apiCallsThisMonth).toBe(0);
});
});
describe('checkLimits', () => {
it('should return no violations when within limits', async () => {
const mockSub = createMockSubscription();
const mockUsage = createMockUsageTracking({
activeUsers: 5,
activeBranches: 2,
storageUsedGb: 10,
apiCalls: 5000,
});
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
const result = await service.checkLimits('tenant-uuid-1');
expect(result.exceeds).toBe(false);
expect(result.violations).toHaveLength(0);
});
it('should return violations when limits exceeded', async () => {
const mockSub = createMockSubscription();
const mockUsage = createMockUsageTracking({
activeUsers: 15, // Exceeds 10
activeBranches: 5, // Exceeds 3
storageUsedGb: 10,
apiCalls: 5000,
});
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
const result = await service.checkLimits('tenant-uuid-1');
expect(result.exceeds).toBe(true);
expect(result.violations.length).toBeGreaterThan(0);
expect(result.violations.some((v: string) => v.includes('Users'))).toBe(true);
expect(result.violations.some((v: string) => v.includes('Branches'))).toBe(true);
});
it('should return warnings at 80% threshold', async () => {
const mockSub = createMockSubscription();
const mockUsage = createMockUsageTracking({
activeUsers: 8, // 80% of 10
activeBranches: 2,
storageUsedGb: 16, // 80% of 20
apiCalls: 8000, // 80% of 10000
});
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
const result = await service.checkLimits('tenant-uuid-1');
expect(result.exceeds).toBe(false);
expect(result.warnings.length).toBeGreaterThan(0);
expect(result.warnings.some((w: string) => w.includes('Users'))).toBe(true);
expect(result.warnings.some((w: string) => w.includes('Storage'))).toBe(true);
});
});
describe('getUsageReport', () => {
it('should generate usage report with totals and averages', async () => {
const mockUsages = [
createMockUsageTracking({
activeUsers: 5,
activeBranches: 2,
storageUsedGb: 5,
apiCalls: 5000,
salesCount: 100,
salesAmount: 50000,
}),
createMockUsageTracking({
activeUsers: 7,
activeBranches: 3,
storageUsedGb: 6,
apiCalls: 6000,
salesCount: 150,
salesAmount: 75000,
}),
];
mockUsageRepository.find.mockResolvedValue(mockUsages);
const result = await service.getUsageReport(
'tenant-uuid-1',
new Date('2026-01-01'),
new Date('2026-02-28')
);
expect(result.tenantId).toBe('tenant-uuid-1');
expect(result.data).toHaveLength(2);
expect(result.totals.apiCalls).toBe(11000);
expect(result.totals.salesCount).toBe(250);
expect(result.totals.salesAmount).toBe(125000);
expect(result.averages.activeUsers).toBe(6);
expect(result.averages.activeBranches).toBe(3);
});
it('should handle empty usage data', async () => {
mockUsageRepository.find.mockResolvedValue([]);
const result = await service.getUsageReport(
'tenant-uuid-1',
new Date('2026-01-01'),
new Date('2026-02-28')
);
expect(result.data).toHaveLength(0);
expect(result.totals.apiCalls).toBe(0);
expect(result.averages.activeUsers).toBe(0);
});
});
describe('calculateBillableAmount (via recordUsage)', () => {
it('should calculate base price for usage within limits', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
activeUsers: 5,
activeBranches: 2,
storageUsedGb: 10,
apiCalls: 5000,
};
mockUsageRepository.findOne.mockResolvedValue(null);
mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription());
mockUsageRepository.create.mockImplementation((data: any) => data);
mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage));
const result = await service.recordUsage(dto);
expect(result.totalBillableAmount).toBe(499); // Base price, no overages
});
it('should add overage charges when limits exceeded', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
activeUsers: 15, // 5 extra users at $10 each = $50
activeBranches: 5, // 2 extra branches at $20 each = $40
storageUsedGb: 25, // 5 extra GB at $0.50 each = $2.50
apiCalls: 15000, // 5000 extra at $0.001 each = $5
};
mockUsageRepository.findOne.mockResolvedValue(null);
mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription());
mockUsageRepository.create.mockImplementation((data: any) => data);
mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage));
const result = await service.recordUsage(dto);
// Base: 499 + Extra users: 50 + Extra branches: 40 + Extra storage: 2.5 + Extra API: 5 = 596.5
expect(result.totalBillableAmount).toBe(596.5);
});
});
});

View File

@ -0,0 +1,61 @@
/**
* Billing Usage Module
*
* Module registration for billing and usage tracking
*/
import { Router } from 'express';
import { DataSource } from 'typeorm';
import {
SubscriptionPlansController,
SubscriptionsController,
UsageController,
InvoicesController,
} from './controllers';
export interface BillingUsageModuleOptions {
dataSource: DataSource;
basePath?: string;
}
export class BillingUsageModule {
public router: Router;
private subscriptionPlansController: SubscriptionPlansController;
private subscriptionsController: SubscriptionsController;
private usageController: UsageController;
private invoicesController: InvoicesController;
constructor(options: BillingUsageModuleOptions) {
const { dataSource, basePath = '/billing' } = options;
this.router = Router();
// Initialize controllers
this.subscriptionPlansController = new SubscriptionPlansController(dataSource);
this.subscriptionsController = new SubscriptionsController(dataSource);
this.usageController = new UsageController(dataSource);
this.invoicesController = new InvoicesController(dataSource);
// Register routes
this.router.use(`${basePath}/subscription-plans`, this.subscriptionPlansController.router);
this.router.use(`${basePath}/subscriptions`, this.subscriptionsController.router);
this.router.use(`${basePath}/usage`, this.usageController.router);
this.router.use(`${basePath}/invoices`, this.invoicesController.router);
}
/**
* Get all entities for this module (for TypeORM configuration)
*/
static getEntities() {
return [
require('./entities/subscription-plan.entity').SubscriptionPlan,
require('./entities/tenant-subscription.entity').TenantSubscription,
require('./entities/usage-tracking.entity').UsageTracking,
require('./entities/invoice.entity').Invoice,
require('./entities/invoice-item.entity').InvoiceItem,
require('./entities/plan-feature.entity').PlanFeature,
];
}
}
export default BillingUsageModule;

View File

@ -0,0 +1,8 @@
/**
* Billing Usage Controllers Index
*/
export { SubscriptionPlansController } from './subscription-plans.controller';
export { SubscriptionsController } from './subscriptions.controller';
export { UsageController } from './usage.controller';
export { InvoicesController } from './invoices.controller';

View File

@ -0,0 +1,258 @@
/**
* Invoices Controller
*
* REST API endpoints for invoice management
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { InvoicesService } from '../services';
import {
CreateInvoiceDto,
UpdateInvoiceDto,
RecordPaymentDto,
VoidInvoiceDto,
RefundInvoiceDto,
GenerateInvoiceDto,
InvoiceFilterDto,
} from '../dto';
export class InvoicesController {
public router: Router;
private service: InvoicesService;
constructor(dataSource: DataSource) {
this.router = Router();
this.service = new InvoicesService(dataSource);
this.initializeRoutes();
}
private initializeRoutes(): void {
// Stats
this.router.get('/stats', this.getStats.bind(this));
// List and search
this.router.get('/', this.getAll.bind(this));
this.router.get('/tenant/:tenantId', this.getByTenant.bind(this));
this.router.get('/:id', this.getById.bind(this));
this.router.get('/number/:invoiceNumber', this.getByNumber.bind(this));
// Create
this.router.post('/', this.create.bind(this));
this.router.post('/generate', this.generate.bind(this));
// Update
this.router.put('/:id', this.update.bind(this));
// Actions
this.router.post('/:id/send', this.send.bind(this));
this.router.post('/:id/payment', this.recordPayment.bind(this));
this.router.post('/:id/void', this.void.bind(this));
this.router.post('/:id/refund', this.refund.bind(this));
// Batch operations
this.router.post('/mark-overdue', this.markOverdue.bind(this));
}
/**
* GET /invoices/stats
* Get invoice statistics
*/
private async getStats(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { tenantId } = req.query;
const stats = await this.service.getStats(tenantId as string);
res.json({ data: stats });
} catch (error) {
next(error);
}
}
/**
* GET /invoices
* Get all invoices with filters
*/
private async getAll(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const filter: InvoiceFilterDto = {
tenantId: req.query.tenantId as string,
status: req.query.status as any,
dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined,
dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined,
overdue: req.query.overdue === 'true',
limit: req.query.limit ? parseInt(req.query.limit as string) : undefined,
offset: req.query.offset ? parseInt(req.query.offset as string) : undefined,
};
const result = await this.service.findAll(filter);
res.json(result);
} catch (error) {
next(error);
}
}
/**
* GET /invoices/tenant/:tenantId
* Get invoices for specific tenant
*/
private async getByTenant(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const result = await this.service.findAll({
tenantId: req.params.tenantId,
limit: req.query.limit ? parseInt(req.query.limit as string) : 50,
offset: req.query.offset ? parseInt(req.query.offset as string) : 0,
});
res.json(result);
} catch (error) {
next(error);
}
}
/**
* GET /invoices/:id
* Get invoice by ID
*/
private async getById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const invoice = await this.service.findById(req.params.id);
if (!invoice) {
res.status(404).json({ error: 'Invoice not found' });
return;
}
res.json({ data: invoice });
} catch (error) {
next(error);
}
}
/**
* GET /invoices/number/:invoiceNumber
* Get invoice by number
*/
private async getByNumber(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const invoice = await this.service.findByNumber(req.params.invoiceNumber);
if (!invoice) {
res.status(404).json({ error: 'Invoice not found' });
return;
}
res.json({ data: invoice });
} catch (error) {
next(error);
}
}
/**
* POST /invoices
* Create invoice manually
*/
private async create(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: CreateInvoiceDto = req.body;
const invoice = await this.service.create(dto);
res.status(201).json({ data: invoice });
} catch (error) {
next(error);
}
}
/**
* POST /invoices/generate
* Generate invoice from subscription
*/
private async generate(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: GenerateInvoiceDto = req.body;
const invoice = await this.service.generateFromSubscription(dto);
res.status(201).json({ data: invoice });
} catch (error) {
next(error);
}
}
/**
* PUT /invoices/:id
* Update invoice
*/
private async update(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: UpdateInvoiceDto = req.body;
const invoice = await this.service.update(req.params.id, dto);
res.json({ data: invoice });
} catch (error) {
next(error);
}
}
/**
* POST /invoices/:id/send
* Send invoice to customer
*/
private async send(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const invoice = await this.service.send(req.params.id);
res.json({ data: invoice });
} catch (error) {
next(error);
}
}
/**
* POST /invoices/:id/payment
* Record payment on invoice
*/
private async recordPayment(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: RecordPaymentDto = req.body;
const invoice = await this.service.recordPayment(req.params.id, dto);
res.json({ data: invoice });
} catch (error) {
next(error);
}
}
/**
* POST /invoices/:id/void
* Void an invoice
*/
private async void(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: VoidInvoiceDto = req.body;
const invoice = await this.service.void(req.params.id, dto);
res.json({ data: invoice });
} catch (error) {
next(error);
}
}
/**
* POST /invoices/:id/refund
* Refund an invoice
*/
private async refund(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: RefundInvoiceDto = req.body;
const invoice = await this.service.refund(req.params.id, dto);
res.json({ data: invoice });
} catch (error) {
next(error);
}
}
/**
* POST /invoices/mark-overdue
* Mark all overdue invoices (scheduled job endpoint)
*/
private async markOverdue(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const count = await this.service.markOverdueInvoices();
res.json({ data: { markedOverdue: count } });
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1,168 @@
/**
* Subscription Plans Controller
*
* REST API endpoints for subscription plan management
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { SubscriptionPlansService } from '../services';
import { CreateSubscriptionPlanDto, UpdateSubscriptionPlanDto } from '../dto';
export class SubscriptionPlansController {
public router: Router;
private service: SubscriptionPlansService;
constructor(dataSource: DataSource) {
this.router = Router();
this.service = new SubscriptionPlansService(dataSource);
this.initializeRoutes();
}
private initializeRoutes(): void {
// Public routes
this.router.get('/public', this.getPublicPlans.bind(this));
this.router.get('/:id/compare/:otherId', this.comparePlans.bind(this));
// Protected routes (require admin)
this.router.get('/', this.getAll.bind(this));
this.router.get('/:id', this.getById.bind(this));
this.router.post('/', this.create.bind(this));
this.router.put('/:id', this.update.bind(this));
this.router.delete('/:id', this.delete.bind(this));
this.router.patch('/:id/activate', this.activate.bind(this));
this.router.patch('/:id/deactivate', this.deactivate.bind(this));
}
/**
* GET /subscription-plans/public
* Get public plans for pricing page
*/
private async getPublicPlans(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const plans = await this.service.findPublicPlans();
res.json({ data: plans });
} catch (error) {
next(error);
}
}
/**
* GET /subscription-plans
* Get all plans (admin only)
*/
private async getAll(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { isActive, isPublic, planType } = req.query;
const plans = await this.service.findAll({
isActive: isActive !== undefined ? isActive === 'true' : undefined,
isPublic: isPublic !== undefined ? isPublic === 'true' : undefined,
planType: planType as any,
});
res.json({ data: plans });
} catch (error) {
next(error);
}
}
/**
* GET /subscription-plans/:id
* Get plan by ID
*/
private async getById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const plan = await this.service.findById(req.params.id);
if (!plan) {
res.status(404).json({ error: 'Plan not found' });
return;
}
res.json({ data: plan });
} catch (error) {
next(error);
}
}
/**
* POST /subscription-plans
* Create new plan
*/
private async create(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: CreateSubscriptionPlanDto = req.body;
const plan = await this.service.create(dto);
res.status(201).json({ data: plan });
} catch (error) {
next(error);
}
}
/**
* PUT /subscription-plans/:id
* Update plan
*/
private async update(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: UpdateSubscriptionPlanDto = req.body;
const plan = await this.service.update(req.params.id, dto);
res.json({ data: plan });
} catch (error) {
next(error);
}
}
/**
* DELETE /subscription-plans/:id
* Delete plan (soft delete)
*/
private async delete(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await this.service.delete(req.params.id);
res.status(204).send();
} catch (error) {
next(error);
}
}
/**
* PATCH /subscription-plans/:id/activate
* Activate plan
*/
private async activate(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const plan = await this.service.setActive(req.params.id, true);
res.json({ data: plan });
} catch (error) {
next(error);
}
}
/**
* PATCH /subscription-plans/:id/deactivate
* Deactivate plan
*/
private async deactivate(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const plan = await this.service.setActive(req.params.id, false);
res.json({ data: plan });
} catch (error) {
next(error);
}
}
/**
* GET /subscription-plans/:id/compare/:otherId
* Compare two plans
*/
private async comparePlans(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const comparison = await this.service.comparePlans(req.params.id, req.params.otherId);
res.json({ data: comparison });
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1,232 @@
/**
* Subscriptions Controller
*
* REST API endpoints for tenant subscription management
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { SubscriptionsService } from '../services';
import {
CreateTenantSubscriptionDto,
UpdateTenantSubscriptionDto,
CancelSubscriptionDto,
ChangePlanDto,
SetPaymentMethodDto,
} from '../dto';
export class SubscriptionsController {
public router: Router;
private service: SubscriptionsService;
constructor(dataSource: DataSource) {
this.router = Router();
this.service = new SubscriptionsService(dataSource);
this.initializeRoutes();
}
private initializeRoutes(): void {
// Stats (admin)
this.router.get('/stats', this.getStats.bind(this));
// Tenant subscription
this.router.get('/tenant/:tenantId', this.getByTenant.bind(this));
this.router.post('/', this.create.bind(this));
this.router.put('/:id', this.update.bind(this));
// Subscription actions
this.router.post('/:id/cancel', this.cancel.bind(this));
this.router.post('/:id/reactivate', this.reactivate.bind(this));
this.router.post('/:id/change-plan', this.changePlan.bind(this));
this.router.post('/:id/payment-method', this.setPaymentMethod.bind(this));
this.router.post('/:id/renew', this.renew.bind(this));
this.router.post('/:id/suspend', this.suspend.bind(this));
this.router.post('/:id/activate', this.activate.bind(this));
// Alerts/expiring
this.router.get('/expiring', this.getExpiring.bind(this));
this.router.get('/trials-ending', this.getTrialsEnding.bind(this));
}
/**
* GET /subscriptions/stats
* Get subscription statistics
*/
private async getStats(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const stats = await this.service.getStats();
res.json({ data: stats });
} catch (error) {
next(error);
}
}
/**
* GET /subscriptions/tenant/:tenantId
* Get subscription by tenant ID
*/
private async getByTenant(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const subscription = await this.service.findByTenantId(req.params.tenantId);
if (!subscription) {
res.status(404).json({ error: 'Subscription not found' });
return;
}
res.json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* POST /subscriptions
* Create new subscription
*/
private async create(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: CreateTenantSubscriptionDto = req.body;
const subscription = await this.service.create(dto);
res.status(201).json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* PUT /subscriptions/:id
* Update subscription
*/
private async update(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: UpdateTenantSubscriptionDto = req.body;
const subscription = await this.service.update(req.params.id, dto);
res.json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* POST /subscriptions/:id/cancel
* Cancel subscription
*/
private async cancel(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: CancelSubscriptionDto = req.body;
const subscription = await this.service.cancel(req.params.id, dto);
res.json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* POST /subscriptions/:id/reactivate
* Reactivate cancelled subscription
*/
private async reactivate(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const subscription = await this.service.reactivate(req.params.id);
res.json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* POST /subscriptions/:id/change-plan
* Change subscription plan
*/
private async changePlan(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: ChangePlanDto = req.body;
const subscription = await this.service.changePlan(req.params.id, dto);
res.json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* POST /subscriptions/:id/payment-method
* Set payment method
*/
private async setPaymentMethod(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: SetPaymentMethodDto = req.body;
const subscription = await this.service.setPaymentMethod(req.params.id, dto);
res.json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* POST /subscriptions/:id/renew
* Renew subscription
*/
private async renew(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const subscription = await this.service.renew(req.params.id);
res.json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* POST /subscriptions/:id/suspend
* Suspend subscription
*/
private async suspend(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const subscription = await this.service.suspend(req.params.id);
res.json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* POST /subscriptions/:id/activate
* Activate subscription
*/
private async activate(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const subscription = await this.service.activate(req.params.id);
res.json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* GET /subscriptions/expiring
* Get subscriptions expiring soon
*/
private async getExpiring(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const days = parseInt(req.query.days as string) || 7;
const subscriptions = await this.service.findExpiringSoon(days);
res.json({ data: subscriptions });
} catch (error) {
next(error);
}
}
/**
* GET /subscriptions/trials-ending
* Get trials ending soon
*/
private async getTrialsEnding(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const days = parseInt(req.query.days as string) || 3;
const subscriptions = await this.service.findTrialsEndingSoon(days);
res.json({ data: subscriptions });
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1,173 @@
/**
* Usage Controller
*
* REST API endpoints for usage tracking
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { UsageTrackingService } from '../services';
import { RecordUsageDto, UpdateUsageDto, IncrementUsageDto, UsageMetrics } from '../dto';
export class UsageController {
public router: Router;
private service: UsageTrackingService;
constructor(dataSource: DataSource) {
this.router = Router();
this.service = new UsageTrackingService(dataSource);
this.initializeRoutes();
}
private initializeRoutes(): void {
// Current usage
this.router.get('/tenant/:tenantId/current', this.getCurrentUsage.bind(this));
this.router.get('/tenant/:tenantId/summary', this.getUsageSummary.bind(this));
this.router.get('/tenant/:tenantId/limits', this.checkLimits.bind(this));
// Usage history
this.router.get('/tenant/:tenantId/history', this.getUsageHistory.bind(this));
this.router.get('/tenant/:tenantId/report', this.getUsageReport.bind(this));
// Record usage
this.router.post('/', this.recordUsage.bind(this));
this.router.put('/:id', this.updateUsage.bind(this));
this.router.post('/increment', this.incrementMetric.bind(this));
}
/**
* GET /usage/tenant/:tenantId/current
* Get current usage for tenant
*/
private async getCurrentUsage(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const usage = await this.service.getCurrentUsage(req.params.tenantId);
res.json({ data: usage });
} catch (error) {
next(error);
}
}
/**
* GET /usage/tenant/:tenantId/summary
* Get usage summary with limits
*/
private async getUsageSummary(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const summary = await this.service.getUsageSummary(req.params.tenantId);
res.json({ data: summary });
} catch (error) {
next(error);
}
}
/**
* GET /usage/tenant/:tenantId/limits
* Check if tenant exceeds limits
*/
private async checkLimits(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const limits = await this.service.checkLimits(req.params.tenantId);
res.json({ data: limits });
} catch (error) {
next(error);
}
}
/**
* GET /usage/tenant/:tenantId/history
* Get usage history
*/
private async getUsageHistory(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { startDate, endDate } = req.query;
if (!startDate || !endDate) {
res.status(400).json({ error: 'startDate and endDate are required' });
return;
}
const history = await this.service.getUsageHistory(
req.params.tenantId,
new Date(startDate as string),
new Date(endDate as string)
);
res.json({ data: history });
} catch (error) {
next(error);
}
}
/**
* GET /usage/tenant/:tenantId/report
* Get usage report
*/
private async getUsageReport(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { startDate, endDate, granularity } = req.query;
if (!startDate || !endDate) {
res.status(400).json({ error: 'startDate and endDate are required' });
return;
}
const report = await this.service.getUsageReport(
req.params.tenantId,
new Date(startDate as string),
new Date(endDate as string),
(granularity as 'daily' | 'weekly' | 'monthly') || 'monthly'
);
res.json({ data: report });
} catch (error) {
next(error);
}
}
/**
* POST /usage
* Record usage for period
*/
private async recordUsage(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: RecordUsageDto = req.body;
const usage = await this.service.recordUsage(dto);
res.status(201).json({ data: usage });
} catch (error) {
next(error);
}
}
/**
* PUT /usage/:id
* Update usage record
*/
private async updateUsage(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: UpdateUsageDto = req.body;
const usage = await this.service.update(req.params.id, dto);
res.json({ data: usage });
} catch (error) {
next(error);
}
}
/**
* POST /usage/increment
* Increment a specific metric
*/
private async incrementMetric(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: IncrementUsageDto = req.body;
await this.service.incrementMetric(
dto.tenantId,
dto.metric as keyof UsageMetrics,
dto.amount || 1
);
res.json({ success: true });
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1,75 @@
/**
* Create Invoice DTO
*/
import { InvoiceStatus, InvoiceItemType } from '../entities';
export class CreateInvoiceDto {
tenantId: string;
subscriptionId?: string;
invoiceDate?: Date;
periodStart: Date;
periodEnd: Date;
billingName?: string;
billingEmail?: string;
billingAddress?: Record<string, any>;
taxId?: string;
dueDate: Date;
currency?: string;
notes?: string;
internalNotes?: string;
items: CreateInvoiceItemDto[];
}
export class CreateInvoiceItemDto {
itemType: InvoiceItemType;
description: string;
quantity: number;
unitPrice: number;
discountPercent?: number;
metadata?: Record<string, any>;
}
export class UpdateInvoiceDto {
billingName?: string;
billingEmail?: string;
billingAddress?: Record<string, any>;
taxId?: string;
dueDate?: Date;
notes?: string;
internalNotes?: string;
}
export class RecordPaymentDto {
amount: number;
paymentMethod: string;
paymentReference?: string;
paymentDate?: Date;
}
export class VoidInvoiceDto {
reason: string;
}
export class RefundInvoiceDto {
amount?: number;
reason: string;
}
export class GenerateInvoiceDto {
tenantId: string;
subscriptionId: string;
periodStart: Date;
periodEnd: Date;
includeUsageCharges?: boolean;
}
export class InvoiceFilterDto {
tenantId?: string;
status?: InvoiceStatus;
dateFrom?: Date;
dateTo?: Date;
overdue?: boolean;
limit?: number;
offset?: number;
}

View File

@ -0,0 +1,41 @@
/**
* Create Subscription Plan DTO
*/
import { PlanType } from '../entities';
export class CreateSubscriptionPlanDto {
code: string;
name: string;
description?: string;
planType?: PlanType;
baseMonthlyPrice: number;
baseAnnualPrice?: number;
setupFee?: number;
maxUsers?: number;
maxBranches?: number;
storageGb?: number;
apiCallsMonthly?: number;
includedModules?: string[];
includedPlatforms?: string[];
features?: Record<string, boolean>;
isActive?: boolean;
isPublic?: boolean;
}
export class UpdateSubscriptionPlanDto {
name?: string;
description?: string;
baseMonthlyPrice?: number;
baseAnnualPrice?: number;
setupFee?: number;
maxUsers?: number;
maxBranches?: number;
storageGb?: number;
apiCallsMonthly?: number;
includedModules?: string[];
includedPlatforms?: string[];
features?: Record<string, boolean>;
isActive?: boolean;
isPublic?: boolean;
}

View File

@ -0,0 +1,57 @@
/**
* Create Tenant Subscription DTO
*/
import { BillingCycle, SubscriptionStatus } from '../entities';
export class CreateTenantSubscriptionDto {
tenantId: string;
planId: string;
billingCycle?: BillingCycle;
currentPeriodStart?: Date;
currentPeriodEnd?: Date;
billingEmail?: string;
billingName?: string;
billingAddress?: Record<string, any>;
taxId?: string;
currentPrice: number;
discountPercent?: number;
discountReason?: string;
contractedUsers?: number;
contractedBranches?: number;
autoRenew?: boolean;
// Trial
startWithTrial?: boolean;
trialDays?: number;
}
export class UpdateTenantSubscriptionDto {
planId?: string;
billingCycle?: BillingCycle;
billingEmail?: string;
billingName?: string;
billingAddress?: Record<string, any>;
taxId?: string;
currentPrice?: number;
discountPercent?: number;
discountReason?: string;
contractedUsers?: number;
contractedBranches?: number;
autoRenew?: boolean;
}
export class CancelSubscriptionDto {
reason?: string;
cancelImmediately?: boolean;
}
export class ChangePlanDto {
newPlanId: string;
effectiveDate?: Date;
prorateBilling?: boolean;
}
export class SetPaymentMethodDto {
paymentMethodId: string;
paymentProvider: string;
}

View File

@ -0,0 +1,8 @@
/**
* Billing Usage DTOs Index
*/
export * from './create-subscription-plan.dto';
export * from './create-subscription.dto';
export * from './create-invoice.dto';
export * from './usage-tracking.dto';

View File

@ -0,0 +1,90 @@
/**
* Usage Tracking DTO
*/
export class RecordUsageDto {
tenantId: string;
periodStart: Date;
periodEnd: Date;
activeUsers?: number;
peakConcurrentUsers?: number;
usersByProfile?: Record<string, number>;
usersByPlatform?: Record<string, number>;
activeBranches?: number;
storageUsedGb?: number;
documentsCount?: number;
apiCalls?: number;
apiErrors?: number;
salesCount?: number;
salesAmount?: number;
invoicesGenerated?: number;
mobileSessions?: number;
offlineSyncs?: number;
paymentTransactions?: number;
}
export class UpdateUsageDto {
activeUsers?: number;
peakConcurrentUsers?: number;
usersByProfile?: Record<string, number>;
usersByPlatform?: Record<string, number>;
activeBranches?: number;
storageUsedGb?: number;
documentsCount?: number;
apiCalls?: number;
apiErrors?: number;
salesCount?: number;
salesAmount?: number;
invoicesGenerated?: number;
mobileSessions?: number;
offlineSyncs?: number;
paymentTransactions?: number;
}
export class IncrementUsageDto {
tenantId: string;
metric: keyof UsageMetrics;
amount?: number;
}
export interface UsageMetrics {
apiCalls: number;
apiErrors: number;
salesCount: number;
salesAmount: number;
invoicesGenerated: number;
mobileSessions: number;
offlineSyncs: number;
paymentTransactions: number;
documentsCount: number;
storageUsedGb: number;
}
export class UsageReportDto {
tenantId: string;
startDate: Date;
endDate: Date;
granularity?: 'daily' | 'weekly' | 'monthly';
}
export class UsageSummaryDto {
tenantId: string;
currentUsers: number;
currentBranches: number;
currentStorageGb: number;
apiCallsThisMonth: number;
salesThisMonth: number;
salesAmountThisMonth: number;
limits: {
maxUsers: number;
maxBranches: number;
maxStorageGb: number;
maxApiCalls: number;
};
percentages: {
usersUsed: number;
branchesUsed: number;
storageUsed: number;
apiCallsUsed: number;
};
}

View File

@ -0,0 +1,72 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export type BillingAlertType =
| 'usage_limit'
| 'payment_due'
| 'payment_failed'
| 'trial_ending'
| 'subscription_ending';
export type AlertSeverity = 'info' | 'warning' | 'critical';
export type AlertStatus = 'active' | 'acknowledged' | 'resolved';
/**
* Entidad para alertas de facturacion y limites de uso.
* Mapea a billing.billing_alerts (DDL: 05-billing-usage.sql)
*/
@Entity({ name: 'billing_alerts', schema: 'billing' })
export class BillingAlert {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
// Tipo de alerta
@Index()
@Column({ name: 'alert_type', type: 'varchar', length: 30 })
alertType: BillingAlertType;
// Detalles
@Column({ type: 'varchar', length: 200 })
title: string;
@Column({ type: 'text', nullable: true })
message: string;
@Column({ type: 'varchar', length: 20, default: 'info' })
severity: AlertSeverity;
// Estado
@Index()
@Column({ type: 'varchar', length: 20, default: 'active' })
status: AlertStatus;
// Notificacion
@Column({ name: 'notified_at', type: 'timestamptz', nullable: true })
notifiedAt: Date;
@Column({ name: 'acknowledged_at', type: 'timestamptz', nullable: true })
acknowledgedAt: Date;
@Column({ name: 'acknowledged_by', type: 'uuid', nullable: true })
acknowledgedBy: string;
// Metadata
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,44 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
Unique,
} from 'typeorm';
import { Coupon } from './coupon.entity.js';
import { TenantSubscription } from './tenant-subscription.entity.js';
@Entity({ name: 'coupon_redemptions', schema: 'billing' })
@Unique(['couponId', 'tenantId'])
export class CouponRedemption {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'coupon_id', type: 'uuid' })
couponId!: string;
@ManyToOne(() => Coupon, (coupon) => coupon.redemptions)
@JoinColumn({ name: 'coupon_id' })
coupon!: Coupon;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId!: string;
@Column({ name: 'subscription_id', type: 'uuid', nullable: true })
subscriptionId?: string;
@ManyToOne(() => TenantSubscription, { nullable: true })
@JoinColumn({ name: 'subscription_id' })
subscription?: TenantSubscription;
@Column({ name: 'discount_amount', type: 'decimal', precision: 10, scale: 2 })
discountAmount!: number;
@CreateDateColumn({ name: 'redeemed_at', type: 'timestamptz' })
redeemedAt!: Date;
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt?: Date;
}

View File

@ -0,0 +1,72 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { CouponRedemption } from './coupon-redemption.entity.js';
export type DiscountType = 'percentage' | 'fixed';
export type DurationPeriod = 'once' | 'forever' | 'months';
@Entity({ name: 'coupons', schema: 'billing' })
export class Coupon {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 50, unique: true })
code!: string;
@Column({ type: 'varchar', length: 255 })
name!: string;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ name: 'discount_type', type: 'varchar', length: 20 })
discountType!: DiscountType;
@Column({ name: 'discount_value', type: 'decimal', precision: 10, scale: 2 })
discountValue!: number;
@Column({ type: 'varchar', length: 3, default: 'MXN' })
currency!: string;
@Column({ name: 'applicable_plans', type: 'uuid', array: true, default: [] })
applicablePlans!: string[];
@Column({ name: 'min_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
minAmount!: number;
@Column({ name: 'duration_period', type: 'varchar', length: 20, default: 'once' })
durationPeriod!: DurationPeriod;
@Column({ name: 'duration_months', type: 'integer', nullable: true })
durationMonths?: number;
@Column({ name: 'max_redemptions', type: 'integer', nullable: true })
maxRedemptions?: number;
@Column({ name: 'current_redemptions', type: 'integer', default: 0 })
currentRedemptions!: number;
@Column({ name: 'valid_from', type: 'timestamptz', nullable: true })
validFrom?: Date;
@Column({ name: 'valid_until', type: 'timestamptz', nullable: true })
validUntil?: Date;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@OneToMany(() => CouponRedemption, (redemption) => redemption.coupon)
redemptions!: CouponRedemption[];
}

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