[TASK-2026-01-25-ERP-INTEGRACIONES] feat: Add payment terminals + AI role-based access

Payment Terminals (MercadoPago + Clip):
- TenantTerminalConfig, TerminalPayment, TerminalWebhookEvent entities
- MercadoPagoService: payments, refunds, links, webhooks
- ClipService: payments, refunds, links, webhooks
- Controllers for authenticated and webhook endpoints
- Retry with exponential backoff
- Multi-tenant credential management

AI Role-Based Access:
- ERPRole config: ADMIN, SUPERVISOR, OPERATOR, CUSTOMER
- 70+ tools mapped to roles
- System prompts per role (admin, supervisor, operator, customer)
- RoleBasedAIService with tool filtering
- OpenRouter integration
- Rate limiting per role

Based on: michangarrito INT-004, INT-005, MCH-012, MCH-013

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 01:32:32 -06:00
parent 2d2a562274
commit fd8a0a508e
22 changed files with 3074 additions and 2 deletions

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

@ -1 +1,11 @@
export { AIService, ConversationFilters } from './ai.service'; 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,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

@ -4,3 +4,11 @@
export { TerminalsController } from './terminals.controller'; export { TerminalsController } from './terminals.controller';
export { TransactionsController } from './transactions.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,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

@ -2,11 +2,19 @@
* Payment Terminals Module * Payment Terminals Module
* *
* Module registration for payment terminals and transactions * Module registration for payment terminals and transactions
* Includes: MercadoPago, Clip, Stripe Terminal
*/ */
import { Router } from 'express'; import { Router } from 'express';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { TerminalsController, TransactionsController } from './controllers'; import {
TerminalsController,
TransactionsController,
MercadoPagoController,
MercadoPagoWebhookController,
ClipController,
ClipWebhookController,
} from './controllers';
export interface PaymentTerminalsModuleOptions { export interface PaymentTerminalsModuleOptions {
dataSource: DataSource; dataSource: DataSource;
@ -15,21 +23,38 @@ export interface PaymentTerminalsModuleOptions {
export class PaymentTerminalsModule { export class PaymentTerminalsModule {
public router: Router; public router: Router;
public webhookRouter: Router;
private terminalsController: TerminalsController; private terminalsController: TerminalsController;
private transactionsController: TransactionsController; private transactionsController: TransactionsController;
private mercadoPagoController: MercadoPagoController;
private mercadoPagoWebhookController: MercadoPagoWebhookController;
private clipController: ClipController;
private clipWebhookController: ClipWebhookController;
constructor(options: PaymentTerminalsModuleOptions) { constructor(options: PaymentTerminalsModuleOptions) {
const { dataSource, basePath = '' } = options; const { dataSource, basePath = '' } = options;
this.router = Router(); this.router = Router();
this.webhookRouter = Router();
// Initialize controllers // Initialize controllers
this.terminalsController = new TerminalsController(dataSource); this.terminalsController = new TerminalsController(dataSource);
this.transactionsController = new TransactionsController(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-terminals`, this.terminalsController.router);
this.router.use(`${basePath}/payment-transactions`, this.transactionsController.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() { static getEntities() {
return [ return [
// Existing entities
require('../branches/entities/branch-payment-terminal.entity').BranchPaymentTerminal, require('../branches/entities/branch-payment-terminal.entity').BranchPaymentTerminal,
require('../mobile/entities/payment-transaction.entity').PaymentTransaction, 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,
]; ];
} }
} }

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

@ -4,3 +4,7 @@
export { TerminalsService } from './terminals.service'; export { TerminalsService } from './terminals.service';
export { TransactionsService } from './transactions.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';
}
}