/home/suroeste/public_html/payments.transportessuroeste.com/src/Services
NameSizeModeActions
ClientDatabaseService.php44440644editdlrm
EncryptionService.php38670644editdlrm
LogService.php96780644editdlrm
PaymentService.php283570644editdlrm
Edit: /home/suroeste/public_html/payments.transportessuroeste.com/src/Services/PaymentService.php (28357B)
db = Database::getInstance(); $this->encryption = new EncryptionService(); } private function getEpayco() { if ($this->epayco === null) { if (!class_exists('Epayco\Epayco')) { throw new PaymentException('SDK ePayco no instalado. Ejecute: composer require epayco/epayco-php'); } set_error_handler(function($errno, $errstr) { return (strpos($errstr, 'deprecated') !== false || $errno === E_DEPRECATED); }); try { $this->epayco = new \Epayco\Epayco(array( "apiKey" => EPAYCO_CONFIG['public_key'], "privateKey" => EPAYCO_CONFIG['private_key'], "lenguage" => EPAYCO_CONFIG['language'], "test" => EPAYCO_CONFIG['test_mode'] )); } finally { restore_error_handler(); } } return $this->epayco; } /** * Ejecutar método de ePayco suprimiendo deprecations */ private function callEpayco(callable $callback) { $oldErrorReporting = error_reporting(); error_reporting($oldErrorReporting & ~E_DEPRECATED & ~E_USER_DEPRECATED); try { return @$callback(); } finally { error_reporting($oldErrorReporting); } } /** * Obtener token de autenticación de Apify */ private function getApifyToken(): string { if ($this->apifyToken !== null) { return $this->apifyToken; } $credentials = base64_encode(EPAYCO_CONFIG['public_key'] . ':' . EPAYCO_CONFIG['private_key']); $ch = curl_init(self::APIFY_LOGIN_URL); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Basic ' . $credentials ], CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => true ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($error) { LogService::error('epayco', 'Error cURL en login Apify', ['error' => $error]); throw new PaymentException('Error de conexión con ePayco: ' . $error); } $data = json_decode($response, true); if ($httpCode !== 200 || empty($data['token'])) { LogService::error('epayco', 'Error autenticación Apify', [ 'http_code' => $httpCode, 'response' => $response ]); throw new PaymentException('Error de autenticación con ePayco'); } $this->apifyToken = $data['token']; return $this->apifyToken; } /** * Crear sesión de Smart Checkout v2 */ private function createSmartCheckoutSession(array $data, string $internalRef, string $transactionUuid): array { $token = $this->getApifyToken(); $sessionData = [ // Requeridos 'checkout_version' => '2', 'name' => APP_NAME ?? 'Transportes Suroeste', 'description' => $data['description'] ?? 'Pago Transportes Suroeste', 'invoice' => $internalRef, 'currency' => $data['currency'] ?? 'COP', 'amount' => (float)$data['amount'], 'tax' => (float)($data['tax'] ?? 0), 'taxBase' => (float)($data['tax_base'] ?? $data['amount']), 'taxIco' => 0, 'country' => 'CO', 'lang' => 'ES', 'test' => EPAYCO_CONFIG['test_mode'], // URLs de respuesta 'response' => $data['response_url'] ?? (APP_URL . '/payment/response') . '?tx=' . urlencode($transactionUuid), 'confirmation' => APP_URL . '/api/v1/webhook/epayco', 'method' => 'POST', // Extras para identificar la transacción 'extras' => [ 'extra1' => $data['ticket_reference'] ?? '', 'extra2' => $transactionUuid, 'extra3' => $internalRef, 'extra4' => '', 'extra5' => '' ], // Billing - datos del comprador (ePayco requiere strings) 'billing' => [ 'email' => (string)($data['payer_email'] ?? ''), 'name' => (string)($data['payer_name'] ?? ''), 'typeDoc' => (string)($data['payer_document_type'] ?? 'CC'), 'numberDoc' => (string)($data['payer_document'] ?? ''), 'mobilePhone' => (string)($data['payer_phone'] ?? ''), 'callingCode' => '+57' ] ]; LogService::info('epayco', 'Datos sesión a enviar', [ 'invoice' => $sessionData['invoice'], 'amount' => $sessionData['amount'], 'currency' => $sessionData['currency'], 'description' => $sessionData['description'] ?? '(null)', 'billing_email' => $sessionData['billing']['email'] ?? '(null)', 'billing_name' => $sessionData['billing']['name'] ?? '(null)', 'billing_typeDoc' => $sessionData['billing']['typeDoc'] ?? '(null)', 'test' => $sessionData['test'] ]); $ch = curl_init(self::APIFY_SESSION_URL); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . $token ], CURLOPT_POSTFIELDS => json_encode($sessionData), CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => true ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($error) { LogService::error('epayco', 'Error cURL creando sesión', ['error' => $error]); throw new PaymentException('Error de conexión con ePayco: ' . $error); } $result = json_decode($response, true); LogService::info('epayco', 'Respuesta crear sesión', [ 'http_code' => $httpCode, 'response' => $result ]); if ($httpCode !== 200 || empty($result['success']) || empty($result['data']['sessionId'])) { LogService::error('epayco', 'Error creando sesión Smart Checkout', [ 'http_code' => $httpCode, 'response' => $response, 'request_data' => json_encode($sessionData) ]); $errorMsg = $result['textResponse'] ?? 'Error desconocido'; // En debug, incluir detalles de ePayco para diagnosticar campos faltantes if (defined('APP_DEBUG') && APP_DEBUG) { $details = $result['data']['errors'] ?? $result['data'] ?? $result['errors'] ?? null; if ($details) { $errorMsg .= ' | Detalles: ' . json_encode($details, JSON_UNESCAPED_UNICODE); } $errorMsg .= ' | HTTP: ' . $httpCode; } throw new PaymentException('Error al crear sesión de pago: ' . $errorMsg); } return [ 'sessionId' => $result['data']['sessionId'], 'token' => $result['data']['token'] ?? null ]; } /** * Crear transacción y generar link de pago de ePayco Smart Checkout v2 */ public function createPayment(array $data, array $client): array { // Verificar si ya existe una transaccion activa para este ticket $existing = $this->db->fetchOne( "SELECT transaction_uuid, status FROM transactions WHERE ticket_reference = :ticket AND client_id = :client AND status IN ('pending', 'approved') LIMIT 1", ['ticket' => $data['ticket_reference'], 'client' => $client['id']] ); if ($existing) { $msg = $existing['status'] === 'approved' ? 'Este ticket ya tiene un pago aprobado' : 'Ya existe una transaccion pendiente para este ticket. Consulte su estado con GET /api/v1/payments/ticket/' . $data['ticket_reference']; throw new PaymentException($msg, 409); } $this->db->beginTransaction(); try { $transactionUuid = $this->encryption->generateUuid(); $internalRef = 'TS' . date('Ymd') . strtoupper(bin2hex(random_bytes(4))); $signature = $this->encryption->sign(json_encode([ 'ticket' => $data['ticket_reference'], 'amount' => $data['amount'], 'client' => $client['uuid'], 'time' => time() ])); // Crear sesión de Smart Checkout v2 $checkoutSession = $this->createSmartCheckoutSession($data, $internalRef, $transactionUuid); // Guardar transacción en BD $transactionId = $this->db->insert('transactions', [ 'transaction_uuid' => $transactionUuid, 'client_id' => $client['id'], 'ticket_reference' => $data['ticket_reference'], 'internal_reference' => $internalRef, 'epayco_session_id' => $checkoutSession['sessionId'], 'amount' => $data['amount'], 'currency' => $data['currency'] ?? 'COP', 'tax' => $data['tax'] ?? 0, 'tax_base' => $data['tax_base'] ?? $data['amount'], 'status' => 'pending', 'payer_email' => $this->encryption->encrypt($data['payer_email']), 'payer_document_type' => $data['payer_document_type'], 'payer_document' => $this->encryption->encrypt($data['payer_document']), 'payer_name' => $this->encryption->encrypt($data['payer_name']), 'payer_phone' => isset($data['payer_phone']) ? $this->encryption->encrypt($data['payer_phone']) : null, 'description' => $data['description'], 'ip_address' => LogService::getClientIp(), 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown', 'request_signature' => $signature, 'extra_data' => json_encode($data['extra_data'] ?? []), 'expires_at' => date('Y-m-d H:i:s', time() + TRANSACTION_CONFIG['timeout']) ]); $this->db->insert('transaction_status_history', [ 'transaction_id' => $transactionId, 'previous_status' => null, 'new_status' => 'pending', 'changed_by' => 'system', 'ip_address' => LogService::getClientIp() ]); // Generar URL del checkout $paymentUrl = APP_URL . '/payment/checkout' . '?sessionId=' . $checkoutSession['sessionId'] . '&uuid=' . $transactionUuid; LogService::transaction($transactionUuid, 'CREATE', 'pending', [ 'ticket' => $data['ticket_reference'], 'amount' => $data['amount'], 'sessionId' => $checkoutSession['sessionId'] ]); $this->db->commit(); return [ 'transaction_uuid' => $transactionUuid, 'internal_reference' => $internalRef, 'ticket_reference' => $data['ticket_reference'], 'amount' => $data['amount'], 'currency' => $data['currency'] ?? 'COP', 'status' => 'pending', 'payment_url' => $paymentUrl, 'session_id' => $checkoutSession['sessionId'], 'expires_at' => date('Y-m-d H:i:s', time() + TRANSACTION_CONFIG['timeout']), 'created_at' => date('Y-m-d H:i:s') ]; } catch (\TransportesSuroeste\Exceptions\DatabaseException $e) { $this->db->rollback(); LogService::error('transactions', 'Error BD creando pago', [ 'error' => $e->getMessage(), 'db_code' => $e->getDbErrorCode(), 'constraint' => $e->getConstraintName(), 'ticket' => $data['ticket_reference'] ?? null ]); // Traducir errores de BD a mensajes de negocio if ($e->isDuplicate()) { $friendlyMsg = match(true) { str_contains($e->getConstraintName(), 'ticket') => 'Ya existe una transaccion para este ticket. Consulte su estado con GET /api/v1/payments/ticket/{reference}', str_contains($e->getConstraintName(), 'internal_reference') => 'Error generando referencia interna, intente de nuevo', str_contains($e->getConstraintName(), 'transaction_uuid') => 'Error generando identificador, intente de nuevo', default => 'Ya existe un registro con estos datos' }; throw new PaymentException($friendlyMsg, 409); } throw new PaymentException('Error al procesar la transaccion. Intente de nuevo', $e->getHttpCode()); } catch (\Exception $e) { $this->db->rollback(); LogService::error('transactions', 'Error creando pago', ['error' => $e->getMessage()]); throw new PaymentException('Error al procesar la transaccion', 500); } } /** * Reintentar pago fallido/rechazado - Crea nueva sesión ePayco y resetea la transacción */ public function retryPayment(string $transactionUuid): array { $transaction = $this->db->fetchOne( "SELECT * FROM transactions WHERE transaction_uuid = :uuid", ['uuid' => $transactionUuid] ); if (!$transaction) { throw new PaymentException('Transacción no encontrada', 404); } $retryableStatuses = ['rejected', 'failed']; if (!in_array($transaction['status'], $retryableStatuses)) { throw new PaymentException('Esta transacción no puede reintentarse (estado: ' . $transaction['status'] . ')', 422); } $this->db->beginTransaction(); try { // Desencriptar datos del pagador para crear nueva sesión // Cast a string: decrypt() retorna mixed y ePayco requiere strings en billing $payerEmail = (string)$this->encryption->decrypt($transaction['payer_email']); $payerDocument = (string)$this->encryption->decrypt($transaction['payer_document']); $payerName = (string)$this->encryption->decrypt($transaction['payer_name']); $payerPhone = $transaction['payer_phone'] ? (string)$this->encryption->decrypt($transaction['payer_phone']) : ''; $sessionData = [ 'amount' => (float)$transaction['amount'], 'currency' => $transaction['currency'] ?? 'COP', 'tax' => (float)($transaction['tax'] ?? 0), 'tax_base' => (float)($transaction['tax_base'] ?? $transaction['amount']), 'description' => $transaction['description'] ?? 'Pago Transportes Suroeste', 'ticket_reference' => $transaction['ticket_reference'], 'payer_email' => $payerEmail, 'payer_name' => $payerName, 'payer_document_type' => $transaction['payer_document_type'] ?? 'CC', 'payer_document' => $payerDocument, 'payer_phone' => $payerPhone ]; // Crear nueva sesión de ePayco $checkoutSession = $this->createSmartCheckoutSession( $sessionData, $transaction['internal_reference'], $transaction['transaction_uuid'] ); // Actualizar transacción con nueva sesión y resetear estado $this->db->update('transactions', [ 'epayco_session_id' => $checkoutSession['sessionId'], 'status' => 'pending', 'status_code' => null, 'status_message' => null, 'epayco_ref' => null, 'epayco_transaction_id' => null, 'payment_method' => null, 'payment_method_type' => null, 'card_last_four' => null, 'card_brand' => null, 'bank_name' => null, 'response_signature' => null, 'processed_at' => null, 'expires_at' => date('Y-m-d H:i:s', time() + TRANSACTION_CONFIG['timeout']) ], 'id = :id', ['id' => $transaction['id']]); // Registrar cambio de estado $this->db->insert('transaction_status_history', [ 'transaction_id' => $transaction['id'], 'previous_status' => $transaction['status'], 'new_status' => 'pending', 'changed_by' => 'retry', 'ip_address' => LogService::getClientIp() ]); LogService::transaction($transaction['transaction_uuid'], 'RETRY', 'pending', [ 'previous_status' => $transaction['status'], 'new_session' => $checkoutSession['sessionId'] ]); $this->db->commit(); $paymentUrl = APP_URL . '/payment/checkout?sessionId=' . $checkoutSession['sessionId'] . '&uuid=' . $transaction['transaction_uuid']; return [ 'transaction_uuid' => $transaction['transaction_uuid'], 'session_id' => $checkoutSession['sessionId'], 'payment_url' => $paymentUrl ]; } catch (PaymentException $e) { $this->db->rollback(); throw $e; } catch (\Exception $e) { $this->db->rollback(); LogService::error('transactions', 'Error en reintento de pago', [ 'uuid' => $transactionUuid, 'error' => $e->getMessage() ]); throw new PaymentException('Error al reintentar el pago. Intente de nuevo', 500); } } /** * Obtener bancos PSE */ public function getPseBanks(): array { try { $epayco = $this->getEpayco(); $banks = $this->callEpayco(function() use ($epayco) { return $epayco->bank->pseBank(); }); if (!$banks->success || empty($banks->data)) { return []; } return array_map(fn($bank) => [ 'bank_code' => $bank->bankCode, 'bank_name' => $bank->bankName ], $banks->data); } catch (\Exception $e) { LogService::error('pse', 'Error obteniendo bancos', ['error' => $e->getMessage()]); return []; } } /** * Consultar estado de transacción */ public function getTransactionStatus(string $identifier, string $type = 'uuid'): ?array { $where = match($type) { 'uuid' => 'transaction_uuid = :id', 'ticket' => 'ticket_reference = :id', 'internal' => 'internal_reference = :id', 'ref_payco' => 'epayco_session_id = :id', default => 'transaction_uuid = :id' }; $transaction = $this->db->fetchOne( "SELECT t.*, c.client_name FROM transactions t JOIN api_clients c ON t.client_id = c.id WHERE {$where}", ['id' => $identifier] ); if (!$transaction) return null; return [ 'transaction_uuid' => $transaction['transaction_uuid'], 'internal_reference' => $transaction['internal_reference'], 'ticket_reference' => $transaction['ticket_reference'], 'amount' => (float)$transaction['amount'], 'currency' => $transaction['currency'], 'status' => $transaction['status'], 'status_code' => $transaction['status_code'], 'status_message' => $transaction['status_message'], 'payment_method' => $transaction['payment_method'], 'epayco_ref' => $transaction['epayco_ref'], 'created_at' => $transaction['created_at'], 'processed_at' => $transaction['processed_at'] ]; } /** * Procesar callback/webhook de ePayco */ public function processCallback(array $data): array { $refPayco = $data['x_ref_payco'] ?? null; $internalRef = $data['x_id_invoice'] ?? $data['x_extra3'] ?? null; $transactionUuid = $data['x_extra2'] ?? null; LogService::info('callback', 'Callback ePayco', $data); $transaction = null; if ($transactionUuid) { $transaction = $this->db->fetchOne( "SELECT * FROM transactions WHERE transaction_uuid = :uuid", ['uuid' => $transactionUuid] ); } if (!$transaction && $internalRef) { $transaction = $this->db->fetchOne( "SELECT * FROM transactions WHERE internal_reference = :ref", ['ref' => $internalRef] ); } if (!$transaction && $refPayco) { $transaction = $this->db->fetchOne( "SELECT * FROM transactions WHERE epayco_ref = :ref", ['ref' => $refPayco] ); } if (!$transaction) { throw new PaymentException('Transacción no encontrada'); } $status = $this->mapEpaycoStatus($data['x_response'] ?? 'Pendiente'); $this->db->update('transactions', [ 'status' => $status, 'status_code' => $data['x_cod_response'] ?? null, 'status_message' => $data['x_response_reason_text'] ?? null, 'epayco_ref' => $refPayco, 'epayco_transaction_id' => $data['x_transaction_id'] ?? null, 'payment_method' => $data['x_franchise'] ?? $data['x_bank_name'] ?? null, 'payment_method_type' => !empty($data['x_bank_name']) ? 'pse' : 'credit_card', 'bank_name' => $data['x_bank_name'] ?? null, 'card_brand' => $data['x_franchise'] ?? null, 'card_last_four' => !empty($data['x_cardnumber']) ? substr($data['x_cardnumber'], -4) : null, 'response_signature' => $data['x_signature'] ?? null, 'processed_at' => date('Y-m-d H:i:s') ], 'id = :id', ['id' => $transaction['id']]); $this->db->insert('transaction_status_history', [ 'transaction_id' => $transaction['id'], 'previous_status' => $transaction['status'], 'new_status' => $status, 'status_code' => $data['x_cod_response'] ?? null, 'status_message' => $data['x_response_reason_text'] ?? null, 'changed_by' => 'epayco', 'ip_address' => LogService::getClientIp() ]); LogService::transaction($transaction['transaction_uuid'], 'CALLBACK', $status); // Actualizar estado de pago en BD del cliente (reservas_clientes) $this->updateClientPaymentStatus( $transaction['ticket_reference'] ?? $data['x_extra1'] ?? '', $status ); $this->sendWebhook($transaction['id']); return [ 'status' => $status, 'transaction_uuid' => $transaction['transaction_uuid'], 'ticket_reference' => $transaction['ticket_reference'] ]; } private function mapEpaycoStatus(string $response): string { return match(strtolower($response)) { 'aceptada', 'aprobada', 'approved' => 'approved', 'rechazada', 'rejected' => 'rejected', 'pendiente', 'pending' => 'pending', 'fallida', 'failed' => 'failed', 'cancelada', 'cancelled' => 'cancelled', default => 'pending' }; } /** * Actualizar estado de pago en la BD del cliente (tabla reservas_clientes) * * Conecta a la BD externa del cliente y actualiza el campo estadopago * buscando por idreserva_cliente = ticket_reference. * Los errores se registran en log pero no interrumpen el flujo principal. */ private function updateClientPaymentStatus(string $ticketReference, string $status): void { if (empty($ticketReference)) { LogService::error('client_db', 'ticket_reference vacío, no se puede actualizar BD del cliente'); return; } try { $clientDb = new ClientDatabaseService(); $updated = $clientDb->updatePaymentStatus($ticketReference, $status); if (!$updated) { LogService::warning('client_db', 'No se encontró registro en reservas_clientes', [ 'idreserva_cliente' => $ticketReference, 'status' => $status ]); } } catch (\Exception $e) { LogService::error('client_db', 'Error actualizando BD del cliente', [ 'ticket_reference' => $ticketReference, 'status' => $status, 'error' => $e->getMessage() ]); } } private function sendWebhook(int $transactionId): void { $transaction = $this->db->fetchOne( "SELECT t.*, c.webhook_url, c.webhook_secret FROM transactions t JOIN api_clients c ON t.client_id = c.id WHERE t.id = :id", ['id' => $transactionId] ); if (!$transaction || empty($transaction['webhook_url'])) return; $payload = json_encode([ 'event' => 'payment.updated', 'transaction_uuid' => $transaction['transaction_uuid'], 'ticket_reference' => $transaction['ticket_reference'], 'status' => $transaction['status'], 'amount' => $transaction['amount'], 'payment_method' => $transaction['payment_method'], 'epayco_ref' => $transaction['epayco_ref'], 'timestamp' => time() ]); $signature = hash_hmac('sha512', $payload, $transaction['webhook_secret'] ?? ''); $this->db->insert('webhook_deliveries', [ 'delivery_uuid' => $this->encryption->generateUuid(), 'transaction_id' => $transactionId, 'client_id' => $transaction['client_id'], 'webhook_url' => $transaction['webhook_url'], 'payload' => $this->encryption->encrypt($payload), 'signature' => $signature, 'status' => 'pending' ]); } }