diff --git a/README.md b/README.md index d4b4e49..952386f 100755 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@ ![PHP](https://img.shields.io/badge/PHP-8.0%2B-purple) ![License](https://img.shields.io/badge/License-MIT-green) -> **📦 Версия модуля: 1.0.2** +> **📦 Версия модуля: 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 ``` @@ -49,20 +50,58 @@ - **«Редактировать»** — для перехода к настройкам 4. ✏️ Заполните соответствующие поля в настройках способа оплаты: -| Поле | Описание | -|-------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------| -| **Статус** | Включите или выключите модуль оплаты | -| **Номер счёта** | Ван номер расширенного счёта в системе [MONETA.RU](https://moneta.ru/) | -| **Код проверки целостности данных** | Код проверки целостности данных, указанный в настройках расширенного счёта в системе [MONETA.RU](https://moneta.ru/) | -| **Сервер платежей** | `Боевой` / `Тестовый` | -| **НДС** | Выберите из списка | -| **Валюта платежа** | `RUB` | -| **Статусы заказа** | Выберите соответствующие статусы заказа из списка | -| **Обрабатываемый** | Укажите статус заказа, который выставляется при его создании | -| **Успешный** | Укажите статус заказа, который выставляется при успешной оплате | -| **Неуспешный** | Укажите статус заказа, который выставляется при ошибке оплаты | -| **Регион отображения** | Географическая зона, в которой будет отображаться способ оплаты | -| **Порядок сортировки** | Определяет порядок отображения платежного метода в списке методов оплаты при оформлении заказа. Чем меньше число, тем выше позиция в списке | +| Поле | Описание | +|-------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Статус** | Включите или выключите модуль оплаты | +| **Номер счёта** | Ваш номер расширенного счёта в системе [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 или файловый менеджер и снова нажмите **«Установить»**. --- @@ -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` | +| **HTTP method\*** | `GET` или `POST` (требуется настройка) | | **Можно переопределять настройки в URL** | `Да` | | **Подпись формы оплаты обязательна** | `Да` | | **Код проверки целостности данных** | `ваш_код` (произвольный набор символов) | @@ -88,13 +127,23 @@ > ⚠️ **Важно!** Для кириллического домена PayURL и CheckURL должны быть указаны в кодировке [Punycode](https://2ip.ru/punycode/). -> ⚠️ **Важно!** В OpenCart 4 при возврате с сайта платежной системы (эквайринга) авторизация пользователя чаще всего слетает из-за конфликта cookie-атрибута SameSite. -Для решения проблемы выполните следующие действия: -> 1. Изменение настроек сессии в панели управления OpenCart по умолчанию использует для кук параметр `Lax` или `Strict`, - что вызывает разрыв сессии при возврате с внешнего сервера Настройки `SameSite`. - Перейдите в административную панель сайта. Откройте `Система (System)` > `Настройки (Settings)` и отредактируйте ваш магазин. -> 2. Перейдите на вкладку `Сервер (Server)`. Найдите настройку `Сессионный файл Cookie (Session Samesite Cookie)`. - Измените значение с `Lax/Strict` на `None (Нет)` (это разрешит отправку куки при переходе с другого сайта). +### \* 📨 Уведомления методом 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. --- diff --git a/admin/controller/payment/payanyway.php b/admin/controller/payment/payanyway.php index 5939e39..a5aeca3 100755 --- a/admin/controller/payment/payanyway.php +++ b/admin/controller/payment/payanyway.php @@ -10,10 +10,12 @@ namespace 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.2'; + 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', @@ -28,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 = []; /** @@ -42,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(); @@ -57,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'); @@ -100,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'); @@ -122,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(); @@ -154,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 @@ -164,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 @@ -171,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'); @@ -190,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); } @@ -205,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'] ?? ''; @@ -229,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'); + } + + /** Строка с формы без экранирования 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 htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); + return array_map('intval', (array)$this->config->get('payment_payanyway_canceled_order_status_ids')); + } + + /** + * @param string[] $values + * @return array + */ + private function getOptions(array $values, string $labelPrefix): array + { + $result = []; + foreach ($values as $value) { + $result[$value] = $this->language->get($labelPrefix . $value); + } + + return $result; } /** @return array */ @@ -308,23 +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|PHP %s|%s v%s', - $cmsName, - $cmsVersion, - PHP_VERSION, - self::MODULE_NAME, - self::MODULE_VERSION - ); + return $this->detectCmsByPatterns($textFooter); } private function detectCmsByPatterns($textFooter): string @@ -350,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; + } } diff --git a/admin/language/en-gb/payment/payanyway.php b/admin/language/en-gb/payment/payanyway.php index 1794228..5a74f11 100755 --- a/admin/language/en-gb/payment/payanyway.php +++ b/admin/language/en-gb/payment/payanyway.php @@ -1,6 +1,6 @@ payanyway_post_route in Extensions → Startup. 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.'; diff --git a/admin/language/ru-ru/payment/payanyway.php b/admin/language/ru-ru/payment/payanyway.php index e7ee6f2..d961bcc 100755 --- a/admin/language/ru-ru/payment/payanyway.php +++ b/admin/language/ru-ru/payment/payanyway.php @@ -1,6 +1,6 @@ payanyway_post_route в Расширения → Startup. Он же снимает оплаченный заказ из сессии покупателя, чтобы следующая покупка в том же браузере создала новый заказ.'; +$_['warning_receipt_email'] = 'Фискализация чеков включена, а «Email для чеков» не задан: покупателю без e-mail и телефона чек не будет пробит. Укажите адрес в настройках ниже или выключите фискализацию, если кассовый сервис не подключён.'; diff --git a/admin/view/template/payment/payanyway.twig b/admin/view/template/payment/payanyway.twig index 3416193..7063283 100755 --- a/admin/view/template/payment/payanyway.twig +++ b/admin/view/template/payment/payanyway.twig @@ -22,6 +22,12 @@
+
+ {{ warning_startup }} +
+
+ {{ warning_receipt_email }} +
@@ -68,8 +74,13 @@
- +
+ + +
@@ -84,15 +95,85 @@
- {# НДС #} + {# Тестовый режим #}
- +
- +
+ + +
+
+
+ + {# Фискализация чеков #} +
+ +
+
+ + +
+
+
+ + {# Поля чека — только при фискализации. Скрываются, а не блокируются: заблокированное + поле браузер не отправляет, и значение потерялось бы. #} +
+ {# Email для чеков #} +
+ +
+ +
+
+ + {# НДС #} +
+ +
+ +
+
+ + {# Признак способа расчёта #} +
+ +
+ +
+
+ + {# Признак предмета расчёта #} +
+ +
+ +
@@ -187,6 +268,29 @@
+ + {# canceled #} +
+ +
+
+ {% for order_status in order_statuses %} +
+ + +
+ {% endfor %} +
+
+
@@ -216,6 +320,20 @@ + {# URL для уведомлений #} +
+ +
+
+ + +
+
+
+ {# sort-order #}
+ {{ footer }} diff --git a/catalog/controller/payment/callback.php b/catalog/controller/payment/callback.php index cc3712e..3870158 100755 --- a/catalog/controller/payment/callback.php +++ b/catalog/controller/payment/callback.php @@ -20,57 +20,82 @@ class Callback extends \Opencart\System\Engine\Controller public const DESCRIPTION_MAX_LENGTH = 500; private const ITEM_NAME_MAX_LENGTH = 128; - private const ITEM_DEFAULT_VAT_TAG = '1105'; - private const DEFAULT_PAYMENT_METHOD = 'full_payment'; - private const DEFAULT_PAYMENT_OBJECT = 'commodity'; - private const SHIPPING_PAYMENT_OBJECT = 'service'; + private const CLIENT_NAME_MAX_LENGTH = 256; + private const CHECK_CLIENT_PHONE_MAX_LENGTH = 13; + private const SERVICE_PAYMENT_OBJECT = 'service'; + private const VOUCHER_PAYMENT_METHOD = 'advance'; + private const VOUCHER_PAYMENT_OBJECT = 'payment'; + private const LEGACY_TRANSACTION_MAX_AGE_DAYS = 14; + private const PAYMENT_ITEM_NAME = 'Оплата заказа №'; private const DEFAULT_MEASURE = 'unit'; - private const CANCEL_ORDER_STATUS_IDS = [7, 9, 16]; - - private int $mntId; - private string $integrityCode; - private string $server; - private string $vatRate; - private string $currency; - private int $pendingOrderStatusId; - private int $successOrderStatusId; - private int $errorOrderStatusId; - private int $geoZoneId; - private int $sortOrder; - private string $cmsModuleVersion; + private const JSON_NUMBER_PREFIX = '__payanyway_number:'; + private const JSON_NUMBER_KEYS = ['amount', 'price']; private array $callbackData = []; - private ?int $orderId; - private array $orderInfo = []; public function __construct(\Opencart\System\Engine\Registry $registry) { parent::__construct($registry); - if (!$this->validateOrder()) { - $this->sendResponse(self::FAIL_RESPONSE); - } - $this->initializeModule(); - $this->initializeCallbackData(); } /** @throws \Exception */ public function index(): void { + try { + $this->callbackData = $this->getCallbackData($this->getCallbackRequestData()); + } catch (\InvalidArgumentException $exception) { + $this->log->write('PayAnyWay callback: ' . $exception->getMessage()); + $this->sendResponse(self::FAIL_RESPONSE); + } + $this->validateCallback(); - ($this->callbackData['MNT_COMMAND'] === 'CHECK') - ? $this->handleCheckCallback() - : $this->handlePayCallback(); + $isCheck = 'CHECK' === $this->callbackData['MNT_COMMAND']; + if (!$this->hasRequiredOperationId()) { + $this->log->write('PayAnyWay callback: MNT_OPERATION_ID is missing or not numeric'); + $this->sendResponse(self::FAIL_RESPONSE); + } + + if (!$this->checkSignature($this->callbackData)) { + $this->log->write('PayAnyWay callback: MNT_SIGNATURE does not match'); + $this->sendResponse(self::FAIL_RESPONSE); + } + + $orderId = $this->parseTransactionId($this->callbackData['MNT_TRANSACTION_ID'])['order_id'] ?? null; + if (null === $orderId) { + $this->log->write('PayAnyWay callback: MNT_TRANSACTION_ID has unexpected format'); + $this->sendResponse(self::FAIL_RESPONSE); + } + + $this->load->model('extension/payanyway/payment/payanyway'); + if (!$isCheck && !$this->model_extension_payanyway_payment_payanyway->acquireOrderLock($orderId, self::$orderLockTimeoutSeconds)) { + $this->log->write('PayAnyWay callback: order is locked by another request'); + $this->sendResponse(self::FAIL_RESPONSE); + } + + $this->loadOrder($orderId); + if ([] === $this->orderInfo) { + $this->sendResponse($isCheck ? $this->buildUnknownOrderResponse() : self::FAIL_RESPONSE); + } + + if (!$this->isPaidWithModule()) { + $this->log->write('PayAnyWay callback: order is not paid with PayAnyWay'); + $this->sendResponse($isCheck ? $this->buildUnknownOrderResponse() : self::FAIL_RESPONSE); + } + + $isCheck ? $this->handleCheckCallback() : $this->handlePayCallback(); } - private function initializeCallbackData(): void + /** + * @param array $requestData + * @return array + */ + private function getCallbackData(array $requestData): array { - $requestData = ($_SERVER['REQUEST_METHOD'] === 'POST') ? $_POST : $_GET; - - $this->callbackData = [ + return [ 'MNT_COMMAND' => $this->getOptionalStringWithDefault($requestData, 'MNT_COMMAND'), 'MNT_ID' => $this->getRequiredString($requestData, 'MNT_ID'), 'MNT_TRANSACTION_ID' => $this->getRequiredString($requestData, 'MNT_TRANSACTION_ID'), @@ -89,15 +114,18 @@ class Callback extends \Opencart\System\Engine\Controller $this->sendResponse(self::FAIL_RESPONSE); } - if ($this->mntId !== (int)$this->callbackData['MNT_ID']) { + if (!$this->isOwnAccount()) { + $this->sendResponse(self::FAIL_RESPONSE); + } + + if ($this->callbackData['MNT_TEST_MODE'] !== ($this->testMode ? '1' : '0')) { + $this->log->write('PayAnyWay callback: MNT_TEST_MODE does not match module settings'); $this->sendResponse(self::FAIL_RESPONSE); } } /** - * @param array|string $response - * @param int $statusCode - * @return void + * @param array{data: string, contentType: string}|string $response */ private function sendResponse(array|string $response, int $statusCode = 200): void { @@ -116,24 +144,25 @@ class Callback extends \Opencart\System\Engine\Controller /** @throws \Exception */ private function handleCheckCallback(): void { - if (!$this->checkSignature($this->callbackData)) { - $this->sendResponse(self::FAIL_RESPONSE); + if (!$this->isOrderPaid()) { + $this->rejectPaymentMismatch(); } [$resultCode, $description] = $this->determineCheckResult(); $responseData = $this->buildBaseResponseData($resultCode, $description); - $products = $this->getAccountOrderProducts(); - $deliveryInfo = $this->getDeliveryInfo(); + if ($this->fiscalization) { + $responseData['client'] = $this->getClientInfo(true); + $responseData['items'] = $this->getCheckItems($this->getReceiptLines()); - $responseData['client'] = $this->getClientInfo(); - $responseData['items'] = $this->getCheckItems($products, $deliveryInfo); + $this->sendResponse($this->buildJsonResponse($responseData)); + } - $this->sendResponse($this->buildJsonResponse($responseData)); + $this->sendResponse($this->buildXmlResponse($responseData + $this->getResponseAttributes())); } /** - * @return array + * @return array{0: int, 1: string} * @throws \Exception */ private function determineCheckResult(): array @@ -144,15 +173,15 @@ class Callback extends \Opencart\System\Engine\Controller } $orderStatus = $this->getOrderStatusById($orderStatusId); - if (empty($this->callbackData['MNT_AMOUNT'])) { + if ('' === $this->callbackData['MNT_AMOUNT']) { return [100, "Order status is {$orderStatus}"]; } - if ($this->successOrderStatusId === $orderStatusId) { + if ($this->isOrderPaid()) { return [200, 'Order Paid']; } - if (in_array($orderStatusId, self::CANCEL_ORDER_STATUS_IDS, true)) { + if (in_array($orderStatusId, $this->canceledOrderStatusIds, true)) { return [500, "Order status is {$orderStatus}"]; } @@ -162,60 +191,133 @@ class Callback extends \Opencart\System\Engine\Controller /** @throws \Exception */ private function handlePayCallback(): void { - $shopData = [ - 'MNT_ID' => $this->mntId, - 'MNT_TRANSACTION_ID' => $this->callbackData['MNT_TRANSACTION_ID'], - 'MNT_OPERATION_ID' => $this->callbackData['MNT_OPERATION_ID'], - 'MNT_AMOUNT' => $this->formatPrice((float)($this->orderInfo['total'] ?? 0)), - 'MNT_CURRENCY_CODE' => $this->orderInfo['currency_code'] ?? 'RUB', - 'MNT_SUBSCRIBER_ID' => $this->callbackData['MNT_SUBSCRIBER_ID'], - 'MNT_TEST_MODE' => $this->callbackData['MNT_TEST_MODE'], - ]; - - if (!$this->checkSignature($shopData)) { - $this->sendResponse(self::FAIL_RESPONSE); - } - $isPayedOrder = $this->isOrderPaid(); if (!$isPayedOrder) { + $this->rejectPaymentMismatch(); + $this->rejectCanceledOrder(); + $this->load->language('extension/payanyway/payment/payanyway'); - $this->addOrderHistory($this->orderId, $this->successOrderStatusId, $this->language->get('text_payment_completed'),); + $this->addOrderHistory($this->orderId, $this->successOrderStatusId, $this->getPaymentCompletedComment($this->callbackData['MNT_OPERATION_ID'])); } + $this->model_extension_payanyway_payment_payanyway->releaseOrderLock($this->orderId); - $resultCode = 200; $description = $isPayedOrder ? 'Order already paid' : 'Order success paid'; - $responseData = $this->buildBaseResponseData($resultCode, $description); + $responseData = $this->buildBaseResponseData(200, $description); - $products = $this->getAccountOrderProducts(); - $deliveryInfo = $this->getDeliveryInfo(); + $this->sendResponse($this->buildXmlResponse($responseData + $this->getResponseAttributes())); + } - $payInventoryJson = $this->getPayInventoryJson($products, $deliveryInfo); - $responseData['inventory'] = $payInventoryJson ?: null; - $clientInfo = $this->getClientInfo(); - $responseData['client'] = $clientInfo - ? json_encode([$clientInfo], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) - : null; - - $this->sendResponse($this->buildXmlResponse($responseData)); + /** Уведомление не согласовано с номером транзакции или заказом — FAIL и запись в лог. */ + private function rejectPaymentMismatch(): void + { + $mismatch = $this->getPaymentMismatch(); + if (null !== $mismatch) { + $this->log->write('PayAnyWay callback: ' . $mismatch); + $this->sendResponse(self::FAIL_RESPONSE); + } + } + + /** + * Заказ отменили до оплаты (менеджер, аннулирование ядром при правке заказа) — оплата его не + * проводит: подписанный 500 без атрибутов прекращает повторы, номер операции в логе нужен + * администратору для возврата в кабинете MONETA.RU. Как Check URL и остальные модули. + */ + private function rejectCanceledOrder(): void + { + $response = $this->buildCanceledOrderPayResponse(); + if (null !== $response) { + $this->log->write('PayAnyWay callback: order ' . $this->orderId . ' is canceled, payment ' . $this->callbackData['MNT_OPERATION_ID'] . ' must be refunded'); + $this->sendResponse($response); + } + } + + /** + * @return array{data: string, contentType: string}|null + * @throws \Exception + */ + private function buildCanceledOrderPayResponse(): ?array + { + $orderStatusId = (int)($this->orderInfo['order_status_id'] ?? 0); + if (!in_array($orderStatusId, $this->canceledOrderStatusIds, true)) { + return null; + } + + return $this->buildXmlResponse($this->buildBaseResponseData(500, 'Order status is ' . $this->getOrderStatusById($orderStatusId)), false); + } + + private function getPaymentMismatch(): ?string + { + // Форма уходит на MONETA.RU только после confirm(), который переводит заказ в «Ожидание». + // Заказ в статусе 0 ядро правит без блокировки (checkout/confirm) — его не проводим. + if (0 === (int)($this->orderInfo['order_status_id'] ?? 0)) { + return 'order was not confirmed before payment'; + } + + $transaction = $this->parseTransactionId($this->callbackData['MNT_TRANSACTION_ID']); + if ((null === $transaction) || ($transaction['order_id'] !== $this->orderId)) { + return 'MNT_TRANSACTION_ID has unexpected format'; + } + + if (null === $transaction['amount']) { + $issuedAt = \DateTimeImmutable::createFromFormat('!YmdHis', $transaction['issued_at']); + if ((false === $issuedAt) || ($issuedAt->modify('+' . self::LEGACY_TRANSACTION_MAX_AGE_DAYS . ' days') < new \DateTimeImmutable())) { + return 'MNT_TRANSACTION_ID of the previous format has expired'; + } + } elseif (('' !== $this->callbackData['MNT_AMOUNT']) && ($this->callbackData['MNT_AMOUNT'] !== $transaction['amount'])) { + return 'MNT_AMOUNT does not match MNT_TRANSACTION_ID'; + } elseif ($transaction['amount'] !== $this->getOrderAmount()) { + return 'order amount changed after the payment form was issued'; + } + + $isCheck = 'CHECK' === $this->callbackData['MNT_COMMAND']; + if ((!$isCheck || ('' !== $this->callbackData['MNT_AMOUNT'])) && !$this->isOrderAmountMatched()) { + return 'MNT_AMOUNT or MNT_CURRENCY_CODE does not match the order'; + } + + return null; + } + + private function getPaymentCompletedComment(string $operationId): string + { + $comment = $this->language->get('text_payment_completed'); + if (preg_match(self::$operationIdPattern, $operationId)) { + $comment .= '. ' . sprintf($this->language->get('text_payment_operation'), $operationId); + } + + return $comment; + } + + /** + * @return array + * @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 * @throws \Exception */ private function buildBaseResponseData(int $resultCode, string $description): array { - $orderTotal = (float)($this->orderInfo['total'] ?? 0); - $data = [ 'MNT_ID' => $this->mntId, 'MNT_TRANSACTION_ID' => $this->callbackData['MNT_TRANSACTION_ID'], 'MNT_RESULT_CODE' => $resultCode, 'MNT_DESCRIPTION' => $this->validateString($description, self::DESCRIPTION_MAX_LENGTH), - 'MNT_AMOUNT' => $this->formatPrice($orderTotal), + 'MNT_AMOUNT' => $this->getOrderAmount(), 'MNT_CURRENCY_CODE' => $this->callbackData['MNT_CURRENCY_CODE'], + 'MNT_CMS' => $this->getCms(), ]; $signatureSource = $resultCode . $this->mntId . $this->callbackData['MNT_TRANSACTION_ID'] . $this->integrityCode; @@ -224,9 +326,49 @@ class Callback extends \Opencart\System\Engine\Controller return $data; } + /** + * @return array{data: string, contentType: string} + * @throws \Exception + */ + private function buildUnknownOrderResponse(): array + { + $responseData = $this->buildBaseResponseData(500, 'Order not found'); + unset($responseData['MNT_AMOUNT']); + + return $this->buildXmlResponse($responseData, false); + } + + /** + * Оплата уже проведена: заказ в «Успешном» статусе или был в нём раньше — администратор мог + * перевести оплаченный заказ дальше (Complete, Refunded, Canceled). Повтор уведомления такой + * заказ не трогает. + */ private function isOrderPaid(): bool { - return $this->successOrderStatusId === (int)$this->orderInfo['order_status_id']; + if ($this->successOrderStatusId === (int)($this->orderInfo['order_status_id'] ?? 0)) { + return true; + } + + $this->load->model('extension/payanyway/payment/payanyway'); + + return $this->model_extension_payanyway_payment_payanyway->hasOrderStatusInHistory((int)$this->orderId, $this->successOrderStatusId); + } + + private function isOwnAccount(): bool + { + return (string)$this->mntId === $this->callbackData['MNT_ID']; + } + + private function hasRequiredOperationId(): bool + { + return ('CHECK' === $this->callbackData['MNT_COMMAND']) + || (1 === preg_match(self::$operationIdPattern, $this->callbackData['MNT_OPERATION_ID'])); + } + + private function isOrderAmountMatched(): bool + { + return ($this->callbackData['MNT_AMOUNT'] === $this->getOrderAmount()) + && ($this->callbackData['MNT_CURRENCY_CODE'] === (string)($this->orderInfo['currency_code'] ?? '')); } /** @param array $data */ @@ -253,130 +395,328 @@ class Callback extends \Opencart\System\Engine\Controller return hash_equals(md5($signatureString), $this->callbackData['MNT_SIGNATURE']); } - private function getDeliveryInfo(): ?array + /** + * @return array цены в копейках + * @throws \Exception + */ + private function getReceiptLines(): array + { + $target = $this->toKopecks($this->orderInfo['total'] ?? 0); + if ($target <= 0) { + return []; + } + + $lines = $this->balanceReceiptLines($this->collectReceiptLines(), $target); + if ([] !== $lines) { + return $lines; + } + + return [ + [ + 'name' => self::PAYMENT_ITEM_NAME . $this->orderId, + 'unit' => $target, + 'quantity' => 1, + 'pm' => $this->paymentMethod, + 'po' => $this->paymentObject, + 'goods' => true, + ], + ]; + } + + /** + * @return array + * @throws \Exception + */ + private function collectReceiptLines(): array { $this->load->model('account/order'); - foreach ($this->model_account_order->getTotals($this->orderId) as $orderTotal) { - if (($orderTotal['code'] ?? '') === 'shipping') { - return $orderTotal; + + $lines = []; + foreach ($this->model_account_order->getProducts($this->orderId) as $product) { + $unit = $this->toKopecks((float)($product['price'] ?? 0) + (float)($product['tax'] ?? 0)); + $quantity = (int)($product['quantity'] ?? 0); + if (($unit > 0) && ($quantity > 0)) { + $lines[] = [ + 'name' => (string)($product['name'] ?? ''), + 'unit' => $unit, + 'quantity' => $quantity, + 'pm' => $this->paymentMethod, + 'po' => $this->paymentObject, + 'goods' => true, + ]; } } - return null; + foreach ($this->getOrderVouchers() as $voucher) { + $unit = $this->toKopecks($voucher['amount'] ?? 0); + if ($unit > 0) { + $lines[] = [ + 'name' => (string)($voucher['description'] ?? ''), + 'unit' => $unit, + 'quantity' => 1, + 'pm' => self::VOUCHER_PAYMENT_METHOD, + 'po' => self::VOUCHER_PAYMENT_OBJECT, + 'goods' => false, + ]; + } + } + + foreach ($this->model_account_order->getTotals($this->orderId) as $orderTotal) { + if (in_array($orderTotal['code'] ?? '', ['sub_total', 'tax', 'total'], true)) { + continue; + } + + $unit = $this->toKopecks($orderTotal['value'] ?? 0); + if ($unit > 0) { + $lines[] = [ + 'name' => (string)($orderTotal['title'] ?? ''), + 'unit' => $unit, + 'quantity' => 1, + 'pm' => $this->paymentMethod, + 'po' => self::SERVICE_PAYMENT_OBJECT, + 'goods' => false, + ]; + } + } + + return $lines; } - private function getClientInfo(): array + /** + * Проданные в заказе подарочные сертификаты. В OpenCart 4.1.0.0 сертификаты убраны из ядра + * вместе с getVouchers() модели заказа — там их нет + * + * @return array> + */ + 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 $lines + * @return array + */ + 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 $lines + * @param array $totals суммы строк в копейках + * @param array $adjustable индексы строк, которые можно менять + * @return array + */ + 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 + */ + 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 + */ + private function getClientInfo(bool $withReceiptEmail): array + { + $email = $this->getClientEmail(); + $phone = $this->getClientPhone(); + if ((null === $email) && (null === $phone) && $withReceiptEmail && ('' !== $this->receiptEmail)) { + $email = $this->receiptEmail; + } + if ((null === $email) && (null === $phone)) { + return []; + } + $clientInfo = []; $name = $this->getClientName(); if (null !== $name) { - $clientInfo['name'] = $name; + $clientInfo['name'] = $this->validateString($name, self::CLIENT_NAME_MAX_LENGTH); } - $email = $this->getClientEmail(); if (null !== $email) { $clientInfo['email'] = $email; } - $phoneNumber = $this->getClientPhone(); - if (null !== $phoneNumber) { - $clientInfo['phone'] = $phoneNumber; + if (null !== $phone) { + $clientInfo['phone'] = $phone; } return $clientInfo; } - private function getCheckItems(array $products, ?array $deliveryInfo): array + /** + * @param array $lines + * @return array> + */ + private function getCheckItems(array $lines): array { - $checkItems = array(); - foreach ($products as $product) { - $checkItems[] = array( - 'name' => $this->validateString($product['name'] ?? '', self::ITEM_NAME_MAX_LENGTH), - 'price' => $this->formatPrice((float)($product['price'] ?? 0)), - 'quantity' => $this->formatQuantity((float)($product['quantity'] ?? 0)), + $checkItems = []; + foreach ($lines as $line) { + $checkItems[] = [ + 'name' => $this->validateString($line['name'], self::ITEM_NAME_MAX_LENGTH), + 'price' => self::JSON_NUMBER_PREFIX . $this->formatPrice($line['unit'] / 100), + 'quantity' => $line['quantity'], 'vat' => $this->getVat($this->vatRate), 'measure' => self::DEFAULT_MEASURE, - 'paymentMethod' => self::DEFAULT_PAYMENT_METHOD, - 'paymentObject' => self::DEFAULT_PAYMENT_OBJECT, - ); + 'paymentMethod' => $line['pm'], + 'paymentObject' => $line['po'], + ]; } - if (null === $deliveryInfo) { - return $checkItems; - } - - $checkItems[] = array( - 'name' => $this->validateString($deliveryInfo['title'] ?? '', self::ITEM_NAME_MAX_LENGTH), - 'price' => $this->formatPrice((float)($deliveryInfo['value'] ?? 0)), - 'quantity' => 1, - 'vat' => $this->getVat($this->vatRate), - 'measure' => self::DEFAULT_MEASURE, - 'paymentMethod' => self::DEFAULT_PAYMENT_METHOD, - 'paymentObject' => self::SHIPPING_PAYMENT_OBJECT, - ); - return $checkItems; } - private function getPayInventoryJson(array $products, ?array $deliveryInfo): false|string + /** + * @param array $lines + */ + private function getInventoryJson(array $lines): false|string { - if (empty($products) && empty($deliveryInfo)) { + if ([] === $lines) { return false; } $inventory = []; - foreach ($products as $product) { - $inventory[] = [ - 'name' => $this->validateString($product['name'] ?? '', self::ITEM_NAME_MAX_LENGTH), - 'price' => $this->formatPrice((float)($product['price'] ?? 0)), - 'quantity' => $this->formatQuantity((float)($product['quantity'] ?? 0)), - 'vatTag' => $this->vatRate, - 'pm' => self::DEFAULT_PAYMENT_METHOD, - 'po' => self::DEFAULT_PAYMENT_OBJECT, - 'measure' => self::DEFAULT_MEASURE, - ]; - } - - if (null !== $deliveryInfo) { - $inventory[] = [ - 'name' => $this->validateString($deliveryInfo['title'] ?? '', self::ITEM_NAME_MAX_LENGTH), - 'price' => $this->formatPrice((float)($deliveryInfo['value'] ?? 0)), - 'quantity' => '1', - 'vatTag' => $this->vatRate, - 'pm' => self::DEFAULT_PAYMENT_METHOD, - 'po' => self::SHIPPING_PAYMENT_OBJECT, + foreach ($lines as $line) { + $position = [ + 'name' => $this->validateString($line['name'], self::ITEM_NAME_MAX_LENGTH), + 'price' => $this->formatPrice($line['unit'] / 100), + 'quantity' => (string)$line['quantity'], 'measure' => self::DEFAULT_MEASURE, ]; + if ($this->fiscalization) { + $position += [ + 'vatTag' => $this->vatRate, + 'pm' => $line['pm'], + 'po' => $line['po'], + ]; + } + $inventory[] = $position; } return json_encode($inventory, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } - /** - * @param int $orderStatusId - * @return string - * @throws \Exception - */ - public function getOrderStatusById(int $orderStatusId): string + /** @throws \Exception */ + private function getOrderStatusById(int $orderStatusId): string { $this->load->model('localisation/order_status'); - /**@var array $orderStatuses */ + /** @var array $orderStatuses */ $orderStatuses = $this->model_localisation_order_status->getOrderStatuses(); - $orderStatus = 'unknown'; foreach ($orderStatuses as $orderStatus) { - $statusId = isset($orderStatus['order_status_id']) ? (int)$orderStatus['order_status_id'] : null; - if ($statusId === $orderStatusId) { - $orderStatus = $orderStatus['name']; - break; + if ((int)($orderStatus['order_status_id'] ?? 0) === $orderStatusId) { + return (string)$orderStatus['name']; } } - return $orderStatus; - } - - private function formatQuantity(float $quantity): string - { - $quantityString = number_format($quantity, 3, '.', ''); - return rtrim(rtrim($quantityString, '0'), '.'); + return 'unknown'; } /** @@ -409,9 +749,51 @@ class Callback extends \Opencart\System\Engine\Controller /** * @param array $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 $data + * @return array{data: string, contentType: string} * @throws \Exception */ - private function buildXmlResponse(array $data): array + private function buildXmlResponse(array $data, bool $withAttributes = true): array { $dom = new \DOMDocument('1.0', 'UTF-8'); $dom->formatOutput = false; @@ -427,69 +809,71 @@ class Callback extends \Opencart\System\Engine\Controller 'MNT_AMOUNT', 'MNT_CURRENCY_CODE', 'MNT_SIGNATURE', + 'MNT_CMS', ]; foreach ($requiredFields as $field) { if (isset($data[$field]) && ($data[$field] !== '')) { - $root->appendChild($dom->createElement($field, (string)$data[$field])); + $root->appendChild($this->createTextElement($dom, $field, (string)$data[$field])); } } - $attributes = $dom->createElement('MNT_ATTRIBUTES'); - $root->appendChild($attributes); + if ($withAttributes) { + $attributes = $dom->createElement('MNT_ATTRIBUTES'); + $root->appendChild($attributes); - $attributeMap = [ - 'INVENTORY' => $data['inventory'] ?? null, - 'CLIENT' => $data['client'] ?? null, - 'SNO' => $data['sno'] ?? null, - 'DELIVERY' => $data['delivery'] ?? null, - ]; + $attributeMap = [ + 'INVENTORY' => $data['inventory'] ?? null, + 'CLIENT' => $data['client'] ?? null, + 'CUSTOMER' => $data['customer'] ?? null, + 'PHONE' => $data['phone'] ?? null, + ]; - foreach ($attributeMap as $key => $value) { - if (!empty($value)) { - $attrElement = $dom->createElement('ATTRIBUTE'); - $attrElement->appendChild($dom->createElement('KEY', $key)); - $attrElement->appendChild($dom->createElement('VALUE', (string)$value)); - $attributes->appendChild($attrElement); + foreach ($attributeMap as $key => $value) { + if (!empty($value)) { + $attrElement = $dom->createElement('ATTRIBUTE'); + $attrElement->appendChild($this->createTextElement($dom, 'KEY', $key)); + $attrElement->appendChild($this->createTextElement($dom, 'VALUE', (string)$value)); + $attributes->appendChild($attrElement); + } } } $xml = $dom->saveXML(); - return array( + return [ 'data' => (false !== $xml) ? $xml : self::FAIL_RESPONSE, 'contentType' => (false !== $xml) ? self::XML_CONTENT_TYPE : self::TEXT_CONTENT_TYPE, - ); + ]; } - public function getVat($vatRate): string + /** createElement($name, $value) не экранирует «&» — текст только через createTextNode(). */ + private function createTextElement(\DOMDocument $dom, string $name, string $value): \DOMElement + { + $element = $dom->createElement($name); + $element->appendChild($dom->createTextNode($value)); + + return $element; + } + + private function getVat($vatRate): string { return match ($vatRate) { '1104' => 'vat0', '1108' => 'vat5', '1109' => 'vat7', '1103' => 'vat10', - '7000' => 'vat20', + '1102' => 'vat20', '1113' => 'vat22', '1110' => 'vat105', '1111' => 'vat107', '1107' => 'vat110', - '7001' => 'vat120', + '1106' => 'vat120', '1114' => 'vat122', default => 'none' }; } - /** - * @return array - * @throws \Exception - */ - public function getAccountOrderProducts(): array - { - $this->load->model('account/order'); - return $this->model_account_order->getProducts($this->orderId); - } - /** @param array $params */ private function getOptionalStringWithDefault(array $params, string $key, string $default = ''): string { diff --git a/catalog/controller/payment/payanyway.php b/catalog/controller/payment/payanyway.php index 43ab3da..da6d87c 100755 --- a/catalog/controller/payment/payanyway.php +++ b/catalog/controller/payment/payanyway.php @@ -9,7 +9,6 @@ use Opencart\Catalog\Model\Account\Order as ModelAccountOrder; use Opencart\Catalog\Model\Checkout\Order as ModelCheckoutOrder; use Opencart\System\Engine\Config; use Opencart\System\Engine\Loader; -use Opencart\System\Library\Cart\Currency; use Opencart\System\Library\Cart\Customer; use Opencart\System\Library\Document; use Opencart\System\Library\Language; @@ -29,11 +28,11 @@ require_once DIR_EXTENSION . '/payanyway/catalog/library/payanyway/traits/payany * @property Session $session * @property Language $language * @property Url $url - * @property Currency $currency * @property ModelCheckoutOrder $model_checkout_order * @property ModelAccountOrder $model_account_order * @property ModelAccountCustomer $model_account_customer * @property Customer $customer + * @property \Opencart\Catalog\Model\Extension\PayAnyWay\Payment\PayAnyWay $model_extension_payanyway_payment_payanyway */ class PayAnyWay extends \Opencart\System\Engine\Controller @@ -46,30 +45,12 @@ class PayAnyWay extends \Opencart\System\Engine\Controller public const DESCRIPTION_MAX_LENGTH = 500; - private int $mntId; - private string $integrityCode; - private string $server; - private string $vatRate; - private string $currency; - private int $pendingOrderStatusId; - private int $successOrderStatusId; - private int $errorOrderStatusId; - private int $geoZoneId; - private int $sortOrder; - private string $cmsModuleVersion; - - private array $orderInfo; - private ?int $orderId; - public function __construct(\Opencart\System\Engine\Registry $registry) { parent::__construct($registry); - if (!$this->validateOrder()) { - $this->response->redirect($this->url->link('account/login', '', true)); - } - $this->initializeModule(); + $this->loadOrder($this->getOrderIdFromSession()); } /** @@ -77,6 +58,13 @@ class PayAnyWay extends \Opencart\System\Engine\Controller */ public function index(): string { + if (([] === $this->orderInfo) + || !$this->isPaidWithModule() + || !$this->isAwaitingPayment((int)($this->orderInfo['order_status_id'] ?? 0)) + ) { + return ''; + } + $this->load->language('extension/payanyway/payment/payanyway'); $this->document->setTitle($this->language->get('heading_title')); @@ -85,14 +73,34 @@ class PayAnyWay extends \Opencart\System\Engine\Controller : self::PAYMENT_URL_DEMO; $data['MNT_ID'] = $this->mntId; - $data['MNT_TRANSACTION_ID'] = $this->createTransactionId($this->orderId); - $data['MNT_AMOUNT'] = $this->formatPrice((float)($this->orderInfo['total'] ?? 0)); + $data['MNT_AMOUNT'] = $this->getOrderAmount(); + $data['MNT_TRANSACTION_ID'] = $this->createTransactionId($this->orderId, $data['MNT_AMOUNT']); $data['MNT_CURRENCY_CODE'] = $this->currency; - $data['MNT_TEST_MODE'] = '0'; + $data['MNT_TEST_MODE'] = $this->testMode ? '1' : '0'; $data['MNT_DESCRIPTION'] = $this->getDescription(); $data['MNT_SUBSCRIBER_ID'] = $this->getSubscriberId(); - $signature = md5( + $data['MNT_SIGNATURE'] = $this->buildFormSignature($data); + $urlArgs = $this->getUrlArgs($this->session->data['customer_token'] ?? null); + $data['MNT_SUCCESS_URL'] = $this->url->link('extension/payanyway/payment/payanyway.success', $urlArgs, true); + $data['MNT_FAIL_URL'] = $this->url->link('extension/payanyway/payment/payanyway.fail', $urlArgs, true); + $data['MNT_RETURN_URL'] = $this->url->link('checkout/checkout', $urlArgs, true); + $data['moneta_locale'] = $this->language->get('text_locale'); + $data['MNT_CMS'] = $this->getCms(); + + $data['btn_confirm'] = $this->language->get('text_paw_pay'); + + $data['confirm_url'] = $this->url->link('extension/payanyway/payment/payanyway.confirm', $urlArgs, true); + + return $this->load->view('extension/payanyway/payment/payanyway', $data); + } + + /** + * @param array $data + */ + private function buildFormSignature(array $data): string + { + return md5( $data['MNT_ID'] . $data['MNT_TRANSACTION_ID'] . $data['MNT_AMOUNT'] . @@ -101,31 +109,13 @@ class PayAnyWay extends \Opencart\System\Engine\Controller $data['MNT_TEST_MODE'] . $this->integrityCode, ); - $data['MNT_SIGNATURE'] = $signature; - $customerToken = $this->session->data['customer_token']; - $data['MNT_SUCCESS_URL'] = $this->url->link('extension/payanyway/payment/payanyway.success', 'customer_token=' . $customerToken, true); - $data['MNT_FAIL_URL'] = $this->url->link('extension/payanyway/payment/payanyway.fail', 'customer_token=' . $customerToken, true); - $data['MNT_RETURN_URL'] = $this->url->link('checkout/checkout', 'customer_token=' . $customerToken, true); - $data['moneta_locale'] = $this->language->get('text_locale'); - $data['MNT_CMS'] = $this->cmsModuleVersion; - - $data['btn_confirm'] = $this->language->get('text_paw_pay'); - - $data['confirm_url'] = $this->url->link('extension/payanyway/payment/payanyway.confirm', 'customer_token=' . $customerToken, true,); - - return $this->load->view('extension/payanyway/payment/payanyway', $data); } public function confirm(): void { $this->load->language('extension/payanyway/payment/payanyway'); - $orderStatusId = isset($this->orderInfo['order_status_id']) ? (int)$this->orderInfo['order_status_id'] : null; - if ((null !== $orderStatusId) && ($orderStatusId !== $this->successOrderStatusId)) { - $this->addOrderHistory($this->orderId, $this->pendingOrderStatusId, $this->language->get('text_order_confirmed'),); - } - - $json['success'] = true; + $json['success'] = $this->changeStatusUnderOrderLock(true, $this->pendingOrderStatusId, $this->language->get('text_order_confirmed')); $this->response->addHeader('Content-Type: application/json'); $this->response->setOutput(json_encode($json)); @@ -135,36 +125,73 @@ class PayAnyWay extends \Opencart\System\Engine\Controller { $this->load->language('extension/payanyway/payment/payanyway'); - $orderStatusId = isset($this->orderInfo['order_status_id']) ? (int)$this->orderInfo['order_status_id'] : null; - if ((null !== $orderStatusId) && ($orderStatusId !== $this->successOrderStatusId)) { - $this->addOrderHistory($this->orderId, $this->pendingOrderStatusId, $this->language->get('text_payment_processing')); - } + $this->changeStatusUnderOrderLock(false, $this->pendingOrderStatusId, $this->language->get('text_payment_processing')); - $customerToken = $this->request->get['customer_token'] ?? ''; - - $this->response->redirect($this->url->link('checkout/success', 'customer_token=' . $customerToken, true)); + $this->response->redirect($this->url->link('checkout/success', $this->getUrlArgs($this->request->get['customer_token'] ?? null), true)); } public function fail(): void { $this->load->language('extension/payanyway/payment/payanyway'); - $orderStatusId = isset($this->orderInfo['order_status_id']) ? (int)$this->orderInfo['order_status_id'] : null; - if ((null !== $orderStatusId) && ($orderStatusId !== $this->successOrderStatusId)) { - $this->addOrderHistory($this->orderId, $this->errorOrderStatusId, $this->language->get('text_payment_error')); - } + $this->changeStatusUnderOrderLock(false, $this->errorOrderStatusId, $this->language->get('text_payment_error')); $this->response->redirect($this->url->link('checkout/failure', '', true)); } + /** + * Смена статуса со страницы покупателя — под блокировкой заказа, как в Pay URL: заказ + * перечитывается, оплаченный (сейчас или по истории) не трогается. Возврат с MONETA.RU + * приходит параллельно с уведомлением об оплате. + * + * @param bool $allowFailed подтверждение формы допускает повторную попытку после «Неуспешного» + * @throws \Exception + */ + private function changeStatusUnderOrderLock(bool $allowFailed, int $orderStatusId, string $comment): bool + { + if (!$this->isStatusChangeableByCustomer($allowFailed)) { + return false; + } + + $this->load->model('extension/payanyway/payment/payanyway'); + if (!$this->model_extension_payanyway_payment_payanyway->acquireOrderLock((int)$this->orderId, self::$orderLockTimeoutSeconds)) { + return false; + } + + $this->loadOrder($this->orderId); + $changeable = $this->isStatusChangeableByCustomer($allowFailed) + && !$this->model_extension_payanyway_payment_payanyway->hasOrderStatusInHistory((int)$this->orderId, $this->successOrderStatusId); + if ($changeable) { + $this->addOrderHistory($this->orderId, $orderStatusId, $comment); + } + $this->model_extension_payanyway_payment_payanyway->releaseOrderLock((int)$this->orderId); + + return $changeable; + } + + /** Заказ модуля, ещё не ушедший дальше «Обрабатываемого» (или «Неуспешного» — для повторной попытки). */ + private function isStatusChangeableByCustomer(bool $allowFailed): bool + { + if (([] === $this->orderInfo) || !$this->isPaidWithModule()) { + return false; + } + + $orderStatusId = (int)$this->orderInfo['order_status_id']; + + return $allowFailed ? $this->isAwaitingPayment($orderStatusId) : in_array($orderStatusId, [0, $this->pendingOrderStatusId], true); + } + private function getDescription(): string { $orderId = $this->orderInfo['order_id'] ?? null; - $description = "Оплата заказа" . (!empty($orderId) ? " №{$orderId}" : ''); + $description = $this->language->get('text_description'); + if (!empty($orderId)) { + $description .= ' ' . sprintf($this->language->get('text_description_number'), $orderId); + } $name = $this->getClientName(); if (null !== $name) { - $description .= " от {$name}"; + $description .= ' ' . sprintf($this->language->get('text_description_customer'), $name); } $comment = $this->orderInfo['comment'] ?? ''; @@ -175,6 +202,16 @@ class PayAnyWay extends \Opencart\System\Engine\Controller return $this->validateString($description, self::DESCRIPTION_MAX_LENGTH); } + private function getUrlArgs(mixed $customerToken): string + { + $args = 'language=' . $this->config->get('config_language'); + if (is_string($customerToken) && ('' !== $customerToken)) { + $args .= '&customer_token=' . urlencode($customerToken); + } + + return $args; + } + private function getSubscriberId(): string { return $this->getClientEmail() ?? $this->getClientPhone() ?? ''; diff --git a/catalog/controller/startup/payanyway.php b/catalog/controller/startup/payanyway.php new file mode 100644 index 0000000..5fd2573 --- /dev/null +++ b/catalog/controller/startup/payanyway.php @@ -0,0 +1,55 @@ +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']); + } + } +} diff --git a/catalog/language/en-gb/payment/payanyway.php b/catalog/language/en-gb/payment/payanyway.php index beede39..3002f67 100755 --- a/catalog/language/en-gb/payment/payanyway.php +++ b/catalog/language/en-gb/payment/payanyway.php @@ -12,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_error'] = 'Payment error'; +$_['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'; diff --git a/catalog/language/ru-ru/payment/payanyway.php b/catalog/language/ru-ru/payment/payanyway.php index 2b2fdd5..c09b2f0 100755 --- a/catalog/language/ru-ru/payment/payanyway.php +++ b/catalog/language/ru-ru/payment/payanyway.php @@ -12,4 +12,9 @@ $_['text_paw_back_to_card'] = 'Вернуться к оплате картой $_['text_order_confirmed'] = 'Заказ подтвержден'; $_['text_payment_processing'] = 'Обработка платежа'; $_['text_payment_completed'] = 'Заказ оплачен'; -$_['text_payment_error'] = 'Ошибка оплаты'; +$_['text_payment_operation'] = 'Номер операции: %s'; + +$_['text_description'] = 'Оплата заказа'; +$_['text_description_number'] = '№%s'; +$_['text_description_customer'] = 'от %s'; +$_['text_payment_error'] = 'Ошибка оплаты'; diff --git a/catalog/library/payanyway/traits/payanyway.php b/catalog/library/payanyway/traits/payanyway.php index ae43f58..b1166a0 100755 --- a/catalog/library/payanyway/traits/payanyway.php +++ b/catalog/library/payanyway/traits/payanyway.php @@ -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 и раньше: <номер заказа>| в поясе сервера, без суммы. + 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 + */ + 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,16 +197,109 @@ 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'); - $this->model_checkout_order->addHistory($orderId, $orderStatusId, $comment); + + // 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 + * @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 */ diff --git a/catalog/model/payment/payanyway.php b/catalog/model/payment/payanyway.php index 8d9a720..f46c5aa 100755 --- a/catalog/model/payment/payanyway.php +++ b/catalog/model/payment/payanyway.php @@ -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'); diff --git a/install.json b/install.json index 9259bab..26ab676 100755 --- a/install.json +++ b/install.json @@ -1,7 +1,7 @@ { "code": "payanyway", - "name": "PayAnyWay", - "version": "1.0.2", - "author": "PayAnyWay", + "name": "PayAnyWay (МОНЕТА)", + "version": "1.0.3", + "author": "PayAnyWay (МОНЕТА)", "link": "https://www.payanyway.ru/" }