[PROP-CORE-004] feat: Add Phase 6 modules from erp-core
Propagated modules: - payment-terminals: MercadoPago + Clip TPV (PCI-DSS compliant) - ai: Role-based AI access (ADMIN, DOCTOR, RECEPCIONISTA, PACIENTE) - mcp: 18 ERP tools for AI assistants Note: Payment data must NOT be stored in clinical records. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
50eb26fa53
commit
297720cdf2
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>;
|
||||
}
|
||||
1
src/modules/mcp/controllers/index.ts
Normal file
1
src/modules/mcp/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { McpController } from './mcp.controller';
|
||||
223
src/modules/mcp/controllers/mcp.controller.ts
Normal file
223
src/modules/mcp/controllers/mcp.controller.ts
Normal file
@ -0,0 +1,223 @@
|
||||
import { Request, Response, NextFunction, Router } from 'express';
|
||||
import { McpServerService } from '../services/mcp-server.service';
|
||||
import { McpContext, CallerType } from '../interfaces';
|
||||
|
||||
export class McpController {
|
||||
public router: Router;
|
||||
|
||||
constructor(private readonly mcpService: McpServerService) {
|
||||
this.router = Router();
|
||||
this.initializeRoutes();
|
||||
}
|
||||
|
||||
private initializeRoutes(): void {
|
||||
// Tools
|
||||
this.router.get('/tools', this.listTools.bind(this));
|
||||
this.router.get('/tools/:name', this.getTool.bind(this));
|
||||
this.router.post('/tools/call', this.callTool.bind(this));
|
||||
|
||||
// Resources
|
||||
this.router.get('/resources', this.listResources.bind(this));
|
||||
this.router.get('/resources/*', this.getResource.bind(this));
|
||||
|
||||
// History / Audit
|
||||
this.router.get('/tool-calls', this.getToolCallHistory.bind(this));
|
||||
this.router.get('/tool-calls/:id', this.getToolCallDetails.bind(this));
|
||||
this.router.get('/stats', this.getToolStats.bind(this));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TOOLS
|
||||
// ============================================
|
||||
|
||||
private async listTools(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tools = this.mcpService.listTools();
|
||||
res.json({ data: tools, total: tools.length });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getTool(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const tool = this.mcpService.getTool(name);
|
||||
|
||||
if (!tool) {
|
||||
res.status(404).json({ error: 'Tool not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: tool });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async callTool(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { tool, parameters } = req.body;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
const agentId = req.headers['x-agent-id'] as string;
|
||||
const conversationId = req.headers['x-conversation-id'] as string;
|
||||
|
||||
if (!tool) {
|
||||
res.status(400).json({ error: 'tool name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'x-tenant-id header is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const context: McpContext = {
|
||||
tenantId,
|
||||
userId,
|
||||
agentId,
|
||||
conversationId,
|
||||
callerType: (req.headers['x-caller-type'] as CallerType) || 'api',
|
||||
permissions: this.extractPermissions(req),
|
||||
};
|
||||
|
||||
const result = await this.mcpService.callTool(tool, parameters || {}, context);
|
||||
res.json({ data: result });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RESOURCES
|
||||
// ============================================
|
||||
|
||||
private async listResources(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const resources = this.mcpService.listResources();
|
||||
res.json({ data: resources, total: resources.length });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getResource(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const uri = 'erp://' + req.params[0];
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'x-tenant-id header is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const context: McpContext = {
|
||||
tenantId,
|
||||
userId,
|
||||
callerType: 'api',
|
||||
permissions: this.extractPermissions(req),
|
||||
};
|
||||
|
||||
const content = await this.mcpService.getResource(uri, context);
|
||||
res.json({ data: { uri, content } });
|
||||
} catch (error: any) {
|
||||
if (error.message.includes('not found')) {
|
||||
res.status(404).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HISTORY / AUDIT
|
||||
// ============================================
|
||||
|
||||
private async getToolCallHistory(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'x-tenant-id header is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const filters = {
|
||||
toolName: req.query.toolName as string,
|
||||
status: req.query.status as any,
|
||||
startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined,
|
||||
endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined,
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||
};
|
||||
|
||||
const result = await this.mcpService.getCallHistory(tenantId, filters);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getToolCallDetails(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'x-tenant-id header is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const call = await this.mcpService.getCallDetails(id, tenantId);
|
||||
|
||||
if (!call) {
|
||||
res.status(404).json({ error: 'Tool call not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: call });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getToolStats(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'x-tenant-id header is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const startDate = req.query.startDate
|
||||
? new Date(req.query.startDate as string)
|
||||
: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); // 7 days ago
|
||||
const endDate = req.query.endDate
|
||||
? new Date(req.query.endDate as string)
|
||||
: new Date();
|
||||
|
||||
const stats = await this.mcpService.getToolStats(tenantId, startDate, endDate);
|
||||
res.json({ data: stats, total: stats.length });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPERS
|
||||
// ============================================
|
||||
|
||||
private extractPermissions(req: Request): string[] {
|
||||
const permHeader = req.headers['x-permissions'] as string;
|
||||
if (!permHeader) return [];
|
||||
|
||||
try {
|
||||
return JSON.parse(permHeader);
|
||||
} catch {
|
||||
return permHeader.split(',').map((p) => p.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/modules/mcp/dto/index.ts
Normal file
1
src/modules/mcp/dto/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './mcp.dto';
|
||||
66
src/modules/mcp/dto/mcp.dto.ts
Normal file
66
src/modules/mcp/dto/mcp.dto.ts
Normal file
@ -0,0 +1,66 @@
|
||||
// =====================================================
|
||||
// DTOs: MCP Server
|
||||
// Modulo: MGN-022
|
||||
// Version: 1.0.0
|
||||
// =====================================================
|
||||
|
||||
import { ToolCallStatus } from '../entities';
|
||||
import { CallerType } from '../interfaces';
|
||||
|
||||
// ============================================
|
||||
// Tool Call DTOs
|
||||
// ============================================
|
||||
|
||||
export interface CallToolDto {
|
||||
tool: string;
|
||||
parameters?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ToolCallResultDto {
|
||||
success: boolean;
|
||||
toolName: string;
|
||||
result?: any;
|
||||
error?: string;
|
||||
callId: string;
|
||||
}
|
||||
|
||||
export interface StartCallData {
|
||||
tenantId: string;
|
||||
toolName: string;
|
||||
parameters: Record<string, any>;
|
||||
agentId?: string;
|
||||
conversationId?: string;
|
||||
callerType: CallerType;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// History & Filters DTOs
|
||||
// ============================================
|
||||
|
||||
export interface CallHistoryFilters {
|
||||
toolName?: string;
|
||||
status?: ToolCallStatus;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Resource DTOs
|
||||
// ============================================
|
||||
|
||||
export interface ResourceContentDto {
|
||||
uri: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
content: any;
|
||||
}
|
||||
2
src/modules/mcp/entities/index.ts
Normal file
2
src/modules/mcp/entities/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { ToolCall, ToolCallStatus } from './tool-call.entity';
|
||||
export { ToolCallResult, ResultType } from './tool-call-result.entity';
|
||||
45
src/modules/mcp/entities/tool-call-result.entity.ts
Normal file
45
src/modules/mcp/entities/tool-call-result.entity.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
OneToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { ToolCall } from './tool-call.entity';
|
||||
|
||||
export type ResultType = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null' | 'error';
|
||||
|
||||
@Entity({ name: 'tool_call_results', schema: 'ai' })
|
||||
export class ToolCallResult {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'tool_call_id', type: 'uuid' })
|
||||
toolCallId: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
result: any;
|
||||
|
||||
@Column({ name: 'result_type', type: 'varchar', length: 20, default: 'object' })
|
||||
resultType: ResultType;
|
||||
|
||||
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||
errorMessage: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'error_code', type: 'varchar', length: 50, nullable: true })
|
||||
errorCode: string;
|
||||
|
||||
@Column({ name: 'tokens_used', type: 'int', nullable: true })
|
||||
tokensUsed: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@OneToOne(() => ToolCall, (call) => call.result, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'tool_call_id' })
|
||||
toolCall: ToolCall;
|
||||
}
|
||||
65
src/modules/mcp/entities/tool-call.entity.ts
Normal file
65
src/modules/mcp/entities/tool-call.entity.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
OneToOne,
|
||||
} from 'typeorm';
|
||||
import { ToolCallResult } from './tool-call-result.entity';
|
||||
import { CallerType } from '../interfaces';
|
||||
|
||||
export type ToolCallStatus = 'pending' | 'running' | 'success' | 'error' | 'timeout';
|
||||
|
||||
@Entity({ name: 'tool_calls', schema: 'ai' })
|
||||
export class ToolCall {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'agent_id', type: 'uuid', nullable: true })
|
||||
agentId: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'conversation_id', type: 'uuid', nullable: true })
|
||||
conversationId: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'tool_name', type: 'varchar', length: 100 })
|
||||
toolName: string;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
parameters: Record<string, any>;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'varchar', length: 20, default: 'pending' })
|
||||
status: ToolCallStatus;
|
||||
|
||||
@Column({ name: 'duration_ms', type: 'int', nullable: true })
|
||||
durationMs: number;
|
||||
|
||||
@Column({ name: 'started_at', type: 'timestamptz' })
|
||||
startedAt: Date;
|
||||
|
||||
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||
completedAt: Date;
|
||||
|
||||
@Column({ name: 'called_by_user_id', type: 'uuid', nullable: true })
|
||||
calledByUserId: string;
|
||||
|
||||
@Column({ name: 'caller_type', type: 'varchar', length: 20, default: 'agent' })
|
||||
callerType: CallerType;
|
||||
|
||||
@Column({ name: 'caller_context', type: 'varchar', length: 100, nullable: true })
|
||||
callerContext: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@OneToOne(() => ToolCallResult, (result) => result.toolCall)
|
||||
result: ToolCallResult;
|
||||
}
|
||||
7
src/modules/mcp/index.ts
Normal file
7
src/modules/mcp/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { McpModule, McpModuleOptions } from './mcp.module';
|
||||
export { McpServerService, ToolRegistryService, ToolLoggerService } from './services';
|
||||
export { McpController } from './controllers';
|
||||
export { ToolCall, ToolCallResult, ToolCallStatus, ResultType } from './entities';
|
||||
export * from './interfaces';
|
||||
export * from './dto';
|
||||
export * from './tools';
|
||||
3
src/modules/mcp/interfaces/index.ts
Normal file
3
src/modules/mcp/interfaces/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './mcp-tool.interface';
|
||||
export * from './mcp-context.interface';
|
||||
export * from './mcp-resource.interface';
|
||||
17
src/modules/mcp/interfaces/mcp-context.interface.ts
Normal file
17
src/modules/mcp/interfaces/mcp-context.interface.ts
Normal file
@ -0,0 +1,17 @@
|
||||
// =====================================================
|
||||
// Interfaces: MCP Context
|
||||
// Modulo: MGN-022
|
||||
// Version: 1.0.0
|
||||
// =====================================================
|
||||
|
||||
export type CallerType = 'agent' | 'api' | 'webhook' | 'system' | 'test';
|
||||
|
||||
export interface McpContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
conversationId?: string;
|
||||
callerType: CallerType;
|
||||
permissions: string[];
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
18
src/modules/mcp/interfaces/mcp-resource.interface.ts
Normal file
18
src/modules/mcp/interfaces/mcp-resource.interface.ts
Normal file
@ -0,0 +1,18 @@
|
||||
// =====================================================
|
||||
// Interfaces: MCP Resource
|
||||
// Modulo: MGN-022
|
||||
// Version: 1.0.0
|
||||
// =====================================================
|
||||
|
||||
import { McpContext } from './mcp-context.interface';
|
||||
|
||||
export interface McpResource {
|
||||
uri: string;
|
||||
name: string;
|
||||
description: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface McpResourceWithHandler extends McpResource {
|
||||
handler: (context: McpContext) => Promise<any>;
|
||||
}
|
||||
62
src/modules/mcp/interfaces/mcp-tool.interface.ts
Normal file
62
src/modules/mcp/interfaces/mcp-tool.interface.ts
Normal file
@ -0,0 +1,62 @@
|
||||
// =====================================================
|
||||
// Interfaces: MCP Tool
|
||||
// Modulo: MGN-022
|
||||
// Version: 1.0.0
|
||||
// =====================================================
|
||||
|
||||
import { McpContext } from './mcp-context.interface';
|
||||
|
||||
export interface JSONSchema {
|
||||
type: string;
|
||||
properties?: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
items?: JSONSchemaProperty;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface JSONSchemaProperty {
|
||||
type: string;
|
||||
description?: string;
|
||||
format?: string;
|
||||
enum?: string[];
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
default?: any;
|
||||
items?: JSONSchemaProperty;
|
||||
properties?: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
}
|
||||
|
||||
export interface RateLimitConfig {
|
||||
maxCalls: number;
|
||||
windowMs: number;
|
||||
perTenant?: boolean;
|
||||
}
|
||||
|
||||
export type ToolCategory =
|
||||
| 'products'
|
||||
| 'inventory'
|
||||
| 'orders'
|
||||
| 'customers'
|
||||
| 'fiados'
|
||||
| 'system';
|
||||
|
||||
export interface McpToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: JSONSchema;
|
||||
returns: JSONSchema;
|
||||
category: ToolCategory;
|
||||
permissions?: string[];
|
||||
rateLimit?: RateLimitConfig;
|
||||
}
|
||||
|
||||
export type McpToolHandler<TParams = any, TResult = any> = (
|
||||
params: TParams,
|
||||
context: McpContext
|
||||
) => Promise<TResult>;
|
||||
|
||||
export interface McpToolProvider {
|
||||
getTools(): McpToolDefinition[];
|
||||
getHandler(toolName: string): McpToolHandler | undefined;
|
||||
}
|
||||
70
src/modules/mcp/mcp.module.ts
Normal file
70
src/modules/mcp/mcp.module.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { Router } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { McpServerService, ToolRegistryService, ToolLoggerService } from './services';
|
||||
import { McpController } from './controllers';
|
||||
import { ToolCall, ToolCallResult } from './entities';
|
||||
import {
|
||||
ProductsToolsService,
|
||||
InventoryToolsService,
|
||||
OrdersToolsService,
|
||||
CustomersToolsService,
|
||||
FiadosToolsService,
|
||||
SalesToolsService,
|
||||
FinancialToolsService,
|
||||
BranchToolsService,
|
||||
} from './tools';
|
||||
|
||||
export interface McpModuleOptions {
|
||||
dataSource: DataSource;
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
export class McpModule {
|
||||
public router: Router;
|
||||
public mcpService: McpServerService;
|
||||
public toolRegistry: ToolRegistryService;
|
||||
private dataSource: DataSource;
|
||||
private basePath: string;
|
||||
|
||||
constructor(options: McpModuleOptions) {
|
||||
this.dataSource = options.dataSource;
|
||||
this.basePath = options.basePath || '';
|
||||
this.router = Router();
|
||||
this.initializeServices();
|
||||
this.initializeRoutes();
|
||||
}
|
||||
|
||||
private initializeServices(): void {
|
||||
// Repositories
|
||||
const toolCallRepository = this.dataSource.getRepository(ToolCall);
|
||||
const toolCallResultRepository = this.dataSource.getRepository(ToolCallResult);
|
||||
|
||||
// Tool Logger
|
||||
const toolLogger = new ToolLoggerService(toolCallRepository, toolCallResultRepository);
|
||||
|
||||
// Tool Registry
|
||||
this.toolRegistry = new ToolRegistryService();
|
||||
|
||||
// Register tool providers
|
||||
this.toolRegistry.registerProvider(new ProductsToolsService());
|
||||
this.toolRegistry.registerProvider(new InventoryToolsService());
|
||||
this.toolRegistry.registerProvider(new OrdersToolsService());
|
||||
this.toolRegistry.registerProvider(new CustomersToolsService());
|
||||
this.toolRegistry.registerProvider(new FiadosToolsService());
|
||||
this.toolRegistry.registerProvider(new SalesToolsService());
|
||||
this.toolRegistry.registerProvider(new FinancialToolsService());
|
||||
this.toolRegistry.registerProvider(new BranchToolsService());
|
||||
|
||||
// MCP Server Service
|
||||
this.mcpService = new McpServerService(this.toolRegistry, toolLogger);
|
||||
}
|
||||
|
||||
private initializeRoutes(): void {
|
||||
const mcpController = new McpController(this.mcpService);
|
||||
this.router.use(`${this.basePath}/mcp`, mcpController.router);
|
||||
}
|
||||
|
||||
static getEntities(): Function[] {
|
||||
return [ToolCall, ToolCallResult];
|
||||
}
|
||||
}
|
||||
3
src/modules/mcp/services/index.ts
Normal file
3
src/modules/mcp/services/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { McpServerService } from './mcp-server.service';
|
||||
export { ToolRegistryService } from './tool-registry.service';
|
||||
export { ToolLoggerService } from './tool-logger.service';
|
||||
197
src/modules/mcp/services/mcp-server.service.ts
Normal file
197
src/modules/mcp/services/mcp-server.service.ts
Normal file
@ -0,0 +1,197 @@
|
||||
import { ToolRegistryService } from './tool-registry.service';
|
||||
import { ToolLoggerService } from './tool-logger.service';
|
||||
import {
|
||||
McpToolDefinition,
|
||||
McpContext,
|
||||
McpResource,
|
||||
McpResourceWithHandler,
|
||||
} from '../interfaces';
|
||||
import { ToolCallResultDto, CallHistoryFilters, PaginatedResult } from '../dto';
|
||||
import { ToolCall } from '../entities';
|
||||
|
||||
export class McpServerService {
|
||||
private resources: Map<string, McpResourceWithHandler> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly toolRegistry: ToolRegistryService,
|
||||
private readonly toolLogger: ToolLoggerService
|
||||
) {
|
||||
this.initializeResources();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TOOLS
|
||||
// ============================================
|
||||
|
||||
listTools(): McpToolDefinition[] {
|
||||
return this.toolRegistry.getAllTools();
|
||||
}
|
||||
|
||||
getTool(name: string): McpToolDefinition | null {
|
||||
return this.toolRegistry.getTool(name);
|
||||
}
|
||||
|
||||
async callTool(
|
||||
toolName: string,
|
||||
params: Record<string, any>,
|
||||
context: McpContext
|
||||
): Promise<ToolCallResultDto> {
|
||||
// 1. Get tool definition
|
||||
const tool = this.toolRegistry.getTool(toolName);
|
||||
if (!tool) {
|
||||
return {
|
||||
success: false,
|
||||
toolName,
|
||||
error: `Tool '${toolName}' not found`,
|
||||
callId: '',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Check permissions
|
||||
if (tool.permissions && tool.permissions.length > 0) {
|
||||
const hasPermission = tool.permissions.some((p) =>
|
||||
context.permissions.includes(p)
|
||||
);
|
||||
if (!hasPermission) {
|
||||
return {
|
||||
success: false,
|
||||
toolName,
|
||||
error: `Missing permissions for tool '${toolName}'`,
|
||||
callId: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Start logging
|
||||
const callId = await this.toolLogger.startCall({
|
||||
tenantId: context.tenantId,
|
||||
toolName,
|
||||
parameters: params,
|
||||
agentId: context.agentId,
|
||||
conversationId: context.conversationId,
|
||||
callerType: context.callerType,
|
||||
userId: context.userId,
|
||||
});
|
||||
|
||||
try {
|
||||
// 4. Get and execute handler
|
||||
const handler = this.toolRegistry.getHandler(toolName);
|
||||
if (!handler) {
|
||||
await this.toolLogger.failCall(callId, 'Handler not found', 'HANDLER_NOT_FOUND');
|
||||
return {
|
||||
success: false,
|
||||
toolName,
|
||||
error: `Handler for tool '${toolName}' not found`,
|
||||
callId,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await handler(params, context);
|
||||
|
||||
// 5. Log success
|
||||
await this.toolLogger.completeCall(callId, result);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
toolName,
|
||||
result,
|
||||
callId,
|
||||
};
|
||||
} catch (error: any) {
|
||||
// 6. Log error
|
||||
await this.toolLogger.failCall(
|
||||
callId,
|
||||
error.message || 'Execution error',
|
||||
error.code || 'EXECUTION_ERROR'
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
toolName,
|
||||
error: error.message || 'Tool execution failed',
|
||||
callId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RESOURCES
|
||||
// ============================================
|
||||
|
||||
listResources(): McpResource[] {
|
||||
return Array.from(this.resources.values()).map(({ handler, ...resource }) => resource);
|
||||
}
|
||||
|
||||
async getResource(uri: string, context: McpContext): Promise<any> {
|
||||
const resource = this.resources.get(uri);
|
||||
if (!resource) {
|
||||
throw new Error(`Resource '${uri}' not found`);
|
||||
}
|
||||
|
||||
return resource.handler(context);
|
||||
}
|
||||
|
||||
private initializeResources(): void {
|
||||
// Business config resource
|
||||
this.resources.set('erp://config/business', {
|
||||
uri: 'erp://config/business',
|
||||
name: 'Business Configuration',
|
||||
description: 'Basic business information and settings',
|
||||
mimeType: 'application/json',
|
||||
handler: async (context) => ({
|
||||
tenantId: context.tenantId,
|
||||
message: 'Business configuration - connect to tenant config service',
|
||||
// TODO: Connect to actual tenant config service
|
||||
}),
|
||||
});
|
||||
|
||||
// Categories catalog resource
|
||||
this.resources.set('erp://catalog/categories', {
|
||||
uri: 'erp://catalog/categories',
|
||||
name: 'Product Categories',
|
||||
description: 'List of product categories',
|
||||
mimeType: 'application/json',
|
||||
handler: async (context) => ({
|
||||
tenantId: context.tenantId,
|
||||
categories: [],
|
||||
message: 'Categories catalog - connect to products service',
|
||||
// TODO: Connect to actual products service
|
||||
}),
|
||||
});
|
||||
|
||||
// Inventory summary resource
|
||||
this.resources.set('erp://inventory/summary', {
|
||||
uri: 'erp://inventory/summary',
|
||||
name: 'Inventory Summary',
|
||||
description: 'Summary of current inventory status',
|
||||
mimeType: 'application/json',
|
||||
handler: async (context) => ({
|
||||
tenantId: context.tenantId,
|
||||
totalProducts: 0,
|
||||
totalValue: 0,
|
||||
lowStockCount: 0,
|
||||
message: 'Inventory summary - connect to inventory service',
|
||||
// TODO: Connect to actual inventory service
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HISTORY / AUDIT
|
||||
// ============================================
|
||||
|
||||
async getCallHistory(
|
||||
tenantId: string,
|
||||
filters: CallHistoryFilters
|
||||
): Promise<PaginatedResult<ToolCall>> {
|
||||
return this.toolLogger.getCallHistory(tenantId, filters);
|
||||
}
|
||||
|
||||
async getCallDetails(id: string, tenantId: string): Promise<ToolCall | null> {
|
||||
return this.toolLogger.getCallById(id, tenantId);
|
||||
}
|
||||
|
||||
async getToolStats(tenantId: string, startDate: Date, endDate: Date) {
|
||||
return this.toolLogger.getToolStats(tenantId, startDate, endDate);
|
||||
}
|
||||
}
|
||||
171
src/modules/mcp/services/tool-logger.service.ts
Normal file
171
src/modules/mcp/services/tool-logger.service.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { ToolCall, ToolCallResult, ResultType } from '../entities';
|
||||
import { StartCallData, CallHistoryFilters, PaginatedResult } from '../dto';
|
||||
|
||||
export class ToolLoggerService {
|
||||
constructor(
|
||||
private readonly toolCallRepo: Repository<ToolCall>,
|
||||
private readonly resultRepo: Repository<ToolCallResult>
|
||||
) {}
|
||||
|
||||
async startCall(data: StartCallData): Promise<string> {
|
||||
const call = this.toolCallRepo.create({
|
||||
tenantId: data.tenantId,
|
||||
toolName: data.toolName,
|
||||
parameters: data.parameters,
|
||||
agentId: data.agentId,
|
||||
conversationId: data.conversationId,
|
||||
callerType: data.callerType,
|
||||
calledByUserId: data.userId,
|
||||
status: 'running',
|
||||
startedAt: new Date(),
|
||||
});
|
||||
|
||||
const saved = await this.toolCallRepo.save(call);
|
||||
return saved.id;
|
||||
}
|
||||
|
||||
async completeCall(callId: string, result: any): Promise<void> {
|
||||
const call = await this.toolCallRepo.findOne({ where: { id: callId } });
|
||||
if (!call) return;
|
||||
|
||||
const duration = Date.now() - call.startedAt.getTime();
|
||||
|
||||
await this.toolCallRepo.update(callId, {
|
||||
status: 'success',
|
||||
completedAt: new Date(),
|
||||
durationMs: duration,
|
||||
});
|
||||
|
||||
await this.resultRepo.save({
|
||||
toolCallId: callId,
|
||||
result,
|
||||
resultType: this.getResultType(result),
|
||||
});
|
||||
}
|
||||
|
||||
async failCall(callId: string, errorMessage: string, errorCode: string): Promise<void> {
|
||||
const call = await this.toolCallRepo.findOne({ where: { id: callId } });
|
||||
if (!call) return;
|
||||
|
||||
const duration = Date.now() - call.startedAt.getTime();
|
||||
|
||||
await this.toolCallRepo.update(callId, {
|
||||
status: 'error',
|
||||
completedAt: new Date(),
|
||||
durationMs: duration,
|
||||
});
|
||||
|
||||
await this.resultRepo.save({
|
||||
toolCallId: callId,
|
||||
resultType: 'error',
|
||||
errorMessage,
|
||||
errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
async timeoutCall(callId: string): Promise<void> {
|
||||
const call = await this.toolCallRepo.findOne({ where: { id: callId } });
|
||||
if (!call) return;
|
||||
|
||||
const duration = Date.now() - call.startedAt.getTime();
|
||||
|
||||
await this.toolCallRepo.update(callId, {
|
||||
status: 'timeout',
|
||||
completedAt: new Date(),
|
||||
durationMs: duration,
|
||||
});
|
||||
|
||||
await this.resultRepo.save({
|
||||
toolCallId: callId,
|
||||
resultType: 'error',
|
||||
errorMessage: 'Tool execution timed out',
|
||||
errorCode: 'TIMEOUT',
|
||||
});
|
||||
}
|
||||
|
||||
async getCallHistory(
|
||||
tenantId: string,
|
||||
filters: CallHistoryFilters
|
||||
): Promise<PaginatedResult<ToolCall>> {
|
||||
const qb = this.toolCallRepo
|
||||
.createQueryBuilder('tc')
|
||||
.leftJoinAndSelect('tc.result', 'result')
|
||||
.where('tc.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (filters.toolName) {
|
||||
qb.andWhere('tc.tool_name = :toolName', { toolName: filters.toolName });
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
qb.andWhere('tc.status = :status', { status: filters.status });
|
||||
}
|
||||
|
||||
if (filters.startDate) {
|
||||
qb.andWhere('tc.created_at >= :startDate', { startDate: filters.startDate });
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
qb.andWhere('tc.created_at <= :endDate', { endDate: filters.endDate });
|
||||
}
|
||||
|
||||
qb.orderBy('tc.created_at', 'DESC');
|
||||
qb.skip((filters.page - 1) * filters.limit);
|
||||
qb.take(filters.limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return { data, total, page: filters.page, limit: filters.limit };
|
||||
}
|
||||
|
||||
async getCallById(id: string, tenantId: string): Promise<ToolCall | null> {
|
||||
return this.toolCallRepo.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['result'],
|
||||
});
|
||||
}
|
||||
|
||||
async getToolStats(
|
||||
tenantId: string,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<{
|
||||
toolName: string;
|
||||
totalCalls: number;
|
||||
successfulCalls: number;
|
||||
failedCalls: number;
|
||||
avgDurationMs: number;
|
||||
}[]> {
|
||||
const result = await this.toolCallRepo
|
||||
.createQueryBuilder('tc')
|
||||
.select('tc.tool_name', 'toolName')
|
||||
.addSelect('COUNT(*)', 'totalCalls')
|
||||
.addSelect("COUNT(*) FILTER (WHERE tc.status = 'success')", 'successfulCalls')
|
||||
.addSelect("COUNT(*) FILTER (WHERE tc.status = 'error')", 'failedCalls')
|
||||
.addSelect('AVG(tc.duration_ms)', 'avgDurationMs')
|
||||
.where('tc.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('tc.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||
.groupBy('tc.tool_name')
|
||||
.orderBy('totalCalls', 'DESC')
|
||||
.getRawMany();
|
||||
|
||||
return result.map((r) => ({
|
||||
toolName: r.toolName,
|
||||
totalCalls: parseInt(r.totalCalls) || 0,
|
||||
successfulCalls: parseInt(r.successfulCalls) || 0,
|
||||
failedCalls: parseInt(r.failedCalls) || 0,
|
||||
avgDurationMs: parseFloat(r.avgDurationMs) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
private getResultType(result: any): ResultType {
|
||||
if (result === null) return 'null';
|
||||
if (Array.isArray(result)) return 'array';
|
||||
const type = typeof result;
|
||||
if (type === 'object') return 'object';
|
||||
if (type === 'string') return 'string';
|
||||
if (type === 'number') return 'number';
|
||||
if (type === 'boolean') return 'boolean';
|
||||
return 'object';
|
||||
}
|
||||
}
|
||||
53
src/modules/mcp/services/tool-registry.service.ts
Normal file
53
src/modules/mcp/services/tool-registry.service.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import {
|
||||
McpToolDefinition,
|
||||
McpToolHandler,
|
||||
McpToolProvider,
|
||||
ToolCategory,
|
||||
} from '../interfaces';
|
||||
|
||||
export class ToolRegistryService {
|
||||
private tools: Map<string, McpToolDefinition> = new Map();
|
||||
private handlers: Map<string, McpToolHandler> = new Map();
|
||||
private providers: McpToolProvider[] = [];
|
||||
|
||||
registerProvider(provider: McpToolProvider): void {
|
||||
this.providers.push(provider);
|
||||
const tools = provider.getTools();
|
||||
|
||||
for (const tool of tools) {
|
||||
this.tools.set(tool.name, tool);
|
||||
const handler = provider.getHandler(tool.name);
|
||||
if (handler) {
|
||||
this.handlers.set(tool.name, handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getAllTools(): McpToolDefinition[] {
|
||||
return Array.from(this.tools.values());
|
||||
}
|
||||
|
||||
getTool(name: string): McpToolDefinition | null {
|
||||
return this.tools.get(name) || null;
|
||||
}
|
||||
|
||||
getHandler(name: string): McpToolHandler | null {
|
||||
return this.handlers.get(name) || null;
|
||||
}
|
||||
|
||||
getToolsByCategory(category: ToolCategory): McpToolDefinition[] {
|
||||
return Array.from(this.tools.values()).filter((t) => t.category === category);
|
||||
}
|
||||
|
||||
hasTool(name: string): boolean {
|
||||
return this.tools.has(name);
|
||||
}
|
||||
|
||||
getCategories(): ToolCategory[] {
|
||||
const categories = new Set<ToolCategory>();
|
||||
for (const tool of this.tools.values()) {
|
||||
categories.add(tool.category);
|
||||
}
|
||||
return Array.from(categories);
|
||||
}
|
||||
}
|
||||
292
src/modules/mcp/tools/branch-tools.service.ts
Normal file
292
src/modules/mcp/tools/branch-tools.service.ts
Normal file
@ -0,0 +1,292 @@
|
||||
import {
|
||||
McpToolProvider,
|
||||
McpToolDefinition,
|
||||
McpToolHandler,
|
||||
McpContext,
|
||||
} from '../interfaces';
|
||||
|
||||
/**
|
||||
* Branch Tools Service
|
||||
* Provides MCP tools for branch management and team operations.
|
||||
* Used by: ADMIN, SUPERVISOR roles
|
||||
*/
|
||||
export class BranchToolsService implements McpToolProvider {
|
||||
getTools(): McpToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: 'get_branch_info',
|
||||
description: 'Obtiene informacion de una sucursal',
|
||||
category: 'branches',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
branch_id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'ID de la sucursal (usa la actual si no se especifica)',
|
||||
},
|
||||
},
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
address: { type: 'string' },
|
||||
phone: { type: 'string' },
|
||||
operating_hours: { type: 'object' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_branch_report',
|
||||
description: 'Genera reporte de desempeno de sucursal',
|
||||
category: 'branches',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
branch_id: { type: 'string', format: 'uuid' },
|
||||
period: {
|
||||
type: 'string',
|
||||
enum: ['today', 'week', 'month'],
|
||||
default: 'today',
|
||||
},
|
||||
},
|
||||
},
|
||||
returns: { type: 'object' },
|
||||
},
|
||||
{
|
||||
name: 'get_team_performance',
|
||||
description: 'Obtiene metricas de desempeno del equipo',
|
||||
category: 'branches',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
branch_id: { type: 'string', format: 'uuid' },
|
||||
period: {
|
||||
type: 'string',
|
||||
enum: ['today', 'week', 'month'],
|
||||
default: 'today',
|
||||
},
|
||||
},
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
team_size: { type: 'number' },
|
||||
members: { type: 'array' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_employee_schedule',
|
||||
description: 'Obtiene horarios de empleados',
|
||||
category: 'branches',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
branch_id: { type: 'string', format: 'uuid' },
|
||||
date: { type: 'string', format: 'date' },
|
||||
employee_id: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
returns: { type: 'array', items: { type: 'object' } },
|
||||
},
|
||||
{
|
||||
name: 'get_branch_hours',
|
||||
description: 'Obtiene horarios de atencion de la sucursal',
|
||||
category: 'branches',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
branch_id: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
regular: { type: 'object' },
|
||||
holidays: { type: 'array' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_promotions',
|
||||
description: 'Obtiene promociones activas',
|
||||
category: 'branches',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
branch_id: { type: 'string', format: 'uuid' },
|
||||
active_only: { type: 'boolean', default: true },
|
||||
},
|
||||
},
|
||||
returns: { type: 'array', items: { type: 'object' } },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
getHandler(toolName: string): McpToolHandler | undefined {
|
||||
const handlers: Record<string, McpToolHandler> = {
|
||||
get_branch_info: this.getBranchInfo.bind(this),
|
||||
get_branch_report: this.getBranchReport.bind(this),
|
||||
get_team_performance: this.getTeamPerformance.bind(this),
|
||||
get_employee_schedule: this.getEmployeeSchedule.bind(this),
|
||||
get_branch_hours: this.getBranchHours.bind(this),
|
||||
get_promotions: this.getPromotions.bind(this),
|
||||
};
|
||||
return handlers[toolName];
|
||||
}
|
||||
|
||||
private async getBranchInfo(
|
||||
params: { branch_id?: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to BranchesService
|
||||
const branchId = params.branch_id || context.branchId;
|
||||
return {
|
||||
id: branchId,
|
||||
name: 'Sucursal Centro',
|
||||
code: 'SUC-001',
|
||||
type: 'store',
|
||||
address: {
|
||||
street: 'Av. Principal 123',
|
||||
city: 'Ciudad de Mexico',
|
||||
state: 'CDMX',
|
||||
postal_code: '06600',
|
||||
},
|
||||
phone: '+52 55 1234 5678',
|
||||
email: 'centro@erp.com',
|
||||
manager: {
|
||||
id: 'mgr-001',
|
||||
name: 'Juan Perez',
|
||||
},
|
||||
operating_hours: {
|
||||
monday: { open: '09:00', close: '20:00' },
|
||||
tuesday: { open: '09:00', close: '20:00' },
|
||||
wednesday: { open: '09:00', close: '20:00' },
|
||||
thursday: { open: '09:00', close: '20:00' },
|
||||
friday: { open: '09:00', close: '20:00' },
|
||||
saturday: { open: '10:00', close: '18:00' },
|
||||
sunday: { open: 'closed', close: 'closed' },
|
||||
},
|
||||
message: 'Conectar a BranchesService real',
|
||||
};
|
||||
}
|
||||
|
||||
private async getBranchReport(
|
||||
params: { branch_id?: string; period?: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to ReportsService
|
||||
return {
|
||||
branch_id: params.branch_id || context.branchId,
|
||||
period: params.period || 'today',
|
||||
sales: {
|
||||
total: 15750.00,
|
||||
count: 42,
|
||||
avg_ticket: 375.00,
|
||||
},
|
||||
inventory: {
|
||||
value: 250000.00,
|
||||
low_stock_items: 5,
|
||||
out_of_stock: 2,
|
||||
},
|
||||
staff: {
|
||||
present: 8,
|
||||
absent: 1,
|
||||
late: 0,
|
||||
},
|
||||
goals: {
|
||||
sales_target: 20000.00,
|
||||
progress: 78.75,
|
||||
},
|
||||
message: 'Conectar a ReportsService real',
|
||||
};
|
||||
}
|
||||
|
||||
private async getTeamPerformance(
|
||||
params: { branch_id?: string; period?: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to HRService + SalesService
|
||||
return {
|
||||
branch_id: params.branch_id || context.branchId,
|
||||
period: params.period || 'today',
|
||||
team_size: 8,
|
||||
members: [
|
||||
{ name: 'Ana Garcia', role: 'Vendedor', sales: 4500.00, transactions: 12, avg_ticket: 375.00 },
|
||||
{ name: 'Carlos Lopez', role: 'Vendedor', sales: 3800.00, transactions: 10, avg_ticket: 380.00 },
|
||||
{ name: 'Maria Rodriguez', role: 'Cajero', sales: 2500.00, transactions: 8, avg_ticket: 312.50 },
|
||||
],
|
||||
top_performer: 'Ana Garcia',
|
||||
message: 'Conectar a HRService real',
|
||||
};
|
||||
}
|
||||
|
||||
private async getEmployeeSchedule(
|
||||
params: { branch_id?: string; date?: string; employee_id?: string },
|
||||
context: McpContext
|
||||
): Promise<any[]> {
|
||||
// TODO: Connect to ScheduleService
|
||||
const date = params.date || new Date().toISOString().split('T')[0];
|
||||
return [
|
||||
{ employee: 'Ana Garcia', shift: 'morning', start: '09:00', end: '15:00', status: 'confirmed' },
|
||||
{ employee: 'Carlos Lopez', shift: 'afternoon', start: '15:00', end: '21:00', status: 'confirmed' },
|
||||
{ employee: 'Maria Rodriguez', shift: 'morning', start: '09:00', end: '15:00', status: 'confirmed' },
|
||||
];
|
||||
}
|
||||
|
||||
private async getBranchHours(
|
||||
params: { branch_id?: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to BranchesService
|
||||
return {
|
||||
regular: {
|
||||
monday: { open: '09:00', close: '20:00' },
|
||||
tuesday: { open: '09:00', close: '20:00' },
|
||||
wednesday: { open: '09:00', close: '20:00' },
|
||||
thursday: { open: '09:00', close: '20:00' },
|
||||
friday: { open: '09:00', close: '20:00' },
|
||||
saturday: { open: '10:00', close: '18:00' },
|
||||
sunday: 'Cerrado',
|
||||
},
|
||||
holidays: [
|
||||
{ date: '2026-01-01', name: 'Año Nuevo', status: 'closed' },
|
||||
{ date: '2026-02-03', name: 'Dia de la Constitucion', status: 'closed' },
|
||||
],
|
||||
next_closed: '2026-02-03',
|
||||
message: 'Conectar a BranchesService real',
|
||||
};
|
||||
}
|
||||
|
||||
private async getPromotions(
|
||||
params: { branch_id?: string; active_only?: boolean },
|
||||
context: McpContext
|
||||
): Promise<any[]> {
|
||||
// TODO: Connect to PromotionsService
|
||||
return [
|
||||
{
|
||||
id: 'promo-001',
|
||||
name: '2x1 en productos seleccionados',
|
||||
type: 'bogo',
|
||||
discount: 50,
|
||||
start_date: '2026-01-20',
|
||||
end_date: '2026-01-31',
|
||||
status: 'active',
|
||||
applicable_products: ['category:electronics'],
|
||||
},
|
||||
{
|
||||
id: 'promo-002',
|
||||
name: '10% descuento con tarjeta',
|
||||
type: 'percentage',
|
||||
discount: 10,
|
||||
start_date: '2026-01-01',
|
||||
end_date: '2026-03-31',
|
||||
status: 'active',
|
||||
conditions: ['payment_method:card'],
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
94
src/modules/mcp/tools/customers-tools.service.ts
Normal file
94
src/modules/mcp/tools/customers-tools.service.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import {
|
||||
McpToolProvider,
|
||||
McpToolDefinition,
|
||||
McpToolHandler,
|
||||
McpContext,
|
||||
} from '../interfaces';
|
||||
|
||||
/**
|
||||
* Customers Tools Service
|
||||
* Provides MCP tools for customer management.
|
||||
*
|
||||
* TODO: Connect to actual CustomersService when available.
|
||||
*/
|
||||
export class CustomersToolsService implements McpToolProvider {
|
||||
getTools(): McpToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: 'search_customers',
|
||||
description: 'Busca clientes por nombre, telefono o email',
|
||||
category: 'customers',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Texto de busqueda' },
|
||||
limit: { type: 'number', description: 'Limite de resultados', default: 10 },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
returns: { type: 'array' },
|
||||
},
|
||||
{
|
||||
name: 'get_customer_balance',
|
||||
description: 'Obtiene el saldo actual de un cliente',
|
||||
category: 'customers',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
customer_id: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
required: ['customer_id'],
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
balance: { type: 'number' },
|
||||
credit_limit: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
getHandler(toolName: string): McpToolHandler | undefined {
|
||||
const handlers: Record<string, McpToolHandler> = {
|
||||
search_customers: this.searchCustomers.bind(this),
|
||||
get_customer_balance: this.getCustomerBalance.bind(this),
|
||||
};
|
||||
return handlers[toolName];
|
||||
}
|
||||
|
||||
private async searchCustomers(
|
||||
params: { query: string; limit?: number },
|
||||
context: McpContext
|
||||
): Promise<any[]> {
|
||||
// TODO: Connect to actual customers service
|
||||
return [
|
||||
{
|
||||
id: 'customer-1',
|
||||
name: 'Juan Perez',
|
||||
phone: '+52 55 1234 5678',
|
||||
email: 'juan@example.com',
|
||||
balance: 500.00,
|
||||
credit_limit: 5000.00,
|
||||
message: 'Conectar a CustomersService real',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async getCustomerBalance(
|
||||
params: { customer_id: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to actual customers service
|
||||
return {
|
||||
customer_id: params.customer_id,
|
||||
customer_name: 'Cliente ejemplo',
|
||||
balance: 500.00,
|
||||
credit_limit: 5000.00,
|
||||
available_credit: 4500.00,
|
||||
last_purchase: new Date().toISOString(),
|
||||
message: 'Conectar a CustomersService real',
|
||||
};
|
||||
}
|
||||
}
|
||||
216
src/modules/mcp/tools/fiados-tools.service.ts
Normal file
216
src/modules/mcp/tools/fiados-tools.service.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import {
|
||||
McpToolProvider,
|
||||
McpToolDefinition,
|
||||
McpToolHandler,
|
||||
McpContext,
|
||||
} from '../interfaces';
|
||||
|
||||
/**
|
||||
* Fiados (Credit) Tools Service
|
||||
* Provides MCP tools for credit/fiado management.
|
||||
*
|
||||
* TODO: Connect to actual FiadosService when available.
|
||||
*/
|
||||
export class FiadosToolsService implements McpToolProvider {
|
||||
getTools(): McpToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: 'get_fiado_balance',
|
||||
description: 'Consulta el saldo de credito de un cliente',
|
||||
category: 'fiados',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
customer_id: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
required: ['customer_id'],
|
||||
},
|
||||
returns: { type: 'object' },
|
||||
},
|
||||
{
|
||||
name: 'create_fiado',
|
||||
description: 'Registra una venta a credito (fiado)',
|
||||
category: 'fiados',
|
||||
permissions: ['fiados.create'],
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
customer_id: { type: 'string', format: 'uuid' },
|
||||
amount: { type: 'number', minimum: 0.01 },
|
||||
order_id: { type: 'string', format: 'uuid' },
|
||||
description: { type: 'string' },
|
||||
},
|
||||
required: ['customer_id', 'amount'],
|
||||
},
|
||||
returns: { type: 'object' },
|
||||
},
|
||||
{
|
||||
name: 'register_fiado_payment',
|
||||
description: 'Registra un abono a la cuenta de credito',
|
||||
category: 'fiados',
|
||||
permissions: ['fiados.payment'],
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
customer_id: { type: 'string', format: 'uuid' },
|
||||
amount: { type: 'number', minimum: 0.01 },
|
||||
payment_method: { type: 'string', enum: ['cash', 'card', 'transfer'] },
|
||||
},
|
||||
required: ['customer_id', 'amount'],
|
||||
},
|
||||
returns: { type: 'object' },
|
||||
},
|
||||
{
|
||||
name: 'check_fiado_eligibility',
|
||||
description: 'Verifica si un cliente puede comprar a credito',
|
||||
category: 'fiados',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
customer_id: { type: 'string', format: 'uuid' },
|
||||
amount: { type: 'number', minimum: 0.01 },
|
||||
},
|
||||
required: ['customer_id', 'amount'],
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
eligible: { type: 'boolean' },
|
||||
reason: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
getHandler(toolName: string): McpToolHandler | undefined {
|
||||
const handlers: Record<string, McpToolHandler> = {
|
||||
get_fiado_balance: this.getFiadoBalance.bind(this),
|
||||
create_fiado: this.createFiado.bind(this),
|
||||
register_fiado_payment: this.registerFiadoPayment.bind(this),
|
||||
check_fiado_eligibility: this.checkFiadoEligibility.bind(this),
|
||||
};
|
||||
return handlers[toolName];
|
||||
}
|
||||
|
||||
private async getFiadoBalance(
|
||||
params: { customer_id: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to actual fiados service
|
||||
return {
|
||||
customer_id: params.customer_id,
|
||||
customer_name: 'Cliente ejemplo',
|
||||
balance: 1500.00,
|
||||
credit_limit: 5000.00,
|
||||
available_credit: 3500.00,
|
||||
pending_fiados: [
|
||||
{ id: 'fiado-1', amount: 500.00, date: '2026-01-10', status: 'pending' },
|
||||
{ id: 'fiado-2', amount: 1000.00, date: '2026-01-05', status: 'pending' },
|
||||
],
|
||||
recent_payments: [],
|
||||
message: 'Conectar a FiadosService real',
|
||||
};
|
||||
}
|
||||
|
||||
private async createFiado(
|
||||
params: { customer_id: string; amount: number; order_id?: string; description?: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to actual fiados service
|
||||
// First check eligibility
|
||||
const eligibility = await this.checkFiadoEligibility(
|
||||
{ customer_id: params.customer_id, amount: params.amount },
|
||||
context
|
||||
);
|
||||
|
||||
if (!eligibility.eligible) {
|
||||
throw new Error(eligibility.reason);
|
||||
}
|
||||
|
||||
return {
|
||||
fiado_id: 'fiado-' + Date.now(),
|
||||
customer_id: params.customer_id,
|
||||
amount: params.amount,
|
||||
order_id: params.order_id,
|
||||
description: params.description,
|
||||
due_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days
|
||||
new_balance: 1500.00 + params.amount,
|
||||
remaining_credit: 3500.00 - params.amount,
|
||||
created_by: context.userId,
|
||||
created_at: new Date().toISOString(),
|
||||
message: 'Conectar a FiadosService real',
|
||||
};
|
||||
}
|
||||
|
||||
private async registerFiadoPayment(
|
||||
params: { customer_id: string; amount: number; payment_method?: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to actual fiados service
|
||||
return {
|
||||
payment_id: 'payment-' + Date.now(),
|
||||
customer_id: params.customer_id,
|
||||
amount: params.amount,
|
||||
payment_method: params.payment_method || 'cash',
|
||||
previous_balance: 1500.00,
|
||||
new_balance: 1500.00 - params.amount,
|
||||
fiados_paid: [],
|
||||
created_by: context.userId,
|
||||
created_at: new Date().toISOString(),
|
||||
message: 'Conectar a FiadosService real',
|
||||
};
|
||||
}
|
||||
|
||||
private async checkFiadoEligibility(
|
||||
params: { customer_id: string; amount: number },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to actual fiados service
|
||||
const mockBalance = 1500.00;
|
||||
const mockCreditLimit = 5000.00;
|
||||
const mockAvailableCredit = mockCreditLimit - mockBalance;
|
||||
const hasOverdue = false;
|
||||
|
||||
if (hasOverdue) {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: 'Cliente tiene saldo vencido',
|
||||
current_balance: mockBalance,
|
||||
credit_limit: mockCreditLimit,
|
||||
available_credit: mockAvailableCredit,
|
||||
requested_amount: params.amount,
|
||||
has_overdue: true,
|
||||
suggestions: ['Solicitar pago del saldo vencido antes de continuar'],
|
||||
};
|
||||
}
|
||||
|
||||
if (params.amount > mockAvailableCredit) {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: 'Monto excede credito disponible',
|
||||
current_balance: mockBalance,
|
||||
credit_limit: mockCreditLimit,
|
||||
available_credit: mockAvailableCredit,
|
||||
requested_amount: params.amount,
|
||||
has_overdue: false,
|
||||
suggestions: [
|
||||
`Reducir el monto a $${mockAvailableCredit.toFixed(2)}`,
|
||||
'Solicitar aumento de limite de credito',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
eligible: true,
|
||||
reason: 'Cliente con credito disponible',
|
||||
current_balance: mockBalance,
|
||||
credit_limit: mockCreditLimit,
|
||||
available_credit: mockAvailableCredit,
|
||||
requested_amount: params.amount,
|
||||
has_overdue: false,
|
||||
suggestions: [],
|
||||
message: 'Conectar a FiadosService real',
|
||||
};
|
||||
}
|
||||
}
|
||||
291
src/modules/mcp/tools/financial-tools.service.ts
Normal file
291
src/modules/mcp/tools/financial-tools.service.ts
Normal file
@ -0,0 +1,291 @@
|
||||
import {
|
||||
McpToolProvider,
|
||||
McpToolDefinition,
|
||||
McpToolHandler,
|
||||
McpContext,
|
||||
} from '../interfaces';
|
||||
|
||||
/**
|
||||
* Financial Tools Service
|
||||
* Provides MCP tools for financial reporting and analysis.
|
||||
* Used by: ADMIN role only
|
||||
*/
|
||||
export class FinancialToolsService implements McpToolProvider {
|
||||
getTools(): McpToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: 'get_financial_report',
|
||||
description: 'Genera reporte financiero (ingresos, gastos, utilidad)',
|
||||
category: 'financial',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['income', 'expenses', 'profit', 'summary'],
|
||||
description: 'Tipo de reporte',
|
||||
default: 'summary',
|
||||
},
|
||||
start_date: {
|
||||
type: 'string',
|
||||
format: 'date',
|
||||
description: 'Fecha inicial',
|
||||
},
|
||||
end_date: {
|
||||
type: 'string',
|
||||
format: 'date',
|
||||
description: 'Fecha final',
|
||||
},
|
||||
branch_id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'Filtrar por sucursal',
|
||||
},
|
||||
},
|
||||
required: ['type'],
|
||||
},
|
||||
returns: { type: 'object' },
|
||||
},
|
||||
{
|
||||
name: 'get_accounts_receivable',
|
||||
description: 'Obtiene cuentas por cobrar (clientes que deben)',
|
||||
category: 'financial',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['all', 'current', 'overdue'],
|
||||
default: 'all',
|
||||
},
|
||||
min_amount: { type: 'number' },
|
||||
limit: { type: 'number', default: 50 },
|
||||
},
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
total: { type: 'number' },
|
||||
count: { type: 'number' },
|
||||
accounts: { type: 'array' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_accounts_payable',
|
||||
description: 'Obtiene cuentas por pagar (deudas a proveedores)',
|
||||
category: 'financial',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['all', 'current', 'overdue'],
|
||||
default: 'all',
|
||||
},
|
||||
due_date_before: { type: 'string', format: 'date' },
|
||||
limit: { type: 'number', default: 50 },
|
||||
},
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
total: { type: 'number' },
|
||||
count: { type: 'number' },
|
||||
accounts: { type: 'array' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_cash_flow',
|
||||
description: 'Analiza flujo de caja (entradas y salidas)',
|
||||
category: 'financial',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
period: {
|
||||
type: 'string',
|
||||
enum: ['week', 'month', 'quarter'],
|
||||
default: 'month',
|
||||
},
|
||||
branch_id: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
inflows: { type: 'number' },
|
||||
outflows: { type: 'number' },
|
||||
net_flow: { type: 'number' },
|
||||
by_category: { type: 'array' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_kpis',
|
||||
description: 'Obtiene indicadores clave de desempeno (KPIs)',
|
||||
category: 'financial',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
period: {
|
||||
type: 'string',
|
||||
enum: ['month', 'quarter', 'year'],
|
||||
default: 'month',
|
||||
},
|
||||
},
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
gross_margin: { type: 'number' },
|
||||
net_margin: { type: 'number' },
|
||||
inventory_turnover: { type: 'number' },
|
||||
avg_collection_days: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
getHandler(toolName: string): McpToolHandler | undefined {
|
||||
const handlers: Record<string, McpToolHandler> = {
|
||||
get_financial_report: this.getFinancialReport.bind(this),
|
||||
get_accounts_receivable: this.getAccountsReceivable.bind(this),
|
||||
get_accounts_payable: this.getAccountsPayable.bind(this),
|
||||
get_cash_flow: this.getCashFlow.bind(this),
|
||||
get_kpis: this.getKPIs.bind(this),
|
||||
};
|
||||
return handlers[toolName];
|
||||
}
|
||||
|
||||
private async getFinancialReport(
|
||||
params: { type: string; start_date?: string; end_date?: string; branch_id?: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to FinancialService
|
||||
return {
|
||||
type: params.type,
|
||||
period: {
|
||||
start: params.start_date || new Date().toISOString().split('T')[0],
|
||||
end: params.end_date || new Date().toISOString().split('T')[0],
|
||||
},
|
||||
income: 125000.00,
|
||||
expenses: 85000.00,
|
||||
gross_profit: 40000.00,
|
||||
net_profit: 32000.00,
|
||||
breakdown: {
|
||||
sales: 120000.00,
|
||||
services: 5000.00,
|
||||
cost_of_goods: 65000.00,
|
||||
operating_expenses: 20000.00,
|
||||
taxes: 8000.00,
|
||||
},
|
||||
message: 'Conectar a FinancialService real',
|
||||
};
|
||||
}
|
||||
|
||||
private async getAccountsReceivable(
|
||||
params: { status?: string; min_amount?: number; limit?: number },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to AccountsService
|
||||
return {
|
||||
total: 45000.00,
|
||||
count: 12,
|
||||
overdue_total: 15000.00,
|
||||
overdue_count: 4,
|
||||
accounts: [
|
||||
{
|
||||
customer: 'Cliente A',
|
||||
amount: 5000.00,
|
||||
due_date: '2026-01-20',
|
||||
days_overdue: 5,
|
||||
status: 'overdue',
|
||||
},
|
||||
{
|
||||
customer: 'Cliente B',
|
||||
amount: 8000.00,
|
||||
due_date: '2026-02-01',
|
||||
days_overdue: 0,
|
||||
status: 'current',
|
||||
},
|
||||
].slice(0, params.limit || 50),
|
||||
message: 'Conectar a AccountsService real',
|
||||
};
|
||||
}
|
||||
|
||||
private async getAccountsPayable(
|
||||
params: { status?: string; due_date_before?: string; limit?: number },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to AccountsService
|
||||
return {
|
||||
total: 32000.00,
|
||||
count: 8,
|
||||
overdue_total: 5000.00,
|
||||
overdue_count: 2,
|
||||
accounts: [
|
||||
{
|
||||
supplier: 'Proveedor X',
|
||||
amount: 12000.00,
|
||||
due_date: '2026-01-28',
|
||||
status: 'current',
|
||||
},
|
||||
{
|
||||
supplier: 'Proveedor Y',
|
||||
amount: 5000.00,
|
||||
due_date: '2026-01-15',
|
||||
days_overdue: 10,
|
||||
status: 'overdue',
|
||||
},
|
||||
].slice(0, params.limit || 50),
|
||||
message: 'Conectar a AccountsService real',
|
||||
};
|
||||
}
|
||||
|
||||
private async getCashFlow(
|
||||
params: { period?: string; branch_id?: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to FinancialService
|
||||
return {
|
||||
period: params.period || 'month',
|
||||
inflows: 95000.00,
|
||||
outflows: 72000.00,
|
||||
net_flow: 23000.00,
|
||||
opening_balance: 45000.00,
|
||||
closing_balance: 68000.00,
|
||||
by_category: [
|
||||
{ category: 'Ventas', type: 'inflow', amount: 90000.00 },
|
||||
{ category: 'Cobranzas', type: 'inflow', amount: 5000.00 },
|
||||
{ category: 'Compras', type: 'outflow', amount: 55000.00 },
|
||||
{ category: 'Nomina', type: 'outflow', amount: 12000.00 },
|
||||
{ category: 'Gastos operativos', type: 'outflow', amount: 5000.00 },
|
||||
],
|
||||
message: 'Conectar a FinancialService real',
|
||||
};
|
||||
}
|
||||
|
||||
private async getKPIs(
|
||||
params: { period?: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to AnalyticsService
|
||||
return {
|
||||
period: params.period || 'month',
|
||||
gross_margin: 32.5,
|
||||
net_margin: 18.2,
|
||||
inventory_turnover: 4.5,
|
||||
avg_collection_days: 28,
|
||||
current_ratio: 1.8,
|
||||
quick_ratio: 1.2,
|
||||
return_on_assets: 12.5,
|
||||
trends: {
|
||||
gross_margin_change: 2.1,
|
||||
net_margin_change: 1.5,
|
||||
},
|
||||
message: 'Conectar a AnalyticsService real',
|
||||
};
|
||||
}
|
||||
}
|
||||
8
src/modules/mcp/tools/index.ts
Normal file
8
src/modules/mcp/tools/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export { ProductsToolsService } from './products-tools.service';
|
||||
export { InventoryToolsService } from './inventory-tools.service';
|
||||
export { OrdersToolsService } from './orders-tools.service';
|
||||
export { CustomersToolsService } from './customers-tools.service';
|
||||
export { FiadosToolsService } from './fiados-tools.service';
|
||||
export { SalesToolsService } from './sales-tools.service';
|
||||
export { FinancialToolsService } from './financial-tools.service';
|
||||
export { BranchToolsService } from './branch-tools.service';
|
||||
154
src/modules/mcp/tools/inventory-tools.service.ts
Normal file
154
src/modules/mcp/tools/inventory-tools.service.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import {
|
||||
McpToolProvider,
|
||||
McpToolDefinition,
|
||||
McpToolHandler,
|
||||
McpContext,
|
||||
} from '../interfaces';
|
||||
|
||||
/**
|
||||
* Inventory Tools Service
|
||||
* Provides MCP tools for inventory management.
|
||||
*
|
||||
* TODO: Connect to actual InventoryService when available.
|
||||
*/
|
||||
export class InventoryToolsService implements McpToolProvider {
|
||||
getTools(): McpToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: 'check_stock',
|
||||
description: 'Consulta el stock actual de productos',
|
||||
category: 'inventory',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
product_ids: { type: 'array', description: 'IDs de productos a consultar' },
|
||||
warehouse_id: { type: 'string', description: 'ID del almacen' },
|
||||
},
|
||||
},
|
||||
returns: { type: 'array' },
|
||||
},
|
||||
{
|
||||
name: 'get_low_stock_products',
|
||||
description: 'Lista productos que estan por debajo del minimo de stock',
|
||||
category: 'inventory',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
threshold: { type: 'number', description: 'Umbral de stock bajo' },
|
||||
},
|
||||
},
|
||||
returns: { type: 'array' },
|
||||
},
|
||||
{
|
||||
name: 'record_inventory_movement',
|
||||
description: 'Registra un movimiento de inventario (entrada, salida, ajuste)',
|
||||
category: 'inventory',
|
||||
permissions: ['inventory.write'],
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
product_id: { type: 'string', format: 'uuid' },
|
||||
quantity: { type: 'number' },
|
||||
movement_type: { type: 'string', enum: ['in', 'out', 'adjustment'] },
|
||||
reason: { type: 'string' },
|
||||
},
|
||||
required: ['product_id', 'quantity', 'movement_type'],
|
||||
},
|
||||
returns: { type: 'object' },
|
||||
},
|
||||
{
|
||||
name: 'get_inventory_value',
|
||||
description: 'Calcula el valor total del inventario',
|
||||
category: 'inventory',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
warehouse_id: { type: 'string', description: 'ID del almacen (opcional)' },
|
||||
},
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
total_value: { type: 'number' },
|
||||
items_count: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
getHandler(toolName: string): McpToolHandler | undefined {
|
||||
const handlers: Record<string, McpToolHandler> = {
|
||||
check_stock: this.checkStock.bind(this),
|
||||
get_low_stock_products: this.getLowStockProducts.bind(this),
|
||||
record_inventory_movement: this.recordInventoryMovement.bind(this),
|
||||
get_inventory_value: this.getInventoryValue.bind(this),
|
||||
};
|
||||
return handlers[toolName];
|
||||
}
|
||||
|
||||
private async checkStock(
|
||||
params: { product_ids?: string[]; warehouse_id?: string },
|
||||
context: McpContext
|
||||
): Promise<any[]> {
|
||||
// TODO: Connect to actual inventory service
|
||||
return [
|
||||
{
|
||||
product_id: 'sample-1',
|
||||
product_name: 'Producto ejemplo',
|
||||
stock: 100,
|
||||
warehouse_id: params.warehouse_id || 'default',
|
||||
message: 'Conectar a InventoryService real',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async getLowStockProducts(
|
||||
params: { threshold?: number },
|
||||
context: McpContext
|
||||
): Promise<any[]> {
|
||||
// TODO: Connect to actual inventory service
|
||||
const threshold = params.threshold || 10;
|
||||
return [
|
||||
{
|
||||
product_id: 'low-stock-1',
|
||||
product_name: 'Producto bajo stock',
|
||||
current_stock: 5,
|
||||
min_stock: threshold,
|
||||
shortage: threshold - 5,
|
||||
message: 'Conectar a InventoryService real',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async recordInventoryMovement(
|
||||
params: { product_id: string; quantity: number; movement_type: string; reason?: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to actual inventory service
|
||||
return {
|
||||
movement_id: 'mov-' + Date.now(),
|
||||
product_id: params.product_id,
|
||||
quantity: params.quantity,
|
||||
movement_type: params.movement_type,
|
||||
reason: params.reason,
|
||||
recorded_by: context.userId,
|
||||
recorded_at: new Date().toISOString(),
|
||||
message: 'Conectar a InventoryService real',
|
||||
};
|
||||
}
|
||||
|
||||
private async getInventoryValue(
|
||||
params: { warehouse_id?: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to actual inventory service
|
||||
return {
|
||||
total_value: 150000.00,
|
||||
items_count: 500,
|
||||
warehouse_id: params.warehouse_id || 'all',
|
||||
currency: 'MXN',
|
||||
message: 'Conectar a InventoryService real',
|
||||
};
|
||||
}
|
||||
}
|
||||
139
src/modules/mcp/tools/orders-tools.service.ts
Normal file
139
src/modules/mcp/tools/orders-tools.service.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import {
|
||||
McpToolProvider,
|
||||
McpToolDefinition,
|
||||
McpToolHandler,
|
||||
McpContext,
|
||||
} from '../interfaces';
|
||||
|
||||
/**
|
||||
* Orders Tools Service
|
||||
* Provides MCP tools for order management.
|
||||
*
|
||||
* TODO: Connect to actual OrdersService when available.
|
||||
*/
|
||||
export class OrdersToolsService implements McpToolProvider {
|
||||
getTools(): McpToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: 'create_order',
|
||||
description: 'Crea un nuevo pedido',
|
||||
category: 'orders',
|
||||
permissions: ['orders.create'],
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
customer_id: { type: 'string', format: 'uuid', description: 'ID del cliente' },
|
||||
items: {
|
||||
type: 'array',
|
||||
description: 'Items del pedido',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
product_id: { type: 'string' },
|
||||
quantity: { type: 'number' },
|
||||
unit_price: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
payment_method: { type: 'string', enum: ['cash', 'card', 'transfer', 'fiado'] },
|
||||
notes: { type: 'string' },
|
||||
},
|
||||
required: ['customer_id', 'items'],
|
||||
},
|
||||
returns: { type: 'object' },
|
||||
},
|
||||
{
|
||||
name: 'get_order_status',
|
||||
description: 'Consulta el estado de un pedido',
|
||||
category: 'orders',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
order_id: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
required: ['order_id'],
|
||||
},
|
||||
returns: { type: 'object' },
|
||||
},
|
||||
{
|
||||
name: 'update_order_status',
|
||||
description: 'Actualiza el estado de un pedido',
|
||||
category: 'orders',
|
||||
permissions: ['orders.update'],
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
order_id: { type: 'string', format: 'uuid' },
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['pending', 'confirmed', 'preparing', 'ready', 'delivered', 'cancelled'],
|
||||
},
|
||||
},
|
||||
required: ['order_id', 'status'],
|
||||
},
|
||||
returns: { type: 'object' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
getHandler(toolName: string): McpToolHandler | undefined {
|
||||
const handlers: Record<string, McpToolHandler> = {
|
||||
create_order: this.createOrder.bind(this),
|
||||
get_order_status: this.getOrderStatus.bind(this),
|
||||
update_order_status: this.updateOrderStatus.bind(this),
|
||||
};
|
||||
return handlers[toolName];
|
||||
}
|
||||
|
||||
private async createOrder(
|
||||
params: { customer_id: string; items: any[]; payment_method?: string; notes?: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to actual orders service
|
||||
const subtotal = params.items.reduce((sum, item) => sum + (item.quantity * (item.unit_price || 0)), 0);
|
||||
return {
|
||||
order_id: 'order-' + Date.now(),
|
||||
customer_id: params.customer_id,
|
||||
items: params.items,
|
||||
subtotal,
|
||||
tax: subtotal * 0.16,
|
||||
total: subtotal * 1.16,
|
||||
payment_method: params.payment_method || 'cash',
|
||||
status: 'pending',
|
||||
created_by: context.userId,
|
||||
created_at: new Date().toISOString(),
|
||||
message: 'Conectar a OrdersService real',
|
||||
};
|
||||
}
|
||||
|
||||
private async getOrderStatus(
|
||||
params: { order_id: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to actual orders service
|
||||
return {
|
||||
order_id: params.order_id,
|
||||
status: 'pending',
|
||||
customer_name: 'Cliente ejemplo',
|
||||
total: 1160.00,
|
||||
items_count: 3,
|
||||
created_at: new Date().toISOString(),
|
||||
message: 'Conectar a OrdersService real',
|
||||
};
|
||||
}
|
||||
|
||||
private async updateOrderStatus(
|
||||
params: { order_id: string; status: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to actual orders service
|
||||
return {
|
||||
order_id: params.order_id,
|
||||
previous_status: 'pending',
|
||||
new_status: params.status,
|
||||
updated_by: context.userId,
|
||||
updated_at: new Date().toISOString(),
|
||||
message: 'Conectar a OrdersService real',
|
||||
};
|
||||
}
|
||||
}
|
||||
128
src/modules/mcp/tools/products-tools.service.ts
Normal file
128
src/modules/mcp/tools/products-tools.service.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import {
|
||||
McpToolProvider,
|
||||
McpToolDefinition,
|
||||
McpToolHandler,
|
||||
McpContext,
|
||||
} from '../interfaces';
|
||||
|
||||
/**
|
||||
* Products Tools Service
|
||||
* Provides MCP tools for product management.
|
||||
*
|
||||
* TODO: Connect to actual ProductsService when available.
|
||||
*/
|
||||
export class ProductsToolsService implements McpToolProvider {
|
||||
getTools(): McpToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: 'list_products',
|
||||
description: 'Lista productos filtrados por categoria, nombre o precio',
|
||||
category: 'products',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: { type: 'string', description: 'Filtrar por categoria' },
|
||||
search: { type: 'string', description: 'Buscar por nombre' },
|
||||
min_price: { type: 'number', description: 'Precio minimo' },
|
||||
max_price: { type: 'number', description: 'Precio maximo' },
|
||||
limit: { type: 'number', description: 'Limite de resultados', default: 20 },
|
||||
},
|
||||
},
|
||||
returns: {
|
||||
type: 'array',
|
||||
items: { type: 'object' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_product_details',
|
||||
description: 'Obtiene detalles completos de un producto',
|
||||
category: 'products',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
product_id: { type: 'string', format: 'uuid', description: 'ID del producto' },
|
||||
},
|
||||
required: ['product_id'],
|
||||
},
|
||||
returns: { type: 'object' },
|
||||
},
|
||||
{
|
||||
name: 'check_product_availability',
|
||||
description: 'Verifica si hay stock suficiente de un producto',
|
||||
category: 'products',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
product_id: { type: 'string', format: 'uuid', description: 'ID del producto' },
|
||||
quantity: { type: 'number', minimum: 1, description: 'Cantidad requerida' },
|
||||
},
|
||||
required: ['product_id', 'quantity'],
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
available: { type: 'boolean' },
|
||||
current_stock: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
getHandler(toolName: string): McpToolHandler | undefined {
|
||||
const handlers: Record<string, McpToolHandler> = {
|
||||
list_products: this.listProducts.bind(this),
|
||||
get_product_details: this.getProductDetails.bind(this),
|
||||
check_product_availability: this.checkProductAvailability.bind(this),
|
||||
};
|
||||
return handlers[toolName];
|
||||
}
|
||||
|
||||
private async listProducts(
|
||||
params: { category?: string; search?: string; min_price?: number; max_price?: number; limit?: number },
|
||||
context: McpContext
|
||||
): Promise<any[]> {
|
||||
// TODO: Connect to actual products service
|
||||
return [
|
||||
{
|
||||
id: 'sample-product-1',
|
||||
name: 'Producto de ejemplo 1',
|
||||
price: 99.99,
|
||||
stock: 50,
|
||||
category: params.category || 'general',
|
||||
message: 'Conectar a ProductsService real',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async getProductDetails(
|
||||
params: { product_id: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to actual products service
|
||||
return {
|
||||
id: params.product_id,
|
||||
name: 'Producto de ejemplo',
|
||||
description: 'Descripcion del producto',
|
||||
sku: 'SKU-001',
|
||||
price: 99.99,
|
||||
stock: 50,
|
||||
message: 'Conectar a ProductsService real',
|
||||
};
|
||||
}
|
||||
|
||||
private async checkProductAvailability(
|
||||
params: { product_id: string; quantity: number },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to actual inventory service
|
||||
const mockStock = 50;
|
||||
return {
|
||||
available: mockStock >= params.quantity,
|
||||
current_stock: mockStock,
|
||||
requested_quantity: params.quantity,
|
||||
shortage: Math.max(0, params.quantity - mockStock),
|
||||
message: 'Conectar a InventoryService real',
|
||||
};
|
||||
}
|
||||
}
|
||||
329
src/modules/mcp/tools/sales-tools.service.ts
Normal file
329
src/modules/mcp/tools/sales-tools.service.ts
Normal file
@ -0,0 +1,329 @@
|
||||
import {
|
||||
McpToolProvider,
|
||||
McpToolDefinition,
|
||||
McpToolHandler,
|
||||
McpContext,
|
||||
} from '../interfaces';
|
||||
|
||||
/**
|
||||
* Sales Tools Service
|
||||
* Provides MCP tools for sales management and reporting.
|
||||
* Used by: ADMIN, SUPERVISOR, OPERATOR roles
|
||||
*/
|
||||
export class SalesToolsService implements McpToolProvider {
|
||||
getTools(): McpToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: 'get_sales_summary',
|
||||
description: 'Obtiene resumen de ventas del dia, semana o mes',
|
||||
category: 'sales',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
period: {
|
||||
type: 'string',
|
||||
enum: ['today', 'week', 'month', 'year'],
|
||||
description: 'Periodo del resumen',
|
||||
default: 'today',
|
||||
},
|
||||
branch_id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'Filtrar por sucursal (opcional)',
|
||||
},
|
||||
},
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
total_sales: { type: 'number' },
|
||||
transaction_count: { type: 'number' },
|
||||
average_ticket: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_sales_report',
|
||||
description: 'Genera reporte detallado de ventas por rango de fechas',
|
||||
category: 'sales',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
start_date: {
|
||||
type: 'string',
|
||||
format: 'date',
|
||||
description: 'Fecha inicial (YYYY-MM-DD)',
|
||||
},
|
||||
end_date: {
|
||||
type: 'string',
|
||||
format: 'date',
|
||||
description: 'Fecha final (YYYY-MM-DD)',
|
||||
},
|
||||
group_by: {
|
||||
type: 'string',
|
||||
enum: ['day', 'week', 'month', 'branch', 'category', 'seller'],
|
||||
description: 'Agrupar resultados por',
|
||||
default: 'day',
|
||||
},
|
||||
branch_id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'Filtrar por sucursal',
|
||||
},
|
||||
},
|
||||
required: ['start_date', 'end_date'],
|
||||
},
|
||||
returns: { type: 'array', items: { type: 'object' } },
|
||||
},
|
||||
{
|
||||
name: 'get_top_products',
|
||||
description: 'Obtiene los productos mas vendidos',
|
||||
category: 'sales',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
period: {
|
||||
type: 'string',
|
||||
enum: ['today', 'week', 'month', 'year'],
|
||||
default: 'month',
|
||||
},
|
||||
limit: { type: 'number', default: 10 },
|
||||
branch_id: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
returns: { type: 'array', items: { type: 'object' } },
|
||||
},
|
||||
{
|
||||
name: 'get_top_customers',
|
||||
description: 'Obtiene los clientes con mas compras',
|
||||
category: 'sales',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
period: {
|
||||
type: 'string',
|
||||
enum: ['month', 'quarter', 'year'],
|
||||
default: 'month',
|
||||
},
|
||||
limit: { type: 'number', default: 10 },
|
||||
order_by: {
|
||||
type: 'string',
|
||||
enum: ['amount', 'transactions'],
|
||||
default: 'amount',
|
||||
},
|
||||
},
|
||||
},
|
||||
returns: { type: 'array', items: { type: 'object' } },
|
||||
},
|
||||
{
|
||||
name: 'get_sales_by_branch',
|
||||
description: 'Compara ventas entre sucursales',
|
||||
category: 'sales',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
period: {
|
||||
type: 'string',
|
||||
enum: ['today', 'week', 'month'],
|
||||
default: 'today',
|
||||
},
|
||||
},
|
||||
},
|
||||
returns: { type: 'array', items: { type: 'object' } },
|
||||
},
|
||||
{
|
||||
name: 'get_my_sales',
|
||||
description: 'Obtiene las ventas del usuario actual (operador)',
|
||||
category: 'sales',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
period: {
|
||||
type: 'string',
|
||||
enum: ['today', 'week', 'month'],
|
||||
default: 'today',
|
||||
},
|
||||
},
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
total: { type: 'number' },
|
||||
count: { type: 'number' },
|
||||
sales: { type: 'array' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_sale',
|
||||
description: 'Registra una nueva venta',
|
||||
category: 'sales',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
product_id: { type: 'string', format: 'uuid' },
|
||||
quantity: { type: 'number', minimum: 1 },
|
||||
unit_price: { type: 'number' },
|
||||
discount: { type: 'number', default: 0 },
|
||||
},
|
||||
required: ['product_id', 'quantity'],
|
||||
},
|
||||
},
|
||||
customer_id: { type: 'string', format: 'uuid' },
|
||||
payment_method: {
|
||||
type: 'string',
|
||||
enum: ['cash', 'card', 'transfer', 'credit'],
|
||||
},
|
||||
notes: { type: 'string' },
|
||||
},
|
||||
required: ['items', 'payment_method'],
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sale_id: { type: 'string' },
|
||||
total: { type: 'number' },
|
||||
status: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
getHandler(toolName: string): McpToolHandler | undefined {
|
||||
const handlers: Record<string, McpToolHandler> = {
|
||||
get_sales_summary: this.getSalesSummary.bind(this),
|
||||
get_sales_report: this.getSalesReport.bind(this),
|
||||
get_top_products: this.getTopProducts.bind(this),
|
||||
get_top_customers: this.getTopCustomers.bind(this),
|
||||
get_sales_by_branch: this.getSalesByBranch.bind(this),
|
||||
get_my_sales: this.getMySales.bind(this),
|
||||
create_sale: this.createSale.bind(this),
|
||||
};
|
||||
return handlers[toolName];
|
||||
}
|
||||
|
||||
private async getSalesSummary(
|
||||
params: { period?: string; branch_id?: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to SalesService
|
||||
const period = params.period || 'today';
|
||||
return {
|
||||
period,
|
||||
branch_id: params.branch_id || 'all',
|
||||
total_sales: 15750.00,
|
||||
transaction_count: 42,
|
||||
average_ticket: 375.00,
|
||||
comparison: {
|
||||
previous_period: 14200.00,
|
||||
change_percent: 10.9,
|
||||
},
|
||||
message: 'Conectar a SalesService real',
|
||||
};
|
||||
}
|
||||
|
||||
private async getSalesReport(
|
||||
params: { start_date: string; end_date: string; group_by?: string; branch_id?: string },
|
||||
context: McpContext
|
||||
): Promise<any[]> {
|
||||
// TODO: Connect to SalesService
|
||||
return [
|
||||
{
|
||||
date: params.start_date,
|
||||
total: 5250.00,
|
||||
count: 15,
|
||||
avg_ticket: 350.00,
|
||||
},
|
||||
{
|
||||
date: params.end_date,
|
||||
total: 4800.00,
|
||||
count: 12,
|
||||
avg_ticket: 400.00,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async getTopProducts(
|
||||
params: { period?: string; limit?: number; branch_id?: string },
|
||||
context: McpContext
|
||||
): Promise<any[]> {
|
||||
// TODO: Connect to SalesService
|
||||
return [
|
||||
{ rank: 1, product: 'Producto A', quantity: 150, revenue: 7500.00 },
|
||||
{ rank: 2, product: 'Producto B', quantity: 120, revenue: 6000.00 },
|
||||
{ rank: 3, product: 'Producto C', quantity: 100, revenue: 5000.00 },
|
||||
].slice(0, params.limit || 10);
|
||||
}
|
||||
|
||||
private async getTopCustomers(
|
||||
params: { period?: string; limit?: number; order_by?: string },
|
||||
context: McpContext
|
||||
): Promise<any[]> {
|
||||
// TODO: Connect to CustomersService
|
||||
return [
|
||||
{ rank: 1, customer: 'Cliente A', total: 25000.00, transactions: 15 },
|
||||
{ rank: 2, customer: 'Cliente B', total: 18000.00, transactions: 12 },
|
||||
].slice(0, params.limit || 10);
|
||||
}
|
||||
|
||||
private async getSalesByBranch(
|
||||
params: { period?: string },
|
||||
context: McpContext
|
||||
): Promise<any[]> {
|
||||
// TODO: Connect to SalesService + BranchesService
|
||||
return [
|
||||
{ branch: 'Sucursal Centro', total: 8500.00, count: 25 },
|
||||
{ branch: 'Sucursal Norte', total: 7250.00, count: 17 },
|
||||
];
|
||||
}
|
||||
|
||||
private async getMySales(
|
||||
params: { period?: string },
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to SalesService with context.userId
|
||||
return {
|
||||
user_id: context.userId,
|
||||
period: params.period || 'today',
|
||||
total: 3500.00,
|
||||
count: 8,
|
||||
sales: [
|
||||
{ id: 'sale-1', total: 450.00, time: '10:30' },
|
||||
{ id: 'sale-2', total: 680.00, time: '11:45' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async createSale(
|
||||
params: {
|
||||
items: Array<{ product_id: string; quantity: number; unit_price?: number; discount?: number }>;
|
||||
customer_id?: string;
|
||||
payment_method: string;
|
||||
notes?: string;
|
||||
},
|
||||
context: McpContext
|
||||
): Promise<any> {
|
||||
// TODO: Connect to SalesService
|
||||
const total = params.items.reduce((sum, item) => {
|
||||
const price = item.unit_price || 100; // Default price for demo
|
||||
const discount = item.discount || 0;
|
||||
return sum + (price * item.quantity * (1 - discount / 100));
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
sale_id: `SALE-${Date.now()}`,
|
||||
total,
|
||||
items_count: params.items.length,
|
||||
payment_method: params.payment_method,
|
||||
status: 'completed',
|
||||
created_by: context.userId,
|
||||
message: 'Conectar a SalesService real',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Clip Webhook Controller
|
||||
*
|
||||
* Endpoint público para recibir webhooks de Clip
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ClipService } from '../services/clip.service';
|
||||
|
||||
export class ClipWebhookController {
|
||||
public router: Router;
|
||||
private clipService: ClipService;
|
||||
|
||||
constructor(private dataSource: DataSource) {
|
||||
this.router = Router();
|
||||
this.clipService = new ClipService(dataSource);
|
||||
this.initializeRoutes();
|
||||
}
|
||||
|
||||
private initializeRoutes(): void {
|
||||
// Webhook endpoint (público, sin auth)
|
||||
this.router.post('/:tenantId', this.handleWebhook.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /webhooks/clip/:tenantId
|
||||
* Recibir notificaciones de Clip
|
||||
*/
|
||||
private async handleWebhook(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.params.tenantId;
|
||||
const eventType = req.body.event || req.body.type;
|
||||
const data = req.body;
|
||||
|
||||
// Extraer headers relevantes
|
||||
const headers: Record<string, string> = {
|
||||
'x-clip-signature': req.headers['x-clip-signature'] as string || '',
|
||||
'x-clip-event-id': req.headers['x-clip-event-id'] as string || '',
|
||||
};
|
||||
|
||||
// Responder inmediatamente
|
||||
res.status(200).json({ received: true });
|
||||
|
||||
// Procesar webhook de forma asíncrona
|
||||
await this.clipService.handleWebhook(tenantId, eventType, data, headers);
|
||||
} catch (error) {
|
||||
console.error('Clip webhook error:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(200).json({ received: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
164
src/modules/payment-terminals/controllers/clip.controller.ts
Normal file
164
src/modules/payment-terminals/controllers/clip.controller.ts
Normal file
@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Clip Controller
|
||||
*
|
||||
* Endpoints para pagos con Clip
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ClipService } from '../services/clip.service';
|
||||
|
||||
export class ClipController {
|
||||
public router: Router;
|
||||
private clipService: ClipService;
|
||||
|
||||
constructor(private dataSource: DataSource) {
|
||||
this.router = Router();
|
||||
this.clipService = new ClipService(dataSource);
|
||||
this.initializeRoutes();
|
||||
}
|
||||
|
||||
private initializeRoutes(): void {
|
||||
// Pagos
|
||||
this.router.post('/payments', this.createPayment.bind(this));
|
||||
this.router.get('/payments/:id', this.getPayment.bind(this));
|
||||
this.router.post('/payments/:id/refund', this.refundPayment.bind(this));
|
||||
|
||||
// Links de pago
|
||||
this.router.post('/links', this.createPaymentLink.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /clip/payments
|
||||
* Crear un nuevo pago
|
||||
*/
|
||||
private async createPayment(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const userId = this.getUserId(req);
|
||||
|
||||
const payment = await this.clipService.createPayment(
|
||||
tenantId,
|
||||
{
|
||||
amount: req.body.amount,
|
||||
currency: req.body.currency,
|
||||
description: req.body.description,
|
||||
customerEmail: req.body.customerEmail,
|
||||
customerName: req.body.customerName,
|
||||
customerPhone: req.body.customerPhone,
|
||||
referenceType: req.body.referenceType,
|
||||
referenceId: req.body.referenceId,
|
||||
metadata: req.body.metadata,
|
||||
},
|
||||
userId
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: this.sanitizePayment(payment),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /clip/payments/:id
|
||||
* Obtener estado de un pago
|
||||
*/
|
||||
private async getPayment(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const paymentId = req.params.id;
|
||||
|
||||
const payment = await this.clipService.getPayment(tenantId, paymentId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: this.sanitizePayment(payment),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /clip/payments/:id/refund
|
||||
* Reembolsar un pago
|
||||
*/
|
||||
private async refundPayment(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const paymentId = req.params.id;
|
||||
|
||||
const payment = await this.clipService.refundPayment(tenantId, {
|
||||
paymentId,
|
||||
amount: req.body.amount,
|
||||
reason: req.body.reason,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: this.sanitizePayment(payment),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /clip/links
|
||||
* Crear un link de pago
|
||||
*/
|
||||
private async createPaymentLink(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const userId = this.getUserId(req);
|
||||
|
||||
const link = await this.clipService.createPaymentLink(
|
||||
tenantId,
|
||||
{
|
||||
amount: req.body.amount,
|
||||
description: req.body.description,
|
||||
expiresInMinutes: req.body.expiresInMinutes,
|
||||
referenceType: req.body.referenceType,
|
||||
referenceId: req.body.referenceId,
|
||||
},
|
||||
userId
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: link,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener tenant ID del request
|
||||
*/
|
||||
private getTenantId(req: Request): string {
|
||||
const tenantId = (req as any).tenantId || (req as any).user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('Tenant ID not found in request');
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener user ID del request
|
||||
*/
|
||||
private getUserId(req: Request): string | undefined {
|
||||
return (req as any).userId || (req as any).user?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizar pago para respuesta
|
||||
*/
|
||||
private sanitizePayment(payment: any): any {
|
||||
const { providerResponse, ...safe } = payment;
|
||||
return safe;
|
||||
}
|
||||
}
|
||||
14
src/modules/payment-terminals/controllers/index.ts
Normal file
14
src/modules/payment-terminals/controllers/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Payment Terminals Controllers Index
|
||||
*/
|
||||
|
||||
export { TerminalsController } from './terminals.controller';
|
||||
export { TransactionsController } from './transactions.controller';
|
||||
|
||||
// MercadoPago
|
||||
export { MercadoPagoController } from './mercadopago.controller';
|
||||
export { MercadoPagoWebhookController } from './mercadopago-webhook.controller';
|
||||
|
||||
// Clip
|
||||
export { ClipController } from './clip.controller';
|
||||
export { ClipWebhookController } from './clip-webhook.controller';
|
||||
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* MercadoPago Webhook Controller
|
||||
*
|
||||
* Endpoint público para recibir webhooks de MercadoPago
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { MercadoPagoService } from '../services/mercadopago.service';
|
||||
|
||||
export class MercadoPagoWebhookController {
|
||||
public router: Router;
|
||||
private mercadoPagoService: MercadoPagoService;
|
||||
|
||||
constructor(private dataSource: DataSource) {
|
||||
this.router = Router();
|
||||
this.mercadoPagoService = new MercadoPagoService(dataSource);
|
||||
this.initializeRoutes();
|
||||
}
|
||||
|
||||
private initializeRoutes(): void {
|
||||
// Webhook endpoint (público, sin auth)
|
||||
this.router.post('/:tenantId', this.handleWebhook.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /webhooks/mercadopago/:tenantId
|
||||
* Recibir notificaciones IPN de MercadoPago
|
||||
*/
|
||||
private async handleWebhook(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.params.tenantId;
|
||||
const eventType = req.body.type || req.body.action;
|
||||
const data = req.body;
|
||||
|
||||
// Extraer headers relevantes
|
||||
const headers: Record<string, string> = {
|
||||
'x-signature': req.headers['x-signature'] as string || '',
|
||||
'x-request-id': req.headers['x-request-id'] as string || '',
|
||||
};
|
||||
|
||||
// Responder inmediatamente (MercadoPago espera 200 rápido)
|
||||
res.status(200).json({ received: true });
|
||||
|
||||
// Procesar webhook de forma asíncrona
|
||||
await this.mercadoPagoService.handleWebhook(tenantId, eventType, data, headers);
|
||||
} catch (error) {
|
||||
// Log error pero no fallar el webhook
|
||||
console.error('MercadoPago webhook error:', error);
|
||||
// Si aún no enviamos respuesta
|
||||
if (!res.headersSent) {
|
||||
res.status(200).json({ received: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,165 @@
|
||||
/**
|
||||
* MercadoPago Controller
|
||||
*
|
||||
* Endpoints para pagos con MercadoPago
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { MercadoPagoService } from '../services/mercadopago.service';
|
||||
|
||||
export class MercadoPagoController {
|
||||
public router: Router;
|
||||
private mercadoPagoService: MercadoPagoService;
|
||||
|
||||
constructor(private dataSource: DataSource) {
|
||||
this.router = Router();
|
||||
this.mercadoPagoService = new MercadoPagoService(dataSource);
|
||||
this.initializeRoutes();
|
||||
}
|
||||
|
||||
private initializeRoutes(): void {
|
||||
// Pagos
|
||||
this.router.post('/payments', this.createPayment.bind(this));
|
||||
this.router.get('/payments/:id', this.getPayment.bind(this));
|
||||
this.router.post('/payments/:id/refund', this.refundPayment.bind(this));
|
||||
|
||||
// Links de pago
|
||||
this.router.post('/links', this.createPaymentLink.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /mercadopago/payments
|
||||
* Crear un nuevo pago
|
||||
*/
|
||||
private async createPayment(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const userId = this.getUserId(req);
|
||||
|
||||
const payment = await this.mercadoPagoService.createPayment(
|
||||
tenantId,
|
||||
{
|
||||
amount: req.body.amount,
|
||||
currency: req.body.currency,
|
||||
description: req.body.description,
|
||||
paymentMethod: req.body.paymentMethod,
|
||||
customerEmail: req.body.customerEmail,
|
||||
customerName: req.body.customerName,
|
||||
referenceType: req.body.referenceType,
|
||||
referenceId: req.body.referenceId,
|
||||
metadata: req.body.metadata,
|
||||
},
|
||||
userId
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: this.sanitizePayment(payment),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /mercadopago/payments/:id
|
||||
* Obtener estado de un pago
|
||||
*/
|
||||
private async getPayment(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const paymentId = req.params.id;
|
||||
|
||||
const payment = await this.mercadoPagoService.getPayment(tenantId, paymentId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: this.sanitizePayment(payment),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /mercadopago/payments/:id/refund
|
||||
* Reembolsar un pago
|
||||
*/
|
||||
private async refundPayment(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const paymentId = req.params.id;
|
||||
|
||||
const payment = await this.mercadoPagoService.refundPayment(tenantId, {
|
||||
paymentId,
|
||||
amount: req.body.amount,
|
||||
reason: req.body.reason,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: this.sanitizePayment(payment),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /mercadopago/links
|
||||
* Crear un link de pago
|
||||
*/
|
||||
private async createPaymentLink(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const userId = this.getUserId(req);
|
||||
|
||||
const link = await this.mercadoPagoService.createPaymentLink(
|
||||
tenantId,
|
||||
{
|
||||
amount: req.body.amount,
|
||||
title: req.body.title,
|
||||
description: req.body.description,
|
||||
expiresAt: req.body.expiresAt ? new Date(req.body.expiresAt) : undefined,
|
||||
referenceType: req.body.referenceType,
|
||||
referenceId: req.body.referenceId,
|
||||
},
|
||||
userId
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: link,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener tenant ID del request
|
||||
*/
|
||||
private getTenantId(req: Request): string {
|
||||
const tenantId = (req as any).tenantId || (req as any).user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('Tenant ID not found in request');
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener user ID del request
|
||||
*/
|
||||
private getUserId(req: Request): string | undefined {
|
||||
return (req as any).userId || (req as any).user?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizar pago para respuesta (ocultar datos sensibles)
|
||||
*/
|
||||
private sanitizePayment(payment: any): any {
|
||||
const { providerResponse, ...safe } = payment;
|
||||
return safe;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Terminals Controller
|
||||
*
|
||||
* REST API endpoints for terminal management
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { TerminalsService } from '../services';
|
||||
import { CreateTerminalDto, UpdateTerminalDto } from '../dto';
|
||||
|
||||
// Extend Request to include tenant info
|
||||
interface AuthenticatedRequest extends Request {
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export class TerminalsController {
|
||||
public router: Router;
|
||||
private service: TerminalsService;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.router = Router();
|
||||
this.service = new TerminalsService(dataSource);
|
||||
this.initializeRoutes();
|
||||
}
|
||||
|
||||
private initializeRoutes(): void {
|
||||
// Branch terminals
|
||||
this.router.get('/branch/:branchId', this.getByBranch.bind(this));
|
||||
this.router.get('/branch/:branchId/primary', this.getPrimary.bind(this));
|
||||
|
||||
// Terminal CRUD
|
||||
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));
|
||||
|
||||
// Terminal actions
|
||||
this.router.get('/:id/health', this.checkHealth.bind(this));
|
||||
this.router.post('/:id/set-primary', this.setPrimary.bind(this));
|
||||
|
||||
// Health check batch (for scheduled job)
|
||||
this.router.post('/health-check-batch', this.healthCheckBatch.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /payment-terminals/branch/:branchId
|
||||
* Get terminals for branch
|
||||
*/
|
||||
private async getByBranch(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const terminals = await this.service.findByBranch(req.params.branchId);
|
||||
res.json({ data: terminals });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /payment-terminals/branch/:branchId/primary
|
||||
* Get primary terminal for branch
|
||||
*/
|
||||
private async getPrimary(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const terminal = await this.service.findPrimaryTerminal(req.params.branchId);
|
||||
res.json({ data: terminal });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /payment-terminals/:id
|
||||
* Get terminal by ID
|
||||
*/
|
||||
private async getById(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const terminal = await this.service.findById(req.params.id);
|
||||
|
||||
if (!terminal) {
|
||||
res.status(404).json({ error: 'Terminal not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: terminal });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payment-terminals
|
||||
* Create new terminal
|
||||
*/
|
||||
private async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const dto: CreateTerminalDto = req.body;
|
||||
const terminal = await this.service.create(req.tenantId!, dto);
|
||||
res.status(201).json({ data: terminal });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /payment-terminals/:id
|
||||
* Update terminal
|
||||
*/
|
||||
private async update(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const dto: UpdateTerminalDto = req.body;
|
||||
const terminal = await this.service.update(req.params.id, dto);
|
||||
res.json({ data: terminal });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /payment-terminals/:id
|
||||
* Delete terminal (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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /payment-terminals/:id/health
|
||||
* Check terminal health
|
||||
*/
|
||||
private async checkHealth(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const health = await this.service.checkHealth(req.params.id);
|
||||
res.json({ data: health });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payment-terminals/:id/set-primary
|
||||
* Set terminal as primary for branch
|
||||
*/
|
||||
private async setPrimary(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const terminal = await this.service.setPrimary(req.params.id);
|
||||
res.json({ data: terminal });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payment-terminals/health-check-batch
|
||||
* Run health check on all terminals needing check (scheduled job endpoint)
|
||||
*/
|
||||
private async healthCheckBatch(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const maxAgeMinutes = parseInt(req.query.maxAgeMinutes as string) || 30;
|
||||
const terminals = await this.service.findTerminalsNeedingHealthCheck(maxAgeMinutes);
|
||||
|
||||
const results: { terminalId: string; status: string; message: string }[] = [];
|
||||
|
||||
for (const terminal of terminals) {
|
||||
try {
|
||||
const health = await this.service.checkHealth(terminal.id);
|
||||
results.push({
|
||||
terminalId: terminal.id,
|
||||
status: health.status,
|
||||
message: health.message,
|
||||
});
|
||||
} catch (error: any) {
|
||||
results.push({
|
||||
terminalId: terminal.id,
|
||||
status: 'error',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ data: { checked: results.length, results } });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Transactions Controller
|
||||
*
|
||||
* REST API endpoints for payment transactions
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { TransactionsService } from '../services';
|
||||
import { ProcessPaymentDto, ProcessRefundDto, SendReceiptDto, TransactionFilterDto } from '../dto';
|
||||
|
||||
// Extend Request to include tenant info
|
||||
interface AuthenticatedRequest extends Request {
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export class TransactionsController {
|
||||
public router: Router;
|
||||
private service: TransactionsService;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.router = Router();
|
||||
this.service = new TransactionsService(dataSource);
|
||||
this.initializeRoutes();
|
||||
}
|
||||
|
||||
private initializeRoutes(): void {
|
||||
// Stats
|
||||
this.router.get('/stats', this.getStats.bind(this));
|
||||
|
||||
// Payment processing
|
||||
this.router.post('/charge', this.processPayment.bind(this));
|
||||
this.router.post('/refund', this.processRefund.bind(this));
|
||||
|
||||
// Transaction queries
|
||||
this.router.get('/', this.getAll.bind(this));
|
||||
this.router.get('/:id', this.getById.bind(this));
|
||||
|
||||
// Actions
|
||||
this.router.post('/:id/receipt', this.sendReceipt.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /payment-transactions/stats
|
||||
* Get transaction statistics
|
||||
*/
|
||||
private async getStats(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const filter: TransactionFilterDto = {
|
||||
branchId: req.query.branchId as string,
|
||||
startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined,
|
||||
endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined,
|
||||
};
|
||||
|
||||
const stats = await this.service.getStats(req.tenantId!, filter);
|
||||
res.json({ data: stats });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payment-transactions/charge
|
||||
* Process a payment
|
||||
*/
|
||||
private async processPayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const dto: ProcessPaymentDto = req.body;
|
||||
const result = await this.service.processPayment(req.tenantId!, req.userId!, dto);
|
||||
|
||||
if (result.success) {
|
||||
res.status(201).json({ data: result });
|
||||
} else {
|
||||
res.status(400).json({ data: result });
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payment-transactions/refund
|
||||
* Process a refund
|
||||
*/
|
||||
private async processRefund(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const dto: ProcessRefundDto = req.body;
|
||||
const result = await this.service.processRefund(req.tenantId!, req.userId!, dto);
|
||||
|
||||
if (result.success) {
|
||||
res.json({ data: result });
|
||||
} else {
|
||||
res.status(400).json({ data: result });
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /payment-transactions
|
||||
* Get transactions with filters
|
||||
*/
|
||||
private async getAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const filter: TransactionFilterDto = {
|
||||
branchId: req.query.branchId as string,
|
||||
userId: req.query.userId as string,
|
||||
status: req.query.status as any,
|
||||
sourceType: req.query.sourceType as any,
|
||||
terminalProvider: req.query.terminalProvider as string,
|
||||
startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined,
|
||||
endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined,
|
||||
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(req.tenantId!, filter);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /payment-transactions/:id
|
||||
* Get transaction by ID
|
||||
*/
|
||||
private async getById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const transaction = await this.service.findById(req.params.id, req.tenantId!);
|
||||
|
||||
if (!transaction) {
|
||||
res.status(404).json({ error: 'Transaction not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: transaction });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payment-transactions/:id/receipt
|
||||
* Send receipt for transaction
|
||||
*/
|
||||
private async sendReceipt(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const dto: SendReceiptDto = req.body;
|
||||
const result = await this.service.sendReceipt(req.params.id, req.tenantId!, dto);
|
||||
|
||||
if (result.success) {
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(400).json({ success: false, error: result.error });
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
src/modules/payment-terminals/dto/index.ts
Normal file
6
src/modules/payment-terminals/dto/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Payment Terminals DTOs Index
|
||||
*/
|
||||
|
||||
export * from './terminal.dto';
|
||||
export * from './transaction.dto';
|
||||
47
src/modules/payment-terminals/dto/terminal.dto.ts
Normal file
47
src/modules/payment-terminals/dto/terminal.dto.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Terminal DTOs
|
||||
*/
|
||||
|
||||
import { TerminalProvider, HealthStatus } from '../../branches/entities/branch-payment-terminal.entity';
|
||||
|
||||
export class CreateTerminalDto {
|
||||
branchId: string;
|
||||
terminalProvider: TerminalProvider;
|
||||
terminalId: string;
|
||||
terminalName?: string;
|
||||
credentials?: Record<string, any>;
|
||||
isPrimary?: boolean;
|
||||
dailyLimit?: number;
|
||||
transactionLimit?: number;
|
||||
}
|
||||
|
||||
export class UpdateTerminalDto {
|
||||
terminalName?: string;
|
||||
credentials?: Record<string, any>;
|
||||
isPrimary?: boolean;
|
||||
isActive?: boolean;
|
||||
dailyLimit?: number;
|
||||
transactionLimit?: number;
|
||||
}
|
||||
|
||||
export class TerminalHealthCheckDto {
|
||||
terminalId: string;
|
||||
status: HealthStatus;
|
||||
message?: string;
|
||||
responseTime?: number;
|
||||
}
|
||||
|
||||
export class TerminalResponseDto {
|
||||
id: string;
|
||||
branchId: string;
|
||||
terminalProvider: TerminalProvider;
|
||||
terminalId: string;
|
||||
terminalName?: string;
|
||||
isPrimary: boolean;
|
||||
isActive: boolean;
|
||||
dailyLimit?: number;
|
||||
transactionLimit?: number;
|
||||
healthStatus: HealthStatus;
|
||||
lastTransactionAt?: Date;
|
||||
lastHealthCheckAt?: Date;
|
||||
}
|
||||
75
src/modules/payment-terminals/dto/transaction.dto.ts
Normal file
75
src/modules/payment-terminals/dto/transaction.dto.ts
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Transaction DTOs
|
||||
*/
|
||||
|
||||
import { PaymentSourceType, PaymentMethod, PaymentStatus } from '../../mobile/entities/payment-transaction.entity';
|
||||
|
||||
export class ProcessPaymentDto {
|
||||
terminalId: string;
|
||||
amount: number;
|
||||
currency?: string;
|
||||
tipAmount?: number;
|
||||
sourceType: PaymentSourceType;
|
||||
sourceId: string;
|
||||
description?: string;
|
||||
customerEmail?: string;
|
||||
customerPhone?: string;
|
||||
}
|
||||
|
||||
export class PaymentResultDto {
|
||||
success: boolean;
|
||||
transactionId?: string;
|
||||
externalTransactionId?: string;
|
||||
amount: number;
|
||||
totalAmount: number;
|
||||
tipAmount: number;
|
||||
currency: string;
|
||||
status: PaymentStatus;
|
||||
paymentMethod?: PaymentMethod;
|
||||
cardBrand?: string;
|
||||
cardLastFour?: string;
|
||||
receiptUrl?: string;
|
||||
error?: string;
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
export class ProcessRefundDto {
|
||||
transactionId: string;
|
||||
amount?: number; // Partial refund if provided
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class RefundResultDto {
|
||||
success: boolean;
|
||||
refundId?: string;
|
||||
amount: number;
|
||||
status: 'pending' | 'completed' | 'failed';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class SendReceiptDto {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export class TransactionFilterDto {
|
||||
branchId?: string;
|
||||
userId?: string;
|
||||
status?: PaymentStatus;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
sourceType?: PaymentSourceType;
|
||||
terminalProvider?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export class TransactionStatsDto {
|
||||
total: number;
|
||||
totalAmount: number;
|
||||
byStatus: Record<PaymentStatus, number>;
|
||||
byProvider: Record<string, { count: number; amount: number }>;
|
||||
byPaymentMethod: Record<PaymentMethod, number>;
|
||||
averageAmount: number;
|
||||
successRate: number;
|
||||
}
|
||||
3
src/modules/payment-terminals/entities/index.ts
Normal file
3
src/modules/payment-terminals/entities/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './tenant-terminal-config.entity';
|
||||
export * from './terminal-payment.entity';
|
||||
export * from './terminal-webhook-event.entity';
|
||||
@ -0,0 +1,82 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export type TerminalProvider = 'mercadopago' | 'clip' | 'stripe_terminal';
|
||||
|
||||
@Entity({ name: 'tenant_terminal_configs', schema: 'payment_terminals' })
|
||||
@Index(['tenantId', 'provider', 'name'], { unique: true })
|
||||
export class TenantTerminalConfig {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['mercadopago', 'clip', 'stripe_terminal'],
|
||||
enumName: 'terminal_provider',
|
||||
})
|
||||
provider: TerminalProvider;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
// Credenciales encriptadas
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
credentials: Record<string, any>;
|
||||
|
||||
// Configuración específica del proveedor
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
config: Record<string, any>;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ name: 'is_verified', type: 'boolean', default: false })
|
||||
isVerified: boolean;
|
||||
|
||||
@Column({ name: 'verification_error', type: 'text', nullable: true })
|
||||
verificationError: string | null;
|
||||
|
||||
@Column({ name: 'verified_at', type: 'timestamptz', nullable: true })
|
||||
verifiedAt: Date | null;
|
||||
|
||||
// Límites
|
||||
@Column({ name: 'daily_limit', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||
dailyLimit: number | null;
|
||||
|
||||
@Column({ name: 'monthly_limit', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||
monthlyLimit: number | null;
|
||||
|
||||
@Column({ name: 'transaction_limit', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||
transactionLimit: number | null;
|
||||
|
||||
// Webhook
|
||||
@Column({ name: 'webhook_url', type: 'varchar', length: 500, nullable: true })
|
||||
webhookUrl: string | null;
|
||||
|
||||
@Column({ name: 'webhook_secret', type: 'varchar', length: 255, nullable: true })
|
||||
webhookSecret: string | null;
|
||||
|
||||
@Column({ 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 | null;
|
||||
}
|
||||
@ -0,0 +1,182 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { TenantTerminalConfig, TerminalProvider } from './tenant-terminal-config.entity';
|
||||
|
||||
export type TerminalPaymentStatus =
|
||||
| 'pending'
|
||||
| 'processing'
|
||||
| 'approved'
|
||||
| 'authorized'
|
||||
| 'in_process'
|
||||
| 'rejected'
|
||||
| 'refunded'
|
||||
| 'partially_refunded'
|
||||
| 'cancelled'
|
||||
| 'charged_back';
|
||||
|
||||
export type PaymentMethodType = 'card' | 'qr' | 'link' | 'cash' | 'bank_transfer';
|
||||
|
||||
@Entity({ name: 'terminal_payments', schema: 'payment_terminals' })
|
||||
export class TerminalPayment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'config_id', type: 'uuid', nullable: true })
|
||||
configId: string | null;
|
||||
|
||||
@Column({ name: 'branch_terminal_id', type: 'uuid', nullable: true })
|
||||
branchTerminalId: string | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['mercadopago', 'clip', 'stripe_terminal'],
|
||||
enumName: 'terminal_provider',
|
||||
})
|
||||
provider: TerminalProvider;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'external_id', type: 'varchar', length: 255, nullable: true })
|
||||
externalId: string | null;
|
||||
|
||||
@Column({ name: 'external_status', type: 'varchar', length: 50, nullable: true })
|
||||
externalStatus: string | null;
|
||||
|
||||
// Monto
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||||
amount: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 3, default: 'MXN' })
|
||||
currency: string;
|
||||
|
||||
// Estado
|
||||
@Index()
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: [
|
||||
'pending',
|
||||
'processing',
|
||||
'approved',
|
||||
'authorized',
|
||||
'in_process',
|
||||
'rejected',
|
||||
'refunded',
|
||||
'partially_refunded',
|
||||
'cancelled',
|
||||
'charged_back',
|
||||
],
|
||||
enumName: 'terminal_payment_status',
|
||||
default: 'pending',
|
||||
})
|
||||
status: TerminalPaymentStatus;
|
||||
|
||||
// Método de pago
|
||||
@Column({
|
||||
name: 'payment_method',
|
||||
type: 'enum',
|
||||
enum: ['card', 'qr', 'link', 'cash', 'bank_transfer'],
|
||||
enumName: 'payment_method_type',
|
||||
default: 'card',
|
||||
})
|
||||
paymentMethod: PaymentMethodType;
|
||||
|
||||
// Datos de tarjeta
|
||||
@Column({ name: 'card_last_four', type: 'varchar', length: 4, nullable: true })
|
||||
cardLastFour: string | null;
|
||||
|
||||
@Column({ name: 'card_brand', type: 'varchar', length: 20, nullable: true })
|
||||
cardBrand: string | null;
|
||||
|
||||
@Column({ name: 'card_type', type: 'varchar', length: 20, nullable: true })
|
||||
cardType: string | null;
|
||||
|
||||
// Cliente
|
||||
@Column({ name: 'customer_email', type: 'varchar', length: 255, nullable: true })
|
||||
customerEmail: string | null;
|
||||
|
||||
@Column({ name: 'customer_phone', type: 'varchar', length: 20, nullable: true })
|
||||
customerPhone: string | null;
|
||||
|
||||
@Column({ name: 'customer_name', type: 'varchar', length: 200, nullable: true })
|
||||
customerName: string | null;
|
||||
|
||||
// Descripción
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ name: 'statement_descriptor', type: 'varchar', length: 50, nullable: true })
|
||||
statementDescriptor: string | null;
|
||||
|
||||
// Referencia interna
|
||||
@Index()
|
||||
@Column({ name: 'reference_type', type: 'varchar', length: 50, nullable: true })
|
||||
referenceType: string | null;
|
||||
|
||||
@Column({ name: 'reference_id', type: 'uuid', nullable: true })
|
||||
referenceId: string | null;
|
||||
|
||||
// Comisiones
|
||||
@Column({ name: 'fee_amount', type: 'decimal', precision: 10, scale: 4, nullable: true })
|
||||
feeAmount: number | null;
|
||||
|
||||
@Column({ name: 'fee_details', type: 'jsonb', nullable: true })
|
||||
feeDetails: Record<string, any> | null;
|
||||
|
||||
@Column({ name: 'net_amount', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||
netAmount: number | null;
|
||||
|
||||
// Reembolso
|
||||
@Column({ name: 'refunded_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||
refundedAmount: number;
|
||||
|
||||
@Column({ name: 'refund_reason', type: 'text', nullable: true })
|
||||
refundReason: string | null;
|
||||
|
||||
// Respuesta del proveedor
|
||||
@Column({ name: 'provider_response', type: 'jsonb', nullable: true })
|
||||
providerResponse: Record<string, any> | null;
|
||||
|
||||
// Error
|
||||
@Column({ name: 'error_code', type: 'varchar', length: 50, nullable: true })
|
||||
errorCode: string | null;
|
||||
|
||||
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||
errorMessage: string | null;
|
||||
|
||||
// Timestamps
|
||||
@Column({ name: 'processed_at', type: 'timestamptz', nullable: true })
|
||||
processedAt: Date | null;
|
||||
|
||||
@Column({ name: 'refunded_at', type: 'timestamptz', nullable: true })
|
||||
refundedAt: Date | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@Index()
|
||||
@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 | null;
|
||||
|
||||
// Relaciones
|
||||
@ManyToOne(() => TenantTerminalConfig, { nullable: true })
|
||||
@JoinColumn({ name: 'config_id' })
|
||||
config?: TenantTerminalConfig;
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { TerminalProvider } from './tenant-terminal-config.entity';
|
||||
import { TerminalPayment } from './terminal-payment.entity';
|
||||
|
||||
@Entity({ name: 'terminal_webhook_events', schema: 'payment_terminals' })
|
||||
@Index(['provider', 'eventId'], { unique: true })
|
||||
export class TerminalWebhookEvent {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['mercadopago', 'clip', 'stripe_terminal'],
|
||||
enumName: 'terminal_provider',
|
||||
})
|
||||
provider: TerminalProvider;
|
||||
|
||||
@Column({ name: 'event_type', type: 'varchar', length: 100 })
|
||||
eventType: string;
|
||||
|
||||
@Column({ name: 'event_id', type: 'varchar', length: 255, nullable: true })
|
||||
eventId: string | null;
|
||||
|
||||
@Column({ name: 'payment_id', type: 'uuid', nullable: true })
|
||||
paymentId: string | null;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'external_id', type: 'varchar', length: 255, nullable: true })
|
||||
externalId: string | null;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
payload: Record<string, any>;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
headers: Record<string, any> | null;
|
||||
|
||||
@Column({ name: 'signature_valid', type: 'boolean', nullable: true })
|
||||
signatureValid: boolean | null;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'boolean', default: false })
|
||||
processed: boolean;
|
||||
|
||||
@Column({ name: 'processed_at', type: 'timestamptz', nullable: true })
|
||||
processedAt: Date | null;
|
||||
|
||||
@Column({ name: 'processing_error', type: 'text', nullable: true })
|
||||
processingError: string | null;
|
||||
|
||||
@Column({ name: 'retry_count', type: 'integer', default: 0 })
|
||||
retryCount: number;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'idempotency_key', type: 'varchar', length: 255, nullable: true })
|
||||
idempotencyKey: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
// Relaciones
|
||||
@ManyToOne(() => TerminalPayment, { nullable: true })
|
||||
@JoinColumn({ name: 'payment_id' })
|
||||
payment?: TerminalPayment;
|
||||
}
|
||||
15
src/modules/payment-terminals/index.ts
Normal file
15
src/modules/payment-terminals/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Payment Terminals Module Index
|
||||
*/
|
||||
|
||||
// Module
|
||||
export { PaymentTerminalsModule, PaymentTerminalsModuleOptions } from './payment-terminals.module';
|
||||
|
||||
// DTOs
|
||||
export * from './dto';
|
||||
|
||||
// Services
|
||||
export * from './services';
|
||||
|
||||
// Controllers
|
||||
export * from './controllers';
|
||||
76
src/modules/payment-terminals/payment-terminals.module.ts
Normal file
76
src/modules/payment-terminals/payment-terminals.module.ts
Normal file
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Payment Terminals Module
|
||||
*
|
||||
* Module registration for payment terminals and transactions
|
||||
* Includes: MercadoPago, Clip, Stripe Terminal
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
TerminalsController,
|
||||
TransactionsController,
|
||||
MercadoPagoController,
|
||||
MercadoPagoWebhookController,
|
||||
ClipController,
|
||||
ClipWebhookController,
|
||||
} from './controllers';
|
||||
|
||||
export interface PaymentTerminalsModuleOptions {
|
||||
dataSource: DataSource;
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
export class PaymentTerminalsModule {
|
||||
public router: Router;
|
||||
public webhookRouter: Router;
|
||||
|
||||
private terminalsController: TerminalsController;
|
||||
private transactionsController: TransactionsController;
|
||||
private mercadoPagoController: MercadoPagoController;
|
||||
private mercadoPagoWebhookController: MercadoPagoWebhookController;
|
||||
private clipController: ClipController;
|
||||
private clipWebhookController: ClipWebhookController;
|
||||
|
||||
constructor(options: PaymentTerminalsModuleOptions) {
|
||||
const { dataSource, basePath = '' } = options;
|
||||
|
||||
this.router = Router();
|
||||
this.webhookRouter = Router();
|
||||
|
||||
// Initialize controllers
|
||||
this.terminalsController = new TerminalsController(dataSource);
|
||||
this.transactionsController = new TransactionsController(dataSource);
|
||||
this.mercadoPagoController = new MercadoPagoController(dataSource);
|
||||
this.mercadoPagoWebhookController = new MercadoPagoWebhookController(dataSource);
|
||||
this.clipController = new ClipController(dataSource);
|
||||
this.clipWebhookController = new ClipWebhookController(dataSource);
|
||||
|
||||
// Register authenticated routes
|
||||
this.router.use(`${basePath}/payment-terminals`, this.terminalsController.router);
|
||||
this.router.use(`${basePath}/payment-transactions`, this.transactionsController.router);
|
||||
this.router.use(`${basePath}/mercadopago`, this.mercadoPagoController.router);
|
||||
this.router.use(`${basePath}/clip`, this.clipController.router);
|
||||
|
||||
// Register public webhook routes (no auth required)
|
||||
this.webhookRouter.use('/mercadopago', this.mercadoPagoWebhookController.router);
|
||||
this.webhookRouter.use('/clip', this.clipWebhookController.router);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all entities for this module (for TypeORM configuration)
|
||||
*/
|
||||
static getEntities() {
|
||||
return [
|
||||
// Existing entities
|
||||
require('../branches/entities/branch-payment-terminal.entity').BranchPaymentTerminal,
|
||||
require('../mobile/entities/payment-transaction.entity').PaymentTransaction,
|
||||
// New entities for MercadoPago/Clip
|
||||
require('./entities/tenant-terminal-config.entity').TenantTerminalConfig,
|
||||
require('./entities/terminal-payment.entity').TerminalPayment,
|
||||
require('./entities/terminal-webhook-event.entity').TerminalWebhookEvent,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export default PaymentTerminalsModule;
|
||||
583
src/modules/payment-terminals/services/clip.service.ts
Normal file
583
src/modules/payment-terminals/services/clip.service.ts
Normal file
@ -0,0 +1,583 @@
|
||||
/**
|
||||
* Clip Service
|
||||
*
|
||||
* Integración con Clip para pagos TPV
|
||||
* Basado en: michangarrito INT-005
|
||||
*
|
||||
* Features:
|
||||
* - Crear pagos con tarjeta
|
||||
* - Generar links de pago
|
||||
* - Procesar reembolsos
|
||||
* - Manejar webhooks
|
||||
* - Multi-tenant con credenciales por tenant
|
||||
* - Retry con backoff exponencial
|
||||
*
|
||||
* Comisión: 3.6% + IVA por transacción
|
||||
*/
|
||||
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import {
|
||||
TenantTerminalConfig,
|
||||
TerminalPayment,
|
||||
TerminalWebhookEvent,
|
||||
} from '../entities';
|
||||
|
||||
// DTOs
|
||||
export interface CreateClipPaymentDto {
|
||||
amount: number;
|
||||
currency?: string;
|
||||
description?: string;
|
||||
customerEmail?: string;
|
||||
customerName?: string;
|
||||
customerPhone?: string;
|
||||
referenceType?: string;
|
||||
referenceId?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface RefundClipPaymentDto {
|
||||
paymentId: string;
|
||||
amount?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CreateClipLinkDto {
|
||||
amount: number;
|
||||
description: string;
|
||||
expiresInMinutes?: number;
|
||||
referenceType?: string;
|
||||
referenceId?: string;
|
||||
}
|
||||
|
||||
export interface ClipCredentials {
|
||||
apiKey: string;
|
||||
secretKey: string;
|
||||
merchantId: string;
|
||||
}
|
||||
|
||||
export interface ClipConfig {
|
||||
defaultCurrency?: string;
|
||||
webhookSecret?: string;
|
||||
}
|
||||
|
||||
// Constantes
|
||||
const CLIP_API_BASE = 'https://api.clip.mx';
|
||||
const MAX_RETRIES = 5;
|
||||
const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000];
|
||||
|
||||
// Clip fee: 3.6% + IVA
|
||||
const CLIP_FEE_RATE = 0.036;
|
||||
const IVA_RATE = 0.16;
|
||||
|
||||
export class ClipService {
|
||||
private configRepository: Repository<TenantTerminalConfig>;
|
||||
private paymentRepository: Repository<TerminalPayment>;
|
||||
private webhookRepository: Repository<TerminalWebhookEvent>;
|
||||
|
||||
constructor(private dataSource: DataSource) {
|
||||
this.configRepository = dataSource.getRepository(TenantTerminalConfig);
|
||||
this.paymentRepository = dataSource.getRepository(TerminalPayment);
|
||||
this.webhookRepository = dataSource.getRepository(TerminalWebhookEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener credenciales de Clip para un tenant
|
||||
*/
|
||||
async getCredentials(tenantId: string): Promise<{
|
||||
credentials: ClipCredentials;
|
||||
config: ClipConfig;
|
||||
configId: string;
|
||||
}> {
|
||||
const terminalConfig = await this.configRepository.findOne({
|
||||
where: {
|
||||
tenantId,
|
||||
provider: 'clip',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!terminalConfig) {
|
||||
throw new Error('Clip not configured for this tenant');
|
||||
}
|
||||
|
||||
if (!terminalConfig.isVerified) {
|
||||
throw new Error('Clip credentials not verified');
|
||||
}
|
||||
|
||||
return {
|
||||
credentials: terminalConfig.credentials as ClipCredentials,
|
||||
config: terminalConfig.config as ClipConfig,
|
||||
configId: terminalConfig.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear un pago
|
||||
*/
|
||||
async createPayment(
|
||||
tenantId: string,
|
||||
dto: CreateClipPaymentDto,
|
||||
createdBy?: string
|
||||
): Promise<TerminalPayment> {
|
||||
const { credentials, config, configId } = await this.getCredentials(tenantId);
|
||||
|
||||
// Calcular comisiones
|
||||
const feeAmount = dto.amount * CLIP_FEE_RATE * (1 + IVA_RATE);
|
||||
const netAmount = dto.amount - feeAmount;
|
||||
|
||||
// Crear registro local
|
||||
const payment = this.paymentRepository.create({
|
||||
tenantId,
|
||||
configId,
|
||||
provider: 'clip',
|
||||
amount: dto.amount,
|
||||
currency: dto.currency || config.defaultCurrency || 'MXN',
|
||||
status: 'pending',
|
||||
paymentMethod: 'card',
|
||||
customerEmail: dto.customerEmail,
|
||||
customerName: dto.customerName,
|
||||
customerPhone: dto.customerPhone,
|
||||
description: dto.description,
|
||||
referenceType: dto.referenceType,
|
||||
referenceId: dto.referenceId ? dto.referenceId : undefined,
|
||||
feeAmount,
|
||||
feeDetails: {
|
||||
rate: CLIP_FEE_RATE,
|
||||
iva: IVA_RATE,
|
||||
calculated: feeAmount,
|
||||
},
|
||||
netAmount,
|
||||
metadata: dto.metadata || {},
|
||||
createdBy,
|
||||
});
|
||||
|
||||
const savedPayment = await this.paymentRepository.save(payment);
|
||||
|
||||
try {
|
||||
// Crear pago en Clip
|
||||
const clipPayment = await this.executeWithRetry(async () => {
|
||||
const response = await fetch(`${CLIP_API_BASE}/v1/payments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${credentials.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Clip-Merchant-Id': credentials.merchantId,
|
||||
'X-Idempotency-Key': savedPayment.id,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: dto.amount,
|
||||
currency: dto.currency || 'MXN',
|
||||
description: dto.description,
|
||||
customer: {
|
||||
email: dto.customerEmail,
|
||||
name: dto.customerName,
|
||||
phone: dto.customerPhone,
|
||||
},
|
||||
metadata: {
|
||||
tenant_id: tenantId,
|
||||
internal_id: savedPayment.id,
|
||||
...dto.metadata,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new ClipError(error.message || 'Payment failed', response.status, error);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
});
|
||||
|
||||
// Actualizar registro local
|
||||
savedPayment.externalId = clipPayment.id;
|
||||
savedPayment.externalStatus = clipPayment.status;
|
||||
savedPayment.status = this.mapClipStatus(clipPayment.status);
|
||||
savedPayment.providerResponse = clipPayment;
|
||||
savedPayment.processedAt = new Date();
|
||||
|
||||
if (clipPayment.card) {
|
||||
savedPayment.cardLastFour = clipPayment.card.last_four;
|
||||
savedPayment.cardBrand = clipPayment.card.brand;
|
||||
savedPayment.cardType = clipPayment.card.type;
|
||||
}
|
||||
|
||||
return this.paymentRepository.save(savedPayment);
|
||||
} catch (error: any) {
|
||||
savedPayment.status = 'rejected';
|
||||
savedPayment.errorCode = error.code || 'unknown';
|
||||
savedPayment.errorMessage = error.message;
|
||||
savedPayment.providerResponse = error.response;
|
||||
await this.paymentRepository.save(savedPayment);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consultar estado de un pago
|
||||
*/
|
||||
async getPayment(tenantId: string, paymentId: string): Promise<TerminalPayment> {
|
||||
const payment = await this.paymentRepository.findOne({
|
||||
where: { id: paymentId, tenantId },
|
||||
});
|
||||
|
||||
if (!payment) {
|
||||
throw new Error('Payment not found');
|
||||
}
|
||||
|
||||
// Sincronizar si es necesario
|
||||
if (payment.externalId && !['approved', 'rejected'].includes(payment.status)) {
|
||||
await this.syncPaymentStatus(tenantId, payment);
|
||||
}
|
||||
|
||||
return payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincronizar estado con Clip
|
||||
*/
|
||||
private async syncPaymentStatus(
|
||||
tenantId: string,
|
||||
payment: TerminalPayment
|
||||
): Promise<TerminalPayment> {
|
||||
const { credentials } = await this.getCredentials(tenantId);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${CLIP_API_BASE}/v1/payments/${payment.externalId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${credentials.apiKey}`,
|
||||
'X-Clip-Merchant-Id': credentials.merchantId,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const clipPayment = await response.json();
|
||||
payment.externalStatus = clipPayment.status;
|
||||
payment.status = this.mapClipStatus(clipPayment.status);
|
||||
payment.providerResponse = clipPayment;
|
||||
await this.paymentRepository.save(payment);
|
||||
}
|
||||
} catch {
|
||||
// Silenciar errores de sincronización
|
||||
}
|
||||
|
||||
return payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesar reembolso
|
||||
*/
|
||||
async refundPayment(
|
||||
tenantId: string,
|
||||
dto: RefundClipPaymentDto
|
||||
): Promise<TerminalPayment> {
|
||||
const payment = await this.paymentRepository.findOne({
|
||||
where: { id: dto.paymentId, tenantId },
|
||||
});
|
||||
|
||||
if (!payment) {
|
||||
throw new Error('Payment not found');
|
||||
}
|
||||
|
||||
if (payment.status !== 'approved') {
|
||||
throw new Error('Cannot refund a payment that is not approved');
|
||||
}
|
||||
|
||||
if (!payment.externalId) {
|
||||
throw new Error('Payment has no external reference');
|
||||
}
|
||||
|
||||
const { credentials } = await this.getCredentials(tenantId);
|
||||
const refundAmount = dto.amount || Number(payment.amount);
|
||||
|
||||
const clipRefund = await this.executeWithRetry(async () => {
|
||||
const response = await fetch(
|
||||
`${CLIP_API_BASE}/v1/payments/${payment.externalId}/refund`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${credentials.apiKey}`,
|
||||
'X-Clip-Merchant-Id': credentials.merchantId,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: refundAmount,
|
||||
reason: dto.reason,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new ClipError(error.message || 'Refund failed', response.status, error);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
});
|
||||
|
||||
// Actualizar pago
|
||||
payment.refundedAmount = Number(payment.refundedAmount || 0) + refundAmount;
|
||||
payment.refundReason = dto.reason;
|
||||
payment.refundedAt = new Date();
|
||||
|
||||
if (payment.refundedAmount >= Number(payment.amount)) {
|
||||
payment.status = 'refunded';
|
||||
} else {
|
||||
payment.status = 'partially_refunded';
|
||||
}
|
||||
|
||||
return this.paymentRepository.save(payment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear link de pago
|
||||
*/
|
||||
async createPaymentLink(
|
||||
tenantId: string,
|
||||
dto: CreateClipLinkDto,
|
||||
createdBy?: string
|
||||
): Promise<{ url: string; id: string }> {
|
||||
const { credentials } = await this.getCredentials(tenantId);
|
||||
|
||||
const paymentLink = await this.executeWithRetry(async () => {
|
||||
const response = await fetch(`${CLIP_API_BASE}/v1/payment-links`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${credentials.apiKey}`,
|
||||
'X-Clip-Merchant-Id': credentials.merchantId,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: dto.amount,
|
||||
description: dto.description,
|
||||
expires_in: dto.expiresInMinutes || 1440, // Default 24 horas
|
||||
metadata: {
|
||||
tenant_id: tenantId,
|
||||
reference_type: dto.referenceType,
|
||||
reference_id: dto.referenceId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new ClipError(
|
||||
error.message || 'Failed to create payment link',
|
||||
response.status,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
});
|
||||
|
||||
return {
|
||||
url: paymentLink.url,
|
||||
id: paymentLink.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manejar webhook de Clip
|
||||
*/
|
||||
async handleWebhook(
|
||||
tenantId: string,
|
||||
eventType: string,
|
||||
data: any,
|
||||
headers: Record<string, string>
|
||||
): Promise<void> {
|
||||
// Verificar firma
|
||||
const config = await this.configRepository.findOne({
|
||||
where: { tenantId, provider: 'clip', isActive: true },
|
||||
});
|
||||
|
||||
if (config?.config?.webhookSecret && headers['x-clip-signature']) {
|
||||
const isValid = this.verifyWebhookSignature(
|
||||
JSON.stringify(data),
|
||||
headers['x-clip-signature'],
|
||||
config.config.webhookSecret as string
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid webhook signature');
|
||||
}
|
||||
}
|
||||
|
||||
// Guardar evento
|
||||
const event = this.webhookRepository.create({
|
||||
tenantId,
|
||||
provider: 'clip',
|
||||
eventType,
|
||||
eventId: data.id,
|
||||
externalId: data.payment_id || data.id,
|
||||
payload: data,
|
||||
headers,
|
||||
signatureValid: true,
|
||||
idempotencyKey: `${data.id}-${eventType}`,
|
||||
});
|
||||
|
||||
await this.webhookRepository.save(event);
|
||||
|
||||
// Procesar evento
|
||||
try {
|
||||
switch (eventType) {
|
||||
case 'payment.succeeded':
|
||||
await this.handlePaymentSucceeded(tenantId, data);
|
||||
break;
|
||||
case 'payment.failed':
|
||||
await this.handlePaymentFailed(tenantId, data);
|
||||
break;
|
||||
case 'refund.succeeded':
|
||||
await this.handleRefundSucceeded(tenantId, data);
|
||||
break;
|
||||
}
|
||||
|
||||
event.processed = true;
|
||||
event.processedAt = new Date();
|
||||
} catch (error: any) {
|
||||
event.processingError = error.message;
|
||||
event.retryCount += 1;
|
||||
}
|
||||
|
||||
await this.webhookRepository.save(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesar pago exitoso
|
||||
*/
|
||||
private async handlePaymentSucceeded(tenantId: string, data: any): Promise<void> {
|
||||
const payment = await this.paymentRepository.findOne({
|
||||
where: [
|
||||
{ externalId: data.payment_id, tenantId },
|
||||
{ id: data.metadata?.internal_id, tenantId },
|
||||
],
|
||||
});
|
||||
|
||||
if (payment) {
|
||||
payment.status = 'approved';
|
||||
payment.externalStatus = 'succeeded';
|
||||
payment.processedAt = new Date();
|
||||
|
||||
if (data.card) {
|
||||
payment.cardLastFour = data.card.last_four;
|
||||
payment.cardBrand = data.card.brand;
|
||||
}
|
||||
|
||||
await this.paymentRepository.save(payment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesar pago fallido
|
||||
*/
|
||||
private async handlePaymentFailed(tenantId: string, data: any): Promise<void> {
|
||||
const payment = await this.paymentRepository.findOne({
|
||||
where: [
|
||||
{ externalId: data.payment_id, tenantId },
|
||||
{ id: data.metadata?.internal_id, tenantId },
|
||||
],
|
||||
});
|
||||
|
||||
if (payment) {
|
||||
payment.status = 'rejected';
|
||||
payment.externalStatus = 'failed';
|
||||
payment.errorCode = data.error?.code;
|
||||
payment.errorMessage = data.error?.message;
|
||||
await this.paymentRepository.save(payment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesar reembolso exitoso
|
||||
*/
|
||||
private async handleRefundSucceeded(tenantId: string, data: any): Promise<void> {
|
||||
const payment = await this.paymentRepository.findOne({
|
||||
where: { externalId: data.payment_id, tenantId },
|
||||
});
|
||||
|
||||
if (payment) {
|
||||
payment.refundedAmount = Number(payment.refundedAmount || 0) + data.amount;
|
||||
payment.refundedAt = new Date();
|
||||
|
||||
if (payment.refundedAmount >= Number(payment.amount)) {
|
||||
payment.status = 'refunded';
|
||||
} else {
|
||||
payment.status = 'partially_refunded';
|
||||
}
|
||||
|
||||
await this.paymentRepository.save(payment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar firma de webhook
|
||||
*/
|
||||
private verifyWebhookSignature(
|
||||
payload: string,
|
||||
signature: string,
|
||||
secret: string
|
||||
): boolean {
|
||||
try {
|
||||
const expected = createHmac('sha256', secret).update(payload, 'utf8').digest('hex');
|
||||
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapear estado de Clip a estado interno
|
||||
*/
|
||||
private mapClipStatus(
|
||||
clipStatus: string
|
||||
): 'pending' | 'processing' | 'approved' | 'rejected' | 'refunded' | 'cancelled' {
|
||||
const statusMap: Record<string, any> = {
|
||||
pending: 'pending',
|
||||
processing: 'processing',
|
||||
succeeded: 'approved',
|
||||
approved: 'approved',
|
||||
failed: 'rejected',
|
||||
declined: 'rejected',
|
||||
cancelled: 'cancelled',
|
||||
refunded: 'refunded',
|
||||
};
|
||||
|
||||
return statusMap[clipStatus] || 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecutar con retry
|
||||
*/
|
||||
private async executeWithRetry<T>(fn: () => Promise<T>, attempt = 0): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error: any) {
|
||||
if (attempt >= MAX_RETRIES) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error.status === 429 || error.status >= 500) {
|
||||
const delay = RETRY_DELAYS[attempt] || RETRY_DELAYS[RETRY_DELAYS.length - 1];
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return this.executeWithRetry(fn, attempt + 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error personalizado para Clip
|
||||
*/
|
||||
export class ClipError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public response?: any
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ClipError';
|
||||
}
|
||||
}
|
||||
10
src/modules/payment-terminals/services/index.ts
Normal file
10
src/modules/payment-terminals/services/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Payment Terminals Services Index
|
||||
*/
|
||||
|
||||
export { TerminalsService } from './terminals.service';
|
||||
export { TransactionsService } from './transactions.service';
|
||||
|
||||
// Proveedores TPV
|
||||
export { MercadoPagoService, MercadoPagoError } from './mercadopago.service';
|
||||
export { ClipService, ClipError } from './clip.service';
|
||||
584
src/modules/payment-terminals/services/mercadopago.service.ts
Normal file
584
src/modules/payment-terminals/services/mercadopago.service.ts
Normal file
@ -0,0 +1,584 @@
|
||||
/**
|
||||
* MercadoPago Service
|
||||
*
|
||||
* Integración con MercadoPago para pagos TPV
|
||||
* Basado en: michangarrito INT-004
|
||||
*
|
||||
* Features:
|
||||
* - Crear pagos con tarjeta
|
||||
* - Generar QR de pago
|
||||
* - Generar links de pago
|
||||
* - Procesar reembolsos
|
||||
* - Manejar webhooks
|
||||
* - Multi-tenant con credenciales por tenant
|
||||
* - Retry con backoff exponencial
|
||||
*/
|
||||
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import {
|
||||
TenantTerminalConfig,
|
||||
TerminalPayment,
|
||||
TerminalWebhookEvent,
|
||||
} from '../entities';
|
||||
|
||||
// DTOs
|
||||
export interface CreatePaymentDto {
|
||||
amount: number;
|
||||
currency?: string;
|
||||
description?: string;
|
||||
paymentMethod?: 'card' | 'qr' | 'link';
|
||||
customerEmail?: string;
|
||||
customerName?: string;
|
||||
referenceType?: string;
|
||||
referenceId?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface RefundPaymentDto {
|
||||
paymentId: string;
|
||||
amount?: number; // Partial refund
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CreatePaymentLinkDto {
|
||||
amount: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
expiresAt?: Date;
|
||||
referenceType?: string;
|
||||
referenceId?: string;
|
||||
}
|
||||
|
||||
export interface MercadoPagoCredentials {
|
||||
accessToken: string;
|
||||
publicKey: string;
|
||||
collectorId?: string;
|
||||
}
|
||||
|
||||
export interface MercadoPagoConfig {
|
||||
statementDescriptor?: string;
|
||||
notificationUrl?: string;
|
||||
externalReference?: string;
|
||||
}
|
||||
|
||||
// Constantes
|
||||
const MP_API_BASE = 'https://api.mercadopago.com';
|
||||
const MAX_RETRIES = 5;
|
||||
const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000]; // Backoff exponencial
|
||||
|
||||
export class MercadoPagoService {
|
||||
private configRepository: Repository<TenantTerminalConfig>;
|
||||
private paymentRepository: Repository<TerminalPayment>;
|
||||
private webhookRepository: Repository<TerminalWebhookEvent>;
|
||||
|
||||
constructor(private dataSource: DataSource) {
|
||||
this.configRepository = dataSource.getRepository(TenantTerminalConfig);
|
||||
this.paymentRepository = dataSource.getRepository(TerminalPayment);
|
||||
this.webhookRepository = dataSource.getRepository(TerminalWebhookEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener credenciales de MercadoPago para un tenant
|
||||
*/
|
||||
async getCredentials(tenantId: string): Promise<{
|
||||
credentials: MercadoPagoCredentials;
|
||||
config: MercadoPagoConfig;
|
||||
configId: string;
|
||||
}> {
|
||||
const terminalConfig = await this.configRepository.findOne({
|
||||
where: {
|
||||
tenantId,
|
||||
provider: 'mercadopago',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!terminalConfig) {
|
||||
throw new Error('MercadoPago not configured for this tenant');
|
||||
}
|
||||
|
||||
if (!terminalConfig.isVerified) {
|
||||
throw new Error('MercadoPago credentials not verified');
|
||||
}
|
||||
|
||||
return {
|
||||
credentials: terminalConfig.credentials as MercadoPagoCredentials,
|
||||
config: terminalConfig.config as MercadoPagoConfig,
|
||||
configId: terminalConfig.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear un pago
|
||||
*/
|
||||
async createPayment(
|
||||
tenantId: string,
|
||||
dto: CreatePaymentDto,
|
||||
createdBy?: string
|
||||
): Promise<TerminalPayment> {
|
||||
const { credentials, config, configId } = await this.getCredentials(tenantId);
|
||||
|
||||
// Crear registro local primero
|
||||
const payment = this.paymentRepository.create({
|
||||
tenantId,
|
||||
configId,
|
||||
provider: 'mercadopago',
|
||||
amount: dto.amount,
|
||||
currency: dto.currency || 'MXN',
|
||||
status: 'pending',
|
||||
paymentMethod: dto.paymentMethod || 'card',
|
||||
customerEmail: dto.customerEmail,
|
||||
customerName: dto.customerName,
|
||||
description: dto.description,
|
||||
statementDescriptor: config.statementDescriptor,
|
||||
referenceType: dto.referenceType,
|
||||
referenceId: dto.referenceId ? dto.referenceId : undefined,
|
||||
metadata: dto.metadata || {},
|
||||
createdBy,
|
||||
});
|
||||
|
||||
const savedPayment = await this.paymentRepository.save(payment);
|
||||
|
||||
try {
|
||||
// Crear pago en MercadoPago con retry
|
||||
const mpPayment = await this.executeWithRetry(async () => {
|
||||
const response = await fetch(`${MP_API_BASE}/v1/payments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${credentials.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Idempotency-Key': savedPayment.id,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
transaction_amount: dto.amount,
|
||||
currency_id: dto.currency || 'MXN',
|
||||
description: dto.description,
|
||||
payment_method_id: 'card', // Se determinará por el checkout
|
||||
payer: {
|
||||
email: dto.customerEmail,
|
||||
},
|
||||
statement_descriptor: config.statementDescriptor,
|
||||
external_reference: savedPayment.id,
|
||||
notification_url: config.notificationUrl,
|
||||
metadata: {
|
||||
tenant_id: tenantId,
|
||||
internal_id: savedPayment.id,
|
||||
...dto.metadata,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new MercadoPagoError(error.message || 'Payment failed', response.status, error);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
});
|
||||
|
||||
// Actualizar registro local
|
||||
savedPayment.externalId = mpPayment.id?.toString();
|
||||
savedPayment.externalStatus = mpPayment.status;
|
||||
savedPayment.status = this.mapMPStatus(mpPayment.status);
|
||||
savedPayment.providerResponse = mpPayment;
|
||||
savedPayment.processedAt = new Date();
|
||||
|
||||
if (mpPayment.fee_details?.length > 0) {
|
||||
const totalFee = mpPayment.fee_details.reduce(
|
||||
(sum: number, fee: any) => sum + fee.amount,
|
||||
0
|
||||
);
|
||||
savedPayment.feeAmount = totalFee;
|
||||
savedPayment.feeDetails = mpPayment.fee_details;
|
||||
savedPayment.netAmount = dto.amount - totalFee;
|
||||
}
|
||||
|
||||
if (mpPayment.card) {
|
||||
savedPayment.cardLastFour = mpPayment.card.last_four_digits;
|
||||
savedPayment.cardBrand = mpPayment.card.payment_method?.name;
|
||||
savedPayment.cardType = mpPayment.card.cardholder?.identification?.type;
|
||||
}
|
||||
|
||||
return this.paymentRepository.save(savedPayment);
|
||||
} catch (error: any) {
|
||||
// Guardar error
|
||||
savedPayment.status = 'rejected';
|
||||
savedPayment.errorCode = error.code || 'unknown';
|
||||
savedPayment.errorMessage = error.message;
|
||||
savedPayment.providerResponse = error.response;
|
||||
await this.paymentRepository.save(savedPayment);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consultar estado de un pago
|
||||
*/
|
||||
async getPayment(tenantId: string, paymentId: string): Promise<TerminalPayment> {
|
||||
const payment = await this.paymentRepository.findOne({
|
||||
where: { id: paymentId, tenantId },
|
||||
});
|
||||
|
||||
if (!payment) {
|
||||
throw new Error('Payment not found');
|
||||
}
|
||||
|
||||
// Si tiene external_id, sincronizar con MercadoPago
|
||||
if (payment.externalId && payment.status !== 'approved' && payment.status !== 'rejected') {
|
||||
await this.syncPaymentStatus(tenantId, payment);
|
||||
}
|
||||
|
||||
return payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincronizar estado de pago con MercadoPago
|
||||
*/
|
||||
private async syncPaymentStatus(
|
||||
tenantId: string,
|
||||
payment: TerminalPayment
|
||||
): Promise<TerminalPayment> {
|
||||
const { credentials } = await this.getCredentials(tenantId);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${MP_API_BASE}/v1/payments/${payment.externalId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${credentials.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const mpPayment = await response.json();
|
||||
payment.externalStatus = mpPayment.status;
|
||||
payment.status = this.mapMPStatus(mpPayment.status);
|
||||
payment.providerResponse = mpPayment;
|
||||
await this.paymentRepository.save(payment);
|
||||
}
|
||||
} catch {
|
||||
// Silenciar errores de sincronización
|
||||
}
|
||||
|
||||
return payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesar reembolso
|
||||
*/
|
||||
async refundPayment(
|
||||
tenantId: string,
|
||||
dto: RefundPaymentDto
|
||||
): Promise<TerminalPayment> {
|
||||
const payment = await this.paymentRepository.findOne({
|
||||
where: { id: dto.paymentId, tenantId },
|
||||
});
|
||||
|
||||
if (!payment) {
|
||||
throw new Error('Payment not found');
|
||||
}
|
||||
|
||||
if (payment.status !== 'approved') {
|
||||
throw new Error('Cannot refund a payment that is not approved');
|
||||
}
|
||||
|
||||
if (!payment.externalId) {
|
||||
throw new Error('Payment has no external reference');
|
||||
}
|
||||
|
||||
const { credentials } = await this.getCredentials(tenantId);
|
||||
const refundAmount = dto.amount || Number(payment.amount);
|
||||
|
||||
const mpRefund = await this.executeWithRetry(async () => {
|
||||
const response = await fetch(
|
||||
`${MP_API_BASE}/v1/payments/${payment.externalId}/refunds`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${credentials.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: refundAmount,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new MercadoPagoError(error.message || 'Refund failed', response.status, error);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
});
|
||||
|
||||
// Actualizar pago
|
||||
payment.refundedAmount = Number(payment.refundedAmount || 0) + refundAmount;
|
||||
payment.refundReason = dto.reason;
|
||||
payment.refundedAt = new Date();
|
||||
|
||||
if (payment.refundedAmount >= Number(payment.amount)) {
|
||||
payment.status = 'refunded';
|
||||
} else {
|
||||
payment.status = 'partially_refunded';
|
||||
}
|
||||
|
||||
return this.paymentRepository.save(payment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear link de pago
|
||||
*/
|
||||
async createPaymentLink(
|
||||
tenantId: string,
|
||||
dto: CreatePaymentLinkDto,
|
||||
createdBy?: string
|
||||
): Promise<{ url: string; id: string }> {
|
||||
const { credentials, config } = await this.getCredentials(tenantId);
|
||||
|
||||
const preference = await this.executeWithRetry(async () => {
|
||||
const response = await fetch(`${MP_API_BASE}/checkout/preferences`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${credentials.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
quantity: 1,
|
||||
currency_id: 'MXN',
|
||||
unit_price: dto.amount,
|
||||
},
|
||||
],
|
||||
back_urls: {
|
||||
success: config.notificationUrl,
|
||||
failure: config.notificationUrl,
|
||||
pending: config.notificationUrl,
|
||||
},
|
||||
notification_url: config.notificationUrl,
|
||||
expires: dto.expiresAt ? true : false,
|
||||
expiration_date_to: dto.expiresAt?.toISOString(),
|
||||
external_reference: dto.referenceId || undefined,
|
||||
metadata: {
|
||||
tenant_id: tenantId,
|
||||
reference_type: dto.referenceType,
|
||||
reference_id: dto.referenceId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new MercadoPagoError(
|
||||
error.message || 'Failed to create payment link',
|
||||
response.status,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
});
|
||||
|
||||
return {
|
||||
url: preference.init_point,
|
||||
id: preference.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manejar webhook de MercadoPago
|
||||
*/
|
||||
async handleWebhook(
|
||||
tenantId: string,
|
||||
eventType: string,
|
||||
data: any,
|
||||
headers: Record<string, string>
|
||||
): Promise<void> {
|
||||
// Verificar firma si está configurada
|
||||
const config = await this.configRepository.findOne({
|
||||
where: { tenantId, provider: 'mercadopago', isActive: true },
|
||||
});
|
||||
|
||||
if (config?.webhookSecret && headers['x-signature']) {
|
||||
const isValid = this.verifyWebhookSignature(
|
||||
headers['x-signature'],
|
||||
headers['x-request-id'],
|
||||
data.id?.toString(),
|
||||
config.webhookSecret
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid webhook signature');
|
||||
}
|
||||
}
|
||||
|
||||
// Guardar evento
|
||||
const event = this.webhookRepository.create({
|
||||
tenantId,
|
||||
provider: 'mercadopago',
|
||||
eventType,
|
||||
eventId: data.id?.toString(),
|
||||
externalId: data.data?.id?.toString(),
|
||||
payload: data,
|
||||
headers,
|
||||
signatureValid: true,
|
||||
idempotencyKey: `${data.id}-${eventType}`,
|
||||
});
|
||||
|
||||
await this.webhookRepository.save(event);
|
||||
|
||||
// Procesar evento
|
||||
try {
|
||||
switch (eventType) {
|
||||
case 'payment':
|
||||
await this.handlePaymentWebhook(tenantId, data.data?.id);
|
||||
break;
|
||||
case 'refund':
|
||||
await this.handleRefundWebhook(tenantId, data.data?.id);
|
||||
break;
|
||||
}
|
||||
|
||||
event.processed = true;
|
||||
event.processedAt = new Date();
|
||||
} catch (error: any) {
|
||||
event.processingError = error.message;
|
||||
event.retryCount += 1;
|
||||
}
|
||||
|
||||
await this.webhookRepository.save(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesar webhook de pago
|
||||
*/
|
||||
private async handlePaymentWebhook(tenantId: string, mpPaymentId: string): Promise<void> {
|
||||
const { credentials } = await this.getCredentials(tenantId);
|
||||
|
||||
// Obtener detalles del pago
|
||||
const response = await fetch(`${MP_API_BASE}/v1/payments/${mpPaymentId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${credentials.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const mpPayment = await response.json();
|
||||
|
||||
// Buscar pago local por external_reference o external_id
|
||||
let payment = await this.paymentRepository.findOne({
|
||||
where: [
|
||||
{ externalId: mpPaymentId.toString(), tenantId },
|
||||
{ id: mpPayment.external_reference, tenantId },
|
||||
],
|
||||
});
|
||||
|
||||
if (payment) {
|
||||
payment.externalId = mpPaymentId.toString();
|
||||
payment.externalStatus = mpPayment.status;
|
||||
payment.status = this.mapMPStatus(mpPayment.status);
|
||||
payment.providerResponse = mpPayment;
|
||||
payment.processedAt = new Date();
|
||||
|
||||
if (mpPayment.card) {
|
||||
payment.cardLastFour = mpPayment.card.last_four_digits;
|
||||
payment.cardBrand = mpPayment.card.payment_method?.name;
|
||||
}
|
||||
|
||||
await this.paymentRepository.save(payment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesar webhook de reembolso
|
||||
*/
|
||||
private async handleRefundWebhook(tenantId: string, refundId: string): Promise<void> {
|
||||
// Implementación similar a handlePaymentWebhook
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar firma de webhook MercadoPago
|
||||
*/
|
||||
private verifyWebhookSignature(
|
||||
xSignature: string,
|
||||
xRequestId: string,
|
||||
dataId: string,
|
||||
secret: string
|
||||
): boolean {
|
||||
try {
|
||||
const parts = xSignature.split(',').reduce((acc, part) => {
|
||||
const [key, value] = part.split('=');
|
||||
acc[key.trim()] = value.trim();
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const ts = parts['ts'];
|
||||
const hash = parts['v1'];
|
||||
|
||||
const manifest = `id:${dataId};request-id:${xRequestId};ts:${ts};`;
|
||||
const expected = createHmac('sha256', secret).update(manifest).digest('hex');
|
||||
|
||||
return timingSafeEqual(Buffer.from(hash), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapear estado de MercadoPago a estado interno
|
||||
*/
|
||||
private mapMPStatus(
|
||||
mpStatus: string
|
||||
): 'pending' | 'processing' | 'approved' | 'rejected' | 'refunded' | 'cancelled' {
|
||||
const statusMap: Record<string, any> = {
|
||||
pending: 'pending',
|
||||
in_process: 'processing',
|
||||
approved: 'approved',
|
||||
authorized: 'approved',
|
||||
rejected: 'rejected',
|
||||
cancelled: 'cancelled',
|
||||
refunded: 'refunded',
|
||||
charged_back: 'charged_back',
|
||||
};
|
||||
|
||||
return statusMap[mpStatus] || 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecutar operación con retry y backoff exponencial
|
||||
*/
|
||||
private async executeWithRetry<T>(fn: () => Promise<T>, attempt = 0): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error: any) {
|
||||
if (attempt >= MAX_RETRIES) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Solo reintentar en errores de rate limit o errores de servidor
|
||||
if (error.status === 429 || error.status >= 500) {
|
||||
const delay = RETRY_DELAYS[attempt] || RETRY_DELAYS[RETRY_DELAYS.length - 1];
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return this.executeWithRetry(fn, attempt + 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error personalizado para MercadoPago
|
||||
*/
|
||||
export class MercadoPagoError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public response?: any
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'MercadoPagoError';
|
||||
}
|
||||
}
|
||||
224
src/modules/payment-terminals/services/terminals.service.ts
Normal file
224
src/modules/payment-terminals/services/terminals.service.ts
Normal file
@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Terminals Service
|
||||
*
|
||||
* Service for managing payment terminals
|
||||
*/
|
||||
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { BranchPaymentTerminal, HealthStatus } from '../../branches/entities/branch-payment-terminal.entity';
|
||||
import { CreateTerminalDto, UpdateTerminalDto, TerminalResponseDto } from '../dto';
|
||||
|
||||
export class TerminalsService {
|
||||
private terminalRepository: Repository<BranchPaymentTerminal>;
|
||||
|
||||
constructor(private dataSource: DataSource) {
|
||||
this.terminalRepository = dataSource.getRepository(BranchPaymentTerminal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new terminal
|
||||
*/
|
||||
async create(tenantId: string, dto: CreateTerminalDto): Promise<BranchPaymentTerminal> {
|
||||
// If setting as primary, unset other primary terminals for this branch
|
||||
if (dto.isPrimary) {
|
||||
await this.terminalRepository.update(
|
||||
{ branchId: dto.branchId, isPrimary: true },
|
||||
{ isPrimary: false }
|
||||
);
|
||||
}
|
||||
|
||||
const terminal = this.terminalRepository.create({
|
||||
branchId: dto.branchId,
|
||||
terminalProvider: dto.terminalProvider,
|
||||
terminalId: dto.terminalId,
|
||||
terminalName: dto.terminalName,
|
||||
credentials: dto.credentials || {},
|
||||
isPrimary: dto.isPrimary || false,
|
||||
dailyLimit: dto.dailyLimit,
|
||||
transactionLimit: dto.transactionLimit,
|
||||
isActive: true,
|
||||
healthStatus: 'unknown',
|
||||
});
|
||||
|
||||
return this.terminalRepository.save(terminal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find terminals by branch
|
||||
*/
|
||||
async findByBranch(branchId: string): Promise<TerminalResponseDto[]> {
|
||||
const terminals = await this.terminalRepository.find({
|
||||
where: { branchId, isActive: true },
|
||||
order: { isPrimary: 'DESC', createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
return terminals.map(this.toResponseDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find primary terminal for branch
|
||||
*/
|
||||
async findPrimaryTerminal(branchId: string): Promise<BranchPaymentTerminal | null> {
|
||||
return this.terminalRepository.findOne({
|
||||
where: { branchId, isPrimary: true, isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find terminal by ID
|
||||
*/
|
||||
async findById(id: string): Promise<BranchPaymentTerminal | null> {
|
||||
return this.terminalRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update terminal
|
||||
*/
|
||||
async update(id: string, dto: UpdateTerminalDto): Promise<BranchPaymentTerminal> {
|
||||
const terminal = await this.findById(id);
|
||||
if (!terminal) {
|
||||
throw new Error('Terminal not found');
|
||||
}
|
||||
|
||||
// If setting as primary, unset other primary terminals for this branch
|
||||
if (dto.isPrimary && !terminal.isPrimary) {
|
||||
await this.terminalRepository.update(
|
||||
{ branchId: terminal.branchId, isPrimary: true },
|
||||
{ isPrimary: false }
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(terminal, dto);
|
||||
return this.terminalRepository.save(terminal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete terminal (soft delete by deactivating)
|
||||
*/
|
||||
async delete(id: string): Promise<void> {
|
||||
const terminal = await this.findById(id);
|
||||
if (!terminal) {
|
||||
throw new Error('Terminal not found');
|
||||
}
|
||||
|
||||
terminal.isActive = false;
|
||||
await this.terminalRepository.save(terminal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set terminal as primary
|
||||
*/
|
||||
async setPrimary(id: string): Promise<BranchPaymentTerminal> {
|
||||
const terminal = await this.findById(id);
|
||||
if (!terminal) {
|
||||
throw new Error('Terminal not found');
|
||||
}
|
||||
|
||||
// Unset other primary terminals
|
||||
await this.terminalRepository.update(
|
||||
{ branchId: terminal.branchId, isPrimary: true },
|
||||
{ isPrimary: false }
|
||||
);
|
||||
|
||||
terminal.isPrimary = true;
|
||||
return this.terminalRepository.save(terminal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check terminal health
|
||||
*/
|
||||
async checkHealth(id: string): Promise<{ status: HealthStatus; message: string }> {
|
||||
const terminal = await this.findById(id);
|
||||
if (!terminal) {
|
||||
throw new Error('Terminal not found');
|
||||
}
|
||||
|
||||
// Simulate health check based on provider
|
||||
// In production, this would make an actual API call to the provider
|
||||
let status: HealthStatus = 'healthy';
|
||||
let message = 'Terminal is operational';
|
||||
|
||||
try {
|
||||
// Simulate provider health check
|
||||
switch (terminal.terminalProvider) {
|
||||
case 'clip':
|
||||
// Check Clip terminal status
|
||||
break;
|
||||
case 'mercadopago':
|
||||
// Check MercadoPago terminal status
|
||||
break;
|
||||
case 'stripe':
|
||||
// Check Stripe terminal status
|
||||
break;
|
||||
}
|
||||
} catch (error: any) {
|
||||
status = 'offline';
|
||||
message = error.message || 'Failed to connect to terminal';
|
||||
}
|
||||
|
||||
// Update terminal health status
|
||||
terminal.healthStatus = status;
|
||||
terminal.lastHealthCheckAt = new Date();
|
||||
await this.terminalRepository.save(terminal);
|
||||
|
||||
return { status, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update health status (called after transactions)
|
||||
*/
|
||||
async updateHealthStatus(id: string, status: HealthStatus): Promise<void> {
|
||||
await this.terminalRepository.update(id, {
|
||||
healthStatus: status,
|
||||
lastHealthCheckAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last transaction timestamp
|
||||
*/
|
||||
async updateLastTransaction(id: string): Promise<void> {
|
||||
await this.terminalRepository.update(id, {
|
||||
lastTransactionAt: new Date(),
|
||||
healthStatus: 'healthy', // If transaction works, terminal is healthy
|
||||
lastHealthCheckAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find terminals needing health check
|
||||
*/
|
||||
async findTerminalsNeedingHealthCheck(maxAgeMinutes: number = 30): Promise<BranchPaymentTerminal[]> {
|
||||
const threshold = new Date();
|
||||
threshold.setMinutes(threshold.getMinutes() - maxAgeMinutes);
|
||||
|
||||
return this.terminalRepository
|
||||
.createQueryBuilder('terminal')
|
||||
.where('terminal.isActive = true')
|
||||
.andWhere(
|
||||
'(terminal.lastHealthCheckAt IS NULL OR terminal.lastHealthCheckAt < :threshold)',
|
||||
{ threshold }
|
||||
)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert entity to response DTO (without credentials)
|
||||
*/
|
||||
private toResponseDto(terminal: BranchPaymentTerminal): TerminalResponseDto {
|
||||
return {
|
||||
id: terminal.id,
|
||||
branchId: terminal.branchId,
|
||||
terminalProvider: terminal.terminalProvider,
|
||||
terminalId: terminal.terminalId,
|
||||
terminalName: terminal.terminalName,
|
||||
isPrimary: terminal.isPrimary,
|
||||
isActive: terminal.isActive,
|
||||
dailyLimit: terminal.dailyLimit ? Number(terminal.dailyLimit) : undefined,
|
||||
transactionLimit: terminal.transactionLimit ? Number(terminal.transactionLimit) : undefined,
|
||||
healthStatus: terminal.healthStatus,
|
||||
lastTransactionAt: terminal.lastTransactionAt,
|
||||
lastHealthCheckAt: terminal.lastHealthCheckAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
497
src/modules/payment-terminals/services/transactions.service.ts
Normal file
497
src/modules/payment-terminals/services/transactions.service.ts
Normal file
@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Transactions Service
|
||||
*
|
||||
* Service for processing and managing payment transactions
|
||||
*/
|
||||
|
||||
import { Repository, DataSource, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm';
|
||||
import {
|
||||
PaymentTransaction,
|
||||
PaymentStatus,
|
||||
PaymentMethod,
|
||||
} from '../../mobile/entities/payment-transaction.entity';
|
||||
import { BranchPaymentTerminal } from '../../branches/entities/branch-payment-terminal.entity';
|
||||
import {
|
||||
ProcessPaymentDto,
|
||||
PaymentResultDto,
|
||||
ProcessRefundDto,
|
||||
RefundResultDto,
|
||||
SendReceiptDto,
|
||||
TransactionFilterDto,
|
||||
TransactionStatsDto,
|
||||
} from '../dto';
|
||||
import { CircuitBreaker } from '../../../shared/utils/circuit-breaker';
|
||||
|
||||
export class TransactionsService {
|
||||
private transactionRepository: Repository<PaymentTransaction>;
|
||||
private terminalRepository: Repository<BranchPaymentTerminal>;
|
||||
private circuitBreakers: Map<string, CircuitBreaker> = new Map();
|
||||
|
||||
constructor(private dataSource: DataSource) {
|
||||
this.transactionRepository = dataSource.getRepository(PaymentTransaction);
|
||||
this.terminalRepository = dataSource.getRepository(BranchPaymentTerminal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a payment
|
||||
*/
|
||||
async processPayment(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
dto: ProcessPaymentDto
|
||||
): Promise<PaymentResultDto> {
|
||||
// Get terminal
|
||||
const terminal = await this.terminalRepository.findOne({
|
||||
where: { id: dto.terminalId, isActive: true },
|
||||
});
|
||||
|
||||
if (!terminal) {
|
||||
return this.errorResult(dto.amount, dto.tipAmount || 0, 'Terminal not found', 'TERMINAL_NOT_FOUND');
|
||||
}
|
||||
|
||||
// Check transaction limit
|
||||
if (terminal.transactionLimit && dto.amount > Number(terminal.transactionLimit)) {
|
||||
return this.errorResult(
|
||||
dto.amount,
|
||||
dto.tipAmount || 0,
|
||||
`Amount exceeds transaction limit of ${terminal.transactionLimit}`,
|
||||
'LIMIT_EXCEEDED'
|
||||
);
|
||||
}
|
||||
|
||||
// Get or create circuit breaker for this terminal
|
||||
const circuitBreaker = this.getCircuitBreaker(terminal.id);
|
||||
|
||||
// Create transaction record
|
||||
const transaction = this.transactionRepository.create({
|
||||
tenantId,
|
||||
branchId: terminal.branchId,
|
||||
userId,
|
||||
sourceType: dto.sourceType,
|
||||
sourceId: dto.sourceId,
|
||||
terminalProvider: terminal.terminalProvider,
|
||||
terminalId: terminal.terminalId,
|
||||
amount: dto.amount,
|
||||
currency: dto.currency || 'MXN',
|
||||
tipAmount: dto.tipAmount || 0,
|
||||
totalAmount: dto.amount + (dto.tipAmount || 0),
|
||||
paymentMethod: 'card', // Default, will be updated from provider response
|
||||
status: 'pending',
|
||||
initiatedAt: new Date(),
|
||||
});
|
||||
|
||||
await this.transactionRepository.save(transaction);
|
||||
|
||||
try {
|
||||
// Process through circuit breaker
|
||||
const providerResult = await circuitBreaker.execute(async () => {
|
||||
return this.processWithProvider(terminal, transaction, dto);
|
||||
});
|
||||
|
||||
// Update transaction with result
|
||||
transaction.status = providerResult.status;
|
||||
transaction.externalTransactionId = providerResult.externalTransactionId || '';
|
||||
transaction.paymentMethod = providerResult.paymentMethod || transaction.paymentMethod;
|
||||
transaction.cardBrand = providerResult.cardBrand || '';
|
||||
transaction.cardLastFour = providerResult.cardLastFour || '';
|
||||
transaction.receiptUrl = providerResult.receiptUrl || '';
|
||||
transaction.providerResponse = providerResult.rawResponse || {};
|
||||
|
||||
if (providerResult.status === 'completed') {
|
||||
transaction.completedAt = new Date();
|
||||
// Update terminal last transaction
|
||||
await this.terminalRepository.update(terminal.id, {
|
||||
lastTransactionAt: new Date(),
|
||||
healthStatus: 'healthy',
|
||||
});
|
||||
} else if (providerResult.status === 'failed') {
|
||||
transaction.failureReason = providerResult.error || '';
|
||||
}
|
||||
|
||||
await this.transactionRepository.save(transaction);
|
||||
|
||||
return {
|
||||
success: providerResult.status === 'completed',
|
||||
transactionId: transaction.id,
|
||||
externalTransactionId: providerResult.externalTransactionId,
|
||||
amount: dto.amount,
|
||||
totalAmount: transaction.totalAmount,
|
||||
tipAmount: transaction.tipAmount,
|
||||
currency: transaction.currency,
|
||||
status: transaction.status,
|
||||
paymentMethod: transaction.paymentMethod,
|
||||
cardBrand: transaction.cardBrand,
|
||||
cardLastFour: transaction.cardLastFour,
|
||||
receiptUrl: transaction.receiptUrl,
|
||||
error: providerResult.error,
|
||||
};
|
||||
} catch (error: any) {
|
||||
// Circuit breaker opened or other error
|
||||
transaction.status = 'failed';
|
||||
transaction.failureReason = error.message;
|
||||
await this.transactionRepository.save(transaction);
|
||||
|
||||
// Update terminal health
|
||||
await this.terminalRepository.update(terminal.id, {
|
||||
healthStatus: 'offline',
|
||||
lastHealthCheckAt: new Date(),
|
||||
});
|
||||
|
||||
return this.errorResult(
|
||||
dto.amount,
|
||||
dto.tipAmount || 0,
|
||||
error.message,
|
||||
'PROVIDER_ERROR',
|
||||
transaction.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process refund
|
||||
*/
|
||||
async processRefund(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
dto: ProcessRefundDto
|
||||
): Promise<RefundResultDto> {
|
||||
const transaction = await this.transactionRepository.findOne({
|
||||
where: { id: dto.transactionId, tenantId },
|
||||
});
|
||||
|
||||
if (!transaction) {
|
||||
return { success: false, amount: 0, status: 'failed', error: 'Transaction not found' };
|
||||
}
|
||||
|
||||
if (transaction.status !== 'completed') {
|
||||
return {
|
||||
success: false,
|
||||
amount: 0,
|
||||
status: 'failed',
|
||||
error: 'Only completed transactions can be refunded',
|
||||
};
|
||||
}
|
||||
|
||||
const refundAmount = dto.amount || Number(transaction.totalAmount);
|
||||
|
||||
if (refundAmount > Number(transaction.totalAmount)) {
|
||||
return {
|
||||
success: false,
|
||||
amount: 0,
|
||||
status: 'failed',
|
||||
error: 'Refund amount cannot exceed transaction amount',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Get terminal for provider info
|
||||
const terminal = await this.terminalRepository.findOne({
|
||||
where: { terminalProvider: transaction.terminalProvider as any },
|
||||
});
|
||||
|
||||
// Process refund with provider
|
||||
// In production, this would call the actual provider API
|
||||
const refundResult = await this.processRefundWithProvider(transaction, refundAmount, dto.reason);
|
||||
|
||||
if (refundResult.success) {
|
||||
transaction.status = 'refunded';
|
||||
await this.transactionRepository.save(transaction);
|
||||
}
|
||||
|
||||
return {
|
||||
success: refundResult.success,
|
||||
refundId: refundResult.refundId,
|
||||
amount: refundAmount,
|
||||
status: refundResult.success ? 'completed' : 'failed',
|
||||
error: refundResult.error,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
amount: refundAmount,
|
||||
status: 'failed',
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transaction by ID
|
||||
*/
|
||||
async findById(id: string, tenantId: string): Promise<PaymentTransaction | null> {
|
||||
return this.transactionRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transactions with filters
|
||||
*/
|
||||
async findAll(
|
||||
tenantId: string,
|
||||
filter: TransactionFilterDto
|
||||
): Promise<{ data: PaymentTransaction[]; total: number }> {
|
||||
const query = this.transactionRepository
|
||||
.createQueryBuilder('tx')
|
||||
.where('tx.tenantId = :tenantId', { tenantId });
|
||||
|
||||
if (filter.branchId) {
|
||||
query.andWhere('tx.branchId = :branchId', { branchId: filter.branchId });
|
||||
}
|
||||
|
||||
if (filter.userId) {
|
||||
query.andWhere('tx.userId = :userId', { userId: filter.userId });
|
||||
}
|
||||
|
||||
if (filter.status) {
|
||||
query.andWhere('tx.status = :status', { status: filter.status });
|
||||
}
|
||||
|
||||
if (filter.sourceType) {
|
||||
query.andWhere('tx.sourceType = :sourceType', { sourceType: filter.sourceType });
|
||||
}
|
||||
|
||||
if (filter.terminalProvider) {
|
||||
query.andWhere('tx.terminalProvider = :provider', { provider: filter.terminalProvider });
|
||||
}
|
||||
|
||||
if (filter.startDate) {
|
||||
query.andWhere('tx.createdAt >= :startDate', { startDate: filter.startDate });
|
||||
}
|
||||
|
||||
if (filter.endDate) {
|
||||
query.andWhere('tx.createdAt <= :endDate', { endDate: filter.endDate });
|
||||
}
|
||||
|
||||
const total = await query.getCount();
|
||||
|
||||
query.orderBy('tx.createdAt', 'DESC');
|
||||
|
||||
if (filter.limit) {
|
||||
query.take(filter.limit);
|
||||
}
|
||||
|
||||
if (filter.offset) {
|
||||
query.skip(filter.offset);
|
||||
}
|
||||
|
||||
const data = await query.getMany();
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send receipt
|
||||
*/
|
||||
async sendReceipt(
|
||||
transactionId: string,
|
||||
tenantId: string,
|
||||
dto: SendReceiptDto
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const transaction = await this.findById(transactionId, tenantId);
|
||||
if (!transaction) {
|
||||
return { success: false, error: 'Transaction not found' };
|
||||
}
|
||||
|
||||
if (!dto.email && !dto.phone) {
|
||||
return { success: false, error: 'Email or phone is required' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Send receipt via email or SMS
|
||||
// In production, this would integrate with email/SMS service
|
||||
|
||||
transaction.receiptSent = true;
|
||||
transaction.receiptSentTo = dto.email || dto.phone || '';
|
||||
await this.transactionRepository.save(transaction);
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transaction statistics
|
||||
*/
|
||||
async getStats(tenantId: string, filter?: TransactionFilterDto): Promise<TransactionStatsDto> {
|
||||
const query = this.transactionRepository
|
||||
.createQueryBuilder('tx')
|
||||
.where('tx.tenantId = :tenantId', { tenantId });
|
||||
|
||||
if (filter?.branchId) {
|
||||
query.andWhere('tx.branchId = :branchId', { branchId: filter.branchId });
|
||||
}
|
||||
|
||||
if (filter?.startDate) {
|
||||
query.andWhere('tx.createdAt >= :startDate', { startDate: filter.startDate });
|
||||
}
|
||||
|
||||
if (filter?.endDate) {
|
||||
query.andWhere('tx.createdAt <= :endDate', { endDate: filter.endDate });
|
||||
}
|
||||
|
||||
const transactions = await query.getMany();
|
||||
|
||||
const byStatus: Record<PaymentStatus, number> = {
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
refunded: 0,
|
||||
cancelled: 0,
|
||||
};
|
||||
|
||||
const byProvider: Record<string, { count: number; amount: number }> = {};
|
||||
const byPaymentMethod: Record<PaymentMethod, number> = {
|
||||
card: 0,
|
||||
contactless: 0,
|
||||
qr: 0,
|
||||
link: 0,
|
||||
};
|
||||
|
||||
let totalAmount = 0;
|
||||
let completedCount = 0;
|
||||
|
||||
for (const tx of transactions) {
|
||||
byStatus[tx.status]++;
|
||||
|
||||
if (!byProvider[tx.terminalProvider]) {
|
||||
byProvider[tx.terminalProvider] = { count: 0, amount: 0 };
|
||||
}
|
||||
byProvider[tx.terminalProvider].count++;
|
||||
|
||||
if (tx.status === 'completed') {
|
||||
totalAmount += Number(tx.totalAmount);
|
||||
completedCount++;
|
||||
byProvider[tx.terminalProvider].amount += Number(tx.totalAmount);
|
||||
byPaymentMethod[tx.paymentMethod]++;
|
||||
}
|
||||
}
|
||||
|
||||
const total = transactions.length;
|
||||
const failedCount = byStatus.failed;
|
||||
|
||||
return {
|
||||
total,
|
||||
totalAmount,
|
||||
byStatus,
|
||||
byProvider,
|
||||
byPaymentMethod,
|
||||
averageAmount: completedCount > 0 ? totalAmount / completedCount : 0,
|
||||
successRate: total > 0 ? ((total - failedCount) / total) * 100 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create circuit breaker for terminal
|
||||
*/
|
||||
private getCircuitBreaker(terminalId: string): CircuitBreaker {
|
||||
if (!this.circuitBreakers.has(terminalId)) {
|
||||
this.circuitBreakers.set(
|
||||
terminalId,
|
||||
new CircuitBreaker(`terminal-${terminalId}`, {
|
||||
failureThreshold: 3,
|
||||
halfOpenRequests: 2,
|
||||
resetTimeout: 30000, // 30 seconds
|
||||
})
|
||||
);
|
||||
}
|
||||
return this.circuitBreakers.get(terminalId)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process payment with provider (simulated)
|
||||
*/
|
||||
private async processWithProvider(
|
||||
terminal: BranchPaymentTerminal,
|
||||
transaction: PaymentTransaction,
|
||||
dto: ProcessPaymentDto
|
||||
): Promise<{
|
||||
status: PaymentStatus;
|
||||
externalTransactionId?: string;
|
||||
paymentMethod?: PaymentMethod;
|
||||
cardBrand?: string;
|
||||
cardLastFour?: string;
|
||||
receiptUrl?: string;
|
||||
rawResponse?: Record<string, any>;
|
||||
error?: string;
|
||||
}> {
|
||||
// In production, this would call the actual provider API
|
||||
// For now, simulate a successful transaction
|
||||
|
||||
// Simulate processing time
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Simulate success rate (95%)
|
||||
const success = Math.random() > 0.05;
|
||||
|
||||
if (success) {
|
||||
return {
|
||||
status: 'completed',
|
||||
externalTransactionId: `${terminal.terminalProvider}-${Date.now()}`,
|
||||
paymentMethod: 'card',
|
||||
cardBrand: 'visa',
|
||||
cardLastFour: '4242',
|
||||
receiptUrl: `https://receipts.example.com/${transaction.id}`,
|
||||
rawResponse: {
|
||||
provider: terminal.terminalProvider,
|
||||
approved: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: 'Payment declined by issuer',
|
||||
rawResponse: {
|
||||
provider: terminal.terminalProvider,
|
||||
approved: false,
|
||||
declineReason: 'insufficient_funds',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process refund with provider (simulated)
|
||||
*/
|
||||
private async processRefundWithProvider(
|
||||
transaction: PaymentTransaction,
|
||||
amount: number,
|
||||
reason?: string
|
||||
): Promise<{ success: boolean; refundId?: string; error?: string }> {
|
||||
// In production, this would call the actual provider API
|
||||
|
||||
// Simulate processing
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
refundId: `ref-${Date.now()}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error result
|
||||
*/
|
||||
private errorResult(
|
||||
amount: number,
|
||||
tipAmount: number,
|
||||
error: string,
|
||||
errorCode: string,
|
||||
transactionId?: string
|
||||
): PaymentResultDto {
|
||||
return {
|
||||
success: false,
|
||||
transactionId,
|
||||
amount,
|
||||
totalAmount: amount + tipAmount,
|
||||
tipAmount,
|
||||
currency: 'MXN',
|
||||
status: 'failed',
|
||||
error,
|
||||
errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user