first commit
This commit is contained in:
214
controllers/front/callback.php
Normal file
214
controllers/front/callback.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?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);
|
||||
};
|
||||
|
||||
// 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 [];
|
||||
}
|
||||
}
|
||||
20
controllers/front/index.php
Normal file
20
controllers/front/index.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
/**
|
||||
* Hutko - Платіжний сервіс, який рухає бізнеси вперед.
|
||||
*
|
||||
* Запускайтесь, набирайте темп, масштабуйтесь – ми підстрахуємо всюди.
|
||||
*
|
||||
* @author panariga
|
||||
* @copyright 2025 Hutko
|
||||
* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0)
|
||||
*/
|
||||
|
||||
header("Expires: Mon, 26 Jul 1997 05:00:00 GMT");
|
||||
header("Last-Modified: ".gmdate("D, d M Y H:i:s")." GMT");
|
||||
|
||||
header("Cache-Control: no-store, no-cache, must-revalidate");
|
||||
header("Cache-Control: post-check=0, pre-check=0", false);
|
||||
header("Pragma: no-cache");
|
||||
|
||||
header("Location: ../");
|
||||
exit;
|
||||
47
controllers/front/redirect.php
Normal file
47
controllers/front/redirect.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?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 HutkoRedirectModuleFrontController
|
||||
*
|
||||
* @property \Hutko $module
|
||||
*/
|
||||
class HutkoRedirectModuleFrontController extends ModuleFrontController
|
||||
{
|
||||
|
||||
/**
|
||||
* Initializes the content of the redirect page for the Hutko payment gateway.
|
||||
*
|
||||
* This method is responsible for preparing the necessary data and assigning
|
||||
* it to the Smarty template that handles the redirection to the Hutko payment
|
||||
* service. It calls the parent class's `initContent` method first and then
|
||||
* assigns the Hutko checkout URL and the payment input parameters to the template.
|
||||
*/
|
||||
public function initContent()
|
||||
{
|
||||
// Call the parent class's initContent method to perform default initializations.
|
||||
parent::initContent();
|
||||
|
||||
// Assign Smarty variables to be used in the redirect template.
|
||||
$this->context->smarty->assign([
|
||||
'hutko_url' => $this->module->checkout_url, // The URL of the Hutko payment gateway.
|
||||
'hutko_inputs' => $this->module->buildInputs(), // An array of input parameters required by Hutko.
|
||||
]);
|
||||
|
||||
// Set the template to be used for displaying the redirection form.
|
||||
$this->setTemplate('module:' . $this->module->name . '/views/templates/front/redirect.tpl');
|
||||
}
|
||||
}
|
||||
185
controllers/front/result.php
Normal file
185
controllers/front/result.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?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 HutkoResultModuleFrontController
|
||||
* Front Controller for handling the result of a Hutko payment.
|
||||
*
|
||||
* This class processes the response from the Hutko payment gateway after a customer
|
||||
* has attempted a payment. It validates the incoming parameters, handles different
|
||||
* payment statuses (approved, declined, processing, expired), and redirects the
|
||||
* customer accordingly to the order confirmation page, order history, or back
|
||||
* to the order page with relevant notifications.
|
||||
*
|
||||
* @property Hutko $module An instance of the Hutko module.
|
||||
*/
|
||||
class HutkoResultModuleFrontController extends ModuleFrontController
|
||||
{
|
||||
/**
|
||||
* Handles the post-processing of the payment gateway response.
|
||||
*
|
||||
* This method retrieves payment status and order details from the request,
|
||||
* performs necessary validations, and then takes action based on the
|
||||
* payment status:
|
||||
* - 'declined' or 'expired': Adds an error and redirects to the order page.
|
||||
* - 'processing': Periodically checks for order creation (up to PHP execution timeout)
|
||||
* and redirects to confirmation if found, or adds an error if not.
|
||||
* - 'approved': Validates the order (if not already created) and redirects
|
||||
* to the order confirmation page.
|
||||
* - Any other status: Redirects to the order history or order page with errors.
|
||||
*/
|
||||
public function postProcess(): void
|
||||
{
|
||||
// Retrieve essential parameters from the request.
|
||||
$orderStatus = Tools::getValue('order_status', false);
|
||||
$transaction_id = Tools::getValue('order_id', false); // This is the combined cart_id|timestamp
|
||||
$amountReceived = round((float)Tools::getValue('amount', 0) / 100, 2);
|
||||
|
||||
// Basic validation: If critical parameters are missing, redirect to home.
|
||||
if (!$transaction_id || !$orderStatus || !$amountReceived) {
|
||||
Tools::redirect('/');
|
||||
}
|
||||
|
||||
// Extract cart ID from the combined order_id parameter.
|
||||
// The order_id is expected to be in the format "cartID|timestamp".
|
||||
$cartIdParts = explode($this->module->order_separator, $transaction_id);
|
||||
$cartId = (int)$cartIdParts[0];
|
||||
|
||||
// Validate extracted cart ID. It must be a numeric value.
|
||||
if (!is_numeric($cartId)) {
|
||||
$this->errors[] = Tools::displayError($this->trans('Invalid cart ID received.', [], 'Modules.Hutko.Shop'));
|
||||
$this->redirectWithNotifications($this->context->link->getPageLink('order', true, $this->context->language->id));
|
||||
return; // Stop execution after redirection
|
||||
}
|
||||
|
||||
// Load the cart object.
|
||||
$cart = new Cart($cartId);
|
||||
|
||||
// Verify that the cart belongs to the current customer to prevent unauthorized access.
|
||||
if (!Validate::isLoadedObject($cart) || $cart->id_customer != $this->context->customer->id) {
|
||||
$this->errors[] = Tools::displayError($this->trans('Access denied to this order.', [], 'Modules.Hutko.Shop'));
|
||||
Tools::redirect('/'); // Redirect to home or a more appropriate error page
|
||||
}
|
||||
|
||||
// Handle different payment statuses.
|
||||
switch ($orderStatus) {
|
||||
case 'declined':
|
||||
$this->errors[] = Tools::displayError($this->trans('Your payment was declined. Please try again or use a different payment method.', [], 'Modules.Hutko.Shop'));
|
||||
$this->redirectWithNotifications($this->context->link->getPageLink('order', true, $this->context->language->id));
|
||||
break;
|
||||
|
||||
case 'expired':
|
||||
$this->errors[] = Tools::displayError($this->trans('Your payment has expired. Please try again.', [], 'Modules.Hutko.Shop'));
|
||||
$this->redirectWithNotifications($this->context->link->getPageLink('order', true, $this->context->language->id));
|
||||
break;
|
||||
|
||||
case 'processing':
|
||||
// For 'processing' status, we need to poll for order creation.
|
||||
// This loop will try to find the order for a limited time to avoid
|
||||
// exceeding PHP execution limits.
|
||||
$maxAttempts = 10; // Max 10 attempts
|
||||
$sleepTime = 5; // Sleep 5 seconds between attempts (total max 50 seconds)
|
||||
$orderFound = false;
|
||||
$orderId = 0;
|
||||
|
||||
for ($i = 0; $i < $maxAttempts; $i++) {
|
||||
$orderId = Order::getIdByCartId($cart->id);
|
||||
if ($orderId) {
|
||||
$orderFound = true;
|
||||
break; // Order found, exit loop
|
||||
}
|
||||
// If not found, wait for a few seconds before retrying.
|
||||
sleep($sleepTime);
|
||||
}
|
||||
|
||||
if ($orderFound) {
|
||||
// Order found, redirect to confirmation page.
|
||||
Tools::redirect($this->context->link->getPageLink('order-confirmation', true, $this->context->language->id, [
|
||||
'id_cart' => $cart->id,
|
||||
'id_module' => $this->module->id,
|
||||
'id_order' => $orderId,
|
||||
'key' => $this->context->customer->secure_key,
|
||||
]));
|
||||
} else {
|
||||
// Order not found after multiple attempts, assume it's still processing or failed silently.
|
||||
$this->errors[] = Tools::displayError($this->trans('Your payment is still processing. Please check your order history later.', [], 'Modules.Hutko.Shop'));
|
||||
$this->redirectWithNotifications($this->context->link->getPageLink('order-history', true, $this->context->language->id));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'approved':
|
||||
$orderId = Order::getIdByCartId($cart->id);
|
||||
|
||||
// If the order doesn't exist yet, validate it.
|
||||
// The postponeCallback is used here to avoid race conditions with the callback controller
|
||||
// (which might be trying to validate the order on seconds ending in 8, while this
|
||||
// controller tries on seconds ending in 3).
|
||||
if (!$orderId) {
|
||||
// Define the validation logic to be executed by postponeCallback.
|
||||
// This callback will first check if the order exists, and only
|
||||
// validate if it doesn't, to avoid race conditions.
|
||||
$validationCallback = function () use ($cart, $amountReceived, $transaction_id) {
|
||||
// Re-check if the order exists right before validation in case the callback
|
||||
// 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.
|
||||
}
|
||||
$idState = (int)Configuration::get('HUTKO_SUCCESS_STATUS_ID');
|
||||
// If order still doesn't exist, proceed with validation.
|
||||
return $this->module->validateOrderFromCart((int)$cart->id, $amountReceived, $transaction_id, $idState);
|
||||
};
|
||||
|
||||
// Postpone the execution of the validation callback until the second ends in 3.
|
||||
$validationResult = $this->module->postponeCallback($validationCallback, 3);
|
||||
|
||||
// After the postponed callback has run, try to get the order ID again.
|
||||
$orderId = Order::getIdByCartId($cart->id);
|
||||
|
||||
// If validation failed or order still not found, add an error.
|
||||
if (!$orderId || !$validationResult) {
|
||||
$this->errors[] = Tools::displayError($this->trans('Payment approved but order could not be created. Please contact support.', [], 'Modules.Hutko.Shop'));
|
||||
$this->redirectWithNotifications($this->context->link->getPageLink('order', true, $this->context->language->id));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If order exists (either found initially or created by validation), redirect to confirmation.
|
||||
Tools::redirect($this->context->link->getPageLink('order-confirmation', true, $this->context->language->id, [
|
||||
'id_cart' => $cart->id,
|
||||
'id_module' => $this->module->id,
|
||||
'id_order' => $orderId,
|
||||
'key' => $this->context->customer->secure_key,
|
||||
]));
|
||||
break;
|
||||
|
||||
default:
|
||||
// For any unexpected status, redirect to order history with a generic error.
|
||||
$this->errors[] = Tools::displayError($this->trans('An unexpected payment status was received. Please check your order history.', [], 'Modules.Hutko.Shop'));
|
||||
$this->redirectWithNotifications($this->context->link->getPageLink('order-history', true, $this->context->language->id));
|
||||
break;
|
||||
}
|
||||
|
||||
// This part should ideally not be reached if all cases are handled with redirects.
|
||||
// However, as a fallback, if any errors were accumulated without a specific redirect,
|
||||
// redirect to the order page.
|
||||
if (count($this->errors)) {
|
||||
$this->redirectWithNotifications($this->context->link->getPageLink('order', true, $this->context->language->id));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user