<?php namespace App\Integrations\Celcoin; use App\Models\CelcoinWebhookLog; use App\Models\CelcoinIdempotencyKey; use Exception; /** * Serviço de processamento de webhooks da Celcoin * Valida, processa e registra webhooks de forma segura */ class CelcoinWebhookService { private CelcoinChargeService $chargeService; private CelcoinSubscriptionService $subscriptionService; private int $companyId; private int $gatewaySettingId; public function __construct(int $companyId, int $gatewaySettingId) { $this->companyId = $companyId; $this->gatewaySettingId = $gatewaySettingId; $this->chargeService = new CelcoinChargeService($companyId, $gatewaySettingId); $this->subscriptionService = new CelcoinSubscriptionService($companyId, $gatewaySettingId); } /** * Processa webhook da Celcoin */ public function processWebhook(array $webhookData, array $headers = []): array { try { // Validar webhook $this->validateWebhook($webhookData, $headers); // Verificar idempotência $idempotencyKey = $this->extractIdempotencyKey($webhookData); if ($this->isIdempotent($idempotencyKey)) { return ['success' => true, 'processed' => true, 'idempotent' => true]; } // Registrar webhook $webhookLogId = $this->logWebhook($webhookData, $headers); // Processar evento específico $result = $this->processWebhookEvent($webhookData); // Marcar como processado $this->markAsProcessed($webhookLogId, $result); // Registrar chave de idempotência $this->storeIdempotencyKey($idempotencyKey); return [ 'success' => true, 'processed' => true, 'event' => $webhookData['event'] ?? 'unknown', 'result' => $result ]; } catch (Exception $e) { // Registrar erro $this->logWebhookError($webhookData, $e->getMessage()); return [ 'success' => false, 'processed' => false, 'error' => $e->getMessage() ]; } } /** * Processa evento específico do webhook */ private function processWebhookEvent(array $webhookData): array { $event = $webhookData['event'] ?? null; if (!$event) { throw new Exception('Missing event type in webhook data'); } // Separar tipo e subtipo do evento $eventParts = explode('.', $event); $eventType = $eventParts[0] ?? 'unknown'; $eventSubtype = $eventParts[1] ?? 'unknown'; switch ($eventType) { case 'charge': return $this->chargeService->processChargeWebhook($webhookData); case 'subscription': return $this->subscriptionService->processSubscriptionWebhook($webhookData); case 'customer': return $this->processCustomerWebhook($webhookData); case 'payment': return $this->processPaymentWebhook($webhookData); default: return $this->processGenericWebhook($webhookData); } } /** * Processa webhook de cliente */ private function processCustomerWebhook(array $webhookData): array { $event = $webhookData['event'] ?? ''; $customerId = $webhookData['data']['id'] ?? null; if (!$customerId) { throw new Exception('Missing customer ID in webhook data'); } switch ($event) { case 'customer.created': return ['action' => 'customer_created', 'customer_id' => $customerId]; case 'customer.updated': return ['action' => 'customer_updated', 'customer_id' => $customerId]; case 'customer.deleted': return ['action' => 'customer_deleted', 'customer_id' => $customerId]; default: return ['action' => 'customer_event', 'event' => $event, 'customer_id' => $customerId]; } } /** * Processa webhook de pagamento */ private function processPaymentWebhook(array $webhookData): array { $event = $webhookData['event'] ?? ''; $paymentId = $webhookData['data']['id'] ?? null; if (!$paymentId) { throw new Exception('Missing payment ID in webhook data'); } switch ($event) { case 'payment.succeeded': return ['action' => 'payment_succeeded', 'payment_id' => $paymentId]; case 'payment.failed': return ['action' => 'payment_failed', 'payment_id' => $paymentId]; case 'payment.refunded': return ['action' => 'payment_refunded', 'payment_id' => $paymentId]; case 'payment.disputed': return ['action' => 'payment_disputed', 'payment_id' => $paymentId]; default: return ['action' => 'payment_event', 'event' => $event, 'payment_id' => $paymentId]; } } /** * Processa webhook genérico */ private function processGenericWebhook(array $webhookData): array { $event = $webhookData['event'] ?? 'unknown'; return [ 'action' => 'generic_event', 'event' => $event, 'data' => $webhookData['data'] ?? [] ]; } /** * Valida webhook da Celcoin */ private function validateWebhook(array $webhookData, array $headers): void { // Verificar se tem dados básicos if (empty($webhookData)) { throw new Exception('Empty webhook data'); } // Verificar assinatura se disponível if (isset($headers['x-celcoin-signature'])) { $this->validateSignature($webhookData, $headers['x-celcoin-signature']); } // Verificar timestamp para evitar webhooks muito antigos if (isset($webhookData['timestamp'])) { $this->validateTimestamp($webhookData['timestamp']); } // Verificar se o evento é válido $validEvents = [ 'charge.created', 'charge.succeeded', 'charge.failed', 'charge.refunded', 'subscription.created', 'subscription.canceled', 'subscription.expired', 'subscription.payment_succeeded', 'subscription.payment_failed', 'customer.created', 'customer.updated', 'customer.deleted', 'payment.succeeded', 'payment.failed', 'payment.refunded', 'payment.disputed' ]; $event = $webhookData['event'] ?? null; if ($event && !in_array($event, $validEvents)) { throw new Exception("Invalid webhook event: {$event}"); } } /** * Valida assinatura do webhook */ private function validateSignature(array $webhookData, string $signature): void { // Obter chave secreta da configuração $webhookSecret = $this->getWebhookSecret(); if (!$webhookSecret) { // Se não há chave configurada, pular validação return; } // Criar payload para verificação $payload = json_encode($webhookData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); // Calcular assinatura esperada $expectedSignature = hash_hmac('sha256', $payload, $webhookSecret); // Comparar assinaturas de forma segura contra timing attacks if (!hash_equals($expectedSignature, $signature)) { throw new Exception('Invalid webhook signature'); } } /** * Valida timestamp do webhook */ private function validateTimestamp(string $timestamp): void { $webhookTime = strtotime($timestamp); $currentTime = time(); $maxAge = 300; // 5 minutos if (($currentTime - $webhookTime) > $maxAge) { throw new Exception('Webhook timestamp too old'); } if ($webhookTime > ($currentTime + 60)) { throw new Exception('Webhook timestamp in the future'); } } /** * Extrai chave de idempotência do webhook */ private function extractIdempotencyKey(array $webhookData): string { $eventId = $webhookData['id'] ?? $webhookData['webhook_id'] ?? 'unknown'; $eventType = $webhookData['event'] ?? 'unknown'; return hash('sha256', $this->companyId . '|' . $eventType . '|' . $eventId); } /** * Verifica se o webhook já foi processado */ private function isIdempotent(string $idempotencyKey): bool { $idempotencyModel = new CelcoinIdempotencyKey(); $existing = $idempotencyModel->findByKey($this->companyId, $idempotencyKey); return $existing !== null; } /** * Armazena chave de idempotência */ private function storeIdempotencyKey(string $idempotencyKey, string $endpoint = '/api/celcoin/webhook', string $requestHash = null): void { $idempotencyModel = new CelcoinIdempotencyKey(); $idempotencyModel->create([ 'company_id' => $this->companyId, 'idempotency_key' => $idempotencyKey, 'endpoint' => $endpoint, 'request_hash' => $requestHash ?? hash('sha256', $idempotencyKey), 'response_data' => json_encode(['processed' => true]), 'status' => 'completed', 'created_at' => date('Y-m-d H:i:s'), 'expires_at' => date('Y-m-d H:i:s', strtotime('+24 hours')) ]); } /** * Registra webhook no log */ private function logWebhook(array $webhookData, array $headers, ?string $idempotencyKey = null): int { $webhookLogModel = new CelcoinWebhookLog(); return $webhookLogModel->create([ 'company_id' => $this->companyId, 'webhook_id' => $webhookData['id'] ?? null, 'event_type' => $webhookData['event'] ?? 'unknown', 'payload' => json_encode($webhookData), 'signature' => $headers['x-celcoin-signature'] ?? $headers['X-Celcoin-Signature'] ?? null, 'status' => 'processing', 'processed_at' => null, 'idempotency_key' => $idempotencyKey ?? $this->extractIdempotencyKey($webhookData), 'created_at' => date('Y-m-d H:i:s') ]); } /** * Marca webhook como processado */ private function markAsProcessed(int $webhookLogId, array $result): void { $webhookLogModel = new CelcoinWebhookLog(); $webhookLogModel->update($webhookLogId, [ 'status' => 'processed', 'processed_at' => date('Y-m-d H:i:s'), 'result' => json_encode($result) ]); } /** * Registra erro no webhook */ private function logWebhookError(array $webhookData, string $error): void { $webhookLogModel = new CelcoinWebhookLog(); $webhookLogModel->create([ 'company_id' => $this->companyId, 'webhook_id' => $webhookData['id'] ?? null, 'event_type' => $webhookData['event'] ?? 'unknown', 'payload' => json_encode($webhookData), 'signature' => null, 'status' => 'error', 'error_message' => $error, 'processed_at' => date('Y-m-d H:i:s'), 'idempotency_key' => $this->extractIdempotencyKey($webhookData), 'created_at' => date('Y-m-d H:i:s') ]); } /** * Lista webhooks com filtros */ public function listWebhooks(array $filters = [], int $page = 1, int $limit = 50): array { $webhookLogModel = new CelcoinWebhookLog(); $offset = ($page - 1) * $limit; $where = ['company_id' => $this->companyId]; // Adicionar filtros if (isset($filters['event_type'])) { $where['event_type'] = $filters['event_type']; } if (isset($filters['status'])) { $where['status'] = $filters['status']; } if (isset($filters['date_from'])) { $where['created_at >='] = $filters['date_from']; } if (isset($filters['date_to'])) { $where['created_at <='] = $filters['date_to']; } $webhooks = $webhookLogModel->findAll($where, $limit, $offset, 'created_at DESC'); $total = $webhookLogModel->count($where); return [ 'webhooks' => $webhooks, 'total' => $total, 'page' => $page, 'limit' => $limit, 'pages' => ceil($total / $limit) ]; } /** * Reprocessa webhook específico */ public function reprocessWebhook(int $webhookLogId): array { try { $webhookLogModel = new CelcoinWebhookLog(); $webhook = $webhookLogModel->find($webhookLogId); if (!$webhook) { throw new Exception('Webhook log not found'); } if ($webhook['company_id'] != $this->companyId) { throw new Exception('Webhook does not belong to this company'); } // Decodificar payload $webhookData = json_decode($webhook['payload'], true); if (!$webhookData) { throw new Exception('Invalid webhook payload'); } // Reprocessar $result = $this->processWebhookEvent($webhookData); // Atualizar log $webhookLogModel->update($webhookLogId, [ 'status' => 'reprocessed', 'processed_at' => date('Y-m-d H:i:s'), 'result' => json_encode($result) ]); return [ 'success' => true, 'reprocessed' => true, 'result' => $result ]; } catch (Exception $e) { return [ 'success' => false, 'reprocessed' => false, 'error' => $e->getMessage() ]; } } /** * Obtém chave secreta do webhook */ private function getWebhookSecret(): ?string { $db = \Database::getInstance()->getConnection(); $stmt = $db->prepare( 'SELECT cgs.webhook_secret FROM company_gateway_settings cgs ' . 'WHERE cgs.company_id = ? AND cgs.id = ? AND cgs.is_active = 1 ' . 'LIMIT 1' ); $stmt->execute([$this->companyId, $this->gatewaySettingId]); $row = $stmt->fetch(); return $row['webhook_secret'] ?? null; } /** * Limpa webhooks antigos */ public function cleanupOldWebhooks(int $daysOld = 30): int { $webhookLogModel = new CelcoinWebhookLog(); $cutoffDate = date('Y-m-d H:i:s', strtotime("-{$daysOld} days")); // Deletar webhooks processados com mais de X dias $deleted = $webhookLogModel->deleteWhere([ 'company_id' => $this->companyId, 'status' => 'processed', 'created_at <' => $cutoffDate ]); return $deleted; } }