/home/suroeste/public_html/payments.transportessuroeste.com/src/Services
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'
]);
}
}