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:
parent
2f76d541d2
commit
95c6b58449
54
package.json
54
package.json
@ -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
69
src/config/database.ts
Normal 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
35
src/config/index.ts
Normal 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
178
src/config/redis.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
196
src/config/swagger.config.ts
Normal file
196
src/config/swagger.config.ts
Normal 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
254
src/config/typeorm.ts
Normal 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
71
src/index.ts
Normal 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
76
src/modules/ai/README.md
Normal 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
|
||||
66
src/modules/ai/ai.module.ts
Normal file
66
src/modules/ai/ai.module.ts
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
381
src/modules/ai/controllers/ai.controller.ts
Normal file
381
src/modules/ai/controllers/ai.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/modules/ai/controllers/index.ts
Normal file
1
src/modules/ai/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { AIController } from './ai.controller';
|
||||
343
src/modules/ai/dto/ai.dto.ts
Normal file
343
src/modules/ai/dto/ai.dto.ts
Normal 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[];
|
||||
}
|
||||
9
src/modules/ai/dto/index.ts
Normal file
9
src/modules/ai/dto/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export {
|
||||
CreatePromptDto,
|
||||
UpdatePromptDto,
|
||||
CreateConversationDto,
|
||||
UpdateConversationDto,
|
||||
AddMessageDto,
|
||||
LogUsageDto,
|
||||
UpdateQuotaDto,
|
||||
} from './ai.dto';
|
||||
92
src/modules/ai/entities/completion.entity.ts
Normal file
92
src/modules/ai/entities/completion.entity.ts
Normal 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;
|
||||
}
|
||||
160
src/modules/ai/entities/conversation.entity.ts
Normal file
160
src/modules/ai/entities/conversation.entity.ts
Normal 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;
|
||||
}
|
||||
77
src/modules/ai/entities/embedding.entity.ts
Normal file
77
src/modules/ai/entities/embedding.entity.ts
Normal 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;
|
||||
}
|
||||
7
src/modules/ai/entities/index.ts
Normal file
7
src/modules/ai/entities/index.ts
Normal 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';
|
||||
98
src/modules/ai/entities/knowledge-base.entity.ts
Normal file
98
src/modules/ai/entities/knowledge-base.entity.ts
Normal 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;
|
||||
}
|
||||
78
src/modules/ai/entities/model.entity.ts
Normal file
78
src/modules/ai/entities/model.entity.ts
Normal 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;
|
||||
}
|
||||
110
src/modules/ai/entities/prompt.entity.ts
Normal file
110
src/modules/ai/entities/prompt.entity.ts
Normal 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;
|
||||
}
|
||||
120
src/modules/ai/entities/usage.entity.ts
Normal file
120
src/modules/ai/entities/usage.entity.ts
Normal 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
5
src/modules/ai/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export { AIModule, AIModuleOptions } from './ai.module';
|
||||
export * from './entities';
|
||||
export * from './services';
|
||||
export * from './controllers';
|
||||
export * from './dto';
|
||||
86
src/modules/ai/prompts/admin-system-prompt.ts
Normal file
86
src/modules/ai/prompts/admin-system-prompt.ts
Normal 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');
|
||||
}
|
||||
67
src/modules/ai/prompts/customer-system-prompt.ts
Normal file
67
src/modules/ai/prompts/customer-system-prompt.ts
Normal 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. **Sé 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
|
||||
|
||||
Sé 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');
|
||||
}
|
||||
48
src/modules/ai/prompts/index.ts
Normal file
48
src/modules/ai/prompts/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
70
src/modules/ai/prompts/operator-system-prompt.ts
Normal file
70
src/modules/ai/prompts/operator-system-prompt.ts
Normal 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. **Sé 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));
|
||||
}
|
||||
78
src/modules/ai/prompts/supervisor-system-prompt.ts
Normal file
78
src/modules/ai/prompts/supervisor-system-prompt.ts
Normal 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
|
||||
|
||||
- Sé 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));
|
||||
}
|
||||
252
src/modules/ai/roles/erp-roles.config.ts
Normal file
252
src/modules/ai/roles/erp-roles.config.ts
Normal 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;
|
||||
}
|
||||
14
src/modules/ai/roles/index.ts
Normal file
14
src/modules/ai/roles/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* ERP Roles Index
|
||||
*/
|
||||
|
||||
export {
|
||||
ERPRole,
|
||||
ERPRoleConfig,
|
||||
ERP_ROLES,
|
||||
DB_ROLE_MAPPING,
|
||||
getERPRole,
|
||||
hasToolAccess,
|
||||
getToolsForRole,
|
||||
getRoleConfig,
|
||||
} from './erp-roles.config';
|
||||
382
src/modules/ai/services/ai.service.ts
Normal file
382
src/modules/ai/services/ai.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
11
src/modules/ai/services/index.ts
Normal file
11
src/modules/ai/services/index.ts
Normal 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';
|
||||
455
src/modules/ai/services/role-based-ai.service.ts
Normal file
455
src/modules/ai/services/role-based-ai.service.ts
Normal 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>;
|
||||
}
|
||||
70
src/modules/audit/audit.module.ts
Normal file
70
src/modules/audit/audit.module.ts
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
335
src/modules/audit/controllers/audit.controller.ts
Normal file
335
src/modules/audit/controllers/audit.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/modules/audit/controllers/index.ts
Normal file
1
src/modules/audit/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { AuditController } from './audit.controller';
|
||||
346
src/modules/audit/dto/audit.dto.ts
Normal file
346
src/modules/audit/dto/audit.dto.ts
Normal 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;
|
||||
}
|
||||
10
src/modules/audit/dto/index.ts
Normal file
10
src/modules/audit/dto/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export {
|
||||
CreateAuditLogDto,
|
||||
CreateEntityChangeDto,
|
||||
CreateLoginHistoryDto,
|
||||
CreateSensitiveDataAccessDto,
|
||||
CreateDataExportDto,
|
||||
UpdateDataExportStatusDto,
|
||||
CreatePermissionChangeDto,
|
||||
CreateConfigChangeDto,
|
||||
} from './audit.dto';
|
||||
108
src/modules/audit/entities/audit-log.entity.ts
Normal file
108
src/modules/audit/entities/audit-log.entity.ts
Normal 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;
|
||||
}
|
||||
47
src/modules/audit/entities/config-change.entity.ts
Normal file
47
src/modules/audit/entities/config-change.entity.ts
Normal 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;
|
||||
}
|
||||
80
src/modules/audit/entities/data-export.entity.ts
Normal file
80
src/modules/audit/entities/data-export.entity.ts
Normal 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;
|
||||
}
|
||||
55
src/modules/audit/entities/entity-change.entity.ts
Normal file
55
src/modules/audit/entities/entity-change.entity.ts
Normal 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;
|
||||
}
|
||||
7
src/modules/audit/entities/index.ts
Normal file
7
src/modules/audit/entities/index.ts
Normal 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';
|
||||
106
src/modules/audit/entities/login-history.entity.ts
Normal file
106
src/modules/audit/entities/login-history.entity.ts
Normal 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;
|
||||
}
|
||||
63
src/modules/audit/entities/permission-change.entity.ts
Normal file
63
src/modules/audit/entities/permission-change.entity.ts
Normal 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;
|
||||
}
|
||||
62
src/modules/audit/entities/sensitive-data-access.entity.ts
Normal file
62
src/modules/audit/entities/sensitive-data-access.entity.ts
Normal 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;
|
||||
}
|
||||
5
src/modules/audit/index.ts
Normal file
5
src/modules/audit/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export { AuditModule, AuditModuleOptions } from './audit.module';
|
||||
export * from './entities';
|
||||
export * from './services';
|
||||
export * from './controllers';
|
||||
export * from './dto';
|
||||
303
src/modules/audit/services/audit.service.ts
Normal file
303
src/modules/audit/services/audit.service.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
1
src/modules/audit/services/index.ts
Normal file
1
src/modules/audit/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { AuditService, AuditLogFilters, PaginationOptions } from './audit.service';
|
||||
334
src/modules/auth/apiKeys.controller.ts
Normal file
334
src/modules/auth/apiKeys.controller.ts
Normal 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();
|
||||
56
src/modules/auth/apiKeys.routes.ts
Normal file
56
src/modules/auth/apiKeys.routes.ts
Normal 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;
|
||||
491
src/modules/auth/apiKeys.service.ts
Normal file
491
src/modules/auth/apiKeys.service.ts
Normal 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();
|
||||
192
src/modules/auth/auth.controller.ts
Normal file
192
src/modules/auth/auth.controller.ts
Normal 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();
|
||||
18
src/modules/auth/auth.routes.ts
Normal file
18
src/modules/auth/auth.routes.ts
Normal 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;
|
||||
234
src/modules/auth/auth.service.ts
Normal file
234
src/modules/auth/auth.service.ts
Normal 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();
|
||||
87
src/modules/auth/entities/api-key.entity.ts
Normal file
87
src/modules/auth/entities/api-key.entity.ts
Normal 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;
|
||||
}
|
||||
93
src/modules/auth/entities/company.entity.ts
Normal file
93
src/modules/auth/entities/company.entity.ts
Normal 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;
|
||||
}
|
||||
64
src/modules/auth/entities/device.entity.ts
Normal file
64
src/modules/auth/entities/device.entity.ts
Normal 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;
|
||||
}
|
||||
89
src/modules/auth/entities/group.entity.ts
Normal file
89
src/modules/auth/entities/group.entity.ts
Normal 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;
|
||||
}
|
||||
26
src/modules/auth/entities/index.ts
Normal file
26
src/modules/auth/entities/index.ts
Normal 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.
|
||||
87
src/modules/auth/entities/mfa-audit-log.entity.ts
Normal file
87
src/modules/auth/entities/mfa-audit-log.entity.ts
Normal 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;
|
||||
}
|
||||
191
src/modules/auth/entities/oauth-provider.entity.ts
Normal file
191
src/modules/auth/entities/oauth-provider.entity.ts
Normal 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;
|
||||
}
|
||||
66
src/modules/auth/entities/oauth-state.entity.ts
Normal file
66
src/modules/auth/entities/oauth-state.entity.ts
Normal 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;
|
||||
}
|
||||
73
src/modules/auth/entities/oauth-user-link.entity.ts
Normal file
73
src/modules/auth/entities/oauth-user-link.entity.ts
Normal 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;
|
||||
}
|
||||
45
src/modules/auth/entities/password-reset.entity.ts
Normal file
45
src/modules/auth/entities/password-reset.entity.ts
Normal 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;
|
||||
}
|
||||
52
src/modules/auth/entities/permission.entity.ts
Normal file
52
src/modules/auth/entities/permission.entity.ts
Normal 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;
|
||||
}
|
||||
27
src/modules/auth/entities/profile-module.entity.ts
Normal file
27
src/modules/auth/entities/profile-module.entity.ts
Normal 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;
|
||||
}
|
||||
36
src/modules/auth/entities/profile-tool.entity.ts
Normal file
36
src/modules/auth/entities/profile-tool.entity.ts
Normal 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;
|
||||
}
|
||||
84
src/modules/auth/entities/role.entity.ts
Normal file
84
src/modules/auth/entities/role.entity.ts
Normal 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;
|
||||
}
|
||||
90
src/modules/auth/entities/session.entity.ts
Normal file
90
src/modules/auth/entities/session.entity.ts
Normal 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;
|
||||
}
|
||||
93
src/modules/auth/entities/tenant.entity.ts
Normal file
93
src/modules/auth/entities/tenant.entity.ts
Normal 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;
|
||||
}
|
||||
115
src/modules/auth/entities/trusted-device.entity.ts
Normal file
115
src/modules/auth/entities/trusted-device.entity.ts
Normal 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;
|
||||
}
|
||||
36
src/modules/auth/entities/user-profile-assignment.entity.ts
Normal file
36
src/modules/auth/entities/user-profile-assignment.entity.ts
Normal 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;
|
||||
}
|
||||
52
src/modules/auth/entities/user-profile.entity.ts
Normal file
52
src/modules/auth/entities/user-profile.entity.ts
Normal 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[];
|
||||
}
|
||||
159
src/modules/auth/entities/user.entity.ts
Normal file
159
src/modules/auth/entities/user.entity.ts
Normal 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;
|
||||
}
|
||||
90
src/modules/auth/entities/verification-code.entity.ts
Normal file
90
src/modules/auth/entities/verification-code.entity.ts
Normal 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;
|
||||
}
|
||||
8
src/modules/auth/index.ts
Normal file
8
src/modules/auth/index.ts
Normal 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';
|
||||
456
src/modules/auth/services/token.service.ts
Normal file
456
src/modules/auth/services/token.service.ts
Normal 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();
|
||||
409
src/modules/billing-usage/__tests__/coupons.service.test.ts
Normal file
409
src/modules/billing-usage/__tests__/coupons.service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
360
src/modules/billing-usage/__tests__/invoices.service.spec.ts
Normal file
360
src/modules/billing-usage/__tests__/invoices.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
786
src/modules/billing-usage/__tests__/invoices.service.test.ts
Normal file
786
src/modules/billing-usage/__tests__/invoices.service.test.ts
Normal 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$/),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
466
src/modules/billing-usage/__tests__/plan-limits.service.test.ts
Normal file
466
src/modules/billing-usage/__tests__/plan-limits.service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
61
src/modules/billing-usage/billing-usage.module.ts
Normal file
61
src/modules/billing-usage/billing-usage.module.ts
Normal 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;
|
||||
8
src/modules/billing-usage/controllers/index.ts
Normal file
8
src/modules/billing-usage/controllers/index.ts
Normal 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';
|
||||
258
src/modules/billing-usage/controllers/invoices.controller.ts
Normal file
258
src/modules/billing-usage/controllers/invoices.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
173
src/modules/billing-usage/controllers/usage.controller.ts
Normal file
173
src/modules/billing-usage/controllers/usage.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
75
src/modules/billing-usage/dto/create-invoice.dto.ts
Normal file
75
src/modules/billing-usage/dto/create-invoice.dto.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
57
src/modules/billing-usage/dto/create-subscription.dto.ts
Normal file
57
src/modules/billing-usage/dto/create-subscription.dto.ts
Normal 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;
|
||||
}
|
||||
8
src/modules/billing-usage/dto/index.ts
Normal file
8
src/modules/billing-usage/dto/index.ts
Normal 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';
|
||||
90
src/modules/billing-usage/dto/usage-tracking.dto.ts
Normal file
90
src/modules/billing-usage/dto/usage-tracking.dto.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
72
src/modules/billing-usage/entities/billing-alert.entity.ts
Normal file
72
src/modules/billing-usage/entities/billing-alert.entity.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
72
src/modules/billing-usage/entities/coupon.entity.ts
Normal file
72
src/modules/billing-usage/entities/coupon.entity.ts
Normal 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
Loading…
Reference in New Issue
Block a user