feat: add fiscalization settings, POST callbacks and fix callback issues
This commit is contained in:
@@ -20,57 +20,82 @@ class Callback extends \Opencart\System\Engine\Controller
|
||||
|
||||
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 CLIENT_NAME_MAX_LENGTH = 256;
|
||||
private const CHECK_CLIENT_PHONE_MAX_LENGTH = 13;
|
||||
private const SERVICE_PAYMENT_OBJECT = 'service';
|
||||
private const VOUCHER_PAYMENT_METHOD = 'advance';
|
||||
private const VOUCHER_PAYMENT_OBJECT = 'payment';
|
||||
private const LEGACY_TRANSACTION_MAX_AGE_DAYS = 14;
|
||||
private const PAYMENT_ITEM_NAME = 'Оплата заказа №';
|
||||
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 const JSON_NUMBER_PREFIX = '__payanyway_number:';
|
||||
private const JSON_NUMBER_KEYS = ['amount', 'price'];
|
||||
|
||||
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
|
||||
{
|
||||
try {
|
||||
$this->callbackData = $this->getCallbackData($this->getCallbackRequestData());
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
$this->log->write('PayAnyWay callback: ' . $exception->getMessage());
|
||||
$this->sendResponse(self::FAIL_RESPONSE);
|
||||
}
|
||||
|
||||
$this->validateCallback();
|
||||
|
||||
($this->callbackData['MNT_COMMAND'] === 'CHECK')
|
||||
? $this->handleCheckCallback()
|
||||
: $this->handlePayCallback();
|
||||
$isCheck = 'CHECK' === $this->callbackData['MNT_COMMAND'];
|
||||
if (!$this->hasRequiredOperationId()) {
|
||||
$this->log->write('PayAnyWay callback: MNT_OPERATION_ID is missing or not numeric');
|
||||
$this->sendResponse(self::FAIL_RESPONSE);
|
||||
}
|
||||
|
||||
if (!$this->checkSignature($this->callbackData)) {
|
||||
$this->log->write('PayAnyWay callback: MNT_SIGNATURE does not match');
|
||||
$this->sendResponse(self::FAIL_RESPONSE);
|
||||
}
|
||||
|
||||
$orderId = $this->parseTransactionId($this->callbackData['MNT_TRANSACTION_ID'])['order_id'] ?? null;
|
||||
if (null === $orderId) {
|
||||
$this->log->write('PayAnyWay callback: MNT_TRANSACTION_ID has unexpected format');
|
||||
$this->sendResponse(self::FAIL_RESPONSE);
|
||||
}
|
||||
|
||||
$this->load->model('extension/payanyway/payment/payanyway');
|
||||
if (!$isCheck && !$this->model_extension_payanyway_payment_payanyway->acquireOrderLock($orderId, self::$orderLockTimeoutSeconds)) {
|
||||
$this->log->write('PayAnyWay callback: order is locked by another request');
|
||||
$this->sendResponse(self::FAIL_RESPONSE);
|
||||
}
|
||||
|
||||
$this->loadOrder($orderId);
|
||||
if ([] === $this->orderInfo) {
|
||||
$this->sendResponse($isCheck ? $this->buildUnknownOrderResponse() : self::FAIL_RESPONSE);
|
||||
}
|
||||
|
||||
if (!$this->isPaidWithModule()) {
|
||||
$this->log->write('PayAnyWay callback: order is not paid with PayAnyWay');
|
||||
$this->sendResponse($isCheck ? $this->buildUnknownOrderResponse() : self::FAIL_RESPONSE);
|
||||
}
|
||||
|
||||
$isCheck ? $this->handleCheckCallback() : $this->handlePayCallback();
|
||||
}
|
||||
|
||||
private function initializeCallbackData(): void
|
||||
/**
|
||||
* @param array<string, mixed> $requestData
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function getCallbackData(array $requestData): array
|
||||
{
|
||||
$requestData = ($_SERVER['REQUEST_METHOD'] === 'POST') ? $_POST : $_GET;
|
||||
|
||||
$this->callbackData = [
|
||||
return [
|
||||
'MNT_COMMAND' => $this->getOptionalStringWithDefault($requestData, 'MNT_COMMAND'),
|
||||
'MNT_ID' => $this->getRequiredString($requestData, 'MNT_ID'),
|
||||
'MNT_TRANSACTION_ID' => $this->getRequiredString($requestData, 'MNT_TRANSACTION_ID'),
|
||||
@@ -89,15 +114,18 @@ class Callback extends \Opencart\System\Engine\Controller
|
||||
$this->sendResponse(self::FAIL_RESPONSE);
|
||||
}
|
||||
|
||||
if ($this->mntId !== (int)$this->callbackData['MNT_ID']) {
|
||||
if (!$this->isOwnAccount()) {
|
||||
$this->sendResponse(self::FAIL_RESPONSE);
|
||||
}
|
||||
|
||||
if ($this->callbackData['MNT_TEST_MODE'] !== ($this->testMode ? '1' : '0')) {
|
||||
$this->log->write('PayAnyWay callback: MNT_TEST_MODE does not match module settings');
|
||||
$this->sendResponse(self::FAIL_RESPONSE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array|string $response
|
||||
* @param int $statusCode
|
||||
* @return void
|
||||
* @param array{data: string, contentType: string}|string $response
|
||||
*/
|
||||
private function sendResponse(array|string $response, int $statusCode = 200): void
|
||||
{
|
||||
@@ -116,24 +144,25 @@ class Callback extends \Opencart\System\Engine\Controller
|
||||
/** @throws \Exception */
|
||||
private function handleCheckCallback(): void
|
||||
{
|
||||
if (!$this->checkSignature($this->callbackData)) {
|
||||
$this->sendResponse(self::FAIL_RESPONSE);
|
||||
if (!$this->isOrderPaid()) {
|
||||
$this->rejectPaymentMismatch();
|
||||
}
|
||||
|
||||
[$resultCode, $description] = $this->determineCheckResult();
|
||||
$responseData = $this->buildBaseResponseData($resultCode, $description);
|
||||
|
||||
$products = $this->getAccountOrderProducts();
|
||||
$deliveryInfo = $this->getDeliveryInfo();
|
||||
if ($this->fiscalization) {
|
||||
$responseData['client'] = $this->getClientInfo(true);
|
||||
$responseData['items'] = $this->getCheckItems($this->getReceiptLines());
|
||||
|
||||
$responseData['client'] = $this->getClientInfo();
|
||||
$responseData['items'] = $this->getCheckItems($products, $deliveryInfo);
|
||||
$this->sendResponse($this->buildJsonResponse($responseData));
|
||||
}
|
||||
|
||||
$this->sendResponse($this->buildJsonResponse($responseData));
|
||||
$this->sendResponse($this->buildXmlResponse($responseData + $this->getResponseAttributes()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int|string>
|
||||
* @return array{0: int, 1: string}
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function determineCheckResult(): array
|
||||
@@ -144,15 +173,15 @@ class Callback extends \Opencart\System\Engine\Controller
|
||||
}
|
||||
$orderStatus = $this->getOrderStatusById($orderStatusId);
|
||||
|
||||
if (empty($this->callbackData['MNT_AMOUNT'])) {
|
||||
if ('' === $this->callbackData['MNT_AMOUNT']) {
|
||||
return [100, "Order status is {$orderStatus}"];
|
||||
}
|
||||
|
||||
if ($this->successOrderStatusId === $orderStatusId) {
|
||||
if ($this->isOrderPaid()) {
|
||||
return [200, 'Order Paid'];
|
||||
}
|
||||
|
||||
if (in_array($orderStatusId, self::CANCEL_ORDER_STATUS_IDS, true)) {
|
||||
if (in_array($orderStatusId, $this->canceledOrderStatusIds, true)) {
|
||||
return [500, "Order status is {$orderStatus}"];
|
||||
}
|
||||
|
||||
@@ -162,60 +191,133 @@ class Callback extends \Opencart\System\Engine\Controller
|
||||
/** @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->rejectPaymentMismatch();
|
||||
$this->rejectCanceledOrder();
|
||||
|
||||
$this->load->language('extension/payanyway/payment/payanyway');
|
||||
$this->addOrderHistory($this->orderId, $this->successOrderStatusId, $this->language->get('text_payment_completed'),);
|
||||
$this->addOrderHistory($this->orderId, $this->successOrderStatusId, $this->getPaymentCompletedComment($this->callbackData['MNT_OPERATION_ID']));
|
||||
}
|
||||
$this->model_extension_payanyway_payment_payanyway->releaseOrderLock($this->orderId);
|
||||
|
||||
$resultCode = 200;
|
||||
$description = $isPayedOrder ? 'Order already paid' : 'Order success paid';
|
||||
$responseData = $this->buildBaseResponseData($resultCode, $description);
|
||||
$responseData = $this->buildBaseResponseData(200, $description);
|
||||
|
||||
$products = $this->getAccountOrderProducts();
|
||||
$deliveryInfo = $this->getDeliveryInfo();
|
||||
$this->sendResponse($this->buildXmlResponse($responseData + $this->getResponseAttributes()));
|
||||
}
|
||||
|
||||
$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));
|
||||
/** Уведомление не согласовано с номером транзакции или заказом — FAIL и запись в лог. */
|
||||
private function rejectPaymentMismatch(): void
|
||||
{
|
||||
$mismatch = $this->getPaymentMismatch();
|
||||
if (null !== $mismatch) {
|
||||
$this->log->write('PayAnyWay callback: ' . $mismatch);
|
||||
$this->sendResponse(self::FAIL_RESPONSE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Заказ отменили до оплаты (менеджер, аннулирование ядром при правке заказа) — оплата его не
|
||||
* проводит: подписанный 500 без атрибутов прекращает повторы, номер операции в логе нужен
|
||||
* администратору для возврата в кабинете MONETA.RU. Как Check URL и остальные модули.
|
||||
*/
|
||||
private function rejectCanceledOrder(): void
|
||||
{
|
||||
$response = $this->buildCanceledOrderPayResponse();
|
||||
if (null !== $response) {
|
||||
$this->log->write('PayAnyWay callback: order ' . $this->orderId . ' is canceled, payment ' . $this->callbackData['MNT_OPERATION_ID'] . ' must be refunded');
|
||||
$this->sendResponse($response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{data: string, contentType: string}|null
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function buildCanceledOrderPayResponse(): ?array
|
||||
{
|
||||
$orderStatusId = (int)($this->orderInfo['order_status_id'] ?? 0);
|
||||
if (!in_array($orderStatusId, $this->canceledOrderStatusIds, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->buildXmlResponse($this->buildBaseResponseData(500, 'Order status is ' . $this->getOrderStatusById($orderStatusId)), false);
|
||||
}
|
||||
|
||||
private function getPaymentMismatch(): ?string
|
||||
{
|
||||
// Форма уходит на MONETA.RU только после confirm(), который переводит заказ в «Ожидание».
|
||||
// Заказ в статусе 0 ядро правит без блокировки (checkout/confirm) — его не проводим.
|
||||
if (0 === (int)($this->orderInfo['order_status_id'] ?? 0)) {
|
||||
return 'order was not confirmed before payment';
|
||||
}
|
||||
|
||||
$transaction = $this->parseTransactionId($this->callbackData['MNT_TRANSACTION_ID']);
|
||||
if ((null === $transaction) || ($transaction['order_id'] !== $this->orderId)) {
|
||||
return 'MNT_TRANSACTION_ID has unexpected format';
|
||||
}
|
||||
|
||||
if (null === $transaction['amount']) {
|
||||
$issuedAt = \DateTimeImmutable::createFromFormat('!YmdHis', $transaction['issued_at']);
|
||||
if ((false === $issuedAt) || ($issuedAt->modify('+' . self::LEGACY_TRANSACTION_MAX_AGE_DAYS . ' days') < new \DateTimeImmutable())) {
|
||||
return 'MNT_TRANSACTION_ID of the previous format has expired';
|
||||
}
|
||||
} elseif (('' !== $this->callbackData['MNT_AMOUNT']) && ($this->callbackData['MNT_AMOUNT'] !== $transaction['amount'])) {
|
||||
return 'MNT_AMOUNT does not match MNT_TRANSACTION_ID';
|
||||
} elseif ($transaction['amount'] !== $this->getOrderAmount()) {
|
||||
return 'order amount changed after the payment form was issued';
|
||||
}
|
||||
|
||||
$isCheck = 'CHECK' === $this->callbackData['MNT_COMMAND'];
|
||||
if ((!$isCheck || ('' !== $this->callbackData['MNT_AMOUNT'])) && !$this->isOrderAmountMatched()) {
|
||||
return 'MNT_AMOUNT or MNT_CURRENCY_CODE does not match the order';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getPaymentCompletedComment(string $operationId): string
|
||||
{
|
||||
$comment = $this->language->get('text_payment_completed');
|
||||
if (preg_match(self::$operationIdPattern, $operationId)) {
|
||||
$comment .= '. ' . sprintf($this->language->get('text_payment_operation'), $operationId);
|
||||
}
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string|null>
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function getResponseAttributes(): array
|
||||
{
|
||||
$inventory = $this->getInventoryJson($this->getReceiptLines());
|
||||
$client = $this->getClientInfo($this->fiscalization);
|
||||
|
||||
return [
|
||||
'inventory' => $inventory ?: null,
|
||||
'client' => $client ? json_encode([$client], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
|
||||
'customer' => $client['email'] ?? null,
|
||||
'phone' => isset($client['phone']) ? ltrim($client['phone'], '+') : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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_AMOUNT' => $this->getOrderAmount(),
|
||||
'MNT_CURRENCY_CODE' => $this->callbackData['MNT_CURRENCY_CODE'],
|
||||
'MNT_CMS' => $this->getCms(),
|
||||
];
|
||||
|
||||
$signatureSource = $resultCode . $this->mntId . $this->callbackData['MNT_TRANSACTION_ID'] . $this->integrityCode;
|
||||
@@ -224,9 +326,49 @@ class Callback extends \Opencart\System\Engine\Controller
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{data: string, contentType: string}
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function buildUnknownOrderResponse(): array
|
||||
{
|
||||
$responseData = $this->buildBaseResponseData(500, 'Order not found');
|
||||
unset($responseData['MNT_AMOUNT']);
|
||||
|
||||
return $this->buildXmlResponse($responseData, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Оплата уже проведена: заказ в «Успешном» статусе или был в нём раньше — администратор мог
|
||||
* перевести оплаченный заказ дальше (Complete, Refunded, Canceled). Повтор уведомления такой
|
||||
* заказ не трогает.
|
||||
*/
|
||||
private function isOrderPaid(): bool
|
||||
{
|
||||
return $this->successOrderStatusId === (int)$this->orderInfo['order_status_id'];
|
||||
if ($this->successOrderStatusId === (int)($this->orderInfo['order_status_id'] ?? 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->load->model('extension/payanyway/payment/payanyway');
|
||||
|
||||
return $this->model_extension_payanyway_payment_payanyway->hasOrderStatusInHistory((int)$this->orderId, $this->successOrderStatusId);
|
||||
}
|
||||
|
||||
private function isOwnAccount(): bool
|
||||
{
|
||||
return (string)$this->mntId === $this->callbackData['MNT_ID'];
|
||||
}
|
||||
|
||||
private function hasRequiredOperationId(): bool
|
||||
{
|
||||
return ('CHECK' === $this->callbackData['MNT_COMMAND'])
|
||||
|| (1 === preg_match(self::$operationIdPattern, $this->callbackData['MNT_OPERATION_ID']));
|
||||
}
|
||||
|
||||
private function isOrderAmountMatched(): bool
|
||||
{
|
||||
return ($this->callbackData['MNT_AMOUNT'] === $this->getOrderAmount())
|
||||
&& ($this->callbackData['MNT_CURRENCY_CODE'] === (string)($this->orderInfo['currency_code'] ?? ''));
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
@@ -253,130 +395,328 @@ class Callback extends \Opencart\System\Engine\Controller
|
||||
return hash_equals(md5($signatureString), $this->callbackData['MNT_SIGNATURE']);
|
||||
}
|
||||
|
||||
private function getDeliveryInfo(): ?array
|
||||
/**
|
||||
* @return array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}> цены в копейках
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function getReceiptLines(): array
|
||||
{
|
||||
$target = $this->toKopecks($this->orderInfo['total'] ?? 0);
|
||||
if ($target <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lines = $this->balanceReceiptLines($this->collectReceiptLines(), $target);
|
||||
if ([] !== $lines) {
|
||||
return $lines;
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
'name' => self::PAYMENT_ITEM_NAME . $this->orderId,
|
||||
'unit' => $target,
|
||||
'quantity' => 1,
|
||||
'pm' => $this->paymentMethod,
|
||||
'po' => $this->paymentObject,
|
||||
'goods' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}>
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function collectReceiptLines(): array
|
||||
{
|
||||
$this->load->model('account/order');
|
||||
foreach ($this->model_account_order->getTotals($this->orderId) as $orderTotal) {
|
||||
if (($orderTotal['code'] ?? '') === 'shipping') {
|
||||
return $orderTotal;
|
||||
|
||||
$lines = [];
|
||||
foreach ($this->model_account_order->getProducts($this->orderId) as $product) {
|
||||
$unit = $this->toKopecks((float)($product['price'] ?? 0) + (float)($product['tax'] ?? 0));
|
||||
$quantity = (int)($product['quantity'] ?? 0);
|
||||
if (($unit > 0) && ($quantity > 0)) {
|
||||
$lines[] = [
|
||||
'name' => (string)($product['name'] ?? ''),
|
||||
'unit' => $unit,
|
||||
'quantity' => $quantity,
|
||||
'pm' => $this->paymentMethod,
|
||||
'po' => $this->paymentObject,
|
||||
'goods' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
foreach ($this->getOrderVouchers() as $voucher) {
|
||||
$unit = $this->toKopecks($voucher['amount'] ?? 0);
|
||||
if ($unit > 0) {
|
||||
$lines[] = [
|
||||
'name' => (string)($voucher['description'] ?? ''),
|
||||
'unit' => $unit,
|
||||
'quantity' => 1,
|
||||
'pm' => self::VOUCHER_PAYMENT_METHOD,
|
||||
'po' => self::VOUCHER_PAYMENT_OBJECT,
|
||||
'goods' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->model_account_order->getTotals($this->orderId) as $orderTotal) {
|
||||
if (in_array($orderTotal['code'] ?? '', ['sub_total', 'tax', 'total'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$unit = $this->toKopecks($orderTotal['value'] ?? 0);
|
||||
if ($unit > 0) {
|
||||
$lines[] = [
|
||||
'name' => (string)($orderTotal['title'] ?? ''),
|
||||
'unit' => $unit,
|
||||
'quantity' => 1,
|
||||
'pm' => $this->paymentMethod,
|
||||
'po' => self::SERVICE_PAYMENT_OBJECT,
|
||||
'goods' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
private function getClientInfo(): array
|
||||
/**
|
||||
* Проданные в заказе подарочные сертификаты. В OpenCart 4.1.0.0 сертификаты убраны из ядра
|
||||
* вместе с getVouchers() модели заказа — там их нет
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function getOrderVouchers(): array
|
||||
{
|
||||
$model = $this->model_account_order;
|
||||
if (!isset($model->getVouchers) && !method_exists($model, 'getVouchers')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $model->getVouchers($this->orderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}> $lines
|
||||
* @return array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}>
|
||||
*/
|
||||
private function balanceReceiptLines(array $lines, int $target): array
|
||||
{
|
||||
if ([] === $lines) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$totals = [];
|
||||
foreach ($lines as $index => $line) {
|
||||
$totals[$index] = $line['unit'] * $line['quantity'];
|
||||
}
|
||||
if (array_sum($totals) === $target) {
|
||||
return $lines;
|
||||
}
|
||||
|
||||
foreach ([true, false] as $goodsOnly) {
|
||||
$adjustable = [];
|
||||
$fixed = 0;
|
||||
foreach ($lines as $index => $line) {
|
||||
if (!$goodsOnly || $line['goods']) {
|
||||
$adjustable[] = $index;
|
||||
} else {
|
||||
$fixed += $totals[$index];
|
||||
}
|
||||
}
|
||||
if (([] === $adjustable) || ($target - $fixed <= 0)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $this->distributeReceiptLines($lines, $totals, $adjustable, $target - $fixed);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Разнос суммы $goal по строкам $adjustable пропорционально их сумме, методом наибольших
|
||||
* остатков: доли округляются вниз, недостающие копейки — строкам с большим остатком.
|
||||
*
|
||||
* @param array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}> $lines
|
||||
* @param array<int, int> $totals суммы строк в копейках
|
||||
* @param array<int, int> $adjustable индексы строк, которые можно менять
|
||||
* @return array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}>
|
||||
*/
|
||||
private function distributeReceiptLines(array $lines, array $totals, array $adjustable, int $goal): array
|
||||
{
|
||||
$base = 0;
|
||||
foreach ($adjustable as $index) {
|
||||
$base += $totals[$index];
|
||||
}
|
||||
if ($base <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$shares = [];
|
||||
$remainders = [];
|
||||
foreach ($adjustable as $index) {
|
||||
if ($totals[$index] > intdiv(PHP_INT_MAX, $goal)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$product = $totals[$index] * $goal;
|
||||
$shares[$index] = intdiv($product, $base);
|
||||
$remainders[$index] = $product % $base;
|
||||
}
|
||||
arsort($remainders);
|
||||
$left = $goal - array_sum($shares);
|
||||
foreach (array_keys($remainders) as $index) {
|
||||
if ($left <= 0) {
|
||||
break;
|
||||
}
|
||||
$shares[$index]++;
|
||||
$left--;
|
||||
}
|
||||
|
||||
$balanced = [];
|
||||
foreach ($lines as $index => $line) {
|
||||
if (!isset($shares[$index])) {
|
||||
$balanced[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = $this->splitReceiptLine($line, $shares[$index]);
|
||||
if ([] === $parts) {
|
||||
return [];
|
||||
}
|
||||
foreach ($parts as $part) {
|
||||
$balanced[] = $part;
|
||||
}
|
||||
}
|
||||
|
||||
return $balanced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строка с новой суммой: целая цена за штуку, остаток копеек — второй строкой той же
|
||||
* позиции с ценой на копейку выше. Нулевые части отбрасываются.
|
||||
*
|
||||
* @param array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool} $line
|
||||
* @return array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}>
|
||||
*/
|
||||
private function splitReceiptLine(array $line, int $total): array
|
||||
{
|
||||
$pieces = $line['quantity'];
|
||||
$unit = intdiv($total, $pieces);
|
||||
$rest = $total - ($unit * $pieces);
|
||||
|
||||
$parts = [];
|
||||
if ($rest > 0) {
|
||||
$parts[] = ['unit' => $unit + 1, 'quantity' => $rest] + $line;
|
||||
}
|
||||
if (($unit > 0) && ($pieces - $rest > 0)) {
|
||||
$parts[] = ['unit' => $unit, 'quantity' => $pieces - $rest] + $line;
|
||||
}
|
||||
|
||||
return $parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Покупатель в чеке: хотя бы один из email/phone, ФИО — только вместе с контактом.
|
||||
* Нет ни email, ни телефона — «Email для чеков» (если разрешено и задан), иначе пусто.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function getClientInfo(bool $withReceiptEmail): array
|
||||
{
|
||||
$email = $this->getClientEmail();
|
||||
$phone = $this->getClientPhone();
|
||||
if ((null === $email) && (null === $phone) && $withReceiptEmail && ('' !== $this->receiptEmail)) {
|
||||
$email = $this->receiptEmail;
|
||||
}
|
||||
if ((null === $email) && (null === $phone)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$clientInfo = [];
|
||||
$name = $this->getClientName();
|
||||
if (null !== $name) {
|
||||
$clientInfo['name'] = $name;
|
||||
$clientInfo['name'] = $this->validateString($name, self::CLIENT_NAME_MAX_LENGTH);
|
||||
}
|
||||
$email = $this->getClientEmail();
|
||||
if (null !== $email) {
|
||||
$clientInfo['email'] = $email;
|
||||
}
|
||||
$phoneNumber = $this->getClientPhone();
|
||||
if (null !== $phoneNumber) {
|
||||
$clientInfo['phone'] = $phoneNumber;
|
||||
if (null !== $phone) {
|
||||
$clientInfo['phone'] = $phone;
|
||||
}
|
||||
|
||||
return $clientInfo;
|
||||
}
|
||||
|
||||
private function getCheckItems(array $products, ?array $deliveryInfo): array
|
||||
/**
|
||||
* @param array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}> $lines
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function getCheckItems(array $lines): 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)),
|
||||
$checkItems = [];
|
||||
foreach ($lines as $line) {
|
||||
$checkItems[] = [
|
||||
'name' => $this->validateString($line['name'], self::ITEM_NAME_MAX_LENGTH),
|
||||
'price' => self::JSON_NUMBER_PREFIX . $this->formatPrice($line['unit'] / 100),
|
||||
'quantity' => $line['quantity'],
|
||||
'vat' => $this->getVat($this->vatRate),
|
||||
'measure' => self::DEFAULT_MEASURE,
|
||||
'paymentMethod' => self::DEFAULT_PAYMENT_METHOD,
|
||||
'paymentObject' => self::DEFAULT_PAYMENT_OBJECT,
|
||||
);
|
||||
'paymentMethod' => $line['pm'],
|
||||
'paymentObject' => $line['po'],
|
||||
];
|
||||
}
|
||||
|
||||
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
|
||||
/**
|
||||
* @param array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}> $lines
|
||||
*/
|
||||
private function getInventoryJson(array $lines): false|string
|
||||
{
|
||||
if (empty($products) && empty($deliveryInfo)) {
|
||||
if ([] === $lines) {
|
||||
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,
|
||||
foreach ($lines as $line) {
|
||||
$position = [
|
||||
'name' => $this->validateString($line['name'], self::ITEM_NAME_MAX_LENGTH),
|
||||
'price' => $this->formatPrice($line['unit'] / 100),
|
||||
'quantity' => (string)$line['quantity'],
|
||||
'measure' => self::DEFAULT_MEASURE,
|
||||
];
|
||||
if ($this->fiscalization) {
|
||||
$position += [
|
||||
'vatTag' => $this->vatRate,
|
||||
'pm' => $line['pm'],
|
||||
'po' => $line['po'],
|
||||
];
|
||||
}
|
||||
$inventory[] = $position;
|
||||
}
|
||||
|
||||
return json_encode($inventory, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $orderStatusId
|
||||
* @return string
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getOrderStatusById(int $orderStatusId): string
|
||||
/** @throws \Exception */
|
||||
private function getOrderStatusById(int $orderStatusId): string
|
||||
{
|
||||
$this->load->model('localisation/order_status');
|
||||
/**@var array<int, array{order_status_id: string, name: string}> $orderStatuses */
|
||||
/** @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;
|
||||
if ((int)($orderStatus['order_status_id'] ?? 0) === $orderStatusId) {
|
||||
return (string)$orderStatus['name'];
|
||||
}
|
||||
}
|
||||
|
||||
return $orderStatus;
|
||||
}
|
||||
|
||||
private function formatQuantity(float $quantity): string
|
||||
{
|
||||
$quantityString = number_format($quantity, 3, '.', '');
|
||||
return rtrim(rtrim($quantityString, '0'), '.');
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -409,9 +749,51 @@ class Callback extends \Opencart\System\Engine\Controller
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array{data: string, contentType: string}
|
||||
*/
|
||||
private function buildJsonResponse(array $data): array
|
||||
{
|
||||
$client = $data['client'];
|
||||
if (isset($client['phone']) && (strlen($client['phone']) > self::CHECK_CLIENT_PHONE_MAX_LENGTH)) {
|
||||
unset($client['phone']);
|
||||
}
|
||||
if (!isset($client['email']) && !isset($client['phone'])) {
|
||||
$client = [];
|
||||
}
|
||||
|
||||
$jsonData = [
|
||||
'id' => (string)$data['MNT_ID'],
|
||||
'transactionId' => $data['MNT_TRANSACTION_ID'],
|
||||
'amount' => self::JSON_NUMBER_PREFIX . $data['MNT_AMOUNT'],
|
||||
'signature' => $data['MNT_SIGNATURE'],
|
||||
'resultCode' => (string)$data['MNT_RESULT_CODE'],
|
||||
'description' => $data['MNT_DESCRIPTION'],
|
||||
'externalId' => $data['MNT_TRANSACTION_ID'],
|
||||
'cms' => $data['MNT_CMS'],
|
||||
'receipt' => [
|
||||
'client' => (object)$client,
|
||||
'items' => $data['items'],
|
||||
],
|
||||
];
|
||||
|
||||
$json = json_encode($jsonData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (false !== $json) {
|
||||
$keys = implode('|', self::JSON_NUMBER_KEYS);
|
||||
$json = preg_replace('/"(' . $keys . ')":"' . preg_quote(self::JSON_NUMBER_PREFIX, '/') . '([0-9]+\.[0-9]{2})"/', '"$1":$2', $json);
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => (string)$json,
|
||||
'contentType' => self::JSON_CONTENT_TYPE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array{data: string, contentType: string}
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function buildXmlResponse(array $data): array
|
||||
private function buildXmlResponse(array $data, bool $withAttributes = true): array
|
||||
{
|
||||
$dom = new \DOMDocument('1.0', 'UTF-8');
|
||||
$dom->formatOutput = false;
|
||||
@@ -427,69 +809,71 @@ class Callback extends \Opencart\System\Engine\Controller
|
||||
'MNT_AMOUNT',
|
||||
'MNT_CURRENCY_CODE',
|
||||
'MNT_SIGNATURE',
|
||||
'MNT_CMS',
|
||||
];
|
||||
|
||||
foreach ($requiredFields as $field) {
|
||||
if (isset($data[$field]) && ($data[$field] !== '')) {
|
||||
$root->appendChild($dom->createElement($field, (string)$data[$field]));
|
||||
$root->appendChild($this->createTextElement($dom, $field, (string)$data[$field]));
|
||||
}
|
||||
}
|
||||
|
||||
$attributes = $dom->createElement('MNT_ATTRIBUTES');
|
||||
$root->appendChild($attributes);
|
||||
if ($withAttributes) {
|
||||
$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,
|
||||
];
|
||||
$attributeMap = [
|
||||
'INVENTORY' => $data['inventory'] ?? null,
|
||||
'CLIENT' => $data['client'] ?? null,
|
||||
'CUSTOMER' => $data['customer'] ?? null,
|
||||
'PHONE' => $data['phone'] ?? 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);
|
||||
foreach ($attributeMap as $key => $value) {
|
||||
if (!empty($value)) {
|
||||
$attrElement = $dom->createElement('ATTRIBUTE');
|
||||
$attrElement->appendChild($this->createTextElement($dom, 'KEY', $key));
|
||||
$attrElement->appendChild($this->createTextElement($dom, 'VALUE', (string)$value));
|
||||
$attributes->appendChild($attrElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$xml = $dom->saveXML();
|
||||
|
||||
return array(
|
||||
return [
|
||||
'data' => (false !== $xml) ? $xml : self::FAIL_RESPONSE,
|
||||
'contentType' => (false !== $xml) ? self::XML_CONTENT_TYPE : self::TEXT_CONTENT_TYPE,
|
||||
);
|
||||
];
|
||||
}
|
||||
|
||||
public function getVat($vatRate): string
|
||||
/** createElement($name, $value) не экранирует «&» — текст только через createTextNode(). */
|
||||
private function createTextElement(\DOMDocument $dom, string $name, string $value): \DOMElement
|
||||
{
|
||||
$element = $dom->createElement($name);
|
||||
$element->appendChild($dom->createTextNode($value));
|
||||
|
||||
return $element;
|
||||
}
|
||||
|
||||
private function getVat($vatRate): string
|
||||
{
|
||||
return match ($vatRate) {
|
||||
'1104' => 'vat0',
|
||||
'1108' => 'vat5',
|
||||
'1109' => 'vat7',
|
||||
'1103' => 'vat10',
|
||||
'7000' => 'vat20',
|
||||
'1102' => 'vat20',
|
||||
'1113' => 'vat22',
|
||||
'1110' => 'vat105',
|
||||
'1111' => 'vat107',
|
||||
'1107' => 'vat110',
|
||||
'7001' => 'vat120',
|
||||
'1106' => '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
|
||||
{
|
||||
|
||||
@@ -9,7 +9,6 @@ use Opencart\Catalog\Model\Account\Order as ModelAccountOrder;
|
||||
use Opencart\Catalog\Model\Checkout\Order as ModelCheckoutOrder;
|
||||
use Opencart\System\Engine\Config;
|
||||
use Opencart\System\Engine\Loader;
|
||||
use Opencart\System\Library\Cart\Currency;
|
||||
use Opencart\System\Library\Cart\Customer;
|
||||
use Opencart\System\Library\Document;
|
||||
use Opencart\System\Library\Language;
|
||||
@@ -29,11 +28,11 @@ require_once DIR_EXTENSION . '/payanyway/catalog/library/payanyway/traits/payany
|
||||
* @property Session $session
|
||||
* @property Language $language
|
||||
* @property Url $url
|
||||
* @property Currency $currency
|
||||
* @property ModelCheckoutOrder $model_checkout_order
|
||||
* @property ModelAccountOrder $model_account_order
|
||||
* @property ModelAccountCustomer $model_account_customer
|
||||
* @property Customer $customer
|
||||
* @property \Opencart\Catalog\Model\Extension\PayAnyWay\Payment\PayAnyWay $model_extension_payanyway_payment_payanyway
|
||||
*/
|
||||
|
||||
class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
@@ -46,30 +45,12 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
|
||||
public const DESCRIPTION_MAX_LENGTH = 500;
|
||||
|
||||
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 $orderInfo;
|
||||
private ?int $orderId;
|
||||
|
||||
public function __construct(\Opencart\System\Engine\Registry $registry)
|
||||
{
|
||||
parent::__construct($registry);
|
||||
|
||||
if (!$this->validateOrder()) {
|
||||
$this->response->redirect($this->url->link('account/login', '', true));
|
||||
}
|
||||
|
||||
$this->initializeModule();
|
||||
$this->loadOrder($this->getOrderIdFromSession());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,6 +58,13 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
*/
|
||||
public function index(): string
|
||||
{
|
||||
if (([] === $this->orderInfo)
|
||||
|| !$this->isPaidWithModule()
|
||||
|| !$this->isAwaitingPayment((int)($this->orderInfo['order_status_id'] ?? 0))
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$this->load->language('extension/payanyway/payment/payanyway');
|
||||
$this->document->setTitle($this->language->get('heading_title'));
|
||||
|
||||
@@ -85,14 +73,34 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
: self::PAYMENT_URL_DEMO;
|
||||
|
||||
$data['MNT_ID'] = $this->mntId;
|
||||
$data['MNT_TRANSACTION_ID'] = $this->createTransactionId($this->orderId);
|
||||
$data['MNT_AMOUNT'] = $this->formatPrice((float)($this->orderInfo['total'] ?? 0));
|
||||
$data['MNT_AMOUNT'] = $this->getOrderAmount();
|
||||
$data['MNT_TRANSACTION_ID'] = $this->createTransactionId($this->orderId, $data['MNT_AMOUNT']);
|
||||
$data['MNT_CURRENCY_CODE'] = $this->currency;
|
||||
$data['MNT_TEST_MODE'] = '0';
|
||||
$data['MNT_TEST_MODE'] = $this->testMode ? '1' : '0';
|
||||
$data['MNT_DESCRIPTION'] = $this->getDescription();
|
||||
$data['MNT_SUBSCRIBER_ID'] = $this->getSubscriberId();
|
||||
|
||||
$signature = md5(
|
||||
$data['MNT_SIGNATURE'] = $this->buildFormSignature($data);
|
||||
$urlArgs = $this->getUrlArgs($this->session->data['customer_token'] ?? null);
|
||||
$data['MNT_SUCCESS_URL'] = $this->url->link('extension/payanyway/payment/payanyway.success', $urlArgs, true);
|
||||
$data['MNT_FAIL_URL'] = $this->url->link('extension/payanyway/payment/payanyway.fail', $urlArgs, true);
|
||||
$data['MNT_RETURN_URL'] = $this->url->link('checkout/checkout', $urlArgs, true);
|
||||
$data['moneta_locale'] = $this->language->get('text_locale');
|
||||
$data['MNT_CMS'] = $this->getCms();
|
||||
|
||||
$data['btn_confirm'] = $this->language->get('text_paw_pay');
|
||||
|
||||
$data['confirm_url'] = $this->url->link('extension/payanyway/payment/payanyway.confirm', $urlArgs, true);
|
||||
|
||||
return $this->load->view('extension/payanyway/payment/payanyway', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function buildFormSignature(array $data): string
|
||||
{
|
||||
return md5(
|
||||
$data['MNT_ID'] .
|
||||
$data['MNT_TRANSACTION_ID'] .
|
||||
$data['MNT_AMOUNT'] .
|
||||
@@ -101,31 +109,13 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
$data['MNT_TEST_MODE'] .
|
||||
$this->integrityCode,
|
||||
);
|
||||
$data['MNT_SIGNATURE'] = $signature;
|
||||
$customerToken = $this->session->data['customer_token'];
|
||||
$data['MNT_SUCCESS_URL'] = $this->url->link('extension/payanyway/payment/payanyway.success', 'customer_token=' . $customerToken, true);
|
||||
$data['MNT_FAIL_URL'] = $this->url->link('extension/payanyway/payment/payanyway.fail', 'customer_token=' . $customerToken, true);
|
||||
$data['MNT_RETURN_URL'] = $this->url->link('checkout/checkout', 'customer_token=' . $customerToken, true);
|
||||
$data['moneta_locale'] = $this->language->get('text_locale');
|
||||
$data['MNT_CMS'] = $this->cmsModuleVersion;
|
||||
|
||||
$data['btn_confirm'] = $this->language->get('text_paw_pay');
|
||||
|
||||
$data['confirm_url'] = $this->url->link('extension/payanyway/payment/payanyway.confirm', 'customer_token=' . $customerToken, true,);
|
||||
|
||||
return $this->load->view('extension/payanyway/payment/payanyway', $data);
|
||||
}
|
||||
|
||||
public function confirm(): void
|
||||
{
|
||||
$this->load->language('extension/payanyway/payment/payanyway');
|
||||
|
||||
$orderStatusId = isset($this->orderInfo['order_status_id']) ? (int)$this->orderInfo['order_status_id'] : null;
|
||||
if ((null !== $orderStatusId) && ($orderStatusId !== $this->successOrderStatusId)) {
|
||||
$this->addOrderHistory($this->orderId, $this->pendingOrderStatusId, $this->language->get('text_order_confirmed'),);
|
||||
}
|
||||
|
||||
$json['success'] = true;
|
||||
$json['success'] = $this->changeStatusUnderOrderLock(true, $this->pendingOrderStatusId, $this->language->get('text_order_confirmed'));
|
||||
|
||||
$this->response->addHeader('Content-Type: application/json');
|
||||
$this->response->setOutput(json_encode($json));
|
||||
@@ -135,36 +125,73 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
{
|
||||
$this->load->language('extension/payanyway/payment/payanyway');
|
||||
|
||||
$orderStatusId = isset($this->orderInfo['order_status_id']) ? (int)$this->orderInfo['order_status_id'] : null;
|
||||
if ((null !== $orderStatusId) && ($orderStatusId !== $this->successOrderStatusId)) {
|
||||
$this->addOrderHistory($this->orderId, $this->pendingOrderStatusId, $this->language->get('text_payment_processing'));
|
||||
}
|
||||
$this->changeStatusUnderOrderLock(false, $this->pendingOrderStatusId, $this->language->get('text_payment_processing'));
|
||||
|
||||
$customerToken = $this->request->get['customer_token'] ?? '';
|
||||
|
||||
$this->response->redirect($this->url->link('checkout/success', 'customer_token=' . $customerToken, true));
|
||||
$this->response->redirect($this->url->link('checkout/success', $this->getUrlArgs($this->request->get['customer_token'] ?? null), true));
|
||||
}
|
||||
|
||||
public function fail(): void
|
||||
{
|
||||
$this->load->language('extension/payanyway/payment/payanyway');
|
||||
|
||||
$orderStatusId = isset($this->orderInfo['order_status_id']) ? (int)$this->orderInfo['order_status_id'] : null;
|
||||
if ((null !== $orderStatusId) && ($orderStatusId !== $this->successOrderStatusId)) {
|
||||
$this->addOrderHistory($this->orderId, $this->errorOrderStatusId, $this->language->get('text_payment_error'));
|
||||
}
|
||||
$this->changeStatusUnderOrderLock(false, $this->errorOrderStatusId, $this->language->get('text_payment_error'));
|
||||
|
||||
$this->response->redirect($this->url->link('checkout/failure', '', true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Смена статуса со страницы покупателя — под блокировкой заказа, как в Pay URL: заказ
|
||||
* перечитывается, оплаченный (сейчас или по истории) не трогается. Возврат с MONETA.RU
|
||||
* приходит параллельно с уведомлением об оплате.
|
||||
*
|
||||
* @param bool $allowFailed подтверждение формы допускает повторную попытку после «Неуспешного»
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function changeStatusUnderOrderLock(bool $allowFailed, int $orderStatusId, string $comment): bool
|
||||
{
|
||||
if (!$this->isStatusChangeableByCustomer($allowFailed)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->load->model('extension/payanyway/payment/payanyway');
|
||||
if (!$this->model_extension_payanyway_payment_payanyway->acquireOrderLock((int)$this->orderId, self::$orderLockTimeoutSeconds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->loadOrder($this->orderId);
|
||||
$changeable = $this->isStatusChangeableByCustomer($allowFailed)
|
||||
&& !$this->model_extension_payanyway_payment_payanyway->hasOrderStatusInHistory((int)$this->orderId, $this->successOrderStatusId);
|
||||
if ($changeable) {
|
||||
$this->addOrderHistory($this->orderId, $orderStatusId, $comment);
|
||||
}
|
||||
$this->model_extension_payanyway_payment_payanyway->releaseOrderLock((int)$this->orderId);
|
||||
|
||||
return $changeable;
|
||||
}
|
||||
|
||||
/** Заказ модуля, ещё не ушедший дальше «Обрабатываемого» (или «Неуспешного» — для повторной попытки). */
|
||||
private function isStatusChangeableByCustomer(bool $allowFailed): bool
|
||||
{
|
||||
if (([] === $this->orderInfo) || !$this->isPaidWithModule()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$orderStatusId = (int)$this->orderInfo['order_status_id'];
|
||||
|
||||
return $allowFailed ? $this->isAwaitingPayment($orderStatusId) : in_array($orderStatusId, [0, $this->pendingOrderStatusId], true);
|
||||
}
|
||||
|
||||
private function getDescription(): string
|
||||
{
|
||||
$orderId = $this->orderInfo['order_id'] ?? null;
|
||||
$description = "Оплата заказа" . (!empty($orderId) ? " №{$orderId}" : '');
|
||||
$description = $this->language->get('text_description');
|
||||
if (!empty($orderId)) {
|
||||
$description .= ' ' . sprintf($this->language->get('text_description_number'), $orderId);
|
||||
}
|
||||
|
||||
$name = $this->getClientName();
|
||||
if (null !== $name) {
|
||||
$description .= " от {$name}";
|
||||
$description .= ' ' . sprintf($this->language->get('text_description_customer'), $name);
|
||||
}
|
||||
|
||||
$comment = $this->orderInfo['comment'] ?? '';
|
||||
@@ -175,6 +202,16 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
return $this->validateString($description, self::DESCRIPTION_MAX_LENGTH);
|
||||
}
|
||||
|
||||
private function getUrlArgs(mixed $customerToken): string
|
||||
{
|
||||
$args = 'language=' . $this->config->get('config_language');
|
||||
if (is_string($customerToken) && ('' !== $customerToken)) {
|
||||
$args .= '&customer_token=' . urlencode($customerToken);
|
||||
}
|
||||
|
||||
return $args;
|
||||
}
|
||||
|
||||
private function getSubscriberId(): string
|
||||
{
|
||||
return $this->getClientEmail() ?? $this->getClientPhone() ?? '';
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Opencart\Catalog\Controller\Extension\PayAnyWay\Startup;
|
||||
|
||||
require_once DIR_EXTENSION . '/payanyway/catalog/library/payanyway/traits/payanyway.php';
|
||||
|
||||
/**
|
||||
* @package Opencart\Catalog\Controller\Extension\PayAnyWay\Startup
|
||||
*/
|
||||
class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
{
|
||||
use \Opencart\Catalog\Library\Extension\PayAnyWay\Traits\PayAnyWay;
|
||||
|
||||
public const CALLBACK_ROUTE = 'extension/payanyway/payment/callback';
|
||||
|
||||
// checkout/confirm.confirm (4.0.2+) и checkout/confirm|confirm (4.0.0–4.0.1); не checkout/success.
|
||||
private const CHECKOUT_ROUTE_PATTERN = '/^checkout\/(checkout|confirm)([.|]|$)/';
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
if (!isset($this->request->get['route'])) {
|
||||
if (($this->request->post['route'] ?? null) === self::CALLBACK_ROUTE) {
|
||||
$this->request->get['route'] = self::CALLBACK_ROUTE;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_string($this->request->get['route']) && preg_match(self::CHECKOUT_ROUTE_PATTERN, $this->request->get['route'])) {
|
||||
$this->releaseClosedSessionOrder();
|
||||
}
|
||||
}
|
||||
|
||||
private function releaseClosedSessionOrder(): void
|
||||
{
|
||||
$this->loadOrder($this->getOrderIdFromSession());
|
||||
if (([] === $this->orderInfo) || !$this->isPaidWithModule()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (0 === (int)$this->orderInfo['order_status_id']) {
|
||||
unset($this->session->data['order_id']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->initializeModule();
|
||||
$orderStatusId = (int)($this->orderInfo['order_status_id'] ?? 0);
|
||||
if (($this->successOrderStatusId > 0) && !$this->isAwaitingPayment($orderStatusId)) {
|
||||
unset($this->session->data['order_id']);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user