From 9de89aab5ad1544f6bd2393878cf69ef5120580c Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 02:43:35 -0600 Subject: [PATCH] [PROP-CORE-004] feat: Add Phase 6 modules from erp-core Propagated modules: - payment-terminals: MercadoPago + Clip TPV integration - ai: Role-based AI access (ADMIN, GERENTE_TIENDA, CAJERO, CLIENTE) - mcp: 18 ERP tools for AI assistants 71 files added. Critical for POS operations (P0). Co-Authored-By: Claude Opus 4.5 --- src/modules/ai/ai.module.ts | 66 ++ src/modules/ai/controllers/ai.controller.ts | 381 ++++++++++++ src/modules/ai/controllers/index.ts | 1 + src/modules/ai/dto/ai.dto.ts | 343 ++++++++++ src/modules/ai/dto/index.ts | 9 + src/modules/ai/entities/completion.entity.ts | 92 +++ .../ai/entities/conversation.entity.ts | 160 +++++ src/modules/ai/entities/embedding.entity.ts | 77 +++ src/modules/ai/entities/index.ts | 7 + .../ai/entities/knowledge-base.entity.ts | 98 +++ src/modules/ai/entities/model.entity.ts | 78 +++ src/modules/ai/entities/prompt.entity.ts | 110 ++++ src/modules/ai/entities/usage.entity.ts | 120 ++++ src/modules/ai/index.ts | 5 + src/modules/ai/prompts/admin-system-prompt.ts | 86 +++ .../ai/prompts/customer-system-prompt.ts | 67 ++ src/modules/ai/prompts/index.ts | 48 ++ .../ai/prompts/operator-system-prompt.ts | 70 +++ .../ai/prompts/supervisor-system-prompt.ts | 78 +++ src/modules/ai/roles/erp-roles.config.ts | 252 ++++++++ src/modules/ai/roles/index.ts | 14 + src/modules/ai/services/ai.service.ts | 382 ++++++++++++ src/modules/ai/services/index.ts | 11 + .../ai/services/role-based-ai.service.ts | 455 ++++++++++++++ src/modules/mcp/controllers/index.ts | 1 + src/modules/mcp/controllers/mcp.controller.ts | 223 +++++++ src/modules/mcp/dto/index.ts | 1 + src/modules/mcp/dto/mcp.dto.ts | 66 ++ src/modules/mcp/entities/index.ts | 2 + .../mcp/entities/tool-call-result.entity.ts | 45 ++ src/modules/mcp/entities/tool-call.entity.ts | 65 ++ src/modules/mcp/index.ts | 7 + src/modules/mcp/interfaces/index.ts | 3 + .../mcp/interfaces/mcp-context.interface.ts | 17 + .../mcp/interfaces/mcp-resource.interface.ts | 18 + .../mcp/interfaces/mcp-tool.interface.ts | 62 ++ src/modules/mcp/mcp.module.ts | 70 +++ src/modules/mcp/services/index.ts | 3 + .../mcp/services/mcp-server.service.ts | 197 ++++++ .../mcp/services/tool-logger.service.ts | 171 +++++ .../mcp/services/tool-registry.service.ts | 53 ++ src/modules/mcp/tools/branch-tools.service.ts | 292 +++++++++ .../mcp/tools/customers-tools.service.ts | 94 +++ src/modules/mcp/tools/fiados-tools.service.ts | 216 +++++++ .../mcp/tools/financial-tools.service.ts | 291 +++++++++ src/modules/mcp/tools/index.ts | 8 + .../mcp/tools/inventory-tools.service.ts | 154 +++++ src/modules/mcp/tools/orders-tools.service.ts | 139 +++++ .../mcp/tools/products-tools.service.ts | 128 ++++ src/modules/mcp/tools/sales-tools.service.ts | 329 ++++++++++ .../controllers/clip-webhook.controller.ts | 54 ++ .../controllers/clip.controller.ts | 164 +++++ .../payment-terminals/controllers/index.ts | 14 + .../mercadopago-webhook.controller.ts | 56 ++ .../controllers/mercadopago.controller.ts | 165 +++++ .../controllers/terminals.controller.ts | 192 ++++++ .../controllers/transactions.controller.ts | 163 +++++ src/modules/payment-terminals/dto/index.ts | 6 + .../payment-terminals/dto/terminal.dto.ts | 47 ++ .../payment-terminals/dto/transaction.dto.ts | 75 +++ .../payment-terminals/entities/index.ts | 3 + .../entities/tenant-terminal-config.entity.ts | 82 +++ .../entities/terminal-payment.entity.ts | 182 ++++++ .../entities/terminal-webhook-event.entity.ts | 77 +++ src/modules/payment-terminals/index.ts | 15 + .../payment-terminals.module.ts | 76 +++ .../services/clip.service.ts | 583 +++++++++++++++++ .../payment-terminals/services/index.ts | 10 + .../services/mercadopago.service.ts | 584 ++++++++++++++++++ .../services/terminals.service.ts | 224 +++++++ .../services/transactions.service.ts | 497 +++++++++++++++ 71 files changed, 8934 insertions(+) create mode 100644 src/modules/ai/ai.module.ts create mode 100644 src/modules/ai/controllers/ai.controller.ts create mode 100644 src/modules/ai/controllers/index.ts create mode 100644 src/modules/ai/dto/ai.dto.ts create mode 100644 src/modules/ai/dto/index.ts create mode 100644 src/modules/ai/entities/completion.entity.ts create mode 100644 src/modules/ai/entities/conversation.entity.ts create mode 100644 src/modules/ai/entities/embedding.entity.ts create mode 100644 src/modules/ai/entities/index.ts create mode 100644 src/modules/ai/entities/knowledge-base.entity.ts create mode 100644 src/modules/ai/entities/model.entity.ts create mode 100644 src/modules/ai/entities/prompt.entity.ts create mode 100644 src/modules/ai/entities/usage.entity.ts create mode 100644 src/modules/ai/index.ts create mode 100644 src/modules/ai/prompts/admin-system-prompt.ts create mode 100644 src/modules/ai/prompts/customer-system-prompt.ts create mode 100644 src/modules/ai/prompts/index.ts create mode 100644 src/modules/ai/prompts/operator-system-prompt.ts create mode 100644 src/modules/ai/prompts/supervisor-system-prompt.ts create mode 100644 src/modules/ai/roles/erp-roles.config.ts create mode 100644 src/modules/ai/roles/index.ts create mode 100644 src/modules/ai/services/ai.service.ts create mode 100644 src/modules/ai/services/index.ts create mode 100644 src/modules/ai/services/role-based-ai.service.ts create mode 100644 src/modules/mcp/controllers/index.ts create mode 100644 src/modules/mcp/controllers/mcp.controller.ts create mode 100644 src/modules/mcp/dto/index.ts create mode 100644 src/modules/mcp/dto/mcp.dto.ts create mode 100644 src/modules/mcp/entities/index.ts create mode 100644 src/modules/mcp/entities/tool-call-result.entity.ts create mode 100644 src/modules/mcp/entities/tool-call.entity.ts create mode 100644 src/modules/mcp/index.ts create mode 100644 src/modules/mcp/interfaces/index.ts create mode 100644 src/modules/mcp/interfaces/mcp-context.interface.ts create mode 100644 src/modules/mcp/interfaces/mcp-resource.interface.ts create mode 100644 src/modules/mcp/interfaces/mcp-tool.interface.ts create mode 100644 src/modules/mcp/mcp.module.ts create mode 100644 src/modules/mcp/services/index.ts create mode 100644 src/modules/mcp/services/mcp-server.service.ts create mode 100644 src/modules/mcp/services/tool-logger.service.ts create mode 100644 src/modules/mcp/services/tool-registry.service.ts create mode 100644 src/modules/mcp/tools/branch-tools.service.ts create mode 100644 src/modules/mcp/tools/customers-tools.service.ts create mode 100644 src/modules/mcp/tools/fiados-tools.service.ts create mode 100644 src/modules/mcp/tools/financial-tools.service.ts create mode 100644 src/modules/mcp/tools/index.ts create mode 100644 src/modules/mcp/tools/inventory-tools.service.ts create mode 100644 src/modules/mcp/tools/orders-tools.service.ts create mode 100644 src/modules/mcp/tools/products-tools.service.ts create mode 100644 src/modules/mcp/tools/sales-tools.service.ts create mode 100644 src/modules/payment-terminals/controllers/clip-webhook.controller.ts create mode 100644 src/modules/payment-terminals/controllers/clip.controller.ts create mode 100644 src/modules/payment-terminals/controllers/index.ts create mode 100644 src/modules/payment-terminals/controllers/mercadopago-webhook.controller.ts create mode 100644 src/modules/payment-terminals/controllers/mercadopago.controller.ts create mode 100644 src/modules/payment-terminals/controllers/terminals.controller.ts create mode 100644 src/modules/payment-terminals/controllers/transactions.controller.ts create mode 100644 src/modules/payment-terminals/dto/index.ts create mode 100644 src/modules/payment-terminals/dto/terminal.dto.ts create mode 100644 src/modules/payment-terminals/dto/transaction.dto.ts create mode 100644 src/modules/payment-terminals/entities/index.ts create mode 100644 src/modules/payment-terminals/entities/tenant-terminal-config.entity.ts create mode 100644 src/modules/payment-terminals/entities/terminal-payment.entity.ts create mode 100644 src/modules/payment-terminals/entities/terminal-webhook-event.entity.ts create mode 100644 src/modules/payment-terminals/index.ts create mode 100644 src/modules/payment-terminals/payment-terminals.module.ts create mode 100644 src/modules/payment-terminals/services/clip.service.ts create mode 100644 src/modules/payment-terminals/services/index.ts create mode 100644 src/modules/payment-terminals/services/mercadopago.service.ts create mode 100644 src/modules/payment-terminals/services/terminals.service.ts create mode 100644 src/modules/payment-terminals/services/transactions.service.ts diff --git a/src/modules/ai/ai.module.ts b/src/modules/ai/ai.module.ts new file mode 100644 index 0000000..c7083dd --- /dev/null +++ b/src/modules/ai/ai.module.ts @@ -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, + ]; + } +} diff --git a/src/modules/ai/controllers/ai.controller.ts b/src/modules/ai/controllers/ai.controller.ts new file mode 100644 index 0000000..3d126cf --- /dev/null +++ b/src/modules/ai/controllers/ai.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/src/modules/ai/controllers/index.ts b/src/modules/ai/controllers/index.ts new file mode 100644 index 0000000..cf85729 --- /dev/null +++ b/src/modules/ai/controllers/index.ts @@ -0,0 +1 @@ +export { AIController } from './ai.controller'; diff --git a/src/modules/ai/dto/ai.dto.ts b/src/modules/ai/dto/ai.dto.ts new file mode 100644 index 0000000..39daa77 --- /dev/null +++ b/src/modules/ai/dto/ai.dto.ts @@ -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; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + allowedModels?: string[]; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +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; + + @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; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +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; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +// ============================================ +// 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; +} + +// ============================================ +// 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; +} + +// ============================================ +// 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[]; +} diff --git a/src/modules/ai/dto/index.ts b/src/modules/ai/dto/index.ts new file mode 100644 index 0000000..65584c6 --- /dev/null +++ b/src/modules/ai/dto/index.ts @@ -0,0 +1,9 @@ +export { + CreatePromptDto, + UpdatePromptDto, + CreateConversationDto, + UpdateConversationDto, + AddMessageDto, + LogUsageDto, + UpdateQuotaDto, +} from './ai.dto'; diff --git a/src/modules/ai/entities/completion.entity.ts b/src/modules/ai/entities/completion.entity.ts new file mode 100644 index 0000000..6c0e712 --- /dev/null +++ b/src/modules/ai/entities/completion.entity.ts @@ -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; + + @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; + + @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; +} diff --git a/src/modules/ai/entities/conversation.entity.ts b/src/modules/ai/entities/conversation.entity.ts new file mode 100644 index 0000000..636d2a8 --- /dev/null +++ b/src/modules/ai/entities/conversation.entity.ts @@ -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; + + @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; + + @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; + + @Column({ name: 'function_result', type: 'jsonb', nullable: true }) + functionResult: Record; + + @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; + + @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; +} diff --git a/src/modules/ai/entities/embedding.entity.ts b/src/modules/ai/entities/embedding.entity.ts new file mode 100644 index 0000000..4d30c99 --- /dev/null +++ b/src/modules/ai/entities/embedding.entity.ts @@ -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; + + @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; +} diff --git a/src/modules/ai/entities/index.ts b/src/modules/ai/entities/index.ts new file mode 100644 index 0000000..8317b21 --- /dev/null +++ b/src/modules/ai/entities/index.ts @@ -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'; diff --git a/src/modules/ai/entities/knowledge-base.entity.ts b/src/modules/ai/entities/knowledge-base.entity.ts new file mode 100644 index 0000000..55e65ec --- /dev/null +++ b/src/modules/ai/entities/knowledge-base.entity.ts @@ -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; + + @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; +} diff --git a/src/modules/ai/entities/model.entity.ts b/src/modules/ai/entities/model.entity.ts new file mode 100644 index 0000000..893ea83 --- /dev/null +++ b/src/modules/ai/entities/model.entity.ts @@ -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; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/ai/entities/prompt.entity.ts b/src/modules/ai/entities/prompt.entity.ts new file mode 100644 index 0000000..dfbaf57 --- /dev/null +++ b/src/modules/ai/entities/prompt.entity.ts @@ -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; + + @Column({ name: 'functions', type: 'jsonb', default: [] }) + functions: Record[]; + + @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; +} diff --git a/src/modules/ai/entities/usage.entity.ts b/src/modules/ai/entities/usage.entity.ts new file mode 100644 index 0000000..42eaf3d --- /dev/null +++ b/src/modules/ai/entities/usage.entity.ts @@ -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; +} diff --git a/src/modules/ai/index.ts b/src/modules/ai/index.ts new file mode 100644 index 0000000..b2ce0ae --- /dev/null +++ b/src/modules/ai/index.ts @@ -0,0 +1,5 @@ +export { AIModule, AIModuleOptions } from './ai.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/ai/prompts/admin-system-prompt.ts b/src/modules/ai/prompts/admin-system-prompt.ts new file mode 100644 index 0000000..a767681 --- /dev/null +++ b/src/modules/ai/prompts/admin-system-prompt.ts @@ -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'); +} diff --git a/src/modules/ai/prompts/customer-system-prompt.ts b/src/modules/ai/prompts/customer-system-prompt.ts new file mode 100644 index 0000000..f832d40 --- /dev/null +++ b/src/modules/ai/prompts/customer-system-prompt.ts @@ -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'); +} diff --git a/src/modules/ai/prompts/index.ts b/src/modules/ai/prompts/index.ts new file mode 100644 index 0000000..a4331a5 --- /dev/null +++ b/src/modules/ai/prompts/index.ts @@ -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); + } +} diff --git a/src/modules/ai/prompts/operator-system-prompt.ts b/src/modules/ai/prompts/operator-system-prompt.ts new file mode 100644 index 0000000..aa65433 --- /dev/null +++ b/src/modules/ai/prompts/operator-system-prompt.ts @@ -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)); +} diff --git a/src/modules/ai/prompts/supervisor-system-prompt.ts b/src/modules/ai/prompts/supervisor-system-prompt.ts new file mode 100644 index 0000000..fd57071 --- /dev/null +++ b/src/modules/ai/prompts/supervisor-system-prompt.ts @@ -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)); +} diff --git a/src/modules/ai/roles/erp-roles.config.ts b/src/modules/ai/roles/erp-roles.config.ts new file mode 100644 index 0000000..500035b --- /dev/null +++ b/src/modules/ai/roles/erp-roles.config.ts @@ -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 = { + 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 = { + // 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; +} diff --git a/src/modules/ai/roles/index.ts b/src/modules/ai/roles/index.ts new file mode 100644 index 0000000..8fc1b8c --- /dev/null +++ b/src/modules/ai/roles/index.ts @@ -0,0 +1,14 @@ +/** + * ERP Roles Index + */ + +export { + ERPRole, + ERPRoleConfig, + ERP_ROLES, + DB_ROLE_MAPPING, + getERPRole, + hasToolAccess, + getToolsForRole, + getRoleConfig, +} from './erp-roles.config'; diff --git a/src/modules/ai/services/ai.service.ts b/src/modules/ai/services/ai.service.ts new file mode 100644 index 0000000..03bec3c --- /dev/null +++ b/src/modules/ai/services/ai.service.ts @@ -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, + private readonly conversationRepository: Repository, + private readonly messageRepository: Repository, + private readonly promptRepository: Repository, + private readonly usageLogRepository: Repository, + private readonly quotaRepository: Repository + ) {} + + // ============================================ + // MODELS + // ============================================ + + async findAllModels(): Promise { + return this.modelRepository.find({ + where: { isActive: true }, + order: { provider: 'ASC', name: 'ASC' }, + }); + } + + async findModel(id: string): Promise { + return this.modelRepository.findOne({ where: { id } }); + } + + async findModelByCode(code: string): Promise { + return this.modelRepository.findOne({ where: { code } }); + } + + async findModelsByProvider(provider: string): Promise { + return this.modelRepository.find({ + where: { provider: provider as any, isActive: true }, + order: { name: 'ASC' }, + }); + } + + async findModelsByType(modelType: string): Promise { + return this.modelRepository.find({ + where: { modelType: modelType as any, isActive: true }, + order: { name: 'ASC' }, + }); + } + + // ============================================ + // PROMPTS + // ============================================ + + async findAllPrompts(tenantId?: string): Promise { + 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 { + return this.promptRepository.findOne({ where: { id } }); + } + + async findPromptByCode(code: string, tenantId?: string): Promise { + 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, + createdBy?: string + ): Promise { + const prompt = this.promptRepository.create({ + ...data, + tenantId, + createdBy, + version: 1, + }); + return this.promptRepository.save(prompt); + } + + async updatePrompt( + id: string, + data: Partial, + updatedBy?: string + ): Promise { + 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 { + 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 { + const where: FindOptionsWhere = { 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 { + return this.conversationRepository.findOne({ + where: { id }, + relations: ['messages'], + }); + } + + async findUserConversations( + tenantId: string, + userId: string, + limit: number = 20 + ): Promise { + return this.conversationRepository.find({ + where: { tenantId, userId }, + order: { updatedAt: 'DESC' }, + take: limit, + }); + } + + async createConversation( + tenantId: string, + userId: string, + data: Partial + ): Promise { + const conversation = this.conversationRepository.create({ + ...data, + tenantId, + userId, + status: 'active', + }); + return this.conversationRepository.save(conversation); + } + + async updateConversation( + id: string, + data: Partial + ): Promise { + 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 { + const result = await this.conversationRepository.update(id, { status: 'archived' }); + return (result.affected ?? 0) > 0; + } + + // ============================================ + // MESSAGES + // ============================================ + + async findMessages(conversationId: string): Promise { + return this.messageRepository.find({ + where: { conversationId }, + order: { createdAt: 'ASC' }, + }); + } + + async addMessage(conversationId: string, data: Partial): Promise { + 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 { + 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): Promise { + 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; + }> { + 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 = {}; + 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 { + return this.quotaRepository.findOne({ where: { tenantId } }); + } + + async updateTenantQuota( + tenantId: string, + data: Partial + ): Promise { + 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 { + 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 { + const result = await this.quotaRepository.update( + {}, + { + currentRequests: 0, + currentTokens: 0, + currentCost: 0, + } + ); + return result.affected ?? 0; + } +} diff --git a/src/modules/ai/services/index.ts b/src/modules/ai/services/index.ts new file mode 100644 index 0000000..c281f6b --- /dev/null +++ b/src/modules/ai/services/index.ts @@ -0,0 +1,11 @@ +export { AIService, ConversationFilters } from './ai.service'; +export { + RoleBasedAIService, + ChatContext, + ChatMessage, + ChatResponse, + ToolCall, + ToolResult, + ToolDefinition, + TenantConfigProvider, +} from './role-based-ai.service'; diff --git a/src/modules/ai/services/role-based-ai.service.ts b/src/modules/ai/services/role-based-ai.service.ts new file mode 100644 index 0000000..81b422b --- /dev/null +++ b/src/modules/ai/services/role-based-ai.service.ts @@ -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; +} + +export interface ChatMessage { + role: 'user' | 'assistant' | 'system'; + content: string; + toolCalls?: ToolCall[]; + toolResults?: ToolResult[]; +} + +export interface ToolCall { + id: string; + name: string; + arguments: Record; +} + +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; + handler?: (args: any, context: ChatContext) => Promise; +} + +/** + * Servicio de IA con Role-Based Access Control + */ +export class RoleBasedAIService extends AIService { + private conversationHistory: Map = new Map(); + private toolRegistry: Map = new Map(); + + constructor( + modelRepository: Repository, + conversationRepository: Repository, + messageRepository: Repository, + promptRepository: Repository, + usageLogRepository: Repository, + quotaRepository: Repository, + 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 { + 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 { + 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 { + // 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 { + // 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>; +} diff --git a/src/modules/mcp/controllers/index.ts b/src/modules/mcp/controllers/index.ts new file mode 100644 index 0000000..452c198 --- /dev/null +++ b/src/modules/mcp/controllers/index.ts @@ -0,0 +1 @@ +export { McpController } from './mcp.controller'; diff --git a/src/modules/mcp/controllers/mcp.controller.ts b/src/modules/mcp/controllers/mcp.controller.ts new file mode 100644 index 0000000..306ae92 --- /dev/null +++ b/src/modules/mcp/controllers/mcp.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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()); + } + } +} diff --git a/src/modules/mcp/dto/index.ts b/src/modules/mcp/dto/index.ts new file mode 100644 index 0000000..06ba2ec --- /dev/null +++ b/src/modules/mcp/dto/index.ts @@ -0,0 +1 @@ +export * from './mcp.dto'; diff --git a/src/modules/mcp/dto/mcp.dto.ts b/src/modules/mcp/dto/mcp.dto.ts new file mode 100644 index 0000000..b586736 --- /dev/null +++ b/src/modules/mcp/dto/mcp.dto.ts @@ -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; +} + +export interface ToolCallResultDto { + success: boolean; + toolName: string; + result?: any; + error?: string; + callId: string; +} + +export interface StartCallData { + tenantId: string; + toolName: string; + parameters: Record; + 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 { + data: T[]; + total: number; + page: number; + limit: number; +} + +// ============================================ +// Resource DTOs +// ============================================ + +export interface ResourceContentDto { + uri: string; + name: string; + mimeType: string; + content: any; +} diff --git a/src/modules/mcp/entities/index.ts b/src/modules/mcp/entities/index.ts new file mode 100644 index 0000000..f9c8658 --- /dev/null +++ b/src/modules/mcp/entities/index.ts @@ -0,0 +1,2 @@ +export { ToolCall, ToolCallStatus } from './tool-call.entity'; +export { ToolCallResult, ResultType } from './tool-call-result.entity'; diff --git a/src/modules/mcp/entities/tool-call-result.entity.ts b/src/modules/mcp/entities/tool-call-result.entity.ts new file mode 100644 index 0000000..b4ab2b2 --- /dev/null +++ b/src/modules/mcp/entities/tool-call-result.entity.ts @@ -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; +} diff --git a/src/modules/mcp/entities/tool-call.entity.ts b/src/modules/mcp/entities/tool-call.entity.ts new file mode 100644 index 0000000..8aee11c --- /dev/null +++ b/src/modules/mcp/entities/tool-call.entity.ts @@ -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; + + @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; +} diff --git a/src/modules/mcp/index.ts b/src/modules/mcp/index.ts new file mode 100644 index 0000000..f83290f --- /dev/null +++ b/src/modules/mcp/index.ts @@ -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'; diff --git a/src/modules/mcp/interfaces/index.ts b/src/modules/mcp/interfaces/index.ts new file mode 100644 index 0000000..d612fa9 --- /dev/null +++ b/src/modules/mcp/interfaces/index.ts @@ -0,0 +1,3 @@ +export * from './mcp-tool.interface'; +export * from './mcp-context.interface'; +export * from './mcp-resource.interface'; diff --git a/src/modules/mcp/interfaces/mcp-context.interface.ts b/src/modules/mcp/interfaces/mcp-context.interface.ts new file mode 100644 index 0000000..69488c4 --- /dev/null +++ b/src/modules/mcp/interfaces/mcp-context.interface.ts @@ -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; +} diff --git a/src/modules/mcp/interfaces/mcp-resource.interface.ts b/src/modules/mcp/interfaces/mcp-resource.interface.ts new file mode 100644 index 0000000..e678ab3 --- /dev/null +++ b/src/modules/mcp/interfaces/mcp-resource.interface.ts @@ -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; +} diff --git a/src/modules/mcp/interfaces/mcp-tool.interface.ts b/src/modules/mcp/interfaces/mcp-tool.interface.ts new file mode 100644 index 0000000..155f8d7 --- /dev/null +++ b/src/modules/mcp/interfaces/mcp-tool.interface.ts @@ -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; + 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; + 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 = ( + params: TParams, + context: McpContext +) => Promise; + +export interface McpToolProvider { + getTools(): McpToolDefinition[]; + getHandler(toolName: string): McpToolHandler | undefined; +} diff --git a/src/modules/mcp/mcp.module.ts b/src/modules/mcp/mcp.module.ts new file mode 100644 index 0000000..8b651b7 --- /dev/null +++ b/src/modules/mcp/mcp.module.ts @@ -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]; + } +} diff --git a/src/modules/mcp/services/index.ts b/src/modules/mcp/services/index.ts new file mode 100644 index 0000000..562464d --- /dev/null +++ b/src/modules/mcp/services/index.ts @@ -0,0 +1,3 @@ +export { McpServerService } from './mcp-server.service'; +export { ToolRegistryService } from './tool-registry.service'; +export { ToolLoggerService } from './tool-logger.service'; diff --git a/src/modules/mcp/services/mcp-server.service.ts b/src/modules/mcp/services/mcp-server.service.ts new file mode 100644 index 0000000..8aa66e9 --- /dev/null +++ b/src/modules/mcp/services/mcp-server.service.ts @@ -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 = 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, + context: McpContext + ): Promise { + // 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 { + 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> { + return this.toolLogger.getCallHistory(tenantId, filters); + } + + async getCallDetails(id: string, tenantId: string): Promise { + return this.toolLogger.getCallById(id, tenantId); + } + + async getToolStats(tenantId: string, startDate: Date, endDate: Date) { + return this.toolLogger.getToolStats(tenantId, startDate, endDate); + } +} diff --git a/src/modules/mcp/services/tool-logger.service.ts b/src/modules/mcp/services/tool-logger.service.ts new file mode 100644 index 0000000..797ba79 --- /dev/null +++ b/src/modules/mcp/services/tool-logger.service.ts @@ -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, + private readonly resultRepo: Repository + ) {} + + async startCall(data: StartCallData): Promise { + 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 { + 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 { + 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 { + 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> { + 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 { + 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'; + } +} diff --git a/src/modules/mcp/services/tool-registry.service.ts b/src/modules/mcp/services/tool-registry.service.ts new file mode 100644 index 0000000..8661f3b --- /dev/null +++ b/src/modules/mcp/services/tool-registry.service.ts @@ -0,0 +1,53 @@ +import { + McpToolDefinition, + McpToolHandler, + McpToolProvider, + ToolCategory, +} from '../interfaces'; + +export class ToolRegistryService { + private tools: Map = new Map(); + private handlers: Map = 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(); + for (const tool of this.tools.values()) { + categories.add(tool.category); + } + return Array.from(categories); + } +} diff --git a/src/modules/mcp/tools/branch-tools.service.ts b/src/modules/mcp/tools/branch-tools.service.ts new file mode 100644 index 0000000..d352893 --- /dev/null +++ b/src/modules/mcp/tools/branch-tools.service.ts @@ -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 = { + 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 { + // 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 { + // 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 { + // 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 { + // 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 { + // 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 { + // 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'], + }, + ]; + } +} diff --git a/src/modules/mcp/tools/customers-tools.service.ts b/src/modules/mcp/tools/customers-tools.service.ts new file mode 100644 index 0000000..daa5298 --- /dev/null +++ b/src/modules/mcp/tools/customers-tools.service.ts @@ -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 = { + 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 { + // 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 { + // 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', + }; + } +} diff --git a/src/modules/mcp/tools/fiados-tools.service.ts b/src/modules/mcp/tools/fiados-tools.service.ts new file mode 100644 index 0000000..6e34982 --- /dev/null +++ b/src/modules/mcp/tools/fiados-tools.service.ts @@ -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 = { + 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 { + // 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 { + // 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 { + // 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 { + // 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', + }; + } +} diff --git a/src/modules/mcp/tools/financial-tools.service.ts b/src/modules/mcp/tools/financial-tools.service.ts new file mode 100644 index 0000000..50b2ba8 --- /dev/null +++ b/src/modules/mcp/tools/financial-tools.service.ts @@ -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 = { + 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 { + // 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 { + // 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 { + // 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 { + // 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 { + // 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', + }; + } +} diff --git a/src/modules/mcp/tools/index.ts b/src/modules/mcp/tools/index.ts new file mode 100644 index 0000000..53c814c --- /dev/null +++ b/src/modules/mcp/tools/index.ts @@ -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'; diff --git a/src/modules/mcp/tools/inventory-tools.service.ts b/src/modules/mcp/tools/inventory-tools.service.ts new file mode 100644 index 0000000..76a45ca --- /dev/null +++ b/src/modules/mcp/tools/inventory-tools.service.ts @@ -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 = { + 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 { + // 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 { + // 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 { + // 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 { + // 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', + }; + } +} diff --git a/src/modules/mcp/tools/orders-tools.service.ts b/src/modules/mcp/tools/orders-tools.service.ts new file mode 100644 index 0000000..facc0b0 --- /dev/null +++ b/src/modules/mcp/tools/orders-tools.service.ts @@ -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 = { + 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 { + // 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 { + // 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 { + // 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', + }; + } +} diff --git a/src/modules/mcp/tools/products-tools.service.ts b/src/modules/mcp/tools/products-tools.service.ts new file mode 100644 index 0000000..92c3e44 --- /dev/null +++ b/src/modules/mcp/tools/products-tools.service.ts @@ -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 = { + 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 { + // 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 { + // 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 { + // 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', + }; + } +} diff --git a/src/modules/mcp/tools/sales-tools.service.ts b/src/modules/mcp/tools/sales-tools.service.ts new file mode 100644 index 0000000..d65ceb9 --- /dev/null +++ b/src/modules/mcp/tools/sales-tools.service.ts @@ -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 = { + 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 { + // 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 { + // 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 { + // 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 { + // 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 { + // 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 { + // 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 { + // 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', + }; + } +} diff --git a/src/modules/payment-terminals/controllers/clip-webhook.controller.ts b/src/modules/payment-terminals/controllers/clip-webhook.controller.ts new file mode 100644 index 0000000..88a968f --- /dev/null +++ b/src/modules/payment-terminals/controllers/clip-webhook.controller.ts @@ -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 { + try { + const tenantId = req.params.tenantId; + const eventType = req.body.event || req.body.type; + const data = req.body; + + // Extraer headers relevantes + const headers: Record = { + '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 }); + } + } + } +} diff --git a/src/modules/payment-terminals/controllers/clip.controller.ts b/src/modules/payment-terminals/controllers/clip.controller.ts new file mode 100644 index 0000000..1ce4ad9 --- /dev/null +++ b/src/modules/payment-terminals/controllers/clip.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/modules/payment-terminals/controllers/index.ts b/src/modules/payment-terminals/controllers/index.ts new file mode 100644 index 0000000..a2e7b17 --- /dev/null +++ b/src/modules/payment-terminals/controllers/index.ts @@ -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'; diff --git a/src/modules/payment-terminals/controllers/mercadopago-webhook.controller.ts b/src/modules/payment-terminals/controllers/mercadopago-webhook.controller.ts new file mode 100644 index 0000000..cf07aa4 --- /dev/null +++ b/src/modules/payment-terminals/controllers/mercadopago-webhook.controller.ts @@ -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 { + try { + const tenantId = req.params.tenantId; + const eventType = req.body.type || req.body.action; + const data = req.body; + + // Extraer headers relevantes + const headers: Record = { + '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 }); + } + } + } +} diff --git a/src/modules/payment-terminals/controllers/mercadopago.controller.ts b/src/modules/payment-terminals/controllers/mercadopago.controller.ts new file mode 100644 index 0000000..357a2db --- /dev/null +++ b/src/modules/payment-terminals/controllers/mercadopago.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/modules/payment-terminals/controllers/terminals.controller.ts b/src/modules/payment-terminals/controllers/terminals.controller.ts new file mode 100644 index 0000000..8749190 --- /dev/null +++ b/src/modules/payment-terminals/controllers/terminals.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/src/modules/payment-terminals/controllers/transactions.controller.ts b/src/modules/payment-terminals/controllers/transactions.controller.ts new file mode 100644 index 0000000..7b736c0 --- /dev/null +++ b/src/modules/payment-terminals/controllers/transactions.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/src/modules/payment-terminals/dto/index.ts b/src/modules/payment-terminals/dto/index.ts new file mode 100644 index 0000000..02c5c6e --- /dev/null +++ b/src/modules/payment-terminals/dto/index.ts @@ -0,0 +1,6 @@ +/** + * Payment Terminals DTOs Index + */ + +export * from './terminal.dto'; +export * from './transaction.dto'; diff --git a/src/modules/payment-terminals/dto/terminal.dto.ts b/src/modules/payment-terminals/dto/terminal.dto.ts new file mode 100644 index 0000000..00b8fca --- /dev/null +++ b/src/modules/payment-terminals/dto/terminal.dto.ts @@ -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; + isPrimary?: boolean; + dailyLimit?: number; + transactionLimit?: number; +} + +export class UpdateTerminalDto { + terminalName?: string; + credentials?: Record; + 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; +} diff --git a/src/modules/payment-terminals/dto/transaction.dto.ts b/src/modules/payment-terminals/dto/transaction.dto.ts new file mode 100644 index 0000000..0a1bfe5 --- /dev/null +++ b/src/modules/payment-terminals/dto/transaction.dto.ts @@ -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; + byProvider: Record; + byPaymentMethod: Record; + averageAmount: number; + successRate: number; +} diff --git a/src/modules/payment-terminals/entities/index.ts b/src/modules/payment-terminals/entities/index.ts new file mode 100644 index 0000000..766af69 --- /dev/null +++ b/src/modules/payment-terminals/entities/index.ts @@ -0,0 +1,3 @@ +export * from './tenant-terminal-config.entity'; +export * from './terminal-payment.entity'; +export * from './terminal-webhook-event.entity'; diff --git a/src/modules/payment-terminals/entities/tenant-terminal-config.entity.ts b/src/modules/payment-terminals/entities/tenant-terminal-config.entity.ts new file mode 100644 index 0000000..7b96834 --- /dev/null +++ b/src/modules/payment-terminals/entities/tenant-terminal-config.entity.ts @@ -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; + + // Configuración específica del proveedor + @Column({ type: 'jsonb', default: {} }) + config: Record; + + @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; + + @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; +} diff --git a/src/modules/payment-terminals/entities/terminal-payment.entity.ts b/src/modules/payment-terminals/entities/terminal-payment.entity.ts new file mode 100644 index 0000000..f1529e1 --- /dev/null +++ b/src/modules/payment-terminals/entities/terminal-payment.entity.ts @@ -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 | 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 | 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; + + @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; +} diff --git a/src/modules/payment-terminals/entities/terminal-webhook-event.entity.ts b/src/modules/payment-terminals/entities/terminal-webhook-event.entity.ts new file mode 100644 index 0000000..d758857 --- /dev/null +++ b/src/modules/payment-terminals/entities/terminal-webhook-event.entity.ts @@ -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; + + @Column({ type: 'jsonb', nullable: true }) + headers: Record | 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; +} diff --git a/src/modules/payment-terminals/index.ts b/src/modules/payment-terminals/index.ts new file mode 100644 index 0000000..6794513 --- /dev/null +++ b/src/modules/payment-terminals/index.ts @@ -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'; diff --git a/src/modules/payment-terminals/payment-terminals.module.ts b/src/modules/payment-terminals/payment-terminals.module.ts new file mode 100644 index 0000000..14410c0 --- /dev/null +++ b/src/modules/payment-terminals/payment-terminals.module.ts @@ -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; diff --git a/src/modules/payment-terminals/services/clip.service.ts b/src/modules/payment-terminals/services/clip.service.ts new file mode 100644 index 0000000..3e47c5d --- /dev/null +++ b/src/modules/payment-terminals/services/clip.service.ts @@ -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; +} + +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; + private paymentRepository: Repository; + private webhookRepository: Repository; + + 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 { + 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 { + 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 { + 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 { + 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 + ): Promise { + // 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 { + 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 { + 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 { + 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 = { + 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(fn: () => Promise, attempt = 0): Promise { + 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'; + } +} diff --git a/src/modules/payment-terminals/services/index.ts b/src/modules/payment-terminals/services/index.ts new file mode 100644 index 0000000..c92768e --- /dev/null +++ b/src/modules/payment-terminals/services/index.ts @@ -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'; diff --git a/src/modules/payment-terminals/services/mercadopago.service.ts b/src/modules/payment-terminals/services/mercadopago.service.ts new file mode 100644 index 0000000..0ea1775 --- /dev/null +++ b/src/modules/payment-terminals/services/mercadopago.service.ts @@ -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; +} + +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; + private paymentRepository: Repository; + private webhookRepository: Repository; + + 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 { + 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 { + 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 { + 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 { + 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 + ): Promise { + // 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 { + 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 { + // 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); + + 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 = { + 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(fn: () => Promise, attempt = 0): Promise { + 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'; + } +} diff --git a/src/modules/payment-terminals/services/terminals.service.ts b/src/modules/payment-terminals/services/terminals.service.ts new file mode 100644 index 0000000..16ed00e --- /dev/null +++ b/src/modules/payment-terminals/services/terminals.service.ts @@ -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; + + constructor(private dataSource: DataSource) { + this.terminalRepository = dataSource.getRepository(BranchPaymentTerminal); + } + + /** + * Create a new terminal + */ + async create(tenantId: string, dto: CreateTerminalDto): Promise { + // 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 { + 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 { + return this.terminalRepository.findOne({ + where: { branchId, isPrimary: true, isActive: true }, + }); + } + + /** + * Find terminal by ID + */ + async findById(id: string): Promise { + return this.terminalRepository.findOne({ where: { id } }); + } + + /** + * Update terminal + */ + async update(id: string, dto: UpdateTerminalDto): Promise { + 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 { + 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 { + 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 { + await this.terminalRepository.update(id, { + healthStatus: status, + lastHealthCheckAt: new Date(), + }); + } + + /** + * Update last transaction timestamp + */ + async updateLastTransaction(id: string): Promise { + 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 { + 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, + }; + } +} diff --git a/src/modules/payment-terminals/services/transactions.service.ts b/src/modules/payment-terminals/services/transactions.service.ts new file mode 100644 index 0000000..146fde5 --- /dev/null +++ b/src/modules/payment-terminals/services/transactions.service.ts @@ -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; + private terminalRepository: Repository; + private circuitBreakers: Map = 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 { + // 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 { + 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 { + 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 { + 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 = { + pending: 0, + processing: 0, + completed: 0, + failed: 0, + refunded: 0, + cancelled: 0, + }; + + const byProvider: Record = {}; + const byPaymentMethod: Record = { + 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; + 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, + }; + } +}