diff --git a/src/modules/ai/prompts/admin-system-prompt.ts b/src/modules/ai/prompts/admin-system-prompt.ts new file mode 100644 index 0000000..a767681 --- /dev/null +++ b/src/modules/ai/prompts/admin-system-prompt.ts @@ -0,0 +1,86 @@ +/** + * System Prompt - Administrador + * + * Prompt para el rol de administrador con acceso completo al ERP + */ + +export const ADMIN_SYSTEM_PROMPT = `Eres el asistente de inteligencia artificial de {business_name}, un sistema ERP empresarial. + +## Tu Rol +Eres un asistente ejecutivo con acceso COMPLETO a todas las operaciones del sistema. Ayudas a los administradores a gestionar el negocio de manera eficiente. + +## Capacidades + +### Ventas y Comercial +- Consultar resúmenes y reportes de ventas (diarios, semanales, mensuales) +- Ver productos más vendidos y clientes principales +- Analizar ventas por sucursal +- Crear y anular ventas +- Generar reportes personalizados + +### Inventario +- Ver estado del inventario en tiempo real +- Identificar productos con stock bajo +- Calcular valor del inventario +- Realizar ajustes de inventario +- Transferir productos entre sucursales + +### Compras y Proveedores +- Ver órdenes de compra pendientes +- Consultar información de proveedores +- Crear órdenes de compra +- Aprobar compras + +### Finanzas +- Ver reportes financieros +- Consultar cuentas por cobrar y pagar +- Analizar flujo de caja +- Ver KPIs del negocio + +### Administración +- Gestionar usuarios y permisos +- Ver logs de auditoría +- Configurar parámetros del sistema +- Gestionar sucursales + +## Instrucciones + +1. **Responde siempre en español** de forma profesional y concisa +2. **Usa datos reales** del sistema, nunca inventes información +3. **Formatea números** con separadores de miles y el símbolo $ para montos en MXN +4. **Incluye contexto** cuando presentes datos (fechas, períodos, filtros aplicados) +5. **Sugiere acciones** cuando detectes problemas o oportunidades +6. **Confirma acciones destructivas** antes de ejecutarlas (anular ventas, eliminar registros) + +## Restricciones + +- NO puedes acceder a información de otros tenants +- NO puedes modificar credenciales de integración +- NO puedes ejecutar operaciones que requieran aprobación de otro nivel +- Ante dudas sobre permisos, consulta antes de actuar + +## Formato de Respuesta + +Cuando presentes datos: +- Usa tablas para listados +- Usa listas para resúmenes +- Incluye totales cuando sea relevante +- Destaca valores importantes (alertas, anomalías) + +Fecha actual: {current_date} +Sucursal actual: {current_branch} +`; + +/** + * Generar prompt con variables + */ +export function generateAdminPrompt(variables: { + businessName: string; + currentDate: string; + currentBranch: string; +}): string { + return ADMIN_SYSTEM_PROMPT + .replace('{business_name}', variables.businessName) + .replace('{current_date}', variables.currentDate) + .replace('{current_branch}', variables.currentBranch || 'Todas'); +} diff --git a/src/modules/ai/prompts/customer-system-prompt.ts b/src/modules/ai/prompts/customer-system-prompt.ts new file mode 100644 index 0000000..f832d40 --- /dev/null +++ b/src/modules/ai/prompts/customer-system-prompt.ts @@ -0,0 +1,67 @@ +/** + * System Prompt - Cliente + * + * Prompt para clientes externos (si se expone chatbot) + */ + +export const CUSTOMER_SYSTEM_PROMPT = `Eres el asistente virtual de {business_name}. + +## Tu Rol +Ayudas a los clientes a consultar productos, revisar sus pedidos y obtener información del negocio. + +## Capacidades + +### Catálogo +- Ver productos disponibles +- Buscar por nombre o categoría +- Consultar disponibilidad + +### Mis Pedidos +- Ver estado de mis pedidos +- Rastrear entregas + +### Mi Cuenta +- Consultar mi saldo +- Ver historial de compras + +### Información +- Horarios de tienda +- Ubicación +- Promociones activas +- Contacto de soporte + +## Instrucciones + +1. **Sé amable y servicial** +2. **Responde en español** +3. **Protege la privacidad** - solo muestra información del cliente autenticado +4. **Ofrece ayuda adicional** cuando sea apropiado +5. **Escala a soporte** si no puedes resolver la consulta + +## Restricciones + +- SOLO puedes ver información del cliente autenticado +- NO puedes ver información de otros clientes +- NO puedes modificar pedidos +- NO puedes procesar pagos +- NO puedes acceder a datos internos del negocio + +## Formato de Respuesta + +Sé amigable pero profesional: +- Saluda al cliente por nombre si está disponible +- Usa emojis con moderación +- Ofrece opciones claras +- Despídete cordialmente + +Horario de atención: {store_hours} +`; + +export function generateCustomerPrompt(variables: { + businessName: string; + storeHours?: string; +}): string { + return CUSTOMER_SYSTEM_PROMPT + .replace('{business_name}', variables.businessName) + .replace('{store_hours}', variables.storeHours || 'Lun-Sáb 9:00-20:00'); +} diff --git a/src/modules/ai/prompts/index.ts b/src/modules/ai/prompts/index.ts new file mode 100644 index 0000000..a4331a5 --- /dev/null +++ b/src/modules/ai/prompts/index.ts @@ -0,0 +1,48 @@ +/** + * System Prompts Index + */ + +export { ADMIN_SYSTEM_PROMPT, generateAdminPrompt } from './admin-system-prompt'; +export { SUPERVISOR_SYSTEM_PROMPT, generateSupervisorPrompt } from './supervisor-system-prompt'; +export { OPERATOR_SYSTEM_PROMPT, generateOperatorPrompt } from './operator-system-prompt'; +export { CUSTOMER_SYSTEM_PROMPT, generateCustomerPrompt } from './customer-system-prompt'; + +import { ERPRole } from '../roles/erp-roles.config'; +import { generateAdminPrompt } from './admin-system-prompt'; +import { generateSupervisorPrompt } from './supervisor-system-prompt'; +import { generateOperatorPrompt } from './operator-system-prompt'; +import { generateCustomerPrompt } from './customer-system-prompt'; + +export interface PromptVariables { + businessName: string; + currentDate?: string; + currentBranch?: string; + maxDiscount?: number; + storeHours?: string; +} + +/** + * Generar system prompt para un rol + */ +export function generateSystemPrompt(role: ERPRole, variables: PromptVariables): string { + const baseVars = { + businessName: variables.businessName, + currentDate: variables.currentDate || new Date().toLocaleDateString('es-MX'), + currentBranch: variables.currentBranch || 'Principal', + maxDiscount: variables.maxDiscount, + storeHours: variables.storeHours, + }; + + switch (role) { + case 'ADMIN': + return generateAdminPrompt(baseVars); + case 'SUPERVISOR': + return generateSupervisorPrompt(baseVars); + case 'OPERATOR': + return generateOperatorPrompt(baseVars); + case 'CUSTOMER': + return generateCustomerPrompt(baseVars); + default: + return generateCustomerPrompt(baseVars); + } +} diff --git a/src/modules/ai/prompts/operator-system-prompt.ts b/src/modules/ai/prompts/operator-system-prompt.ts new file mode 100644 index 0000000..aa65433 --- /dev/null +++ b/src/modules/ai/prompts/operator-system-prompt.ts @@ -0,0 +1,70 @@ +/** + * System Prompt - Operador + * + * Prompt para operadores de punto de venta + */ + +export const OPERATOR_SYSTEM_PROMPT = `Eres el asistente de {business_name} para punto de venta. + +## Tu Rol +Ayudas a los vendedores y cajeros a realizar sus operaciones de forma rápida y eficiente. Tu objetivo es agilizar las ventas y resolver consultas comunes. + +## Capacidades + +### Productos +- Buscar productos por nombre, código o categoría +- Consultar precios +- Verificar disponibilidad en inventario + +### Ventas +- Registrar ventas +- Ver tus ventas del día +- Aplicar descuentos (hasta tu límite) + +### Clientes +- Buscar clientes +- Consultar saldo de cuenta (fiado) +- Registrar pagos + +### Información +- Consultar horarios de la tienda +- Ver promociones activas + +## Instrucciones + +1. **Responde rápido** - los clientes están esperando +2. **Sé conciso** - ve al punto +3. **Confirma precios** antes de una venta +4. **Alerta si no hay stock** suficiente + +## Restricciones + +- NO puedes ver reportes financieros +- NO puedes modificar precios +- NO puedes aprobar descuentos mayores a {max_discount}% +- NO puedes ver información de otras sucursales +- NO puedes anular ventas sin autorización + +## Formato de Respuesta + +Respuestas cortas y claras: +- "Producto X - $150.00 - 5 en stock" +- "Cliente tiene saldo de $500.00 pendiente" +- "Descuento aplicado: 10%" + +Fecha: {current_date} +Sucursal: {current_branch} +`; + +export function generateOperatorPrompt(variables: { + businessName: string; + currentDate: string; + currentBranch: string; + maxDiscount?: number; +}): string { + return OPERATOR_SYSTEM_PROMPT + .replace('{business_name}', variables.businessName) + .replace('{current_date}', variables.currentDate) + .replace('{current_branch}', variables.currentBranch) + .replace('{max_discount}', String(variables.maxDiscount || 10)); +} diff --git a/src/modules/ai/prompts/supervisor-system-prompt.ts b/src/modules/ai/prompts/supervisor-system-prompt.ts new file mode 100644 index 0000000..fd57071 --- /dev/null +++ b/src/modules/ai/prompts/supervisor-system-prompt.ts @@ -0,0 +1,78 @@ +/** + * System Prompt - Supervisor + * + * Prompt para supervisores con acceso a su equipo y sucursal + */ + +export const SUPERVISOR_SYSTEM_PROMPT = `Eres el asistente de inteligencia artificial de {business_name}. + +## Tu Rol +Eres un asistente para supervisores y gerentes de sucursal. Ayudas a gestionar equipos, monitorear operaciones y tomar decisiones a nivel de sucursal. + +## Capacidades + +### Ventas +- Consultar resúmenes de ventas de tu sucursal +- Ver reportes de desempeño del equipo +- Identificar productos más vendidos +- Registrar ventas + +### Inventario +- Ver estado del inventario de tu sucursal +- Identificar productos con stock bajo +- Realizar ajustes menores de inventario + +### Equipo +- Ver desempeño de vendedores +- Consultar horarios de empleados +- Gestionar turnos y asignaciones + +### Aprobaciones +- Aprobar descuentos (hasta tu límite autorizado) +- Aprobar anulaciones de ventas +- Aprobar reembolsos + +### Clientes +- Consultar información de clientes +- Ver saldos pendientes +- Revisar historial de compras + +## Instrucciones + +1. **Responde en español** de forma clara y práctica +2. **Enfócate en tu sucursal** - solo tienes acceso a datos de tu ubicación +3. **Usa datos reales** del sistema +4. **Prioriza la eficiencia** en tus respuestas +5. **Alerta sobre problemas** que requieran atención inmediata + +## Restricciones + +- NO puedes ver ventas de otras sucursales en detalle +- NO puedes modificar configuración del sistema +- NO puedes aprobar operaciones fuera de tus límites +- NO puedes gestionar usuarios de otras sucursales +- Descuentos máximos: {max_discount}% + +## Formato de Respuesta + +- Sé directo y orientado a la acción +- Usa tablas para comparativos +- Destaca anomalías o valores fuera de rango +- Sugiere acciones concretas + +Fecha actual: {current_date} +Sucursal: {current_branch} +`; + +export function generateSupervisorPrompt(variables: { + businessName: string; + currentDate: string; + currentBranch: string; + maxDiscount?: number; +}): string { + return SUPERVISOR_SYSTEM_PROMPT + .replace('{business_name}', variables.businessName) + .replace('{current_date}', variables.currentDate) + .replace('{current_branch}', variables.currentBranch) + .replace('{max_discount}', String(variables.maxDiscount || 15)); +} diff --git a/src/modules/ai/roles/erp-roles.config.ts b/src/modules/ai/roles/erp-roles.config.ts new file mode 100644 index 0000000..500035b --- /dev/null +++ b/src/modules/ai/roles/erp-roles.config.ts @@ -0,0 +1,252 @@ +/** + * ERP Roles Configuration + * + * Define roles, tools permitidos, y system prompts para cada rol en el ERP. + * Basado en: michangarrito MCH-012/MCH-013 (role-based chatbot) + * + * Roles disponibles: + * - ADMIN: Acceso completo a todas las operaciones + * - SUPERVISOR: Gestión de equipos y reportes de sucursal + * - OPERATOR: Operaciones de punto de venta + * - CUSTOMER: Acceso limitado para clientes (si se expone chatbot) + */ + +export type ERPRole = 'ADMIN' | 'SUPERVISOR' | 'OPERATOR' | 'CUSTOMER'; + +export interface ERPRoleConfig { + name: string; + description: string; + tools: string[]; + systemPromptFile: string; + maxConversationHistory: number; + allowedModels?: string[]; // Si vacío, usa el default del tenant + rateLimit: { + requestsPerMinute: number; + tokensPerMinute: number; + }; +} + +/** + * Configuración de roles ERP + */ +export const ERP_ROLES: Record = { + ADMIN: { + name: 'Administrador', + description: 'Acceso completo a todas las operaciones del sistema ERP', + tools: [ + // Ventas + 'get_sales_summary', + 'get_sales_report', + 'get_top_products', + 'get_top_customers', + 'get_sales_by_branch', + 'create_sale', + 'void_sale', + + // Inventario + 'get_inventory_status', + 'get_low_stock_products', + 'get_inventory_value', + 'adjust_inventory', + 'transfer_inventory', + + // Compras + 'get_pending_orders', + 'get_supplier_info', + 'create_purchase_order', + 'approve_purchase', + + // Finanzas + 'get_financial_report', + 'get_accounts_receivable', + 'get_accounts_payable', + 'get_cash_flow', + + // Usuarios y configuración + 'manage_users', + 'view_audit_logs', + 'update_settings', + 'get_branch_info', + 'manage_branches', + + // Reportes avanzados + 'generate_report', + 'export_data', + 'get_kpis', + ], + systemPromptFile: 'admin-system-prompt', + maxConversationHistory: 50, + rateLimit: { + requestsPerMinute: 100, + tokensPerMinute: 50000, + }, + }, + + SUPERVISOR: { + name: 'Supervisor', + description: 'Gestión de equipos, reportes de sucursal y aprobaciones', + tools: [ + // Ventas (lectura + acciones limitadas) + 'get_sales_summary', + 'get_sales_report', + 'get_top_products', + 'get_sales_by_branch', + 'create_sale', + + // Inventario (lectura + ajustes) + 'get_inventory_status', + 'get_low_stock_products', + 'adjust_inventory', + + // Equipo + 'get_team_performance', + 'get_employee_schedule', + 'manage_schedules', + + // Aprobaciones + 'approve_discounts', + 'approve_voids', + 'approve_refunds', + + // Sucursal + 'get_branch_info', + 'get_branch_report', + + // Clientes + 'get_customer_info', + 'get_customer_balance', + ], + systemPromptFile: 'supervisor-system-prompt', + maxConversationHistory: 30, + rateLimit: { + requestsPerMinute: 60, + tokensPerMinute: 30000, + }, + }, + + OPERATOR: { + name: 'Operador', + description: 'Operaciones de punto de venta y consultas básicas', + tools: [ + // Productos + 'search_products', + 'get_product_price', + 'check_product_availability', + + // Ventas + 'create_sale', + 'get_my_sales', + 'apply_discount', // Con límite + + // Clientes + 'search_customers', + 'get_customer_balance', + 'register_payment', + + // Inventario (solo lectura) + 'check_stock', + + // Información + 'get_branch_hours', + 'get_promotions', + ], + systemPromptFile: 'operator-system-prompt', + maxConversationHistory: 20, + rateLimit: { + requestsPerMinute: 30, + tokensPerMinute: 15000, + }, + }, + + CUSTOMER: { + name: 'Cliente', + description: 'Acceso limitado para clientes externos', + tools: [ + // Catálogo + 'view_catalog', + 'search_products', + 'check_availability', + + // Pedidos + 'get_my_orders', + 'track_order', + + // Cuenta + 'get_my_balance', + 'get_my_history', + + // Soporte + 'contact_support', + 'get_store_info', + 'get_promotions', + ], + systemPromptFile: 'customer-system-prompt', + maxConversationHistory: 10, + rateLimit: { + requestsPerMinute: 10, + tokensPerMinute: 5000, + }, + }, +}; + +/** + * Mapeo de rol de base de datos a ERPRole + */ +export const DB_ROLE_MAPPING: Record = { + // Roles típicos de sistema + admin: 'ADMIN', + administrator: 'ADMIN', + superadmin: 'ADMIN', + owner: 'ADMIN', + + // Supervisores + supervisor: 'SUPERVISOR', + manager: 'SUPERVISOR', + branch_manager: 'SUPERVISOR', + store_manager: 'SUPERVISOR', + + // Operadores + operator: 'OPERATOR', + cashier: 'OPERATOR', + sales: 'OPERATOR', + employee: 'OPERATOR', + staff: 'OPERATOR', + + // Clientes + customer: 'CUSTOMER', + client: 'CUSTOMER', + guest: 'CUSTOMER', +}; + +/** + * Obtener rol ERP desde rol de base de datos + */ +export function getERPRole(dbRole: string | undefined): ERPRole { + if (!dbRole) return 'CUSTOMER'; // Default para roles no mapeados + const normalized = dbRole.toLowerCase().trim(); + return DB_ROLE_MAPPING[normalized] || 'CUSTOMER'; +} + +/** + * Verificar si un rol tiene acceso a un tool + */ +export function hasToolAccess(role: ERPRole, toolName: string): boolean { + const roleConfig = ERP_ROLES[role]; + if (!roleConfig) return false; + return roleConfig.tools.includes(toolName); +} + +/** + * Obtener todos los tools para un rol + */ +export function getToolsForRole(role: ERPRole): string[] { + const roleConfig = ERP_ROLES[role]; + return roleConfig?.tools || []; +} + +/** + * Obtener configuración completa de un rol + */ +export function getRoleConfig(role: ERPRole): ERPRoleConfig | null { + return ERP_ROLES[role] || null; +} diff --git a/src/modules/ai/roles/index.ts b/src/modules/ai/roles/index.ts new file mode 100644 index 0000000..8fc1b8c --- /dev/null +++ b/src/modules/ai/roles/index.ts @@ -0,0 +1,14 @@ +/** + * ERP Roles Index + */ + +export { + ERPRole, + ERPRoleConfig, + ERP_ROLES, + DB_ROLE_MAPPING, + getERPRole, + hasToolAccess, + getToolsForRole, + getRoleConfig, +} from './erp-roles.config'; diff --git a/src/modules/ai/services/index.ts b/src/modules/ai/services/index.ts index d4fe86b..c281f6b 100644 --- a/src/modules/ai/services/index.ts +++ b/src/modules/ai/services/index.ts @@ -1 +1,11 @@ export { AIService, ConversationFilters } from './ai.service'; +export { + RoleBasedAIService, + ChatContext, + ChatMessage, + ChatResponse, + ToolCall, + ToolResult, + ToolDefinition, + TenantConfigProvider, +} from './role-based-ai.service'; diff --git a/src/modules/ai/services/role-based-ai.service.ts b/src/modules/ai/services/role-based-ai.service.ts new file mode 100644 index 0000000..81b422b --- /dev/null +++ b/src/modules/ai/services/role-based-ai.service.ts @@ -0,0 +1,455 @@ +/** + * Role-Based AI Service + * + * Servicio de IA con control de acceso basado en roles. + * Extiende la funcionalidad del AIService con validación de permisos. + * + * Basado en: michangarrito MCH-012/MCH-013 + */ + +import { Repository, DataSource } from 'typeorm'; +import { + AIModel, + AIConversation, + AIMessage, + AIPrompt, + AIUsageLog, + AITenantQuota, +} from '../entities'; +import { AIService } from './ai.service'; +import { + ERPRole, + ERP_ROLES, + getERPRole, + hasToolAccess, + getToolsForRole, + getRoleConfig, +} from '../roles/erp-roles.config'; +import { generateSystemPrompt, PromptVariables } from '../prompts'; + +export interface ChatContext { + tenantId: string; + userId: string; + userRole: string; // Rol de BD + branchId?: string; + branchName?: string; + conversationId?: string; + metadata?: Record; +} + +export interface ChatMessage { + role: 'user' | 'assistant' | 'system'; + content: string; + toolCalls?: ToolCall[]; + toolResults?: ToolResult[]; +} + +export interface ToolCall { + id: string; + name: string; + arguments: Record; +} + +export interface ToolResult { + toolCallId: string; + result: any; + error?: string; +} + +export interface ChatResponse { + message: string; + conversationId: string; + toolsUsed?: string[]; + tokensUsed: { + input: number; + output: number; + total: number; + }; + model: string; +} + +export interface ToolDefinition { + name: string; + description: string; + inputSchema: Record; + handler?: (args: any, context: ChatContext) => Promise; +} + +/** + * Servicio de IA con Role-Based Access Control + */ +export class RoleBasedAIService extends AIService { + private conversationHistory: Map = new Map(); + private toolRegistry: Map = new Map(); + + constructor( + modelRepository: Repository, + conversationRepository: Repository, + messageRepository: Repository, + promptRepository: Repository, + usageLogRepository: Repository, + quotaRepository: Repository, + private tenantConfigProvider?: TenantConfigProvider + ) { + super( + modelRepository, + conversationRepository, + messageRepository, + promptRepository, + usageLogRepository, + quotaRepository + ); + } + + /** + * Registrar un tool disponible + */ + registerTool(tool: ToolDefinition): void { + this.toolRegistry.set(tool.name, tool); + } + + /** + * Registrar múltiples tools + */ + registerTools(tools: ToolDefinition[]): void { + for (const tool of tools) { + this.registerTool(tool); + } + } + + /** + * Obtener tools permitidos para un rol + */ + getToolsForRole(role: ERPRole): ToolDefinition[] { + const allowedToolNames = getToolsForRole(role); + const tools: ToolDefinition[] = []; + + for (const toolName of allowedToolNames) { + const tool = this.toolRegistry.get(toolName); + if (tool) { + tools.push(tool); + } + } + + return tools; + } + + /** + * Verificar si el usuario puede usar un tool + */ + canUseTool(context: ChatContext, toolName: string): boolean { + const erpRole = getERPRole(context.userRole); + return hasToolAccess(erpRole, toolName); + } + + /** + * Enviar mensaje de chat con role-based access + */ + async chat( + context: ChatContext, + message: string, + options?: { + modelCode?: string; + temperature?: number; + maxTokens?: number; + } + ): Promise { + const erpRole = getERPRole(context.userRole); + const roleConfig = getRoleConfig(erpRole); + + if (!roleConfig) { + throw new Error(`Invalid role: ${context.userRole}`); + } + + // Verificar quota + const quotaCheck = await this.checkQuotaAvailable(context.tenantId); + if (!quotaCheck.available) { + throw new Error(quotaCheck.reason || 'Quota exceeded'); + } + + // Obtener o crear conversación + let conversation: AIConversation; + if (context.conversationId) { + const existing = await this.findConversation(context.conversationId); + if (existing) { + conversation = existing; + } else { + conversation = await this.createConversation(context.tenantId, context.userId, { + title: message.substring(0, 100), + metadata: { + role: erpRole, + branchId: context.branchId, + }, + }); + } + } else { + conversation = await this.createConversation(context.tenantId, context.userId, { + title: message.substring(0, 100), + metadata: { + role: erpRole, + branchId: context.branchId, + }, + }); + } + + // Obtener historial de conversación + const history = await this.getConversationHistory( + conversation.id, + roleConfig.maxConversationHistory + ); + + // Generar system prompt + const systemPrompt = await this.generateSystemPromptForContext(context, erpRole); + + // Obtener tools permitidos + const allowedTools = this.getToolsForRole(erpRole); + + // Obtener modelo + const model = options?.modelCode + ? await this.findModelByCode(options.modelCode) + : await this.getDefaultModel(context.tenantId); + + if (!model) { + throw new Error('No AI model available'); + } + + // Construir mensajes para la API + const messages: ChatMessage[] = [ + { role: 'system', content: systemPrompt }, + ...history, + { role: 'user', content: message }, + ]; + + // Guardar mensaje del usuario + await this.addMessage(conversation.id, { + role: 'user', + content: message, + }); + + // Llamar a la API de AI (OpenRouter) + const response = await this.callAIProvider(model, messages, allowedTools, options); + + // Procesar tool calls si hay + let finalResponse = response.content; + const toolsUsed: string[] = []; + + if (response.toolCalls && response.toolCalls.length > 0) { + for (const toolCall of response.toolCalls) { + // Validar que el tool esté permitido + if (!this.canUseTool(context, toolCall.name)) { + continue; // Ignorar tools no permitidos + } + + toolsUsed.push(toolCall.name); + + // Ejecutar tool + const tool = this.toolRegistry.get(toolCall.name); + if (tool?.handler) { + try { + const result = await tool.handler(toolCall.arguments, context); + // El resultado se incorpora a la respuesta + // En una implementación completa, se haría otra llamada a la API + } catch (error: any) { + console.error(`Tool ${toolCall.name} failed:`, error.message); + } + } + } + } + + // Guardar respuesta del asistente + await this.addMessage(conversation.id, { + role: 'assistant', + content: finalResponse, + metadata: { + model: model.code, + toolsUsed, + tokensUsed: response.tokensUsed, + }, + }); + + // Registrar uso + await this.logUsage(context.tenantId, { + modelId: model.id, + conversationId: conversation.id, + inputTokens: response.tokensUsed.input, + outputTokens: response.tokensUsed.output, + costUsd: this.calculateCost(model, response.tokensUsed), + usageType: 'chat', + }); + + // Incrementar quota + await this.incrementQuotaUsage( + context.tenantId, + 1, + response.tokensUsed.total, + this.calculateCost(model, response.tokensUsed) + ); + + return { + message: finalResponse, + conversationId: conversation.id, + toolsUsed: toolsUsed.length > 0 ? toolsUsed : undefined, + tokensUsed: response.tokensUsed, + model: model.code, + }; + } + + /** + * Obtener historial de conversación formateado + */ + private async getConversationHistory( + conversationId: string, + maxMessages: number + ): Promise { + const messages = await this.findMessages(conversationId); + + // Tomar los últimos N mensajes + const recentMessages = messages.slice(-maxMessages); + + return recentMessages.map((msg) => ({ + role: msg.role as 'user' | 'assistant', + content: msg.content, + })); + } + + /** + * Generar system prompt para el contexto + */ + private async generateSystemPromptForContext( + context: ChatContext, + role: ERPRole + ): Promise { + // Obtener configuración del tenant + const tenantConfig = this.tenantConfigProvider + ? await this.tenantConfigProvider.getConfig(context.tenantId) + : null; + + const variables: PromptVariables = { + businessName: tenantConfig?.businessName || 'ERP System', + currentDate: new Date().toLocaleDateString('es-MX'), + currentBranch: context.branchName, + maxDiscount: tenantConfig?.maxDiscount, + storeHours: tenantConfig?.storeHours, + }; + + return generateSystemPrompt(role, variables); + } + + /** + * Obtener modelo por defecto para el tenant + */ + private async getDefaultModel(tenantId: string): Promise { + // Buscar configuración del tenant o usar default + const models = await this.findAllModels(); + return models.find((m) => m.isDefault) || models[0] || null; + } + + /** + * Llamar al proveedor de AI (OpenRouter) + */ + private async callAIProvider( + model: AIModel, + messages: ChatMessage[], + tools: ToolDefinition[], + options?: { temperature?: number; maxTokens?: number } + ): Promise<{ + content: string; + toolCalls?: ToolCall[]; + tokensUsed: { input: number; output: number; total: number }; + }> { + // Aquí iría la integración con OpenRouter + // Por ahora retornamos un placeholder + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + throw new Error('OPENROUTER_API_KEY not configured'); + } + + const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': process.env.APP_URL || 'https://erp.local', + }, + body: JSON.stringify({ + model: model.externalId || model.code, + messages: messages.map((m) => ({ + role: m.role, + content: m.content, + })), + tools: tools.length > 0 + ? tools.map((t) => ({ + type: 'function', + function: { + name: t.name, + description: t.description, + parameters: t.inputSchema, + }, + })) + : undefined, + temperature: options?.temperature ?? 0.7, + max_tokens: options?.maxTokens ?? 2000, + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error?.message || 'AI provider error'); + } + + const data = await response.json(); + const choice = data.choices?.[0]; + + return { + content: choice?.message?.content || '', + toolCalls: choice?.message?.tool_calls?.map((tc: any) => ({ + id: tc.id, + name: tc.function?.name, + arguments: JSON.parse(tc.function?.arguments || '{}'), + })), + tokensUsed: { + input: data.usage?.prompt_tokens || 0, + output: data.usage?.completion_tokens || 0, + total: data.usage?.total_tokens || 0, + }, + }; + } + + /** + * Calcular costo de uso + */ + private calculateCost( + model: AIModel, + tokens: { input: number; output: number } + ): number { + const inputCost = (tokens.input / 1000000) * (model.inputCostPer1m || 0); + const outputCost = (tokens.output / 1000000) * (model.outputCostPer1m || 0); + return inputCost + outputCost; + } + + /** + * Limpiar conversación antigua (para liberar memoria) + */ + cleanupOldConversations(maxAgeMinutes: number = 60): void { + const now = Date.now(); + const maxAge = maxAgeMinutes * 60 * 1000; + + // En una implementación real, esto estaría en Redis o similar + // Por ahora limpiamos el Map en memoria + for (const [key, _] of this.conversationHistory) { + // Implementar lógica de limpieza basada en timestamp + } + } +} + +/** + * Interface para proveedor de configuración de tenant + */ +export interface TenantConfigProvider { + getConfig(tenantId: string): Promise<{ + businessName: string; + maxDiscount?: number; + storeHours?: string; + defaultModel?: string; + } | null>; +} diff --git a/src/modules/payment-terminals/controllers/clip-webhook.controller.ts b/src/modules/payment-terminals/controllers/clip-webhook.controller.ts new file mode 100644 index 0000000..88a968f --- /dev/null +++ b/src/modules/payment-terminals/controllers/clip-webhook.controller.ts @@ -0,0 +1,54 @@ +/** + * Clip Webhook Controller + * + * Endpoint público para recibir webhooks de Clip + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { ClipService } from '../services/clip.service'; + +export class ClipWebhookController { + public router: Router; + private clipService: ClipService; + + constructor(private dataSource: DataSource) { + this.router = Router(); + this.clipService = new ClipService(dataSource); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Webhook endpoint (público, sin auth) + this.router.post('/:tenantId', this.handleWebhook.bind(this)); + } + + /** + * POST /webhooks/clip/:tenantId + * Recibir notificaciones de Clip + */ + private async handleWebhook(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.params.tenantId; + const eventType = req.body.event || req.body.type; + const data = req.body; + + // Extraer headers relevantes + const headers: Record = { + 'x-clip-signature': req.headers['x-clip-signature'] as string || '', + 'x-clip-event-id': req.headers['x-clip-event-id'] as string || '', + }; + + // Responder inmediatamente + res.status(200).json({ received: true }); + + // Procesar webhook de forma asíncrona + await this.clipService.handleWebhook(tenantId, eventType, data, headers); + } catch (error) { + console.error('Clip webhook error:', error); + if (!res.headersSent) { + res.status(200).json({ received: true }); + } + } + } +} diff --git a/src/modules/payment-terminals/controllers/clip.controller.ts b/src/modules/payment-terminals/controllers/clip.controller.ts new file mode 100644 index 0000000..1ce4ad9 --- /dev/null +++ b/src/modules/payment-terminals/controllers/clip.controller.ts @@ -0,0 +1,164 @@ +/** + * Clip Controller + * + * Endpoints para pagos con Clip + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { ClipService } from '../services/clip.service'; + +export class ClipController { + public router: Router; + private clipService: ClipService; + + constructor(private dataSource: DataSource) { + this.router = Router(); + this.clipService = new ClipService(dataSource); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Pagos + this.router.post('/payments', this.createPayment.bind(this)); + this.router.get('/payments/:id', this.getPayment.bind(this)); + this.router.post('/payments/:id/refund', this.refundPayment.bind(this)); + + // Links de pago + this.router.post('/links', this.createPaymentLink.bind(this)); + } + + /** + * POST /clip/payments + * Crear un nuevo pago + */ + private async createPayment(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + + const payment = await this.clipService.createPayment( + tenantId, + { + amount: req.body.amount, + currency: req.body.currency, + description: req.body.description, + customerEmail: req.body.customerEmail, + customerName: req.body.customerName, + customerPhone: req.body.customerPhone, + referenceType: req.body.referenceType, + referenceId: req.body.referenceId, + metadata: req.body.metadata, + }, + userId + ); + + res.status(201).json({ + success: true, + data: this.sanitizePayment(payment), + }); + } catch (error) { + next(error); + } + } + + /** + * GET /clip/payments/:id + * Obtener estado de un pago + */ + private async getPayment(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const paymentId = req.params.id; + + const payment = await this.clipService.getPayment(tenantId, paymentId); + + res.json({ + success: true, + data: this.sanitizePayment(payment), + }); + } catch (error) { + next(error); + } + } + + /** + * POST /clip/payments/:id/refund + * Reembolsar un pago + */ + private async refundPayment(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const paymentId = req.params.id; + + const payment = await this.clipService.refundPayment(tenantId, { + paymentId, + amount: req.body.amount, + reason: req.body.reason, + }); + + res.json({ + success: true, + data: this.sanitizePayment(payment), + }); + } catch (error) { + next(error); + } + } + + /** + * POST /clip/links + * Crear un link de pago + */ + private async createPaymentLink(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + + const link = await this.clipService.createPaymentLink( + tenantId, + { + amount: req.body.amount, + description: req.body.description, + expiresInMinutes: req.body.expiresInMinutes, + referenceType: req.body.referenceType, + referenceId: req.body.referenceId, + }, + userId + ); + + res.status(201).json({ + success: true, + data: link, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener tenant ID del request + */ + private getTenantId(req: Request): string { + const tenantId = (req as any).tenantId || (req as any).user?.tenantId; + if (!tenantId) { + throw new Error('Tenant ID not found in request'); + } + return tenantId; + } + + /** + * Obtener user ID del request + */ + private getUserId(req: Request): string | undefined { + return (req as any).userId || (req as any).user?.id; + } + + /** + * Sanitizar pago para respuesta + */ + private sanitizePayment(payment: any): any { + const { providerResponse, ...safe } = payment; + return safe; + } +} diff --git a/src/modules/payment-terminals/controllers/index.ts b/src/modules/payment-terminals/controllers/index.ts index 5aff8eb..a2e7b17 100644 --- a/src/modules/payment-terminals/controllers/index.ts +++ b/src/modules/payment-terminals/controllers/index.ts @@ -4,3 +4,11 @@ export { TerminalsController } from './terminals.controller'; export { TransactionsController } from './transactions.controller'; + +// MercadoPago +export { MercadoPagoController } from './mercadopago.controller'; +export { MercadoPagoWebhookController } from './mercadopago-webhook.controller'; + +// Clip +export { ClipController } from './clip.controller'; +export { ClipWebhookController } from './clip-webhook.controller'; diff --git a/src/modules/payment-terminals/controllers/mercadopago-webhook.controller.ts b/src/modules/payment-terminals/controllers/mercadopago-webhook.controller.ts new file mode 100644 index 0000000..cf07aa4 --- /dev/null +++ b/src/modules/payment-terminals/controllers/mercadopago-webhook.controller.ts @@ -0,0 +1,56 @@ +/** + * MercadoPago Webhook Controller + * + * Endpoint público para recibir webhooks de MercadoPago + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { MercadoPagoService } from '../services/mercadopago.service'; + +export class MercadoPagoWebhookController { + public router: Router; + private mercadoPagoService: MercadoPagoService; + + constructor(private dataSource: DataSource) { + this.router = Router(); + this.mercadoPagoService = new MercadoPagoService(dataSource); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Webhook endpoint (público, sin auth) + this.router.post('/:tenantId', this.handleWebhook.bind(this)); + } + + /** + * POST /webhooks/mercadopago/:tenantId + * Recibir notificaciones IPN de MercadoPago + */ + private async handleWebhook(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.params.tenantId; + const eventType = req.body.type || req.body.action; + const data = req.body; + + // Extraer headers relevantes + const headers: Record = { + 'x-signature': req.headers['x-signature'] as string || '', + 'x-request-id': req.headers['x-request-id'] as string || '', + }; + + // Responder inmediatamente (MercadoPago espera 200 rápido) + res.status(200).json({ received: true }); + + // Procesar webhook de forma asíncrona + await this.mercadoPagoService.handleWebhook(tenantId, eventType, data, headers); + } catch (error) { + // Log error pero no fallar el webhook + console.error('MercadoPago webhook error:', error); + // Si aún no enviamos respuesta + if (!res.headersSent) { + res.status(200).json({ received: true }); + } + } + } +} diff --git a/src/modules/payment-terminals/controllers/mercadopago.controller.ts b/src/modules/payment-terminals/controllers/mercadopago.controller.ts new file mode 100644 index 0000000..357a2db --- /dev/null +++ b/src/modules/payment-terminals/controllers/mercadopago.controller.ts @@ -0,0 +1,165 @@ +/** + * MercadoPago Controller + * + * Endpoints para pagos con MercadoPago + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { MercadoPagoService } from '../services/mercadopago.service'; + +export class MercadoPagoController { + public router: Router; + private mercadoPagoService: MercadoPagoService; + + constructor(private dataSource: DataSource) { + this.router = Router(); + this.mercadoPagoService = new MercadoPagoService(dataSource); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Pagos + this.router.post('/payments', this.createPayment.bind(this)); + this.router.get('/payments/:id', this.getPayment.bind(this)); + this.router.post('/payments/:id/refund', this.refundPayment.bind(this)); + + // Links de pago + this.router.post('/links', this.createPaymentLink.bind(this)); + } + + /** + * POST /mercadopago/payments + * Crear un nuevo pago + */ + private async createPayment(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + + const payment = await this.mercadoPagoService.createPayment( + tenantId, + { + amount: req.body.amount, + currency: req.body.currency, + description: req.body.description, + paymentMethod: req.body.paymentMethod, + customerEmail: req.body.customerEmail, + customerName: req.body.customerName, + referenceType: req.body.referenceType, + referenceId: req.body.referenceId, + metadata: req.body.metadata, + }, + userId + ); + + res.status(201).json({ + success: true, + data: this.sanitizePayment(payment), + }); + } catch (error) { + next(error); + } + } + + /** + * GET /mercadopago/payments/:id + * Obtener estado de un pago + */ + private async getPayment(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const paymentId = req.params.id; + + const payment = await this.mercadoPagoService.getPayment(tenantId, paymentId); + + res.json({ + success: true, + data: this.sanitizePayment(payment), + }); + } catch (error) { + next(error); + } + } + + /** + * POST /mercadopago/payments/:id/refund + * Reembolsar un pago + */ + private async refundPayment(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const paymentId = req.params.id; + + const payment = await this.mercadoPagoService.refundPayment(tenantId, { + paymentId, + amount: req.body.amount, + reason: req.body.reason, + }); + + res.json({ + success: true, + data: this.sanitizePayment(payment), + }); + } catch (error) { + next(error); + } + } + + /** + * POST /mercadopago/links + * Crear un link de pago + */ + private async createPaymentLink(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + + const link = await this.mercadoPagoService.createPaymentLink( + tenantId, + { + amount: req.body.amount, + title: req.body.title, + description: req.body.description, + expiresAt: req.body.expiresAt ? new Date(req.body.expiresAt) : undefined, + referenceType: req.body.referenceType, + referenceId: req.body.referenceId, + }, + userId + ); + + res.status(201).json({ + success: true, + data: link, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener tenant ID del request + */ + private getTenantId(req: Request): string { + const tenantId = (req as any).tenantId || (req as any).user?.tenantId; + if (!tenantId) { + throw new Error('Tenant ID not found in request'); + } + return tenantId; + } + + /** + * Obtener user ID del request + */ + private getUserId(req: Request): string | undefined { + return (req as any).userId || (req as any).user?.id; + } + + /** + * Sanitizar pago para respuesta (ocultar datos sensibles) + */ + private sanitizePayment(payment: any): any { + const { providerResponse, ...safe } = payment; + return safe; + } +} diff --git a/src/modules/payment-terminals/entities/index.ts b/src/modules/payment-terminals/entities/index.ts new file mode 100644 index 0000000..766af69 --- /dev/null +++ b/src/modules/payment-terminals/entities/index.ts @@ -0,0 +1,3 @@ +export * from './tenant-terminal-config.entity'; +export * from './terminal-payment.entity'; +export * from './terminal-webhook-event.entity'; diff --git a/src/modules/payment-terminals/entities/tenant-terminal-config.entity.ts b/src/modules/payment-terminals/entities/tenant-terminal-config.entity.ts new file mode 100644 index 0000000..7b96834 --- /dev/null +++ b/src/modules/payment-terminals/entities/tenant-terminal-config.entity.ts @@ -0,0 +1,82 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; + +export type TerminalProvider = 'mercadopago' | 'clip' | 'stripe_terminal'; + +@Entity({ name: 'tenant_terminal_configs', schema: 'payment_terminals' }) +@Index(['tenantId', 'provider', 'name'], { unique: true }) +export class TenantTerminalConfig { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ + type: 'enum', + enum: ['mercadopago', 'clip', 'stripe_terminal'], + enumName: 'terminal_provider', + }) + provider: TerminalProvider; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + // Credenciales encriptadas + @Column({ type: 'jsonb', default: {} }) + credentials: Record; + + // Configuración específica del proveedor + @Column({ type: 'jsonb', default: {} }) + config: Record; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ name: 'verification_error', type: 'text', nullable: true }) + verificationError: string | null; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date | null; + + // Límites + @Column({ name: 'daily_limit', type: 'decimal', precision: 12, scale: 2, nullable: true }) + dailyLimit: number | null; + + @Column({ name: 'monthly_limit', type: 'decimal', precision: 12, scale: 2, nullable: true }) + monthlyLimit: number | null; + + @Column({ name: 'transaction_limit', type: 'decimal', precision: 12, scale: 2, nullable: true }) + transactionLimit: number | null; + + // Webhook + @Column({ name: 'webhook_url', type: 'varchar', length: 500, nullable: true }) + webhookUrl: string | null; + + @Column({ name: 'webhook_secret', type: 'varchar', length: 255, nullable: true }) + webhookSecret: string | null; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string | null; +} diff --git a/src/modules/payment-terminals/entities/terminal-payment.entity.ts b/src/modules/payment-terminals/entities/terminal-payment.entity.ts new file mode 100644 index 0000000..f1529e1 --- /dev/null +++ b/src/modules/payment-terminals/entities/terminal-payment.entity.ts @@ -0,0 +1,182 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { TenantTerminalConfig, TerminalProvider } from './tenant-terminal-config.entity'; + +export type TerminalPaymentStatus = + | 'pending' + | 'processing' + | 'approved' + | 'authorized' + | 'in_process' + | 'rejected' + | 'refunded' + | 'partially_refunded' + | 'cancelled' + | 'charged_back'; + +export type PaymentMethodType = 'card' | 'qr' | 'link' | 'cash' | 'bank_transfer'; + +@Entity({ name: 'terminal_payments', schema: 'payment_terminals' }) +export class TerminalPayment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'config_id', type: 'uuid', nullable: true }) + configId: string | null; + + @Column({ name: 'branch_terminal_id', type: 'uuid', nullable: true }) + branchTerminalId: string | null; + + @Index() + @Column({ + type: 'enum', + enum: ['mercadopago', 'clip', 'stripe_terminal'], + enumName: 'terminal_provider', + }) + provider: TerminalProvider; + + @Index() + @Column({ name: 'external_id', type: 'varchar', length: 255, nullable: true }) + externalId: string | null; + + @Column({ name: 'external_status', type: 'varchar', length: 50, nullable: true }) + externalStatus: string | null; + + // Monto + @Column({ type: 'decimal', precision: 12, scale: 2 }) + amount: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + // Estado + @Index() + @Column({ + type: 'enum', + enum: [ + 'pending', + 'processing', + 'approved', + 'authorized', + 'in_process', + 'rejected', + 'refunded', + 'partially_refunded', + 'cancelled', + 'charged_back', + ], + enumName: 'terminal_payment_status', + default: 'pending', + }) + status: TerminalPaymentStatus; + + // Método de pago + @Column({ + name: 'payment_method', + type: 'enum', + enum: ['card', 'qr', 'link', 'cash', 'bank_transfer'], + enumName: 'payment_method_type', + default: 'card', + }) + paymentMethod: PaymentMethodType; + + // Datos de tarjeta + @Column({ name: 'card_last_four', type: 'varchar', length: 4, nullable: true }) + cardLastFour: string | null; + + @Column({ name: 'card_brand', type: 'varchar', length: 20, nullable: true }) + cardBrand: string | null; + + @Column({ name: 'card_type', type: 'varchar', length: 20, nullable: true }) + cardType: string | null; + + // Cliente + @Column({ name: 'customer_email', type: 'varchar', length: 255, nullable: true }) + customerEmail: string | null; + + @Column({ name: 'customer_phone', type: 'varchar', length: 20, nullable: true }) + customerPhone: string | null; + + @Column({ name: 'customer_name', type: 'varchar', length: 200, nullable: true }) + customerName: string | null; + + // Descripción + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ name: 'statement_descriptor', type: 'varchar', length: 50, nullable: true }) + statementDescriptor: string | null; + + // Referencia interna + @Index() + @Column({ name: 'reference_type', type: 'varchar', length: 50, nullable: true }) + referenceType: string | null; + + @Column({ name: 'reference_id', type: 'uuid', nullable: true }) + referenceId: string | null; + + // Comisiones + @Column({ name: 'fee_amount', type: 'decimal', precision: 10, scale: 4, nullable: true }) + feeAmount: number | null; + + @Column({ name: 'fee_details', type: 'jsonb', nullable: true }) + feeDetails: Record | null; + + @Column({ name: 'net_amount', type: 'decimal', precision: 12, scale: 2, nullable: true }) + netAmount: number | null; + + // Reembolso + @Column({ name: 'refunded_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + refundedAmount: number; + + @Column({ name: 'refund_reason', type: 'text', nullable: true }) + refundReason: string | null; + + // Respuesta del proveedor + @Column({ name: 'provider_response', type: 'jsonb', nullable: true }) + providerResponse: Record | null; + + // Error + @Column({ name: 'error_code', type: 'varchar', length: 50, nullable: true }) + errorCode: string | null; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string | null; + + // Timestamps + @Column({ name: 'processed_at', type: 'timestamptz', nullable: true }) + processedAt: Date | null; + + @Column({ name: 'refunded_at', type: 'timestamptz', nullable: true }) + refundedAt: Date | null; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string | null; + + // Relaciones + @ManyToOne(() => TenantTerminalConfig, { nullable: true }) + @JoinColumn({ name: 'config_id' }) + config?: TenantTerminalConfig; +} diff --git a/src/modules/payment-terminals/entities/terminal-webhook-event.entity.ts b/src/modules/payment-terminals/entities/terminal-webhook-event.entity.ts new file mode 100644 index 0000000..d758857 --- /dev/null +++ b/src/modules/payment-terminals/entities/terminal-webhook-event.entity.ts @@ -0,0 +1,77 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { TerminalProvider } from './tenant-terminal-config.entity'; +import { TerminalPayment } from './terminal-payment.entity'; + +@Entity({ name: 'terminal_webhook_events', schema: 'payment_terminals' }) +@Index(['provider', 'eventId'], { unique: true }) +export class TerminalWebhookEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ + type: 'enum', + enum: ['mercadopago', 'clip', 'stripe_terminal'], + enumName: 'terminal_provider', + }) + provider: TerminalProvider; + + @Column({ name: 'event_type', type: 'varchar', length: 100 }) + eventType: string; + + @Column({ name: 'event_id', type: 'varchar', length: 255, nullable: true }) + eventId: string | null; + + @Column({ name: 'payment_id', type: 'uuid', nullable: true }) + paymentId: string | null; + + @Index() + @Column({ name: 'external_id', type: 'varchar', length: 255, nullable: true }) + externalId: string | null; + + @Column({ type: 'jsonb' }) + payload: Record; + + @Column({ type: 'jsonb', nullable: true }) + headers: Record | null; + + @Column({ name: 'signature_valid', type: 'boolean', nullable: true }) + signatureValid: boolean | null; + + @Index() + @Column({ type: 'boolean', default: false }) + processed: boolean; + + @Column({ name: 'processed_at', type: 'timestamptz', nullable: true }) + processedAt: Date | null; + + @Column({ name: 'processing_error', type: 'text', nullable: true }) + processingError: string | null; + + @Column({ name: 'retry_count', type: 'integer', default: 0 }) + retryCount: number; + + @Index() + @Column({ name: 'idempotency_key', type: 'varchar', length: 255, nullable: true }) + idempotencyKey: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relaciones + @ManyToOne(() => TerminalPayment, { nullable: true }) + @JoinColumn({ name: 'payment_id' }) + payment?: TerminalPayment; +} diff --git a/src/modules/payment-terminals/payment-terminals.module.ts b/src/modules/payment-terminals/payment-terminals.module.ts index b807407..14410c0 100644 --- a/src/modules/payment-terminals/payment-terminals.module.ts +++ b/src/modules/payment-terminals/payment-terminals.module.ts @@ -2,11 +2,19 @@ * 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 } from './controllers'; +import { + TerminalsController, + TransactionsController, + MercadoPagoController, + MercadoPagoWebhookController, + ClipController, + ClipWebhookController, +} from './controllers'; export interface PaymentTerminalsModuleOptions { dataSource: DataSource; @@ -15,21 +23,38 @@ export interface PaymentTerminalsModuleOptions { 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 routes + // 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); } /** @@ -37,8 +62,13 @@ export class PaymentTerminalsModule { */ 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, ]; } } diff --git a/src/modules/payment-terminals/services/clip.service.ts b/src/modules/payment-terminals/services/clip.service.ts new file mode 100644 index 0000000..3e47c5d --- /dev/null +++ b/src/modules/payment-terminals/services/clip.service.ts @@ -0,0 +1,583 @@ +/** + * Clip Service + * + * Integración con Clip para pagos TPV + * Basado en: michangarrito INT-005 + * + * Features: + * - Crear pagos con tarjeta + * - Generar links de pago + * - Procesar reembolsos + * - Manejar webhooks + * - Multi-tenant con credenciales por tenant + * - Retry con backoff exponencial + * + * Comisión: 3.6% + IVA por transacción + */ + +import { Repository, DataSource } from 'typeorm'; +import { createHmac, timingSafeEqual } from 'crypto'; +import { + TenantTerminalConfig, + TerminalPayment, + TerminalWebhookEvent, +} from '../entities'; + +// DTOs +export interface CreateClipPaymentDto { + amount: number; + currency?: string; + description?: string; + customerEmail?: string; + customerName?: string; + customerPhone?: string; + referenceType?: string; + referenceId?: string; + metadata?: Record; +} + +export interface RefundClipPaymentDto { + paymentId: string; + amount?: number; + reason?: string; +} + +export interface CreateClipLinkDto { + amount: number; + description: string; + expiresInMinutes?: number; + referenceType?: string; + referenceId?: string; +} + +export interface ClipCredentials { + apiKey: string; + secretKey: string; + merchantId: string; +} + +export interface ClipConfig { + defaultCurrency?: string; + webhookSecret?: string; +} + +// Constantes +const CLIP_API_BASE = 'https://api.clip.mx'; +const MAX_RETRIES = 5; +const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000]; + +// Clip fee: 3.6% + IVA +const CLIP_FEE_RATE = 0.036; +const IVA_RATE = 0.16; + +export class ClipService { + private configRepository: Repository; + private paymentRepository: Repository; + private webhookRepository: Repository; + + constructor(private dataSource: DataSource) { + this.configRepository = dataSource.getRepository(TenantTerminalConfig); + this.paymentRepository = dataSource.getRepository(TerminalPayment); + this.webhookRepository = dataSource.getRepository(TerminalWebhookEvent); + } + + /** + * Obtener credenciales de Clip para un tenant + */ + async getCredentials(tenantId: string): Promise<{ + credentials: ClipCredentials; + config: ClipConfig; + configId: string; + }> { + const terminalConfig = await this.configRepository.findOne({ + where: { + tenantId, + provider: 'clip', + isActive: true, + }, + }); + + if (!terminalConfig) { + throw new Error('Clip not configured for this tenant'); + } + + if (!terminalConfig.isVerified) { + throw new Error('Clip credentials not verified'); + } + + return { + credentials: terminalConfig.credentials as ClipCredentials, + config: terminalConfig.config as ClipConfig, + configId: terminalConfig.id, + }; + } + + /** + * Crear un pago + */ + async createPayment( + tenantId: string, + dto: CreateClipPaymentDto, + createdBy?: string + ): Promise { + const { credentials, config, configId } = await this.getCredentials(tenantId); + + // Calcular comisiones + const feeAmount = dto.amount * CLIP_FEE_RATE * (1 + IVA_RATE); + const netAmount = dto.amount - feeAmount; + + // Crear registro local + const payment = this.paymentRepository.create({ + tenantId, + configId, + provider: 'clip', + amount: dto.amount, + currency: dto.currency || config.defaultCurrency || 'MXN', + status: 'pending', + paymentMethod: 'card', + customerEmail: dto.customerEmail, + customerName: dto.customerName, + customerPhone: dto.customerPhone, + description: dto.description, + referenceType: dto.referenceType, + referenceId: dto.referenceId ? dto.referenceId : undefined, + feeAmount, + feeDetails: { + rate: CLIP_FEE_RATE, + iva: IVA_RATE, + calculated: feeAmount, + }, + netAmount, + metadata: dto.metadata || {}, + createdBy, + }); + + const savedPayment = await this.paymentRepository.save(payment); + + try { + // Crear pago en Clip + const clipPayment = await this.executeWithRetry(async () => { + const response = await fetch(`${CLIP_API_BASE}/v1/payments`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${credentials.apiKey}`, + 'Content-Type': 'application/json', + 'X-Clip-Merchant-Id': credentials.merchantId, + 'X-Idempotency-Key': savedPayment.id, + }, + body: JSON.stringify({ + amount: dto.amount, + currency: dto.currency || 'MXN', + description: dto.description, + customer: { + email: dto.customerEmail, + name: dto.customerName, + phone: dto.customerPhone, + }, + metadata: { + tenant_id: tenantId, + internal_id: savedPayment.id, + ...dto.metadata, + }, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new ClipError(error.message || 'Payment failed', response.status, error); + } + + return response.json(); + }); + + // Actualizar registro local + savedPayment.externalId = clipPayment.id; + savedPayment.externalStatus = clipPayment.status; + savedPayment.status = this.mapClipStatus(clipPayment.status); + savedPayment.providerResponse = clipPayment; + savedPayment.processedAt = new Date(); + + if (clipPayment.card) { + savedPayment.cardLastFour = clipPayment.card.last_four; + savedPayment.cardBrand = clipPayment.card.brand; + savedPayment.cardType = clipPayment.card.type; + } + + return this.paymentRepository.save(savedPayment); + } catch (error: any) { + savedPayment.status = 'rejected'; + savedPayment.errorCode = error.code || 'unknown'; + savedPayment.errorMessage = error.message; + savedPayment.providerResponse = error.response; + await this.paymentRepository.save(savedPayment); + throw error; + } + } + + /** + * Consultar estado de un pago + */ + async getPayment(tenantId: string, paymentId: string): Promise { + const payment = await this.paymentRepository.findOne({ + where: { id: paymentId, tenantId }, + }); + + if (!payment) { + throw new Error('Payment not found'); + } + + // Sincronizar si es necesario + if (payment.externalId && !['approved', 'rejected'].includes(payment.status)) { + await this.syncPaymentStatus(tenantId, payment); + } + + return payment; + } + + /** + * Sincronizar estado con Clip + */ + private async syncPaymentStatus( + tenantId: string, + payment: TerminalPayment + ): Promise { + const { credentials } = await this.getCredentials(tenantId); + + try { + const response = await fetch(`${CLIP_API_BASE}/v1/payments/${payment.externalId}`, { + headers: { + 'Authorization': `Bearer ${credentials.apiKey}`, + 'X-Clip-Merchant-Id': credentials.merchantId, + }, + }); + + if (response.ok) { + const clipPayment = await response.json(); + payment.externalStatus = clipPayment.status; + payment.status = this.mapClipStatus(clipPayment.status); + payment.providerResponse = clipPayment; + await this.paymentRepository.save(payment); + } + } catch { + // Silenciar errores de sincronización + } + + return payment; + } + + /** + * Procesar reembolso + */ + async refundPayment( + tenantId: string, + dto: RefundClipPaymentDto + ): Promise { + const payment = await this.paymentRepository.findOne({ + where: { id: dto.paymentId, tenantId }, + }); + + if (!payment) { + throw new Error('Payment not found'); + } + + if (payment.status !== 'approved') { + throw new Error('Cannot refund a payment that is not approved'); + } + + if (!payment.externalId) { + throw new Error('Payment has no external reference'); + } + + const { credentials } = await this.getCredentials(tenantId); + const refundAmount = dto.amount || Number(payment.amount); + + const clipRefund = await this.executeWithRetry(async () => { + const response = await fetch( + `${CLIP_API_BASE}/v1/payments/${payment.externalId}/refund`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${credentials.apiKey}`, + 'X-Clip-Merchant-Id': credentials.merchantId, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + amount: refundAmount, + reason: dto.reason, + }), + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new ClipError(error.message || 'Refund failed', response.status, error); + } + + return response.json(); + }); + + // Actualizar pago + payment.refundedAmount = Number(payment.refundedAmount || 0) + refundAmount; + payment.refundReason = dto.reason; + payment.refundedAt = new Date(); + + if (payment.refundedAmount >= Number(payment.amount)) { + payment.status = 'refunded'; + } else { + payment.status = 'partially_refunded'; + } + + return this.paymentRepository.save(payment); + } + + /** + * Crear link de pago + */ + async createPaymentLink( + tenantId: string, + dto: CreateClipLinkDto, + createdBy?: string + ): Promise<{ url: string; id: string }> { + const { credentials } = await this.getCredentials(tenantId); + + const paymentLink = await this.executeWithRetry(async () => { + const response = await fetch(`${CLIP_API_BASE}/v1/payment-links`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${credentials.apiKey}`, + 'X-Clip-Merchant-Id': credentials.merchantId, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + amount: dto.amount, + description: dto.description, + expires_in: dto.expiresInMinutes || 1440, // Default 24 horas + metadata: { + tenant_id: tenantId, + reference_type: dto.referenceType, + reference_id: dto.referenceId, + }, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new ClipError( + error.message || 'Failed to create payment link', + response.status, + error + ); + } + + return response.json(); + }); + + return { + url: paymentLink.url, + id: paymentLink.id, + }; + } + + /** + * Manejar webhook de Clip + */ + async handleWebhook( + tenantId: string, + eventType: string, + data: any, + headers: Record + ): Promise { + // Verificar firma + const config = await this.configRepository.findOne({ + where: { tenantId, provider: 'clip', isActive: true }, + }); + + if (config?.config?.webhookSecret && headers['x-clip-signature']) { + const isValid = this.verifyWebhookSignature( + JSON.stringify(data), + headers['x-clip-signature'], + config.config.webhookSecret as string + ); + + if (!isValid) { + throw new Error('Invalid webhook signature'); + } + } + + // Guardar evento + const event = this.webhookRepository.create({ + tenantId, + provider: 'clip', + eventType, + eventId: data.id, + externalId: data.payment_id || data.id, + payload: data, + headers, + signatureValid: true, + idempotencyKey: `${data.id}-${eventType}`, + }); + + await this.webhookRepository.save(event); + + // Procesar evento + try { + switch (eventType) { + case 'payment.succeeded': + await this.handlePaymentSucceeded(tenantId, data); + break; + case 'payment.failed': + await this.handlePaymentFailed(tenantId, data); + break; + case 'refund.succeeded': + await this.handleRefundSucceeded(tenantId, data); + break; + } + + event.processed = true; + event.processedAt = new Date(); + } catch (error: any) { + event.processingError = error.message; + event.retryCount += 1; + } + + await this.webhookRepository.save(event); + } + + /** + * Procesar pago exitoso + */ + private async handlePaymentSucceeded(tenantId: string, data: any): Promise { + const payment = await this.paymentRepository.findOne({ + where: [ + { externalId: data.payment_id, tenantId }, + { id: data.metadata?.internal_id, tenantId }, + ], + }); + + if (payment) { + payment.status = 'approved'; + payment.externalStatus = 'succeeded'; + payment.processedAt = new Date(); + + if (data.card) { + payment.cardLastFour = data.card.last_four; + payment.cardBrand = data.card.brand; + } + + await this.paymentRepository.save(payment); + } + } + + /** + * Procesar pago fallido + */ + private async handlePaymentFailed(tenantId: string, data: any): Promise { + const payment = await this.paymentRepository.findOne({ + where: [ + { externalId: data.payment_id, tenantId }, + { id: data.metadata?.internal_id, tenantId }, + ], + }); + + if (payment) { + payment.status = 'rejected'; + payment.externalStatus = 'failed'; + payment.errorCode = data.error?.code; + payment.errorMessage = data.error?.message; + await this.paymentRepository.save(payment); + } + } + + /** + * Procesar reembolso exitoso + */ + private async handleRefundSucceeded(tenantId: string, data: any): Promise { + const payment = await this.paymentRepository.findOne({ + where: { externalId: data.payment_id, tenantId }, + }); + + if (payment) { + payment.refundedAmount = Number(payment.refundedAmount || 0) + data.amount; + payment.refundedAt = new Date(); + + if (payment.refundedAmount >= Number(payment.amount)) { + payment.status = 'refunded'; + } else { + payment.status = 'partially_refunded'; + } + + await this.paymentRepository.save(payment); + } + } + + /** + * Verificar firma de webhook + */ + private verifyWebhookSignature( + payload: string, + signature: string, + secret: string + ): boolean { + try { + const expected = createHmac('sha256', secret).update(payload, 'utf8').digest('hex'); + return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); + } catch { + return false; + } + } + + /** + * Mapear estado de Clip a estado interno + */ + private mapClipStatus( + clipStatus: string + ): 'pending' | 'processing' | 'approved' | 'rejected' | 'refunded' | 'cancelled' { + const statusMap: Record = { + pending: 'pending', + processing: 'processing', + succeeded: 'approved', + approved: 'approved', + failed: 'rejected', + declined: 'rejected', + cancelled: 'cancelled', + refunded: 'refunded', + }; + + return statusMap[clipStatus] || 'pending'; + } + + /** + * Ejecutar con retry + */ + private async executeWithRetry(fn: () => Promise, attempt = 0): Promise { + try { + return await fn(); + } catch (error: any) { + if (attempt >= MAX_RETRIES) { + throw error; + } + + if (error.status === 429 || error.status >= 500) { + const delay = RETRY_DELAYS[attempt] || RETRY_DELAYS[RETRY_DELAYS.length - 1]; + await new Promise((resolve) => setTimeout(resolve, delay)); + return this.executeWithRetry(fn, attempt + 1); + } + + throw error; + } + } +} + +/** + * Error personalizado para Clip + */ +export class ClipError extends Error { + constructor( + message: string, + public status: number, + public response?: any + ) { + super(message); + this.name = 'ClipError'; + } +} diff --git a/src/modules/payment-terminals/services/index.ts b/src/modules/payment-terminals/services/index.ts index b15f539..c92768e 100644 --- a/src/modules/payment-terminals/services/index.ts +++ b/src/modules/payment-terminals/services/index.ts @@ -4,3 +4,7 @@ export { TerminalsService } from './terminals.service'; export { TransactionsService } from './transactions.service'; + +// Proveedores TPV +export { MercadoPagoService, MercadoPagoError } from './mercadopago.service'; +export { ClipService, ClipError } from './clip.service'; diff --git a/src/modules/payment-terminals/services/mercadopago.service.ts b/src/modules/payment-terminals/services/mercadopago.service.ts new file mode 100644 index 0000000..0ea1775 --- /dev/null +++ b/src/modules/payment-terminals/services/mercadopago.service.ts @@ -0,0 +1,584 @@ +/** + * MercadoPago Service + * + * Integración con MercadoPago para pagos TPV + * Basado en: michangarrito INT-004 + * + * Features: + * - Crear pagos con tarjeta + * - Generar QR de pago + * - Generar links de pago + * - Procesar reembolsos + * - Manejar webhooks + * - Multi-tenant con credenciales por tenant + * - Retry con backoff exponencial + */ + +import { Repository, DataSource } from 'typeorm'; +import { createHmac, timingSafeEqual } from 'crypto'; +import { + TenantTerminalConfig, + TerminalPayment, + TerminalWebhookEvent, +} from '../entities'; + +// DTOs +export interface CreatePaymentDto { + amount: number; + currency?: string; + description?: string; + paymentMethod?: 'card' | 'qr' | 'link'; + customerEmail?: string; + customerName?: string; + referenceType?: string; + referenceId?: string; + metadata?: Record; +} + +export interface RefundPaymentDto { + paymentId: string; + amount?: number; // Partial refund + reason?: string; +} + +export interface CreatePaymentLinkDto { + amount: number; + title: string; + description?: string; + expiresAt?: Date; + referenceType?: string; + referenceId?: string; +} + +export interface MercadoPagoCredentials { + accessToken: string; + publicKey: string; + collectorId?: string; +} + +export interface MercadoPagoConfig { + statementDescriptor?: string; + notificationUrl?: string; + externalReference?: string; +} + +// Constantes +const MP_API_BASE = 'https://api.mercadopago.com'; +const MAX_RETRIES = 5; +const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000]; // Backoff exponencial + +export class MercadoPagoService { + private configRepository: Repository; + private paymentRepository: Repository; + private webhookRepository: Repository; + + constructor(private dataSource: DataSource) { + this.configRepository = dataSource.getRepository(TenantTerminalConfig); + this.paymentRepository = dataSource.getRepository(TerminalPayment); + this.webhookRepository = dataSource.getRepository(TerminalWebhookEvent); + } + + /** + * Obtener credenciales de MercadoPago para un tenant + */ + async getCredentials(tenantId: string): Promise<{ + credentials: MercadoPagoCredentials; + config: MercadoPagoConfig; + configId: string; + }> { + const terminalConfig = await this.configRepository.findOne({ + where: { + tenantId, + provider: 'mercadopago', + isActive: true, + }, + }); + + if (!terminalConfig) { + throw new Error('MercadoPago not configured for this tenant'); + } + + if (!terminalConfig.isVerified) { + throw new Error('MercadoPago credentials not verified'); + } + + return { + credentials: terminalConfig.credentials as MercadoPagoCredentials, + config: terminalConfig.config as MercadoPagoConfig, + configId: terminalConfig.id, + }; + } + + /** + * Crear un pago + */ + async createPayment( + tenantId: string, + dto: CreatePaymentDto, + createdBy?: string + ): Promise { + const { credentials, config, configId } = await this.getCredentials(tenantId); + + // Crear registro local primero + const payment = this.paymentRepository.create({ + tenantId, + configId, + provider: 'mercadopago', + amount: dto.amount, + currency: dto.currency || 'MXN', + status: 'pending', + paymentMethod: dto.paymentMethod || 'card', + customerEmail: dto.customerEmail, + customerName: dto.customerName, + description: dto.description, + statementDescriptor: config.statementDescriptor, + referenceType: dto.referenceType, + referenceId: dto.referenceId ? dto.referenceId : undefined, + metadata: dto.metadata || {}, + createdBy, + }); + + const savedPayment = await this.paymentRepository.save(payment); + + try { + // Crear pago en MercadoPago con retry + const mpPayment = await this.executeWithRetry(async () => { + const response = await fetch(`${MP_API_BASE}/v1/payments`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${credentials.accessToken}`, + 'Content-Type': 'application/json', + 'X-Idempotency-Key': savedPayment.id, + }, + body: JSON.stringify({ + transaction_amount: dto.amount, + currency_id: dto.currency || 'MXN', + description: dto.description, + payment_method_id: 'card', // Se determinará por el checkout + payer: { + email: dto.customerEmail, + }, + statement_descriptor: config.statementDescriptor, + external_reference: savedPayment.id, + notification_url: config.notificationUrl, + metadata: { + tenant_id: tenantId, + internal_id: savedPayment.id, + ...dto.metadata, + }, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new MercadoPagoError(error.message || 'Payment failed', response.status, error); + } + + return response.json(); + }); + + // Actualizar registro local + savedPayment.externalId = mpPayment.id?.toString(); + savedPayment.externalStatus = mpPayment.status; + savedPayment.status = this.mapMPStatus(mpPayment.status); + savedPayment.providerResponse = mpPayment; + savedPayment.processedAt = new Date(); + + if (mpPayment.fee_details?.length > 0) { + const totalFee = mpPayment.fee_details.reduce( + (sum: number, fee: any) => sum + fee.amount, + 0 + ); + savedPayment.feeAmount = totalFee; + savedPayment.feeDetails = mpPayment.fee_details; + savedPayment.netAmount = dto.amount - totalFee; + } + + if (mpPayment.card) { + savedPayment.cardLastFour = mpPayment.card.last_four_digits; + savedPayment.cardBrand = mpPayment.card.payment_method?.name; + savedPayment.cardType = mpPayment.card.cardholder?.identification?.type; + } + + return this.paymentRepository.save(savedPayment); + } catch (error: any) { + // Guardar error + savedPayment.status = 'rejected'; + savedPayment.errorCode = error.code || 'unknown'; + savedPayment.errorMessage = error.message; + savedPayment.providerResponse = error.response; + await this.paymentRepository.save(savedPayment); + throw error; + } + } + + /** + * Consultar estado de un pago + */ + async getPayment(tenantId: string, paymentId: string): Promise { + const payment = await this.paymentRepository.findOne({ + where: { id: paymentId, tenantId }, + }); + + if (!payment) { + throw new Error('Payment not found'); + } + + // Si tiene external_id, sincronizar con MercadoPago + if (payment.externalId && payment.status !== 'approved' && payment.status !== 'rejected') { + await this.syncPaymentStatus(tenantId, payment); + } + + return payment; + } + + /** + * Sincronizar estado de pago con MercadoPago + */ + private async syncPaymentStatus( + tenantId: string, + payment: TerminalPayment + ): Promise { + const { credentials } = await this.getCredentials(tenantId); + + try { + const response = await fetch(`${MP_API_BASE}/v1/payments/${payment.externalId}`, { + headers: { + 'Authorization': `Bearer ${credentials.accessToken}`, + }, + }); + + if (response.ok) { + const mpPayment = await response.json(); + payment.externalStatus = mpPayment.status; + payment.status = this.mapMPStatus(mpPayment.status); + payment.providerResponse = mpPayment; + await this.paymentRepository.save(payment); + } + } catch { + // Silenciar errores de sincronización + } + + return payment; + } + + /** + * Procesar reembolso + */ + async refundPayment( + tenantId: string, + dto: RefundPaymentDto + ): Promise { + const payment = await this.paymentRepository.findOne({ + where: { id: dto.paymentId, tenantId }, + }); + + if (!payment) { + throw new Error('Payment not found'); + } + + if (payment.status !== 'approved') { + throw new Error('Cannot refund a payment that is not approved'); + } + + if (!payment.externalId) { + throw new Error('Payment has no external reference'); + } + + const { credentials } = await this.getCredentials(tenantId); + const refundAmount = dto.amount || Number(payment.amount); + + const mpRefund = await this.executeWithRetry(async () => { + const response = await fetch( + `${MP_API_BASE}/v1/payments/${payment.externalId}/refunds`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${credentials.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + amount: refundAmount, + }), + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new MercadoPagoError(error.message || 'Refund failed', response.status, error); + } + + return response.json(); + }); + + // Actualizar pago + payment.refundedAmount = Number(payment.refundedAmount || 0) + refundAmount; + payment.refundReason = dto.reason; + payment.refundedAt = new Date(); + + if (payment.refundedAmount >= Number(payment.amount)) { + payment.status = 'refunded'; + } else { + payment.status = 'partially_refunded'; + } + + return this.paymentRepository.save(payment); + } + + /** + * Crear link de pago + */ + async createPaymentLink( + tenantId: string, + dto: CreatePaymentLinkDto, + createdBy?: string + ): Promise<{ url: string; id: string }> { + const { credentials, config } = await this.getCredentials(tenantId); + + const preference = await this.executeWithRetry(async () => { + const response = await fetch(`${MP_API_BASE}/checkout/preferences`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${credentials.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + items: [ + { + title: dto.title, + description: dto.description, + quantity: 1, + currency_id: 'MXN', + unit_price: dto.amount, + }, + ], + back_urls: { + success: config.notificationUrl, + failure: config.notificationUrl, + pending: config.notificationUrl, + }, + notification_url: config.notificationUrl, + expires: dto.expiresAt ? true : false, + expiration_date_to: dto.expiresAt?.toISOString(), + external_reference: dto.referenceId || undefined, + metadata: { + tenant_id: tenantId, + reference_type: dto.referenceType, + reference_id: dto.referenceId, + }, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new MercadoPagoError( + error.message || 'Failed to create payment link', + response.status, + error + ); + } + + return response.json(); + }); + + return { + url: preference.init_point, + id: preference.id, + }; + } + + /** + * Manejar webhook de MercadoPago + */ + async handleWebhook( + tenantId: string, + eventType: string, + data: any, + headers: Record + ): Promise { + // Verificar firma si está configurada + const config = await this.configRepository.findOne({ + where: { tenantId, provider: 'mercadopago', isActive: true }, + }); + + if (config?.webhookSecret && headers['x-signature']) { + const isValid = this.verifyWebhookSignature( + headers['x-signature'], + headers['x-request-id'], + data.id?.toString(), + config.webhookSecret + ); + + if (!isValid) { + throw new Error('Invalid webhook signature'); + } + } + + // Guardar evento + const event = this.webhookRepository.create({ + tenantId, + provider: 'mercadopago', + eventType, + eventId: data.id?.toString(), + externalId: data.data?.id?.toString(), + payload: data, + headers, + signatureValid: true, + idempotencyKey: `${data.id}-${eventType}`, + }); + + await this.webhookRepository.save(event); + + // Procesar evento + try { + switch (eventType) { + case 'payment': + await this.handlePaymentWebhook(tenantId, data.data?.id); + break; + case 'refund': + await this.handleRefundWebhook(tenantId, data.data?.id); + break; + } + + event.processed = true; + event.processedAt = new Date(); + } catch (error: any) { + event.processingError = error.message; + event.retryCount += 1; + } + + await this.webhookRepository.save(event); + } + + /** + * Procesar webhook de pago + */ + private async handlePaymentWebhook(tenantId: string, mpPaymentId: string): Promise { + const { credentials } = await this.getCredentials(tenantId); + + // Obtener detalles del pago + const response = await fetch(`${MP_API_BASE}/v1/payments/${mpPaymentId}`, { + headers: { + 'Authorization': `Bearer ${credentials.accessToken}`, + }, + }); + + if (!response.ok) return; + + const mpPayment = await response.json(); + + // Buscar pago local por external_reference o external_id + let payment = await this.paymentRepository.findOne({ + where: [ + { externalId: mpPaymentId.toString(), tenantId }, + { id: mpPayment.external_reference, tenantId }, + ], + }); + + if (payment) { + payment.externalId = mpPaymentId.toString(); + payment.externalStatus = mpPayment.status; + payment.status = this.mapMPStatus(mpPayment.status); + payment.providerResponse = mpPayment; + payment.processedAt = new Date(); + + if (mpPayment.card) { + payment.cardLastFour = mpPayment.card.last_four_digits; + payment.cardBrand = mpPayment.card.payment_method?.name; + } + + await this.paymentRepository.save(payment); + } + } + + /** + * Procesar webhook de reembolso + */ + private async handleRefundWebhook(tenantId: string, refundId: string): Promise { + // Implementación similar a handlePaymentWebhook + } + + /** + * Verificar firma de webhook MercadoPago + */ + private verifyWebhookSignature( + xSignature: string, + xRequestId: string, + dataId: string, + secret: string + ): boolean { + try { + const parts = xSignature.split(',').reduce((acc, part) => { + const [key, value] = part.split('='); + acc[key.trim()] = value.trim(); + return acc; + }, {} as Record); + + const ts = parts['ts']; + const hash = parts['v1']; + + const manifest = `id:${dataId};request-id:${xRequestId};ts:${ts};`; + const expected = createHmac('sha256', secret).update(manifest).digest('hex'); + + return timingSafeEqual(Buffer.from(hash), Buffer.from(expected)); + } catch { + return false; + } + } + + /** + * Mapear estado de MercadoPago a estado interno + */ + private mapMPStatus( + mpStatus: string + ): 'pending' | 'processing' | 'approved' | 'rejected' | 'refunded' | 'cancelled' { + const statusMap: Record = { + pending: 'pending', + in_process: 'processing', + approved: 'approved', + authorized: 'approved', + rejected: 'rejected', + cancelled: 'cancelled', + refunded: 'refunded', + charged_back: 'charged_back', + }; + + return statusMap[mpStatus] || 'pending'; + } + + /** + * Ejecutar operación con retry y backoff exponencial + */ + private async executeWithRetry(fn: () => Promise, attempt = 0): Promise { + try { + return await fn(); + } catch (error: any) { + if (attempt >= MAX_RETRIES) { + throw error; + } + + // Solo reintentar en errores de rate limit o errores de servidor + if (error.status === 429 || error.status >= 500) { + const delay = RETRY_DELAYS[attempt] || RETRY_DELAYS[RETRY_DELAYS.length - 1]; + await new Promise((resolve) => setTimeout(resolve, delay)); + return this.executeWithRetry(fn, attempt + 1); + } + + throw error; + } + } +} + +/** + * Error personalizado para MercadoPago + */ +export class MercadoPagoError extends Error { + constructor( + message: string, + public status: number, + public response?: any + ) { + super(message); + this.name = 'MercadoPagoError'; + } +}