Files
hutko/controllers/front/callback.php
2025-05-31 15:53:21 +03:00

215 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Hutko - Платіжний сервіс, який рухає бізнеси вперед.
*
* Запускайтесь, набирайте темп, масштабуйтесь ми підстрахуємо всюди.
*
* @author panariga
* @copyright 2025 Hutko
* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0)
*/
if (!defined('_PS_VERSION_')) {
exit;
}
/**
* Class HutkoCallbackModuleFrontController
*
* This front controller handles the asynchronous callback notifications from the Hutko payment gateway.
* It is responsible for validating the payment response, updating the order status in PrestaShop,
* and handling various payment statuses (approved, declined, expired, processing).
* It also incorporates logic to mitigate race conditions with the customer's return to the result page.
*
* @property \Hutko $module An instance of the Hutko module.
*/
class HutkoCallbackModuleFrontController extends ModuleFrontController
{
/**
* Handles the post-processing of the payment gateway callback.
*
* This method is the entry point for Hutko's server-to-server notifications.
* It performs the following steps:
* 1. Parses the incoming request body (from POST or raw input).
* 2. Validates the integrity of the request using the module's signature validation.
* 3. Extracts the cart ID and loads the corresponding cart.
* 4. Checks if the order already exists for the cart. If not, it attempts to validate
* and create the order, using `postponeCallback` to manage potential race conditions.
* 5. Based on the `order_status` received in the callback, it updates the PrestaShop
* order's status (e.g., to success, error, or processing).
* 6. Logs all significant events and errors using PrestaShopLogger.
* 7. Exits with a simple string response ('OK' or an error message) as expected by
* payment gateways.
*
* @return void
*/
public function postProcess(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit;
}
try {
// 1. Parse the incoming request body.
$requestBody = $this->getRequestBody();
// If request body is empty, log and exit.
if (empty($requestBody)) {
PrestaShopLogger::addLog('Hutko Callback: Empty request body received.', 2, null, 'Cart', null, true);
exit('Empty request');
}
// 2. Validate the request signature and required fields.
// Ensure all expected fields are present before proceeding with validation.
$requiredFields = ['order_id', 'amount', 'order_status', 'signature', 'merchant_id'];
foreach ($requiredFields as $field) {
if (!isset($requestBody[$field])) {
PrestaShopLogger::addLog('Hutko Callback: Missing required field in request: ' . $field, 2, null, 'Cart', null, true);
exit('Missing parameter: ' . $field);
}
}
// Assuming validateResponse returns true on success, or a string error message on failure.
$isSignatureValid = $this->module->validateResponse($requestBody);
if ($isSignatureValid !== true) {
PrestaShopLogger::addLog('Hutko Callback: Invalid signature. Error: ' . $isSignatureValid, 2, null, 'Cart', null, true);
exit('Invalid signature');
}
// 3. Extract cart ID and load the cart.
// The order_id is expected to be in the format "cartID|timestamp".
$transaction_id = $requestBody['order_id'];
$orderIdParamParts = explode($this->module->order_separator, $transaction_id);
$cartId = (int)$orderIdParamParts[0]; // Ensure it's an integer
$cart = new Cart($cartId);
// Validate cart object.
if (!Validate::isLoadedObject($cart)) {
PrestaShopLogger::addLog('Hutko Callback: Cart not found for ID: ' . $cartId, 3, null, 'Cart', $cartId, true);
exit('Cart not found');
}
// 4. Determine the amount received from the callback.
$amountReceived = round((float)$requestBody['amount'] / 100, 2);
// 5. Check if the order already exists for this cart.
$orderId = Order::getIdByCartId($cart->id);
$orderExists = (bool)$orderId;
// 6. If the order doesn't exist, attempt to validate it using postponeCallback.
// This handles the scenario where the callback arrives before the customer returns to the site.
if (!$orderExists) {
// The callback function will check for order existence again right before validation
// to handle potential race conditions.
$validationCallback = function () use ($cart, $amountReceived, $transaction_id) {
// Re-check if the order exists right before validation in case the result controller
// created it in the interim while we were waiting for the second digit.
if (Order::getIdByCartId($cart->id)) {
return true; // Order already exists, no need to validate again.
}
// If order still doesn't exist, proceed with validation.
$idState = (int)Configuration::get('HUTKO_SUCCESS_STATUS_ID');
return $this->module->validateOrderFromCart((int)$cart->id, $amountReceived, $transaction_id, $idState, true);
};
// Postpone validation to seconds ending in 8 to avoid collision with result controller (ending in 3).
$validationResult = $this->module->postponeCallback($validationCallback, 8);
// Re-fetch order ID after potential validation.
$orderId = Order::getIdByCartId($cart->id);
if (!$orderId || !$validationResult) {
PrestaShopLogger::addLog('Hutko Callback: Order validation failed for cart ID: ' . $cart->id, 2, null, 'Cart', $cart->id, true);
exit('Order validation failed');
}
}
// If we reached here, an order should exist. Load it.
$order = new Order($orderId);
if (!Validate::isLoadedObject($order)) {
PrestaShopLogger::addLog('Hutko Callback: Order could not be loaded for ID: ' . $orderId, 3, null, 'Order', $orderId, true);
exit('Order not found after validation');
}
// 7. Handle payment status from the callback.
$orderStatusCallback = $requestBody['order_status'];
$currentOrderState = (int)$order->getCurrentState();
switch ($orderStatusCallback) {
case 'approved':
$expectedState = (int)Configuration::get('HUTKO_SUCCESS_STATUS_ID');
// Only change state if it's not already the success state or "Payment accepted".
// "Payment accepted" (PS_OS_PAYMENT) might be set by validateOrderFromCart.
if ($currentOrderState !== $expectedState && $currentOrderState !== (int)Configuration::get('PS_OS_PAYMENT')) {
$this->module->updateOrderStatus($orderId, $expectedState, 'Payment approved by Hutko.');
}
exit('OK');
break;
case 'declined':
$expectedState = (int)Configuration::get('PS_OS_ERROR');
// Only change state if it's not already the error state.
if ($currentOrderState !== $expectedState) {
$this->module->updateOrderStatus($orderId, $expectedState, 'Payment ' . $orderStatusCallback . ' by Hutko.');
}
exit('Order ' . $orderStatusCallback);
break;
case 'expired':
$expectedState = (int)Configuration::get('PS_OS_ERROR');
// Only change state if it's not already the error state.
if ($currentOrderState !== $expectedState) {
$this->module->updateOrderStatus($orderId, $expectedState, 'Payment ' . $orderStatusCallback . ' by Hutko.');
}
exit('Order ' . $orderStatusCallback);
break;
case 'processing':
// If the order is still processing, we might want to update its status
// to a specific 'processing' state if available, or just acknowledge.
// For now, if it's not already in a success/error state, set it to 'processing'.
$processingState = (int)Configuration::get('PS_OS_PAYMENT'); // Or a custom 'processing' state
if ($currentOrderState !== $processingState && $currentOrderState !== (int)Configuration::get('HUTKO_SUCCESS_STATUS_ID') && $currentOrderState !== (int)Configuration::get('PS_OS_ERROR')) {
$this->module->updateOrderStatus($orderId, $processingState, 'Payment processing by Hutko.');
}
exit('Processing');
break;
default:
// Log unexpected status and exit with an error.
PrestaShopLogger::addLog('Hutko Callback: Unexpected order status received: ' . $orderStatusCallback . ' for order ID: ' . $orderId, 3, null, 'Order', $orderId, true);
exit('Unexpected status');
break;
}
} catch (Exception $e) {
// Log any uncaught exceptions and exit with the error message.
PrestaShopLogger::addLog('Hutko Callback Error: ' . $e->getMessage(), 3, null, 'HutkoCallbackModuleFrontController', null, true);
exit($e->getMessage());
}
}
/**
* Helper method to parse the request body from POST or raw input.
*
* @return array The parsed request body.
*/
private function getRequestBody(): array
{
// Prioritize $_POST for form data.
if (!empty($_POST)) {
return $_POST;
}
// Fallback to raw input for JSON payloads, common for callbacks.
$jsonBody = json_decode(Tools::file_get_contents("php://input"), true);
if (is_array($jsonBody)) {
return $jsonBody;
}
return [];
}
}