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>
584 lines
15 KiB
TypeScript
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';
|
|
}
|
|
}
|