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