Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a701f0a71 | ||
|
|
b1acb1a1de | ||
|
|
a9bc7f315c | ||
|
|
6cc9866f24 |
@@ -5,13 +5,13 @@
|
||||

|
||||

|
||||
|
||||
> **📦 Версия модуля: 1.0.1**
|
||||
> **📦 Версия модуля: 1.0.3**
|
||||
|
||||
> **ℹ️ Совместимость версий OpenCart:**
|
||||
> 4.0.0.0, 4.0.1.0, 4.0.1.1, 4.0.2.0, 4.0.2.1, 4.0.2.2, 4.0.2.3, 4.1.0.0, 4.1.0.1, 4.1.0.2, 4.1.0.3
|
||||
> 4.0.2.0, 4.0.2.1, 4.0.2.2, 4.0.2.3, 4.1.0.0, 4.1.0.1, 4.1.0.2, 4.1.0.3, 4.1.0.4
|
||||
|
||||
> **ℹ️ Совместимость версий OpenCart "Русская сборка":**
|
||||
> 4.0.0.0, 4.0.1.0, 4.0.1.1, 4.0.2.1, 4.0.2.2, 4.0.2.3, 4.1.0.0
|
||||
> 4.0.2.1, 4.0.2.2, 4.0.2.3, 4.1.0.0, 4.1.0.4
|
||||
|
||||
> **ℹ️ Примечание по валюте**
|
||||
> Данный модуль работает **только с валютой RUB (Российский рубль)**.
|
||||
@@ -27,12 +27,13 @@
|
||||
│ └── view/template/payment/payanyway.twig
|
||||
├── catalog
|
||||
│ ├── controller/payment/
|
||||
│ │ ├── callback.php
|
||||
│ │ └── payanyway.php
|
||||
│ │ ├── callback.php — Check URL и Pay URL
|
||||
│ │ └── payanyway.php — форма оплаты, возврат покупателя
|
||||
│ ├── controller/startup/payanyway.php — приём Check/Pay URL методом POST, сброс оплаченного заказа из сессии
|
||||
│ ├── language/{en-gb,ru-ru}/payment/payanyway.php
|
||||
│ ├── library/payanyway/traits/payanyway.php
|
||||
│ ├── model/payment/payanyway.php
|
||||
│ └── view/template/extension/payment/payanyway.twig
|
||||
│ ├── model/payment/payanyway.php — блокировка заказа
|
||||
│ └── view/template/payment/payanyway.twig
|
||||
├── install.json
|
||||
└── README.md
|
||||
```
|
||||
@@ -50,20 +51,58 @@
|
||||
4. ✏️ Заполните соответствующие поля в настройках способа оплаты:
|
||||
|
||||
| Поле | Описание |
|
||||
|-------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
|-------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Статус** | Включите или выключите модуль оплаты |
|
||||
| **Номер счёта** | Ван номер расширенного счёта в системе [MONETA.RU](https://moneta.ru/) |
|
||||
| **Код проверки целостности данных** | Код проверки целостности данных, указанный в настройках расширенного счёта в системе [MONETA.RU](https://moneta.ru/) |
|
||||
| **Сервер платежей** | `Боевой` / `Тестовый` |
|
||||
| **НДС** | Выберите из списка |
|
||||
| **Номер счёта** | Ваш номер расширенного счёта в системе [MONETA.RU](https://moneta.ru/), только цифры |
|
||||
| **Код проверки целостности данных** | Код проверки целостности данных, указанный в настройках расширенного счёта в системе [MONETA.RU](https://moneta.ru/). Сохранённый код в форме не показывается: оставьте поле пустым, чтобы не менять его |
|
||||
| **Сервер платежей** | `Боевой` / `Демо` (демо-площадка MONETA.RU) |
|
||||
| **Тестовый режим** | Имитация оплаты без реального списания средств. Должен совпадать с настройкой счёта в MONETA.RU |
|
||||
| **Фискализация чеков** | Включайте, только если к счёту подключён кассовый сервис [kassa.payanyway.ru](https://kassa.payanyway.ru/) |
|
||||
| **Email для чеков** | Обязателен при фискализации. Подставляется в чек, только если у покупателя нет ни e-mail, ни телефона (ФИО покупателя в чеке остаётся) |
|
||||
| **НДС** | Ставка НДС всех позиций чека |
|
||||
| **Признак способа расчёта** | Для товаров, доставки и сборов. Обычно `Полный расчёт`. Проданные подарочные сертификаты всегда идут авансом |
|
||||
| **Признак предмета расчёта** | Для товаров. Доставка и сборы всегда идут услугой, подарочные сертификаты — платежом |
|
||||
| **Валюта платежа** | `RUB` |
|
||||
| **Статусы заказа** | Выберите соответствующие статусы заказа из списка |
|
||||
| **Обрабатываемый** | Укажите статус заказа, который выставляется при его создании |
|
||||
| **Успешный** | Укажите статус заказа, который выставляется при успешной оплате |
|
||||
| **Неуспешный** | Укажите статус заказа, который выставляется при ошибке оплаты |
|
||||
| **Отменённые** | Заказ в этих статусах не оплачивается: на проверочный запрос и уведомление об оплате модуль отвечает отказом (деньги за такую оплату возвращаются в личном кабинете MONETA.RU, номер операции — в журнале ошибок). Статусы Обрабатываемого, Успешного и Неуспешного заказа выбрать нельзя. По умолчанию `Canceled`, `Canceled Reversal`, `Voided` |
|
||||
| **URL для уведомлений** | Адрес Check URL и Pay URL для настроек счёта MONETA.RU, кнопка «Скопировать» |
|
||||
| **Регион отображения** | Географическая зона, в которой будет отображаться способ оплаты |
|
||||
| **Порядок сортировки** | Определяет порядок отображения платежного метода в списке методов оплаты при оформлении заказа. Чем меньше число, тем выше позиция в списке |
|
||||
|
||||
Сумма позиций чека всегда равна сумме оплаты: налоги OpenCart входят в цену товаров, скидки (купоны, подарочные сертификаты, бонусные баллы) распределяются по товарам, доставка и сборы идут отдельными позициями.
|
||||
|
||||
> ℹ️ Сумма платёжной формы записывается в номер транзакции (`MNT_TRANSACTION_ID`, например `42_1500000_20261022120000UTC`; время — UTC). Если после показа формы сумма заказа изменилась, оплата по старой форме отклоняется: покупателю нужно заново открыть оплату заказа.
|
||||
|
||||
> ⚠️ **После обновления модуля** откройте настройки PayAnyWay, проверьте новые поля (фискализация после обновления включена) и нажмите **«Сохранить»**. Пока при включённой фискализации не задан «Email для чеков», над формой настроек показывается предупреждение.
|
||||
|
||||
## 🔄 Обновление модуля
|
||||
|
||||
OpenCart 4 не устанавливает расширение поверх уже установленного: загрузка нового архива в **«Установка расширений»** заканчивается ошибкой «Расширение уже установлено» (Extension already installed) или «Файл уже существует» (File already exist), а установка — ошибкой «Путь payanyway/ уже существует» (Path payanyway/ already exists). Обновить модуль можно одним из двух способов.
|
||||
|
||||
### Способ 1. Замена файлов — настройки сохраняются (рекомендуется)
|
||||
|
||||
Нужен доступ к файлам сайта (FTP, SSH или файловый менеджер хостинга).
|
||||
|
||||
1. Распакуйте архив новой версии.
|
||||
2. Скопируйте из него `admin/`, `catalog/`, `install.json` и `README.md` в каталог `extension/payanyway/` в корне OpenCart с заменой файлов.
|
||||
3. Откройте настройки PayAnyWay (**«Модули / Расширения» → «Оплата» → PayAnyWay → «Редактировать»**), проверьте новые поля и нажмите **«Сохранить»**.
|
||||
|
||||
В списке **«Установка расширений»** останется прежний номер версии — на работу модуля это не влияет.
|
||||
|
||||
### Способ 2. Переустановка через панель управления — настройки удаляются
|
||||
|
||||
При удалении способа оплаты OpenCart стирает все настройки модуля. Заранее перепишите их; код проверки целостности в форме не показывается — возьмите его в настройках расширенного счёта в личном кабинете MONETA.RU.
|
||||
|
||||
1. **«Модули / Расширения» → «Оплата»** → PayAnyWay → **«Деинсталлировать»** (Uninstall).
|
||||
2. **«Модули / Расширения» → «Установка расширений»** → PayAnyWay → **«Деинсталлировать»** (Uninstall, удаляет файлы модуля), затем **«Удалить»** (Delete, кнопка с корзиной — удаляет архив и строку из списка).
|
||||
3. Загрузите архив новой версии, переименованный в `payanyway.ocmod.zip`, и нажмите **«Установить»**.
|
||||
4. **«Модули / Расширения» → «Оплата»** → PayAnyWay → **«Установить»**, затем **«Редактировать»**: заполните настройки и нажмите **«Сохранить»**.
|
||||
|
||||
> ⚠️ Если на шаге 3 появляется «Путь payanyway/ уже существует», файлы прежней версии остались на сервере (например, строку удалили корзиной, не нажав перед этим «Деинсталлировать»). Удалите каталог `extension/payanyway/` через FTP или файловый менеджер и снова нажмите **«Установить»**.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Инструкция по настройке счета PayAnyWay
|
||||
@@ -77,7 +116,7 @@
|
||||
| **Тестовый режим** | `Нет` |
|
||||
| **Check URL** | `https://your_site_name/index.php?route=extension/payanyway/payment/callback` |
|
||||
| **Pay URL** | `https://your_site_name/index.php?route=extension/payanyway/payment/callback` |
|
||||
| **HTTP method** | `GET` / `POST` |
|
||||
| **HTTP method<sup>\*</sup>** | `GET` или `POST` (требуется настройка) |
|
||||
| **Можно переопределять настройки в URL** | `Да` |
|
||||
| **Подпись формы оплаты обязательна** | `Да` |
|
||||
| **Код проверки целостности данных** | `ваш_код` (произвольный набор символов) |
|
||||
@@ -88,6 +127,24 @@
|
||||
|
||||
> ⚠️ **Важно!** Для кириллического домена PayURL и CheckURL должны быть указаны в кодировке [Punycode](https://2ip.ru/punycode/).
|
||||
|
||||
### <sup>\*</sup> 📨 Уведомления методом POST
|
||||
|
||||
При **HTTP method** = `POST` MONETA.RU переносит параметры из адреса Check URL / Pay URL в тело запроса, в том числе `route=extension/payanyway/payment/callback`. Штатный OpenCart 4 ищет маршрут только в адресе и без доработки отвечает главной страницей, а платёжная форма показывает «Код ошибки: 302».
|
||||
|
||||
Модуль решает это собственным startup-контроллером витрины `payanyway_post_route`: маршрут колбэка (и только его) берётся из тела запроса. Ядро OpenCart не изменяется, модификации обновлять не нужно.
|
||||
|
||||
1. Startup регистрируется автоматически при установке способа оплаты PayAnyWay (**«Модули / Расширения» → «Оплата» → «Установить»**).
|
||||
2. Если модуль обновлён поверх старой версии без переустановки, откройте настройки PayAnyWay и нажмите **«Сохранить»**: startup зарегистрируется при сохранении.
|
||||
3. Если startup не зарегистрирован или выключен, над формой настроек будет жёлтое предупреждение. Проверить вручную можно в **«Модули / Расширения» → «Startup»** (Extensions → Startup): строка `payanyway_post_route` должна быть включена.
|
||||
|
||||
Если включить startup не удаётся, выберите в настройках счёта **HTTP method** = `GET`: при GET уведомления доходят и без него. Но startup нужен и для оформления следующего заказа (см. ниже про `SameSite`), поэтому лучше держать его включённым.
|
||||
|
||||
> ⚠️ **Важно!** Если после возврата с сайта MONETA.RU покупатель оказывается разлогинен, дело в атрибуте `SameSite` cookie сессии. В OpenCart 4 по умолчанию стоит `Strict`: с ним браузер не отправляет cookie, когда покупатель переходит на магазин с другого сайта, и OpenCart открывает новую пустую сессию. Старая сессия при этом сохраняет оплаченный заказ: без startup `payanyway_post_route` следующий заказ в том же браузере не создаётся, и платёжная форма открывает уже оплаченный. Startup снимает оплаченный или отменённый заказ из сессии на странице оформления, и OpenCart заводит новый.
|
||||
> 1. Откройте **«Система» → «Настройки»** (System → Settings) и отредактируйте магазин.
|
||||
> 2. На вкладке **«Сервер»** (Server) в поле **«Сессионный файл Cookie»** (Session Samesite Cookie) выберите `Lax` и сохраните.
|
||||
>
|
||||
> `Lax` достаточно: покупатель возвращается с MONETA.RU обычным переходом по ссылке, и cookie при таком переходе отправляется, а в остальных запросах с чужих сайтов — нет, поэтому защита от подделки запросов (CSRF) сохраняется. Значение `None` не выбирайте: cookie будет уходить при любых запросах с чужих сайтов, а браузеры принимают `SameSite=None` только для сайтов на HTTPS.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Полезные ресурсы OpenCart
|
||||
|
||||
@@ -5,17 +5,17 @@ declare(strict_types=1);
|
||||
namespace Opencart\Admin\Controller\Extension\PayAnyWay\Payment;
|
||||
|
||||
/**
|
||||
* Class PayAnyWay
|
||||
*
|
||||
* @package Opencart\Admin\Controller\Extension\PayAnyWay\Payment
|
||||
*/
|
||||
class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
{
|
||||
public const CMS_NAME = 'OpenCart';
|
||||
public const MODULE_NAME = 'payanyway';
|
||||
public const MODULE_VERSION = '1.0.1';
|
||||
public const MODULE_VERSION = '1.0.3';
|
||||
public const MODULE_DEFAULT_SORT_ORDER = 0;
|
||||
|
||||
private const ROUTE_STARTUP_CODE = 'payanyway_post_route';
|
||||
private const ROUTE_STARTUP_ACTION = 'catalog/extension/payanyway/startup/payanyway';
|
||||
|
||||
private const SUPPORTED_CURRENCIES = ['RUB'];
|
||||
private const AVAILABLE_VAT_RATES = [
|
||||
'vat_none' => '1105',
|
||||
@@ -30,6 +30,45 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
'vat122' => '1114',
|
||||
];
|
||||
|
||||
private const PAYMENT_METHODS = [
|
||||
'full_prepayment',
|
||||
'prepayment',
|
||||
'advance',
|
||||
'full_payment',
|
||||
'partial_payment',
|
||||
'credit',
|
||||
'credit_payment',
|
||||
];
|
||||
|
||||
private const PAYMENT_OBJECTS = [
|
||||
'commodity',
|
||||
'excise',
|
||||
'job',
|
||||
'service',
|
||||
'gambling_bet',
|
||||
'gambling_prize',
|
||||
'lottery',
|
||||
'lottery_prize',
|
||||
'intellectual_activity',
|
||||
'payment',
|
||||
'agent_commission',
|
||||
'composite',
|
||||
'another',
|
||||
'property_right',
|
||||
'sales_tax',
|
||||
'resort_fee',
|
||||
'award',
|
||||
'non_operating',
|
||||
'insurance_premium',
|
||||
];
|
||||
|
||||
/** Canceled, Canceled Reversal, Voided штатной установки OpenCart. */
|
||||
private const DEFAULT_CANCELED_ORDER_STATUS_IDS = [7, 9, 16];
|
||||
|
||||
private const RECEIPT_EMAIL_MAX_LENGTH = 64;
|
||||
private const DEFAULT_PAYMENT_METHOD = 'full_payment';
|
||||
private const DEFAULT_PAYMENT_OBJECT = 'commodity';
|
||||
|
||||
private array $errors = [];
|
||||
|
||||
/**
|
||||
@@ -44,13 +83,25 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
$this->applyValidationErrors($data);
|
||||
|
||||
$data['breadcrumbs'] = $this->getBreadcrumbs();
|
||||
$warnings = $this->getWarnings($this->isFiscalizationEnabled(), (string)$this->config->get('payment_payanyway_receipt_email'));
|
||||
$data['warning_startup'] = $warnings['startup'];
|
||||
$data['warning_receipt_email'] = $warnings['receipt_email'];
|
||||
$data['save'] = $this->url->link('extension/payanyway/payment/payanyway.save', 'user_token=' . $this->session->data['user_token']);
|
||||
$data['back'] = $this->url->link('marketplace/extension', 'user_token=' . $this->session->data['user_token'] . '&type=payment');
|
||||
|
||||
$data['payment_payanyway_status'] = $this->retrieveValue('payment_payanyway_status');
|
||||
$data['payment_payanyway_account'] = $this->retrieveValue('payment_payanyway_account');
|
||||
$data['payment_payanyway_integrity_code'] = $this->getIntegrityCode();
|
||||
$data['integrity_code_placeholder'] = $this->hasIntegrityCode()
|
||||
? $this->language->get('text_integrity_code_saved')
|
||||
: $this->language->get('entry_integrity_code_description');
|
||||
$data['payment_payanyway_server'] = $this->retrieveValue('payment_payanyway_server');
|
||||
$data['payment_payanyway_test_mode'] = (string)$this->retrieveValue('payment_payanyway_test_mode');
|
||||
$data['payment_payanyway_fiscalization'] = $this->isFiscalizationEnabled() ? '1' : '0';
|
||||
$data['payment_payanyway_receipt_email'] = htmlspecialchars((string)$this->config->get('payment_payanyway_receipt_email'), ENT_QUOTES, 'UTF-8');
|
||||
$data['payment_payanyway_payment_methods'] = $this->getOptions(self::PAYMENT_METHODS, 'entry_payment_method_');
|
||||
$data['payment_payanyway_payment_method'] = $this->retrieveValueOrDefault('payment_payanyway_payment_method', self::DEFAULT_PAYMENT_METHOD);
|
||||
$data['payment_payanyway_payment_objects'] = $this->getOptions(self::PAYMENT_OBJECTS, 'entry_payment_object_');
|
||||
$data['payment_payanyway_payment_object'] = $this->retrieveValueOrDefault('payment_payanyway_payment_object', self::DEFAULT_PAYMENT_OBJECT);
|
||||
$data['payment_payanyway_vat_rates'] = $this->getModuleVatRates();
|
||||
$data['payment_payanyway_vat_rate'] = $this->retrieveValue('payment_payanyway_vat_rate');
|
||||
$data['payment_payanyway_currencies'] = $this->getModuleCurrencies();
|
||||
@@ -59,10 +110,11 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
$data['payment_payanyway_pending_order_status_id'] = $this->retrieveValue('payment_payanyway_pending_order_status_id');
|
||||
$data['payment_payanyway_success_order_status_id'] = $this->retrieveValue('payment_payanyway_success_order_status_id');
|
||||
$data['payment_payanyway_error_order_status_id'] = $this->retrieveValue('payment_payanyway_error_order_status_id');
|
||||
$data['payment_payanyway_canceled_order_status_ids'] = $this->getCanceledOrderStatusIds();
|
||||
$data['geo_zones'] = $this->getAvailableGeoZones();
|
||||
$data['payment_payanyway_geo_zone_id'] = $this->retrieveValue('payment_payanyway_geo_zone_id');
|
||||
$data['payment_payanyway_sort_order'] = $this->retrieveValue('payment_payanyway_sort_order');
|
||||
$data['payment_payanyway_cms_module_version'] = $this->getCmsModuleVersion();
|
||||
$data['callback_url'] = HTTP_CATALOG . 'index.php?route=extension/payanyway/payment/callback';
|
||||
|
||||
$data['header'] = $this->load->controller('common/header');
|
||||
$data['column_left'] = $this->load->controller('common/column_left');
|
||||
@@ -102,8 +154,15 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
if (!$json) {
|
||||
$this->load->model('setting/setting');
|
||||
$this->model_setting_setting->editSetting('payment_payanyway', $this->request->post);
|
||||
if (!$this->isRouteStartupRegistered()) {
|
||||
$this->registerRouteStartup();
|
||||
}
|
||||
|
||||
$json['success'] = $this->language->get('text_success');
|
||||
$json['payanyway_warnings'] = $this->getWarnings(
|
||||
(bool)$this->request->post['payment_payanyway_fiscalization'],
|
||||
(string)$this->request->post['payment_payanyway_receipt_email']
|
||||
);
|
||||
}
|
||||
|
||||
$this->response->addHeader('Content-Type: application/json');
|
||||
@@ -124,8 +183,15 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
'payment_payanyway_account' => '',
|
||||
'payment_payanyway_integrity_code' => '',
|
||||
'payment_payanyway_server' => 'prod',
|
||||
'payment_payanyway_test_mode' => 0,
|
||||
'payment_payanyway_fiscalization' => 0,
|
||||
'payment_payanyway_vat_rate' => self::AVAILABLE_VAT_RATES['vat_none'],
|
||||
'payment_payanyway_receipt_email' => '',
|
||||
'payment_payanyway_payment_method' => self::DEFAULT_PAYMENT_METHOD,
|
||||
'payment_payanyway_payment_object' => self::DEFAULT_PAYMENT_OBJECT,
|
||||
'payment_payanyway_canceled_order_status_ids' => self::DEFAULT_CANCELED_ORDER_STATUS_IDS,
|
||||
'payment_payanyway_sort_order' => 0,
|
||||
'payment_payanyway_cms_module_version' => $this->getCmsModuleVersion(),
|
||||
'payment_payanyway_cms_name' => $this->getCmsName(),
|
||||
];
|
||||
|
||||
$orderStatuses = $this->getAvailableOrderStatuses();
|
||||
@@ -156,6 +222,8 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
|
||||
$this->load->model('setting/setting');
|
||||
$this->model_setting_setting->editSetting('payment_payanyway', $defaultSettings);
|
||||
|
||||
$this->registerRouteStartup();
|
||||
}
|
||||
|
||||
public function uninstall(): void
|
||||
@@ -166,6 +234,69 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
|
||||
$this->load->model('setting/setting');
|
||||
$this->model_setting_setting->deleteSetting('payment_payanyway');
|
||||
|
||||
$this->load->model('setting/startup');
|
||||
$this->model_setting_startup->deleteStartupByCode(self::ROUTE_STARTUP_CODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Регистрирует startup витрины. Сигнатура addStartup() менялась: 4.0.0–4.0.1 —
|
||||
* позиционные аргументы, 4.0.2 — массив, 4.1 — массив с обязательным description
|
||||
* (лишний ключ 4.0.2 игнорирует).
|
||||
*/
|
||||
private function registerRouteStartup(): void
|
||||
{
|
||||
$this->load->model('setting/startup');
|
||||
$this->model_setting_startup->deleteStartupByCode(self::ROUTE_STARTUP_CODE);
|
||||
|
||||
if (version_compare(VERSION, '4.0.2.0', '<')) {
|
||||
$this->model_setting_startup->addStartup(self::ROUTE_STARTUP_CODE, self::ROUTE_STARTUP_ACTION, true, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->model_setting_startup->addStartup([
|
||||
'code' => self::ROUTE_STARTUP_CODE,
|
||||
'description' => 'PayAnyWay: Check/Pay URL методом POST',
|
||||
'action' => self::ROUTE_STARTUP_ACTION,
|
||||
'status' => 1,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private function isRouteStartupRegistered(): bool
|
||||
{
|
||||
$this->load->model('setting/startup');
|
||||
|
||||
return [] !== $this->model_setting_startup->getStartupByCode(self::ROUTE_STARTUP_CODE);
|
||||
}
|
||||
|
||||
private function isRouteStartupActive(): bool
|
||||
{
|
||||
$this->load->model('setting/startup');
|
||||
$startup = $this->model_setting_startup->getStartupByCode(self::ROUTE_STARTUP_CODE);
|
||||
|
||||
return !empty($startup['status']) && (($startup['action'] ?? '') === self::ROUTE_STARTUP_ACTION);
|
||||
}
|
||||
|
||||
/**
|
||||
* Предупреждения над формой настроек; пустая строка — предупреждения нет.
|
||||
*
|
||||
* @return array{startup: string, receipt_email: string}
|
||||
*/
|
||||
private function getWarnings(bool $fiscalization, string $receiptEmail): array
|
||||
{
|
||||
return [
|
||||
'startup' => $this->isRouteStartupActive()
|
||||
? ''
|
||||
: sprintf(
|
||||
$this->language->get('warning_startup_route'),
|
||||
$this->url->link('marketplace/startup', 'user_token=' . $this->session->data['user_token'])
|
||||
),
|
||||
'receipt_email' => ($fiscalization && ('' === $receiptEmail))
|
||||
? $this->language->get('warning_receipt_email')
|
||||
: '',
|
||||
];
|
||||
}
|
||||
|
||||
private function validate(): bool
|
||||
@@ -173,14 +304,42 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
$this->errors = [];
|
||||
|
||||
if (!isset($this->request->post['payment_payanyway_account']) ||
|
||||
!preg_match('/^[0-9]{1,20}$/D', (string)$this->request->post['payment_payanyway_account']) ||
|
||||
((int)$this->request->post['payment_payanyway_account'] <= 0)
|
||||
) {
|
||||
$this->errors['account'] = $this->language->get('error_account');
|
||||
}
|
||||
|
||||
if (empty($this->request->post['payment_payanyway_integrity_code'])) {
|
||||
// OpenCart экранирует весь POST (htmlspecialchars в system/library/request.php): код с & " < >
|
||||
// сохранился бы экранированным, и подписи не сошлись бы с MONETA.RU.
|
||||
$integrityCode = $this->decodePostedString('payment_payanyway_integrity_code');
|
||||
if (('' === $integrityCode) && $this->hasIntegrityCode()) {
|
||||
$integrityCode = (string)$this->config->get('payment_payanyway_integrity_code');
|
||||
}
|
||||
if ('' === $integrityCode) {
|
||||
$this->errors['integrity_code'] = $this->language->get('error_integrity_code');
|
||||
}
|
||||
$this->request->post['payment_payanyway_integrity_code'] = $integrityCode;
|
||||
|
||||
$fiscalization = !empty($this->request->post['payment_payanyway_fiscalization']);
|
||||
$this->request->post['payment_payanyway_fiscalization'] = $fiscalization ? 1 : 0;
|
||||
$this->request->post['payment_payanyway_test_mode'] = empty($this->request->post['payment_payanyway_test_mode']) ? 0 : 1;
|
||||
|
||||
$receiptEmail = $this->decodePostedString('payment_payanyway_receipt_email');
|
||||
if (($fiscalization || ('' !== $receiptEmail)) && !$this->isValidReceiptEmail($receiptEmail)) {
|
||||
$this->errors['receipt_email'] = $this->language->get('error_receipt_email');
|
||||
}
|
||||
$this->request->post['payment_payanyway_receipt_email'] = $receiptEmail;
|
||||
|
||||
if (!in_array($this->request->post['payment_payanyway_payment_method'] ?? '', self::PAYMENT_METHODS, true)) {
|
||||
$this->request->post['payment_payanyway_payment_method'] = self::DEFAULT_PAYMENT_METHOD;
|
||||
}
|
||||
if (!in_array($this->request->post['payment_payanyway_payment_object'] ?? '', self::PAYMENT_OBJECTS, true)) {
|
||||
$this->request->post['payment_payanyway_payment_object'] = self::DEFAULT_PAYMENT_OBJECT;
|
||||
}
|
||||
|
||||
$canceled = (array)($this->request->post['payment_payanyway_canceled_order_status_ids'] ?? []);
|
||||
$this->request->post['payment_payanyway_canceled_order_status_ids'] = array_values(array_unique(array_filter(array_map('intval', $canceled), static fn (int $statusId): bool => $statusId > 0)));
|
||||
|
||||
if (empty($this->request->post['payment_payanyway_pending_order_status_id'])) {
|
||||
$this->errors['pending_order_status'] = $this->language->get('error_pending_order_status');
|
||||
@@ -192,11 +351,20 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
$this->errors['error_order_status'] = $this->language->get('error_error_order_status');
|
||||
}
|
||||
|
||||
$moduleStatuses = [
|
||||
(int)($this->request->post['payment_payanyway_pending_order_status_id'] ?? 0),
|
||||
(int)($this->request->post['payment_payanyway_success_order_status_id'] ?? 0),
|
||||
(int)($this->request->post['payment_payanyway_error_order_status_id'] ?? 0),
|
||||
];
|
||||
if ([] !== array_intersect($this->request->post['payment_payanyway_canceled_order_status_ids'], array_diff($moduleStatuses, [0]))) {
|
||||
$this->errors['canceled_order_status'] = $this->language->get('error_canceled_order_status');
|
||||
}
|
||||
|
||||
$sortOrder = isset($this->request->post['payment_payanyway_sort_order'])
|
||||
? (int)$this->request->post['payment_payanyway_sort_order']
|
||||
: self::MODULE_DEFAULT_SORT_ORDER;
|
||||
$this->request->post['payment_payanyway_sort_order'] = $sortOrder;
|
||||
$this->request->post['payment_payanyway_cms_module_version'] = $this->getCmsModuleVersion();
|
||||
$this->request->post['payment_payanyway_cms_name'] = $this->getCmsName();
|
||||
|
||||
return empty($this->errors);
|
||||
}
|
||||
@@ -207,6 +375,7 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
$data['error_warning'] = $this->errors['warning'] ?? '';
|
||||
$data['error_account'] = $this->errors['account'] ?? '';
|
||||
$data['error_integrity_code'] = $this->errors['integrity_code'] ?? '';
|
||||
$data['error_receipt_email'] = $this->errors['receipt_email'] ?? '';
|
||||
$data['error_pending_order_status'] = $this->errors['pending_order_status'] ?? '';
|
||||
$data['error_success_order_status'] = $this->errors['success_order_status'] ?? '';
|
||||
$data['error_error_order_status'] = $this->errors['error_order_status'] ?? '';
|
||||
@@ -231,14 +400,57 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
];
|
||||
}
|
||||
|
||||
private function getIntegrityCode(): string
|
||||
private function hasIntegrityCode(): bool
|
||||
{
|
||||
$value = $this->retrieveValue('payment_payanyway_integrity_code');
|
||||
if (null === $value) {
|
||||
return '';
|
||||
return '' !== (string)$this->config->get('payment_payanyway_integrity_code');
|
||||
}
|
||||
|
||||
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
||||
/** Строка с формы без экранирования OpenCart: htmlspecialchars_decode обращает ENT_COMPAT из system/library/request.php. */
|
||||
private function decodePostedString(string $name): string
|
||||
{
|
||||
$value = $this->request->post[$name] ?? '';
|
||||
|
||||
return is_string($value) ? trim(htmlspecialchars_decode($value, ENT_COMPAT)) : '';
|
||||
}
|
||||
|
||||
private function isValidReceiptEmail(string $email): bool
|
||||
{
|
||||
return ('' !== $email)
|
||||
&& (strlen($email) <= self::RECEIPT_EMAIL_MAX_LENGTH)
|
||||
&& (false !== filter_var($email, FILTER_VALIDATE_EMAIL));
|
||||
}
|
||||
|
||||
private function isFiscalizationEnabled(): bool
|
||||
{
|
||||
// Config::get() в OpenCart 4 для отсутствующего ключа возвращает '' — наличие через has().
|
||||
return !$this->config->has('payment_payanyway_fiscalization')
|
||||
|| (bool)$this->config->get('payment_payanyway_fiscalization');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
private function getCanceledOrderStatusIds(): array
|
||||
{
|
||||
if (!$this->config->has('payment_payanyway_canceled_order_status_ids')) {
|
||||
return self::DEFAULT_CANCELED_ORDER_STATUS_IDS;
|
||||
}
|
||||
|
||||
return array_map('intval', (array)$this->config->get('payment_payanyway_canceled_order_status_ids'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $values
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function getOptions(array $values, string $labelPrefix): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($values as $value) {
|
||||
$result[$value] = $this->language->get($labelPrefix . $value);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
@@ -310,22 +522,13 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
return $this->model_localisation_geo_zone->getGeoZones();
|
||||
}
|
||||
|
||||
/** @return string */
|
||||
private function getCmsModuleVersion(): string
|
||||
private function getCmsName(): string
|
||||
{
|
||||
$this->load->language('common/footer');
|
||||
$textFooter = $this->language->get('text_footer');
|
||||
$textFooter = is_string($textFooter) ? $textFooter : '';
|
||||
|
||||
$cmsName = $this->detectCmsByPatterns($textFooter);
|
||||
$cmsVersion = defined('VERSION') ? constant('VERSION') : '.unknown';
|
||||
|
||||
return sprintf('%s v%s | %s v%s',
|
||||
$cmsName,
|
||||
$cmsVersion,
|
||||
self::MODULE_NAME,
|
||||
self::MODULE_VERSION
|
||||
);
|
||||
return $this->detectCmsByPatterns($textFooter);
|
||||
}
|
||||
|
||||
private function detectCmsByPatterns($textFooter): string
|
||||
@@ -351,4 +554,11 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
{
|
||||
return $this->request->post[$name] ?? $this->config->get($name);
|
||||
}
|
||||
|
||||
private function retrieveValueOrDefault(string $name, string $default): string
|
||||
{
|
||||
$value = (string)$this->retrieveValue($name);
|
||||
|
||||
return ('' !== $value) ? $value : $default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Heading
|
||||
$_['heading_title'] = 'PayAnyWay';
|
||||
$_['heading_title'] = 'PayAnyWay (MONETA)';
|
||||
|
||||
// Text
|
||||
$_['text_extension'] = 'Extensions';
|
||||
@@ -15,6 +15,7 @@ $_['entry_account_label'] = 'Account number';
|
||||
$_['entry_account_description'] = 'Your extended account number in the MONETA.RU system';
|
||||
$_['entry_integrity_code_label'] = 'Integrity Code';
|
||||
$_['entry_integrity_code_description'] = 'Data integrity verification code specified in the extended account settings in the MONETA.RU system';
|
||||
$_['text_integrity_code_saved'] = 'The code is saved. Leave the field empty to keep it';
|
||||
$_['entry_server_label'] = 'Payment server';
|
||||
$_['entry_server_prod_label'] = 'Production';
|
||||
$_['entry_server_demo_label'] = 'Demo';
|
||||
@@ -44,6 +45,40 @@ $_['entry_error_order_status_label'] = 'Error';
|
||||
$_['entry_geo_zone_label'] = 'Area of display';
|
||||
$_['entry_sort_order_label'] = 'Sort Order';
|
||||
|
||||
$_['entry_payment_method_label'] = 'Payment method';
|
||||
$_['entry_payment_object_label'] = 'Payment object';
|
||||
$_['entry_test_mode_label'] = 'Test mode';
|
||||
$_['entry_fiscalization_label'] = 'Receipt fiscalization';
|
||||
$_['entry_receipt_email_label'] = 'Email for receipts';
|
||||
$_['entry_canceled_order_status_label'] = 'Canceled';
|
||||
$_['entry_callback_url_label'] = 'Notification URL';
|
||||
$_['entry_payment_method_full_prepayment'] = 'Full prepayment';
|
||||
$_['entry_payment_method_prepayment'] = 'Partial prepayment';
|
||||
$_['entry_payment_method_advance'] = 'Advance';
|
||||
$_['entry_payment_method_full_payment'] = 'Full payment';
|
||||
$_['entry_payment_method_partial_payment'] = 'Partial payment and credit';
|
||||
$_['entry_payment_method_credit'] = 'Credit';
|
||||
$_['entry_payment_method_credit_payment'] = 'Credit payment';
|
||||
$_['entry_payment_object_commodity'] = 'Commodity';
|
||||
$_['entry_payment_object_excise'] = 'Excise commodity';
|
||||
$_['entry_payment_object_job'] = 'Job';
|
||||
$_['entry_payment_object_service'] = 'Service';
|
||||
$_['entry_payment_object_gambling_bet'] = 'Gambling bet';
|
||||
$_['entry_payment_object_gambling_prize'] = 'Gambling prize';
|
||||
$_['entry_payment_object_lottery'] = 'Lottery ticket';
|
||||
$_['entry_payment_object_lottery_prize'] = 'Lottery prize';
|
||||
$_['entry_payment_object_intellectual_activity'] = 'Intellectual activity';
|
||||
$_['entry_payment_object_payment'] = 'Payment (advance, deposit, prepayment, credit)';
|
||||
$_['entry_payment_object_agent_commission'] = 'Agent commission';
|
||||
$_['entry_payment_object_composite'] = 'Composite';
|
||||
$_['entry_payment_object_another'] = 'Another';
|
||||
$_['entry_payment_object_property_right'] = 'Property right';
|
||||
$_['entry_payment_object_sales_tax'] = 'Sales tax';
|
||||
$_['entry_payment_object_resort_fee'] = 'Resort fee';
|
||||
$_['entry_payment_object_award'] = 'Contribution, penalty, fine, reward, bonus';
|
||||
$_['entry_payment_object_non_operating'] = 'Non-operating income';
|
||||
$_['entry_payment_object_insurance_premium'] = 'Insurance premium';
|
||||
|
||||
// Help
|
||||
$_['help_status'] = 'Enable or disable the payment module';
|
||||
$_['help_pending_order_status'] = 'Specify the order status that is set when the order is created';
|
||||
@@ -51,12 +86,28 @@ $_['help_success_order_status'] = 'Specify the order status that is set upon suc
|
||||
$_['help_error_order_status'] = 'Specify the order status that is set upon payment error';
|
||||
$_['help_geo_zone'] = 'Geographic area where the payment method will be displayed';
|
||||
$_['help_sort_order'] = 'Determines the display order of the payment method. The smaller the number, the higher the position in the list';
|
||||
$_['help_test_mode'] = 'Simulates payments without charging. Must match the account setting in MONETA.RU';
|
||||
$_['help_fiscalization'] = 'Enable only if the kassa.payanyway.ru cash register service is connected to the account';
|
||||
$_['help_receipt_email'] = 'Required with fiscalization. Used in the receipt only when the customer has neither e-mail nor phone: the cash register does not accept a receipt without a contact, so it is sent to this address (the customer name stays in the receipt)';
|
||||
$_['help_canceled_order_status'] = 'Orders in these statuses are not paid: the module rejects the check request and the payment notification';
|
||||
$_['help_callback_url'] = 'Set this address as Check URL and Pay URL in the extended account settings';
|
||||
|
||||
// Button
|
||||
$_['button_integrity_code_toggle'] = 'Show or hide';
|
||||
$_['button_copy'] = 'Copy';
|
||||
$_['text_copied'] = 'Copied';
|
||||
|
||||
// Error
|
||||
$_['error_permission'] = 'Warning: You do not have permission to modify PayAnyWay payment method!';
|
||||
$_['error_validation'] = 'Warning: To modify the PayAnyWay payment method, you need to fill in all required fields!';
|
||||
$_['error_account'] = 'Account Number is required and must be greater than 0';
|
||||
$_['error_account'] = 'Account number is required and must contain digits only';
|
||||
$_['error_integrity_code'] = 'Account Integrity Code Required';
|
||||
$_['error_pending_order_status'] = 'Select Pending order status';
|
||||
$_['error_success_order_status'] = 'Select Success order status';
|
||||
$_['error_error_order_status'] = 'Select Error order status';
|
||||
$_['error_canceled_order_status'] = 'Pending, Success and Error order statuses cannot be among Canceled';
|
||||
$_['error_receipt_email'] = 'Enter a valid e-mail no longer than 64 characters';
|
||||
|
||||
// Warning
|
||||
$_['warning_startup_route'] = 'The PayAnyWay startup for POST notifications is not registered or is disabled: Check URL and Pay URL notifications sent by POST will not reach the module. Save the module settings (the startup will be registered) or enable <code>payanyway_post_route</code> in <a href="%s">Extensions → Startup</a>. It also releases a paid order from the customer session so that the next purchase in the same browser creates a new order.';
|
||||
$_['warning_receipt_email'] = 'Receipt fiscalization is enabled but "Email for receipts" is empty: a customer without e-mail and phone will not get a receipt. Set the address below or disable fiscalization if no cash register service is connected.';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// Heading
|
||||
$_['heading_title'] = 'PayAnyWay';
|
||||
$_['heading_title'] = 'PayAnyWay (МОНЕТА)';
|
||||
|
||||
// Text
|
||||
$_['text_extension'] = 'Расширения';
|
||||
@@ -15,9 +15,10 @@ $_['entry_account_label'] = 'Номер счёта';
|
||||
$_['entry_account_description'] = 'Ваш номер расширенного счёта в системе MONETA.RU';
|
||||
$_['entry_integrity_code_label'] = 'Код проверки целостности данных';
|
||||
$_['entry_integrity_code_description'] = 'Код проверки целостности данных, указанный в настройках расширенного счёта в системе MONETA.RU';
|
||||
$_['text_integrity_code_saved'] = 'Код сохранён. Оставьте поле пустым, чтобы не менять его';
|
||||
$_['entry_server_label'] = 'Сервер платежей';
|
||||
$_['entry_server_prod_label'] = 'Боевой';
|
||||
$_['entry_server_demo_label'] = 'Тестовый';
|
||||
$_['entry_server_demo_label'] = 'Демо';
|
||||
|
||||
$_['entry_vat_rate_label'] = 'НДС';
|
||||
$_['entry_vat_none_label'] = 'Без НДС';
|
||||
@@ -44,6 +45,40 @@ $_['entry_error_order_status_label'] = 'Неуспешный';
|
||||
$_['entry_geo_zone_label'] = 'Регион отображения';
|
||||
$_['entry_sort_order_label'] = 'Порядок сортировки';
|
||||
|
||||
$_['entry_payment_method_label'] = 'Признак способа расчёта';
|
||||
$_['entry_payment_object_label'] = 'Признак предмета расчёта';
|
||||
$_['entry_test_mode_label'] = 'Тестовый режим';
|
||||
$_['entry_fiscalization_label'] = 'Фискализация чеков';
|
||||
$_['entry_receipt_email_label'] = 'Email для чеков';
|
||||
$_['entry_canceled_order_status_label'] = 'Отменённые';
|
||||
$_['entry_callback_url_label'] = 'URL для уведомлений';
|
||||
$_['entry_payment_method_full_prepayment'] = 'Полная предоплата';
|
||||
$_['entry_payment_method_prepayment'] = 'Частичная предоплата';
|
||||
$_['entry_payment_method_advance'] = 'Аванс';
|
||||
$_['entry_payment_method_full_payment'] = 'Полный расчёт';
|
||||
$_['entry_payment_method_partial_payment'] = 'Частичный расчёт и кредит';
|
||||
$_['entry_payment_method_credit'] = 'Передача в кредит';
|
||||
$_['entry_payment_method_credit_payment'] = 'Оплата кредита';
|
||||
$_['entry_payment_object_commodity'] = 'Товар';
|
||||
$_['entry_payment_object_excise'] = 'Подакцизный товар';
|
||||
$_['entry_payment_object_job'] = 'Работа';
|
||||
$_['entry_payment_object_service'] = 'Услуга';
|
||||
$_['entry_payment_object_gambling_bet'] = 'Ставка азартной игры';
|
||||
$_['entry_payment_object_gambling_prize'] = 'Выигрыш азартной игры';
|
||||
$_['entry_payment_object_lottery'] = 'Лотерейный билет';
|
||||
$_['entry_payment_object_lottery_prize'] = 'Выигрыш лотереи';
|
||||
$_['entry_payment_object_intellectual_activity'] = 'Результаты интеллектуальной деятельности';
|
||||
$_['entry_payment_object_payment'] = 'Платёж (аванс, задаток, предоплата, кредит)';
|
||||
$_['entry_payment_object_agent_commission'] = 'Агентское вознаграждение';
|
||||
$_['entry_payment_object_composite'] = 'Составной предмет расчёта';
|
||||
$_['entry_payment_object_another'] = 'Иной предмет расчёта';
|
||||
$_['entry_payment_object_property_right'] = 'Имущественное право';
|
||||
$_['entry_payment_object_sales_tax'] = 'Торговый сбор';
|
||||
$_['entry_payment_object_resort_fee'] = 'Курортный сбор';
|
||||
$_['entry_payment_object_award'] = 'Взнос в счёт оплаты, пени, штраф, вознаграждение, бонус';
|
||||
$_['entry_payment_object_non_operating'] = 'Внереализационный доход';
|
||||
$_['entry_payment_object_insurance_premium'] = 'Страховой сбор';
|
||||
|
||||
// Help
|
||||
$_['help_status'] = 'Включите или выключите модуль оплаты';
|
||||
$_['help_pending_order_status'] = 'Укажите статус заказа, который выставляется при его создании';
|
||||
@@ -51,12 +86,28 @@ $_['help_success_order_status'] = 'Укажите статус заказа, к
|
||||
$_['help_error_order_status'] = 'Укажите статус заказа, который выставляется при ошибке оплаты';
|
||||
$_['help_geo_zone'] = 'Географическая зона, в которой будет отображаться способ оплаты';
|
||||
$_['help_sort_order'] = 'Определяет порядок отображения платежного метода в списке методов оплаты при оформлении заказа. Чем меньше число, тем выше позиция в списке';
|
||||
$_['help_test_mode'] = 'Имитация оплаты без реального списания средств. Режим должен совпадать с настройкой счёта в MONETA.RU';
|
||||
$_['help_fiscalization'] = 'Включайте, только если к счёту подключён кассовый сервис kassa.payanyway.ru';
|
||||
$_['help_receipt_email'] = 'Обязателен при фискализации. Подставляется в чек, только если у покупателя нет ни e-mail, ни телефона: без контакта касса чек не примет, поэтому он уйдёт на этот адрес (ФИО покупателя в чеке остаётся)';
|
||||
$_['help_canceled_order_status'] = 'Заказ в этих статусах не оплачивается: на проверочный запрос и уведомление об оплате модуль отвечает отказом';
|
||||
$_['help_callback_url'] = 'Укажите этот адрес в настройках расширенного счёта как Check URL и Pay URL';
|
||||
|
||||
// Button
|
||||
$_['button_integrity_code_toggle'] = 'Показать или скрыть';
|
||||
$_['button_copy'] = 'Скопировать';
|
||||
$_['text_copied'] = 'Скопировано';
|
||||
|
||||
// Error
|
||||
$_['error_permission'] = 'Внимание: У вас нет прав на изменение платежного метода PayAnyWay!';
|
||||
$_['error_validation'] = 'Внимание: Чтобы изменить платежный метод PayAnyWay, вам необходимо заполнить все обязательные поля!';
|
||||
$_['error_account'] = 'Номер счёта обязателен';
|
||||
$_['error_account'] = 'Номер счёта обязателен и состоит только из цифр';
|
||||
$_['error_integrity_code'] = 'Код проверки целостности данных обязателен';
|
||||
$_['error_pending_order_status'] = 'Выберите статус Обрабатываемого заказа';
|
||||
$_['error_success_order_status'] = 'Выберите статус Успешного заказа';
|
||||
$_['error_error_order_status'] = 'Выберите статус Неуспешного заказа';
|
||||
$_['error_canceled_order_status'] = 'Статусы Обрабатываемого, Успешного и Неуспешного заказа не могут быть среди Отменённых';
|
||||
$_['error_receipt_email'] = 'Укажите корректный e-mail не длиннее 64 символов';
|
||||
|
||||
// Warning
|
||||
$_['warning_startup_route'] = 'Startup PayAnyWay для приёма уведомлений методом POST не зарегистрирован или выключен: Check URL и Pay URL методом POST не дойдут до модуля. Сохраните настройки модуля (startup зарегистрируется) или включите <code>payanyway_post_route</code> в <a href="%s">Расширения → Startup</a>. Он же снимает оплаченный заказ из сессии покупателя, чтобы следующая покупка в том же браузере создала новый заказ.';
|
||||
$_['warning_receipt_email'] = 'Фискализация чеков включена, а «Email для чеков» не задан: покупателю без e-mail и телефона чек не будет пробит. Укажите адрес в настройках ниже или выключите фискализацию, если кассовый сервис не подключён.';
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 10 KiB |
@@ -22,13 +22,19 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="container-fluid">
|
||||
<div id="payanyway-warning-startup" class="alert alert-warning{% if not warning_startup %} d-none{% endif %}">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i> <span>{{ warning_startup }}</span>
|
||||
</div>
|
||||
<div id="payanyway-warning-receipt-email" class="alert alert-warning{% if not warning_receipt_email %} d-none{% endif %}">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i> <span>{{ warning_receipt_email }}</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="fa-solid fa-pencil"></i>
|
||||
{{ text_edit }}
|
||||
</div>
|
||||
<div class="card-header text-center">
|
||||
<a href="https://www.payanyway.ru/" target="_blank"><img src="../extension/payanyway/admin/view/image/payment/payanyway.png" alt="PayAnyWay" title="PayAnyWay" width="250" height="29" /></a>
|
||||
<a href="https://www.payanyway.ru/" target="_blank"><img src="../extension/payanyway/admin/view/image/payment/payanyway.png" alt="PayAnyWay" title="PayAnyWay" width="336" height="50" /></a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="form-payment" action="{{ save }}" method="post" data-oc-toggle="ajax">
|
||||
@@ -68,8 +74,13 @@
|
||||
<div class="row mb-3 required">
|
||||
<label class="col-sm-2 col-form-label" for="input-integrity-code">{{ entry_integrity_code_label }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" name="payment_payanyway_integrity_code" value="{{ payment_payanyway_integrity_code }}"
|
||||
placeholder="{{ entry_integrity_code_description }}" id="input-integrity-code" class="form-control"/>
|
||||
<div class="input-group">
|
||||
<input type="password" name="payment_payanyway_integrity_code" value=""
|
||||
placeholder="{{ integrity_code_placeholder }}" id="input-integrity-code" class="form-control"
|
||||
autocomplete="off"/>
|
||||
<button type="button" class="btn btn-light" id="button-integrity-code-toggle"
|
||||
data-bs-toggle="tooltip" title="{{ button_integrity_code_toggle }}"><i class="fa-solid fa-eye"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -84,6 +95,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Тестовый режим #}
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-2 col-form-label" for="input-test-mode">
|
||||
<span>{{ entry_test_mode_label }}</span>
|
||||
<i class="fa-regular fa-circle-question text-info" data-bs-toggle="tooltip" title="{{ help_test_mode }}"></i>
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<div class="form-check form-switch form-switch-lg">
|
||||
<input type="hidden" name="payment_payanyway_test_mode" value="0"/>
|
||||
<input type="checkbox" name="payment_payanyway_test_mode" value="1" id="input-test-mode"
|
||||
class="form-check-input" {% if payment_payanyway_test_mode == "1" %} checked{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Фискализация чеков #}
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-2 col-form-label" for="input-fiscalization">
|
||||
<span>{{ entry_fiscalization_label }}</span>
|
||||
<i class="fa-regular fa-circle-question text-info" data-bs-toggle="tooltip" title="{{ help_fiscalization }}"></i>
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<div class="form-check form-switch form-switch-lg">
|
||||
<input type="hidden" name="payment_payanyway_fiscalization" value="0"/>
|
||||
<input type="checkbox" name="payment_payanyway_fiscalization" value="1" id="input-fiscalization"
|
||||
class="form-check-input" {% if payment_payanyway_fiscalization == "1" %} checked{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Поля чека — только при фискализации. Скрываются, а не блокируются: заблокированное
|
||||
поле браузер не отправляет, и значение потерялось бы. #}
|
||||
<div id="fiscalization-fields">
|
||||
{# Email для чеков #}
|
||||
<div class="row mb-3 required">
|
||||
<label class="col-sm-2 col-form-label" for="input-receipt-email">
|
||||
<span>{{ entry_receipt_email_label }}</span>
|
||||
<i class="fa-regular fa-circle-question text-info" data-bs-toggle="tooltip" title="{{ help_receipt_email }}"></i>
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="email" name="payment_payanyway_receipt_email" value="{{ payment_payanyway_receipt_email }}"
|
||||
placeholder="shop@example.ru" id="input-receipt-email" class="form-control" maxlength="64"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# НДС #}
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-2 col-form-label" for="input-vat-rate">{{ entry_vat_rate_label }}</label>
|
||||
@@ -96,6 +152,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Признак способа расчёта #}
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-2 col-form-label" for="input-payment-method">{{ entry_payment_method_label }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select name="payment_payanyway_payment_method" id="input-payment-method" class="form-select">
|
||||
{% for value, label in payment_payanyway_payment_methods %}
|
||||
<option value="{{ value }}" {% if value == payment_payanyway_payment_method %}selected="selected"{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Признак предмета расчёта #}
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-2 col-form-label" for="input-payment-object">{{ entry_payment_object_label }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select name="payment_payanyway_payment_object" id="input-payment-object" class="form-select">
|
||||
{% for value, label in payment_payanyway_payment_objects %}
|
||||
<option value="{{ value }}" {% if value == payment_payanyway_payment_object %}selected="selected"{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Валюта платежа #}
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-2 col-form-label" for="input-currency">{{ entry_currency_label }}</label>
|
||||
@@ -187,6 +268,29 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# canceled #}
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label">
|
||||
<span>{{ entry_canceled_order_status_label }}</span>
|
||||
<i class="fa-regular fa-circle-question text-info"
|
||||
data-bs-toggle="tooltip"
|
||||
title="{{ help_canceled_order_status }}"
|
||||
></i>
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
<div class="form-control p-0" style="height: 150px; overflow: auto;">
|
||||
{% for order_status in order_statuses %}
|
||||
<div class="form-check m-2">
|
||||
<input type="checkbox" name="payment_payanyway_canceled_order_status_ids[]"
|
||||
value="{{ order_status.order_status_id }}" id="input-canceled-{{ order_status.order_status_id }}"
|
||||
class="form-check-input"{% if order_status.order_status_id in payment_payanyway_canceled_order_status_ids %} checked{% endif %}/>
|
||||
<label for="input-canceled-{{ order_status.order_status_id }}" class="form-check-label">{{ order_status.name }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,6 +320,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# URL для уведомлений #}
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-2 col-form-label" for="input-callback-url">
|
||||
<span>{{ entry_callback_url_label }}</span>
|
||||
<i class="fa-regular fa-circle-question text-info" data-bs-toggle="tooltip" title="{{ help_callback_url }}"></i>
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<div class="input-group">
|
||||
<input type="text" value="{{ callback_url }}" id="input-callback-url" class="form-control" readonly/>
|
||||
<button type="button" class="btn btn-light" id="button-callback-url-copy">{{ button_copy }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# sort-order #}
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-2 col-form-label" for="input-sort-order">
|
||||
@@ -238,4 +356,39 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
$(function () {
|
||||
function toggleFiscalizationFields() {
|
||||
$('#fiscalization-fields').toggle($('#input-fiscalization').is(':checked'));
|
||||
}
|
||||
$('#input-fiscalization').on('change', toggleFiscalizationFields);
|
||||
toggleFiscalizationFields();
|
||||
|
||||
// Форму отправляет common.js OpenCart; после сохранения обновляем предупреждения без перезагрузки.
|
||||
$(document).on('ajaxSuccess', function (event, xhr, settings) {
|
||||
var json = xhr.responseJSON;
|
||||
if (!json || !json['payanyway_warnings'] || (settings.url.indexOf('payanyway.save') === -1)) {
|
||||
return;
|
||||
}
|
||||
$.each({'startup': '#payanyway-warning-startup', 'receipt_email': '#payanyway-warning-receipt-email'}, function (key, selector) {
|
||||
var text = json['payanyway_warnings'][key] || '';
|
||||
$(selector).toggleClass('d-none', text === '').find('span').html(text);
|
||||
});
|
||||
});
|
||||
|
||||
$('#button-integrity-code-toggle').on('click', function () {
|
||||
var $input = $('#input-integrity-code');
|
||||
$input.attr('type', $input.attr('type') === 'password' ? 'text' : 'password');
|
||||
$(this).find('i').toggleClass('fa-eye fa-eye-slash');
|
||||
});
|
||||
|
||||
$('#button-callback-url-copy').on('click', function () {
|
||||
var $button = $(this);
|
||||
$('#input-callback-url').trigger('select');
|
||||
document.execCommand('copy');
|
||||
$button.text('{{ text_copied }}');
|
||||
setTimeout(function () { $button.text('{{ button_copy }}'); }, 2000);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{ footer }}
|
||||
|
||||
@@ -13,55 +13,89 @@ class Callback extends \Opencart\System\Engine\Controller
|
||||
{
|
||||
use \Opencart\Catalog\Library\Extension\PayAnyWay\Traits\PayAnyWay;
|
||||
|
||||
private const INVENTORY_ITEM_NAME_MAX_LENGTH = 128;
|
||||
private const INVENTORY_ITEM_DEFAULT_VAT_TAG = '1105';
|
||||
private const INVENTORY_ITEM_DEFAULT_PAYMENT_METHOD = 'full_payment';
|
||||
private const INVENTORY_ITEM_DEFAULT_PAYMENT_OBJECT = 'commodity';
|
||||
private const INVENTORY_ITEM_DEFAULT_MEASURE = 'unit';
|
||||
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';
|
||||
|
||||
private const CANCEL_ORDER_STATUS_IDS = [7, 9, 16];
|
||||
public const DESCRIPTION_MAX_LENGTH = 500;
|
||||
private const ITEM_NAME_MAX_LENGTH = 128;
|
||||
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 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('FAIL');
|
||||
}
|
||||
|
||||
$this->initializeModule();
|
||||
$this->initializeCallbackData();
|
||||
}
|
||||
|
||||
/** @throws \Exception */
|
||||
public function index(): void
|
||||
{
|
||||
($this->callbackData['MNT_COMMAND'] === 'CHECK')
|
||||
? $this->handleCheckCallback()
|
||||
: $this->handlePayCallback();
|
||||
try {
|
||||
$this->callbackData = $this->getCallbackData($this->getCallbackRequestData());
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
$this->log->write('PayAnyWay callback: ' . $exception->getMessage());
|
||||
$this->sendResponse(self::FAIL_RESPONSE);
|
||||
}
|
||||
|
||||
private function initializeCallbackData(): void
|
||||
{
|
||||
$requestData = ($_SERVER['REQUEST_METHOD'] === 'POST') ? $_POST : $_GET;
|
||||
$this->validateCallback();
|
||||
|
||||
$this->callbackData = [
|
||||
$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();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $requestData
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function getCallbackData(array $requestData): array
|
||||
{
|
||||
return [
|
||||
'MNT_COMMAND' => $this->getOptionalStringWithDefault($requestData, 'MNT_COMMAND'),
|
||||
'MNT_ID' => $this->getRequiredString($requestData, 'MNT_ID'),
|
||||
'MNT_TRANSACTION_ID' => $this->getRequiredString($requestData, 'MNT_TRANSACTION_ID'),
|
||||
@@ -74,32 +108,61 @@ class Callback extends \Opencart\System\Engine\Controller
|
||||
];
|
||||
}
|
||||
|
||||
private function sendResponse(string $response, int $statusCode = 200): void
|
||||
private function validateCallback(): void
|
||||
{
|
||||
if (empty($this->mntId) || empty($this->integrityCode)) {
|
||||
$this->sendResponse(self::FAIL_RESPONSE);
|
||||
}
|
||||
|
||||
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{data: string, contentType: string}|string $response
|
||||
*/
|
||||
private function sendResponse(array|string $response, int $statusCode = 200): void
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
|
||||
$isXml = str_starts_with($response, '<?xml') || str_contains($response, '<MNT_RESPONSE');
|
||||
header('Content-Type: ' . ($isXml ? 'application/xml' : 'text/plain; charset=UTF-8'));
|
||||
|
||||
if (is_string($response)) {
|
||||
header('Content-Type: ' . self::TEXT_CONTENT_TYPE);
|
||||
echo $response;
|
||||
exit;
|
||||
} else {
|
||||
header('Content-Type: ' . $response['contentType']);
|
||||
echo $response['data'];
|
||||
}
|
||||
|
||||
exit();
|
||||
}
|
||||
|
||||
/** @throws \Exception */
|
||||
private function handleCheckCallback(): void
|
||||
{
|
||||
if (!$this->checkSignature($this->callbackData)) {
|
||||
$this->sendResponse('FAIL');
|
||||
if (!$this->isOrderPaid()) {
|
||||
$this->rejectPaymentMismatch();
|
||||
}
|
||||
|
||||
[$resultCode, $description] = $this->determineCheckResult();
|
||||
$responseData = $this->buildResponseData($resultCode, $description);
|
||||
$responseData = $this->buildBaseResponseData($resultCode, $description);
|
||||
|
||||
$this->sendResponse($this->buildXMLResponse($responseData));
|
||||
if ($this->fiscalization) {
|
||||
$responseData['client'] = $this->getClientInfo(true);
|
||||
$responseData['items'] = $this->getCheckItems($this->getReceiptLines());
|
||||
|
||||
$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
|
||||
@@ -110,16 +173,16 @@ class Callback extends \Opencart\System\Engine\Controller
|
||||
}
|
||||
$orderStatus = $this->getOrderStatusById($orderStatusId);
|
||||
|
||||
if (empty($this->callbackData['MNT_AMOUNT'])) {
|
||||
return [100, "Order status is '{$orderStatus}'"];
|
||||
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)) {
|
||||
return [500, "Order status is '{$orderStatus}'"];
|
||||
if (in_array($orderStatusId, $this->canceledOrderStatusIds, true)) {
|
||||
return [500, "Order status is {$orderStatus}"];
|
||||
}
|
||||
|
||||
return [402, 'Order created, but not paid'];
|
||||
@@ -128,70 +191,184 @@ 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('FAIL');
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
$description = $isPayedOrder ? 'Order already paid' : 'Order success paid';
|
||||
$responseData = $this->buildBaseResponseData(200, $description);
|
||||
|
||||
$this->sendResponse($this->buildXmlResponse($responseData + $this->getResponseAttributes()));
|
||||
}
|
||||
|
||||
$resultCode = 200;
|
||||
$description = $isPayedOrder ? 'Order already paid' : 'Order success paid';
|
||||
$responseData = $this->buildResponseData($resultCode, $description);
|
||||
/** Уведомление не согласовано с номером транзакции или заказом — FAIL и запись в лог. */
|
||||
private function rejectPaymentMismatch(): void
|
||||
{
|
||||
$mismatch = $this->getPaymentMismatch();
|
||||
if (null !== $mismatch) {
|
||||
$this->log->write('PayAnyWay callback: ' . $mismatch);
|
||||
$this->sendResponse(self::FAIL_RESPONSE);
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendResponse($this->buildXMLResponse($responseData));
|
||||
/**
|
||||
* Заказ отменили до оплаты (менеджер, аннулирование ядром при правке заказа) — оплата его не
|
||||
* проводит: подписанный 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 buildResponseData(int $resultCode, string $description): array
|
||||
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' => $description,
|
||||
'MNT_AMOUNT' => $this->formatPrice($orderTotal),
|
||||
'MNT_DESCRIPTION' => $this->validateString($description, self::DESCRIPTION_MAX_LENGTH),
|
||||
'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;
|
||||
$data['MNT_SIGNATURE'] = md5($signatureSource);
|
||||
|
||||
$this->load->model('account/order');
|
||||
$products = $this->model_account_order->getProducts($this->orderId);
|
||||
$deliveryPrice = $this->getDeliveryPrice();
|
||||
|
||||
$data['inventory'] = $this->getInventoryJson($products) ?: null;
|
||||
$data['client'] = $this->callbackData['MNT_SUBSCRIBER_ID'];
|
||||
$data['sno'] = null;
|
||||
$data['delivery'] = (null !== $deliveryPrice) ? $this->formatPrice($deliveryPrice) : null;
|
||||
|
||||
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 */
|
||||
@@ -218,79 +395,405 @@ class Callback extends \Opencart\System\Engine\Controller
|
||||
return hash_equals(md5($signatureString), $this->callbackData['MNT_SIGNATURE']);
|
||||
}
|
||||
|
||||
private function getDeliveryPrice(): ?float
|
||||
/**
|
||||
* @return array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}> цены в копейках
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function getReceiptLines(): array
|
||||
{
|
||||
$this->load->model('account/order');
|
||||
foreach ($this->model_account_order->getTotals($this->orderId) as $orderTotal) {
|
||||
if (($orderTotal['code'] ?? '') === 'shipping') {
|
||||
$deliveryPrice = (float)($orderTotal['value'] ?? 0);
|
||||
return $deliveryPrice !== 0.0 ? $deliveryPrice : null;
|
||||
}
|
||||
$target = $this->toKopecks($this->orderInfo['total'] ?? 0);
|
||||
if ($target <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return null;
|
||||
$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,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $products
|
||||
* @return false|string
|
||||
* @return array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}>
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function getInventoryJson(array $products): false|string
|
||||
private function collectReceiptLines(): array
|
||||
{
|
||||
if (empty($products)) {
|
||||
$this->load->model('account/order');
|
||||
|
||||
$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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проданные в заказе подарочные сертификаты. В 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'] = $this->validateString($name, self::CLIENT_NAME_MAX_LENGTH);
|
||||
}
|
||||
if (null !== $email) {
|
||||
$clientInfo['email'] = $email;
|
||||
}
|
||||
if (null !== $phone) {
|
||||
$clientInfo['phone'] = $phone;
|
||||
}
|
||||
|
||||
return $clientInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 = [];
|
||||
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' => $line['pm'],
|
||||
'paymentObject' => $line['po'],
|
||||
];
|
||||
}
|
||||
|
||||
return $checkItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 ([] === $lines) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$inventory = [];
|
||||
foreach ($products as $product) {
|
||||
$inventory[] = [
|
||||
'name' => $this->validateString($product['name'] ?? '', self::INVENTORY_ITEM_NAME_MAX_LENGTH),
|
||||
'price' => $this->formatPrice((float)($product['price'] ?? 0)),
|
||||
'quantity' => $this->formatQuantity((float)($product['quantity'] ?? 0)),
|
||||
'vatTag' => $this->vatRate,
|
||||
'pm' => self::INVENTORY_ITEM_DEFAULT_PAYMENT_METHOD,
|
||||
'po' => self::INVENTORY_ITEM_DEFAULT_PAYMENT_OBJECT,
|
||||
'measure' => self::INVENTORY_ITEM_DEFAULT_MEASURE,
|
||||
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;
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
private function formatQuantity(float $quantity): string
|
||||
/**
|
||||
* @param array<non-empty-string, mixed> $data
|
||||
* @return array
|
||||
*/
|
||||
public function buildJsonResponse(array $data): array
|
||||
{
|
||||
$quantityString = number_format($quantity, 3, '.', '');
|
||||
return rtrim(rtrim($quantityString, '0'), '.');
|
||||
$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
|
||||
* @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): false|string
|
||||
private function buildXmlResponse(array $data, bool $withAttributes = true): array
|
||||
{
|
||||
$dom = new \DOMDocument('1.0', 'UTF-8');
|
||||
$dom->formatOutput = false;
|
||||
@@ -306,35 +809,69 @@ 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]));
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
'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));
|
||||
$attrElement->appendChild($this->createTextElement($dom, 'KEY', $key));
|
||||
$attrElement->appendChild($this->createTextElement($dom, 'VALUE', (string)$value));
|
||||
$attributes->appendChild($attrElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$xml = $dom->saveXML();
|
||||
return (false !== $xml) ? $xml : 'FAIL';
|
||||
|
||||
return [
|
||||
'data' => (false !== $xml) ? $xml : self::FAIL_RESPONSE,
|
||||
'contentType' => (false !== $xml) ? self::XML_CONTENT_TYPE : self::TEXT_CONTENT_TYPE,
|
||||
];
|
||||
}
|
||||
|
||||
/** 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',
|
||||
'1102' => 'vat20',
|
||||
'1113' => 'vat22',
|
||||
'1110' => 'vat105',
|
||||
'1111' => 'vat107',
|
||||
'1107' => 'vat110',
|
||||
'1106' => 'vat120',
|
||||
'1114' => 'vat122',
|
||||
default => 'none'
|
||||
};
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $params */
|
||||
|
||||
@@ -4,8 +4,37 @@ declare(strict_types=1);
|
||||
|
||||
namespace Opencart\Catalog\Controller\Extension\PayAnyWay\Payment;
|
||||
|
||||
use Opencart\Catalog\Model\Account\Customer as ModelAccountCustomer;
|
||||
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\Customer;
|
||||
use Opencart\System\Library\Document;
|
||||
use Opencart\System\Library\Language;
|
||||
use Opencart\System\Library\Request;
|
||||
use Opencart\System\Library\Response;
|
||||
use Opencart\System\Library\Session;
|
||||
use Opencart\System\Library\Url;
|
||||
|
||||
require_once DIR_EXTENSION . '/payanyway/catalog/library/payanyway/traits/payanyway.php';
|
||||
|
||||
/**
|
||||
* @property Config $config
|
||||
* @property Loader $load
|
||||
* @property Request $request
|
||||
* @property Response $response
|
||||
* @property Document $document
|
||||
* @property Session $session
|
||||
* @property Language $language
|
||||
* @property Url $url
|
||||
* @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
|
||||
{
|
||||
use \Opencart\Catalog\Library\Extension\PayAnyWay\Traits\PayAnyWay;
|
||||
@@ -13,32 +42,15 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
private const PAYMENT_SERVER_PROD = 'prod';
|
||||
private const PAYMENT_URL_PROD = 'https://www.payanyway.ru/assistant.htm';
|
||||
private const PAYMENT_URL_DEMO = 'https://demo.moneta.ru/assistant.htm';
|
||||
private 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 const DESCRIPTION_MAX_LENGTH = 500;
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,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'));
|
||||
|
||||
@@ -54,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'] .
|
||||
@@ -70,25 +109,13 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
$data['MNT_TEST_MODE'] .
|
||||
$this->integrityCode,
|
||||
);
|
||||
$data['MNT_SIGNATURE'] = $signature;
|
||||
$data['MNT_SUCCESS_URL'] = $this->url->link('extension/payanyway/payment/payanyway.success', 'language=' . $this->config->get('config_language'), true,);
|
||||
$data['MNT_FAIL_URL'] = $this->url->link('extension/payanyway/payment/payanyway.fail', 'language=' . $this->config->get('config_language'), true,);
|
||||
$data['MNT_RETURN_URL'] = $this->url->link('checkout/checkout', 'language=' . $this->config->get('config_language'), true,);
|
||||
$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', 'language=' . $this->config->get('config_language'), true,);
|
||||
|
||||
return $this->load->view('extension/payanyway/payment/payanyway', $data);
|
||||
}
|
||||
|
||||
public function confirm(): void
|
||||
{
|
||||
$this->load->language('extension/payanyway/payment/payanyway');
|
||||
$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));
|
||||
@@ -97,45 +124,96 @@ class PayAnyWay extends \Opencart\System\Engine\Controller
|
||||
public function success(): void
|
||||
{
|
||||
$this->load->language('extension/payanyway/payment/payanyway');
|
||||
$this->addOrderHistory($this->orderId, $this->pendingOrderStatusId, $this->language->get('text_payment_processing'),);
|
||||
$this->response->redirect($this->url->link('checkout/success', '', true));
|
||||
|
||||
$this->changeStatusUnderOrderLock(false, $this->pendingOrderStatusId, $this->language->get('text_payment_processing'));
|
||||
|
||||
$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');
|
||||
$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
|
||||
{
|
||||
$clientName = trim(($this->orderInfo['firstname'] ?? '') . ' ' . ($this->orderInfo['lastname'] ?? ''));
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
if ('' !== $clientName) {
|
||||
$description .= " от {$clientName}";
|
||||
$name = $this->getClientName();
|
||||
if (null !== $name) {
|
||||
$description .= ' ' . sprintf($this->language->get('text_description_customer'), $name);
|
||||
}
|
||||
|
||||
$comment = $this->orderInfo['comment'] ?? '';
|
||||
if ($comment !== '') {
|
||||
if ('' !== $comment) {
|
||||
$description .= ': ' . htmlspecialchars($comment, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$email = filter_var($this->orderInfo['email'] ?? '', FILTER_SANITIZE_EMAIL);
|
||||
if ('' !== $email) {
|
||||
return $email;
|
||||
}
|
||||
|
||||
$phoneNumber = preg_replace('/[^0-9+]/', '', $this->orderInfo['telephone'] ?? '');
|
||||
|
||||
return $phoneNumber ?: '';
|
||||
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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
// Text
|
||||
$_['text_locale'] = 'en';
|
||||
$_['text_title'] = 'PayAnyWay (MIR, VISA, MasterCard and 30+ other ways)';
|
||||
$_['text_ap_pay_with'] = 'Pay with';
|
||||
$_['text_ap_and_other'] = 'or another payment method';
|
||||
@@ -11,4 +12,9 @@ $_['text_paw_back_to_card'] = 'Back to payment by card';
|
||||
$_['text_order_confirmed'] = 'Order confirmed';
|
||||
$_['text_payment_processing'] = 'Payment processing';
|
||||
$_['text_payment_completed'] = 'Order is paid';
|
||||
$_['text_payment_operation'] = 'Operation number: %s';
|
||||
|
||||
$_['text_description'] = 'Payment for order';
|
||||
$_['text_description_number'] = 'No. %s';
|
||||
$_['text_description_customer'] = 'from %s';
|
||||
$_['text_payment_error'] = 'Payment error';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
// Text
|
||||
$_['text_locale'] = 'ru';
|
||||
$_['text_title'] = 'PayAnyWay (МИР, Visa, MasterCard и более 30-и способов оплаты)';
|
||||
$_['text_ap_pay_with'] = 'Оплатить с';
|
||||
$_['text_ap_and_other'] = 'или другой способ оплаты';
|
||||
@@ -11,4 +12,9 @@ $_['text_paw_back_to_card'] = 'Вернуться к оплате картой
|
||||
$_['text_order_confirmed'] = 'Заказ подтвержден';
|
||||
$_['text_payment_processing'] = 'Обработка платежа';
|
||||
$_['text_payment_completed'] = 'Заказ оплачен';
|
||||
$_['text_payment_operation'] = 'Номер операции: %s';
|
||||
|
||||
$_['text_description'] = 'Оплата заказа';
|
||||
$_['text_description_number'] = '№%s';
|
||||
$_['text_description_customer'] = 'от %s';
|
||||
$_['text_payment_error'] = 'Ошибка оплаты';
|
||||
|
||||
@@ -6,34 +6,116 @@ namespace Opencart\Catalog\Library\Extension\PayAnyWay\Traits;
|
||||
|
||||
trait PayAnyWay
|
||||
{
|
||||
// Статические свойства, а не константы: константы в трейтах — только с PHP 8.2, модуль — 8.0+.
|
||||
|
||||
private static string $transactionIdDelimiter = '_';
|
||||
private static string $transactionIdZone = 'UTC';
|
||||
private static string $transactionIdPattern = '/^([1-9][0-9]*)_(0|[1-9][0-9]{0,15})_([0-9]{14})UTC$/D';
|
||||
// Номер модуля 1.0.2 и раньше: <номер заказа>|<YmdHis> в поясе сервера, без суммы.
|
||||
private static string $legacyTransactionIdPattern = '/^([1-9][0-9]*)\\|([0-9]{14})$/D';
|
||||
private static string $operationIdPattern = '/^[0-9]{1,20}$/D';
|
||||
private static int $orderLockTimeoutSeconds = 10;
|
||||
|
||||
private static string $moduleCode = 'payanyway';
|
||||
private static string $moduleVersion = '1.0.3';
|
||||
private static string $defaultCmsName = 'OpenCart';
|
||||
|
||||
private static int $clientEmailMaxLength = 64;
|
||||
private static int $clientPhoneMaxLength = 19;
|
||||
|
||||
private static string $defaultPaymentMethod = 'full_payment';
|
||||
private static string $defaultPaymentObject = 'commodity';
|
||||
|
||||
private static array $defaultCanceledOrderStatusIds = [7, 9, 16];
|
||||
|
||||
private static string $coreOrderMailFile = '/catalog/controller/mail/order.php';
|
||||
|
||||
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 bool $testMode;
|
||||
private bool $fiscalization;
|
||||
private string $receiptEmail;
|
||||
private string $paymentMethod;
|
||||
private string $paymentObject;
|
||||
/** @var int[] */
|
||||
private array $canceledOrderStatusIds;
|
||||
private string $cmsName;
|
||||
|
||||
private ?int $orderId = null;
|
||||
private array $orderInfo = [];
|
||||
|
||||
private function initializeModule(): void
|
||||
{
|
||||
$this->mntId = (int)$this->config->get('payment_payanyway_account');
|
||||
$this->integrityCode = (string)$this->config->get('payment_payanyway_integrity_code');
|
||||
$this->server = (string)$this->config->get('payment_payanyway_server');
|
||||
$this->vatRate = (string)$this->config->get('payment_payanyway_vat_rate');
|
||||
$this->currency = $this->config->get('payment_payanyway_currency');
|
||||
$this->currency = (string)$this->config->get('payment_payanyway_currency');
|
||||
$this->pendingOrderStatusId = (int)$this->config->get('payment_payanyway_pending_order_status_id');
|
||||
$this->successOrderStatusId = (int)$this->config->get('payment_payanyway_success_order_status_id');
|
||||
$this->errorOrderStatusId = (int)$this->config->get('payment_payanyway_error_order_status_id');
|
||||
$this->cmsModuleVersion = (string)$this->config->get('payment_payanyway_cms_module_version');
|
||||
$this->testMode = (bool)$this->config->get('payment_payanyway_test_mode');
|
||||
|
||||
$this->fiscalization = !$this->config->has('payment_payanyway_fiscalization')
|
||||
|| (bool)$this->config->get('payment_payanyway_fiscalization');
|
||||
$this->receiptEmail = $this->normalizeEmail($this->config->get('payment_payanyway_receipt_email')) ?? '';
|
||||
|
||||
$paymentMethod = (string)$this->config->get('payment_payanyway_payment_method');
|
||||
$this->paymentMethod = ('' !== $paymentMethod) ? $paymentMethod : self::$defaultPaymentMethod;
|
||||
$paymentObject = (string)$this->config->get('payment_payanyway_payment_object');
|
||||
$this->paymentObject = ('' !== $paymentObject) ? $paymentObject : self::$defaultPaymentObject;
|
||||
|
||||
$this->canceledOrderStatusIds = $this->config->has('payment_payanyway_canceled_order_status_ids')
|
||||
? array_map('intval', (array)$this->config->get('payment_payanyway_canceled_order_status_ids'))
|
||||
: self::$defaultCanceledOrderStatusIds;
|
||||
|
||||
$this->cmsName = $this->detectCmsName();
|
||||
}
|
||||
|
||||
private function validateOrder(): bool
|
||||
private function detectCmsName(): string
|
||||
{
|
||||
$this->orderId = $this->getOrderIdFromTransactionId() ?? $this->getOrderIdFromSession();
|
||||
if (null === $this->orderId) {
|
||||
return false;
|
||||
$cmsName = (string)$this->config->get('payment_payanyway_cms_name');
|
||||
if ('' !== $cmsName) {
|
||||
return $cmsName;
|
||||
}
|
||||
|
||||
$legacy = (string)$this->config->get('payment_payanyway_cms_module_version');
|
||||
if (preg_match('/^(.+?) v/', $legacy, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return self::$defaultCmsName;
|
||||
}
|
||||
|
||||
private function getCms(): string
|
||||
{
|
||||
return sprintf('%s v%s_PHP %s_%s v%s',
|
||||
$this->cmsName,
|
||||
defined('VERSION') ? constant('VERSION') : 'unknown',
|
||||
PHP_VERSION,
|
||||
self::$moduleCode,
|
||||
self::$moduleVersion
|
||||
);
|
||||
}
|
||||
|
||||
private function loadOrder(?int $orderId): void
|
||||
{
|
||||
$this->orderId = $orderId;
|
||||
$this->orderInfo = [];
|
||||
if (null === $orderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->load->model('checkout/order');
|
||||
$this->orderInfo = $this->model_checkout_order->getOrder($this->orderId);
|
||||
|
||||
return ([] !== $this->orderInfo);
|
||||
$this->orderInfo = $this->model_checkout_order->getOrder($orderId);
|
||||
}
|
||||
|
||||
private string $transactionIdDelimiter = '|';
|
||||
|
||||
private function validateString(string $value, int $maxLength): string
|
||||
{
|
||||
$maxLength = max(0, $maxLength);
|
||||
@@ -50,7 +132,7 @@ trait PayAnyWay
|
||||
private function sanitizeString(string $value): string
|
||||
{
|
||||
$decoded = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$cleaned = preg_replace('/[^\p{L}\p{N}\s.,()_№+-]/u', '', $decoded);
|
||||
$cleaned = preg_replace('/[^\p{L}\p{N}\s:.,()_№+-]/u', '', $decoded);
|
||||
$cleaned = str_replace(['&', '/', '\\', ';', '%', '#', '"', "'"], '', $cleaned);
|
||||
|
||||
return trim(preg_replace('/\s+/', ' ', $cleaned));
|
||||
@@ -61,14 +143,53 @@ trait PayAnyWay
|
||||
return number_format($price, 2, '.', '');
|
||||
}
|
||||
|
||||
private function getOrderIdFromTransactionId(): ?int
|
||||
private function toKopecks(float|int|string $amount): int
|
||||
{
|
||||
if (!isset($_REQUEST['MNT_TRANSACTION_ID'])) {
|
||||
$currencyValue = (float)($this->orderInfo['currency_value'] ?? 0);
|
||||
if ($currencyValue <= 0) {
|
||||
$currencyValue = 1.0;
|
||||
}
|
||||
|
||||
return (int)round((float)$amount * $currencyValue * 100);
|
||||
}
|
||||
|
||||
private function getOrderAmount(): string
|
||||
{
|
||||
return $this->formatPrice($this->toKopecks($this->orderInfo['total'] ?? 0) / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getCallbackRequestData(): array
|
||||
{
|
||||
return array_merge($_GET, $_POST);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{order_id: int, amount: ?string, issued_at: string}|null
|
||||
*/
|
||||
private function parseTransactionId(mixed $transactionId): ?array
|
||||
{
|
||||
if (!is_string($transactionId)) {
|
||||
return null;
|
||||
}
|
||||
$transactionIdData = explode($this->transactionIdDelimiter, $_REQUEST['MNT_TRANSACTION_ID'], 2);
|
||||
|
||||
return isset($transactionIdData[0]) ? (int)$transactionIdData[0] : null;
|
||||
if (preg_match(self::$transactionIdPattern, $transactionId, $matches)) {
|
||||
$kopecks = str_pad($matches[2], 3, '0', STR_PAD_LEFT);
|
||||
|
||||
return [
|
||||
'order_id' => (int)$matches[1],
|
||||
'amount' => substr($kopecks, 0, -2) . '.' . substr($kopecks, -2),
|
||||
'issued_at' => $matches[3],
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match(self::$legacyTransactionIdPattern, $transactionId, $matches)) {
|
||||
return ['order_id' => (int)$matches[1], 'amount' => null, 'issued_at' => $matches[2]];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getOrderIdFromSession(): ?int
|
||||
@@ -76,15 +197,129 @@ trait PayAnyWay
|
||||
return isset($this->session->data['order_id']) ? (int)$this->session->data['order_id'] : null;
|
||||
}
|
||||
|
||||
private function createTransactionId(int $orderId): string
|
||||
private function isPaidWithModule(): bool
|
||||
{
|
||||
$date = (new \DateTimeImmutable())->format('YmdHis');
|
||||
return $orderId . $this->transactionIdDelimiter . $date;
|
||||
$code = $this->orderInfo['payment_method']['code'] ?? '';
|
||||
|
||||
return is_string($code) && (self::$moduleCode === strtok($code, '.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $amount MNT_AMOUNT формы — с двумя знаками после точки
|
||||
* @throws \DateInvalidTimeZoneException
|
||||
*/
|
||||
private function createTransactionId(int $orderId, string $amount): string
|
||||
{
|
||||
$kopecks = ltrim(str_replace('.', '', $amount), '0');
|
||||
$issuedAt = new \DateTimeImmutable('now', new \DateTimeZone(self::$transactionIdZone));
|
||||
|
||||
return $orderId
|
||||
. self::$transactionIdDelimiter . (('' !== $kopecks) ? $kopecks : '0')
|
||||
. self::$transactionIdDelimiter . $issuedAt->format('YmdHis') . self::$transactionIdZone;
|
||||
}
|
||||
|
||||
private function addOrderHistory(int $orderId, int $orderStatusId, string $comment): void
|
||||
{
|
||||
$this->load->model('checkout/order');
|
||||
|
||||
// OpenCart 4.0.2.x: письмо о заказе (событие mail/order до addHistory) читает
|
||||
// shipping_method['name'] у заказа без доставки — Warning, а обработчик ядра выводит его
|
||||
// в ответ или уходит на error_page с exit: confirm() отвечает не JSON, колбэк — не XML,
|
||||
// статус не меняется. Глушится только это предупреждение, письмо уходит без способа доставки.
|
||||
$previousHandler = set_error_handler(static function (int $severity, string $message, string $file, int $line) use (&$previousHandler): bool {
|
||||
if ((E_WARNING === $severity)
|
||||
&& str_ends_with(str_replace('\\', '/', $file), self::$coreOrderMailFile)
|
||||
&& str_starts_with($message, 'Trying to access array offset on')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (null !== $previousHandler)
|
||||
&& (false !== $previousHandler(...self::errorHandlerArguments($previousHandler, [$severity, $message, $file, $line])));
|
||||
});
|
||||
|
||||
try {
|
||||
$this->model_checkout_order->addHistory($orderId, $orderStatusId, $comment);
|
||||
} finally {
|
||||
restore_error_handler();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Аргументы для прежнего обработчика ошибок под его сигнатуру: из файла со strict_types
|
||||
* int в string-параметр не приводится. Обработчик ядра 4.0.x — error(string $code, …,
|
||||
* string $line), 4.1 — (int $code, …, int $line).
|
||||
*
|
||||
* @param array{0: int, 1: string, 2: string, 3: int} $arguments
|
||||
* @return array<int, int|string>
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
private static function errorHandlerArguments(callable $handler, array $arguments): array
|
||||
{
|
||||
$parameters = (new \ReflectionFunction(\Closure::fromCallable($handler)))->getParameters();
|
||||
foreach ($parameters as $index => $parameter) {
|
||||
$type = $parameter->getType();
|
||||
if (isset($arguments[$index]) && ($type instanceof \ReflectionNamedType) && ('string' === $type->getName())) {
|
||||
$arguments[$index] = (string)$arguments[$index];
|
||||
}
|
||||
}
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
private function isAwaitingPayment(int $orderStatusId): bool
|
||||
{
|
||||
return in_array($orderStatusId, [0, $this->pendingOrderStatusId, $this->errorOrderStatusId], true);
|
||||
}
|
||||
|
||||
/** @return non-empty-string|null */
|
||||
private function getClientName(): ?string
|
||||
{
|
||||
$name = trim(($this->orderInfo['firstname'] ?? '') . ' ' . ($this->orderInfo['lastname'] ?? ''));
|
||||
return ('' !== $name) ? $name : null;
|
||||
}
|
||||
|
||||
/** @return non-empty-string|null */
|
||||
private function getClientEmail(): ?string
|
||||
{
|
||||
return $this->normalizeEmail($this->orderInfo['email'] ?? '');
|
||||
}
|
||||
|
||||
/** @return non-empty-string|null */
|
||||
private function getClientPhone(): ?string
|
||||
{
|
||||
$digits = preg_replace('/\D/', '', trim((string)($this->orderInfo['telephone'] ?? '')));
|
||||
return (('' !== $digits) && (strlen($digits) + 1 <= self::$clientPhoneMaxLength)) ? '+' . $digits : null;
|
||||
}
|
||||
|
||||
private function normalizeEmail(mixed $email): ?string
|
||||
{
|
||||
$email = trim((string)$email);
|
||||
if (('' === $email) || (strlen($email) > self::$clientEmailMaxLength) || (false === filter_var($email, FILTER_VALIDATE_EMAIL))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
/** @return non-empty-string|null */
|
||||
private function getClientName(): ?string
|
||||
{
|
||||
$name = trim(($this->orderInfo['firstname'] ?? '') . ' ' . ($this->orderInfo['lastname'] ?? ''));
|
||||
return ('' !== $name) ? $name : null;
|
||||
}
|
||||
|
||||
/** @return non-empty-string|null */
|
||||
private function getClientEmail(): ?string
|
||||
{
|
||||
$email = trim((string)($this->orderInfo['email'] ?? ''));
|
||||
return ('' !== $email) ? filter_var($email, FILTER_SANITIZE_EMAIL) : null;
|
||||
}
|
||||
|
||||
/** @return non-empty-string|null */
|
||||
private function getClientPhone(): ?string
|
||||
{
|
||||
$digits = preg_replace('/\D/', '', trim((string)($this->orderInfo['telephone'] ?? '')));
|
||||
return ('' !== $digits) ? '+' . $digits : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,41 @@ class PayAnyWay extends \Opencart\System\Engine\Model
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Блокировка заказа на время Pay URL и страниц модуля (GET_LOCK MySQL/MariaDB): два уведомления
|
||||
* подряд, confirm() и возврат покупателя не идут параллельно. Ядро (checkout/confirm) её не берёт:
|
||||
* заказ в статусе 0 оно правит без блокировки, поэтому Check/Pay URL такой заказ не проводят.
|
||||
* Снимается releaseOrderLock() или с закрытием соединения в конце запроса.
|
||||
*/
|
||||
public function acquireOrderLock(int $orderId, int $timeoutSeconds): bool
|
||||
{
|
||||
$query = $this->db->query("SELECT GET_LOCK('" . $this->db->escape($this->getOrderLockName($orderId)) . "', " . (int)$timeoutSeconds . ") AS acquired");
|
||||
|
||||
return 1 === (int)($query->row['acquired'] ?? 0);
|
||||
}
|
||||
|
||||
public function releaseOrderLock(int $orderId): void
|
||||
{
|
||||
$this->db->query("SELECT RELEASE_LOCK('" . $this->db->escape($this->getOrderLockName($orderId)) . "')");
|
||||
}
|
||||
|
||||
/** Заказ когда-либо получал этот статус — по истории заказа. */
|
||||
public function hasOrderStatusInHistory(int $orderId, int $orderStatusId): bool
|
||||
{
|
||||
$query = $this->db->query("SELECT 1 FROM `" . DB_PREFIX . "order_history`
|
||||
WHERE `order_id` = '" . (int)$orderId . "'
|
||||
AND `order_status_id` = '" . (int)$orderStatusId . "'
|
||||
LIMIT 1");
|
||||
|
||||
return $query->num_rows > 0;
|
||||
}
|
||||
|
||||
/** Имя блокировки — не длиннее 64 символов и своё для каждой БД магазина. */
|
||||
private function getOrderLockName(int $orderId): string
|
||||
{
|
||||
return 'payanyway_' . substr(md5(DB_DATABASE . '.' . DB_PREFIX), 0, 16) . '_' . $orderId;
|
||||
}
|
||||
|
||||
private function isModuleEnabled(): bool
|
||||
{
|
||||
return (bool)$this->config->get('payment_payanyway_status');
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<input type="hidden" name="MNT_SUCCESS_URL" value="{{ MNT_SUCCESS_URL }}"/>
|
||||
<input type="hidden" name="MNT_FAIL_URL" value="{{ MNT_FAIL_URL }}"/>
|
||||
<input type="hidden" name="MNT_RETURN_URL" value="{{ MNT_RETURN_URL }}"/>
|
||||
<input type="hidden" name="moneta.locale" value="{{ moneta_locale }}"/>
|
||||
<input type="hidden" name="MNT_CMS" value="{{ MNT_CMS }}"/>
|
||||
<div class="buttons">
|
||||
<div class="{% if ap_use %} text-center {% else %} pull-right {% endif %}">
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"code": "payanyway",
|
||||
"name": "PayAnyWay",
|
||||
"version": "1.0.1",
|
||||
"author": "PayAnyWay",
|
||||
"name": "PayAnyWay (МОНЕТА)",
|
||||
"version": "1.0.3",
|
||||
"author": "PayAnyWay (МОНЕТА)",
|
||||
"link": "https://www.payanyway.ru/"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user