[PROP-CORE-004] feat: Add Phase 6 modules from erp-core

Propagated modules:
- payment-terminals: MercadoPago + Clip TPV
- ai: Role-based AI access (ADMIN, SUPERVISOR_OBRA, RESIDENTE, ALMACENISTA)
- mcp: 18 ERP tools for AI assistants

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 02:45:03 -06:00
parent 9a3f43ed2e
commit 0493d4b8bd
71 changed files with 8934 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export { McpController } from './mcp.controller';

View File

@ -0,0 +1,223 @@
import { Request, Response, NextFunction, Router } from 'express';
import { McpServerService } from '../services/mcp-server.service';
import { McpContext, CallerType } from '../interfaces';
export class McpController {
public router: Router;
constructor(private readonly mcpService: McpServerService) {
this.router = Router();
this.initializeRoutes();
}
private initializeRoutes(): void {
// Tools
this.router.get('/tools', this.listTools.bind(this));
this.router.get('/tools/:name', this.getTool.bind(this));
this.router.post('/tools/call', this.callTool.bind(this));
// Resources
this.router.get('/resources', this.listResources.bind(this));
this.router.get('/resources/*', this.getResource.bind(this));
// History / Audit
this.router.get('/tool-calls', this.getToolCallHistory.bind(this));
this.router.get('/tool-calls/:id', this.getToolCallDetails.bind(this));
this.router.get('/stats', this.getToolStats.bind(this));
}
// ============================================
// TOOLS
// ============================================
private async listTools(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tools = this.mcpService.listTools();
res.json({ data: tools, total: tools.length });
} catch (error) {
next(error);
}
}
private async getTool(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { name } = req.params;
const tool = this.mcpService.getTool(name);
if (!tool) {
res.status(404).json({ error: 'Tool not found' });
return;
}
res.json({ data: tool });
} catch (error) {
next(error);
}
}
private async callTool(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { tool, parameters } = req.body;
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const agentId = req.headers['x-agent-id'] as string;
const conversationId = req.headers['x-conversation-id'] as string;
if (!tool) {
res.status(400).json({ error: 'tool name is required' });
return;
}
if (!tenantId) {
res.status(400).json({ error: 'x-tenant-id header is required' });
return;
}
const context: McpContext = {
tenantId,
userId,
agentId,
conversationId,
callerType: (req.headers['x-caller-type'] as CallerType) || 'api',
permissions: this.extractPermissions(req),
};
const result = await this.mcpService.callTool(tool, parameters || {}, context);
res.json({ data: result });
} catch (error) {
next(error);
}
}
// ============================================
// RESOURCES
// ============================================
private async listResources(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const resources = this.mcpService.listResources();
res.json({ data: resources, total: resources.length });
} catch (error) {
next(error);
}
}
private async getResource(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const uri = 'erp://' + req.params[0];
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'x-tenant-id header is required' });
return;
}
const context: McpContext = {
tenantId,
userId,
callerType: 'api',
permissions: this.extractPermissions(req),
};
const content = await this.mcpService.getResource(uri, context);
res.json({ data: { uri, content } });
} catch (error: any) {
if (error.message.includes('not found')) {
res.status(404).json({ error: error.message });
return;
}
next(error);
}
}
// ============================================
// HISTORY / AUDIT
// ============================================
private async getToolCallHistory(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'x-tenant-id header is required' });
return;
}
const filters = {
toolName: req.query.toolName as string,
status: req.query.status as any,
startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined,
endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined,
page: parseInt(req.query.page as string) || 1,
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
};
const result = await this.mcpService.getCallHistory(tenantId, filters);
res.json(result);
} catch (error) {
next(error);
}
}
private async getToolCallDetails(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'x-tenant-id header is required' });
return;
}
const call = await this.mcpService.getCallDetails(id, tenantId);
if (!call) {
res.status(404).json({ error: 'Tool call not found' });
return;
}
res.json({ data: call });
} catch (error) {
next(error);
}
}
private async getToolStats(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'x-tenant-id header is required' });
return;
}
const startDate = req.query.startDate
? new Date(req.query.startDate as string)
: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); // 7 days ago
const endDate = req.query.endDate
? new Date(req.query.endDate as string)
: new Date();
const stats = await this.mcpService.getToolStats(tenantId, startDate, endDate);
res.json({ data: stats, total: stats.length });
} catch (error) {
next(error);
}
}
// ============================================
// HELPERS
// ============================================
private extractPermissions(req: Request): string[] {
const permHeader = req.headers['x-permissions'] as string;
if (!permHeader) return [];
try {
return JSON.parse(permHeader);
} catch {
return permHeader.split(',').map((p) => p.trim());
}
}
}

View File

@ -0,0 +1 @@
export * from './mcp.dto';

View File

@ -0,0 +1,66 @@
// =====================================================
// DTOs: MCP Server
// Modulo: MGN-022
// Version: 1.0.0
// =====================================================
import { ToolCallStatus } from '../entities';
import { CallerType } from '../interfaces';
// ============================================
// Tool Call DTOs
// ============================================
export interface CallToolDto {
tool: string;
parameters?: Record<string, any>;
}
export interface ToolCallResultDto {
success: boolean;
toolName: string;
result?: any;
error?: string;
callId: string;
}
export interface StartCallData {
tenantId: string;
toolName: string;
parameters: Record<string, any>;
agentId?: string;
conversationId?: string;
callerType: CallerType;
userId?: string;
}
// ============================================
// History & Filters DTOs
// ============================================
export interface CallHistoryFilters {
toolName?: string;
status?: ToolCallStatus;
startDate?: Date;
endDate?: Date;
page: number;
limit: number;
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
}
// ============================================
// Resource DTOs
// ============================================
export interface ResourceContentDto {
uri: string;
name: string;
mimeType: string;
content: any;
}

View File

@ -0,0 +1,2 @@
export { ToolCall, ToolCallStatus } from './tool-call.entity';
export { ToolCallResult, ResultType } from './tool-call-result.entity';

View File

@ -0,0 +1,45 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
OneToOne,
JoinColumn,
Index,
} from 'typeorm';
import { ToolCall } from './tool-call.entity';
export type ResultType = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null' | 'error';
@Entity({ name: 'tool_call_results', schema: 'ai' })
export class ToolCallResult {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tool_call_id', type: 'uuid' })
toolCallId: string;
@Column({ type: 'jsonb', nullable: true })
result: any;
@Column({ name: 'result_type', type: 'varchar', length: 20, default: 'object' })
resultType: ResultType;
@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage: string;
@Index()
@Column({ name: 'error_code', type: 'varchar', length: 50, nullable: true })
errorCode: string;
@Column({ name: 'tokens_used', type: 'int', nullable: true })
tokensUsed: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@OneToOne(() => ToolCall, (call) => call.result, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tool_call_id' })
toolCall: ToolCall;
}

View File

@ -0,0 +1,65 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
OneToOne,
} from 'typeorm';
import { ToolCallResult } from './tool-call-result.entity';
import { CallerType } from '../interfaces';
export type ToolCallStatus = 'pending' | 'running' | 'success' | 'error' | 'timeout';
@Entity({ name: 'tool_calls', schema: 'ai' })
export class ToolCall {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'agent_id', type: 'uuid', nullable: true })
agentId: string;
@Index()
@Column({ name: 'conversation_id', type: 'uuid', nullable: true })
conversationId: string;
@Index()
@Column({ name: 'tool_name', type: 'varchar', length: 100 })
toolName: string;
@Column({ type: 'jsonb', default: {} })
parameters: Record<string, any>;
@Index()
@Column({ type: 'varchar', length: 20, default: 'pending' })
status: ToolCallStatus;
@Column({ name: 'duration_ms', type: 'int', nullable: true })
durationMs: number;
@Column({ name: 'started_at', type: 'timestamptz' })
startedAt: Date;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt: Date;
@Column({ name: 'called_by_user_id', type: 'uuid', nullable: true })
calledByUserId: string;
@Column({ name: 'caller_type', type: 'varchar', length: 20, default: 'agent' })
callerType: CallerType;
@Column({ name: 'caller_context', type: 'varchar', length: 100, nullable: true })
callerContext: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@OneToOne(() => ToolCallResult, (result) => result.toolCall)
result: ToolCallResult;
}

7
src/modules/mcp/index.ts Normal file
View File

@ -0,0 +1,7 @@
export { McpModule, McpModuleOptions } from './mcp.module';
export { McpServerService, ToolRegistryService, ToolLoggerService } from './services';
export { McpController } from './controllers';
export { ToolCall, ToolCallResult, ToolCallStatus, ResultType } from './entities';
export * from './interfaces';
export * from './dto';
export * from './tools';

View File

@ -0,0 +1,3 @@
export * from './mcp-tool.interface';
export * from './mcp-context.interface';
export * from './mcp-resource.interface';

View File

@ -0,0 +1,17 @@
// =====================================================
// Interfaces: MCP Context
// Modulo: MGN-022
// Version: 1.0.0
// =====================================================
export type CallerType = 'agent' | 'api' | 'webhook' | 'system' | 'test';
export interface McpContext {
tenantId: string;
userId?: string;
agentId?: string;
conversationId?: string;
callerType: CallerType;
permissions: string[];
metadata?: Record<string, any>;
}

View File

@ -0,0 +1,18 @@
// =====================================================
// Interfaces: MCP Resource
// Modulo: MGN-022
// Version: 1.0.0
// =====================================================
import { McpContext } from './mcp-context.interface';
export interface McpResource {
uri: string;
name: string;
description: string;
mimeType: string;
}
export interface McpResourceWithHandler extends McpResource {
handler: (context: McpContext) => Promise<any>;
}

View File

@ -0,0 +1,62 @@
// =====================================================
// Interfaces: MCP Tool
// Modulo: MGN-022
// Version: 1.0.0
// =====================================================
import { McpContext } from './mcp-context.interface';
export interface JSONSchema {
type: string;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
items?: JSONSchemaProperty;
description?: string;
}
export interface JSONSchemaProperty {
type: string;
description?: string;
format?: string;
enum?: string[];
minimum?: number;
maximum?: number;
default?: any;
items?: JSONSchemaProperty;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
}
export interface RateLimitConfig {
maxCalls: number;
windowMs: number;
perTenant?: boolean;
}
export type ToolCategory =
| 'products'
| 'inventory'
| 'orders'
| 'customers'
| 'fiados'
| 'system';
export interface McpToolDefinition {
name: string;
description: string;
parameters: JSONSchema;
returns: JSONSchema;
category: ToolCategory;
permissions?: string[];
rateLimit?: RateLimitConfig;
}
export type McpToolHandler<TParams = any, TResult = any> = (
params: TParams,
context: McpContext
) => Promise<TResult>;
export interface McpToolProvider {
getTools(): McpToolDefinition[];
getHandler(toolName: string): McpToolHandler | undefined;
}

View File

@ -0,0 +1,70 @@
import { Router } from 'express';
import { DataSource } from 'typeorm';
import { McpServerService, ToolRegistryService, ToolLoggerService } from './services';
import { McpController } from './controllers';
import { ToolCall, ToolCallResult } from './entities';
import {
ProductsToolsService,
InventoryToolsService,
OrdersToolsService,
CustomersToolsService,
FiadosToolsService,
SalesToolsService,
FinancialToolsService,
BranchToolsService,
} from './tools';
export interface McpModuleOptions {
dataSource: DataSource;
basePath?: string;
}
export class McpModule {
public router: Router;
public mcpService: McpServerService;
public toolRegistry: ToolRegistryService;
private dataSource: DataSource;
private basePath: string;
constructor(options: McpModuleOptions) {
this.dataSource = options.dataSource;
this.basePath = options.basePath || '';
this.router = Router();
this.initializeServices();
this.initializeRoutes();
}
private initializeServices(): void {
// Repositories
const toolCallRepository = this.dataSource.getRepository(ToolCall);
const toolCallResultRepository = this.dataSource.getRepository(ToolCallResult);
// Tool Logger
const toolLogger = new ToolLoggerService(toolCallRepository, toolCallResultRepository);
// Tool Registry
this.toolRegistry = new ToolRegistryService();
// Register tool providers
this.toolRegistry.registerProvider(new ProductsToolsService());
this.toolRegistry.registerProvider(new InventoryToolsService());
this.toolRegistry.registerProvider(new OrdersToolsService());
this.toolRegistry.registerProvider(new CustomersToolsService());
this.toolRegistry.registerProvider(new FiadosToolsService());
this.toolRegistry.registerProvider(new SalesToolsService());
this.toolRegistry.registerProvider(new FinancialToolsService());
this.toolRegistry.registerProvider(new BranchToolsService());
// MCP Server Service
this.mcpService = new McpServerService(this.toolRegistry, toolLogger);
}
private initializeRoutes(): void {
const mcpController = new McpController(this.mcpService);
this.router.use(`${this.basePath}/mcp`, mcpController.router);
}
static getEntities(): Function[] {
return [ToolCall, ToolCallResult];
}
}

View File

@ -0,0 +1,3 @@
export { McpServerService } from './mcp-server.service';
export { ToolRegistryService } from './tool-registry.service';
export { ToolLoggerService } from './tool-logger.service';

View File

@ -0,0 +1,197 @@
import { ToolRegistryService } from './tool-registry.service';
import { ToolLoggerService } from './tool-logger.service';
import {
McpToolDefinition,
McpContext,
McpResource,
McpResourceWithHandler,
} from '../interfaces';
import { ToolCallResultDto, CallHistoryFilters, PaginatedResult } from '../dto';
import { ToolCall } from '../entities';
export class McpServerService {
private resources: Map<string, McpResourceWithHandler> = new Map();
constructor(
private readonly toolRegistry: ToolRegistryService,
private readonly toolLogger: ToolLoggerService
) {
this.initializeResources();
}
// ============================================
// TOOLS
// ============================================
listTools(): McpToolDefinition[] {
return this.toolRegistry.getAllTools();
}
getTool(name: string): McpToolDefinition | null {
return this.toolRegistry.getTool(name);
}
async callTool(
toolName: string,
params: Record<string, any>,
context: McpContext
): Promise<ToolCallResultDto> {
// 1. Get tool definition
const tool = this.toolRegistry.getTool(toolName);
if (!tool) {
return {
success: false,
toolName,
error: `Tool '${toolName}' not found`,
callId: '',
};
}
// 2. Check permissions
if (tool.permissions && tool.permissions.length > 0) {
const hasPermission = tool.permissions.some((p) =>
context.permissions.includes(p)
);
if (!hasPermission) {
return {
success: false,
toolName,
error: `Missing permissions for tool '${toolName}'`,
callId: '',
};
}
}
// 3. Start logging
const callId = await this.toolLogger.startCall({
tenantId: context.tenantId,
toolName,
parameters: params,
agentId: context.agentId,
conversationId: context.conversationId,
callerType: context.callerType,
userId: context.userId,
});
try {
// 4. Get and execute handler
const handler = this.toolRegistry.getHandler(toolName);
if (!handler) {
await this.toolLogger.failCall(callId, 'Handler not found', 'HANDLER_NOT_FOUND');
return {
success: false,
toolName,
error: `Handler for tool '${toolName}' not found`,
callId,
};
}
const result = await handler(params, context);
// 5. Log success
await this.toolLogger.completeCall(callId, result);
return {
success: true,
toolName,
result,
callId,
};
} catch (error: any) {
// 6. Log error
await this.toolLogger.failCall(
callId,
error.message || 'Execution error',
error.code || 'EXECUTION_ERROR'
);
return {
success: false,
toolName,
error: error.message || 'Tool execution failed',
callId,
};
}
}
// ============================================
// RESOURCES
// ============================================
listResources(): McpResource[] {
return Array.from(this.resources.values()).map(({ handler, ...resource }) => resource);
}
async getResource(uri: string, context: McpContext): Promise<any> {
const resource = this.resources.get(uri);
if (!resource) {
throw new Error(`Resource '${uri}' not found`);
}
return resource.handler(context);
}
private initializeResources(): void {
// Business config resource
this.resources.set('erp://config/business', {
uri: 'erp://config/business',
name: 'Business Configuration',
description: 'Basic business information and settings',
mimeType: 'application/json',
handler: async (context) => ({
tenantId: context.tenantId,
message: 'Business configuration - connect to tenant config service',
// TODO: Connect to actual tenant config service
}),
});
// Categories catalog resource
this.resources.set('erp://catalog/categories', {
uri: 'erp://catalog/categories',
name: 'Product Categories',
description: 'List of product categories',
mimeType: 'application/json',
handler: async (context) => ({
tenantId: context.tenantId,
categories: [],
message: 'Categories catalog - connect to products service',
// TODO: Connect to actual products service
}),
});
// Inventory summary resource
this.resources.set('erp://inventory/summary', {
uri: 'erp://inventory/summary',
name: 'Inventory Summary',
description: 'Summary of current inventory status',
mimeType: 'application/json',
handler: async (context) => ({
tenantId: context.tenantId,
totalProducts: 0,
totalValue: 0,
lowStockCount: 0,
message: 'Inventory summary - connect to inventory service',
// TODO: Connect to actual inventory service
}),
});
}
// ============================================
// HISTORY / AUDIT
// ============================================
async getCallHistory(
tenantId: string,
filters: CallHistoryFilters
): Promise<PaginatedResult<ToolCall>> {
return this.toolLogger.getCallHistory(tenantId, filters);
}
async getCallDetails(id: string, tenantId: string): Promise<ToolCall | null> {
return this.toolLogger.getCallById(id, tenantId);
}
async getToolStats(tenantId: string, startDate: Date, endDate: Date) {
return this.toolLogger.getToolStats(tenantId, startDate, endDate);
}
}

View File

@ -0,0 +1,171 @@
import { Repository } from 'typeorm';
import { ToolCall, ToolCallResult, ResultType } from '../entities';
import { StartCallData, CallHistoryFilters, PaginatedResult } from '../dto';
export class ToolLoggerService {
constructor(
private readonly toolCallRepo: Repository<ToolCall>,
private readonly resultRepo: Repository<ToolCallResult>
) {}
async startCall(data: StartCallData): Promise<string> {
const call = this.toolCallRepo.create({
tenantId: data.tenantId,
toolName: data.toolName,
parameters: data.parameters,
agentId: data.agentId,
conversationId: data.conversationId,
callerType: data.callerType,
calledByUserId: data.userId,
status: 'running',
startedAt: new Date(),
});
const saved = await this.toolCallRepo.save(call);
return saved.id;
}
async completeCall(callId: string, result: any): Promise<void> {
const call = await this.toolCallRepo.findOne({ where: { id: callId } });
if (!call) return;
const duration = Date.now() - call.startedAt.getTime();
await this.toolCallRepo.update(callId, {
status: 'success',
completedAt: new Date(),
durationMs: duration,
});
await this.resultRepo.save({
toolCallId: callId,
result,
resultType: this.getResultType(result),
});
}
async failCall(callId: string, errorMessage: string, errorCode: string): Promise<void> {
const call = await this.toolCallRepo.findOne({ where: { id: callId } });
if (!call) return;
const duration = Date.now() - call.startedAt.getTime();
await this.toolCallRepo.update(callId, {
status: 'error',
completedAt: new Date(),
durationMs: duration,
});
await this.resultRepo.save({
toolCallId: callId,
resultType: 'error',
errorMessage,
errorCode,
});
}
async timeoutCall(callId: string): Promise<void> {
const call = await this.toolCallRepo.findOne({ where: { id: callId } });
if (!call) return;
const duration = Date.now() - call.startedAt.getTime();
await this.toolCallRepo.update(callId, {
status: 'timeout',
completedAt: new Date(),
durationMs: duration,
});
await this.resultRepo.save({
toolCallId: callId,
resultType: 'error',
errorMessage: 'Tool execution timed out',
errorCode: 'TIMEOUT',
});
}
async getCallHistory(
tenantId: string,
filters: CallHistoryFilters
): Promise<PaginatedResult<ToolCall>> {
const qb = this.toolCallRepo
.createQueryBuilder('tc')
.leftJoinAndSelect('tc.result', 'result')
.where('tc.tenant_id = :tenantId', { tenantId });
if (filters.toolName) {
qb.andWhere('tc.tool_name = :toolName', { toolName: filters.toolName });
}
if (filters.status) {
qb.andWhere('tc.status = :status', { status: filters.status });
}
if (filters.startDate) {
qb.andWhere('tc.created_at >= :startDate', { startDate: filters.startDate });
}
if (filters.endDate) {
qb.andWhere('tc.created_at <= :endDate', { endDate: filters.endDate });
}
qb.orderBy('tc.created_at', 'DESC');
qb.skip((filters.page - 1) * filters.limit);
qb.take(filters.limit);
const [data, total] = await qb.getManyAndCount();
return { data, total, page: filters.page, limit: filters.limit };
}
async getCallById(id: string, tenantId: string): Promise<ToolCall | null> {
return this.toolCallRepo.findOne({
where: { id, tenantId },
relations: ['result'],
});
}
async getToolStats(
tenantId: string,
startDate: Date,
endDate: Date
): Promise<{
toolName: string;
totalCalls: number;
successfulCalls: number;
failedCalls: number;
avgDurationMs: number;
}[]> {
const result = await this.toolCallRepo
.createQueryBuilder('tc')
.select('tc.tool_name', 'toolName')
.addSelect('COUNT(*)', 'totalCalls')
.addSelect("COUNT(*) FILTER (WHERE tc.status = 'success')", 'successfulCalls')
.addSelect("COUNT(*) FILTER (WHERE tc.status = 'error')", 'failedCalls')
.addSelect('AVG(tc.duration_ms)', 'avgDurationMs')
.where('tc.tenant_id = :tenantId', { tenantId })
.andWhere('tc.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.groupBy('tc.tool_name')
.orderBy('totalCalls', 'DESC')
.getRawMany();
return result.map((r) => ({
toolName: r.toolName,
totalCalls: parseInt(r.totalCalls) || 0,
successfulCalls: parseInt(r.successfulCalls) || 0,
failedCalls: parseInt(r.failedCalls) || 0,
avgDurationMs: parseFloat(r.avgDurationMs) || 0,
}));
}
private getResultType(result: any): ResultType {
if (result === null) return 'null';
if (Array.isArray(result)) return 'array';
const type = typeof result;
if (type === 'object') return 'object';
if (type === 'string') return 'string';
if (type === 'number') return 'number';
if (type === 'boolean') return 'boolean';
return 'object';
}
}

View File

@ -0,0 +1,53 @@
import {
McpToolDefinition,
McpToolHandler,
McpToolProvider,
ToolCategory,
} from '../interfaces';
export class ToolRegistryService {
private tools: Map<string, McpToolDefinition> = new Map();
private handlers: Map<string, McpToolHandler> = new Map();
private providers: McpToolProvider[] = [];
registerProvider(provider: McpToolProvider): void {
this.providers.push(provider);
const tools = provider.getTools();
for (const tool of tools) {
this.tools.set(tool.name, tool);
const handler = provider.getHandler(tool.name);
if (handler) {
this.handlers.set(tool.name, handler);
}
}
}
getAllTools(): McpToolDefinition[] {
return Array.from(this.tools.values());
}
getTool(name: string): McpToolDefinition | null {
return this.tools.get(name) || null;
}
getHandler(name: string): McpToolHandler | null {
return this.handlers.get(name) || null;
}
getToolsByCategory(category: ToolCategory): McpToolDefinition[] {
return Array.from(this.tools.values()).filter((t) => t.category === category);
}
hasTool(name: string): boolean {
return this.tools.has(name);
}
getCategories(): ToolCategory[] {
const categories = new Set<ToolCategory>();
for (const tool of this.tools.values()) {
categories.add(tool.category);
}
return Array.from(categories);
}
}

View File

@ -0,0 +1,292 @@
import {
McpToolProvider,
McpToolDefinition,
McpToolHandler,
McpContext,
} from '../interfaces';
/**
* Branch Tools Service
* Provides MCP tools for branch management and team operations.
* Used by: ADMIN, SUPERVISOR roles
*/
export class BranchToolsService implements McpToolProvider {
getTools(): McpToolDefinition[] {
return [
{
name: 'get_branch_info',
description: 'Obtiene informacion de una sucursal',
category: 'branches',
parameters: {
type: 'object',
properties: {
branch_id: {
type: 'string',
format: 'uuid',
description: 'ID de la sucursal (usa la actual si no se especifica)',
},
},
},
returns: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
address: { type: 'string' },
phone: { type: 'string' },
operating_hours: { type: 'object' },
},
},
},
{
name: 'get_branch_report',
description: 'Genera reporte de desempeno de sucursal',
category: 'branches',
parameters: {
type: 'object',
properties: {
branch_id: { type: 'string', format: 'uuid' },
period: {
type: 'string',
enum: ['today', 'week', 'month'],
default: 'today',
},
},
},
returns: { type: 'object' },
},
{
name: 'get_team_performance',
description: 'Obtiene metricas de desempeno del equipo',
category: 'branches',
parameters: {
type: 'object',
properties: {
branch_id: { type: 'string', format: 'uuid' },
period: {
type: 'string',
enum: ['today', 'week', 'month'],
default: 'today',
},
},
},
returns: {
type: 'object',
properties: {
team_size: { type: 'number' },
members: { type: 'array' },
},
},
},
{
name: 'get_employee_schedule',
description: 'Obtiene horarios de empleados',
category: 'branches',
parameters: {
type: 'object',
properties: {
branch_id: { type: 'string', format: 'uuid' },
date: { type: 'string', format: 'date' },
employee_id: { type: 'string', format: 'uuid' },
},
},
returns: { type: 'array', items: { type: 'object' } },
},
{
name: 'get_branch_hours',
description: 'Obtiene horarios de atencion de la sucursal',
category: 'branches',
parameters: {
type: 'object',
properties: {
branch_id: { type: 'string', format: 'uuid' },
},
},
returns: {
type: 'object',
properties: {
regular: { type: 'object' },
holidays: { type: 'array' },
},
},
},
{
name: 'get_promotions',
description: 'Obtiene promociones activas',
category: 'branches',
parameters: {
type: 'object',
properties: {
branch_id: { type: 'string', format: 'uuid' },
active_only: { type: 'boolean', default: true },
},
},
returns: { type: 'array', items: { type: 'object' } },
},
];
}
getHandler(toolName: string): McpToolHandler | undefined {
const handlers: Record<string, McpToolHandler> = {
get_branch_info: this.getBranchInfo.bind(this),
get_branch_report: this.getBranchReport.bind(this),
get_team_performance: this.getTeamPerformance.bind(this),
get_employee_schedule: this.getEmployeeSchedule.bind(this),
get_branch_hours: this.getBranchHours.bind(this),
get_promotions: this.getPromotions.bind(this),
};
return handlers[toolName];
}
private async getBranchInfo(
params: { branch_id?: string },
context: McpContext
): Promise<any> {
// TODO: Connect to BranchesService
const branchId = params.branch_id || context.branchId;
return {
id: branchId,
name: 'Sucursal Centro',
code: 'SUC-001',
type: 'store',
address: {
street: 'Av. Principal 123',
city: 'Ciudad de Mexico',
state: 'CDMX',
postal_code: '06600',
},
phone: '+52 55 1234 5678',
email: 'centro@erp.com',
manager: {
id: 'mgr-001',
name: 'Juan Perez',
},
operating_hours: {
monday: { open: '09:00', close: '20:00' },
tuesday: { open: '09:00', close: '20:00' },
wednesday: { open: '09:00', close: '20:00' },
thursday: { open: '09:00', close: '20:00' },
friday: { open: '09:00', close: '20:00' },
saturday: { open: '10:00', close: '18:00' },
sunday: { open: 'closed', close: 'closed' },
},
message: 'Conectar a BranchesService real',
};
}
private async getBranchReport(
params: { branch_id?: string; period?: string },
context: McpContext
): Promise<any> {
// TODO: Connect to ReportsService
return {
branch_id: params.branch_id || context.branchId,
period: params.period || 'today',
sales: {
total: 15750.00,
count: 42,
avg_ticket: 375.00,
},
inventory: {
value: 250000.00,
low_stock_items: 5,
out_of_stock: 2,
},
staff: {
present: 8,
absent: 1,
late: 0,
},
goals: {
sales_target: 20000.00,
progress: 78.75,
},
message: 'Conectar a ReportsService real',
};
}
private async getTeamPerformance(
params: { branch_id?: string; period?: string },
context: McpContext
): Promise<any> {
// TODO: Connect to HRService + SalesService
return {
branch_id: params.branch_id || context.branchId,
period: params.period || 'today',
team_size: 8,
members: [
{ name: 'Ana Garcia', role: 'Vendedor', sales: 4500.00, transactions: 12, avg_ticket: 375.00 },
{ name: 'Carlos Lopez', role: 'Vendedor', sales: 3800.00, transactions: 10, avg_ticket: 380.00 },
{ name: 'Maria Rodriguez', role: 'Cajero', sales: 2500.00, transactions: 8, avg_ticket: 312.50 },
],
top_performer: 'Ana Garcia',
message: 'Conectar a HRService real',
};
}
private async getEmployeeSchedule(
params: { branch_id?: string; date?: string; employee_id?: string },
context: McpContext
): Promise<any[]> {
// TODO: Connect to ScheduleService
const date = params.date || new Date().toISOString().split('T')[0];
return [
{ employee: 'Ana Garcia', shift: 'morning', start: '09:00', end: '15:00', status: 'confirmed' },
{ employee: 'Carlos Lopez', shift: 'afternoon', start: '15:00', end: '21:00', status: 'confirmed' },
{ employee: 'Maria Rodriguez', shift: 'morning', start: '09:00', end: '15:00', status: 'confirmed' },
];
}
private async getBranchHours(
params: { branch_id?: string },
context: McpContext
): Promise<any> {
// TODO: Connect to BranchesService
return {
regular: {
monday: { open: '09:00', close: '20:00' },
tuesday: { open: '09:00', close: '20:00' },
wednesday: { open: '09:00', close: '20:00' },
thursday: { open: '09:00', close: '20:00' },
friday: { open: '09:00', close: '20:00' },
saturday: { open: '10:00', close: '18:00' },
sunday: 'Cerrado',
},
holidays: [
{ date: '2026-01-01', name: 'Año Nuevo', status: 'closed' },
{ date: '2026-02-03', name: 'Dia de la Constitucion', status: 'closed' },
],
next_closed: '2026-02-03',
message: 'Conectar a BranchesService real',
};
}
private async getPromotions(
params: { branch_id?: string; active_only?: boolean },
context: McpContext
): Promise<any[]> {
// TODO: Connect to PromotionsService
return [
{
id: 'promo-001',
name: '2x1 en productos seleccionados',
type: 'bogo',
discount: 50,
start_date: '2026-01-20',
end_date: '2026-01-31',
status: 'active',
applicable_products: ['category:electronics'],
},
{
id: 'promo-002',
name: '10% descuento con tarjeta',
type: 'percentage',
discount: 10,
start_date: '2026-01-01',
end_date: '2026-03-31',
status: 'active',
conditions: ['payment_method:card'],
},
];
}
}

View File

@ -0,0 +1,94 @@
import {
McpToolProvider,
McpToolDefinition,
McpToolHandler,
McpContext,
} from '../interfaces';
/**
* Customers Tools Service
* Provides MCP tools for customer management.
*
* TODO: Connect to actual CustomersService when available.
*/
export class CustomersToolsService implements McpToolProvider {
getTools(): McpToolDefinition[] {
return [
{
name: 'search_customers',
description: 'Busca clientes por nombre, telefono o email',
category: 'customers',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Texto de busqueda' },
limit: { type: 'number', description: 'Limite de resultados', default: 10 },
},
required: ['query'],
},
returns: { type: 'array' },
},
{
name: 'get_customer_balance',
description: 'Obtiene el saldo actual de un cliente',
category: 'customers',
parameters: {
type: 'object',
properties: {
customer_id: { type: 'string', format: 'uuid' },
},
required: ['customer_id'],
},
returns: {
type: 'object',
properties: {
balance: { type: 'number' },
credit_limit: { type: 'number' },
},
},
},
];
}
getHandler(toolName: string): McpToolHandler | undefined {
const handlers: Record<string, McpToolHandler> = {
search_customers: this.searchCustomers.bind(this),
get_customer_balance: this.getCustomerBalance.bind(this),
};
return handlers[toolName];
}
private async searchCustomers(
params: { query: string; limit?: number },
context: McpContext
): Promise<any[]> {
// TODO: Connect to actual customers service
return [
{
id: 'customer-1',
name: 'Juan Perez',
phone: '+52 55 1234 5678',
email: 'juan@example.com',
balance: 500.00,
credit_limit: 5000.00,
message: 'Conectar a CustomersService real',
},
];
}
private async getCustomerBalance(
params: { customer_id: string },
context: McpContext
): Promise<any> {
// TODO: Connect to actual customers service
return {
customer_id: params.customer_id,
customer_name: 'Cliente ejemplo',
balance: 500.00,
credit_limit: 5000.00,
available_credit: 4500.00,
last_purchase: new Date().toISOString(),
message: 'Conectar a CustomersService real',
};
}
}

View File

@ -0,0 +1,216 @@
import {
McpToolProvider,
McpToolDefinition,
McpToolHandler,
McpContext,
} from '../interfaces';
/**
* Fiados (Credit) Tools Service
* Provides MCP tools for credit/fiado management.
*
* TODO: Connect to actual FiadosService when available.
*/
export class FiadosToolsService implements McpToolProvider {
getTools(): McpToolDefinition[] {
return [
{
name: 'get_fiado_balance',
description: 'Consulta el saldo de credito de un cliente',
category: 'fiados',
parameters: {
type: 'object',
properties: {
customer_id: { type: 'string', format: 'uuid' },
},
required: ['customer_id'],
},
returns: { type: 'object' },
},
{
name: 'create_fiado',
description: 'Registra una venta a credito (fiado)',
category: 'fiados',
permissions: ['fiados.create'],
parameters: {
type: 'object',
properties: {
customer_id: { type: 'string', format: 'uuid' },
amount: { type: 'number', minimum: 0.01 },
order_id: { type: 'string', format: 'uuid' },
description: { type: 'string' },
},
required: ['customer_id', 'amount'],
},
returns: { type: 'object' },
},
{
name: 'register_fiado_payment',
description: 'Registra un abono a la cuenta de credito',
category: 'fiados',
permissions: ['fiados.payment'],
parameters: {
type: 'object',
properties: {
customer_id: { type: 'string', format: 'uuid' },
amount: { type: 'number', minimum: 0.01 },
payment_method: { type: 'string', enum: ['cash', 'card', 'transfer'] },
},
required: ['customer_id', 'amount'],
},
returns: { type: 'object' },
},
{
name: 'check_fiado_eligibility',
description: 'Verifica si un cliente puede comprar a credito',
category: 'fiados',
parameters: {
type: 'object',
properties: {
customer_id: { type: 'string', format: 'uuid' },
amount: { type: 'number', minimum: 0.01 },
},
required: ['customer_id', 'amount'],
},
returns: {
type: 'object',
properties: {
eligible: { type: 'boolean' },
reason: { type: 'string' },
},
},
},
];
}
getHandler(toolName: string): McpToolHandler | undefined {
const handlers: Record<string, McpToolHandler> = {
get_fiado_balance: this.getFiadoBalance.bind(this),
create_fiado: this.createFiado.bind(this),
register_fiado_payment: this.registerFiadoPayment.bind(this),
check_fiado_eligibility: this.checkFiadoEligibility.bind(this),
};
return handlers[toolName];
}
private async getFiadoBalance(
params: { customer_id: string },
context: McpContext
): Promise<any> {
// TODO: Connect to actual fiados service
return {
customer_id: params.customer_id,
customer_name: 'Cliente ejemplo',
balance: 1500.00,
credit_limit: 5000.00,
available_credit: 3500.00,
pending_fiados: [
{ id: 'fiado-1', amount: 500.00, date: '2026-01-10', status: 'pending' },
{ id: 'fiado-2', amount: 1000.00, date: '2026-01-05', status: 'pending' },
],
recent_payments: [],
message: 'Conectar a FiadosService real',
};
}
private async createFiado(
params: { customer_id: string; amount: number; order_id?: string; description?: string },
context: McpContext
): Promise<any> {
// TODO: Connect to actual fiados service
// First check eligibility
const eligibility = await this.checkFiadoEligibility(
{ customer_id: params.customer_id, amount: params.amount },
context
);
if (!eligibility.eligible) {
throw new Error(eligibility.reason);
}
return {
fiado_id: 'fiado-' + Date.now(),
customer_id: params.customer_id,
amount: params.amount,
order_id: params.order_id,
description: params.description,
due_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days
new_balance: 1500.00 + params.amount,
remaining_credit: 3500.00 - params.amount,
created_by: context.userId,
created_at: new Date().toISOString(),
message: 'Conectar a FiadosService real',
};
}
private async registerFiadoPayment(
params: { customer_id: string; amount: number; payment_method?: string },
context: McpContext
): Promise<any> {
// TODO: Connect to actual fiados service
return {
payment_id: 'payment-' + Date.now(),
customer_id: params.customer_id,
amount: params.amount,
payment_method: params.payment_method || 'cash',
previous_balance: 1500.00,
new_balance: 1500.00 - params.amount,
fiados_paid: [],
created_by: context.userId,
created_at: new Date().toISOString(),
message: 'Conectar a FiadosService real',
};
}
private async checkFiadoEligibility(
params: { customer_id: string; amount: number },
context: McpContext
): Promise<any> {
// TODO: Connect to actual fiados service
const mockBalance = 1500.00;
const mockCreditLimit = 5000.00;
const mockAvailableCredit = mockCreditLimit - mockBalance;
const hasOverdue = false;
if (hasOverdue) {
return {
eligible: false,
reason: 'Cliente tiene saldo vencido',
current_balance: mockBalance,
credit_limit: mockCreditLimit,
available_credit: mockAvailableCredit,
requested_amount: params.amount,
has_overdue: true,
suggestions: ['Solicitar pago del saldo vencido antes de continuar'],
};
}
if (params.amount > mockAvailableCredit) {
return {
eligible: false,
reason: 'Monto excede credito disponible',
current_balance: mockBalance,
credit_limit: mockCreditLimit,
available_credit: mockAvailableCredit,
requested_amount: params.amount,
has_overdue: false,
suggestions: [
`Reducir el monto a $${mockAvailableCredit.toFixed(2)}`,
'Solicitar aumento de limite de credito',
],
};
}
return {
eligible: true,
reason: 'Cliente con credito disponible',
current_balance: mockBalance,
credit_limit: mockCreditLimit,
available_credit: mockAvailableCredit,
requested_amount: params.amount,
has_overdue: false,
suggestions: [],
message: 'Conectar a FiadosService real',
};
}
}

View File

@ -0,0 +1,291 @@
import {
McpToolProvider,
McpToolDefinition,
McpToolHandler,
McpContext,
} from '../interfaces';
/**
* Financial Tools Service
* Provides MCP tools for financial reporting and analysis.
* Used by: ADMIN role only
*/
export class FinancialToolsService implements McpToolProvider {
getTools(): McpToolDefinition[] {
return [
{
name: 'get_financial_report',
description: 'Genera reporte financiero (ingresos, gastos, utilidad)',
category: 'financial',
parameters: {
type: 'object',
properties: {
type: {
type: 'string',
enum: ['income', 'expenses', 'profit', 'summary'],
description: 'Tipo de reporte',
default: 'summary',
},
start_date: {
type: 'string',
format: 'date',
description: 'Fecha inicial',
},
end_date: {
type: 'string',
format: 'date',
description: 'Fecha final',
},
branch_id: {
type: 'string',
format: 'uuid',
description: 'Filtrar por sucursal',
},
},
required: ['type'],
},
returns: { type: 'object' },
},
{
name: 'get_accounts_receivable',
description: 'Obtiene cuentas por cobrar (clientes que deben)',
category: 'financial',
parameters: {
type: 'object',
properties: {
status: {
type: 'string',
enum: ['all', 'current', 'overdue'],
default: 'all',
},
min_amount: { type: 'number' },
limit: { type: 'number', default: 50 },
},
},
returns: {
type: 'object',
properties: {
total: { type: 'number' },
count: { type: 'number' },
accounts: { type: 'array' },
},
},
},
{
name: 'get_accounts_payable',
description: 'Obtiene cuentas por pagar (deudas a proveedores)',
category: 'financial',
parameters: {
type: 'object',
properties: {
status: {
type: 'string',
enum: ['all', 'current', 'overdue'],
default: 'all',
},
due_date_before: { type: 'string', format: 'date' },
limit: { type: 'number', default: 50 },
},
},
returns: {
type: 'object',
properties: {
total: { type: 'number' },
count: { type: 'number' },
accounts: { type: 'array' },
},
},
},
{
name: 'get_cash_flow',
description: 'Analiza flujo de caja (entradas y salidas)',
category: 'financial',
parameters: {
type: 'object',
properties: {
period: {
type: 'string',
enum: ['week', 'month', 'quarter'],
default: 'month',
},
branch_id: { type: 'string', format: 'uuid' },
},
},
returns: {
type: 'object',
properties: {
inflows: { type: 'number' },
outflows: { type: 'number' },
net_flow: { type: 'number' },
by_category: { type: 'array' },
},
},
},
{
name: 'get_kpis',
description: 'Obtiene indicadores clave de desempeno (KPIs)',
category: 'financial',
parameters: {
type: 'object',
properties: {
period: {
type: 'string',
enum: ['month', 'quarter', 'year'],
default: 'month',
},
},
},
returns: {
type: 'object',
properties: {
gross_margin: { type: 'number' },
net_margin: { type: 'number' },
inventory_turnover: { type: 'number' },
avg_collection_days: { type: 'number' },
},
},
},
];
}
getHandler(toolName: string): McpToolHandler | undefined {
const handlers: Record<string, McpToolHandler> = {
get_financial_report: this.getFinancialReport.bind(this),
get_accounts_receivable: this.getAccountsReceivable.bind(this),
get_accounts_payable: this.getAccountsPayable.bind(this),
get_cash_flow: this.getCashFlow.bind(this),
get_kpis: this.getKPIs.bind(this),
};
return handlers[toolName];
}
private async getFinancialReport(
params: { type: string; start_date?: string; end_date?: string; branch_id?: string },
context: McpContext
): Promise<any> {
// TODO: Connect to FinancialService
return {
type: params.type,
period: {
start: params.start_date || new Date().toISOString().split('T')[0],
end: params.end_date || new Date().toISOString().split('T')[0],
},
income: 125000.00,
expenses: 85000.00,
gross_profit: 40000.00,
net_profit: 32000.00,
breakdown: {
sales: 120000.00,
services: 5000.00,
cost_of_goods: 65000.00,
operating_expenses: 20000.00,
taxes: 8000.00,
},
message: 'Conectar a FinancialService real',
};
}
private async getAccountsReceivable(
params: { status?: string; min_amount?: number; limit?: number },
context: McpContext
): Promise<any> {
// TODO: Connect to AccountsService
return {
total: 45000.00,
count: 12,
overdue_total: 15000.00,
overdue_count: 4,
accounts: [
{
customer: 'Cliente A',
amount: 5000.00,
due_date: '2026-01-20',
days_overdue: 5,
status: 'overdue',
},
{
customer: 'Cliente B',
amount: 8000.00,
due_date: '2026-02-01',
days_overdue: 0,
status: 'current',
},
].slice(0, params.limit || 50),
message: 'Conectar a AccountsService real',
};
}
private async getAccountsPayable(
params: { status?: string; due_date_before?: string; limit?: number },
context: McpContext
): Promise<any> {
// TODO: Connect to AccountsService
return {
total: 32000.00,
count: 8,
overdue_total: 5000.00,
overdue_count: 2,
accounts: [
{
supplier: 'Proveedor X',
amount: 12000.00,
due_date: '2026-01-28',
status: 'current',
},
{
supplier: 'Proveedor Y',
amount: 5000.00,
due_date: '2026-01-15',
days_overdue: 10,
status: 'overdue',
},
].slice(0, params.limit || 50),
message: 'Conectar a AccountsService real',
};
}
private async getCashFlow(
params: { period?: string; branch_id?: string },
context: McpContext
): Promise<any> {
// TODO: Connect to FinancialService
return {
period: params.period || 'month',
inflows: 95000.00,
outflows: 72000.00,
net_flow: 23000.00,
opening_balance: 45000.00,
closing_balance: 68000.00,
by_category: [
{ category: 'Ventas', type: 'inflow', amount: 90000.00 },
{ category: 'Cobranzas', type: 'inflow', amount: 5000.00 },
{ category: 'Compras', type: 'outflow', amount: 55000.00 },
{ category: 'Nomina', type: 'outflow', amount: 12000.00 },
{ category: 'Gastos operativos', type: 'outflow', amount: 5000.00 },
],
message: 'Conectar a FinancialService real',
};
}
private async getKPIs(
params: { period?: string },
context: McpContext
): Promise<any> {
// TODO: Connect to AnalyticsService
return {
period: params.period || 'month',
gross_margin: 32.5,
net_margin: 18.2,
inventory_turnover: 4.5,
avg_collection_days: 28,
current_ratio: 1.8,
quick_ratio: 1.2,
return_on_assets: 12.5,
trends: {
gross_margin_change: 2.1,
net_margin_change: 1.5,
},
message: 'Conectar a AnalyticsService real',
};
}
}

View File

@ -0,0 +1,8 @@
export { ProductsToolsService } from './products-tools.service';
export { InventoryToolsService } from './inventory-tools.service';
export { OrdersToolsService } from './orders-tools.service';
export { CustomersToolsService } from './customers-tools.service';
export { FiadosToolsService } from './fiados-tools.service';
export { SalesToolsService } from './sales-tools.service';
export { FinancialToolsService } from './financial-tools.service';
export { BranchToolsService } from './branch-tools.service';

View File

@ -0,0 +1,154 @@
import {
McpToolProvider,
McpToolDefinition,
McpToolHandler,
McpContext,
} from '../interfaces';
/**
* Inventory Tools Service
* Provides MCP tools for inventory management.
*
* TODO: Connect to actual InventoryService when available.
*/
export class InventoryToolsService implements McpToolProvider {
getTools(): McpToolDefinition[] {
return [
{
name: 'check_stock',
description: 'Consulta el stock actual de productos',
category: 'inventory',
parameters: {
type: 'object',
properties: {
product_ids: { type: 'array', description: 'IDs de productos a consultar' },
warehouse_id: { type: 'string', description: 'ID del almacen' },
},
},
returns: { type: 'array' },
},
{
name: 'get_low_stock_products',
description: 'Lista productos que estan por debajo del minimo de stock',
category: 'inventory',
parameters: {
type: 'object',
properties: {
threshold: { type: 'number', description: 'Umbral de stock bajo' },
},
},
returns: { type: 'array' },
},
{
name: 'record_inventory_movement',
description: 'Registra un movimiento de inventario (entrada, salida, ajuste)',
category: 'inventory',
permissions: ['inventory.write'],
parameters: {
type: 'object',
properties: {
product_id: { type: 'string', format: 'uuid' },
quantity: { type: 'number' },
movement_type: { type: 'string', enum: ['in', 'out', 'adjustment'] },
reason: { type: 'string' },
},
required: ['product_id', 'quantity', 'movement_type'],
},
returns: { type: 'object' },
},
{
name: 'get_inventory_value',
description: 'Calcula el valor total del inventario',
category: 'inventory',
parameters: {
type: 'object',
properties: {
warehouse_id: { type: 'string', description: 'ID del almacen (opcional)' },
},
},
returns: {
type: 'object',
properties: {
total_value: { type: 'number' },
items_count: { type: 'number' },
},
},
},
];
}
getHandler(toolName: string): McpToolHandler | undefined {
const handlers: Record<string, McpToolHandler> = {
check_stock: this.checkStock.bind(this),
get_low_stock_products: this.getLowStockProducts.bind(this),
record_inventory_movement: this.recordInventoryMovement.bind(this),
get_inventory_value: this.getInventoryValue.bind(this),
};
return handlers[toolName];
}
private async checkStock(
params: { product_ids?: string[]; warehouse_id?: string },
context: McpContext
): Promise<any[]> {
// TODO: Connect to actual inventory service
return [
{
product_id: 'sample-1',
product_name: 'Producto ejemplo',
stock: 100,
warehouse_id: params.warehouse_id || 'default',
message: 'Conectar a InventoryService real',
},
];
}
private async getLowStockProducts(
params: { threshold?: number },
context: McpContext
): Promise<any[]> {
// TODO: Connect to actual inventory service
const threshold = params.threshold || 10;
return [
{
product_id: 'low-stock-1',
product_name: 'Producto bajo stock',
current_stock: 5,
min_stock: threshold,
shortage: threshold - 5,
message: 'Conectar a InventoryService real',
},
];
}
private async recordInventoryMovement(
params: { product_id: string; quantity: number; movement_type: string; reason?: string },
context: McpContext
): Promise<any> {
// TODO: Connect to actual inventory service
return {
movement_id: 'mov-' + Date.now(),
product_id: params.product_id,
quantity: params.quantity,
movement_type: params.movement_type,
reason: params.reason,
recorded_by: context.userId,
recorded_at: new Date().toISOString(),
message: 'Conectar a InventoryService real',
};
}
private async getInventoryValue(
params: { warehouse_id?: string },
context: McpContext
): Promise<any> {
// TODO: Connect to actual inventory service
return {
total_value: 150000.00,
items_count: 500,
warehouse_id: params.warehouse_id || 'all',
currency: 'MXN',
message: 'Conectar a InventoryService real',
};
}
}

View File

@ -0,0 +1,139 @@
import {
McpToolProvider,
McpToolDefinition,
McpToolHandler,
McpContext,
} from '../interfaces';
/**
* Orders Tools Service
* Provides MCP tools for order management.
*
* TODO: Connect to actual OrdersService when available.
*/
export class OrdersToolsService implements McpToolProvider {
getTools(): McpToolDefinition[] {
return [
{
name: 'create_order',
description: 'Crea un nuevo pedido',
category: 'orders',
permissions: ['orders.create'],
parameters: {
type: 'object',
properties: {
customer_id: { type: 'string', format: 'uuid', description: 'ID del cliente' },
items: {
type: 'array',
description: 'Items del pedido',
items: {
type: 'object',
properties: {
product_id: { type: 'string' },
quantity: { type: 'number' },
unit_price: { type: 'number' },
},
},
},
payment_method: { type: 'string', enum: ['cash', 'card', 'transfer', 'fiado'] },
notes: { type: 'string' },
},
required: ['customer_id', 'items'],
},
returns: { type: 'object' },
},
{
name: 'get_order_status',
description: 'Consulta el estado de un pedido',
category: 'orders',
parameters: {
type: 'object',
properties: {
order_id: { type: 'string', format: 'uuid' },
},
required: ['order_id'],
},
returns: { type: 'object' },
},
{
name: 'update_order_status',
description: 'Actualiza el estado de un pedido',
category: 'orders',
permissions: ['orders.update'],
parameters: {
type: 'object',
properties: {
order_id: { type: 'string', format: 'uuid' },
status: {
type: 'string',
enum: ['pending', 'confirmed', 'preparing', 'ready', 'delivered', 'cancelled'],
},
},
required: ['order_id', 'status'],
},
returns: { type: 'object' },
},
];
}
getHandler(toolName: string): McpToolHandler | undefined {
const handlers: Record<string, McpToolHandler> = {
create_order: this.createOrder.bind(this),
get_order_status: this.getOrderStatus.bind(this),
update_order_status: this.updateOrderStatus.bind(this),
};
return handlers[toolName];
}
private async createOrder(
params: { customer_id: string; items: any[]; payment_method?: string; notes?: string },
context: McpContext
): Promise<any> {
// TODO: Connect to actual orders service
const subtotal = params.items.reduce((sum, item) => sum + (item.quantity * (item.unit_price || 0)), 0);
return {
order_id: 'order-' + Date.now(),
customer_id: params.customer_id,
items: params.items,
subtotal,
tax: subtotal * 0.16,
total: subtotal * 1.16,
payment_method: params.payment_method || 'cash',
status: 'pending',
created_by: context.userId,
created_at: new Date().toISOString(),
message: 'Conectar a OrdersService real',
};
}
private async getOrderStatus(
params: { order_id: string },
context: McpContext
): Promise<any> {
// TODO: Connect to actual orders service
return {
order_id: params.order_id,
status: 'pending',
customer_name: 'Cliente ejemplo',
total: 1160.00,
items_count: 3,
created_at: new Date().toISOString(),
message: 'Conectar a OrdersService real',
};
}
private async updateOrderStatus(
params: { order_id: string; status: string },
context: McpContext
): Promise<any> {
// TODO: Connect to actual orders service
return {
order_id: params.order_id,
previous_status: 'pending',
new_status: params.status,
updated_by: context.userId,
updated_at: new Date().toISOString(),
message: 'Conectar a OrdersService real',
};
}
}

View File

@ -0,0 +1,128 @@
import {
McpToolProvider,
McpToolDefinition,
McpToolHandler,
McpContext,
} from '../interfaces';
/**
* Products Tools Service
* Provides MCP tools for product management.
*
* TODO: Connect to actual ProductsService when available.
*/
export class ProductsToolsService implements McpToolProvider {
getTools(): McpToolDefinition[] {
return [
{
name: 'list_products',
description: 'Lista productos filtrados por categoria, nombre o precio',
category: 'products',
parameters: {
type: 'object',
properties: {
category: { type: 'string', description: 'Filtrar por categoria' },
search: { type: 'string', description: 'Buscar por nombre' },
min_price: { type: 'number', description: 'Precio minimo' },
max_price: { type: 'number', description: 'Precio maximo' },
limit: { type: 'number', description: 'Limite de resultados', default: 20 },
},
},
returns: {
type: 'array',
items: { type: 'object' },
},
},
{
name: 'get_product_details',
description: 'Obtiene detalles completos de un producto',
category: 'products',
parameters: {
type: 'object',
properties: {
product_id: { type: 'string', format: 'uuid', description: 'ID del producto' },
},
required: ['product_id'],
},
returns: { type: 'object' },
},
{
name: 'check_product_availability',
description: 'Verifica si hay stock suficiente de un producto',
category: 'products',
parameters: {
type: 'object',
properties: {
product_id: { type: 'string', format: 'uuid', description: 'ID del producto' },
quantity: { type: 'number', minimum: 1, description: 'Cantidad requerida' },
},
required: ['product_id', 'quantity'],
},
returns: {
type: 'object',
properties: {
available: { type: 'boolean' },
current_stock: { type: 'number' },
},
},
},
];
}
getHandler(toolName: string): McpToolHandler | undefined {
const handlers: Record<string, McpToolHandler> = {
list_products: this.listProducts.bind(this),
get_product_details: this.getProductDetails.bind(this),
check_product_availability: this.checkProductAvailability.bind(this),
};
return handlers[toolName];
}
private async listProducts(
params: { category?: string; search?: string; min_price?: number; max_price?: number; limit?: number },
context: McpContext
): Promise<any[]> {
// TODO: Connect to actual products service
return [
{
id: 'sample-product-1',
name: 'Producto de ejemplo 1',
price: 99.99,
stock: 50,
category: params.category || 'general',
message: 'Conectar a ProductsService real',
},
];
}
private async getProductDetails(
params: { product_id: string },
context: McpContext
): Promise<any> {
// TODO: Connect to actual products service
return {
id: params.product_id,
name: 'Producto de ejemplo',
description: 'Descripcion del producto',
sku: 'SKU-001',
price: 99.99,
stock: 50,
message: 'Conectar a ProductsService real',
};
}
private async checkProductAvailability(
params: { product_id: string; quantity: number },
context: McpContext
): Promise<any> {
// TODO: Connect to actual inventory service
const mockStock = 50;
return {
available: mockStock >= params.quantity,
current_stock: mockStock,
requested_quantity: params.quantity,
shortage: Math.max(0, params.quantity - mockStock),
message: 'Conectar a InventoryService real',
};
}
}

View File

@ -0,0 +1,329 @@
import {
McpToolProvider,
McpToolDefinition,
McpToolHandler,
McpContext,
} from '../interfaces';
/**
* Sales Tools Service
* Provides MCP tools for sales management and reporting.
* Used by: ADMIN, SUPERVISOR, OPERATOR roles
*/
export class SalesToolsService implements McpToolProvider {
getTools(): McpToolDefinition[] {
return [
{
name: 'get_sales_summary',
description: 'Obtiene resumen de ventas del dia, semana o mes',
category: 'sales',
parameters: {
type: 'object',
properties: {
period: {
type: 'string',
enum: ['today', 'week', 'month', 'year'],
description: 'Periodo del resumen',
default: 'today',
},
branch_id: {
type: 'string',
format: 'uuid',
description: 'Filtrar por sucursal (opcional)',
},
},
},
returns: {
type: 'object',
properties: {
total_sales: { type: 'number' },
transaction_count: { type: 'number' },
average_ticket: { type: 'number' },
},
},
},
{
name: 'get_sales_report',
description: 'Genera reporte detallado de ventas por rango de fechas',
category: 'sales',
parameters: {
type: 'object',
properties: {
start_date: {
type: 'string',
format: 'date',
description: 'Fecha inicial (YYYY-MM-DD)',
},
end_date: {
type: 'string',
format: 'date',
description: 'Fecha final (YYYY-MM-DD)',
},
group_by: {
type: 'string',
enum: ['day', 'week', 'month', 'branch', 'category', 'seller'],
description: 'Agrupar resultados por',
default: 'day',
},
branch_id: {
type: 'string',
format: 'uuid',
description: 'Filtrar por sucursal',
},
},
required: ['start_date', 'end_date'],
},
returns: { type: 'array', items: { type: 'object' } },
},
{
name: 'get_top_products',
description: 'Obtiene los productos mas vendidos',
category: 'sales',
parameters: {
type: 'object',
properties: {
period: {
type: 'string',
enum: ['today', 'week', 'month', 'year'],
default: 'month',
},
limit: { type: 'number', default: 10 },
branch_id: { type: 'string', format: 'uuid' },
},
},
returns: { type: 'array', items: { type: 'object' } },
},
{
name: 'get_top_customers',
description: 'Obtiene los clientes con mas compras',
category: 'sales',
parameters: {
type: 'object',
properties: {
period: {
type: 'string',
enum: ['month', 'quarter', 'year'],
default: 'month',
},
limit: { type: 'number', default: 10 },
order_by: {
type: 'string',
enum: ['amount', 'transactions'],
default: 'amount',
},
},
},
returns: { type: 'array', items: { type: 'object' } },
},
{
name: 'get_sales_by_branch',
description: 'Compara ventas entre sucursales',
category: 'sales',
parameters: {
type: 'object',
properties: {
period: {
type: 'string',
enum: ['today', 'week', 'month'],
default: 'today',
},
},
},
returns: { type: 'array', items: { type: 'object' } },
},
{
name: 'get_my_sales',
description: 'Obtiene las ventas del usuario actual (operador)',
category: 'sales',
parameters: {
type: 'object',
properties: {
period: {
type: 'string',
enum: ['today', 'week', 'month'],
default: 'today',
},
},
},
returns: {
type: 'object',
properties: {
total: { type: 'number' },
count: { type: 'number' },
sales: { type: 'array' },
},
},
},
{
name: 'create_sale',
description: 'Registra una nueva venta',
category: 'sales',
parameters: {
type: 'object',
properties: {
items: {
type: 'array',
items: {
type: 'object',
properties: {
product_id: { type: 'string', format: 'uuid' },
quantity: { type: 'number', minimum: 1 },
unit_price: { type: 'number' },
discount: { type: 'number', default: 0 },
},
required: ['product_id', 'quantity'],
},
},
customer_id: { type: 'string', format: 'uuid' },
payment_method: {
type: 'string',
enum: ['cash', 'card', 'transfer', 'credit'],
},
notes: { type: 'string' },
},
required: ['items', 'payment_method'],
},
returns: {
type: 'object',
properties: {
sale_id: { type: 'string' },
total: { type: 'number' },
status: { type: 'string' },
},
},
},
];
}
getHandler(toolName: string): McpToolHandler | undefined {
const handlers: Record<string, McpToolHandler> = {
get_sales_summary: this.getSalesSummary.bind(this),
get_sales_report: this.getSalesReport.bind(this),
get_top_products: this.getTopProducts.bind(this),
get_top_customers: this.getTopCustomers.bind(this),
get_sales_by_branch: this.getSalesByBranch.bind(this),
get_my_sales: this.getMySales.bind(this),
create_sale: this.createSale.bind(this),
};
return handlers[toolName];
}
private async getSalesSummary(
params: { period?: string; branch_id?: string },
context: McpContext
): Promise<any> {
// TODO: Connect to SalesService
const period = params.period || 'today';
return {
period,
branch_id: params.branch_id || 'all',
total_sales: 15750.00,
transaction_count: 42,
average_ticket: 375.00,
comparison: {
previous_period: 14200.00,
change_percent: 10.9,
},
message: 'Conectar a SalesService real',
};
}
private async getSalesReport(
params: { start_date: string; end_date: string; group_by?: string; branch_id?: string },
context: McpContext
): Promise<any[]> {
// TODO: Connect to SalesService
return [
{
date: params.start_date,
total: 5250.00,
count: 15,
avg_ticket: 350.00,
},
{
date: params.end_date,
total: 4800.00,
count: 12,
avg_ticket: 400.00,
},
];
}
private async getTopProducts(
params: { period?: string; limit?: number; branch_id?: string },
context: McpContext
): Promise<any[]> {
// TODO: Connect to SalesService
return [
{ rank: 1, product: 'Producto A', quantity: 150, revenue: 7500.00 },
{ rank: 2, product: 'Producto B', quantity: 120, revenue: 6000.00 },
{ rank: 3, product: 'Producto C', quantity: 100, revenue: 5000.00 },
].slice(0, params.limit || 10);
}
private async getTopCustomers(
params: { period?: string; limit?: number; order_by?: string },
context: McpContext
): Promise<any[]> {
// TODO: Connect to CustomersService
return [
{ rank: 1, customer: 'Cliente A', total: 25000.00, transactions: 15 },
{ rank: 2, customer: 'Cliente B', total: 18000.00, transactions: 12 },
].slice(0, params.limit || 10);
}
private async getSalesByBranch(
params: { period?: string },
context: McpContext
): Promise<any[]> {
// TODO: Connect to SalesService + BranchesService
return [
{ branch: 'Sucursal Centro', total: 8500.00, count: 25 },
{ branch: 'Sucursal Norte', total: 7250.00, count: 17 },
];
}
private async getMySales(
params: { period?: string },
context: McpContext
): Promise<any> {
// TODO: Connect to SalesService with context.userId
return {
user_id: context.userId,
period: params.period || 'today',
total: 3500.00,
count: 8,
sales: [
{ id: 'sale-1', total: 450.00, time: '10:30' },
{ id: 'sale-2', total: 680.00, time: '11:45' },
],
};
}
private async createSale(
params: {
items: Array<{ product_id: string; quantity: number; unit_price?: number; discount?: number }>;
customer_id?: string;
payment_method: string;
notes?: string;
},
context: McpContext
): Promise<any> {
// TODO: Connect to SalesService
const total = params.items.reduce((sum, item) => {
const price = item.unit_price || 100; // Default price for demo
const discount = item.discount || 0;
return sum + (price * item.quantity * (1 - discount / 100));
}, 0);
return {
sale_id: `SALE-${Date.now()}`,
total,
items_count: params.items.length,
payment_method: params.payment_method,
status: 'completed',
created_by: context.userId,
message: 'Conectar a SalesService real',
};
}
}

View File

@ -0,0 +1,54 @@
/**
* Clip Webhook Controller
*
* Endpoint público para recibir webhooks de Clip
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { ClipService } from '../services/clip.service';
export class ClipWebhookController {
public router: Router;
private clipService: ClipService;
constructor(private dataSource: DataSource) {
this.router = Router();
this.clipService = new ClipService(dataSource);
this.initializeRoutes();
}
private initializeRoutes(): void {
// Webhook endpoint (público, sin auth)
this.router.post('/:tenantId', this.handleWebhook.bind(this));
}
/**
* POST /webhooks/clip/:tenantId
* Recibir notificaciones de Clip
*/
private async handleWebhook(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.params.tenantId;
const eventType = req.body.event || req.body.type;
const data = req.body;
// Extraer headers relevantes
const headers: Record<string, string> = {
'x-clip-signature': req.headers['x-clip-signature'] as string || '',
'x-clip-event-id': req.headers['x-clip-event-id'] as string || '',
};
// Responder inmediatamente
res.status(200).json({ received: true });
// Procesar webhook de forma asíncrona
await this.clipService.handleWebhook(tenantId, eventType, data, headers);
} catch (error) {
console.error('Clip webhook error:', error);
if (!res.headersSent) {
res.status(200).json({ received: true });
}
}
}
}

View File

@ -0,0 +1,164 @@
/**
* Clip Controller
*
* Endpoints para pagos con Clip
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { ClipService } from '../services/clip.service';
export class ClipController {
public router: Router;
private clipService: ClipService;
constructor(private dataSource: DataSource) {
this.router = Router();
this.clipService = new ClipService(dataSource);
this.initializeRoutes();
}
private initializeRoutes(): void {
// Pagos
this.router.post('/payments', this.createPayment.bind(this));
this.router.get('/payments/:id', this.getPayment.bind(this));
this.router.post('/payments/:id/refund', this.refundPayment.bind(this));
// Links de pago
this.router.post('/links', this.createPaymentLink.bind(this));
}
/**
* POST /clip/payments
* Crear un nuevo pago
*/
private async createPayment(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const userId = this.getUserId(req);
const payment = await this.clipService.createPayment(
tenantId,
{
amount: req.body.amount,
currency: req.body.currency,
description: req.body.description,
customerEmail: req.body.customerEmail,
customerName: req.body.customerName,
customerPhone: req.body.customerPhone,
referenceType: req.body.referenceType,
referenceId: req.body.referenceId,
metadata: req.body.metadata,
},
userId
);
res.status(201).json({
success: true,
data: this.sanitizePayment(payment),
});
} catch (error) {
next(error);
}
}
/**
* GET /clip/payments/:id
* Obtener estado de un pago
*/
private async getPayment(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const paymentId = req.params.id;
const payment = await this.clipService.getPayment(tenantId, paymentId);
res.json({
success: true,
data: this.sanitizePayment(payment),
});
} catch (error) {
next(error);
}
}
/**
* POST /clip/payments/:id/refund
* Reembolsar un pago
*/
private async refundPayment(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const paymentId = req.params.id;
const payment = await this.clipService.refundPayment(tenantId, {
paymentId,
amount: req.body.amount,
reason: req.body.reason,
});
res.json({
success: true,
data: this.sanitizePayment(payment),
});
} catch (error) {
next(error);
}
}
/**
* POST /clip/links
* Crear un link de pago
*/
private async createPaymentLink(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const userId = this.getUserId(req);
const link = await this.clipService.createPaymentLink(
tenantId,
{
amount: req.body.amount,
description: req.body.description,
expiresInMinutes: req.body.expiresInMinutes,
referenceType: req.body.referenceType,
referenceId: req.body.referenceId,
},
userId
);
res.status(201).json({
success: true,
data: link,
});
} catch (error) {
next(error);
}
}
/**
* Obtener tenant ID del request
*/
private getTenantId(req: Request): string {
const tenantId = (req as any).tenantId || (req as any).user?.tenantId;
if (!tenantId) {
throw new Error('Tenant ID not found in request');
}
return tenantId;
}
/**
* Obtener user ID del request
*/
private getUserId(req: Request): string | undefined {
return (req as any).userId || (req as any).user?.id;
}
/**
* Sanitizar pago para respuesta
*/
private sanitizePayment(payment: any): any {
const { providerResponse, ...safe } = payment;
return safe;
}
}

View File

@ -0,0 +1,14 @@
/**
* Payment Terminals Controllers Index
*/
export { TerminalsController } from './terminals.controller';
export { TransactionsController } from './transactions.controller';
// MercadoPago
export { MercadoPagoController } from './mercadopago.controller';
export { MercadoPagoWebhookController } from './mercadopago-webhook.controller';
// Clip
export { ClipController } from './clip.controller';
export { ClipWebhookController } from './clip-webhook.controller';

View File

@ -0,0 +1,56 @@
/**
* MercadoPago Webhook Controller
*
* Endpoint público para recibir webhooks de MercadoPago
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { MercadoPagoService } from '../services/mercadopago.service';
export class MercadoPagoWebhookController {
public router: Router;
private mercadoPagoService: MercadoPagoService;
constructor(private dataSource: DataSource) {
this.router = Router();
this.mercadoPagoService = new MercadoPagoService(dataSource);
this.initializeRoutes();
}
private initializeRoutes(): void {
// Webhook endpoint (público, sin auth)
this.router.post('/:tenantId', this.handleWebhook.bind(this));
}
/**
* POST /webhooks/mercadopago/:tenantId
* Recibir notificaciones IPN de MercadoPago
*/
private async handleWebhook(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.params.tenantId;
const eventType = req.body.type || req.body.action;
const data = req.body;
// Extraer headers relevantes
const headers: Record<string, string> = {
'x-signature': req.headers['x-signature'] as string || '',
'x-request-id': req.headers['x-request-id'] as string || '',
};
// Responder inmediatamente (MercadoPago espera 200 rápido)
res.status(200).json({ received: true });
// Procesar webhook de forma asíncrona
await this.mercadoPagoService.handleWebhook(tenantId, eventType, data, headers);
} catch (error) {
// Log error pero no fallar el webhook
console.error('MercadoPago webhook error:', error);
// Si aún no enviamos respuesta
if (!res.headersSent) {
res.status(200).json({ received: true });
}
}
}
}

View File

@ -0,0 +1,165 @@
/**
* MercadoPago Controller
*
* Endpoints para pagos con MercadoPago
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { MercadoPagoService } from '../services/mercadopago.service';
export class MercadoPagoController {
public router: Router;
private mercadoPagoService: MercadoPagoService;
constructor(private dataSource: DataSource) {
this.router = Router();
this.mercadoPagoService = new MercadoPagoService(dataSource);
this.initializeRoutes();
}
private initializeRoutes(): void {
// Pagos
this.router.post('/payments', this.createPayment.bind(this));
this.router.get('/payments/:id', this.getPayment.bind(this));
this.router.post('/payments/:id/refund', this.refundPayment.bind(this));
// Links de pago
this.router.post('/links', this.createPaymentLink.bind(this));
}
/**
* POST /mercadopago/payments
* Crear un nuevo pago
*/
private async createPayment(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const userId = this.getUserId(req);
const payment = await this.mercadoPagoService.createPayment(
tenantId,
{
amount: req.body.amount,
currency: req.body.currency,
description: req.body.description,
paymentMethod: req.body.paymentMethod,
customerEmail: req.body.customerEmail,
customerName: req.body.customerName,
referenceType: req.body.referenceType,
referenceId: req.body.referenceId,
metadata: req.body.metadata,
},
userId
);
res.status(201).json({
success: true,
data: this.sanitizePayment(payment),
});
} catch (error) {
next(error);
}
}
/**
* GET /mercadopago/payments/:id
* Obtener estado de un pago
*/
private async getPayment(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const paymentId = req.params.id;
const payment = await this.mercadoPagoService.getPayment(tenantId, paymentId);
res.json({
success: true,
data: this.sanitizePayment(payment),
});
} catch (error) {
next(error);
}
}
/**
* POST /mercadopago/payments/:id/refund
* Reembolsar un pago
*/
private async refundPayment(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const paymentId = req.params.id;
const payment = await this.mercadoPagoService.refundPayment(tenantId, {
paymentId,
amount: req.body.amount,
reason: req.body.reason,
});
res.json({
success: true,
data: this.sanitizePayment(payment),
});
} catch (error) {
next(error);
}
}
/**
* POST /mercadopago/links
* Crear un link de pago
*/
private async createPaymentLink(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const userId = this.getUserId(req);
const link = await this.mercadoPagoService.createPaymentLink(
tenantId,
{
amount: req.body.amount,
title: req.body.title,
description: req.body.description,
expiresAt: req.body.expiresAt ? new Date(req.body.expiresAt) : undefined,
referenceType: req.body.referenceType,
referenceId: req.body.referenceId,
},
userId
);
res.status(201).json({
success: true,
data: link,
});
} catch (error) {
next(error);
}
}
/**
* Obtener tenant ID del request
*/
private getTenantId(req: Request): string {
const tenantId = (req as any).tenantId || (req as any).user?.tenantId;
if (!tenantId) {
throw new Error('Tenant ID not found in request');
}
return tenantId;
}
/**
* Obtener user ID del request
*/
private getUserId(req: Request): string | undefined {
return (req as any).userId || (req as any).user?.id;
}
/**
* Sanitizar pago para respuesta (ocultar datos sensibles)
*/
private sanitizePayment(payment: any): any {
const { providerResponse, ...safe } = payment;
return safe;
}
}

View File

@ -0,0 +1,192 @@
/**
* Terminals Controller
*
* REST API endpoints for terminal management
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { TerminalsService } from '../services';
import { CreateTerminalDto, UpdateTerminalDto } from '../dto';
// Extend Request to include tenant info
interface AuthenticatedRequest extends Request {
tenantId?: string;
userId?: string;
}
export class TerminalsController {
public router: Router;
private service: TerminalsService;
constructor(dataSource: DataSource) {
this.router = Router();
this.service = new TerminalsService(dataSource);
this.initializeRoutes();
}
private initializeRoutes(): void {
// Branch terminals
this.router.get('/branch/:branchId', this.getByBranch.bind(this));
this.router.get('/branch/:branchId/primary', this.getPrimary.bind(this));
// Terminal CRUD
this.router.get('/:id', this.getById.bind(this));
this.router.post('/', this.create.bind(this));
this.router.put('/:id', this.update.bind(this));
this.router.delete('/:id', this.delete.bind(this));
// Terminal actions
this.router.get('/:id/health', this.checkHealth.bind(this));
this.router.post('/:id/set-primary', this.setPrimary.bind(this));
// Health check batch (for scheduled job)
this.router.post('/health-check-batch', this.healthCheckBatch.bind(this));
}
/**
* GET /payment-terminals/branch/:branchId
* Get terminals for branch
*/
private async getByBranch(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const terminals = await this.service.findByBranch(req.params.branchId);
res.json({ data: terminals });
} catch (error) {
next(error);
}
}
/**
* GET /payment-terminals/branch/:branchId/primary
* Get primary terminal for branch
*/
private async getPrimary(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const terminal = await this.service.findPrimaryTerminal(req.params.branchId);
res.json({ data: terminal });
} catch (error) {
next(error);
}
}
/**
* GET /payment-terminals/:id
* Get terminal by ID
*/
private async getById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const terminal = await this.service.findById(req.params.id);
if (!terminal) {
res.status(404).json({ error: 'Terminal not found' });
return;
}
res.json({ data: terminal });
} catch (error) {
next(error);
}
}
/**
* POST /payment-terminals
* Create new terminal
*/
private async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const dto: CreateTerminalDto = req.body;
const terminal = await this.service.create(req.tenantId!, dto);
res.status(201).json({ data: terminal });
} catch (error) {
next(error);
}
}
/**
* PUT /payment-terminals/:id
* Update terminal
*/
private async update(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: UpdateTerminalDto = req.body;
const terminal = await this.service.update(req.params.id, dto);
res.json({ data: terminal });
} catch (error) {
next(error);
}
}
/**
* DELETE /payment-terminals/:id
* Delete terminal (soft delete)
*/
private async delete(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await this.service.delete(req.params.id);
res.status(204).send();
} catch (error) {
next(error);
}
}
/**
* GET /payment-terminals/:id/health
* Check terminal health
*/
private async checkHealth(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const health = await this.service.checkHealth(req.params.id);
res.json({ data: health });
} catch (error) {
next(error);
}
}
/**
* POST /payment-terminals/:id/set-primary
* Set terminal as primary for branch
*/
private async setPrimary(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const terminal = await this.service.setPrimary(req.params.id);
res.json({ data: terminal });
} catch (error) {
next(error);
}
}
/**
* POST /payment-terminals/health-check-batch
* Run health check on all terminals needing check (scheduled job endpoint)
*/
private async healthCheckBatch(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const maxAgeMinutes = parseInt(req.query.maxAgeMinutes as string) || 30;
const terminals = await this.service.findTerminalsNeedingHealthCheck(maxAgeMinutes);
const results: { terminalId: string; status: string; message: string }[] = [];
for (const terminal of terminals) {
try {
const health = await this.service.checkHealth(terminal.id);
results.push({
terminalId: terminal.id,
status: health.status,
message: health.message,
});
} catch (error: any) {
results.push({
terminalId: terminal.id,
status: 'error',
message: error.message,
});
}
}
res.json({ data: { checked: results.length, results } });
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1,163 @@
/**
* Transactions Controller
*
* REST API endpoints for payment transactions
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { TransactionsService } from '../services';
import { ProcessPaymentDto, ProcessRefundDto, SendReceiptDto, TransactionFilterDto } from '../dto';
// Extend Request to include tenant info
interface AuthenticatedRequest extends Request {
tenantId?: string;
userId?: string;
}
export class TransactionsController {
public router: Router;
private service: TransactionsService;
constructor(dataSource: DataSource) {
this.router = Router();
this.service = new TransactionsService(dataSource);
this.initializeRoutes();
}
private initializeRoutes(): void {
// Stats
this.router.get('/stats', this.getStats.bind(this));
// Payment processing
this.router.post('/charge', this.processPayment.bind(this));
this.router.post('/refund', this.processRefund.bind(this));
// Transaction queries
this.router.get('/', this.getAll.bind(this));
this.router.get('/:id', this.getById.bind(this));
// Actions
this.router.post('/:id/receipt', this.sendReceipt.bind(this));
}
/**
* GET /payment-transactions/stats
* Get transaction statistics
*/
private async getStats(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const filter: TransactionFilterDto = {
branchId: req.query.branchId as string,
startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined,
endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined,
};
const stats = await this.service.getStats(req.tenantId!, filter);
res.json({ data: stats });
} catch (error) {
next(error);
}
}
/**
* POST /payment-transactions/charge
* Process a payment
*/
private async processPayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const dto: ProcessPaymentDto = req.body;
const result = await this.service.processPayment(req.tenantId!, req.userId!, dto);
if (result.success) {
res.status(201).json({ data: result });
} else {
res.status(400).json({ data: result });
}
} catch (error) {
next(error);
}
}
/**
* POST /payment-transactions/refund
* Process a refund
*/
private async processRefund(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const dto: ProcessRefundDto = req.body;
const result = await this.service.processRefund(req.tenantId!, req.userId!, dto);
if (result.success) {
res.json({ data: result });
} else {
res.status(400).json({ data: result });
}
} catch (error) {
next(error);
}
}
/**
* GET /payment-transactions
* Get transactions with filters
*/
private async getAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const filter: TransactionFilterDto = {
branchId: req.query.branchId as string,
userId: req.query.userId as string,
status: req.query.status as any,
sourceType: req.query.sourceType as any,
terminalProvider: req.query.terminalProvider as string,
startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined,
endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined,
limit: req.query.limit ? parseInt(req.query.limit as string) : undefined,
offset: req.query.offset ? parseInt(req.query.offset as string) : undefined,
};
const result = await this.service.findAll(req.tenantId!, filter);
res.json(result);
} catch (error) {
next(error);
}
}
/**
* GET /payment-transactions/:id
* Get transaction by ID
*/
private async getById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const transaction = await this.service.findById(req.params.id, req.tenantId!);
if (!transaction) {
res.status(404).json({ error: 'Transaction not found' });
return;
}
res.json({ data: transaction });
} catch (error) {
next(error);
}
}
/**
* POST /payment-transactions/:id/receipt
* Send receipt for transaction
*/
private async sendReceipt(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const dto: SendReceiptDto = req.body;
const result = await this.service.sendReceipt(req.params.id, req.tenantId!, dto);
if (result.success) {
res.json({ success: true });
} else {
res.status(400).json({ success: false, error: result.error });
}
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1,6 @@
/**
* Payment Terminals DTOs Index
*/
export * from './terminal.dto';
export * from './transaction.dto';

View File

@ -0,0 +1,47 @@
/**
* Terminal DTOs
*/
import { TerminalProvider, HealthStatus } from '../../branches/entities/branch-payment-terminal.entity';
export class CreateTerminalDto {
branchId: string;
terminalProvider: TerminalProvider;
terminalId: string;
terminalName?: string;
credentials?: Record<string, any>;
isPrimary?: boolean;
dailyLimit?: number;
transactionLimit?: number;
}
export class UpdateTerminalDto {
terminalName?: string;
credentials?: Record<string, any>;
isPrimary?: boolean;
isActive?: boolean;
dailyLimit?: number;
transactionLimit?: number;
}
export class TerminalHealthCheckDto {
terminalId: string;
status: HealthStatus;
message?: string;
responseTime?: number;
}
export class TerminalResponseDto {
id: string;
branchId: string;
terminalProvider: TerminalProvider;
terminalId: string;
terminalName?: string;
isPrimary: boolean;
isActive: boolean;
dailyLimit?: number;
transactionLimit?: number;
healthStatus: HealthStatus;
lastTransactionAt?: Date;
lastHealthCheckAt?: Date;
}

View File

@ -0,0 +1,75 @@
/**
* Transaction DTOs
*/
import { PaymentSourceType, PaymentMethod, PaymentStatus } from '../../mobile/entities/payment-transaction.entity';
export class ProcessPaymentDto {
terminalId: string;
amount: number;
currency?: string;
tipAmount?: number;
sourceType: PaymentSourceType;
sourceId: string;
description?: string;
customerEmail?: string;
customerPhone?: string;
}
export class PaymentResultDto {
success: boolean;
transactionId?: string;
externalTransactionId?: string;
amount: number;
totalAmount: number;
tipAmount: number;
currency: string;
status: PaymentStatus;
paymentMethod?: PaymentMethod;
cardBrand?: string;
cardLastFour?: string;
receiptUrl?: string;
error?: string;
errorCode?: string;
}
export class ProcessRefundDto {
transactionId: string;
amount?: number; // Partial refund if provided
reason?: string;
}
export class RefundResultDto {
success: boolean;
refundId?: string;
amount: number;
status: 'pending' | 'completed' | 'failed';
error?: string;
}
export class SendReceiptDto {
email?: string;
phone?: string;
}
export class TransactionFilterDto {
branchId?: string;
userId?: string;
status?: PaymentStatus;
startDate?: Date;
endDate?: Date;
sourceType?: PaymentSourceType;
terminalProvider?: string;
limit?: number;
offset?: number;
}
export class TransactionStatsDto {
total: number;
totalAmount: number;
byStatus: Record<PaymentStatus, number>;
byProvider: Record<string, { count: number; amount: number }>;
byPaymentMethod: Record<PaymentMethod, number>;
averageAmount: number;
successRate: number;
}

View File

@ -0,0 +1,3 @@
export * from './tenant-terminal-config.entity';
export * from './terminal-payment.entity';
export * from './terminal-webhook-event.entity';

View File

@ -0,0 +1,82 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
export type TerminalProvider = 'mercadopago' | 'clip' | 'stripe_terminal';
@Entity({ name: 'tenant_terminal_configs', schema: 'payment_terminals' })
@Index(['tenantId', 'provider', 'name'], { unique: true })
export class TenantTerminalConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({
type: 'enum',
enum: ['mercadopago', 'clip', 'stripe_terminal'],
enumName: 'terminal_provider',
})
provider: TerminalProvider;
@Column({ type: 'varchar', length: 100 })
name: string;
// Credenciales encriptadas
@Column({ type: 'jsonb', default: {} })
credentials: Record<string, any>;
// Configuración específica del proveedor
@Column({ type: 'jsonb', default: {} })
config: Record<string, any>;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'is_verified', type: 'boolean', default: false })
isVerified: boolean;
@Column({ name: 'verification_error', type: 'text', nullable: true })
verificationError: string | null;
@Column({ name: 'verified_at', type: 'timestamptz', nullable: true })
verifiedAt: Date | null;
// Límites
@Column({ name: 'daily_limit', type: 'decimal', precision: 12, scale: 2, nullable: true })
dailyLimit: number | null;
@Column({ name: 'monthly_limit', type: 'decimal', precision: 12, scale: 2, nullable: true })
monthlyLimit: number | null;
@Column({ name: 'transaction_limit', type: 'decimal', precision: 12, scale: 2, nullable: true })
transactionLimit: number | null;
// Webhook
@Column({ name: 'webhook_url', type: 'varchar', length: 500, nullable: true })
webhookUrl: string | null;
@Column({ name: 'webhook_secret', type: 'varchar', length: 255, nullable: true })
webhookSecret: string | null;
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string | null;
}

View File

@ -0,0 +1,182 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { TenantTerminalConfig, TerminalProvider } from './tenant-terminal-config.entity';
export type TerminalPaymentStatus =
| 'pending'
| 'processing'
| 'approved'
| 'authorized'
| 'in_process'
| 'rejected'
| 'refunded'
| 'partially_refunded'
| 'cancelled'
| 'charged_back';
export type PaymentMethodType = 'card' | 'qr' | 'link' | 'cash' | 'bank_transfer';
@Entity({ name: 'terminal_payments', schema: 'payment_terminals' })
export class TerminalPayment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'config_id', type: 'uuid', nullable: true })
configId: string | null;
@Column({ name: 'branch_terminal_id', type: 'uuid', nullable: true })
branchTerminalId: string | null;
@Index()
@Column({
type: 'enum',
enum: ['mercadopago', 'clip', 'stripe_terminal'],
enumName: 'terminal_provider',
})
provider: TerminalProvider;
@Index()
@Column({ name: 'external_id', type: 'varchar', length: 255, nullable: true })
externalId: string | null;
@Column({ name: 'external_status', type: 'varchar', length: 50, nullable: true })
externalStatus: string | null;
// Monto
@Column({ type: 'decimal', precision: 12, scale: 2 })
amount: number;
@Column({ type: 'varchar', length: 3, default: 'MXN' })
currency: string;
// Estado
@Index()
@Column({
type: 'enum',
enum: [
'pending',
'processing',
'approved',
'authorized',
'in_process',
'rejected',
'refunded',
'partially_refunded',
'cancelled',
'charged_back',
],
enumName: 'terminal_payment_status',
default: 'pending',
})
status: TerminalPaymentStatus;
// Método de pago
@Column({
name: 'payment_method',
type: 'enum',
enum: ['card', 'qr', 'link', 'cash', 'bank_transfer'],
enumName: 'payment_method_type',
default: 'card',
})
paymentMethod: PaymentMethodType;
// Datos de tarjeta
@Column({ name: 'card_last_four', type: 'varchar', length: 4, nullable: true })
cardLastFour: string | null;
@Column({ name: 'card_brand', type: 'varchar', length: 20, nullable: true })
cardBrand: string | null;
@Column({ name: 'card_type', type: 'varchar', length: 20, nullable: true })
cardType: string | null;
// Cliente
@Column({ name: 'customer_email', type: 'varchar', length: 255, nullable: true })
customerEmail: string | null;
@Column({ name: 'customer_phone', type: 'varchar', length: 20, nullable: true })
customerPhone: string | null;
@Column({ name: 'customer_name', type: 'varchar', length: 200, nullable: true })
customerName: string | null;
// Descripción
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ name: 'statement_descriptor', type: 'varchar', length: 50, nullable: true })
statementDescriptor: string | null;
// Referencia interna
@Index()
@Column({ name: 'reference_type', type: 'varchar', length: 50, nullable: true })
referenceType: string | null;
@Column({ name: 'reference_id', type: 'uuid', nullable: true })
referenceId: string | null;
// Comisiones
@Column({ name: 'fee_amount', type: 'decimal', precision: 10, scale: 4, nullable: true })
feeAmount: number | null;
@Column({ name: 'fee_details', type: 'jsonb', nullable: true })
feeDetails: Record<string, any> | null;
@Column({ name: 'net_amount', type: 'decimal', precision: 12, scale: 2, nullable: true })
netAmount: number | null;
// Reembolso
@Column({ name: 'refunded_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
refundedAmount: number;
@Column({ name: 'refund_reason', type: 'text', nullable: true })
refundReason: string | null;
// Respuesta del proveedor
@Column({ name: 'provider_response', type: 'jsonb', nullable: true })
providerResponse: Record<string, any> | null;
// Error
@Column({ name: 'error_code', type: 'varchar', length: 50, nullable: true })
errorCode: string | null;
@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage: string | null;
// Timestamps
@Column({ name: 'processed_at', type: 'timestamptz', nullable: true })
processedAt: Date | null;
@Column({ name: 'refunded_at', type: 'timestamptz', nullable: true })
refundedAt: Date | null;
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, any>;
@Index()
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string | null;
// Relaciones
@ManyToOne(() => TenantTerminalConfig, { nullable: true })
@JoinColumn({ name: 'config_id' })
config?: TenantTerminalConfig;
}

View File

@ -0,0 +1,77 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { TerminalProvider } from './tenant-terminal-config.entity';
import { TerminalPayment } from './terminal-payment.entity';
@Entity({ name: 'terminal_webhook_events', schema: 'payment_terminals' })
@Index(['provider', 'eventId'], { unique: true })
export class TerminalWebhookEvent {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({
type: 'enum',
enum: ['mercadopago', 'clip', 'stripe_terminal'],
enumName: 'terminal_provider',
})
provider: TerminalProvider;
@Column({ name: 'event_type', type: 'varchar', length: 100 })
eventType: string;
@Column({ name: 'event_id', type: 'varchar', length: 255, nullable: true })
eventId: string | null;
@Column({ name: 'payment_id', type: 'uuid', nullable: true })
paymentId: string | null;
@Index()
@Column({ name: 'external_id', type: 'varchar', length: 255, nullable: true })
externalId: string | null;
@Column({ type: 'jsonb' })
payload: Record<string, any>;
@Column({ type: 'jsonb', nullable: true })
headers: Record<string, any> | null;
@Column({ name: 'signature_valid', type: 'boolean', nullable: true })
signatureValid: boolean | null;
@Index()
@Column({ type: 'boolean', default: false })
processed: boolean;
@Column({ name: 'processed_at', type: 'timestamptz', nullable: true })
processedAt: Date | null;
@Column({ name: 'processing_error', type: 'text', nullable: true })
processingError: string | null;
@Column({ name: 'retry_count', type: 'integer', default: 0 })
retryCount: number;
@Index()
@Column({ name: 'idempotency_key', type: 'varchar', length: 255, nullable: true })
idempotencyKey: string | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
// Relaciones
@ManyToOne(() => TerminalPayment, { nullable: true })
@JoinColumn({ name: 'payment_id' })
payment?: TerminalPayment;
}

View File

@ -0,0 +1,15 @@
/**
* Payment Terminals Module Index
*/
// Module
export { PaymentTerminalsModule, PaymentTerminalsModuleOptions } from './payment-terminals.module';
// DTOs
export * from './dto';
// Services
export * from './services';
// Controllers
export * from './controllers';

View File

@ -0,0 +1,76 @@
/**
* Payment Terminals Module
*
* Module registration for payment terminals and transactions
* Includes: MercadoPago, Clip, Stripe Terminal
*/
import { Router } from 'express';
import { DataSource } from 'typeorm';
import {
TerminalsController,
TransactionsController,
MercadoPagoController,
MercadoPagoWebhookController,
ClipController,
ClipWebhookController,
} from './controllers';
export interface PaymentTerminalsModuleOptions {
dataSource: DataSource;
basePath?: string;
}
export class PaymentTerminalsModule {
public router: Router;
public webhookRouter: Router;
private terminalsController: TerminalsController;
private transactionsController: TransactionsController;
private mercadoPagoController: MercadoPagoController;
private mercadoPagoWebhookController: MercadoPagoWebhookController;
private clipController: ClipController;
private clipWebhookController: ClipWebhookController;
constructor(options: PaymentTerminalsModuleOptions) {
const { dataSource, basePath = '' } = options;
this.router = Router();
this.webhookRouter = Router();
// Initialize controllers
this.terminalsController = new TerminalsController(dataSource);
this.transactionsController = new TransactionsController(dataSource);
this.mercadoPagoController = new MercadoPagoController(dataSource);
this.mercadoPagoWebhookController = new MercadoPagoWebhookController(dataSource);
this.clipController = new ClipController(dataSource);
this.clipWebhookController = new ClipWebhookController(dataSource);
// Register authenticated routes
this.router.use(`${basePath}/payment-terminals`, this.terminalsController.router);
this.router.use(`${basePath}/payment-transactions`, this.transactionsController.router);
this.router.use(`${basePath}/mercadopago`, this.mercadoPagoController.router);
this.router.use(`${basePath}/clip`, this.clipController.router);
// Register public webhook routes (no auth required)
this.webhookRouter.use('/mercadopago', this.mercadoPagoWebhookController.router);
this.webhookRouter.use('/clip', this.clipWebhookController.router);
}
/**
* Get all entities for this module (for TypeORM configuration)
*/
static getEntities() {
return [
// Existing entities
require('../branches/entities/branch-payment-terminal.entity').BranchPaymentTerminal,
require('../mobile/entities/payment-transaction.entity').PaymentTransaction,
// New entities for MercadoPago/Clip
require('./entities/tenant-terminal-config.entity').TenantTerminalConfig,
require('./entities/terminal-payment.entity').TerminalPayment,
require('./entities/terminal-webhook-event.entity').TerminalWebhookEvent,
];
}
}
export default PaymentTerminalsModule;

View File

@ -0,0 +1,583 @@
/**
* Clip Service
*
* Integración con Clip para pagos TPV
* Basado en: michangarrito INT-005
*
* Features:
* - Crear pagos con tarjeta
* - Generar links de pago
* - Procesar reembolsos
* - Manejar webhooks
* - Multi-tenant con credenciales por tenant
* - Retry con backoff exponencial
*
* Comisión: 3.6% + IVA por transacción
*/
import { Repository, DataSource } from 'typeorm';
import { createHmac, timingSafeEqual } from 'crypto';
import {
TenantTerminalConfig,
TerminalPayment,
TerminalWebhookEvent,
} from '../entities';
// DTOs
export interface CreateClipPaymentDto {
amount: number;
currency?: string;
description?: string;
customerEmail?: string;
customerName?: string;
customerPhone?: string;
referenceType?: string;
referenceId?: string;
metadata?: Record<string, any>;
}
export interface RefundClipPaymentDto {
paymentId: string;
amount?: number;
reason?: string;
}
export interface CreateClipLinkDto {
amount: number;
description: string;
expiresInMinutes?: number;
referenceType?: string;
referenceId?: string;
}
export interface ClipCredentials {
apiKey: string;
secretKey: string;
merchantId: string;
}
export interface ClipConfig {
defaultCurrency?: string;
webhookSecret?: string;
}
// Constantes
const CLIP_API_BASE = 'https://api.clip.mx';
const MAX_RETRIES = 5;
const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000];
// Clip fee: 3.6% + IVA
const CLIP_FEE_RATE = 0.036;
const IVA_RATE = 0.16;
export class ClipService {
private configRepository: Repository<TenantTerminalConfig>;
private paymentRepository: Repository<TerminalPayment>;
private webhookRepository: Repository<TerminalWebhookEvent>;
constructor(private dataSource: DataSource) {
this.configRepository = dataSource.getRepository(TenantTerminalConfig);
this.paymentRepository = dataSource.getRepository(TerminalPayment);
this.webhookRepository = dataSource.getRepository(TerminalWebhookEvent);
}
/**
* Obtener credenciales de Clip para un tenant
*/
async getCredentials(tenantId: string): Promise<{
credentials: ClipCredentials;
config: ClipConfig;
configId: string;
}> {
const terminalConfig = await this.configRepository.findOne({
where: {
tenantId,
provider: 'clip',
isActive: true,
},
});
if (!terminalConfig) {
throw new Error('Clip not configured for this tenant');
}
if (!terminalConfig.isVerified) {
throw new Error('Clip credentials not verified');
}
return {
credentials: terminalConfig.credentials as ClipCredentials,
config: terminalConfig.config as ClipConfig,
configId: terminalConfig.id,
};
}
/**
* Crear un pago
*/
async createPayment(
tenantId: string,
dto: CreateClipPaymentDto,
createdBy?: string
): Promise<TerminalPayment> {
const { credentials, config, configId } = await this.getCredentials(tenantId);
// Calcular comisiones
const feeAmount = dto.amount * CLIP_FEE_RATE * (1 + IVA_RATE);
const netAmount = dto.amount - feeAmount;
// Crear registro local
const payment = this.paymentRepository.create({
tenantId,
configId,
provider: 'clip',
amount: dto.amount,
currency: dto.currency || config.defaultCurrency || 'MXN',
status: 'pending',
paymentMethod: 'card',
customerEmail: dto.customerEmail,
customerName: dto.customerName,
customerPhone: dto.customerPhone,
description: dto.description,
referenceType: dto.referenceType,
referenceId: dto.referenceId ? dto.referenceId : undefined,
feeAmount,
feeDetails: {
rate: CLIP_FEE_RATE,
iva: IVA_RATE,
calculated: feeAmount,
},
netAmount,
metadata: dto.metadata || {},
createdBy,
});
const savedPayment = await this.paymentRepository.save(payment);
try {
// Crear pago en Clip
const clipPayment = await this.executeWithRetry(async () => {
const response = await fetch(`${CLIP_API_BASE}/v1/payments`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${credentials.apiKey}`,
'Content-Type': 'application/json',
'X-Clip-Merchant-Id': credentials.merchantId,
'X-Idempotency-Key': savedPayment.id,
},
body: JSON.stringify({
amount: dto.amount,
currency: dto.currency || 'MXN',
description: dto.description,
customer: {
email: dto.customerEmail,
name: dto.customerName,
phone: dto.customerPhone,
},
metadata: {
tenant_id: tenantId,
internal_id: savedPayment.id,
...dto.metadata,
},
}),
});
if (!response.ok) {
const error = await response.json();
throw new ClipError(error.message || 'Payment failed', response.status, error);
}
return response.json();
});
// Actualizar registro local
savedPayment.externalId = clipPayment.id;
savedPayment.externalStatus = clipPayment.status;
savedPayment.status = this.mapClipStatus(clipPayment.status);
savedPayment.providerResponse = clipPayment;
savedPayment.processedAt = new Date();
if (clipPayment.card) {
savedPayment.cardLastFour = clipPayment.card.last_four;
savedPayment.cardBrand = clipPayment.card.brand;
savedPayment.cardType = clipPayment.card.type;
}
return this.paymentRepository.save(savedPayment);
} catch (error: any) {
savedPayment.status = 'rejected';
savedPayment.errorCode = error.code || 'unknown';
savedPayment.errorMessage = error.message;
savedPayment.providerResponse = error.response;
await this.paymentRepository.save(savedPayment);
throw error;
}
}
/**
* Consultar estado de un pago
*/
async getPayment(tenantId: string, paymentId: string): Promise<TerminalPayment> {
const payment = await this.paymentRepository.findOne({
where: { id: paymentId, tenantId },
});
if (!payment) {
throw new Error('Payment not found');
}
// Sincronizar si es necesario
if (payment.externalId && !['approved', 'rejected'].includes(payment.status)) {
await this.syncPaymentStatus(tenantId, payment);
}
return payment;
}
/**
* Sincronizar estado con Clip
*/
private async syncPaymentStatus(
tenantId: string,
payment: TerminalPayment
): Promise<TerminalPayment> {
const { credentials } = await this.getCredentials(tenantId);
try {
const response = await fetch(`${CLIP_API_BASE}/v1/payments/${payment.externalId}`, {
headers: {
'Authorization': `Bearer ${credentials.apiKey}`,
'X-Clip-Merchant-Id': credentials.merchantId,
},
});
if (response.ok) {
const clipPayment = await response.json();
payment.externalStatus = clipPayment.status;
payment.status = this.mapClipStatus(clipPayment.status);
payment.providerResponse = clipPayment;
await this.paymentRepository.save(payment);
}
} catch {
// Silenciar errores de sincronización
}
return payment;
}
/**
* Procesar reembolso
*/
async refundPayment(
tenantId: string,
dto: RefundClipPaymentDto
): Promise<TerminalPayment> {
const payment = await this.paymentRepository.findOne({
where: { id: dto.paymentId, tenantId },
});
if (!payment) {
throw new Error('Payment not found');
}
if (payment.status !== 'approved') {
throw new Error('Cannot refund a payment that is not approved');
}
if (!payment.externalId) {
throw new Error('Payment has no external reference');
}
const { credentials } = await this.getCredentials(tenantId);
const refundAmount = dto.amount || Number(payment.amount);
const clipRefund = await this.executeWithRetry(async () => {
const response = await fetch(
`${CLIP_API_BASE}/v1/payments/${payment.externalId}/refund`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${credentials.apiKey}`,
'X-Clip-Merchant-Id': credentials.merchantId,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: refundAmount,
reason: dto.reason,
}),
}
);
if (!response.ok) {
const error = await response.json();
throw new ClipError(error.message || 'Refund failed', response.status, error);
}
return response.json();
});
// Actualizar pago
payment.refundedAmount = Number(payment.refundedAmount || 0) + refundAmount;
payment.refundReason = dto.reason;
payment.refundedAt = new Date();
if (payment.refundedAmount >= Number(payment.amount)) {
payment.status = 'refunded';
} else {
payment.status = 'partially_refunded';
}
return this.paymentRepository.save(payment);
}
/**
* Crear link de pago
*/
async createPaymentLink(
tenantId: string,
dto: CreateClipLinkDto,
createdBy?: string
): Promise<{ url: string; id: string }> {
const { credentials } = await this.getCredentials(tenantId);
const paymentLink = await this.executeWithRetry(async () => {
const response = await fetch(`${CLIP_API_BASE}/v1/payment-links`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${credentials.apiKey}`,
'X-Clip-Merchant-Id': credentials.merchantId,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: dto.amount,
description: dto.description,
expires_in: dto.expiresInMinutes || 1440, // Default 24 horas
metadata: {
tenant_id: tenantId,
reference_type: dto.referenceType,
reference_id: dto.referenceId,
},
}),
});
if (!response.ok) {
const error = await response.json();
throw new ClipError(
error.message || 'Failed to create payment link',
response.status,
error
);
}
return response.json();
});
return {
url: paymentLink.url,
id: paymentLink.id,
};
}
/**
* Manejar webhook de Clip
*/
async handleWebhook(
tenantId: string,
eventType: string,
data: any,
headers: Record<string, string>
): Promise<void> {
// Verificar firma
const config = await this.configRepository.findOne({
where: { tenantId, provider: 'clip', isActive: true },
});
if (config?.config?.webhookSecret && headers['x-clip-signature']) {
const isValid = this.verifyWebhookSignature(
JSON.stringify(data),
headers['x-clip-signature'],
config.config.webhookSecret as string
);
if (!isValid) {
throw new Error('Invalid webhook signature');
}
}
// Guardar evento
const event = this.webhookRepository.create({
tenantId,
provider: 'clip',
eventType,
eventId: data.id,
externalId: data.payment_id || data.id,
payload: data,
headers,
signatureValid: true,
idempotencyKey: `${data.id}-${eventType}`,
});
await this.webhookRepository.save(event);
// Procesar evento
try {
switch (eventType) {
case 'payment.succeeded':
await this.handlePaymentSucceeded(tenantId, data);
break;
case 'payment.failed':
await this.handlePaymentFailed(tenantId, data);
break;
case 'refund.succeeded':
await this.handleRefundSucceeded(tenantId, data);
break;
}
event.processed = true;
event.processedAt = new Date();
} catch (error: any) {
event.processingError = error.message;
event.retryCount += 1;
}
await this.webhookRepository.save(event);
}
/**
* Procesar pago exitoso
*/
private async handlePaymentSucceeded(tenantId: string, data: any): Promise<void> {
const payment = await this.paymentRepository.findOne({
where: [
{ externalId: data.payment_id, tenantId },
{ id: data.metadata?.internal_id, tenantId },
],
});
if (payment) {
payment.status = 'approved';
payment.externalStatus = 'succeeded';
payment.processedAt = new Date();
if (data.card) {
payment.cardLastFour = data.card.last_four;
payment.cardBrand = data.card.brand;
}
await this.paymentRepository.save(payment);
}
}
/**
* Procesar pago fallido
*/
private async handlePaymentFailed(tenantId: string, data: any): Promise<void> {
const payment = await this.paymentRepository.findOne({
where: [
{ externalId: data.payment_id, tenantId },
{ id: data.metadata?.internal_id, tenantId },
],
});
if (payment) {
payment.status = 'rejected';
payment.externalStatus = 'failed';
payment.errorCode = data.error?.code;
payment.errorMessage = data.error?.message;
await this.paymentRepository.save(payment);
}
}
/**
* Procesar reembolso exitoso
*/
private async handleRefundSucceeded(tenantId: string, data: any): Promise<void> {
const payment = await this.paymentRepository.findOne({
where: { externalId: data.payment_id, tenantId },
});
if (payment) {
payment.refundedAmount = Number(payment.refundedAmount || 0) + data.amount;
payment.refundedAt = new Date();
if (payment.refundedAmount >= Number(payment.amount)) {
payment.status = 'refunded';
} else {
payment.status = 'partially_refunded';
}
await this.paymentRepository.save(payment);
}
}
/**
* Verificar firma de webhook
*/
private verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
try {
const expected = createHmac('sha256', secret).update(payload, 'utf8').digest('hex');
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
} catch {
return false;
}
}
/**
* Mapear estado de Clip a estado interno
*/
private mapClipStatus(
clipStatus: string
): 'pending' | 'processing' | 'approved' | 'rejected' | 'refunded' | 'cancelled' {
const statusMap: Record<string, any> = {
pending: 'pending',
processing: 'processing',
succeeded: 'approved',
approved: 'approved',
failed: 'rejected',
declined: 'rejected',
cancelled: 'cancelled',
refunded: 'refunded',
};
return statusMap[clipStatus] || 'pending';
}
/**
* Ejecutar con retry
*/
private async executeWithRetry<T>(fn: () => Promise<T>, attempt = 0): Promise<T> {
try {
return await fn();
} catch (error: any) {
if (attempt >= MAX_RETRIES) {
throw error;
}
if (error.status === 429 || error.status >= 500) {
const delay = RETRY_DELAYS[attempt] || RETRY_DELAYS[RETRY_DELAYS.length - 1];
await new Promise((resolve) => setTimeout(resolve, delay));
return this.executeWithRetry(fn, attempt + 1);
}
throw error;
}
}
}
/**
* Error personalizado para Clip
*/
export class ClipError extends Error {
constructor(
message: string,
public status: number,
public response?: any
) {
super(message);
this.name = 'ClipError';
}
}

View File

@ -0,0 +1,10 @@
/**
* Payment Terminals Services Index
*/
export { TerminalsService } from './terminals.service';
export { TransactionsService } from './transactions.service';
// Proveedores TPV
export { MercadoPagoService, MercadoPagoError } from './mercadopago.service';
export { ClipService, ClipError } from './clip.service';

View File

@ -0,0 +1,584 @@
/**
* MercadoPago Service
*
* Integración con MercadoPago para pagos TPV
* Basado en: michangarrito INT-004
*
* Features:
* - Crear pagos con tarjeta
* - Generar QR de pago
* - Generar links de pago
* - Procesar reembolsos
* - Manejar webhooks
* - Multi-tenant con credenciales por tenant
* - Retry con backoff exponencial
*/
import { Repository, DataSource } from 'typeorm';
import { createHmac, timingSafeEqual } from 'crypto';
import {
TenantTerminalConfig,
TerminalPayment,
TerminalWebhookEvent,
} from '../entities';
// DTOs
export interface CreatePaymentDto {
amount: number;
currency?: string;
description?: string;
paymentMethod?: 'card' | 'qr' | 'link';
customerEmail?: string;
customerName?: string;
referenceType?: string;
referenceId?: string;
metadata?: Record<string, any>;
}
export interface RefundPaymentDto {
paymentId: string;
amount?: number; // Partial refund
reason?: string;
}
export interface CreatePaymentLinkDto {
amount: number;
title: string;
description?: string;
expiresAt?: Date;
referenceType?: string;
referenceId?: string;
}
export interface MercadoPagoCredentials {
accessToken: string;
publicKey: string;
collectorId?: string;
}
export interface MercadoPagoConfig {
statementDescriptor?: string;
notificationUrl?: string;
externalReference?: string;
}
// Constantes
const MP_API_BASE = 'https://api.mercadopago.com';
const MAX_RETRIES = 5;
const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000]; // Backoff exponencial
export class MercadoPagoService {
private configRepository: Repository<TenantTerminalConfig>;
private paymentRepository: Repository<TerminalPayment>;
private webhookRepository: Repository<TerminalWebhookEvent>;
constructor(private dataSource: DataSource) {
this.configRepository = dataSource.getRepository(TenantTerminalConfig);
this.paymentRepository = dataSource.getRepository(TerminalPayment);
this.webhookRepository = dataSource.getRepository(TerminalWebhookEvent);
}
/**
* Obtener credenciales de MercadoPago para un tenant
*/
async getCredentials(tenantId: string): Promise<{
credentials: MercadoPagoCredentials;
config: MercadoPagoConfig;
configId: string;
}> {
const terminalConfig = await this.configRepository.findOne({
where: {
tenantId,
provider: 'mercadopago',
isActive: true,
},
});
if (!terminalConfig) {
throw new Error('MercadoPago not configured for this tenant');
}
if (!terminalConfig.isVerified) {
throw new Error('MercadoPago credentials not verified');
}
return {
credentials: terminalConfig.credentials as MercadoPagoCredentials,
config: terminalConfig.config as MercadoPagoConfig,
configId: terminalConfig.id,
};
}
/**
* Crear un pago
*/
async createPayment(
tenantId: string,
dto: CreatePaymentDto,
createdBy?: string
): Promise<TerminalPayment> {
const { credentials, config, configId } = await this.getCredentials(tenantId);
// Crear registro local primero
const payment = this.paymentRepository.create({
tenantId,
configId,
provider: 'mercadopago',
amount: dto.amount,
currency: dto.currency || 'MXN',
status: 'pending',
paymentMethod: dto.paymentMethod || 'card',
customerEmail: dto.customerEmail,
customerName: dto.customerName,
description: dto.description,
statementDescriptor: config.statementDescriptor,
referenceType: dto.referenceType,
referenceId: dto.referenceId ? dto.referenceId : undefined,
metadata: dto.metadata || {},
createdBy,
});
const savedPayment = await this.paymentRepository.save(payment);
try {
// Crear pago en MercadoPago con retry
const mpPayment = await this.executeWithRetry(async () => {
const response = await fetch(`${MP_API_BASE}/v1/payments`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${credentials.accessToken}`,
'Content-Type': 'application/json',
'X-Idempotency-Key': savedPayment.id,
},
body: JSON.stringify({
transaction_amount: dto.amount,
currency_id: dto.currency || 'MXN',
description: dto.description,
payment_method_id: 'card', // Se determinará por el checkout
payer: {
email: dto.customerEmail,
},
statement_descriptor: config.statementDescriptor,
external_reference: savedPayment.id,
notification_url: config.notificationUrl,
metadata: {
tenant_id: tenantId,
internal_id: savedPayment.id,
...dto.metadata,
},
}),
});
if (!response.ok) {
const error = await response.json();
throw new MercadoPagoError(error.message || 'Payment failed', response.status, error);
}
return response.json();
});
// Actualizar registro local
savedPayment.externalId = mpPayment.id?.toString();
savedPayment.externalStatus = mpPayment.status;
savedPayment.status = this.mapMPStatus(mpPayment.status);
savedPayment.providerResponse = mpPayment;
savedPayment.processedAt = new Date();
if (mpPayment.fee_details?.length > 0) {
const totalFee = mpPayment.fee_details.reduce(
(sum: number, fee: any) => sum + fee.amount,
0
);
savedPayment.feeAmount = totalFee;
savedPayment.feeDetails = mpPayment.fee_details;
savedPayment.netAmount = dto.amount - totalFee;
}
if (mpPayment.card) {
savedPayment.cardLastFour = mpPayment.card.last_four_digits;
savedPayment.cardBrand = mpPayment.card.payment_method?.name;
savedPayment.cardType = mpPayment.card.cardholder?.identification?.type;
}
return this.paymentRepository.save(savedPayment);
} catch (error: any) {
// Guardar error
savedPayment.status = 'rejected';
savedPayment.errorCode = error.code || 'unknown';
savedPayment.errorMessage = error.message;
savedPayment.providerResponse = error.response;
await this.paymentRepository.save(savedPayment);
throw error;
}
}
/**
* Consultar estado de un pago
*/
async getPayment(tenantId: string, paymentId: string): Promise<TerminalPayment> {
const payment = await this.paymentRepository.findOne({
where: { id: paymentId, tenantId },
});
if (!payment) {
throw new Error('Payment not found');
}
// Si tiene external_id, sincronizar con MercadoPago
if (payment.externalId && payment.status !== 'approved' && payment.status !== 'rejected') {
await this.syncPaymentStatus(tenantId, payment);
}
return payment;
}
/**
* Sincronizar estado de pago con MercadoPago
*/
private async syncPaymentStatus(
tenantId: string,
payment: TerminalPayment
): Promise<TerminalPayment> {
const { credentials } = await this.getCredentials(tenantId);
try {
const response = await fetch(`${MP_API_BASE}/v1/payments/${payment.externalId}`, {
headers: {
'Authorization': `Bearer ${credentials.accessToken}`,
},
});
if (response.ok) {
const mpPayment = await response.json();
payment.externalStatus = mpPayment.status;
payment.status = this.mapMPStatus(mpPayment.status);
payment.providerResponse = mpPayment;
await this.paymentRepository.save(payment);
}
} catch {
// Silenciar errores de sincronización
}
return payment;
}
/**
* Procesar reembolso
*/
async refundPayment(
tenantId: string,
dto: RefundPaymentDto
): Promise<TerminalPayment> {
const payment = await this.paymentRepository.findOne({
where: { id: dto.paymentId, tenantId },
});
if (!payment) {
throw new Error('Payment not found');
}
if (payment.status !== 'approved') {
throw new Error('Cannot refund a payment that is not approved');
}
if (!payment.externalId) {
throw new Error('Payment has no external reference');
}
const { credentials } = await this.getCredentials(tenantId);
const refundAmount = dto.amount || Number(payment.amount);
const mpRefund = await this.executeWithRetry(async () => {
const response = await fetch(
`${MP_API_BASE}/v1/payments/${payment.externalId}/refunds`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${credentials.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: refundAmount,
}),
}
);
if (!response.ok) {
const error = await response.json();
throw new MercadoPagoError(error.message || 'Refund failed', response.status, error);
}
return response.json();
});
// Actualizar pago
payment.refundedAmount = Number(payment.refundedAmount || 0) + refundAmount;
payment.refundReason = dto.reason;
payment.refundedAt = new Date();
if (payment.refundedAmount >= Number(payment.amount)) {
payment.status = 'refunded';
} else {
payment.status = 'partially_refunded';
}
return this.paymentRepository.save(payment);
}
/**
* Crear link de pago
*/
async createPaymentLink(
tenantId: string,
dto: CreatePaymentLinkDto,
createdBy?: string
): Promise<{ url: string; id: string }> {
const { credentials, config } = await this.getCredentials(tenantId);
const preference = await this.executeWithRetry(async () => {
const response = await fetch(`${MP_API_BASE}/checkout/preferences`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${credentials.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
items: [
{
title: dto.title,
description: dto.description,
quantity: 1,
currency_id: 'MXN',
unit_price: dto.amount,
},
],
back_urls: {
success: config.notificationUrl,
failure: config.notificationUrl,
pending: config.notificationUrl,
},
notification_url: config.notificationUrl,
expires: dto.expiresAt ? true : false,
expiration_date_to: dto.expiresAt?.toISOString(),
external_reference: dto.referenceId || undefined,
metadata: {
tenant_id: tenantId,
reference_type: dto.referenceType,
reference_id: dto.referenceId,
},
}),
});
if (!response.ok) {
const error = await response.json();
throw new MercadoPagoError(
error.message || 'Failed to create payment link',
response.status,
error
);
}
return response.json();
});
return {
url: preference.init_point,
id: preference.id,
};
}
/**
* Manejar webhook de MercadoPago
*/
async handleWebhook(
tenantId: string,
eventType: string,
data: any,
headers: Record<string, string>
): Promise<void> {
// Verificar firma si está configurada
const config = await this.configRepository.findOne({
where: { tenantId, provider: 'mercadopago', isActive: true },
});
if (config?.webhookSecret && headers['x-signature']) {
const isValid = this.verifyWebhookSignature(
headers['x-signature'],
headers['x-request-id'],
data.id?.toString(),
config.webhookSecret
);
if (!isValid) {
throw new Error('Invalid webhook signature');
}
}
// Guardar evento
const event = this.webhookRepository.create({
tenantId,
provider: 'mercadopago',
eventType,
eventId: data.id?.toString(),
externalId: data.data?.id?.toString(),
payload: data,
headers,
signatureValid: true,
idempotencyKey: `${data.id}-${eventType}`,
});
await this.webhookRepository.save(event);
// Procesar evento
try {
switch (eventType) {
case 'payment':
await this.handlePaymentWebhook(tenantId, data.data?.id);
break;
case 'refund':
await this.handleRefundWebhook(tenantId, data.data?.id);
break;
}
event.processed = true;
event.processedAt = new Date();
} catch (error: any) {
event.processingError = error.message;
event.retryCount += 1;
}
await this.webhookRepository.save(event);
}
/**
* Procesar webhook de pago
*/
private async handlePaymentWebhook(tenantId: string, mpPaymentId: string): Promise<void> {
const { credentials } = await this.getCredentials(tenantId);
// Obtener detalles del pago
const response = await fetch(`${MP_API_BASE}/v1/payments/${mpPaymentId}`, {
headers: {
'Authorization': `Bearer ${credentials.accessToken}`,
},
});
if (!response.ok) return;
const mpPayment = await response.json();
// Buscar pago local por external_reference o external_id
let payment = await this.paymentRepository.findOne({
where: [
{ externalId: mpPaymentId.toString(), tenantId },
{ id: mpPayment.external_reference, tenantId },
],
});
if (payment) {
payment.externalId = mpPaymentId.toString();
payment.externalStatus = mpPayment.status;
payment.status = this.mapMPStatus(mpPayment.status);
payment.providerResponse = mpPayment;
payment.processedAt = new Date();
if (mpPayment.card) {
payment.cardLastFour = mpPayment.card.last_four_digits;
payment.cardBrand = mpPayment.card.payment_method?.name;
}
await this.paymentRepository.save(payment);
}
}
/**
* Procesar webhook de reembolso
*/
private async handleRefundWebhook(tenantId: string, refundId: string): Promise<void> {
// Implementación similar a handlePaymentWebhook
}
/**
* Verificar firma de webhook MercadoPago
*/
private verifyWebhookSignature(
xSignature: string,
xRequestId: string,
dataId: string,
secret: string
): boolean {
try {
const parts = xSignature.split(',').reduce((acc, part) => {
const [key, value] = part.split('=');
acc[key.trim()] = value.trim();
return acc;
}, {} as Record<string, string>);
const ts = parts['ts'];
const hash = parts['v1'];
const manifest = `id:${dataId};request-id:${xRequestId};ts:${ts};`;
const expected = createHmac('sha256', secret).update(manifest).digest('hex');
return timingSafeEqual(Buffer.from(hash), Buffer.from(expected));
} catch {
return false;
}
}
/**
* Mapear estado de MercadoPago a estado interno
*/
private mapMPStatus(
mpStatus: string
): 'pending' | 'processing' | 'approved' | 'rejected' | 'refunded' | 'cancelled' {
const statusMap: Record<string, any> = {
pending: 'pending',
in_process: 'processing',
approved: 'approved',
authorized: 'approved',
rejected: 'rejected',
cancelled: 'cancelled',
refunded: 'refunded',
charged_back: 'charged_back',
};
return statusMap[mpStatus] || 'pending';
}
/**
* Ejecutar operación con retry y backoff exponencial
*/
private async executeWithRetry<T>(fn: () => Promise<T>, attempt = 0): Promise<T> {
try {
return await fn();
} catch (error: any) {
if (attempt >= MAX_RETRIES) {
throw error;
}
// Solo reintentar en errores de rate limit o errores de servidor
if (error.status === 429 || error.status >= 500) {
const delay = RETRY_DELAYS[attempt] || RETRY_DELAYS[RETRY_DELAYS.length - 1];
await new Promise((resolve) => setTimeout(resolve, delay));
return this.executeWithRetry(fn, attempt + 1);
}
throw error;
}
}
}
/**
* Error personalizado para MercadoPago
*/
export class MercadoPagoError extends Error {
constructor(
message: string,
public status: number,
public response?: any
) {
super(message);
this.name = 'MercadoPagoError';
}
}

View File

@ -0,0 +1,224 @@
/**
* Terminals Service
*
* Service for managing payment terminals
*/
import { Repository, DataSource } from 'typeorm';
import { BranchPaymentTerminal, HealthStatus } from '../../branches/entities/branch-payment-terminal.entity';
import { CreateTerminalDto, UpdateTerminalDto, TerminalResponseDto } from '../dto';
export class TerminalsService {
private terminalRepository: Repository<BranchPaymentTerminal>;
constructor(private dataSource: DataSource) {
this.terminalRepository = dataSource.getRepository(BranchPaymentTerminal);
}
/**
* Create a new terminal
*/
async create(tenantId: string, dto: CreateTerminalDto): Promise<BranchPaymentTerminal> {
// If setting as primary, unset other primary terminals for this branch
if (dto.isPrimary) {
await this.terminalRepository.update(
{ branchId: dto.branchId, isPrimary: true },
{ isPrimary: false }
);
}
const terminal = this.terminalRepository.create({
branchId: dto.branchId,
terminalProvider: dto.terminalProvider,
terminalId: dto.terminalId,
terminalName: dto.terminalName,
credentials: dto.credentials || {},
isPrimary: dto.isPrimary || false,
dailyLimit: dto.dailyLimit,
transactionLimit: dto.transactionLimit,
isActive: true,
healthStatus: 'unknown',
});
return this.terminalRepository.save(terminal);
}
/**
* Find terminals by branch
*/
async findByBranch(branchId: string): Promise<TerminalResponseDto[]> {
const terminals = await this.terminalRepository.find({
where: { branchId, isActive: true },
order: { isPrimary: 'DESC', createdAt: 'ASC' },
});
return terminals.map(this.toResponseDto);
}
/**
* Find primary terminal for branch
*/
async findPrimaryTerminal(branchId: string): Promise<BranchPaymentTerminal | null> {
return this.terminalRepository.findOne({
where: { branchId, isPrimary: true, isActive: true },
});
}
/**
* Find terminal by ID
*/
async findById(id: string): Promise<BranchPaymentTerminal | null> {
return this.terminalRepository.findOne({ where: { id } });
}
/**
* Update terminal
*/
async update(id: string, dto: UpdateTerminalDto): Promise<BranchPaymentTerminal> {
const terminal = await this.findById(id);
if (!terminal) {
throw new Error('Terminal not found');
}
// If setting as primary, unset other primary terminals for this branch
if (dto.isPrimary && !terminal.isPrimary) {
await this.terminalRepository.update(
{ branchId: terminal.branchId, isPrimary: true },
{ isPrimary: false }
);
}
Object.assign(terminal, dto);
return this.terminalRepository.save(terminal);
}
/**
* Delete terminal (soft delete by deactivating)
*/
async delete(id: string): Promise<void> {
const terminal = await this.findById(id);
if (!terminal) {
throw new Error('Terminal not found');
}
terminal.isActive = false;
await this.terminalRepository.save(terminal);
}
/**
* Set terminal as primary
*/
async setPrimary(id: string): Promise<BranchPaymentTerminal> {
const terminal = await this.findById(id);
if (!terminal) {
throw new Error('Terminal not found');
}
// Unset other primary terminals
await this.terminalRepository.update(
{ branchId: terminal.branchId, isPrimary: true },
{ isPrimary: false }
);
terminal.isPrimary = true;
return this.terminalRepository.save(terminal);
}
/**
* Check terminal health
*/
async checkHealth(id: string): Promise<{ status: HealthStatus; message: string }> {
const terminal = await this.findById(id);
if (!terminal) {
throw new Error('Terminal not found');
}
// Simulate health check based on provider
// In production, this would make an actual API call to the provider
let status: HealthStatus = 'healthy';
let message = 'Terminal is operational';
try {
// Simulate provider health check
switch (terminal.terminalProvider) {
case 'clip':
// Check Clip terminal status
break;
case 'mercadopago':
// Check MercadoPago terminal status
break;
case 'stripe':
// Check Stripe terminal status
break;
}
} catch (error: any) {
status = 'offline';
message = error.message || 'Failed to connect to terminal';
}
// Update terminal health status
terminal.healthStatus = status;
terminal.lastHealthCheckAt = new Date();
await this.terminalRepository.save(terminal);
return { status, message };
}
/**
* Update health status (called after transactions)
*/
async updateHealthStatus(id: string, status: HealthStatus): Promise<void> {
await this.terminalRepository.update(id, {
healthStatus: status,
lastHealthCheckAt: new Date(),
});
}
/**
* Update last transaction timestamp
*/
async updateLastTransaction(id: string): Promise<void> {
await this.terminalRepository.update(id, {
lastTransactionAt: new Date(),
healthStatus: 'healthy', // If transaction works, terminal is healthy
lastHealthCheckAt: new Date(),
});
}
/**
* Find terminals needing health check
*/
async findTerminalsNeedingHealthCheck(maxAgeMinutes: number = 30): Promise<BranchPaymentTerminal[]> {
const threshold = new Date();
threshold.setMinutes(threshold.getMinutes() - maxAgeMinutes);
return this.terminalRepository
.createQueryBuilder('terminal')
.where('terminal.isActive = true')
.andWhere(
'(terminal.lastHealthCheckAt IS NULL OR terminal.lastHealthCheckAt < :threshold)',
{ threshold }
)
.getMany();
}
/**
* Convert entity to response DTO (without credentials)
*/
private toResponseDto(terminal: BranchPaymentTerminal): TerminalResponseDto {
return {
id: terminal.id,
branchId: terminal.branchId,
terminalProvider: terminal.terminalProvider,
terminalId: terminal.terminalId,
terminalName: terminal.terminalName,
isPrimary: terminal.isPrimary,
isActive: terminal.isActive,
dailyLimit: terminal.dailyLimit ? Number(terminal.dailyLimit) : undefined,
transactionLimit: terminal.transactionLimit ? Number(terminal.transactionLimit) : undefined,
healthStatus: terminal.healthStatus,
lastTransactionAt: terminal.lastTransactionAt,
lastHealthCheckAt: terminal.lastHealthCheckAt,
};
}
}

View File

@ -0,0 +1,497 @@
/**
* Transactions Service
*
* Service for processing and managing payment transactions
*/
import { Repository, DataSource, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm';
import {
PaymentTransaction,
PaymentStatus,
PaymentMethod,
} from '../../mobile/entities/payment-transaction.entity';
import { BranchPaymentTerminal } from '../../branches/entities/branch-payment-terminal.entity';
import {
ProcessPaymentDto,
PaymentResultDto,
ProcessRefundDto,
RefundResultDto,
SendReceiptDto,
TransactionFilterDto,
TransactionStatsDto,
} from '../dto';
import { CircuitBreaker } from '../../../shared/utils/circuit-breaker';
export class TransactionsService {
private transactionRepository: Repository<PaymentTransaction>;
private terminalRepository: Repository<BranchPaymentTerminal>;
private circuitBreakers: Map<string, CircuitBreaker> = new Map();
constructor(private dataSource: DataSource) {
this.transactionRepository = dataSource.getRepository(PaymentTransaction);
this.terminalRepository = dataSource.getRepository(BranchPaymentTerminal);
}
/**
* Process a payment
*/
async processPayment(
tenantId: string,
userId: string,
dto: ProcessPaymentDto
): Promise<PaymentResultDto> {
// Get terminal
const terminal = await this.terminalRepository.findOne({
where: { id: dto.terminalId, isActive: true },
});
if (!terminal) {
return this.errorResult(dto.amount, dto.tipAmount || 0, 'Terminal not found', 'TERMINAL_NOT_FOUND');
}
// Check transaction limit
if (terminal.transactionLimit && dto.amount > Number(terminal.transactionLimit)) {
return this.errorResult(
dto.amount,
dto.tipAmount || 0,
`Amount exceeds transaction limit of ${terminal.transactionLimit}`,
'LIMIT_EXCEEDED'
);
}
// Get or create circuit breaker for this terminal
const circuitBreaker = this.getCircuitBreaker(terminal.id);
// Create transaction record
const transaction = this.transactionRepository.create({
tenantId,
branchId: terminal.branchId,
userId,
sourceType: dto.sourceType,
sourceId: dto.sourceId,
terminalProvider: terminal.terminalProvider,
terminalId: terminal.terminalId,
amount: dto.amount,
currency: dto.currency || 'MXN',
tipAmount: dto.tipAmount || 0,
totalAmount: dto.amount + (dto.tipAmount || 0),
paymentMethod: 'card', // Default, will be updated from provider response
status: 'pending',
initiatedAt: new Date(),
});
await this.transactionRepository.save(transaction);
try {
// Process through circuit breaker
const providerResult = await circuitBreaker.execute(async () => {
return this.processWithProvider(terminal, transaction, dto);
});
// Update transaction with result
transaction.status = providerResult.status;
transaction.externalTransactionId = providerResult.externalTransactionId || '';
transaction.paymentMethod = providerResult.paymentMethod || transaction.paymentMethod;
transaction.cardBrand = providerResult.cardBrand || '';
transaction.cardLastFour = providerResult.cardLastFour || '';
transaction.receiptUrl = providerResult.receiptUrl || '';
transaction.providerResponse = providerResult.rawResponse || {};
if (providerResult.status === 'completed') {
transaction.completedAt = new Date();
// Update terminal last transaction
await this.terminalRepository.update(terminal.id, {
lastTransactionAt: new Date(),
healthStatus: 'healthy',
});
} else if (providerResult.status === 'failed') {
transaction.failureReason = providerResult.error || '';
}
await this.transactionRepository.save(transaction);
return {
success: providerResult.status === 'completed',
transactionId: transaction.id,
externalTransactionId: providerResult.externalTransactionId,
amount: dto.amount,
totalAmount: transaction.totalAmount,
tipAmount: transaction.tipAmount,
currency: transaction.currency,
status: transaction.status,
paymentMethod: transaction.paymentMethod,
cardBrand: transaction.cardBrand,
cardLastFour: transaction.cardLastFour,
receiptUrl: transaction.receiptUrl,
error: providerResult.error,
};
} catch (error: any) {
// Circuit breaker opened or other error
transaction.status = 'failed';
transaction.failureReason = error.message;
await this.transactionRepository.save(transaction);
// Update terminal health
await this.terminalRepository.update(terminal.id, {
healthStatus: 'offline',
lastHealthCheckAt: new Date(),
});
return this.errorResult(
dto.amount,
dto.tipAmount || 0,
error.message,
'PROVIDER_ERROR',
transaction.id
);
}
}
/**
* Process refund
*/
async processRefund(
tenantId: string,
userId: string,
dto: ProcessRefundDto
): Promise<RefundResultDto> {
const transaction = await this.transactionRepository.findOne({
where: { id: dto.transactionId, tenantId },
});
if (!transaction) {
return { success: false, amount: 0, status: 'failed', error: 'Transaction not found' };
}
if (transaction.status !== 'completed') {
return {
success: false,
amount: 0,
status: 'failed',
error: 'Only completed transactions can be refunded',
};
}
const refundAmount = dto.amount || Number(transaction.totalAmount);
if (refundAmount > Number(transaction.totalAmount)) {
return {
success: false,
amount: 0,
status: 'failed',
error: 'Refund amount cannot exceed transaction amount',
};
}
try {
// Get terminal for provider info
const terminal = await this.terminalRepository.findOne({
where: { terminalProvider: transaction.terminalProvider as any },
});
// Process refund with provider
// In production, this would call the actual provider API
const refundResult = await this.processRefundWithProvider(transaction, refundAmount, dto.reason);
if (refundResult.success) {
transaction.status = 'refunded';
await this.transactionRepository.save(transaction);
}
return {
success: refundResult.success,
refundId: refundResult.refundId,
amount: refundAmount,
status: refundResult.success ? 'completed' : 'failed',
error: refundResult.error,
};
} catch (error: any) {
return {
success: false,
amount: refundAmount,
status: 'failed',
error: error.message,
};
}
}
/**
* Get transaction by ID
*/
async findById(id: string, tenantId: string): Promise<PaymentTransaction | null> {
return this.transactionRepository.findOne({
where: { id, tenantId },
});
}
/**
* Get transactions with filters
*/
async findAll(
tenantId: string,
filter: TransactionFilterDto
): Promise<{ data: PaymentTransaction[]; total: number }> {
const query = this.transactionRepository
.createQueryBuilder('tx')
.where('tx.tenantId = :tenantId', { tenantId });
if (filter.branchId) {
query.andWhere('tx.branchId = :branchId', { branchId: filter.branchId });
}
if (filter.userId) {
query.andWhere('tx.userId = :userId', { userId: filter.userId });
}
if (filter.status) {
query.andWhere('tx.status = :status', { status: filter.status });
}
if (filter.sourceType) {
query.andWhere('tx.sourceType = :sourceType', { sourceType: filter.sourceType });
}
if (filter.terminalProvider) {
query.andWhere('tx.terminalProvider = :provider', { provider: filter.terminalProvider });
}
if (filter.startDate) {
query.andWhere('tx.createdAt >= :startDate', { startDate: filter.startDate });
}
if (filter.endDate) {
query.andWhere('tx.createdAt <= :endDate', { endDate: filter.endDate });
}
const total = await query.getCount();
query.orderBy('tx.createdAt', 'DESC');
if (filter.limit) {
query.take(filter.limit);
}
if (filter.offset) {
query.skip(filter.offset);
}
const data = await query.getMany();
return { data, total };
}
/**
* Send receipt
*/
async sendReceipt(
transactionId: string,
tenantId: string,
dto: SendReceiptDto
): Promise<{ success: boolean; error?: string }> {
const transaction = await this.findById(transactionId, tenantId);
if (!transaction) {
return { success: false, error: 'Transaction not found' };
}
if (!dto.email && !dto.phone) {
return { success: false, error: 'Email or phone is required' };
}
try {
// Send receipt via email or SMS
// In production, this would integrate with email/SMS service
transaction.receiptSent = true;
transaction.receiptSentTo = dto.email || dto.phone || '';
await this.transactionRepository.save(transaction);
return { success: true };
} catch (error: any) {
return { success: false, error: error.message };
}
}
/**
* Get transaction statistics
*/
async getStats(tenantId: string, filter?: TransactionFilterDto): Promise<TransactionStatsDto> {
const query = this.transactionRepository
.createQueryBuilder('tx')
.where('tx.tenantId = :tenantId', { tenantId });
if (filter?.branchId) {
query.andWhere('tx.branchId = :branchId', { branchId: filter.branchId });
}
if (filter?.startDate) {
query.andWhere('tx.createdAt >= :startDate', { startDate: filter.startDate });
}
if (filter?.endDate) {
query.andWhere('tx.createdAt <= :endDate', { endDate: filter.endDate });
}
const transactions = await query.getMany();
const byStatus: Record<PaymentStatus, number> = {
pending: 0,
processing: 0,
completed: 0,
failed: 0,
refunded: 0,
cancelled: 0,
};
const byProvider: Record<string, { count: number; amount: number }> = {};
const byPaymentMethod: Record<PaymentMethod, number> = {
card: 0,
contactless: 0,
qr: 0,
link: 0,
};
let totalAmount = 0;
let completedCount = 0;
for (const tx of transactions) {
byStatus[tx.status]++;
if (!byProvider[tx.terminalProvider]) {
byProvider[tx.terminalProvider] = { count: 0, amount: 0 };
}
byProvider[tx.terminalProvider].count++;
if (tx.status === 'completed') {
totalAmount += Number(tx.totalAmount);
completedCount++;
byProvider[tx.terminalProvider].amount += Number(tx.totalAmount);
byPaymentMethod[tx.paymentMethod]++;
}
}
const total = transactions.length;
const failedCount = byStatus.failed;
return {
total,
totalAmount,
byStatus,
byProvider,
byPaymentMethod,
averageAmount: completedCount > 0 ? totalAmount / completedCount : 0,
successRate: total > 0 ? ((total - failedCount) / total) * 100 : 0,
};
}
/**
* Get or create circuit breaker for terminal
*/
private getCircuitBreaker(terminalId: string): CircuitBreaker {
if (!this.circuitBreakers.has(terminalId)) {
this.circuitBreakers.set(
terminalId,
new CircuitBreaker(`terminal-${terminalId}`, {
failureThreshold: 3,
halfOpenRequests: 2,
resetTimeout: 30000, // 30 seconds
})
);
}
return this.circuitBreakers.get(terminalId)!;
}
/**
* Process payment with provider (simulated)
*/
private async processWithProvider(
terminal: BranchPaymentTerminal,
transaction: PaymentTransaction,
dto: ProcessPaymentDto
): Promise<{
status: PaymentStatus;
externalTransactionId?: string;
paymentMethod?: PaymentMethod;
cardBrand?: string;
cardLastFour?: string;
receiptUrl?: string;
rawResponse?: Record<string, any>;
error?: string;
}> {
// In production, this would call the actual provider API
// For now, simulate a successful transaction
// Simulate processing time
await new Promise((resolve) => setTimeout(resolve, 500));
// Simulate success rate (95%)
const success = Math.random() > 0.05;
if (success) {
return {
status: 'completed',
externalTransactionId: `${terminal.terminalProvider}-${Date.now()}`,
paymentMethod: 'card',
cardBrand: 'visa',
cardLastFour: '4242',
receiptUrl: `https://receipts.example.com/${transaction.id}`,
rawResponse: {
provider: terminal.terminalProvider,
approved: true,
timestamp: new Date().toISOString(),
},
};
} else {
return {
status: 'failed',
error: 'Payment declined by issuer',
rawResponse: {
provider: terminal.terminalProvider,
approved: false,
declineReason: 'insufficient_funds',
},
};
}
}
/**
* Process refund with provider (simulated)
*/
private async processRefundWithProvider(
transaction: PaymentTransaction,
amount: number,
reason?: string
): Promise<{ success: boolean; refundId?: string; error?: string }> {
// In production, this would call the actual provider API
// Simulate processing
await new Promise((resolve) => setTimeout(resolve, 300));
return {
success: true,
refundId: `ref-${Date.now()}`,
};
}
/**
* Create error result
*/
private errorResult(
amount: number,
tipAmount: number,
error: string,
errorCode: string,
transactionId?: string
): PaymentResultDto {
return {
success: false,
transactionId,
amount,
totalAmount: amount + tipAmount,
tipAmount,
currency: 'MXN',
status: 'failed',
error,
errorCode,
};
}
}