534 lines
15 KiB
PHP
Executable File
534 lines
15 KiB
PHP
Executable File
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Opencart\Catalog\Controller\Extension\PayAnyWay\Payment;
|
|
|
|
require_once DIR_EXTENSION . '/payanyway/catalog/library/payanyway/traits/payanyway.php';
|
|
|
|
/**
|
|
* @property \Opencart\System\Engine\Config $config
|
|
*/
|
|
class Callback extends \Opencart\System\Engine\Controller
|
|
{
|
|
use \Opencart\Catalog\Library\Extension\PayAnyWay\Traits\PayAnyWay;
|
|
|
|
private const FAIL_RESPONSE = 'FAIL';
|
|
private const XML_CONTENT_TYPE = 'application/xml';
|
|
private const JSON_CONTENT_TYPE = 'application/json';
|
|
private const TEXT_CONTENT_TYPE = 'text/plain; charset=UTF-8';
|
|
|
|
public const DESCRIPTION_MAX_LENGTH = 500;
|
|
private const ITEM_NAME_MAX_LENGTH = 128;
|
|
private const ITEM_DEFAULT_VAT_TAG = '1105';
|
|
private const DEFAULT_PAYMENT_METHOD = 'full_payment';
|
|
private const DEFAULT_PAYMENT_OBJECT = 'commodity';
|
|
private const SHIPPING_PAYMENT_OBJECT = 'service';
|
|
private const DEFAULT_MEASURE = 'unit';
|
|
|
|
private const CANCEL_ORDER_STATUS_IDS = [7, 9, 16];
|
|
|
|
private int $mntId;
|
|
private string $integrityCode;
|
|
private string $server;
|
|
private string $vatRate;
|
|
private string $currency;
|
|
private int $pendingOrderStatusId;
|
|
private int $successOrderStatusId;
|
|
private int $errorOrderStatusId;
|
|
private int $geoZoneId;
|
|
private int $sortOrder;
|
|
private string $cmsModuleVersion;
|
|
|
|
private array $callbackData = [];
|
|
private ?int $orderId;
|
|
private array $orderInfo = [];
|
|
|
|
public function __construct(\Opencart\System\Engine\Registry $registry)
|
|
{
|
|
parent::__construct($registry);
|
|
|
|
if (!$this->validateOrder()) {
|
|
$this->sendResponse(self::FAIL_RESPONSE);
|
|
}
|
|
|
|
$this->initializeModule();
|
|
$this->initializeCallbackData();
|
|
}
|
|
|
|
/** @throws \Exception */
|
|
public function index(): void
|
|
{
|
|
$this->validateCallback();
|
|
|
|
($this->callbackData['MNT_COMMAND'] === 'CHECK')
|
|
? $this->handleCheckCallback()
|
|
: $this->handlePayCallback();
|
|
}
|
|
|
|
private function initializeCallbackData(): void
|
|
{
|
|
$requestData = ($_SERVER['REQUEST_METHOD'] === 'POST') ? $_POST : $_GET;
|
|
|
|
$this->callbackData = [
|
|
'MNT_COMMAND' => $this->getOptionalStringWithDefault($requestData, 'MNT_COMMAND'),
|
|
'MNT_ID' => $this->getRequiredString($requestData, 'MNT_ID'),
|
|
'MNT_TRANSACTION_ID' => $this->getRequiredString($requestData, 'MNT_TRANSACTION_ID'),
|
|
'MNT_OPERATION_ID' => $this->getOptionalStringWithDefault($requestData, 'MNT_OPERATION_ID'),
|
|
'MNT_AMOUNT' => $this->getOptionalStringWithDefault($requestData, 'MNT_AMOUNT'),
|
|
'MNT_CURRENCY_CODE' => $this->getRequiredString($requestData, 'MNT_CURRENCY_CODE'),
|
|
'MNT_SUBSCRIBER_ID' => $this->getOptionalStringWithDefault($requestData, 'MNT_SUBSCRIBER_ID'),
|
|
'MNT_TEST_MODE' => $this->getRequiredString($requestData, 'MNT_TEST_MODE'),
|
|
'MNT_SIGNATURE' => $this->getRequiredString($requestData, 'MNT_SIGNATURE'),
|
|
];
|
|
}
|
|
|
|
private function validateCallback(): void
|
|
{
|
|
if (empty($this->mntId) || empty($this->integrityCode)) {
|
|
$this->sendResponse(self::FAIL_RESPONSE);
|
|
}
|
|
|
|
if ($this->mntId !== (int)$this->callbackData['MNT_ID']) {
|
|
$this->sendResponse(self::FAIL_RESPONSE);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array|string $response
|
|
* @param int $statusCode
|
|
* @return void
|
|
*/
|
|
private function sendResponse(array|string $response, int $statusCode = 200): void
|
|
{
|
|
http_response_code($statusCode);
|
|
if (is_string($response)) {
|
|
header('Content-Type: ' . self::TEXT_CONTENT_TYPE);
|
|
echo $response;
|
|
} else {
|
|
header('Content-Type: ' . $response['contentType']);
|
|
echo $response['data'];
|
|
}
|
|
|
|
exit();
|
|
}
|
|
|
|
/** @throws \Exception */
|
|
private function handleCheckCallback(): void
|
|
{
|
|
if (!$this->checkSignature($this->callbackData)) {
|
|
$this->sendResponse(self::FAIL_RESPONSE);
|
|
}
|
|
|
|
[$resultCode, $description] = $this->determineCheckResult();
|
|
$responseData = $this->buildBaseResponseData($resultCode, $description);
|
|
|
|
$products = $this->getAccountOrderProducts();
|
|
$deliveryInfo = $this->getDeliveryInfo();
|
|
|
|
$responseData['client'] = $this->getClientInfo();
|
|
$responseData['items'] = $this->getCheckItems($products, $deliveryInfo);
|
|
|
|
$this->sendResponse($this->buildJsonResponse($responseData));
|
|
}
|
|
|
|
/**
|
|
* @return array<int|string>
|
|
* @throws \Exception
|
|
*/
|
|
private function determineCheckResult(): array
|
|
{
|
|
$orderStatusId = isset($this->orderInfo['order_status_id']) ? (int)$this->orderInfo['order_status_id'] : null;
|
|
if (null === $orderStatusId) {
|
|
return [500, 'Order status not set'];
|
|
}
|
|
$orderStatus = $this->getOrderStatusById($orderStatusId);
|
|
|
|
if (empty($this->callbackData['MNT_AMOUNT'])) {
|
|
return [100, "Order status is {$orderStatus}"];
|
|
}
|
|
|
|
if ($this->successOrderStatusId === $orderStatusId) {
|
|
return [200, 'Order Paid'];
|
|
}
|
|
|
|
if (in_array($orderStatusId, self::CANCEL_ORDER_STATUS_IDS, true)) {
|
|
return [500, "Order status is {$orderStatus}"];
|
|
}
|
|
|
|
return [402, 'Order created, but not paid'];
|
|
}
|
|
|
|
/** @throws \Exception */
|
|
private function handlePayCallback(): void
|
|
{
|
|
$shopData = [
|
|
'MNT_ID' => $this->mntId,
|
|
'MNT_TRANSACTION_ID' => $this->callbackData['MNT_TRANSACTION_ID'],
|
|
'MNT_OPERATION_ID' => $this->callbackData['MNT_OPERATION_ID'],
|
|
'MNT_AMOUNT' => $this->formatPrice((float)($this->orderInfo['total'] ?? 0)),
|
|
'MNT_CURRENCY_CODE' => $this->orderInfo['currency_code'] ?? 'RUB',
|
|
'MNT_SUBSCRIBER_ID' => $this->callbackData['MNT_SUBSCRIBER_ID'],
|
|
'MNT_TEST_MODE' => $this->callbackData['MNT_TEST_MODE'],
|
|
];
|
|
|
|
if (!$this->checkSignature($shopData)) {
|
|
$this->sendResponse(self::FAIL_RESPONSE);
|
|
}
|
|
|
|
$isPayedOrder = $this->isOrderPaid();
|
|
if (!$isPayedOrder) {
|
|
$this->load->language('extension/payanyway/payment/payanyway');
|
|
$this->addOrderHistory($this->orderId, $this->successOrderStatusId, $this->language->get('text_payment_completed'),);
|
|
}
|
|
|
|
$resultCode = 200;
|
|
$description = $isPayedOrder ? 'Order already paid' : 'Order success paid';
|
|
$responseData = $this->buildBaseResponseData($resultCode, $description);
|
|
|
|
$products = $this->getAccountOrderProducts();
|
|
$deliveryInfo = $this->getDeliveryInfo();
|
|
|
|
$payInventoryJson = $this->getPayInventoryJson($products, $deliveryInfo);
|
|
$responseData['inventory'] = $payInventoryJson ?: null;
|
|
$clientInfo = $this->getClientInfo();
|
|
$responseData['client'] = $clientInfo
|
|
? json_encode([$clientInfo], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
|
: null;
|
|
|
|
$this->sendResponse($this->buildXmlResponse($responseData));
|
|
}
|
|
|
|
/**
|
|
* @param int $resultCode
|
|
* @param string $description
|
|
* @return array<string, mixed>
|
|
* @throws \Exception
|
|
*/
|
|
private function buildBaseResponseData(int $resultCode, string $description): array
|
|
{
|
|
$orderTotal = (float)($this->orderInfo['total'] ?? 0);
|
|
|
|
$data = [
|
|
'MNT_ID' => $this->mntId,
|
|
'MNT_TRANSACTION_ID' => $this->callbackData['MNT_TRANSACTION_ID'],
|
|
'MNT_RESULT_CODE' => $resultCode,
|
|
'MNT_DESCRIPTION' => $this->validateString($description, self::DESCRIPTION_MAX_LENGTH),
|
|
'MNT_AMOUNT' => $this->formatPrice($orderTotal),
|
|
'MNT_CURRENCY_CODE' => $this->callbackData['MNT_CURRENCY_CODE'],
|
|
];
|
|
|
|
$signatureSource = $resultCode . $this->mntId . $this->callbackData['MNT_TRANSACTION_ID'] . $this->integrityCode;
|
|
$data['MNT_SIGNATURE'] = md5($signatureSource);
|
|
|
|
return $data;
|
|
}
|
|
|
|
private function isOrderPaid(): bool
|
|
{
|
|
return $this->successOrderStatusId === (int)$this->orderInfo['order_status_id'];
|
|
}
|
|
|
|
/** @param array<string, mixed> $data */
|
|
private function checkSignature(array $data): bool
|
|
{
|
|
$baseFields = [
|
|
'MNT_ID',
|
|
'MNT_TRANSACTION_ID',
|
|
'MNT_OPERATION_ID',
|
|
'MNT_AMOUNT',
|
|
'MNT_CURRENCY_CODE',
|
|
'MNT_SUBSCRIBER_ID',
|
|
'MNT_TEST_MODE',
|
|
];
|
|
|
|
$fields = isset($data['MNT_COMMAND']) && ('CHECK' === $data['MNT_COMMAND'])
|
|
? array_merge(['MNT_COMMAND'], $baseFields)
|
|
: $baseFields;
|
|
|
|
$signatureString = array_reduce($fields, static function ($carry, $field) use ($data) {
|
|
return $carry . $data[$field];
|
|
}, '') . $this->integrityCode;
|
|
|
|
return hash_equals(md5($signatureString), $this->callbackData['MNT_SIGNATURE']);
|
|
}
|
|
|
|
private function getDeliveryInfo(): ?array
|
|
{
|
|
$this->load->model('account/order');
|
|
foreach ($this->model_account_order->getTotals($this->orderId) as $orderTotal) {
|
|
if (($orderTotal['code'] ?? '') === 'shipping') {
|
|
return $orderTotal;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function getClientInfo(): array
|
|
{
|
|
$clientInfo = [];
|
|
$name = $this->getClientName();
|
|
if (null !== $name) {
|
|
$clientInfo['name'] = $name;
|
|
}
|
|
$email = $this->getClientEmail();
|
|
if (null !== $email) {
|
|
$clientInfo['email'] = $email;
|
|
}
|
|
$phoneNumber = $this->getClientPhone();
|
|
if (null !== $phoneNumber) {
|
|
$clientInfo['phone'] = $phoneNumber;
|
|
}
|
|
|
|
return $clientInfo;
|
|
}
|
|
|
|
private function getCheckItems(array $products, ?array $deliveryInfo): array
|
|
{
|
|
$checkItems = array();
|
|
foreach ($products as $product) {
|
|
$checkItems[] = array(
|
|
'name' => $this->validateString($product['name'] ?? '', self::ITEM_NAME_MAX_LENGTH),
|
|
'price' => $this->formatPrice((float)($product['price'] ?? 0)),
|
|
'quantity' => $this->formatQuantity((float)($product['quantity'] ?? 0)),
|
|
'vat' => $this->getVat($this->vatRate),
|
|
'measure' => self::DEFAULT_MEASURE,
|
|
'paymentMethod' => self::DEFAULT_PAYMENT_METHOD,
|
|
'paymentObject' => self::DEFAULT_PAYMENT_OBJECT,
|
|
);
|
|
}
|
|
|
|
if (null === $deliveryInfo) {
|
|
return $checkItems;
|
|
}
|
|
|
|
$checkItems[] = array(
|
|
'name' => $this->validateString($deliveryInfo['title'] ?? '', self::ITEM_NAME_MAX_LENGTH),
|
|
'price' => $this->formatPrice((float)($deliveryInfo['value'] ?? 0)),
|
|
'quantity' => 1,
|
|
'vat' => $this->getVat($this->vatRate),
|
|
'measure' => self::DEFAULT_MEASURE,
|
|
'paymentMethod' => self::DEFAULT_PAYMENT_METHOD,
|
|
'paymentObject' => self::SHIPPING_PAYMENT_OBJECT,
|
|
);
|
|
|
|
return $checkItems;
|
|
}
|
|
|
|
private function getPayInventoryJson(array $products, ?array $deliveryInfo): false|string
|
|
{
|
|
if (empty($products) && empty($deliveryInfo)) {
|
|
return false;
|
|
}
|
|
|
|
$inventory = [];
|
|
foreach ($products as $product) {
|
|
$inventory[] = [
|
|
'name' => $this->validateString($product['name'] ?? '', self::ITEM_NAME_MAX_LENGTH),
|
|
'price' => $this->formatPrice((float)($product['price'] ?? 0)),
|
|
'quantity' => $this->formatQuantity((float)($product['quantity'] ?? 0)),
|
|
'vatTag' => $this->vatRate,
|
|
'pm' => self::DEFAULT_PAYMENT_METHOD,
|
|
'po' => self::DEFAULT_PAYMENT_OBJECT,
|
|
'measure' => self::DEFAULT_MEASURE,
|
|
];
|
|
}
|
|
|
|
if (null !== $deliveryInfo) {
|
|
$inventory[] = [
|
|
'name' => $this->validateString($deliveryInfo['title'] ?? '', self::ITEM_NAME_MAX_LENGTH),
|
|
'price' => $this->formatPrice((float)($deliveryInfo['value'] ?? 0)),
|
|
'quantity' => '1',
|
|
'vatTag' => $this->vatRate,
|
|
'pm' => self::DEFAULT_PAYMENT_METHOD,
|
|
'po' => self::SHIPPING_PAYMENT_OBJECT,
|
|
'measure' => self::DEFAULT_MEASURE,
|
|
];
|
|
}
|
|
|
|
return json_encode($inventory, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
}
|
|
|
|
/**
|
|
* @param int $orderStatusId
|
|
* @return string
|
|
* @throws \Exception
|
|
*/
|
|
public function getOrderStatusById(int $orderStatusId): string
|
|
{
|
|
$this->load->model('localisation/order_status');
|
|
/**@var array<int, array{order_status_id: string, name: string}> $orderStatuses */
|
|
$orderStatuses = $this->model_localisation_order_status->getOrderStatuses();
|
|
|
|
$orderStatus = 'unknown';
|
|
foreach ($orderStatuses as $orderStatus) {
|
|
$statusId = isset($orderStatus['order_status_id']) ? (int)$orderStatus['order_status_id'] : null;
|
|
if ($statusId === $orderStatusId) {
|
|
$orderStatus = $orderStatus['name'];
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $orderStatus;
|
|
}
|
|
|
|
private function formatQuantity(float $quantity): string
|
|
{
|
|
$quantityString = number_format($quantity, 3, '.', '');
|
|
return rtrim(rtrim($quantityString, '0'), '.');
|
|
}
|
|
|
|
/**
|
|
* @param array<non-empty-string, mixed> $data
|
|
* @return array
|
|
*/
|
|
public function buildJsonResponse(array $data): array
|
|
{
|
|
$jsonData = [
|
|
'id' => $data['MNT_ID'],
|
|
'transactionId' => $data['MNT_TRANSACTION_ID'],
|
|
'amount' => $data['MNT_AMOUNT'],
|
|
'signature' => $data['MNT_SIGNATURE'],
|
|
'resultCode' => $data['MNT_RESULT_CODE'],
|
|
'description' => $data['MNT_DESCRIPTION'],
|
|
'externalId' => $data['MNT_TRANSACTION_ID'],
|
|
'receipt' => [
|
|
'client' => $data['client'],
|
|
'items' => $data['items'],
|
|
],
|
|
];
|
|
|
|
$json = json_encode($jsonData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
|
|
return [
|
|
'data' => $json ?: '',
|
|
'contentType' => self::JSON_CONTENT_TYPE,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
* @throws \Exception
|
|
*/
|
|
private function buildXmlResponse(array $data): array
|
|
{
|
|
$dom = new \DOMDocument('1.0', 'UTF-8');
|
|
$dom->formatOutput = false;
|
|
|
|
$root = $dom->createElement('MNT_RESPONSE');
|
|
$dom->appendChild($root);
|
|
|
|
$requiredFields = [
|
|
'MNT_ID',
|
|
'MNT_TRANSACTION_ID',
|
|
'MNT_RESULT_CODE',
|
|
'MNT_DESCRIPTION',
|
|
'MNT_AMOUNT',
|
|
'MNT_CURRENCY_CODE',
|
|
'MNT_SIGNATURE',
|
|
];
|
|
|
|
foreach ($requiredFields as $field) {
|
|
if (isset($data[$field]) && ($data[$field] !== '')) {
|
|
$root->appendChild($dom->createElement($field, (string)$data[$field]));
|
|
}
|
|
}
|
|
|
|
$attributes = $dom->createElement('MNT_ATTRIBUTES');
|
|
$root->appendChild($attributes);
|
|
|
|
$attributeMap = [
|
|
'INVENTORY' => $data['inventory'] ?? null,
|
|
'CLIENT' => $data['client'] ?? null,
|
|
'SNO' => $data['sno'] ?? null,
|
|
'DELIVERY' => $data['delivery'] ?? null,
|
|
];
|
|
|
|
foreach ($attributeMap as $key => $value) {
|
|
if (!empty($value)) {
|
|
$attrElement = $dom->createElement('ATTRIBUTE');
|
|
$attrElement->appendChild($dom->createElement('KEY', $key));
|
|
$attrElement->appendChild($dom->createElement('VALUE', (string)$value));
|
|
$attributes->appendChild($attrElement);
|
|
}
|
|
}
|
|
|
|
$xml = $dom->saveXML();
|
|
|
|
return array(
|
|
'data' => (false !== $xml) ? $xml : self::FAIL_RESPONSE,
|
|
'contentType' => (false !== $xml) ? self::XML_CONTENT_TYPE : self::TEXT_CONTENT_TYPE,
|
|
);
|
|
}
|
|
|
|
public function getVat($vatRate): string
|
|
{
|
|
return match ($vatRate) {
|
|
'1104' => 'vat0',
|
|
'1108' => 'vat5',
|
|
'1109' => 'vat7',
|
|
'1103' => 'vat10',
|
|
'7000' => 'vat20',
|
|
'1113' => 'vat22',
|
|
'1110' => 'vat105',
|
|
'1111' => 'vat107',
|
|
'1107' => 'vat110',
|
|
'7001' => 'vat120',
|
|
'1114' => 'vat122',
|
|
default => 'none'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array
|
|
* @throws \Exception
|
|
*/
|
|
public function getAccountOrderProducts(): array
|
|
{
|
|
$this->load->model('account/order');
|
|
return $this->model_account_order->getProducts($this->orderId);
|
|
}
|
|
|
|
/** @param array<string, mixed> $params */
|
|
private function getOptionalStringWithDefault(array $params, string $key, string $default = ''): string
|
|
{
|
|
return $this->getOptionalString($params, $key) ?? $default;
|
|
}
|
|
|
|
/** @param array<string, mixed> $params */
|
|
private function getOptionalString(array $params, string $key): ?string
|
|
{
|
|
$value = $params[$key] ?? null;
|
|
if (!is_string($value) || trim($value) === '') {
|
|
return null;
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $params
|
|
* @throws \InvalidArgumentException
|
|
*/
|
|
private function getRequiredString(array $params, string $key): string
|
|
{
|
|
if (!isset($params[$key])) {
|
|
throw new \InvalidArgumentException('Missing required field: ' . $key);
|
|
}
|
|
|
|
$value = $params[$key];
|
|
if (!is_string($value)) {
|
|
throw new \InvalidArgumentException(
|
|
sprintf('Field "%s" must be a string, %s given', $key, gettype($value)),
|
|
);
|
|
}
|
|
|
|
if ('' === $value) {
|
|
throw new \InvalidArgumentException(sprintf('Field "%s" must be a non-empty string', $key));
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
}
|