erp-core-backend-v2/src/modules/payment-terminals/services/clip.service.ts
Adrian Flores Cortes fd8a0a508e [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>
2026-01-25 01:32:32 -06:00

584 lines
15 KiB
TypeScript

/**
* 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';
}
}