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