diff --git a/README.md b/README.md index f97c43f..76e7ebb 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,169 @@ -# PHPShop +# 🌟 Платёжный модуль «Монета» дл� [PHPShop](https://www.phpshop.ru/) +![PHPShop](https://img.shields.io/badge/PHPShop-0070C0?logo=php&logoColor=white) +![PHP](https://img.shields.io/badge/PHP-7.4%2B-purple) +![License](https://img.shields.io/badge/License-GPLv3%2B-blue) +> **© Правообладатель:** �КО «МО�ЕТ�» (ООО) -## Getting started +> **📦 Вер�и� модул�: 2.0** -To make it easy for you to get started with GitLab, here's a list of recommended next steps. +> ℹ� Модуль замен�ет в�троенный в PHPShop 6.7 и 7.0 модуль «Монета» 1.0: тот же каталог `moneta` и тот же �по�об оплаты, на�тройки перено��т�� кнопкой «Обновить модуль» (раздел [«Обновление �о в�троенного модул�»](#обновление-�о-в�троенного-модул�)). -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! +> **ℹ� Требовани�:**
+> **PHPShop:** 6.7.0+
+> **PHP:** 7.4+
+> **Валюта магазина:** только RUB
-## Add your files +## 🚀 УÑ�тановка и наÑ�тройка модулÑ� -* [Create](https://docs.gitlab.com/user/project/repository/web_editor/#create-a-file) or [upload](https://docs.gitlab.com/user/project/repository/web_editor/#upload-a-file) files -* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command: +1. 📥 [Скачайте архив модулÑ�](https://git.pub.moneta.ru/CMS/PHPShop/releases). +2. РаÑ�пакуйте архив в каталог `phpshop/modules/` магазина — в корне архива лежит каталог `moneta`, он заменит файлы вÑ�троенного модулÑ� «Монета». Загрузить архив через админку PHPShop нельзÑ�: в ней нет уÑ�тановки модулей из архива. Два Ñ�поÑ�оба: + - **файловый менеджер панели хоÑ�тинга** (ISPmanager, cPanel, Plesk и др.): загрузите архив в `phpshop/modules/` и раÑ�пакуйте его там, Ñ�оглашаÑ�Ñ�ÑŒ на замену файлов, затем удалите архив и раÑ�пакованный Ñ€Ñ�дом `README.md` (Ñ�то Ñ�та инÑ�трукциÑ�, модулю он не нужен); + - **FTP/SFTP**: раÑ�пакуйте архив на Ñ�воём компьютере и Ñ�копируйте каталог `moneta` в `phpshop/modules/` Ñ� заменой файлов. -``` -cd existing_repo -git remote add origin https://git.moneta.ru:8000/products/cashier/cms/PHPShop.git -git branch -M main -git push -uf origin main + Ð’ итоге должен получитьÑ�Ñ� путь `phpshop/modules/moneta/install/module.xml` — без лишнего вложенного каталога. ЕÑ�ли вÑ�троенный модуль «Монета» у ваÑ� включён, Ñ�начала прочитайте раздел [«Обновление Ñ�о вÑ�троенного модулÑ�»](#обновление-Ñ�о-вÑ�троенного-модулÑ�). +3. Ð�дминка PHPShop -> **«Модули»** -> **«Монета»** -> **«Включить»**. +4. Откройте наÑ�тройки модулÑ� и заполните полÑ� (таблица ниже), нажмите **«Применить»**. +5. Включите Ñ�поÑ�об оплаты **«Приём платежей банковÑ�кими картами, через СБП и Pay-Ñ�ервиÑ�Ñ‹ (Монета)»**: **«Заказы»** -> **«СпоÑ�обы оплаты»** (`/phpshop/admpanel/admin.php?path=payment`). При уÑ�тановке он вÑ�таёт первым в Ñ�пиÑ�ке Ñ�поÑ�обов оплаты при оформлении заказа; порÑ�док менÑ�етÑ�Ñ� полем **«Приоритет»** в карточке Ñ�поÑ�оба оплаты (меньшее чиÑ�ло — выше). +6. Ð�аÑ�тройте Ñ�чёт в личном кабинете MONETA.RU (раздел ниже). + +> **Выключение модулÑ� в PHPShop удалÑ�ет его таблицы** — наÑ�тройки придётÑ�Ñ� ввеÑ�ти заново. + +### Магазин Ñ� базой в UTF-8 + +ЕÑ�ли в `phpshop/inc/config.ini` указано `charset = "utf8"`, переключите кодировку Ñ�татичеÑ�ких файлов в `.htaccess` в корне Ñ�айта — PHPShop поÑ�тавлÑ�ет его Ñ� кодировкой windows-1251, а Ñ�трока длÑ� UTF-8 в нём уже еÑ�ть, но закомментирована: + +```apache +#AddDefaultCharset windows-1251 +AddDefaultCharset utf-8 ``` -## Integrate with your tools +Без Ñ�того браузер читает файлы локали PHPShop (`phpshop/locale/russian_utf/*.js`) в неверной кодировке: подÑ�казки и Ñ�ообщениÑ� админки и витрины (например, «ПожалуйÑ�та, выберите доÑ�тавку») выводÑ�Ñ‚Ñ�Ñ� кракозÑ�брами. Ð�а nginx то же Ñ�амое задаёт директива `charset utf-8;` в блоке `server` Ñ�айта. ПоÑ�ле изменениÑ� обновите Ñ�траницы Ñ� очиÑ�ткой кÑ�ша браузера (Ctrl+F5). Модуль от Ñ�той наÑ�тройки не завиÑ�ит: его Ñ�траницы и данные длÑ� MONETA.RU выводÑ�Ñ‚Ñ�Ñ� в нужной кодировке Ñ�ами. -* [Set up project integrations](https://git.moneta.ru:8000/products/cashier/cms/PHPShop/-/settings/integrations) +## Обновление Ñ�о вÑ�троенного модулÑ� -## Collaborate with your team +Ð’ PHPShop 6.7 и 7.0 модуль «Монета» 1.0 уже вÑ�троен — в том же каталоге `phpshop/modules/moneta`. ЕÑ�ли он у ваÑ� включён, не выключайте его: при выключении PHPShop удалÑ�ет таблицы модулÑ�, и номер Ñ�чёта Ñ� кодом пришлоÑ�ÑŒ бы вводить заново. Обновление переноÑ�ит наÑ�тройки Ñ�амо. -* [Invite team members and collaborators](https://docs.gitlab.com/user/project/members/) -* [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/) -* [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically) -* [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/) -* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/) +**Перед обновлением** -## Test and Deploy +- ОбновлÑ�йте, когда нет оплат «в процеÑ�Ñ�е»: оплату, которую покупатель начал во вÑ�троенном модуле, а MONETA.RU подтвердила уже поÑ�ле обновлениÑ�, новый модуль не узнает и не зачтёт — такие заказы поÑ�ле обновлениÑ� Ñ�верьте Ñ� операциÑ�ми в личном кабинете MONETA.RU и отметьте оплаченными вручную. +- ЕÑ�ли вы правили шаблоны в каталоге модулÑ� (`templates/`), Ñ�охраните их копию — файлы будут заменены. -Use the built-in continuous integration in GitLab. +**Обновление** -* [Get started with GitLab CI/CD](https://docs.gitlab.com/ci/quick_start/) -* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/) -* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/) -* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/) -* [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/) +1. РаÑ�пакуйте архив модулÑ� в `phpshop/modules/` Ñ� заменой файлов вÑ�троенного модулÑ� — как в шаге 2 [уÑ�тановки](#-уÑ�тановка-и-наÑ�тройка-модулÑ�). Модуль не выключайте. +2. Ð�дминка PHPShop -> **«Модули»** -> **«Монета»**. Ð�а вкладке «ОÑ�новное» поÑ�витÑ�Ñ� предупреждение, что наÑ�тройки ещё от вÑ�троенного модулÑ�, — до нажатиÑ� кнопки из шага 3 приём оплат выключен (покупатель видит «Оплата через Монету временно недоÑ�тупна»), а изменениÑ� на Ñ�той вкладке не Ñ�охранÑ�ÑŽÑ‚Ñ�Ñ�. +3. Вкладка **«О модуле»**: PHPShop пишет «ВерÑ�иÑ� Ñ�дра 2.0 не Ñ�оответÑ�твует верÑ�ии базы данных 1.2» — нажмите **«Обновить модуль»**. +4. Проверьте наÑ�тройки на вкладке «ОÑ�новное» и нажмите **«Применить»**: + - **Ð�омер Ñ�чёта** перенеÑ�ён из полÑ� «ID» вÑ�троенного модулÑ� (только цифры), **код проверки целоÑ�тноÑ�ти данных** — из полÑ� «Секретное Ñ�лово», **«Оплата при Ñ�татуÑ�е»** и **«ОпиÑ�ание оплаты»** — как было; + - **Сервер платежей** — «Боевой», как работал вÑ�троенный модуль; длÑ� демо-Ñ�чёта выберите «Демо»; + - **ТеÑ�товый режим** и **фиÑ�кализациÑ�** — выключены: включите их, еÑ�ли они нужны (Ñ�м. [«Ð�аÑ�тройки модулÑ�»](#наÑ�тройки-модулÑ�)). +5. СпоÑ�об оплаты оÑ�таётÑ�Ñ� прежним, включённым или выключенным, как был. Его название и иконка заменÑ�ÑŽÑ‚Ñ�Ñ� на новые, еÑ�ли вы их не менÑ�ли; изменённые Ñ�охранÑ�ÑŽÑ‚Ñ�Ñ�. -*** +**Личный кабинет MONETA.RU** — наÑ�тройки Ñ�чёта (раздел [«Ð�аÑ�тройка Ñ�чёта в личном кабинете MONETA.RU»](#наÑ�тройка-Ñ�чёта-в-личном-кабинете-monetaru)): -# Editing this README +| Поле | Было у вÑ�троенного модулÑ� | Что указать | +|-------------------------------|----------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Check URL** | пуÑ�то | `https://<ваш-Ñ�айт>/phpshop/modules/moneta/payment/callback.php` — модуль проверит заказ до оплаты: Ñ�умму, Ñ�татуÑ�, не оплачен ли он уже | +| **Pay URL** | `https://<ваш-Ñ�айт>/phpshop/modules/moneta/payment/result.php` | `…/payment/callback.php`, как Check URL. Старый адреÑ� `result.php` тоже работает, менÑ�ть его можно не Ñ�разу | +| **Success URL**, **Fail URL** | `http://<ваш-Ñ�айт>/success` и `/fail` | Лучше включить флаг **«Можно переопределÑ�ть наÑ�тройки в URL»**: модуль Ñ�ам вернёт покупателÑ� на Ñ�траницу Ñ�о Ñ�татуÑ�ом оплаты. Без флага укажите адреÑ�а **Ñ�о Ñ�лÑ�шем на конце** — `https://<ваш-Ñ�айт>/success/` и `https://<ваш-Ñ�айт>/fail/`: адреÑ�а без Ñ�лÑ�ша PHPShop не открывает (404), так было и Ñ�о вÑ�троенным модулем | -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. +## Ð�аÑ�тройки модулÑ� -## Suggestions for a good README +| Поле | ОпиÑ�ание | +|-------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Ð�омер Ñ�чёта** | Ð�омер раÑ�ширенного Ñ�чёта [MONETA.RU](https://moneta.ru/), только цифры | +| **Код проверки целоÑ�тноÑ�ти данных** | Код проверки целоÑ�тноÑ�ти данных, указанный в наÑ�тройках раÑ�ширенного Ñ�чёта. Сохранённый код в наÑ�тройках не показываетÑ�Ñ� — оÑ�тавьте поле пуÑ�тым, чтобы его не менÑ�ть | +| **Сервер платежей** | `Боевой` (по умолчанию) / `Демо` (demo.moneta.ru, отдельный номер Ñ�чёта) | +| **ТеÑ�товый режим** | По умолчанию `Выкл`. Передаёт `MNT_TEST_MODE=1`; должен Ñ�овпадать Ñ� флагом «ТеÑ�товый режим» в наÑ�тройках Ñ�чёта | +| **URL длÑ� уведомлений** | Скопируйте и укажите Ñ�тот адреÑ� в наÑ�тройках раÑ�ширенного Ñ�чёта как Check URL и Pay URL | +| **ОпиÑ�ание оплаты** | ТекÑ�Ñ‚ над Ñ�уммой и кнопкой оплаты на Ñ�транице «Заказ оформлен» и в личном кабинете покупателÑ�. По умолчанию `Оплатите пожалуйÑ�та Ñ�вой заказ`, пуÑ�то — без текÑ�та. Пока заказ ждёт проверки менеджером, текÑ�Ñ‚ не показываетÑ�Ñ�: покупатель видит «Заказ обрабатываетÑ�Ñ� менеджером.» | +| **ФиÑ�кализациÑ� чеков** | По умолчанию `Выкл`. ФиÑ�кализациÑ� чеков по 54-ФЗ Ñ�редÑ�твами Ñ�иÑ�темы MONETA.RU Ñ�ервиÑ�ом kassa.payanyway.ru | +| **Email длÑ� чеков** | ОбÑ�зателен при включённой фиÑ�кализации: еÑ�ли у покупателÑ� нет ни email, ни телефона, чек уйдёт на Ñ�тот адреÑ� | +| **СиÑ�тема налогообложениÑ�** | По умолчанию `Ð�е передавать` — каÑ�Ñ�а возьмёт её из Ñ�воих наÑ�троек | +| **Ð�ДС на товары** | `Из наÑ�троек раздела «Ð�аÑ�тройка цен»` (по умолчанию) — «Значение Ð�ДС» в разделе «Ð�аÑ�тройка цен» Ñ�иÑ�темных наÑ�троек PHPShop; иначе одна Ñ�тавка на вÑ�е товары, включаÑ� раÑ�чётные 5/105 … 22/122 | +| **Ð�ДС на доÑ�тавку** | `Из наÑ�троек Ñ�поÑ�оба доÑ�тавки` (по умолчанию) — «Значение Ð�ДС» Ñ�поÑ�оба доÑ�тавки; иначе одна Ñ�тавка на доÑ�тавку | +| **Признак Ñ�поÑ�оба раÑ�чёта** | У вÑ�ех позиций чека, включаÑ� доÑ�тавку. По умолчанию `Полный раÑ�чёт` | +| **Признак предмета раÑ�чёта** | У товаров. По умолчанию `Товар`; длÑ� уÑ�луг, работ и др. — по перечню каÑ�Ñ�Ñ‹. ДоÑ�тавка в чеке вÑ�егда «УÑ�луга» | +| **Оплата при Ñ�татуÑ�е** | `Ð�овый заказ` — оплата Ñ�разу поÑ�ле оформлениÑ�; другой Ñ�татуÑ� — поÑ�ле проверки заказа менеджером | +| **СтатуÑ� поÑ�ле оплаты** | По умолчанию `Оплачено платежными Ñ�иÑ�темами` | -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. +СохранÑ�ть наÑ�тройки может только админиÑ�тратор Ñ� правом «Модули: редактирование» (раздел «Ð�дминиÑ�траторы», вкладка «Права»). Модуль принимает Ñ�охранение только Ñ�о Ñ�траницы наÑ�троек Ñ�того же магазина. ЕÑ�ли вмеÑ�то Ñ�охранениÑ� поÑ�вилоÑ�ÑŒ «запроÑ� пришёл не Ñ�о Ñ�траницы Ñ�того магазина», откройте наÑ�тройки заново и нажмите «Применить». ЕÑ�ли Ñ�ообщение повторÑ�етÑ�Ñ�, проверьте, не убирает ли браузер, раÑ�ширение или прокÑ�и заголовки Origin и Referer. -## Name -Choose a self-explaining name for your project. +## Оформление блока оплаты -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. +Ð�а Ñ�транице «Заказ оформлен» покупатель видит карточку оплаты: логотип MONETA.RU, опиÑ�ание оплаты из наÑ�троек, Ñ�умму «К оплате», кнопку «Оплатить <Ñ�умма>» и поÑ�Ñ�нение о переходе на защищённую Ñ�траницу MONETA.RU. Карточка Ñ�обираетÑ�Ñ� по шаблону `phpshop/modules/moneta/templates/payment_forma.tpl`, Ñ�тили — `templates/moneta.css`, логотип — `templates/moneta.svg`; их можно изменить под тему магазина: -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. +| ПеременнаÑ� | Что подÑ�тавлÑ�етÑ�Ñ� | +|-----------------------|---------------------------------------------------------------------------------------------------| +| `@payment_info@` | «ОпиÑ�ание оплаты» из наÑ�троек модулÑ� — обычный текÑ�Ñ‚, переноÑ�Ñ‹ Ñ�трок Ñ�охранÑ�ÑŽÑ‚Ñ�Ñ� | +| `@payment_info_hide@` | `moneta-hide`, еÑ�ли опиÑ�ание пуÑ�тое или заказ Ñ�ейчаÑ� нельзÑ� оплатить | +| `@moneta_total@` | Сумма к оплате, как её пишет витрина: `18 500` и знак рублÑ� темы (`p`) | +| `@moneta_total_hide@` | `moneta-hide`, еÑ�ли заказ Ñ�ейчаÑ� нельзÑ� оплатить, — Ñ�крывает Ñ�умму и поÑ�Ñ�нение | +| `@payment_forma@` | Форма Ñ� кнопкой «Оплатить» или текÑ�Ñ‚ о Ñ�оÑ�тоÑ�нии заказа | -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. +Переменные `@payment_info@` и `@payment_forma@` — те же, что во вÑ�троенном модуле PHPShop, поÑ�тому шаблон от него подойдёт. Пути к файлам модулÑ� в шаблоне пишутÑ�Ñ� без начального Ñ�лÑ�ша (`phpshop/modules/...`) — витрина PHPShop Ñ�ама делает их абÑ�олютными. Шаблон и Ñ�тили лежат в каталоге модулÑ�: **обновление модулÑ� их перезапишет** — Ñ�охраните Ñ�вою верÑ�ию перед обновлением. -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. +## 🔧 Ð�аÑ�тройка Ñ�чёта в личном кабинете MONETA.RU -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. +1. ðŸ“� [ЗарегиÑ�трируйтеÑ�ÑŒ](https://payanyway.ru/partnerRegistration.htm) в платёжной Ñ�иÑ�теме PayAnyWay и заполните вÑ�е + необходимые данные. ДождитеÑ�ÑŒ проверки аккаунта и Ñ�оздайте **раÑ�ширенный Ñ�чет**. -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. +2. âš™ï¸� Заполните наÑ�тройки раÑ�ширенного Ñ�чета (раздел **«Мой Ñ�чет» → «Управление Ñ�четами» → «Редактировать Ñ�чет» → + «Показать дополнительные полÑ�»**): -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. +| Параметр | Значение | +|------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **ПÑ�евдоним** | любой, например `Монета` | +| **ТеÑ�товый режим** | `Ð�ет` (как в наÑ�тройке модулÑ�) | +| **Check URL** и **Pay URL** | `https://<ваш-Ñ�айт>/phpshop/modules/moneta/payment/callback.php` — готовый адреÑ� — в поле «URL длÑ� уведомлений» наÑ�троек модулÑ� (кнопка «Копировать») и на вкладке «ИнÑ�трукциÑ�» | +| **HTTP метод (PayUrl, CheckUrl)** | `POST` или `GET` | +| **Код проверки целоÑ�тноÑ�ти данных** | произвольный набор Ñ�имволов; тот же код укажите в наÑ�тройках модулÑ� | +| **ПодпиÑ�ÑŒ формы оплаты обÑ�зательна** | `Да` | +| **Можно переопределÑ�ть наÑ�тройки в url** | `Да` | +| **Success URL** | ОÑ�тавить пуÑ�тым | +| **Fail URL** | ОÑ�тавить пуÑ�тым | +| **InProgress URL** | ОÑ�тавить пуÑ�тым | +| **Return URL** | ОÑ�тавить пуÑ�тым | -## Contributing -State if you are open to contributions and what your requirements are for accepting them. +> âš ï¸� **Важно!** ДлÑ� кирилличеÑ�кого домена PayURL и CheckURL должны быть указаны в +> кодировке [Punycode](https://2ip.ru/punycode/). -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. +3. ДлÑ� фиÑ�кализации чеков по 54-ФЗ Ñ�редÑ�твами за рамками PHPShop наÑ�тройте вашу каÑ�Ñ�у в Ñ�ервиÑ�е kassa.payanyway.ru, + в наÑ�тройках раÑ�ширенного Ñ�чёта в MONETA.RU уÑ�тановите Pay URL https://kassa.payanyway.ru/index.php?do=invoicepayurl, + а в наÑ�тройках каÑ�Ñ�Ñ‹ пропишите Pay URL вашего интернет-магазина `https://<ваш-Ñ�айт>/phpshop/modules/moneta/payment/callback.php`. + Ð’ Ñ�том Ñ�лучае чек по 54-ФЗ будет пробиватьÑ�Ñ� через kassa.payanyway.ru, а запроÑ� на Pay URL магазина будет проходить через Ñ�тот Ñ�ервиÑ� транзитом. -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. +## Журнал операций -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. +**«Модули»** -> **«Монета»** -> **«Журнал операций»**: каждое уведомление MONETA.RU (проверка заказа и оплата) Ñ� ответом модулÑ�. Код проверки целоÑ�тноÑ�ти и подпиÑ�и в журнал не пишутÑ�Ñ�. -## License -For open source projects, say how it is licensed. +Ð�а Ñ�транице — 500 поÑ�ледних запиÑ�ей, по ним работают поиÑ�к и Ñ�ортировка таблицы. ЗапиÑ�и одного заказа за вÑ�Ñ‘ времÑ� находит поле над таблицей: введите номер заказа (`33-57`) или целиком `MNT_TRANSACTION_ID` из личного кабинета MONETA.RU. -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. +Ð�а что обратить внимание: + +- **«ВтораÑ� оплата оплаченного заказа»** — покупатель оплатил заказ дважды. Модуль вторую оплату не зачитывает, MONETA.RU будет повторÑ�ть уведомление; Ñ�делайте возврат в личном кабинете MONETA.RU или Ñ�вÑ�житеÑ�ÑŒ Ñ� покупателем. +- **«Сумма не Ñ�овпадает Ñ� заказом»**, **«У заказа другой Ñ�поÑ�об оплаты»** — заказ изменили поÑ�ле того, как покупатель открыл форму оплаты. Верните Ñ�умму или Ñ�поÑ�об оплаты — повторное уведомление MONETA.RU зачтёт оплату. +- **«Чек не Ñ�обран»** — при включённой фиÑ�кализации модуль не Ñ�мог Ñ�обрать чек заказа, и оплата не началаÑ�ÑŒ: без чека каÑ�Ñ�а не пробьёт его по 54-ФЗ. Причина — в журнале ошибок PHP (Ñ�троки Ñ� префикÑ�ом `moneta:`); обычно Ñ�то Ñ�умма заказа выше предела каÑ�Ñ�Ñ‹ (42 949 672,95 ₽) или Ñ�бой базы данных. +- **«ТеÑ�товый режим не Ñ�овпадает Ñ� наÑ�тройкой модулÑ�»** — флаг «ТеÑ�товый режим» в модуле и в наÑ�тройках Ñ�чёта MONETA.RU должен Ñ�овпадать. + +## Как проходит оплата + +1. ПоÑ�ле оформлениÑ� заказа (или в личном кабинете, еÑ�ли оплата доÑ�тупна поÑ�ле проверки менеджером) покупатель нажимает кнопку оплаты и переходит на платёжную форму MONETA.RU. +2. MONETA.RU проверÑ�ет заказ (Check URL) и поÑ�ле оплаты уведомлÑ�ет магазин (Pay URL) — модуль переводит заказ в Ñ�татуÑ� «поÑ�ле оплаты». +3. Покупатель возвращаетÑ�Ñ� в магазин на Ñ�траницу `/moneta/`. ЕÑ�ли MONETA.RU уже подтвердила оплату — «Оплата прошла уÑ�пешно»; еÑ�ли подтверждение ещё в пути — Ñ�траница около минуты ждёт его и обновлÑ�етÑ�Ñ� Ñ�ама. СтатуÑ� заказа виден только его владельцу: вошедшему покупателю или в том же браузере, где показывалаÑ�ÑŒ кнопка оплаты; оÑ�тальным — «Заказ не найден». СтатуÑ� заказа менÑ�ет только уведомление MONETA.RU, а не возврат покупателÑ�. Шаблон Ñ�траницы — `templates/return_forma.tpl`, Ñ�тили — `templates/moneta.css`. + +## 📚 Полезные реÑ�урÑ�Ñ‹ + +* [PHPShop](https://www.phpshop.ru/) +* [PHP API](https://wiki.phpshop.ru/api/php-api) +* [HTML ВерÑ�тальщику](https://wiki.phpshop.ru/dizain/verstalshiku) +* [ПрайÑ�-лиÑ�ты поÑ�тавщиков в CSV](https://wiki.phpshop.ru/rabota-s-bazoi/import-i-eksport/csv) +* [Ручное добавление каталогов, товаров](https://wiki.phpshop.ru/tovary/ruchnoe-dobavlenie-tovarov) +* [УÑ�тановка на хоÑ�тинг](https://wiki.phpshop.ru/ustanovka-i-obnovlenie/ustanovka-na-khosting#ustanovka-cherez-arhiv-v-ruchnom-rezhime) +* [Разработчикам](https://wiki.phpshop.ru/moduli/razrabotchikam/) + +--- + +**✅ Модуль наÑ�троен, приÑ�тных платежей!** 💰🎉 diff --git a/moneta/admpanel/adm_module.php b/moneta/admpanel/adm_module.php new file mode 100644 index 0000000..bad63c9 --- /dev/null +++ b/moneta/admpanel/adm_module.php @@ -0,0 +1,525 @@ +getParam('base.moneta.moneta_system')); + +/** + * Ïðàâî «Ìîäóëè: ðåäàêòèðîâàíèå» è çàïðîñ ñî ñòðàíèöû ìàãàçèíà — äî ëþáîé çàïèñè íàñòðîåê. + * ßäðî ñàìî ïðîâåðÿåò ïðàâà, òîëüêî åñëè êëèåíò ïðèñëàë ñóôôèêñ `.modules.edit` â èìåíè + * äåéñòâèÿ, ïîýòîìó ïðîâåðÿåì çäåñü ({@see SettingsWriteGuard}). Ïðè îòêàçå âûâîäèò ñîîáùåíèå, + * êàê ÿäðî ïðè íåõâàòêå ïðàâ. + */ +function monetaSettingsWriteAllowed(): bool +{ + global $PHPShopBase; + + $reason = SettingsWriteGuard::denialReason( + (bool) $PHPShopBase->Rule->CheckedRules('modules', 'edit'), + $_SERVER, + ); + if ($reason === null) { + return true; + } + + $reasons = [ + SettingsWriteGuard::DENIED_RIGHTS => 'ó àäìèíèñòðàòîðà íåò ïðàâà «Ìîäóëè: ðåäàêòèðîâàíèå»', + SettingsWriteGuard::DENIED_METHOD => 'çàïðîñ íå POST (ïåðåõîä ïî ññûëêå)', + SettingsWriteGuard::DENIED_ORIGIN => 'çàïðîñ îòïðàâëåí íå ñî ñòðàíèöû ìàãàçèíà (Origin/Referer)', + ]; + (new ErrorLogLogger())->error('Íàñòðîéêè íå ñîõðàíåíû: ' . ($reasons[$reason] ?? $reason) . '.'); + if (isset($_REQUEST['ajax'])) { + exit(json_encode(['success' => false])); + } + if ($reason === SettingsWriteGuard::DENIED_RIGHTS) { + $PHPShopBase->Rule->BadUserFormaWindow(); + } else { + echo ''; + } + + return false; +} + +/** + * Êíîïêà «Îáíîâèòü ìîäóëü»: SQL èç updates/<âåðñèÿ â ÁÄ>/ è íîâàÿ âåðñèÿ èç module.xml. + */ +function actionBaseUpdate() +{ + global $PHPShopModules, $PHPShopOrm; + + if (!monetaSettingsWriteAllowed()) { + return true; + } + + $PHPShopOrm->clean(); + $option = $PHPShopOrm->select(); + $newVersion = $PHPShopModules->getUpdate($option['version']); + $PHPShopOrm->clean(); + $action = $PHPShopOrm->update(['version_new' => $newVersion]); + header('Location: ?path=modules&id=' . urlencode((string) $_GET['id'])); + + return $action; +} + +function actionUpdate() +{ + global $PHPShopOrm, $PHPShopModules; + + if (!monetaSettingsWriteAllowed()) { + return true; + } + + // Çàùèòà íà âðåìÿ ìåæäó çàìåíîé ôàéëîâ è êíîïêîé «Îáíîâèòü ìîäóëü». Ïîêà îíà íå íàæàòà, + // òàáëèöà íàñòðîåê — åù¸ îò âñòðîåííîãî ìîäóëÿ «Ìîíåòà» 1.0: êîëîíîê account_id, + // integrity_code è îñòàëüíûõ íåò (èõ äîáàâëÿåò updates/1.2/update_module.sql). UPDATE ïî íèì + // óïàë áû, à ÿäðî íå ïîêàçûâàåò îøèáêó — ââåä¸ííîå ïðîïàëî áû. Ïîýòîìó ñîõðàíåíèå + // ïðîïóñêàåì; íà âêëàäêå «Îñíîâíîå» â ýòî âðåìÿ âèñèò ïðåäóïðåæäåíèå (actionStart()). + // Ïîñëå ìèãðàöèè ïðîâåðêà âñåãäà ïðîõîäèò: ýòî îäèí SELECT ñòðîêè íàñòðîåê íà «Ïðèìåíèòü». + // clean() — ñáðîñ ñîñòîÿíèÿ ORM ÿäðà äî è ïîñëå select(), êàê â actionBaseUpdate(). + $PHPShopOrm->clean(); + $row = $PHPShopOrm->select(); + if (!is_array($row) || SettingsRepository::needsUpgrade($row)) { + header('Location: ?path=modules&id=' . urlencode((string) $_GET['id'])); + + return false; + } + $PHPShopOrm->clean(); + + // Âèòðèíû ìóëüòèñàéòà, íà êîòîðûõ âêëþ÷¸í ìîäóëü. + $PHPShopModules->updateOption($_GET['id'], $_POST['servers'] ?? null); + + $post = static function (string $name): string { + $value = $_POST[$name] ?? ''; + + return is_string($value) ? trim($value) : ''; + }; + + $update = [ + 'payment_server_new' => PaymentServer::fromSetting($post('payment_server_new'))->getValue(), + 'test_mode_new' => $post('test_mode_new') === '1' ? '1' : '0', + 'title_end_new' => strip_tags($post('title_end_new')), + 'fiscalization_new' => $post('fiscalization_new') === '1' ? '1' : '0', + 'sno_new' => TaxSystem::fromSetting($post('sno_new'))->getValue(), + 'payment_method_new' => PaymentMethod::fromSetting($post('payment_method_new'))->getValue(), + 'payment_object_new' => PaymentObject::fromSetting($post('payment_object_new'))->getValue(), + 'goods_vat_new' => VatSetting::fromSetting($post('goods_vat_new'))->getValue(), + 'delivery_vat_new' => VatSetting::fromSetting($post('delivery_vat_new'))->getValue(), + 'status_new' => (int) $post('status_new'), + 'paid_status_new' => (int) $post('paid_status_new') > 0 + ? (int) $post('paid_status_new') + : Settings::STATUS_PAID_BY_GATEWAY, + ]; + + $receiptEmail = $post('receipt_email_new'); + $update['receipt_email_new'] = strlen($receiptEmail) <= 64 + && filter_var($receiptEmail, FILTER_VALIDATE_EMAIL) !== false + ? $receiptEmail + : ''; + + $accountId = preg_replace('/[^0-9]/', '', $post('account_id_new')) ?? ''; + if ($accountId !== '') { + $update['account_id_new'] = $accountId; + } + + $integrityCode = $post('integrity_code_new'); + if ($integrityCode !== '') { + $update['integrity_code_new'] = $integrityCode; + } + + // «Email äëÿ ÷åêîâ» îáÿçàòåëåí ïðè ôèñêàëèçàöèè: áåç àäðåñà âêëþ÷åíèå íå ñîõðàíÿåòñÿ, à óæå + // âêëþ÷¸ííàÿ ôèñêàëèçàöèÿ ìîë÷à íå âûêëþ÷àåòñÿ — î ïóñòîì àäðåñå ïðåäóïðåæäàåò actionStart(). + if ($update['fiscalization_new'] === '1' && $update['receipt_email_new'] === '') { + $update['fiscalization_new'] = ((string) ($row['fiscalization'] ?? '0') === '1') ? '1' : '0'; + } + + $PHPShopOrm->debug = false; + $action = $PHPShopOrm->update($update); + header('Location: ?path=modules&id=' . urlencode((string) $_GET['id'])); + + return $action; +} + +function actionStart() +{ + global $PHPShopGUI, $PHPShopOrm; + + PHPShopObj::loadClass('order'); + + $data = $PHPShopOrm->select(); + $statuses = (new PHPShopOrderStatusArray())->getArray(); + $statuses = is_array($statuses) ? $statuses : []; + + $settings = Settings::fromRow(is_array($data) ? $data : []); + $context = new ShopContext(); + + // Íà ìàãàçèíå ñ áàçîé utf-8 ÿäðî ïåðåêîäèðóåò òîëüêî ñòðîêè, êîòîðûå ñàìî ïðîïóñêàåò ÷åðåç + // __() (ïîäïèñè ïîëåé, ïîäñêàçêè, âêëàäêè). Òåêñòû setAlert(..., false), ïóíêòû setSelect() + // è ñîáñòâåííàÿ ðàçìåòêà âûâîäÿòñÿ êàê åñòü — èõ ïåðåêîäèðóåò ìîäóëü. Ñòðîêè èç ÁÄ + // (ñòàòóñû çàêàçà) óæå â êîäèðîâêå ñòðàíèöû. + $page = static function (string $text): string { + return HostCharset::toPage($text); + }; + + $Tab1 = ''; + $needsUpgrade = is_array($data) && SettingsRepository::needsUpgrade($data); + // Êíîïêà «Îáíîâèòü ìîäóëü» ÿäðà îòïðàâëÿåò ýòó æå ôîðìó: ïóñòûå îáÿçàòåëüíûå ïîëÿ ñòàðîé + // ñõåìû áðàóçåð íå ïðîïóñòèë áû, è êíîïêà íå ñðàáàòûâàëà áû. + $requiredText = $needsUpgrade ? 'text' : 'text.required'; + if ($needsUpgrade) { + $Tab1 .= $PHPShopGUI->setAlert( + HostCharset::toPage( + 'Ôàéëû ìîäóëÿ îáíîâëåíû, à íàñòðîéêè åù¸ îò âñòðîåííîãî ìîäóëÿ «Ìîíåòà». Íàæìèòå «Îáíîâèòü ìîäóëü»' + . ' íà âêëàäêå «Î ìîäóëå» - íîìåð ñ÷¸òà è êîä ïðîâåðêè öåëîñòíîñòè ïåðåíåñóòñÿ. Äî ýòîãî ïðè¸ì' + . ' îïëàò âûêëþ÷åí, à èçìåíåíèÿ íà ýòîé âêëàäêå íå ñîõðàíÿþòñÿ.', + ), + 'warning', + false, + ); + } + + $Tab1 .= $PHPShopGUI->setField( + 'Íîìåð ñ÷¸òà', + $PHPShopGUI->setInput($requiredText, 'account_id_new', $settings->getAccountId(), false, 300) + . $PHPShopGUI->setHelp('Âàø íîìåð ðàñøèðåííîãî ñ÷¸òà'), + ); + + // Ñîõðàí¸ííûé êîä â HTML íå âûâîäèòñÿ: ïóñòîå ïîëå ïðè ñîõðàíåíèè — «íå ìåíÿòü» (actionUpdate()). + // Ðàçìåòêà ñâîÿ: setInput() ÿäðà íå óìååò autocomplete, à áåç new-password áðàóçåð ïîäñòàâèë áû + // ñþäà ñîõðàí¸ííûé ïàðîëü àäìèíèñòðàòîðà è ïðè ñîõðàíåíèè çàò¸ð áû èì êîä. + $hasIntegrityCode = is_array($data) && (string) ($data['integrity_code'] ?? '') !== ''; + $Tab1 .= $PHPShopGUI->setField( + 'Êîä ïðîâåðêè öåëîñòíîñòè äàííûõ', + '' + . $PHPShopGUI->setHelp( + 'Êîä ïðîâåðêè öåëîñòíîñòè äàííûõ, óêàçàííûé â íàñòðîéêàõ ðàñøèðåííîãî ñ÷¸òà' + . ($hasIntegrityCode ? '. Ñîõðàí¸ííûé êîä íå ïîêàçûâàåòñÿ — îñòàâüòå ïîëå ïóñòûì, ÷òîáû åãî íå ìåíÿòü' : ''), + ), + ); + + $servers = [ + [HostCharset::toPage('Áîåâîé'), PaymentServer::PROD, $settings->getPaymentServer()->getValue()], + [HostCharset::toPage('Äåìî'), PaymentServer::DEMO, $settings->getPaymentServer()->getValue()], + ]; + $Tab1 .= $PHPShopGUI->setField('Ñåðâåð ïëàòåæåé', $PHPShopGUI->setSelect('payment_server_new', $servers, 300)); + + $Tab1 .= $PHPShopGUI->setField( + 'Òåñòîâûé ðåæèì', + $PHPShopGUI->setCheckbox('test_mode_new', '1', '', $settings->isTestMode() ? 1 : 0) + . $PHPShopGUI->setHelp('Äîëæåí ñîâïàäàòü ñ ôëàãîì «Òåñòîâûé ðåæèì» â íàñòðîéêàõ ðàñøèðåííîãî ñ÷¸òà'), + ); + + // Òîëüêî äëÿ ÷òåíèÿ, ñ êíîïêîé êîïèðîâàíèÿ: navigator.clipboard åñòü ëèøü íà HTTPS è localhost, + // íà HTTP-àäìèíêå — çàïàñíîé ïóòü ÷åðåç âûäåëåíèå ïîëÿ è execCommand('copy'). + $callbackUrl = htmlspecialchars($context->callbackUrl(), ENT_QUOTES, 'Windows-1251'); + $Tab1 .= $PHPShopGUI->setField( + 'URL äëÿ óâåäîìëåíèé', + HostCharset::toPage( + '
' + . '' + . '
', + ) + . $PHPShopGUI->setHelp( + 'Óêàæèòå ýòîò àäðåñ â íàñòðîéêàõ ðàñøèðåííîãî ñ÷¸òà êàê Check URL è Pay URL', + ), + ); + + $Tab1 .= $PHPShopGUI->setField( + 'Îïèñàíèå îïëàòû', + $PHPShopGUI->setTextarea( + 'title_end_new', + htmlspecialchars($settings->getPaymentDescription(), ENT_QUOTES, 'Windows-1251'), + false, + 300, + 80, + ) + . $PHPShopGUI->setHelp( + 'Òåêñò íàä ñóììîé è êíîïêîé îïëàòû íà ñòðàíèöå «Çàêàç îôîðìëåí» è â ëè÷íîì êàáèíåòå. Ïóñòî — áåç òåêñòà', + ), + ); + + $Tab1 .= $PHPShopGUI->setField( + 'Ôèñêàëèçàöèÿ ÷åêîâ', + $PHPShopGUI->setCheckbox('fiscalization_new', '1', '', $settings->isFiscalization() ? 1 : 0) + . $PHPShopGUI->setHelp('Ôèñêàëèçàöèÿ ÷åêîâ ïî 54-ÔÇ ñðåäñòâàìè ñèñòåìû ÌÎÍÅÒÀ ñåðâèñîì kassa.payanyway.ru'), + ); + + // Ïîëÿ ÷åêà äëÿ êàññû âèäíû òîëüêî ïðè âêëþ÷¸ííîé ôèñêàëèçàöèè; çíà÷åíèÿ + // ñîõðàíÿþòñÿ è ïðè ñêðûòûõ ïîëÿõ. + $fiscal = 'moneta-fiscal'; + + if ($settings->isFiscalization() && $settings->getReceiptEmail() === '') { + $Tab1 .= $PHPShopGUI->setAlert( + HostCharset::toPage( + 'Ôèñêàëèçàöèÿ âêëþ÷åíà, à «Email äëÿ ÷åêîâ» íå çàïîëíåí. Óêàæèòå àäðåñ: íà íåãî ïðèä¸ò ÷åê,' + . ' åñëè ó ïîêóïàòåëÿ íåò íè email, íè òåëåôîíà.', + ), + 'warning', + false, + ); + } + + $Tab1 .= $PHPShopGUI->setField( + 'Email äëÿ ÷åêîâ', + $PHPShopGUI->setInput( + 'email', + 'receipt_email_new', + htmlspecialchars($settings->getReceiptEmail(), ENT_QUOTES, 'Windows-1251'), + false, + 300, + ) + . $PHPShopGUI->setHelp( + 'Ïîäñòàâëÿåòñÿ â ÷åê, åñëè ó ïîêóïàòåëÿ íåò íè email, íè òåëåôîíà — ÷åê ïðèä¸ò íà ýòîò àäðåñ, à ÔÈÎ ïîêóïàòåëÿ â ÷åêå ñîõðàíèòñÿ', + ), + 1, + null, + $fiscal, + ); + + $Tab1 .= $PHPShopGUI->setField( + 'Ñèñòåìà íàëîãîîáëîæåíèÿ', + $PHPShopGUI->setSelect( + 'sno_new', + SelectOptions::fromLabels(array_map($page, TaxSystem::LABELS), $settings->getTaxSystem()->getValue()), + 300, + ) + . $PHPShopGUI->setHelp('Åñëè íå ïåðåäàâàòü, êàññà âîçüì¸ò ñèñòåìó íàëîãîîáëîæåíèÿ èç ñâîèõ íàñòðîåê'), + 1, + null, + $fiscal, + ); + + // Ñòàâêè PHPShop íå èç ïåðå÷íÿ êàññû (ñâîáîäíîå ÷èñëî, íàïðèìåð îïå÷àòêà 23): ïîçèöèè ñ íèìè + // óõîäÿò â ÷åê «áåç ÍÄÑ», î êàæäîé òàêîé ïîçèöèè ìîäóëü ïèøåò è â æóðíàë PHP. + $vatWarnings = []; + try { + $vatRates = new HostVatRates(Database::fromHost()); + $vatWarnings = VatRateWarnings::find( + $settings->isFiscalization(), + $vatRates->isEnabled(), + $settings->getGoodsVat(), + $settings->getDeliveryVat(), + $vatRates->systemPercent(), + $vatRates->enabledDeliveries(), + ); + } catch (\Throwable $exception) { + (new ErrorLogLogger())->error('Ñòàâêè ÍÄÑ PHPShop íå ïðîâåðåíû: ' . $exception->getMessage()); + } + if ($vatWarnings !== []) { + $escape = static function (string $text): string { + return htmlspecialchars($text, ENT_QUOTES, 'Windows-1251'); + }; + $Tab1 .= $PHPShopGUI->setAlert( + HostCharset::toPage( + 'Ñòàâêè ÍÄÑ íå èç ïåðå÷íÿ êàññû — ýòè ïîçèöèè óõîäÿò â ÷åê «áåç ÍÄÑ»: ' + . implode('; ', array_map($escape, $vatWarnings)) + . '. Èñïðàâüòå «Çíà÷åíèå ÍÄÑ» â ðàçäåëå «Íàñòðîéêà öåí» èëè â ñïîñîáå äîñòàâêè' + . ' ëèáî âûáåðèòå ñòàâêó â ïîëÿõ íèæå.', + ), + 'warning', + false, + ); + } + + $Tab1 .= $PHPShopGUI->setField( + 'ÍÄÑ íà òîâàðû', + $PHPShopGUI->setSelect( + 'goods_vat_new', + SelectOptions::fromLabels( + array_map($page, VatSetting::labels('Èç íàñòðîåê ðàçäåëà «Íàñòðîéêà öåí»')), + $settings->getGoodsVat()->getValue(), + ), + 300, + ) + . $PHPShopGUI->setHelp('Îäíà ñòàâêà íà âñå òîâàðû èëè «Çíà÷åíèå ÍÄÑ» èç ðàçäåëà «Íàñòðîéêà öåí»'), + 1, + null, + $fiscal, + ); + + $Tab1 .= $PHPShopGUI->setField( + 'ÍÄÑ íà äîñòàâêó', + $PHPShopGUI->setSelect( + 'delivery_vat_new', + SelectOptions::fromLabels( + array_map($page, VatSetting::labels('Èç íàñòðîåê ñïîñîáà äîñòàâêè')), + $settings->getDeliveryVat()->getValue(), + ), + 300, + ) + . $PHPShopGUI->setHelp('Îäíà ñòàâêà íà äîñòàâêó èëè «Çíà÷åíèå ÍÄÑ» ñïîñîáà äîñòàâêè.'), + 1, + null, + $fiscal, + ); + + $Tab1 .= $PHPShopGUI->setField( + 'Ïðèçíàê ñïîñîáà ðàñ÷¸òà', + $PHPShopGUI->setSelect( + 'payment_method_new', + SelectOptions::fromLabels(array_map($page, PaymentMethod::LABELS), $settings->getPaymentMethod()->getValue()), + 300, + ) + . $PHPShopGUI->setHelp('Ó âñåõ ïîçèöèé ÷åêà, âêëþ÷àÿ äîñòàâêó'), + 1, + null, + $fiscal, + ); + + $Tab1 .= $PHPShopGUI->setField( + 'Ïðèçíàê ïðåäìåòà ðàñ÷¸òà', + $PHPShopGUI->setSelect( + 'payment_object_new', + SelectOptions::fromLabels(array_map($page, PaymentObject::LABELS), $settings->getPaymentObject()->getValue()), + 300, + ) + . $PHPShopGUI->setHelp('Ó òîâàðîâ; äîñòàâêà â ÷åêå — âñåãäà «Óñëóãà»'), + 1, + null, + $fiscal, + ); + + // Ñêðûòîå ïîëå email ñ îøèáêîé áðàóçåð íå äàë áû îòïðàâèòü, à ïîäñêàçêó íå ïîêàçàë áû — + // íà âðåìÿ ñêðûòèÿ îíî ñòàíîâèòñÿ òåêñòîâûì è íåîáÿçàòåëüíûì (àäðåñ âñ¸ ðàâíî ïðîâåðÿåò + // actionUpdate()); ïðè ôèñêàëèçàöèè «Email äëÿ ÷åêîâ» îáÿçàòåëåí. + $Tab1 .= ''; + + $Tab1 .= $PHPShopGUI->setField( + 'Îïëàòà ïðè ñòàòóñå', + $PHPShopGUI->setSelect( + 'status_new', + OrderStatusOptions::build($statuses, $settings->getPayableStatus(), $page('Íîâûé çàêàç'), Settings::STATUS_NEW), + 300, + ), + ); + $Tab1 .= $PHPShopGUI->setField( + 'Ñòàòóñ ïîñëå îïëàòû', + $PHPShopGUI->setSelect( + 'paid_status_new', + OrderStatusOptions::build( + $statuses, + $settings->getPaidStatus(), + $page('Îïëà÷åíî ïëàòåæíûìè ñèñòåìàìè'), + Settings::STATUS_PAID_BY_GATEWAY, + ), + 300, + ), + ); + + // Òîëüêî ñèìâîëû cp1251: ñáîðêà ïåðåêîäèðóåò ôàéë. Ïàíåëü ñîáèðàåòñÿ çäåñü, à íå ÷åðåç + // setInfo(): òîò íà UTF-8-áàçå ïðîïóñêàåò áëîê ÷åðåç __() ñî strip_tags è âûðåçàåò òàáëèöó. + $link = static function (string $url, string $text): string { + return '' . $text . ''; + }; + $kassa = $link('https://kassa.payanyway.ru/', 'kassa.payanyway.ru'); + + $accountRows = [ + ['Ïñåâäîíèì', 'ëþáîé, íàïðèìåð Ìîíåòà'], + ['Òåñòîâûé ðåæèì', 'êàê â íàñòðîéêå ìîäóëÿ'], + ['Check URL è Pay URL', '' . $callbackUrl . ''], + ['HTTP-ìåòîä (Check URL, Pay URL)', 'POST èëè GET'], + [ + 'Êîä ïðîâåðêè öåëîñòíîñòè äàííûõ', + 'ïðîèçâîëüíûé íàáîð ñèìâîëîâ; òîò æå êîä óêàæèòå â íàñòðîéêàõ ìîäóëÿ', + ], + ['Ïîäïèñü ôîðìû îïëàòû îáÿçàòåëüíà', 'Äà'], + ['Ìîæíî ïåðåîïðåäåëÿòü íàñòðîéêè â URL', 'Äà'], + ['Success URL', 'îñòàâèòü ïóñòûì'], + ['Fail URL', 'îñòàâèòü ïóñòûì'], + ['InProgress URL', 'îñòàâèòü ïóñòûì'], + ['Return URL', 'îñòàâèòü ïóñòûì'], + ]; + $accountTable = ''; + foreach ($accountRows as [$field, $value]) { + $accountTable .= '' . $field . '' . $value . ''; + } + + $info = '

Íàñòðîéêà ñ÷¸òà â ëè÷íîì êàáèíåòå ' . $link('https://moneta.ru/', 'MONETA.RU') . '

+
    +
  1. ' . $link('https://payanyway.ru/partnerRegistration.htm', 'Çàðåãèñòðèðóéòåñü') . ' + â ïëàò¸æíîé ñèñòåìå PayAnyWay è çàïîëíèòå âñå íåîáõîäèìûå äàííûå. Äîæäèòåñü ïðîâåðêè àêêàóíòà + è ñîçäàéòå ðàñøèðåííûé ñ÷¸ò.
  2. +
  3. Çàïîëíèòå íàñòðîéêè ðàñøèðåííîãî ñ÷¸òà â ëè÷íîì êàáèíåòå: «Ìîé ñ÷¸ò» -> «Óïðàâëåíèå + ñ÷åòàìè» -> «Ðåäàêòèðîâàòü ñ÷¸ò» -> «Ïîêàçàòü äîïîëíèòåëüíûå ïîëÿ». + + + ' . $accountTable . ' +
    Ïîëå â ëè÷íîì êàáèíåòåÇíà÷åíèå
    +
  4. +
  5. Íà âêëàäêå «Îñíîâíîå» óêàæèòå íîìåð ñ÷¸òà è òîò æå êîä ïðîâåðêè öåëîñòíîñòè äàííûõ. + Íà äåìî-ïëîùàäêå demo.moneta.ru íîìåð ñ÷¸òà ñâîé.
  6. +
  7. Äëÿ ôèñêàëèçàöèè ÷åêîâ ïî 54-ÔÇ ñðåäñòâàìè çà ðàìêàìè PHPShop íàñòðîéòå âàøó êàññó â ñåðâèñå + ' . $kassa . ', â íàñòðîéêàõ ðàñøèðåííîãî ñ÷¸òà â MONETA.RU óñòàíîâèòå Pay URL + https://kassa.payanyway.ru/index.php?do=invoicepayurl, à â íàñòðîéêàõ êàññû + ïðîïèøèòå Pay URL âàøåãî èíòåðíåò-ìàãàçèíà ' . '' . $callbackUrl . '' . '.  ýòîì ñëó÷àå ÷åê ïî 54-ÔÇ + áóäåò ïðîáèâàòüñÿ ÷åðåç ' . $kassa . ', à çàïðîñ íà Pay URL ìàãàçèíà áóäåò ïðîõîäèòü + ÷åðåç ýòîò ñåðâèñ òðàíçèòîì.
  8. +
+

Âàæíî! Äëÿ êèðèëëè÷åñêîãî äîìåíà Check URL è Pay URL óêàæèòå + â êîäèðîâêå ' . $link('https://2ip.ru/punycode/', 'Punycode') . '.

+

Âûêëþ÷åíèå ìîäóëÿ

+

Âûêëþ÷åíèå ìîäóëÿ â PHPShop óäàëÿåò åãî òàáëèöû: íàñòðîéêè ïðèä¸òñÿ ââåñòè çàíîâî.

'; + $Tab2 = '
' . HostCharset::toPage($info) . '
'; + + $Tab3 = $PHPShopGUI->setPay(false, false, $data['version'], true); + + $PHPShopGUI->setTab(['Îñíîâíîå', $Tab1, true], ['Èíñòðóêöèÿ', $Tab2], ['Î ìîäóëå', $Tab3]); + + $ContentFooter = $PHPShopGUI->setInput('hidden', 'rowID', $data['id']) + . $PHPShopGUI->setInput('submit', 'saveID', 'Ïðèìåíèòü', 'right', 80, '', 'but', 'actionUpdate.modules.edit'); + + $PHPShopGUI->setFooter($ContentFooter); + + return true; +} + +// Îáðàáîòêà ñîáûòèé +$PHPShopGUI->getAction(); + +// Âûâîä ôîðìû ïðè ñòàðòå +$PHPShopGUI->setLoader($_POST['saveID'] ?? null, 'actionStart'); diff --git a/moneta/admpanel/admin_moneta.php b/moneta/admpanel/admin_moneta.php new file mode 100644 index 0000000..e17b285 --- /dev/null +++ b/moneta/admpanel/admin_moneta.php @@ -0,0 +1,107 @@ + Ìîíåòà -> Æóðíàë îïåðàöèé). + * + * Êàê ôàéë ïîïàäàåò â àäìèíêó: ïóíêò ïîäìåíþ â `install/module.xml` (`podmenu_action` = `dir.moneta`) + * âåä¸ò íà `admin.php?path=modules.dir.moneta`. `admpanel/admin.php` ÿäðà ðåæåò ïóòü ïî òî÷êàì + * è ïîäêëþ÷àåò `admpanel/modules/admin_dir.php`, à òîò — `modules/<òðåòüÿ ÷àñòü ïóòè>/admpanel/ + * admin_<òðåòüÿ ÷àñòü ïóòè>.php`, òî åñòü ýòîò ôàéë (èìÿ ôàéëà ìåíÿòü íåëüçÿ). Ïåðåä ýòèì ÿäðî + * çàïîëíÿåò ãëîáàëüíûå `$TitlePage` è `$select_name` (çàãîëîâîê è ïóíêòû ïîäìåíþ), ïîñëå — âûçûâàåò + * ãëîáàëüíóþ ôóíêöèþ `actionStart()`: èìÿ òîæå çàäà¸ò ÿäðî (`$loader_function`). Òàê æå â 6.7 è 7.0. + * + * Ïîäïèñü çàïðîñà è êîä ïðîâåðêè öåëîñòíîñòè â æóðíàë íå ïèøóòñÿ. + */ + +require_once dirname(__DIR__) . '/class/autoload.php'; + +use MonetaPhpShop\callback\CallbackEvent; +use MonetaPhpShop\host\Database; +use MonetaPhpShop\host\HostCharset; +use MonetaPhpShop\host\OperationRepository; +use MonetaPhpShop\protocol\TransactionId; + +function actionStart() +{ + global $PHPShopInterface, $TitlePage, $select_name; + + // Íà ñòðàíèöó îòäà¸ì òîëüêî 500 ïîñëåäíèõ çàïèñåé, íîâûå ñâåðõó. Ïîñòðàíè÷íûé âûâîä, ïîèñê + // è ñîðòèðîâêó äåëàåò DataTables, êîòîðûì ÿäðî îáîðà÷èâàåò òàáëèöó àäìèíêè (admpanel/js/phpshop.js), + // — â áðàóçåðå, ïî óæå âûâåäåííûì ñòðîêàì; áåç ëèìèòà ñòðàíèöà ðîñëà áû âìåñòå ñ æóðíàëîì. + // À îí íå ÷èñòèòñÿ è ðàñò¸ò áûñòðî: íà êàæäîå óâåäîìëåíèå ñòðîêà, íà çàêàç èõ ìèíèìóì äâå + // (Check URL è Pay URL), ïðîâåðî÷íûé çàïðîñ MONETA.RU ìîæåò ïðèñëàòü íåñêîëüêî ðàç, à Pay URL + // ïîñëå îòâåòà FAIL ïîâòîðÿåò äî ñóòîê. Ñòàðûå çàïèñè îñòàþòñÿ â òàáëèöå, íà ýêðàí íå âûâîäÿòñÿ. + $limit = 500; + + $escape = static function (string $value): string { + return htmlspecialchars($value, ENT_QUOTES, 'Windows-1251'); + }; + // Íà ìàãàçèíå ñ áàçîé utf-8 ÿäðî ïåðåêîäèðóåò çàãîëîâêè setCaption(), íî íå ÿ÷åéêè setRow() + // è íå ðàçìåòêó ïàíåëè — òåêñòû ìîäóëÿ ïåðåêîäèðóþòñÿ çäåñü. Ââåä¸ííîå â ïîèñê è ñòðîêè + // æóðíàëà (ASCII) óæå â êîäèðîâêå ñòðàíèöû. + $page = static function (string $text): string { + return HostCharset::toPage($text); + }; + + // Ôèëüòð ïî çàêàçó: íîìåð (`33-57`) èëè öåëèêîì MNT_TRANSACTION_ID èç êàáèíåòà MONETA.RU. + // Èùåò ïî âñåìó æóðíàëó, à íå òîëüêî ïî âûâåäåííûì ñòðîêàì, êàê ïîèñê DataTables. + $input = isset($_GET['moneta_order']) && is_string($_GET['moneta_order']) ? trim($_GET['moneta_order']) : ''; + $orderUid = $input !== '' ? TransactionId::orderUidFromInput($input) : null; + + $PHPShopInterface->checkbox_action = false; + $PHPShopInterface->setActionPanel($TitlePage, $select_name, false); + // Ïîä ïàíåëüþ ñ çàãîëîâêîì, âíå ôîðì ÿäðà: Compile() âûâîäèò actionPanel ïåðåä òàáëèöåé. + $PHPShopInterface->actionPanel .= '
' + . '' + . ' ' + . ' ' + . ($input !== '' ? 'Ñáðîñèòü' : '') + . ($input !== '' && $orderUid === null + ? ' Íîìåð çàêàçà íå ðàñïîçíàí — ïîêàçàí âåñü æóðíàë' + : '') + . '
', + ); + $PHPShopInterface->setCaption( + ['Äàòà', '12%'], + ['Çàêàç', '8%'], + ['Çàïðîñ', '8%'], + ['Ñîáûòèå', '30%'], + ['Îòâåò', '6%'], + ['Ñóììà', '8%'], + ['Íîìåð îïåðàöèè', '8%'], + ['Èäåíòèôèêàòîð çàêàçà', '20%'] + ); + + $rows = (new OperationRepository(Database::fromHost()))->latest($limit, $orderUid); + foreach ($rows as $row) { + $orderId = (int) $row['order_id']; + $order = $orderId > 0 + ? ['name' => $escape((string) $row['order_uid']), 'link' => '?path=order&id=' . $orderId] + : $page('—'); + $command = (string) $row['command'] === 'CHECK' ? 'Ïðîâåðêà' : 'Îïëàòà'; + if ((string) $row['test_mode'] === '1') { + $command .= ' (òåñò)'; + } + + $resultCode = $row['result_code'] === null ? null : (int) $row['result_code']; + $icon = CallbackEvent::iconHtml((string) $row['event'], $resultCode); + $event = ($icon === '' ? '' : $icon . ' ') . $escape(CallbackEvent::label((string) $row['event'])); + + $PHPShopInterface->setRow( + PHPShopDate::get((int) $row['created_at'], true), + $order, + $page($command), + $page($event), + $row['result_code'] === null ? 'FAIL' : (string) $row['result_code'], + ['name' => $escape((string) $row['amount']), 'align' => 'right'], + $escape((string) $row['operation_id']), + $escape((string) $row['transaction_id']) + ); + } + + $PHPShopInterface->Compile(); +} diff --git a/moneta/class/CmsInfo.php b/moneta/class/CmsInfo.php new file mode 100644 index 0000000..d1cd33a --- /dev/null +++ b/moneta/class/CmsInfo.php @@ -0,0 +1,43 @@ +_PHP <âåðñèÿ>_Moneta v<âåðñèÿ>` + * Âåðñèÿ ìîäóëÿ — êàê å¸ õðàíèò PHPShop (`ModuleInfo::VERSION`, äâà ÷èñëà): `Moneta v1.0`. + */ +final class CmsInfo +{ + private const CMS_NAME = 'PHPShop'; + private const MODULE_NAME = 'Moneta'; + private const SEPARATOR = '_'; + + private function __construct() {} + + public static function format(string $cmsVersion, string $phpVersion, string $moduleVersion): string + { + return sprintf( + '%s v%s' . self::SEPARATOR . 'PHP %s' . self::SEPARATOR . '%s v%s', + self::CMS_NAME, + $cmsVersion, + $phpVersion, + self::MODULE_NAME, + $moduleVersion, + ); + } + + /** + * Âåðñèÿ PHPShop èç `[upload] version` êîíôèãà ÿäðà: `670` -> `6.7.0`. + */ + public static function hostVersion(string $uploadVersion): string + { + if (preg_match('/\A[0-9]{3,}\z/', $uploadVersion) !== 1) { + return 'unknown'; + } + + return implode('.', str_split($uploadVersion)); + } +} diff --git a/moneta/class/ModuleInfo.php b/moneta/class/ModuleInfo.php new file mode 100644 index 0000000..029c70d --- /dev/null +++ b/moneta/class/ModuleInfo.php @@ -0,0 +1,36 @@ +` (`PHPShopGUI::setPay()`), + * à òðè ÷èñëà PHP ñðàâíèâàåò ïîñèìâîëüíî.  `MNT_CMS` óõîäèò òàê æå ({@see CmsInfo::format()}). + * + * Äîëæíà áûòü áîëüøå {@see self::BUILT_IN_VERSION}: èíà÷å ó ìåð÷àíòà ñî âñòðîåííûì ìîäóëåì + * íå ïîÿâèòñÿ êíîïêà «Îáíîâèòü ìîäóëü» è ìèãðàöèÿ `updates/1.2/` íå çàïóñòèòñÿ. + */ + public const VERSION = '2.0'; + + /** + * Âåðñèÿ, êîòîðóþ âñòðîåííûé ìîäóëü PHPShop 6.7.0 çàïèñûâàåò â òàáëèöó íàñòðîåê ïðè óñòàíîâêå + * (â åãî `module.xml` ïðè ýòîì `1.0`). Ïî íåé ÿäðî âûáèðàåò ôàéë ìèãðàöèè `updates/<âåðñèÿ>/`. + */ + public const BUILT_IN_VERSION = '1.2'; + + private function __construct() {} +} diff --git a/moneta/class/Money.php b/moneta/class/Money.php new file mode 100644 index 0000000..0c41817 --- /dev/null +++ b/moneta/class/Money.php @@ -0,0 +1,113 @@ +value = $value; + } + + /** + * Ñòðîãèé ðàçáîð äåñÿòè÷íîé ñòðîêè; âñ¸, ÷òî íå `^\d+\.\d{2}$`, îòêëîíÿåòñÿ. + */ + public static function fromDecimal(string $value): self + { + if (preg_match(self::PATTERN, $value) !== 1) { + throw new InvalidArgumentException('Ñóììà äîëæíà áûòü äåñÿòè÷íîé ñòðîêîé ñ äâóìÿ çíàêàìè ïîñëå òî÷êè.'); + } + + $money = new self($value); + if ($money->toMinorUnits() > (new self(self::MAX))->toMinorUnits()) { + throw new InvalidArgumentException('Ñóììà ïðåâûøàåò äîïóñòèìûé ìàêñèìóì.'); + } + + return $money; + } + + /** + * Ñóììà â êîïåéêàõ. + */ + public static function fromMinorUnits(int $minorUnits): self + { + if ($minorUnits < 0) { + throw new InvalidArgumentException('Ñóììà íå ìîæåò áûòü îòðèöàòåëüíîé.'); + } + + return self::fromDecimal(sprintf('%d.%02d', intdiv($minorUnits, 100), $minorUnits % 100)); + } + + /** + * Ãðàíèöà ñ õîñòîì: îêðóãëåíèå äî êîïååê äåëàåòñÿ çäåñü îäèí ðàç, äàëüøå `float` íå èñïîëüçóåòñÿ. + */ + public static function fromFloat(float $amount): self + { + if (($amount < 0) || !is_finite($amount)) { + throw new InvalidArgumentException('Ñóììà õîñòà äîëæíà áûòü êîíå÷íûì íåîòðèöàòåëüíûì ÷èñëîì.'); + } + + return self::fromDecimal(number_format($amount, 2, '.', '')); + } + + public function toDecimal(): string + { + return $this->value; + } + + public function toMinorUnits(): int + { + [$units, $cents] = explode('.', $this->value); + + return ((int) $units * 100) + (int) $cents; + } + + public function isPositive(): bool + { + return ($this->value !== '0.00'); + } + + public function equals(self $other): bool + { + return ($this->value === $other->value); + } + + public function add(self $other): self + { + return self::fromMinorUnits($this->toMinorUnits() + $other->toMinorUnits()); + } + + public function multiply(int $quantity): self + { + if ($quantity < 0) { + throw new InvalidArgumentException('Êîëè÷åñòâî íå ìîæåò áûòü îòðèöàòåëüíûì.'); + } + + return self::fromMinorUnits($this->toMinorUnits() * $quantity); + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/moneta/class/PhoneNumber.php b/moneta/class/PhoneNumber.php new file mode 100644 index 0000000..67bf9c0 --- /dev/null +++ b/moneta/class/PhoneNumber.php @@ -0,0 +1,43 @@ +` — äëÿ `MNT_SUBSCRIBER_ID` è äàííûõ ÷åêà. + * PHPShop õðàíèò åãî êàê ââ¸ë ïîêóïàòåëü: `+7(926) 111-11-11`, `8 926 111 11 11`, `9261111111`. + */ +final class PhoneNumber +{ + /** E.164: íå áîëüøå 15 öèôð. */ + private const MAX_DIGITS = 15; + private const MIN_DIGITS = 10; + + private function __construct() {} + + /** + * Íîðìàëèçîâàííûé íîìåð èëè null, åñëè ñòðîêà íà òåëåôîí íå ïîõîæà. + * Ðîññèéñêèå íîìåðà ïðèâîäÿòñÿ ê `+7`: `8XXXXXXXXXX` è äåñÿòèçíà÷íûé `9XXXXXXXXX`. + */ + public static function normalize(string $raw): ?string + { + if (preg_match('/[^0-9+()\-\s.]/', $raw) === 1) { + return null; + } + + $digits = preg_replace('/[^0-9]/', '', $raw) ?? ''; + if ((strlen($digits) === 11) && ($digits[0] === '8')) { + $digits = '7' . substr($digits, 1); + } elseif ((strlen($digits) === 10) && ($digits[0] === '9')) { + $digits = '7' . $digits; + } + + $length = strlen($digits); + if (($length < self::MIN_DIGITS) || ($length > self::MAX_DIGITS)) { + return null; + } + + return '+' . $digits; + } +} diff --git a/moneta/class/SelectOptions.php b/moneta/class/SelectOptions.php new file mode 100644 index 0000000..89006a7 --- /dev/null +++ b/moneta/class/SelectOptions.php @@ -0,0 +1,29 @@ + $labels çíà÷åíèå -> ïîäïèñü, â ïîðÿäêå âûâîäà + * @param string $current çíà÷åíèå èç íàñòðîåê — ïóíêò ñ íèì áóäåò âûáðàí + * + * @return array + */ + public static function fromLabels(array $labels, string $current): array + { + $options = []; + foreach ($labels as $value => $label) { + $options[] = [$label, (string) $value, $current]; + } + + return $options; + } +} diff --git a/moneta/class/Settings.php b/moneta/class/Settings.php new file mode 100644 index 0000000..33f1167 --- /dev/null +++ b/moneta/class/Settings.php @@ -0,0 +1,220 @@ + $row ñòðîêà òàáëèöû íàñòðîåê + */ + public static function fromRow(array $row): self + { + $settings = new self(); + $settings->accountId = trim(self::string($row, 'account_id')); + $settings->integrityCode = self::string($row, 'integrity_code'); + $settings->paymentServer = PaymentServer::fromSetting(self::string($row, 'payment_server')); + $settings->testMode = self::string($row, 'test_mode') === '1'; + $settings->fiscalization = self::string($row, 'fiscalization') === '1'; + $settings->receiptEmail = trim(self::string($row, 'receipt_email')); + $settings->paymentDescription = trim(self::string($row, 'title_end')); + $settings->paymentMethod = PaymentMethod::fromSetting(self::string($row, 'payment_method')); + $settings->paymentObject = PaymentObject::fromSetting(self::string($row, 'payment_object')); + $settings->taxSystem = TaxSystem::fromSetting(self::string($row, 'sno')); + $settings->goodsVat = VatSetting::fromSetting(self::string($row, 'goods_vat')); + $settings->deliveryVat = VatSetting::fromSetting(self::string($row, 'delivery_vat')); + $settings->payableStatus = (int) self::string($row, 'status'); + $paidStatus = (int) self::string($row, 'paid_status'); + $settings->paidStatus = ($paidStatus > 0) ? $paidStatus : self::STATUS_PAID_BY_GATEWAY; + $settings->version = self::string($row, 'version'); + + return $settings; + } + + /** + * Ìîæíî ïðèíèìàòü îïëàòó: çàäàí íîìåð ñ÷¸òà (òîëüêî öèôðû) è êîä ïðîâåðêè öåëîñòíîñòè äàííûõ. + */ + public function isConfigured(): bool + { + return preg_match('/\A[0-9]{1,20}\z/', $this->accountId) === 1 && $this->integrityCode !== ''; + } + + public function getAccountId(): string + { + return $this->accountId; + } + + public function signature(): Signature + { + return new Signature($this->integrityCode); + } + + /** + * Êîä ïðîâåðêè öåëîñòíîñòè äàííûõ íå âûâîäèòñÿ â var_dump()/print_r() è æóðíàëû, êàê ó {@see Signature}; + * âèäíî òîëüêî, çàäàí ëè îí. + * + * @return array + */ + public function __debugInfo(): array + { + $properties = get_object_vars($this); + $properties['integrityCode'] = ($this->integrityCode === '') ? '' : '***'; + + return $properties; + } + + public function getPaymentServer(): PaymentServer + { + return $this->paymentServer; + } + + public function isTestMode(): bool + { + return $this->testMode; + } + + public function isFiscalization(): bool + { + return $this->fiscalization; + } + + public function getReceiptEmail(): string + { + return $this->receiptEmail; + } + + /** + * «Îïèñàíèå îïëàòû» (êîëîíêà `title_end`, êàê ó øòàòíûõ ìîäóëåé PHPShop è âñòðîåííîé «Ìîíåòû»): + * òåêñò íàä ñóììîé è êíîïêîé îïëàòû. Îáû÷íûé òåêñò â êîäèðîâêå ÁÄ; ïóñòî — áåç òåêñòà. + */ + public function getPaymentDescription(): string + { + return $this->paymentDescription; + } + + public function getPaymentMethod(): PaymentMethod + { + return $this->paymentMethod; + } + + public function getPaymentObject(): PaymentObject + { + return $this->paymentObject; + } + + public function getTaxSystem(): TaxSystem + { + return $this->taxSystem; + } + + public function getGoodsVat(): VatSetting + { + return $this->goodsVat; + } + + public function getDeliveryVat(): VatSetting + { + return $this->deliveryVat; + } + + /** + * Ñòàòóñ, ïðè êîòîðîì ïîêóïàòåëþ äîñòóïíà îïëàòà; {@see self::STATUS_NEW} — ñðàçó ïîñëå îôîðìëåíèÿ. + */ + public function getPayableStatus(): int + { + return $this->payableStatus; + } + + public function isPayableRightAfterCheckout(): bool + { + return $this->payableStatus === self::STATUS_NEW; + } + + /** + * Ñòàòóñ, â êîòîðûé ìîäóëü ïåðåâîäèò çàêàç ïîñëå ïîäòâåðæäåíèÿ îïëàòû. + */ + public function getPaidStatus(): int + { + return $this->paidStatus; + } + + public function getVersion(): string + { + return $this->version; + } + + /** + * @param array $row + */ + private static function string(array $row, string $key): string + { + $value = $row[$key] ?? ''; + + return is_scalar($value) ? (string) $value : ''; + } +} diff --git a/moneta/class/SettingsWriteGuard.php b/moneta/class/SettingsWriteGuard.php new file mode 100644 index 0000000..516838f --- /dev/null +++ b/moneta/class/SettingsWriteGuard.php @@ -0,0 +1,109 @@ + $server `$_SERVER` + */ + public static function denialReason(bool $canEditModules, array $server): ?string + { + if (!$canEditModules) { + return self::DENIED_RIGHTS; + } + if (self::header($server, 'REQUEST_METHOD') !== 'POST') { + return self::DENIED_METHOD; + } + + return self::isSameOrigin($server) ? null : self::DENIED_ORIGIN; + } + + /** + * Origin (áðàóçåð ñòàâèò åãî ñàì, ñòðàíèöà ÷óæîãî ñàéòà ïîäìåíèòü íå ìîæåò), à åñëè åãî íåò — + * Referer, äîëæåí óêàçûâàòü íà òîò æå õîñò, íà êîòîðûé ïðèø¸ë çàïðîñ. Áåç îáîèõ — îòêàç: + * òàê æå ÿäðî íå ïóñêàåò áåç Referer âîññòàíîâëåíèå ïàðîëÿ â àäìèíêó. + * + * Õîñò çàïðîñà — `Host` è, çà îáðàòíûì ïðîêñè, `X-Forwarded-Host`: ïîäñòàâèòü ñâîé çàãîëîâîê + * â ìåæñàéòîâûé çàïðîñ áðàóçåð ñòðàíèöå íå äàñò. + * + * @param array $server + */ + public static function isSameOrigin(array $server): bool + { + $origin = self::header($server, 'HTTP_ORIGIN'); + $source = ($origin !== '') ? $origin : self::header($server, 'HTTP_REFERER'); + $sourceHost = self::urlHost($source); + if ($sourceHost === null) { + return false; + } + + $requestHosts = explode(',', self::header($server, 'HTTP_X_FORWARDED_HOST')); + $requestHosts[] = self::header($server, 'HTTP_HOST'); + foreach ($requestHosts as $requestHost) { + $requestHost = trim($requestHost); + if ($requestHost !== '' && self::normalizeHost($requestHost) === $sourceHost) { + return true; + } + } + + return false; + } + + /** + * Õîñò è ïîðò èç àáñîëþòíîãî àäðåñà http(s) èëè null. Origin `null` (ïåñî÷íèöà, data:) — null. + */ + private static function urlHost(string $url): ?string + { + $parts = parse_url($url); + if (!is_array($parts) || !isset($parts['scheme'], $parts['host'])) { + return null; + } + if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) { + return null; + } + + return self::normalizeHost($parts['host'] . (isset($parts['port']) ? ':' . $parts['port'] : '')); + } + + /** + * Íèæíèé ðåãèñòð, áåç ñòàíäàðòíûõ ïîðòîâ: áðàóçåð íå ïèøåò `:443` â Origin, à `Host` çà + * ïðîêñè èíîãäà ïèøåò. + */ + private static function normalizeHost(string $host): string + { + return (string) preg_replace('/:(80|443)\z/', '', strtolower($host)); + } + + /** + * @param array $server + */ + private static function header(array $server, string $name): string + { + $value = $server[$name] ?? ''; + + return is_string($value) ? trim($value) : ''; + } +} diff --git a/moneta/class/autoload.php b/moneta/class/autoload.php new file mode 100644 index 0000000..0a7ed0c --- /dev/null +++ b/moneta/class/autoload.php @@ -0,0 +1,22 @@ +` -> `class/<ïóòü>.php`. + * + * Composer ó PHPShop íåò, ïîýòîìó ìîäóëü ðåãèñòðèðóåò ñâîé çàãðóç÷èê; õîñò ñâîè + * êëàññû ïîäêëþ÷àåò ÷åðåç `PHPShopObj::loadClass()`, êîíôëèêòîâ íåò. + */ +spl_autoload_register(static function (string $class): void { + $prefix = 'MonetaPhpShop\\'; + if (strncmp($class, $prefix, strlen($prefix)) !== 0) { + return; + } + + $relative = str_replace('\\', '/', substr($class, strlen($prefix))); + $file = __DIR__ . '/' . $relative . '.php'; + if (is_file($file)) { + require_once $file; + } +}); diff --git a/moneta/class/callback/CallbackController.php b/moneta/class/callback/CallbackController.php new file mode 100644 index 0000000..8aab023 --- /dev/null +++ b/moneta/class/callback/CallbackController.php @@ -0,0 +1,369 @@ +settings = $settings; + $this->shopCurrency = $shopCurrency; + $this->cms = $cms; + $this->orderGateway = $orderGateway; + $this->operationJournal = $operationJournal; + $this->orderLock = $orderLock; + $this->receiptSource = $receiptSource; + $this->logger = $logger; + $this->rejectionLogger = $rejectionLogger ?? $logger; + } + + /** + * Ïàðàìåòðû óâåäîìëåíèÿ èç àäðåñà è òåëà âìåñòå, ïðè ñîâïàäåíèè èì¸í — èç òåëà. + * Ìåòîä çàäà¸òñÿ â íàñòðîéêàõ ñ÷¸òà MONETA.RU, à ïðè POST MONETA.RU ïåðåíîñèò â òåëî è + * ïàðàìåòðû èç àäðåñà Check URL. + * Ëèøíåå èç àäðåñà íå îïàñíî: êàæäîå ïîëå ïîäïèñè âñ¸ ðàâíî ñâåðÿåòñÿ. + * + * @param array $query `$_GET` + * @param array $body `$_POST` + * + * @return array + */ + public static function requestFields(array $query, array $body): array + { + return array_replace($query, $body); + } + + /** + * @param array $fields ïàðàìåòðû çàïðîñà ({@see requestFields()}) + */ + public function handle(array $fields): CallbackResponse + { + if (!$this->settings->isConfigured()) { + $this->rejectionLogger->error( + 'Óâåäîìëåíèå îòêëîíåíî: ìîäóëü íå íàñòðîåí (íîìåð ñ÷¸òà, êîä ïðîâåðêè öåëîñòíîñòè äàííûõ).', + ); + + return CallbackResponse::fail(); + } + + try { + $notification = new CallbackNotification($fields); + } catch (InvalidArgumentException $exception) { + $this->rejectionLogger->error('Óâåäîìëåíèå îòêëîíåíî: ' . $exception->getMessage()); + + return CallbackResponse::fail(); + } + + if ( + $notification->getAccountId() !== $this->settings->getAccountId() + || !$notification->isSignedBy($this->settings->signature()) + ) { + $this->rejectionLogger->error('Óâåäîìëåíèå îòêëîíåíî: íîìåð ñ÷¸òà èëè ïîäïèñü íå ñîâïàäàþò.'); + + return CallbackResponse::fail(); + } + + $transactionId = TransactionId::parse($notification->getTransactionId()); + $order = ($transactionId === null) ? null : $this->orderGateway->findByUid($transactionId->getOrderUid()); + if ($transactionId === null || $order === null) { + return $this->unknownOrder($notification); + } + + if (!$this->orderLock->acquire($order->getId())) { + $this->operationJournal->note($notification, $order, CallbackEvent::LOCK_TIMEOUT, null); + + return CallbackResponse::fail(); + } + + try { + // Âíóòðè áëîêèðîâêè — ñâåæèé ñíèìîê: ïàðàëëåëüíîå óâåäîìëåíèå ìîãëî åãî èçìåíèòü. + $order = $this->orderGateway->findByUid($transactionId->getOrderUid()) ?? $order; + + return $this->handleLocked($notification, $transactionId, $order); + } finally { + $this->orderLock->release($order->getId()); + } + } + + private function handleLocked( + CallbackNotification $notification, + TransactionId $transactionId, + Order $order + ): CallbackResponse { + $mismatch = $this->mismatch($notification, $transactionId, $order); + if ($mismatch !== null) { + $this->operationJournal->note($notification, $order, $mismatch, null); + + return CallbackResponse::fail(); + } + + return $notification->isCheck() + ? $this->check($notification, $order) + : $this->pay($notification, $order); + } + + private function mismatch(CallbackNotification $notification, TransactionId $transactionId, Order $order): ?string + { + if ($notification->getCurrency() !== AssistantProtocol::CURRENCY_RUB) { + return CallbackEvent::CURRENCY_MISMATCH; + } + + if ($notification->isTestMode() !== $this->settings->isTestMode()) { + return CallbackEvent::TEST_MODE_MISMATCH; + } + + // Ñóììà ôîðìû çàøèòà â íîìåð è çàêðûòà ïîäïèñüþ ({@see TransactionId}): ñ íåé ñâåðÿåòñÿ + // è ñóììà óâåäîìëåíèÿ, è çàêàç — èíà÷å ïåðâûå öèôðû ñóììû ôîðìû, ïåðåíåñ¸ííûå â + // MNT_OPERATION_ID, ïðîâåëè áû çàêàç, ïîäåøåâåâøèé ïîñëå ïîêàçà ôîðìû. + $amount = $notification->getAmount(); + if ($amount !== null && !$amount->equals($transactionId->getAmount())) { + return CallbackEvent::AMOUNT_MISMATCH; + } + if (!$transactionId->getAmount()->equals($order->getAmount())) { + return CallbackEvent::ORDER_AMOUNT_CHANGED; + } + + return null; + } + + private function check(CallbackNotification $notification, Order $order): CallbackResponse + { + $event = CallbackEvent::CHECKED; + if ($this->isPaid($order)) { + $resultCode = ResultCode::paid(); + } else { + $eligibility = PaymentEligibility::check($this->settings, $order, $this->shopCurrency); + if ($eligibility === PaymentEligibility::PAYABLE) { + $resultCode = ($notification->getAmount() === null) + ? ResultCode::withAmount() + : ResultCode::awaitingPayment(); + } else { + $resultCode = ResultCode::rejected(); + $event = $eligibility; + } + } + + $response = new CheckResponse( + $this->settings->getAccountId(), + $notification->getTransactionId(), + $order->getAmount(), + $resultCode, + $this->settings->signature(), + $this->cms, + ); + + if (!$this->settings->isFiscalization()) { + $this->operationJournal->note($notification, $order, $event, $resultCode); + + return $response->toXml($this->receiptSource->attributes($order)); + } + + $receipt = $this->receiptSource->jsonReceipt($order); + if ($receipt === null) { + $this->operationJournal->note($notification, $order, CallbackEvent::RECEIPT_UNAVAILABLE, null); + + return CallbackResponse::fail(); + } + $this->operationJournal->note($notification, $order, $event, $resultCode); + + return $response->toJson($receipt); + } + + private function pay(CallbackNotification $notification, Order $order): CallbackResponse + { + $paidOperation = $this->operationJournal->findPaidOperation($order->getId()); + if ($paidOperation !== null) { + if ($paidOperation === $notification->getOperationId()) { + return $this->payResponse($notification, $order, CallbackEvent::REPEAT, ResultCode::paid()); + } + // Âòîðàÿ îïåðàöèÿ ïî óæå îïëà÷åííîìó çàêàçó: ïîêóïàòåëü çàïëàòèë äâàæäû, äåíüãè ñïèñàíû. + // Îòâå÷àåì FAIL — ìàãàçèí íå ïîäòâåðæäàåò óâåäîìëåíèå: îïåðàöèÿ â MONETA.RU íå ïåðåõîäèò + // â «Âûïîëíåíî», îñòà¸òñÿ «Óâåäîìëåíèå íå îòïðàâëåíî», Pay URL ïîâòîðÿåòñÿ äî ñóòîê + // (MONETA.Assistant.ru.pdf) — òàê å¸ âèäíî â êàáèíåòå ñðåäè îáû÷íûõ îïëàò. Çà÷åñòü å¸ ìîäóëü + // íå ìîæåò; ñòðîêà â æóðíàëå — ñèãíàë àäìèíèñòðàòîðó ñäåëàòü âîçâðàò â êàáèíåòå MONETA.RU. + $this->operationJournal->note($notification, $order, CallbackEvent::DUPLICATE_OPERATION, null); + + return CallbackResponse::fail(); + } + + if ($order->getStatusId() === PaymentEligibility::STATUS_CANCELLED) { + // Îòìåí¸ííûé çàêàç: ïîâòîðû áåññìûñëåííû, íî ñëåä â æóðíàëå îñòà¸òñÿ. + return $this->payResponse($notification, $order, PaymentEligibility::CANCELLED, ResultCode::rejected()); + } + if ($order->getPaymentMethodId() !== ModuleInfo::PAYMENT_METHOD_ID) { + // Ïîêà ïîêóïàòåëü ïëàòèë, â çàêàçå ñìåíèëè ñïîñîá îïëàòû íà äðóãîé. Îòâå÷àåì FAIL: + // MONETA.RU áóäåò ïîâòîðÿòü óâåäîìëåíèå, è îïëàòà çà÷ò¸òñÿ, êîãäà àäìèíèñòðàòîð + // âåðí¸ò çàêàçó ñïîñîá îïëàòû «Ìîíåòà». + $this->operationJournal->note($notification, $order, PaymentEligibility::OTHER_PAYMENT_METHOD, null); + + return CallbackResponse::fail(); + } + if (AssistantProtocol::normalizeCurrency($this->shopCurrency) === null) { + // Âàëþòó âèòðèíû ñìåíèëè ñ ðóáëåé ïîñëå ïîêàçà ôîðìû: ñóììà çàêàçà óæå íå â ðóáëÿõ. + // Òàê æå, êàê ñî ñïîñîáîì îïëàòû, — FAIL, ïîâòîðû è ñòðîêà â æóðíàëå äëÿ àäìèíèñòðàòîðà. + $this->operationJournal->note($notification, $order, PaymentEligibility::UNSUPPORTED_CURRENCY, null); + + return CallbackResponse::fail(); + } + + $event = CallbackEvent::ALREADY_PAID_IN_SHOP; + if (!$this->isPaid($order)) { + try { + $this->orderGateway->markPaid($order, $this->settings->getPaidStatus(), $order->getAmount()); + } catch (Throwable $exception) { + $this->logger->error( + sprintf( + 'Çàêàç %s íå ïåðåâåä¸í â îïëà÷åííûå: %s: %s', + $order->getUid(), + get_class($exception), + $exception->getMessage(), + ), + ); + $this->operationJournal->note($notification, $order, CallbackEvent::ORDER_UPDATE_FAILED, null); + + return CallbackResponse::fail(); + } + $event = CallbackEvent::PAID; + } + + // Îòìåòêó «îïëàòà çà÷òåíà» ïèøåì â æóðíàë òîëüêî ïîñëå òîãî, êàê çàêàç ñòàë îïëà÷åííûì. + // Åñëè çàïèñü â æóðíàë íå óäàëàñü, îòâå÷àåì FAIL, è MONETA.RU ïîâòîðèò óâåäîìëåíèå. + // Ïîâòîð óâèäèò, ÷òî çàêàç óæå îïëà÷åí, è òîëüêî äîïèøåò îòìåòêó + // (ñîáûòèå ALREADY_PAID_IN_SHOP): ñòàòóñ íå ìåíÿåòñÿ, âòîðîãî ïèñüìà ïîêóïàòåëþ íåò. + if (!$this->operationJournal->recordPaid($notification, $order, $event)) { + return CallbackResponse::fail(); + } + + return $this->response($notification, $order, ResultCode::paid()); + } + + /** + * Îïëà÷åí ïî äàííûì ìàãàçèíà èëè ïî æóðíàëó ìîäóëÿ (ìàðêåð ìîã çàïèñàòüñÿ, + * à ñòàòóñ ïîòîì ñìåíèë ìåíåäæåð). + */ + private function isPaid(Order $order): bool + { + return PaymentEligibility::isPaid($this->settings, $order) + || $this->operationJournal->findPaidOperation($order->getId()) !== null; + } + + private function payResponse( + CallbackNotification $notification, + Order $order, + string $event, + ResultCode $resultCode + ): CallbackResponse { + $this->operationJournal->note($notification, $order, $event, $resultCode); + + return $this->response($notification, $order, $resultCode); + } + + /** + * Îòâåò íà Pay URL: `MNT_ATTRIBUTES` — òîëüêî â óñïåøíîì (`200`), íåóñïåøíûé — áåç íèõ. + */ + private function response( + CallbackNotification $notification, + Order $order, + ResultCode $resultCode + ): CallbackResponse { + $attributes = $resultCode->equals(ResultCode::paid()) ? $this->receiptSource->attributes($order) : []; + + return (new PayResponse( + $this->settings->getAccountId(), + $notification->getTransactionId(), + $order->getAmount(), + $resultCode, + $this->settings->signature(), + $this->cms, + ))->toXml($attributes); + } + + private function unknownOrder(CallbackNotification $notification): CallbackResponse + { + if (!$notification->isCheck()) { + $this->operationJournal->note($notification, null, CallbackEvent::UNKNOWN_ORDER, null); + $this->logger->error( + sprintf( + 'Îïëàòà ïî íåèçâåñòíîìó çàêàçó %s, îïåðàöèÿ %s.', + $notification->getTransactionId(), + $notification->getOperationId(), + ), + ); + + return CallbackResponse::fail(); + } + + $resultCode = ResultCode::rejected(); + $this->operationJournal->note($notification, null, CallbackEvent::UNKNOWN_ORDER, $resultCode); + + // XML è ïðè âêëþ÷¸ííîé ôèñêàëèçàöèè: ÷åêà ó íåèçâåñòíîãî çàêàçà íåò, à JSON-ñõåìà + // `cmsspecification.pdf` òðåáóåò õîòÿ áû îäíó ïîçèöèþ. + return (new CheckResponse( + $this->settings->getAccountId(), + $notification->getTransactionId(), + null, + $resultCode, + $this->settings->signature(), + $this->cms, + ))->toXml(); + } +} diff --git a/moneta/class/callback/CallbackEvent.php b/moneta/class/callback/CallbackEvent.php new file mode 100644 index 0000000..0357483 --- /dev/null +++ b/moneta/class/callback/CallbackEvent.php @@ -0,0 +1,115 @@ + 'Ïðîâåðêà çàêàçà', + self::PAID => 'Îïëàòà çà÷òåíà', + self::ALREADY_PAID_IN_SHOP => 'Îïëàòà çà÷òåíà, çàêàç óæå áûë îïëà÷åí â ìàãàçèíå', + self::REPEAT => 'Ïîâòîð óâåäîìëåíèÿ', + self::DUPLICATE_OPERATION => 'Âòîðàÿ îïëàòà îïëà÷åííîãî çàêàçà: ñäåëàéòå âîçâðàò â ëè÷íîì êàáèíåòå MONETA.RU', + self::UNKNOWN_ORDER => 'Çàêàç íå íàéäåí', + self::ORDER_AMOUNT_CHANGED => 'Ñóììà çàêàçà èçìåíèëàñü ïîñëå ïîêàçà ôîðìû îïëàòû', + self::AMOUNT_MISMATCH => 'Ñóììà íå ñîâïàäàåò ñ çàêàçîì', + self::CURRENCY_MISMATCH => 'Âàëþòà íå ñîâïàäàåò', + self::TEST_MODE_MISMATCH => 'Òåñòîâûé ðåæèì íå ñîâïàäàåò ñ íàñòðîéêîé ìîäóëÿ', + self::ORDER_UPDATE_FAILED => 'Íå óäàëîñü ïåðåâåñòè çàêàç â îïëà÷åííûå', + self::RECEIPT_UNAVAILABLE => '×åê íå ñîáðàí', + self::LOCK_TIMEOUT => 'Çàêàç çàíÿò ïàðàëëåëüíûì óâåäîìëåíèåì', + PaymentEligibility::NOT_CONFIGURED => 'Ìîäóëü íå íàñòðîåí', + PaymentEligibility::OTHER_PAYMENT_METHOD => 'Ó çàêàçà äðóãîé ñïîñîá îïëàòû', + PaymentEligibility::ALREADY_PAID => 'Çàêàç óæå îïëà÷åí', + PaymentEligibility::CANCELLED => 'Çàêàç îòìåí¸í', + PaymentEligibility::AWAITING_STATUS => 'Çàêàç åù¸ íå ïîäòâåðæä¸í ìåíåäæåðîì', + PaymentEligibility::UNSUPPORTED_CURRENCY => 'Âàëþòà ìàãàçèíà íå ðóáëè', + PaymentEligibility::ZERO_AMOUNT => 'Íóëåâàÿ ñóììà çàêàçà', + ]; + + private function __construct() {} + + /** + * Çíà÷îê ñòðîêè æóðíàëà îïåðàöèé â àäìèíêå — glyphicon Bootstrap 3, êîòîðûé àäìèíêà PHPShop + * ïîñòàâëÿåò ñî ñâîèìè òåìàìè (`admpanel/fonts/`, êëàññû `glyphicon-*` — â 6.7 â êàæäîé òåìå, + * â 7.0 â `bootstrap.min.css`); öâåòà — êëàññû òåìû. Îïëàòà çà÷òåíà — çåë¸íàÿ ãàëî÷êà + * â êðóæêå, îòêàç ìîäóëÿ (`FAIL` èëè êîä 500) — êðàñíûé êðåñòèê, ïðîâåðêà çàêàçà — ñåðûé + * âîïðîñ; îñòàëüíîå (ïîâòîð óâåäîìëåíèÿ) — áåç çíà÷êà. + * + * @param int|null $resultCode `MNT_RESULT_CODE` îòâåòà ìîäóëÿ; null — îòâåò `FAIL` + */ + public static function iconHtml(string $event, ?int $resultCode): string + { + if ($event === self::PAID || $event === self::ALREADY_PAID_IN_SHOP) { + return self::glyphicon('ok-sign', 'text-success'); + } + if ($resultCode === null || $resultCode === ResultCode::REJECTED) { + return self::glyphicon('remove-sign', 'text-danger'); + } + if ($event === self::CHECKED) { + return self::glyphicon('question-sign', 'text-muted'); + } + + return ''; + } + + private static function glyphicon(string $name, string $color): string + { + return ''; + } + + /** + * Ïîäïèñü ñîáûòèÿ; íåèçâåñòíûé êîä (èç áóäóùåé âåðñèè) ïîêàçûâàåòñÿ êàê åñòü. + */ + public static function label(string $event): string + { + return self::LABELS[$event] ?? $event; + } +} diff --git a/moneta/class/callback/LogThrottle.php b/moneta/class/callback/LogThrottle.php new file mode 100644 index 0000000..b4a75f1 --- /dev/null +++ b/moneta/class/callback/LogThrottle.php @@ -0,0 +1,22 @@ + + */ + public function attributes(Order $order): array; + + /** + * Îáúåêò `receipt` JSON-îòâåòà Check URL èëè null, åñëè ÷åê íå ñîáðàòü. + * + * @return array|null + */ + public function jsonReceipt(Order $order): ?array; +} diff --git a/moneta/class/callback/ThrottledLogger.php b/moneta/class/callback/ThrottledLogger.php new file mode 100644 index 0000000..e7e7159 --- /dev/null +++ b/moneta/class/callback/ThrottledLogger.php @@ -0,0 +1,59 @@ +logger = $logger; + $this->throttle = $throttle; + $this->clock = $clock ?? 'time'; + } + + public function error(string $message): void + { + try { + $skipped = $this->throttle->pass(($this->clock)(), self::INTERVAL); + } catch (Throwable $exception) { + $this->logger->error($message); + + return; + } + + if ($skipped === null) { + return; + } + if ($skipped > 0) { + $message .= sprintf( + ' Ïåðåä ýòèì íå çàïèñàíî îòêàçîâ: %d (íå ÷àùå ñòðîêè â %d ñ).', + $skipped, + self::INTERVAL, + ); + } + $this->logger->error($message); + } +} diff --git a/moneta/class/form/AmountText.php b/moneta/class/form/AmountText.php new file mode 100644 index 0000000..2b3202a --- /dev/null +++ b/moneta/class/form/AmountText.php @@ -0,0 +1,27 @@ += 0) && ($decimals <= 2)) ? $decimals : 0; + + return number_format((float) $amount->toDecimal(), $decimals, '.', ' '); + } +} diff --git a/moneta/class/form/PaymentForm.php b/moneta/class/form/PaymentForm.php new file mode 100644 index 0000000..b1b2a0e --- /dev/null +++ b/moneta/class/form/PaymentForm.php @@ -0,0 +1,151 @@ +_<ñóììà>_UTC`): ïîâòîðíóþ îïëàòó + * óæå îïëà÷åííîãî çàêàçà îñòàíàâëèâàåò Check URL. + */ +final class PaymentForm +{ + /** Ïðåôèêñ ID ïîêóïàòåëÿ PHPShop â `MNT_SUBSCRIBER_ID`. */ + public const SUBSCRIBER_USER_PREFIX = 'USER_'; + + /** Òåêñò êíîïêè ïîñëå íàæàòèÿ; áåç êàâû÷åê è ðàçìåòêè — ïîäñòàâëÿåòñÿ â onsubmit. */ + private const REDIRECTING = 'Ïåðåõîäèì ê îïëàòå...'; + + /** @var string */ + private $action; + + /** @var array */ + private $fields; + + /** + * @param array $fields + */ + private function __construct(string $action, array $fields) + { + $this->action = $action; + $this->fields = $fields; + } + + /** + * @param string $currency `MNT_CURRENCY_CODE` ({@see AssistantProtocol::normalizeCurrency()}) + * @param ReturnUrls $returnUrls àäðåñà âîçâðàòà ïîêóïàòåëÿ + * @param string $description `MNT_DESCRIPTION` â êîäèðîâêå âèòðèíû + * @param string $cms `MNT_CMS` + * @param string $locale `moneta.locale` — ÿçûê ïëàò¸æíîé ôîðìû ({@see AssistantProtocol::normalizeLocale()}) + */ + public static function create( + Settings $settings, + Order $order, + string $currency, + DateTimeImmutable $issuedAt, + ReturnUrls $returnUrls, + string $description, + string $cms, + string $locale + ): self { + $amount = $order->getAmount(); + $transactionId = TransactionId::build($order->getUid(), $amount, $issuedAt); + $subscriberId = self::subscriberId($order); + $testMode = $settings->isTestMode(); + + $fields = [ + 'MNT_ID' => $settings->getAccountId(), + 'MNT_TRANSACTION_ID' => $transactionId, + 'MNT_CURRENCY_CODE' => $currency, + 'MNT_AMOUNT' => $amount->toDecimal(), + 'MNT_TEST_MODE' => AssistantProtocol::testModeFlag($testMode), + 'MNT_SUBSCRIBER_ID' => $subscriberId, + 'MNT_SIGNATURE' => $settings->signature()->forPaymentForm( + $settings->getAccountId(), + $transactionId, + $amount, + $currency, + $subscriberId, + $testMode, + ), + 'MNT_DESCRIPTION' => $description, + 'MNT_SUCCESS_URL' => $returnUrls->getSuccess(), + 'MNT_FAIL_URL' => $returnUrls->getFail(), + 'MNT_INPROGRESS_URL' => $returnUrls->getInProgress(), + 'MNT_RETURN_URL' => $returnUrls->getReturn(), + 'MNT_CMS' => $cms, + 'moneta.locale' => $locale, + ]; + + return new self($settings->getPaymentServer()->url(), $fields); + } + + /** + * `MNT_SUBSCRIBER_ID`: email -> òåëåôîí (`+öèôðû`) -> ID ïîêóïàòåëÿ PHPShop; ïóñòî — äàííûõ íåò + */ + public static function subscriberId(Order $order): string + { + if ($order->getEmail() !== '') { + return $order->getEmail(); + } + + $phone = PhoneNumber::normalize($order->getPhone()); + if ($phone !== null) { + return $phone; + } + + return ($order->getUserId() > 0) ? self::SUBSCRIBER_USER_PREFIX . $order->getUserId() : ''; + } + + public function getAction(): string + { + return $this->action; + } + + /** + * @return array + */ + public function getFields(): array + { + return $this->fields; + } + + /** + * HTML ôîðìû ñ îäíîé êíîïêîé; ñòðîêè â êîäèðîâêå âèòðèíû (cp1251). + * + * `accept-charset="UTF-8"`: áðàóçåð ïåðåêîäèðóåò çíà÷åíèÿ èç cp1251 ñòðàíèöû â UTF-8, + * êîòîðóþ æä¸ò Ìîíåòà. Êíîïêà ïîñëå íàæàòèÿ áëîêèðóåòñÿ (îò äâîéíîé îòïðàâêè), ïîêà îòêðûâàåòñÿ ñòðàíèöà Ìîíåòû. + */ + /** + * @param string $buttonTitle òåêñò êíîïêè, ýêðàíèðóåòñÿ + * @param string $buttonSuffixHtml ãîòîâûé HTML ïîñëå òåêñòà (çíàê âàëþòû âèòðèíû), íå ýêðàíèðóåòñÿ + */ + public function toHtml(string $buttonTitle, string $buttonSuffixHtml = ''): string + { + $html = '
'; + foreach ($this->fields as $name => $value) { + $html .= ''; + } + $html .= ''; + + return $html . '
'; + } + + private static function escape(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'Windows-1251'); + } +} diff --git a/moneta/class/form/ReturnAccess.php b/moneta/class/form/ReturnAccess.php new file mode 100644 index 0000000..450331e --- /dev/null +++ b/moneta/class/form/ReturnAccess.php @@ -0,0 +1,83 @@ +_<ñóììà>_<âðåìÿ>UTC`) óãàäûâàåòñÿ, ïîýòîìó îäíîãî åãî ìàëî — çàêàç äîëæåí ïðèíàäëåæàòü + * òîìó, êòî ñìîòðèò: + * - âîøåäøèé ïîêóïàòåëü — âëàäåëåö çàêàçà (`phpshop_orders.user`); + * - èëè ôîðìà îïëàòû ýòîãî çàêàçà ïîêàçûâàëàñü â ýòîé æå ñåññèè áðàóçåðà: òàê âèäèò ñòàòóñ + * ãîñòü, êîòîðûé îôîðìèë çàêàç áåç âõîäà (PHPShop çàâîäèò åìó ïîëüçîâàòåëÿ, íî íå âõîäèò). + */ +final class ReturnAccess +{ + /** Êëþ÷ `$_SESSION`: çàêàçû, ôîðìà îïëàòû êîòîðûõ ïîêàçûâàëàñü â ýòîé ñåññèè. */ + public const SESSION_KEY = 'moneta_payable_orders'; + + /** Ñêîëüêî çàêàçîâ ïîìíèòü â ñåññèè. */ + public const SESSION_LIMIT = 20; + + private function __construct() {} + + /** + * @param int $sessionUserId `$_SESSION['UsersId']`; 0 — ïîêóïàòåëü íå âîø¸ë + * @param mixed $sessionOrders `$_SESSION[SESSION_KEY]` + */ + public static function findOrder( + string $transactionId, + OrderGateway $orders, + int $sessionUserId, + $sessionOrders + ): ?Order { + $parsed = TransactionId::parse($transactionId); + $order = ($parsed === null) ? null : $orders->findByUid($parsed->getOrderUid()); + + return ($order !== null && self::canView($order, $sessionUserId, $sessionOrders)) ? $order : null; + } + + /** + * @param int $sessionUserId `$_SESSION['UsersId']`; 0 — ïîêóïàòåëü íå âîø¸ë + * @param mixed $sessionOrders `$_SESSION[SESSION_KEY]` + */ + public static function canView(Order $order, int $sessionUserId, $sessionOrders): bool + { + if (self::isOwner($order, $sessionUserId)) { + return true; + } + + return is_array($sessionOrders) && in_array($order->getId(), $sessionOrders, true); + } + + /** + * Çàêàç âîøåäøåãî ïîêóïàòåëÿ. Ó ãîñòÿ ID 0 — ñîâïàë áû ñ çàêàçàìè áåç ïîëüçîâàòåëÿ. + * + * @param int $sessionUserId `$_SESSION['UsersId']`; 0 — ïîêóïàòåëü íå âîø¸ë + */ + public static function isOwner(Order $order, int $sessionUserId): bool + { + return $sessionUserId > 0 && $order->getUserId() === $sessionUserId; + } + + /** + * Çàïîìíèòü çàêàç, ôîðìà îïëàòû êîòîðîãî ïîêàçàíà ïîêóïàòåëþ; ñòàðûå âûòåñíÿþòñÿ. + * + * @param mixed $sessionOrders `$_SESSION[SESSION_KEY]` + * + * @return array + */ + public static function remember($sessionOrders, int $orderId): array + { + $orders = is_array($sessionOrders) ? array_values(array_filter($sessionOrders, 'is_int')) : []; + $orders = array_values(array_diff($orders, [$orderId])); + $orders[] = $orderId; + + return array_slice($orders, -self::SESSION_LIMIT); + } +} diff --git a/moneta/class/form/ReturnMessage.php b/moneta/class/form/ReturnMessage.php new file mode 100644 index 0000000..e286e59 --- /dev/null +++ b/moneta/class/form/ReturnMessage.php @@ -0,0 +1,207 @@ +` â `templates/moneta.css`. */ + public const ICON_SUCCESS = 'success'; + public const ICON_ERROR = 'error'; + public const ICON_WAIT = 'wait'; + public const ICON_INFO = 'info'; + public const ICON_WARNING = 'warning'; + + /** Ñêîëüêî ðàç ñòðàíèöà ïåðåçàïðàøèâàåò ñòàòóñ, îæèäàÿ Pay URL. */ + public const MAX_ATTEMPTS = 20; + + /** Ïàóçà ìåæäó ïåðåçàïðîñàìè, ìñ: âìåñòå ñ {@see self::MAX_ATTEMPTS} — îêîëî ìèíóòû. */ + public const REFRESH_DELAY_MS = 3000; + + /** Òåêñòû áåç çàêàçà: òîëüêî ïî àäðåñó âîçâðàòà. */ + private const TEXTS = [ + ReturnUrls::RESULT_SUCCESS => [ + self::ICON_INFO, + 'Ñïàñèáî, Âàø ïëàò¸æ ïðèíÿò', + 'Ñòàòóñ çàêàçà îáíîâèòñÿ, êàê òîëüêî ' . self::SERVICE_NAME . ' ïîäòâåðäèò îïëàòó.', + ], + ReturnUrls::RESULT_IN_PROGRESS => [ + self::ICON_INFO, + 'Ïëàò¸æ îáðàáàòûâàåòñÿ', + self::SERVICE_NAME . ' åù¸ ïðîâåðÿåò ïëàò¸æ. Ñòàòóñ çàêàçà îáíîâèòñÿ àâòîìàòè÷åñêè.', + ], + ReturnUrls::RESULT_FAIL => [ + self::ICON_ERROR, + 'Ïëàò¸æ íå ïðîø¸ë', + 'Îïëàòó ìîæíî ïîâòîðèòü â ðàçäåëå «Ìîè çàêàçû» èëè ñâÿæèòåñü ñ ìàãàçèíîì.', + ], + ReturnUrls::RESULT_RETURN => [ + self::ICON_INFO, + 'Îïëàòà íå çàâåðøåíà', + 'Âû âåðíóëèñü â ìàãàçèí áåç îïëàòû. Îïëàòèòü çàêàç ìîæíî â ðàçäåëå «Ìîè çàêàçû».', + ], + ]; + + /** @var string */ + private $icon; + + /** @var string */ + private $title; + + /** @var string */ + private $text; + + /** @var string */ + private $orderUid; + + /** @var bool */ + private $waiting; + + private function __construct( + string $icon, + string $title, + string $text, + string $orderUid = '', + bool $waiting = false + ) { + $this->icon = $icon; + $this->title = $title; + $this->text = $text; + $this->orderUid = $orderUid; + $this->waiting = $waiting; + } + + /** + * @param string $result çíà÷åíèå `?result=` àäðåñà âîçâðàòà + * @param bool $transactionGiven â àäðåñå åñòü `MNT_TRANSACTION_ID` + * @param Order|null $order çàêàç ïî `MNT_TRANSACTION_ID`, óæå ïðîâåðåííûé {@see ReturnAccess} + * @param bool $paidByJournal ïî çàêàçó åñòü çà÷ò¸ííàÿ îïëàòà â æóðíàëå ìîäóëÿ + * @param int $attempt íîìåð ïåðåçàïðîñà ñòàòóñà, ñ 0 + * + * @return self|null null — íåèçâåñòíûé `result`, ñòðàíèöà îòâå÷àåò 404 + */ + public static function resolve( + string $result, + bool $transactionGiven, + ?Order $order, + bool $paidByJournal, + Settings $settings, + int $attempt = 0 + ): ?self { + if (!isset(self::TEXTS[$result])) { + return null; + } + + if ($order === null) { + if ($transactionGiven) { + return new self( + self::ICON_WARNING, + 'Çàêàç íå íàéäåí', + 'Ñòàòóñ îïëàòû ìîæíî ïðîâåðèòü â ðàçäåëå «Ìîè çàêàçû». Åñëè äåíüãè ñïèñàíû, à çàêàç' + . ' íå îïëà÷åí, ñâÿæèòåñü ñ ìàãàçèíîì.', + ); + } + [$icon, $title, $text] = self::TEXTS[$result]; + + return new self($icon, $title, $text); + } + + $uid = $order->getUid(); + if ($paidByJournal || PaymentEligibility::isPaid($settings, $order)) { + return new self(self::ICON_SUCCESS, 'Îïëàòà ïðîøëà óñïåøíî', 'Çàêàç ¹' . $uid . ' îïëà÷åí. Ñïàñèáî!', $uid); + } + if ($order->getStatusId() === PaymentEligibility::STATUS_CANCELLED) { + return new self( + self::ICON_ERROR, + 'Çàêàç ¹' . $uid . ' îòìåí¸í', + 'Åñëè äåíüãè ñïèñàíû, ñâÿæèòåñü ñ ìàãàçèíîì.', + $uid, + ); + } + if ($result === ReturnUrls::RESULT_FAIL) { + return new self( + self::ICON_ERROR, + 'Ïëàò¸æ íå ïðîø¸ë', + 'Çàêàç ¹' . $uid . ' íå îïëà÷åí. Îïëàòó ìîæíî ïîâòîðèòü â ðàçäåëå «Ìîè çàêàçû»' + . ' èëè ñâÿæèòåñü ñ ìàãàçèíîì.', + $uid, + ); + } + if ($result === ReturnUrls::RESULT_RETURN) { + return new self( + self::ICON_INFO, + 'Îïëàòà íå çàâåðøåíà', + 'Çàêàç ¹' . $uid . ' íå îïëà÷åí. Îïëàòèòü åãî ìîæíî â ðàçäåëå «Ìîè çàêàçû».', + $uid, + ); + } + if ($attempt < self::MAX_ATTEMPTS) { + return new self( + self::ICON_WAIT, + 'Îæèäàåì ïîäòâåðæäåíèå îïëàòû', + 'Çàêàç ¹' . $uid . '. Íå çàêðûâàéòå ñòðàíèöó - ñòàòóñ îáíîâèòñÿ àâòîìàòè÷åñêè.', + $uid, + true, + ); + } + + return new self( + self::ICON_INFO, + 'Îïëàòà åù¸ íå ïîäòâåðæäåíà', + 'Çàêàç ¹' . $uid . '. ' . self::SERVICE_NAME . ' ïîêà íå ïðèñëàëà ïîäòâåðæäåíèå. Ñòàòóñ' + . ' çàêàçà îáíîâèòñÿ àâòîìàòè÷åñêè - ïðîâåðüòå åãî â ðàçäåëå «Ìîè çàêàçû» ïîçæå.', + $uid, + ); + } + + public function getIcon(): string + { + return $this->icon; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getText(): string + { + return $this->text; + } + + /** + * Íîìåð çàêàçà äëÿ ññûëêè «Ïîäðîáíåå î çàêàçå»; ïóñòîé — çàêàç ïîêóïàòåëþ íå ïîêàçûâàåì. + */ + public function getOrderUid(): string + { + return $this->orderUid; + } + + /** + * Ñòðàíèöà æä¸ò Pay URL è äîëæíà ïåðåçàïðîñèòü ñòàòóñ. + */ + public function isWaiting(): bool + { + return $this->waiting; + } +} diff --git a/moneta/class/form/ReturnUrls.php b/moneta/class/form/ReturnUrls.php new file mode 100644 index 0000000..24eca93 --- /dev/null +++ b/moneta/class/form/ReturnUrls.php @@ -0,0 +1,86 @@ +pageUrl = $pageUrl; + } + + public function getSuccess(): string + { + return $this->withResult(self::RESULT_SUCCESS); + } + + public function getFail(): string + { + return $this->withResult(self::RESULT_FAIL); + } + + public function getInProgress(): string + { + return $this->withResult(self::RESULT_IN_PROGRESS); + } + + public function getReturn(): string + { + return $this->withResult(self::RESULT_RETURN); + } + + /** + * Àäðåñ ñòðàíèöû ìîäóëÿ äëÿ ïîêóïàòåëÿ, êîòîðîãî MONETA.RU âåðíóëà íà `/success` èëè `/fail` + * ÿäðà: òàê íàñòðîåíû ñ÷åòà ìåð÷àíòîâ ñî âñòðîåííûì ìîäóëåì, à áåç ôëàãà «Ìîæíî + * ïåðåîïðåäåëÿòü íàñòðîéêè â URL» íàøè `MNT_*_URL` èãíîðèðóþòñÿ (MONETA.Assistant.ru.pdf, + * ïàðàìåòðû `MNT_SUCCESS_URL`, `MNT_FAIL_URL`). + * + * @param mixed $transactionId `MNT_TRANSACTION_ID` èç àäðåñà âîçâðàòà + * + * @return string|null null — âîçâðàò íå îò MONETA.RU (íåò íîìåðà îïåðàöèè), ñòðàíèöà ÿäðà + * îáðàáàòûâàåò åãî ñàìà + */ + public function withTransaction(string $result, $transactionId): ?string + { + if ($transactionId === '' + || !is_string($transactionId) + || !in_array($result, self::RESULTS, true) + || strlen($transactionId) > 255 + ) { + return null; + } + + return $this->withResult($result) . '&MNT_TRANSACTION_ID=' . rawurlencode($transactionId); + } + + private function withResult(string $result): string + { + return $this->pageUrl . '?result=' . $result; + } +} diff --git a/moneta/class/host/CheckoutBlock.php b/moneta/class/host/CheckoutBlock.php new file mode 100644 index 0000000..6c17862 --- /dev/null +++ b/moneta/class/host/CheckoutBlock.php @@ -0,0 +1,55 @@ +form = $form; + $this->total = $total; + $this->info = $info; + } + + public function getInfo(): string + { + return $this->info; + } + + public function getForm(): string + { + return $this->form; + } + + public function getTotal(): string + { + return $this->total; + } + + public function isEmpty(): bool + { + return ($this->form === ''); + } +} diff --git a/moneta/class/host/CheckoutView.php b/moneta/class/host/CheckoutView.php new file mode 100644 index 0000000..d9e1919 --- /dev/null +++ b/moneta/class/host/CheckoutView.php @@ -0,0 +1,184 @@ + 'Îïëàòà ÷åðåç Ìîíåòó âðåìåííî íåäîñòóïíà. Ñâÿæèòåñü ñ ìàãàçèíîì.', + PaymentEligibility::ALREADY_PAID => 'Çàêàç îïëà÷åí.', + PaymentEligibility::CANCELLED => 'Çàêàç îòìåí¸í.', + PaymentEligibility::AWAITING_STATUS => 'Çàêàç îáðàáàòûâàåòñÿ ìåíåäæåðîì.', + PaymentEligibility::UNSUPPORTED_CURRENCY => 'Îïëàòà ÷åðåç Ìîíåòó âîçìîæíà òîëüêî â ðóáëÿõ.', + PaymentEligibility::ZERO_AMOUNT => 'Îïëàòà íå òðåáóåòñÿ.', + ]; + + /** Òåêñò êíîïêè îïëàòû; íà êàðòî÷êå ïîñëå îôîðìëåíèÿ ê íåìó äîáàâëÿåòñÿ ñóììà. */ + private const BUTTON_TITLE = 'Îïëàòèòü'; + + private const UNAVAILABLE = 'Îïëàòà âðåìåííî íåäîñòóïíà. Ïîïðîáóéòå ïîçæå èëè ñâÿæèòåñü ñ ìàãàçèíîì.'; + + /** @var SettingsRepository */ + private $settingsRepository; + + /** @var OrderRepository */ + private $orderRepository; + + /** @var ShopContext */ + private $shopContext; + + public function __construct( + SettingsRepository $settingsRepository, + OrderRepository $orderRepository, + ShopContext $shopContext + ) { + $this->settingsRepository = $settingsRepository; + $this->orderRepository = $orderRepository; + $this->shopContext = $shopContext; + } + + public static function create(): self + { + return new self(new SettingsRepository(), new OrderRepository(), new ShopContext()); + } + + /** + * Áëîê íà ñòðàíèöå «Çàêàç îôîðìëåí»: ñóììà è êíîïêà, åñëè çàêàç óæå ìîæíî îïëàòèòü, èíà÷å + * òåêñò î ñîñòîÿíèè çàêàçà. Ðàçìåòêó ñîáèðàåò øàáëîí `payment_forma.tpl` â õóêå; ÷àñòè — â cp1251. + */ + public function afterCheckout(int $orderId): CheckoutBlock + { + return $this->block($orderId, true); + } + + /** + * ß÷åéêà «Ñïîñîá îïëàòû» â ëè÷íîì êàáèíåòå: îïèñàíèå îïëàòû è êíîïêà èëè ñîñòîÿíèå çàêàçà, áåç ñóììû. + */ + public function inAccount(int $orderId): string + { + $block = $this->block($orderId, false); + if ($block->isEmpty()) { + return ''; + } + $info = ($block->getInfo() === '') ? '' : '

' . $block->getInfo() . '

'; + + return HostCharset::toPage('
' . $info . $block->getForm() . '
'); + } + + /** + * ×òî ïîêàçàòü ïî ñîñòîÿíèþ çàêàçà: ôîðìó îïëàòû ñ ñóììîé, åñëè çàêàç ìîæíî îïëàòèòü, + * èíà÷å — òåêñò î ñîñòîÿíèè. + * + * @param string $formHtml ôîðìà îïëàòû; íóæíà òîëüêî äëÿ {@see PaymentEligibility::PAYABLE} + * @param string $total ñóììà ê îïëàòå òåêñòîì; ïîêàçûâàåòñÿ òîëüêî âìåñòå ñ ôîðìîé + * @param string $description «Îïèñàíèå îïëàòû» îáû÷íûì òåêñòîì; ïîêàçûâàåòñÿ òîëüêî âìåñòå ñ ôîðìîé: + * «Îïëàòèòå çàêàç» áåç êíîïêè (çàêàç æä¸ò ìåíåäæåðà) ñáèâàë áû ñ òîëêó + */ + public static function compose( + string $eligibility, + string $formHtml, + string $total = '', + string $description = '' + ): CheckoutBlock { + if ($eligibility === PaymentEligibility::PAYABLE) { + return new CheckoutBlock($formHtml, $total, self::descriptionHtml($description)); + } + + return new CheckoutBlock('

' . (self::MESSAGES[$eligibility] ?? self::UNAVAILABLE) . '

'); + } + + private function block(int $orderId, bool $afterCheckout): CheckoutBlock + { + try { + $settings = $this->settingsRepository->load(); + $order = $this->orderRepository->findById($orderId); + if ($order === null) { + return new CheckoutBlock(''); + } + // Ëè÷íûé êàáèíåò îòêðûâàåò çàêàç ïî îäíîìó uid, íå ñâåðÿÿ âëàäåëüöà + // (`users.core/action_order_info.php` ÿäðà): ôîðìó è çàïèñü â ñåññèþ — òîëüêî âëàäåëüöó. + if (!$afterCheckout && !ReturnAccess::isOwner($order, $this->shopContext->sessionUserId())) { + return new CheckoutBlock(''); + } + + $currency = $this->shopContext->orderCurrency(); + $eligibility = PaymentEligibility::check($settings, $order, $currency); + if ($eligibility !== PaymentEligibility::PAYABLE) { + return self::compose($eligibility, ''); + } + $this->shopContext->rememberPayableOrder($order->getId()); + + // Íà êàðòî÷êå ïîñëå îôîðìëåíèÿ — ñóììà îòäåëüíîé ñòðîêîé è â êíîïêå, + // ñî çíàêîì âàëþòû âèòðèíû; â ÿ÷åéêå ëè÷íîãî êàáèíåòà — êîðîòêàÿ êíîïêà. + $amount = ''; + $currencyIcon = ''; + if ($afterCheckout) { + $amount = AmountText::format($order->getAmount(), $this->shopContext->priceDecimals()); + $currencyIcon = $this->shopContext->currencyIcon(); + } + $suffix = ($currencyIcon === '') ? '' : ' ' . $currencyIcon; + $formHtml = $this->form( + $settings, + $order, + (string) AssistantProtocol::normalizeCurrency($currency), + ($amount === '') ? self::BUTTON_TITLE : self::BUTTON_TITLE . ' ' . $amount, + ($amount === '') ? '' : $suffix, + ); + $total = ($amount === '') ? '' : $amount . $suffix; + $description = HostCharset::fromDatabase($settings->getPaymentDescription()); + + return self::compose($eligibility, $formHtml, $total, $description); + } catch (\Throwable $exception) { + (new ErrorLogLogger())->error( + 'Êíîïêà îïëàòû çàêàçà ' . $orderId . ' íå ïîñòðîåíà: ' . $exception->getMessage(), + ); + + return new CheckoutBlock('

' . self::UNAVAILABLE . '

'); + } + } + + /** + * Òåêñò ìåð÷àíòà èç íàñòðîåê — â HTML: ýêðàíèðîâàíèå, ïåðåíîñû ñòðîê — `
`. + */ + private static function descriptionHtml(string $text): string + { + $text = trim($text); + + return ($text === '') ? '' : nl2br(htmlspecialchars($text, ENT_QUOTES, 'Windows-1251'), false); + } + + private function form( + Settings $settings, + Order $order, + string $currency, + string $buttonTitle, + string $buttonSuffixHtml + ): string { + return PaymentForm::create( + $settings, + $order, + $currency, + $this->shopContext->now(), + new ReturnUrls($this->shopContext->returnPageUrl()), + 'Îïëàòà çàêàçà ¹' . $order->getUid(), + $this->shopContext->cms(), + AssistantProtocol::normalizeLocale($this->shopContext->shopLocale()), + )->toHtml($buttonTitle, $buttonSuffixHtml); + } +} diff --git a/moneta/class/host/Database.php b/moneta/class/host/Database.php new file mode 100644 index 0000000..1276fd8 --- /dev/null +++ b/moneta/class/host/Database.php @@ -0,0 +1,131 @@ +link_db`). + * + * `PHPShopOrm` ñîáèðàåò SQL êîíêàòåíàöèåé — äëÿ çàïèñåé êîëáýêà, ãäå â çàïðîñ ïîïàäàþò + * äàííûå èçâíå, ìîäóëü õîäèò â ÁÄ ñàì. Îøèáêà çàïðîñà — {@see RuntimeException} ñ êîäîì + * îøèáêè MySQL (1062 — äóáëèêàò êëþ÷à) ïðè ëþáîì `mysqli_report()`: â PHP 7.4 ïî óìîë÷àíèþ + * mysqli ìîë÷èò, ñ 8.1 áðîñàåò èñêëþ÷åíèÿ. + */ +final class Database +{ + /** Äóáëèêàò óíèêàëüíîãî êëþ÷à. */ + public const ER_DUP_ENTRY = 1062; + + /** @var mysqli */ + private $link; + + public function __construct(mysqli $link) + { + $this->link = $link; + } + + public static function fromHost(): self + { + // Êîëáýê è âèòðèíà äåðæàò ñîåäèíåíèå â $PHPShopBase, ÿäðî äóáëèðóåò åãî â ãëîáàëüíûé $link_db. + $base = $GLOBALS['PHPShopBase'] ?? null; + $link = is_object($base) && isset($base->link_db) ? $base->link_db : ($GLOBALS['link_db'] ?? null); + if (!$link instanceof mysqli) { + throw new RuntimeException('Íåò ñîåäèíåíèÿ ÿäðà PHPShop ñ ÁÄ.'); + } + + return new self($link); + } + + /** + * @param array $params + * + * @return array> + */ + public function fetchAll(string $sql, array $params = []): array + { + $statement = $this->run($sql, $params); + try { + $result = $statement->get_result(); + if ($result === false) { + throw new RuntimeException('Çàïðîñ íå âåðíóë ñòðîê: ' . $statement->error, $statement->errno); + } + $rows = []; + while (is_array($row = $result->fetch_assoc())) { + $rows[] = $row; + } + + return $rows; + } finally { + $statement->close(); + } + } + + /** + * @param array $params + * + * @return mixed ïåðâàÿ êîëîíêà ïåðâîé ñòðîêè èëè null + */ + public function fetchValue(string $sql, array $params = []) + { + $rows = $this->fetchAll($sql, $params); + if ($rows === []) { + return null; + } + + return reset($rows[0]); + } + + /** + * @param array $params + * + * @return int ÷èñëî çàòðîíóòûõ ñòðîê + */ + public function execute(string $sql, array $params = []): int + { + $statement = $this->run($sql, $params); + $affected = $statement->affected_rows; + $statement->close(); + + return (int) $affected; + } + + /** + * @param array $params + */ + private function run(string $sql, array $params): mysqli_stmt + { + try { + $statement = $this->link->prepare($sql); + if ($statement === false) { + throw new RuntimeException('Çàïðîñ íå ïîäãîòîâëåí: ' . $this->link->error, $this->link->errno); + } + if ($params !== []) { + $types = ''; + foreach ($params as $param) { + $types .= is_int($param) ? 'i' : 's'; + } + $statement->bind_param($types, ...$params); + } + if (!$statement->execute()) { + $error = new RuntimeException('Çàïðîñ íå âûïîëíåí: ' . $statement->error, $statement->errno); + $statement->close(); + + throw $error; + } + + return $statement; + } catch (mysqli_sql_exception $exception) { + throw new RuntimeException( + 'Îøèáêà MySQL: ' . $exception->getMessage(), + (int) $exception->getCode(), + $exception, + ); + } + } +} diff --git a/moneta/class/host/ErrorLogLogger.php b/moneta/class/host/ErrorLogLogger.php new file mode 100644 index 0000000..e38810c --- /dev/null +++ b/moneta/class/host/ErrorLogLogger.php @@ -0,0 +1,19 @@ +codBase`) îòäà¸ò ñòðîêè èç ÁÄ â UTF-8 è âûâîäèò + * ñòðàíèöû â UTF-8 — íà ýòîé ãðàíèöå ñòðîêè ïåðåêîäèðóþòñÿ, êàê â øòàòíûõ ìîäóëÿõ ÿäðà. + * Âñ¸, ÷òî óõîäèò â Ìîíåòó (JSON, XML), — UTF-8. + */ +final class HostCharset +{ + private function __construct() {} + + public static function isUtf8Shop(): bool + { + $base = $GLOBALS['PHPShopBase'] ?? null; + + return is_object($base) && isset($base->codBase) && ($base->codBase === 'utf-8'); + } + + /** + * Ñòðîêà èç ÁÄ ìàãàçèíà -> cp1251. + */ + public static function fromDatabase(string $value): string + { + if (!self::isUtf8Shop()) { + return $value; + } + $converted = iconv('UTF-8', 'Windows-1251//IGNORE', $value); + + return ($converted === false) ? '' : $converted; + } + + /** + * Ñòðîêà ìîäóëÿ (cp1251) -> êîäèðîâêà ÁÄ ìàãàçèíà. + */ + public static function toDatabase(string $value): string + { + return self::isUtf8Shop() ? self::toUtf8($value) : $value; + } + + /** + * HTML ìîäóëÿ (cp1251) -> êîäèðîâêà ñòðàíèöû ìàãàçèíà. + */ + public static function toPage(string $html): string + { + return self::isUtf8Shop() ? self::toUtf8($html) : $html; + } + + /** + * cp1251 -> UTF-8 äëÿ Ìîíåòû. Áàéò 0x98 â cp1251 íå îïðåäåë¸í — íà í¸ì iconv âåðíóë áû false + * íà âñþ ñòðîêó (íàçâàíèå ïîçèöèè â ÷åêå ñòàëî áû ïóñòûì), ïîýòîìó îí óäàëÿåòñÿ çàðàíåå. + * Íå //IGNORE: åãî ïîääåðæêà çàâèñèò îò ðåàëèçàöèè iconv (glibc — äà, musl — íåò). + */ + public static function toUtf8(string $value): string + { + $converted = iconv('Windows-1251', 'UTF-8', str_replace("\x98", '', $value)); + + return ($converted === false) ? '' : $converted; + } +} diff --git a/moneta/class/host/HostReceiptSource.php b/moneta/class/host/HostReceiptSource.php new file mode 100644 index 0000000..227258f --- /dev/null +++ b/moneta/class/host/HostReceiptSource.php @@ -0,0 +1,158 @@ + */ + private $receipts = []; + + public function __construct(Settings $settings, Database $database, Logger $logger) + { + $this->settings = $settings; + $this->vatRates = new HostVatRates($database); + $this->logger = $logger; + } + + public function attributes(Order $order): array + { + $receipt = $this->receipt($order); + + return ($receipt !== null) + ? $receipt->toAttributes($this->settings->isFiscalization(), $this->settings->getTaxSystem()) + : []; + } + + public function jsonReceipt(Order $order): ?array + { + $receipt = $this->receipt($order); + + return ($receipt !== null) ? $receipt->toJsonReceipt() : null; + } + + private function receipt(Order $order): ?Receipt + { + if (isset($this->receipts[$order->getId()])) { + return $this->receipts[$order->getId()]; + } + + try { + $builder = new ReceiptBuilder( + Closure::fromCallable([HostCharset::class, 'toUtf8']), + $this->settings->getPaymentMethod(), + $this->settings->getPaymentObject(), + $this->settings->getGoodsVat()->getFixed(), + ); + $receiptEmail = $this->settings->isFiscalization() ? $this->settings->getReceiptEmail() : ''; + $receipt = $builder->build($order, $this->goodsVat($order), $this->deliveryVat($order), $receiptEmail); + } catch (\Throwable $exception) { + $this->logger->error( + sprintf( + '×åê çàêàçà %s íå ñîáðàí: %s: %s', + $order->getUid(), + get_class($exception), + $exception->getMessage(), + ), + ); + + return null; + } + + return $this->receipts[$order->getId()] = $receipt; + } + + private function goodsVat(Order $order): Vat + { + $fixed = $this->settings->getGoodsVat()->getFixed(); + if ($fixed !== null) { + return $fixed; + } + + if (!$this->vatRates->isEnabled()) { + return Vat::none(); + } + + return $this->platformVat($order, $this->vatRates->systemPercent(), 'ñèñòåìíàÿ ñòàâêà PHPShop (òîâàðû)'); + } + + private function deliveryVat(Order $order): Vat + { + $fixed = $this->settings->getDeliveryVat()->getFixed(); + if ($fixed !== null) { + return $fixed; + } + + if (!$this->vatRates->isEnabled()) { + return Vat::none(); + } + + $percent = $this->vatRates->deliveryPercent($order->getDeliveryMethodId()); + if ($percent !== '') { + return $this->platformVat($order, $percent, 'ñòàâêà ñïîñîáà äîñòàâêè'); + } + + return $this->platformVat($order, $this->vatRates->systemPercent(), 'ñèñòåìíàÿ ñòàâêà PHPShop (äîñòàâêà)'); + } + + /** + * Ñòàâêà èç PHPShop; íå èç ïåðå÷íÿ êàññû — «áåç ÍÄÑ» è ïðåäóïðåæäåíèå, åñëè ÷åê ïðîáèâàåòñÿ. + */ + private function platformVat(Order $order, string $percent, string $source): Vat + { + $vat = Vat::tryFromPercent($percent); + if ($vat !== null) { + return $vat; + } + + if ($this->settings->isFiscalization()) { + $this->logger->error( + sprintf( + 'Çàêàç %s: %s %s íå èç ïåðå÷íÿ êàññû — ïîçèöèÿ óõîäèò â ÷åê «áåç ÍÄÑ».' + . ' Èñïðàâüòå ñòàâêó â PHPShop èëè çàäàéòå ñòàâêó â íàñòðîéêàõ ìîäóëÿ.', + $order->getUid(), + $source, + //  æóðíàë — òîëüêî òî, ÷òî ïîõîæå íà ÷èñëî: çíà÷åíèå ââîäèò àäìèíèñòðàòîð. + VatRateWarnings::shown((string) preg_replace('/[^0-9.,%+\- ]/', '?', substr($percent, 0, 16))), + ), + ); + } + + return Vat::none(); + } +} diff --git a/moneta/class/host/HostVatRates.php b/moneta/class/host/HostVatRates.php new file mode 100644 index 0000000..0023d46 --- /dev/null +++ b/moneta/class/host/HostVatRates.php @@ -0,0 +1,93 @@ +database = $database; + } + + public function isEnabled(): bool + { + return !empty(self::systemParam('nds_enabled')); + } + + public function systemPercent(): string + { + return self::systemParam('nds'); + } + + /** + * «Çíà÷åíèå ÍÄÑ» ñïîñîáà äîñòàâêè êàê ââ¸ë ìåð÷àíò; ïóñòî — ó äîñòàâêè ñâîåé ñòàâêè íåò. + */ + public function deliveryPercent(int $deliveryId): string + { + $table = self::deliveryTable(); + if ($deliveryId <= 0 || $table === null) { + return ''; + } + + return trim((string) $this->database->fetchValue( + 'SELECT ofd_nds FROM `' . $table . '` WHERE id = ? LIMIT 1', + [$deliveryId], + )); + } + + /** + * Âêëþ÷¸ííûå ñïîñîáû äîñòàâêè (áåç ïàïîê): [íàçâàíèå â cp1251, «Çíà÷åíèå ÍÄÑ»]. + * + * @return array + */ + public function enabledDeliveries(): array + { + $table = self::deliveryTable(); + if ($table === null) { + return []; + } + + $deliveries = []; + $rows = $this->database->fetchAll( + 'SELECT city, ofd_nds FROM `' . $table . "` WHERE enabled = '1' AND (is_folder IS NULL OR is_folder <> '1')" + . ' ORDER BY num, id', + ); + foreach ($rows as $row) { + $deliveries[] = [ + HostCharset::fromDatabase((string) ($row['city'] ?? '')), + trim((string) ($row['ofd_nds'] ?? '')), + ]; + } + + return $deliveries; + } + + private static function deliveryTable(): ?string + { + $table = $GLOBALS['SysValue']['base']['delivery'] ?? null; + + // Èìÿ òàáëèöû èä¸ò â SQL: ÿâíûé êëàññ, à íå \w — ñì. OperationRepository::table(). + return (is_string($table) && preg_match('/\A[0-9A-Za-z_]+\z/', $table) === 1) ? $table : null; + } + + private static function systemParam(string $name): string + { + $system = $GLOBALS['PHPShopSystem'] ?? null; + if (!is_object($system) || !method_exists($system, 'getParam')) { + return ''; + } + $value = $system->getParam($name); + + return is_scalar($value) ? (string) $value : ''; + } +} diff --git a/moneta/class/host/MysqlLogThrottle.php b/moneta/class/host/MysqlLogThrottle.php new file mode 100644 index 0000000..44af41f --- /dev/null +++ b/moneta/class/host/MysqlLogThrottle.php @@ -0,0 +1,74 @@ +database = $database; + } + + public function pass(int $now, int $interval): ?int + { + $table = self::table(); + + // Çàòðîíóòûå ñòðîêè: 1 — ñòðîêè íå áûëî è îíà âñòàâëåíà, 2 — ìåòêà îáíîâëåíà, + // 0 — îêíî åù¸ îòêðûòî è ñòðîêà íå èçìåíèëàñü. Âðåìÿ â áóäóùåì (÷àñû ñåðâåðà ïåðåâåëè + // íàçàä) — íå æäàòü, ïîêà äîãîíÿò. + $claimed = $this->database->execute( + 'INSERT INTO `' . $table . '` (`id`, `logged_at`, `skipped`) VALUES (' . self::ROW_ID . ', ?, 0)' + . ' ON DUPLICATE KEY UPDATE `logged_at` = IF(`logged_at` <= ? OR `logged_at` > ?, ?, `logged_at`)', + [$now, $now - $interval, $now, $now], + ); + if ($claimed === 0) { + $this->database->execute('UPDATE `' . $table . '` SET `skipped` = `skipped` + 1'); + + return null; + } + + $skipped = (int) $this->database->fetchValue('SELECT `skipped` FROM `' . $table . '` LIMIT 1'); + if ($skipped > 0) { + $this->database->execute( + 'UPDATE `' . $table . '` SET `skipped` = GREATEST(`skipped` - ?, 0)', + [$skipped], + ); + } + + return $skipped; + } + + public static function table(): string + { + $table = $GLOBALS['SysValue']['base'][ModuleInfo::PATH]['moneta_log_throttle'] ?? null; + // ßâíûé êëàññ, à íå \w: â cp1251-ëîêàëè PHPShop \w ïðîïóñòèë áû êèðèëëèöó (êàê â OperationRepository). + if (!is_string($table) || preg_match('/\A[0-9A-Za-z_]+\z/', $table) !== 1) { + throw new RuntimeException('Íåò òàáëèöû ñ÷¸ò÷èêà æóðíàëà â êîíôèãóðàöèè ìîäóëÿ.'); + } + + return $table; + } +} diff --git a/moneta/class/host/MysqlOrderLock.php b/moneta/class/host/MysqlOrderLock.php new file mode 100644 index 0000000..b005a29 --- /dev/null +++ b/moneta/class/host/MysqlOrderLock.php @@ -0,0 +1,38 @@ +database = $database; + } + + public function acquire(int $orderId): bool + { + return (int) $this->database->fetchValue('SELECT GET_LOCK(' . self::NAME . ', ?)', [$orderId, self::TIMEOUT]) === 1; + } + + public function release(int $orderId): void + { + $this->database->fetchValue('SELECT RELEASE_LOCK(' . self::NAME . ')', [$orderId]); + } +} diff --git a/moneta/class/host/OperationRepository.php b/moneta/class/host/OperationRepository.php new file mode 100644 index 0000000..a3837ed --- /dev/null +++ b/moneta/class/host/OperationRepository.php @@ -0,0 +1,133 @@ +database = $database; + } + + public function findPaidOperation(int $orderId): ?string + { + $value = $this->database->fetchValue( + 'SELECT operation_id FROM `' . self::table() . '` WHERE paid_order_id = ? LIMIT 1', + [$orderId], + ); + + return $value === null ? null : (string) $value; + } + + public function recordPaid(CallbackNotification $notification, Order $order, string $event): bool + { + try { + $this->insert($notification, $order, $event, ResultCode::paid(), $order->getId()); + } catch (RuntimeException $exception) { + if ($exception->getCode() === Database::ER_DUP_ENTRY) { + return false; + } + + throw $exception; + } + + return true; + } + + public function note( + CallbackNotification $notification, + ?Order $order, + string $event, + ?ResultCode $resultCode + ): void { + $this->insert($notification, $order, $event, $resultCode, null); + } + + /** + * Ïîñëåäíèå çàïèñè æóðíàëà äëÿ àäìèíêè, íîâûå ñâåðõó. + * + * @param string|null $orderUid òîëüêî çàïèñè ýòîãî çàêàçà ({@see TransactionId::orderUidFromInput()}): + * ñ ýòèì `order_uid` èëè — åñëè çàêàç íå ðàñïîçíàí — ñ `MNT_TRANSACTION_ID` âèäà `<íîìåð>_…` + * + * @return array> + */ + public function latest(int $limit, ?string $orderUid = null): array + { + $where = ''; + $params = []; + if ($orderUid !== null) { + //  íîìåðå çàêàçà íåò `_` è `%` (TransactionId::ORDER_UID) — ýêðàíèðîâàòü â LIKE + // íóæíî òîëüêî ñàì ðàçäåëèòåëü `_`. + $where = ' WHERE order_uid = ? OR transaction_id LIKE ?'; + $params = [$orderUid, $orderUid . '\\' . TransactionId::SEPARATOR . '%']; + } + $params[] = $limit; + + return $this->database->fetchAll( + 'SELECT id, created_at, command, transaction_id, operation_id, order_id, order_uid, amount,' + . ' test_mode, result_code, event FROM `' . self::table() . '`' . $where . ' ORDER BY id DESC LIMIT ?', + $params, + ); + } + + public static function table(): string + { + $table = $GLOBALS['SysValue']['base'][ModuleInfo::PATH]['moneta_operations'] ?? null; + // ßâíûé êëàññ, à íå \w: áåç /u \w ñëåäóåò ëîêàëè, à PHPShop ñòàâèò ñâîþ (setlocale â + // class/base.class.php) — â cp1251 \w ïðîïóñòèë áû êèðèëëèöó â èìÿ òàáëèöû, êîòîðîå èä¸ò â SQL. + if (!is_string($table) || preg_match('/\A[0-9A-Za-z_]+\z/', $table) !== 1) { + throw new RuntimeException('Ìîäóëü íå óñòàíîâëåí: íåò òàáëèöû îïåðàöèé â êîíôèãóðàöèè.'); + } + + return $table; + } + + private function insert( + CallbackNotification $notification, + ?Order $order, + string $event, + ?ResultCode $resultCode, + ?int $paidOrderId + ): void { + $amount = $notification->getAmount(); + $this->database->execute( + 'INSERT INTO `' . self::table() . '` (created_at, command, transaction_id, operation_id, order_id,' + . ' order_uid, amount, test_mode, result_code, event, paid_order_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + time(), + $notification->isCheck() ? $notification->getCommand() : self::COMMAND_PAY, + $notification->getTransactionId(), + $notification->getOperationId(), + ($order !== null) ? $order->getId() : 0, + ($order !== null) ? $order->getUid() : '', + ($amount !== null) ? $amount->toDecimal() : '', + $notification->getRawTestMode(), + ($resultCode !== null) ? $resultCode->getValue() : null, + $event, + $paidOrderId, + ], + ); + } +} diff --git a/moneta/class/host/OrderRepository.php b/moneta/class/host/OrderRepository.php new file mode 100644 index 0000000..4a9806a --- /dev/null +++ b/moneta/class/host/OrderRepository.php @@ -0,0 +1,136 @@ +findOne(['id' => '=' . $id]); + } + + public function findByUid(string $uid): ?Order + { + if (preg_match('/\A' . TransactionId::ORDER_UID . '\z/', $uid) !== 1) { + return null; + } + + return $this->findOne(['uid' => "='" . $uid . "'"]); + } + + /** + * Êàê øòàòíûå ìîäóëè ÿäðà: ñòðîêà â æóðíàëå îïëàò, `changeStatus()` (ïèñüìî ïîêóïàòåëþ, + * ñêëàä, ìåññåíäæåðû — ïî íàñòðîéêàì ñòàòóñà) è ôëàã `paid`. `ofd()` ÿäðà íå âûçûâàåòñÿ: + * ÷åê ïðîáèâàåò êàññà MONETA. + */ + public function markPaid(Order $order, int $paidStatus, Money $amount): void + { + $database = Database::fromHost(); + if ($paidStatus === Settings::STATUS_PAID_BY_GATEWAY) { + $this->ensurePaidByGatewayStatus($database); + } + $this->logPayment($database, $order, $amount); + + $orderFunction = new PHPShopOrderFunction($order->getId()); + $orderFunction->changeStatus($paidStatus, $order->getStatusId()); + $orderFunction->changePaymentStatus(1); + } + + /** + * `phpshop_payment`: êëþ÷ — íîìåð çàêàçà áåç äåôèñà ÷èñëîì (`4-36` -> 436), ïî íåìó ÿäðî + * ïîêàçûâàåò äàòó îïëàòû (`PHPShopOrderFunction::checkPay()`). Íîìåð íå øòàòíîãî âèäà + * (ïåðåîïðåäåë¸í õóêîì `setNum`) â ýòó òàáëèöó íå çàïèñûâàåòñÿ — çàïèñü ïðîïóñêàåòñÿ. + */ + private function logPayment(Database $database, Order $order, Money $amount): void + { + if (preg_match('/\A([0-9]+)-([0-9]+)\z/', $order->getUid(), $matches) !== 1) { + return; + } + $database->execute( + 'INSERT IGNORE INTO `' . self::table('payment') . '` (uid, name, sum, datas) VALUES (?, ?, ?, ?)', + [(int) ($matches[1] . $matches[2]), self::PAYMENT_LOG_NAME, $amount->toDecimal(), time()], + ); + } + + private function ensurePaidByGatewayStatus(Database $database): void + { + $database->execute( + 'INSERT IGNORE INTO `' . self::table('order_status') . "` (id, name, color, external_code, bot_message)" + . " VALUES (?, ?, '#ccff00', '', '')", + [Settings::STATUS_PAID_BY_GATEWAY, HostCharset::toDatabase(self::STATUS_PAID_BY_GATEWAY_NAME)], + ); + } + + private static function table(string $key): string + { + $table = $GLOBALS['SysValue']['base'][$key] ?? null; + // ßâíûé êëàññ, à íå \w: áåç /u \w ñëåäóåò ëîêàëè, à PHPShop ñòàâèò ñâîþ (setlocale â + // class/base.class.php) — â cp1251 \w ïðîïóñòèë áû êèðèëëèöó â èìÿ òàáëèöû, êîòîðîå èä¸ò â SQL. + if (!is_string($table) || preg_match('/\A[0-9A-Za-z_]+\z/', $table) !== 1) { + throw new RuntimeException("Íåò òàáëèöû ÿäðà {$key} â êîíôèãóðàöèè."); + } + + return $table; + } + + /** + * @param array $where + */ + private function findOne(array $where): ?Order + { + $orm = new PHPShopOrm($GLOBALS['SysValue']['base']['orders']); + $orm->debug = false; + $row = $orm->select(['*'], $where, false, ['limit' => 1]); + if (!is_array($row) || empty($row['id'])) { + return null; + } + + $details = @unserialize((string) ($row['orders'] ?? ''), ['allowed_classes' => false]); + if (!is_array($details)) { + $details = []; + } + foreach (['fio', 'tel'] as $field) { + if (isset($row[$field]) && is_string($row[$field])) { + $row[$field] = HostCharset::fromDatabase($row[$field]); + } + } + // Íàçâàíèÿ òîâàðîâ â êîðçèíå — â êîäèðîâêå ÁÄ, êàê è îñòàëüíûå ñòðîêè çàêàçà. + if (isset($details['Cart']['cart']) && is_array($details['Cart']['cart'])) { + foreach ($details['Cart']['cart'] as $key => $product) { + if (is_array($product) && isset($product['name']) && is_string($product['name'])) { + $details['Cart']['cart'][$key]['name'] = HostCharset::fromDatabase($product['name']); + } + } + } + + return Order::fromRow($row, $details); + } +} diff --git a/moneta/class/host/SettingsRepository.php b/moneta/class/host/SettingsRepository.php new file mode 100644 index 0000000..0cf9e23 --- /dev/null +++ b/moneta/class/host/SettingsRepository.php @@ -0,0 +1,49 @@ +debug = false; + $row = $orm->select(['*'], false, false, ['limit' => 1]); + if (!is_array($row)) { + throw new RuntimeException('Íåò ñòðîêè íàñòðîåê ìîäóëÿ.'); + } + + return Settings::fromRow($row); + } + + /** + * Ñòðîêà íàñòðîåê åù¸ îò âñòðîåííîãî ìîäóëÿ PHPShop: ôàéëû çàìåíåíû, à «Îáíîâèòü ìîäóëü» + * (ìèãðàöèÿ `updates/1.2/`) íå íàæàòà — íîâûõ êîëîíîê â òàáëèöå íåò. + * + * @param array $row ñòðîêà òàáëèöû íàñòðîåê + */ + public static function needsUpgrade(array $row): bool + { + return !array_key_exists('account_id', $row); + } + + public static function table(): string + { + $table = $GLOBALS['SysValue']['base'][ModuleInfo::PATH]['moneta_system'] ?? null; + if (!is_string($table) || ($table === '')) { + throw new RuntimeException('Ìîäóëü íå óñòàíîâëåí: íåò òàáëèöû íàñòðîåê â êîíôèãóðàöèè.'); + } + + return $table; + } +} diff --git a/moneta/class/host/ShopContext.php b/moneta/class/host/ShopContext.php new file mode 100644 index 0000000..9ebb6c3 --- /dev/null +++ b/moneta/class/host/ShopContext.php @@ -0,0 +1,171 @@ +isHttps() ? 'https://' : 'http://') . $host . $this->basePath(); + } + + public function returnPageUrl(): string + { + return $this->baseUrl() . self::RETURN_PAGE; + } + + /** + * Ïóòü ñòðàíèöû âîçâðàòà îò êîðíÿ ñàéòà, áåç ñõåìû è õîñòà — äëÿ `Location` õóêîâ `/success` + * è `/fail`. `SERVER_NAME` íà Apache ïî óìîë÷àíèþ áåð¸òñÿ èç çàãîëîâêà `Host` çàïðîñà, è + * êýøèðóþùèé ïðîêñè ïåðåä ìàãàçèíîì ìîã áû ðàçäàòü ðåäèðåêò íà ÷óæîé õîñò. + */ + public function returnPagePath(): string + { + return $this->basePath() . self::RETURN_PAGE; + } + + public function callbackUrl(): string + { + return $this->baseUrl() . self::CALLBACK_PATH; + } + + /** + * ISO-êîä âàëþòû çàêàçà ñ ó÷¸òîì âèòðèíû (ìóëüòèñàéò). + */ + public function orderCurrency(): string + { + $system = $GLOBALS['PHPShopSystem'] ?? null; + if (!is_object($system) || !method_exists($system, 'getDefaultValutaIso')) { + return ''; + } + + return (string) $system->getDefaultValutaIso(true); + } + + /** + * ßçûê âèòðèíû òåêóùåãî ïîêóïàòåëÿ: ÿäðî êëàä¸ò åãî â ñåññèþ (`PHPShopElements`). + */ + public function shopLocale(): string + { + $locale = $_SESSION['lang'] ?? ''; + + return is_string($locale) ? $locale : ''; + } + + /** + * Çàïîìíèòü â ñåññèè ïîêóïàòåëÿ çàêàç, ôîðìó îïëàòû êîòîðîãî åìó ïîêàçàëè: ïî íåìó ñòðàíèöà + * âîçâðàòà ïîêàæåò ñòàòóñ è ãîñòþ, îôîðìèâøåìó çàêàç áåç âõîäà ({@see ReturnAccess}). + */ + public function rememberPayableOrder(int $orderId): void + { + if (session_status() !== PHP_SESSION_ACTIVE) { + return; + } + $_SESSION[ReturnAccess::SESSION_KEY] = ReturnAccess::remember( + $_SESSION[ReturnAccess::SESSION_KEY] ?? null, + $orderId, + ); + } + + /** + * ID âîøåäøåãî ïîêóïàòåëÿ (`$_SESSION['UsersId']`); 0 — íå âîø¸ë. + */ + public function sessionUserId(): int + { + return (int) ($_SESSION['UsersId'] ?? 0); + } + + /** + * Çíàêîâ ïîñëå òî÷êè â öåíàõ âèòðèíû (`admoption.price_znak`). + */ + public function priceDecimals(): int + { + $system = $GLOBALS['PHPShopSystem'] ?? null; + if (!is_object($system) || !method_exists($system, 'getSerilizeParam')) { + return 0; + } + + return (int) $system->getSerilizeParam('admoption.price_znak'); + } + + /** + * Çíàê âàëþòû çàêàçà òàê, êàê åãî âûâîäèò âèòðèíà: äëÿ ðóáëÿ ÿäðî îòäà¸ò + * `p` — øðèôò òåìû ðèñóåò «p» çíàêîì ðóáëÿ; äëÿ äðóãîé âàëþòû — + * îáîçíà÷åíèå èç ñïðàâî÷íèêà. HTML ÿäðà, â cp1251. + */ + public function currencyIcon(): string + { + $system = $GLOBALS['PHPShopSystem'] ?? null; + if (!is_object($system) || !method_exists($system, 'getValutaIcon')) { + return ''; + } + + return HostCharset::fromDatabase(trim((string) $system->getValutaIcon(true))); + } + + /** + * `MNT_CMS`: âåðñèÿ PHPShop — èç `[upload] version` êîíôèãà ÿäðà (`670`). + */ + public function cms(): string + { + $uploadVersion = (string) ($GLOBALS['SysValue']['upload']['version'] ?? ''); + + return CmsInfo::format(CmsInfo::hostVersion($uploadVersion), PHP_VERSION, ModuleInfo::VERSION); + } + + /** + * Òåêóùåå âðåìÿ. Ïîÿñ âûñòàâëÿåò ÿäðî èç íàñòðîåê ìàãàçèíà (`PHPShopSystem::setTimeZone()`), + * à åñëè íàñòðîéêà ïóñòà èëè íåâåðíà — äåéñòâóåò ïîÿñ PHP ñåðâåðà; ìåòêå + * `MNT_TRANSACTION_ID` ýòî íåâàæíî, îíà ïèøåòñÿ â UTC. + */ + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('now'); + } + + /** + * Êàòàëîã óñòàíîâêè PHPShop áåç ñëýøà íà êîíöå (`[dir] dir` â `inc/config.ini`), ïóñòî — â êîðíå ñàéòà. + */ + private function basePath(): string + { + return rtrim((string) ($GLOBALS['SysValue']['dir']['dir'] ?? ''), '/'); + } + + private function isHttps(): bool + { + $https = strtolower((string) ($_SERVER['HTTPS'] ?? '')); + if (($https !== '') && ($https !== 'off')) { + return true; + } + + return strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https'; + } +} diff --git a/moneta/class/order/Order.php b/moneta/class/order/Order.php new file mode 100644 index 0000000..94777d8 --- /dev/null +++ b/moneta/class/order/Order.php @@ -0,0 +1,194 @@ +` + * (`PHPShopDone::send_to_order()`) — ýòî íå àäðåñ ïîêóïàòåëÿ. + */ + private const GUEST_EMAIL_PREFIX = 'guest@'; + + /** @var int */ + private $id; + + /** @var string */ + private $uid; + + /** @var Money */ + private $amount; + + /** @var int */ + private $statusId; + + /** @var bool */ + private $paid; + + /** @var int */ + private $paymentMethodId; + + /** @var string */ + private $email; + + /** @var string */ + private $phone; + + /** @var string */ + private $customerName; + + /** @var int */ + private $userId; + + /** @var array */ + private $cart; + + /** @var float */ + private $discountPercent; + + /** @var int */ + private $deliveryMethodId; + + private function __construct() {} + + /** + * @param array $row ñòðîêà `phpshop_orders` + * @param array $details ðàñïàêîâàííîå ïîëå `orders`: `['Cart' => ..., 'Person' => ...]` + */ + public static function fromRow(array $row, array $details): self + { + $id = (int) ($row['id'] ?? 0); + $uid = (string) ($row['uid'] ?? ''); + if (($id <= 0) || ($uid === '')) { + throw new InvalidArgumentException('Ñòðîêà çàêàçà áåç id èëè uid.'); + } + + $person = isset($details['Person']) && is_array($details['Person']) ? $details['Person'] : []; + $cart = isset($details['Cart']) && is_array($details['Cart']) ? $details['Cart'] : []; + + $order = new self(); + $order->id = $id; + $order->uid = $uid; + $order->amount = Money::fromFloat((float) ($row['sum'] ?? 0)); + $order->statusId = (int) ($row['statusi'] ?? 0); + $order->paid = (int) ($row['paid'] ?? 0) === 1; + $order->paymentMethodId = (int) ($person['order_metod'] ?? 0); + $order->email = self::email((string) ($person['mail'] ?? '')); + $order->phone = trim((string) ($row['tel'] ?? '')); + $order->customerName = trim((string) ($row['fio'] ?? '')); + $order->userId = (int) ($row['user'] ?? 0); + $order->cart = $cart; + $order->discountPercent = (float) ($person['discount'] ?? 0); + $order->deliveryMethodId = (int) ($person['dostavka_metod'] ?? 0); + + return $order; + } + + public function getId(): int + { + return $this->id; + } + + /** + * Íîìåð çàêàçà, êîòîðûé âèäèò ïîêóïàòåëü (`4-36`). + */ + public function getUid(): string + { + return $this->uid; + } + + /** + * Èòîã çàêàçà (`phpshop_orders.sum`): òîâàðû ñî ñêèäêàìè, áîíóñû è äîñòàâêà óæå ó÷òåíû ÿäðîì. + */ + public function getAmount(): Money + { + return $this->amount; + } + + public function getStatusId(): int + { + return $this->statusId; + } + + public function isPaidFlag(): bool + { + return $this->paid; + } + + public function getPaymentMethodId(): int + { + return $this->paymentMethodId; + } + + /** + * Email ïîêóïàòåëÿ èëè ïóñòàÿ ñòðîêà (ãîñòåâîé `guest@...` íå ñ÷èòàåòñÿ). + */ + public function getEmail(): string + { + return $this->email; + } + + public function getPhone(): string + { + return $this->phone; + } + + public function getCustomerName(): string + { + return $this->customerName; + } + + /** + * ID ïîêóïàòåëÿ PHPShop (`phpshop_shopusers.id`), 0 — íåò. + */ + public function getUserId(): int + { + return $this->userId; + } + + /** + * Êîðçèíà çàêàçà êàê å¸ õðàíèò ÿäðî (`Cart`: `cart`, `dostavka`, `sum`...) — äëÿ ÷åêà. + * + * @return array + */ + public function getCart(): array + { + return $this->cart; + } + + /** + * Ñêèäêà ïîêóïàòåëÿ â ïðîöåíòàõ (`Person.discount`): ÿäðî ïðèìåíÿåò å¸ êî âñåì òîâàðàì, + * êðîìå òîâàðîâ ñ ïðîìî-öåíîé. + */ + public function getDiscountPercent(): float + { + return $this->discountPercent; + } + + /** + * Ñïîñîá äîñòàâêè (`phpshop_delivery.id`), 0 — íå âûáðàí. + */ + public function getDeliveryMethodId(): int + { + return $this->deliveryMethodId; + } + + private static function email(string $raw): string + { + $email = trim($raw); + if (($email === '') || stripos($email, self::GUEST_EMAIL_PREFIX) === 0) { + return ''; + } + + return (filter_var($email, FILTER_VALIDATE_EMAIL) !== false) ? $email : ''; + } +} diff --git a/moneta/class/order/OrderStatusOptions.php b/moneta/class/order/OrderStatusOptions.php new file mode 100644 index 0000000..44354c4 --- /dev/null +++ b/moneta/class/order/OrderStatusOptions.php @@ -0,0 +1,42 @@ + $statuses ñòàòóñû ìàãàçèíà: ñòðîêè ñ êëþ÷àìè `id` è `name` + * @param int $current çíà÷åíèå èç íàñòðîåê — ïóíêò ñ íèì áóäåò âûáðàí + * + * @return array + */ + public static function build(array $statuses, int $current, string $firstLabel, int $firstValue): array + { + $options = [[$firstLabel, $firstValue, $current]]; + foreach ($statuses as $status) { + if (!is_array($status) || !isset($status['id'], $status['name'])) { + continue; + } + + $id = (int) $status['id']; + if ($id !== $firstValue) { + $options[] = [(string) $status['name'], $id, $current]; + } + } + + return $options; + } +} diff --git a/moneta/class/order/PaymentEligibility.php b/moneta/class/order/PaymentEligibility.php new file mode 100644 index 0000000..45bdbd7 --- /dev/null +++ b/moneta/class/order/PaymentEligibility.php @@ -0,0 +1,76 @@ +isConfigured()) { + return self::NOT_CONFIGURED; + } + + if ($order->getPaymentMethodId() !== ModuleInfo::PAYMENT_METHOD_ID) { + return self::OTHER_PAYMENT_METHOD; + } + + if (self::isPaid($settings, $order)) { + return self::ALREADY_PAID; + } + + if ($order->getStatusId() === self::STATUS_CANCELLED) { + return self::CANCELLED; + } + + if (!$settings->isPayableRightAfterCheckout() && $order->getStatusId() !== $settings->getPayableStatus()) { + return self::AWAITING_STATUS; + } + + if (AssistantProtocol::normalizeCurrency($shopCurrency) === null) { + return self::UNSUPPORTED_CURRENCY; + } + + if (!$order->getAmount()->isPositive()) { + return self::ZERO_AMOUNT; + } + + return self::PAYABLE; + } + + /** + * Îïëà÷åí: ôëàã `paid` ÿäðà èëè ñòàòóñ «ïîñëå îïëàòû» èç íàñòðîåê ìîäóëÿ. + */ + public static function isPaid(Settings $settings, Order $order): bool + { + return $order->isPaidFlag() || ($order->getStatusId() === $settings->getPaidStatus()); + } +} diff --git a/moneta/class/protocol/AssistantProtocol.php b/moneta/class/protocol/AssistantProtocol.php new file mode 100644 index 0000000..8ba79e8 --- /dev/null +++ b/moneta/class/protocol/AssistantProtocol.php @@ -0,0 +1,51 @@ + `MNT_CURRENCY_CODE`; null — âàëþòà ìîäóëåì íå ïîääåðæèâàåòñÿ. + * `RUR` — óñòàðåâøèé êîä ðóáëÿ, êîòîðûé åù¸ âñòðå÷àåòñÿ â ñïðàâî÷íèêàõ âàëþò ìàãàçèíîâ. + */ + public static function normalizeCurrency(string $iso): ?string + { + $iso = strtoupper(trim($iso)); + + return (($iso === self::CURRENCY_RUB) || ($iso === 'RUR')) ? self::CURRENCY_RUB : null; + } + + /** + * Çíà÷åíèå `MNT_TEST_MODE` äëÿ ôîðìû è ïîäïèñè. + */ + public static function testModeFlag(bool $testMode): string + { + return $testMode ? '1' : '0'; + } + + /** + * ßçûê âèòðèíû PHPShop (`russian`, `russian_utf`, `english`, `english_utf`) -> + * `moneta.locale` ïëàò¸æíîé ôîðìû. Ìîíåòà çíàåò òîëüêî `ru` è `en` + * (`MONETA.Assistant.ru.pdf`, ïàðàìåòðû çàïðîñà): àíãëèéñêàÿ âèòðèíà — `en`, îñòàëüíûå — `ru`. + */ + public static function normalizeLocale(string $shopLocale): string + { + return strncmp(strtolower(trim($shopLocale)), 'english', 7) === 0 ? 'en' : 'ru'; + } +} diff --git a/moneta/class/protocol/CallbackNotification.php b/moneta/class/protocol/CallbackNotification.php new file mode 100644 index 0000000..bfd2091 --- /dev/null +++ b/moneta/class/protocol/CallbackNotification.php @@ -0,0 +1,183 @@ + $fields ïàðàìåòðû çàïðîñà ($_POST èëè $_GET — ìåòîä çàäà¸òñÿ â íàñòðîéêàõ ñ÷¸òà) + */ + public function __construct(array $fields) + { + $this->command = self::optional($fields, 'MNT_COMMAND', strlen(AssistantProtocol::COMMAND_CHECK)); + if (($this->command !== '') && ($this->command !== AssistantProtocol::COMMAND_CHECK)) { + throw new InvalidArgumentException('Íåèçâåñòíàÿ êîìàíäà óâåäîìëåíèÿ.'); + } + + $this->accountId = self::required($fields, 'MNT_ID', self::ACCOUNT_ID_MAX_LENGTH); + $this->transactionId = self::required($fields, 'MNT_TRANSACTION_ID', self::TRANSACTION_ID_MAX_LENGTH); + $this->operationId = self::optional($fields, 'MNT_OPERATION_ID', self::OPERATION_ID_MAX_LENGTH); + $this->rawAmount = self::optional($fields, 'MNT_AMOUNT', strlen(Money::MAX)); + $this->amount = $this->rawAmount === '' ? null : Money::fromDecimal($this->rawAmount); + $this->currency = self::required($fields, 'MNT_CURRENCY_CODE', self::CURRENCY_LENGTH); + $this->subscriberId = self::optional($fields, 'MNT_SUBSCRIBER_ID', self::SUBSCRIBER_ID_MAX_LENGTH); + $this->rawTestMode = self::required($fields, 'MNT_TEST_MODE', 1); + if (($this->rawTestMode !== '0') && ($this->rawTestMode !== '1')) { + throw new InvalidArgumentException('Íåäîïóñòèìîå çíà÷åíèå MNT_TEST_MODE.'); + } + $this->signature = self::required($fields, 'MNT_SIGNATURE', self::SIGNATURE_LENGTH); + + if ( + (($this->amount === null) || (preg_match(self::PAY_OPERATION_ID, $this->operationId) !== 1)) + && !$this->isCheck() + ) { + throw new InvalidArgumentException('Óâåäîìëåíèþ îá îïëàòå íóæíû ñóììà è íîìåð îïåðàöèè èç öèôð.'); + } + } + + public function isCheck(): bool + { + return ($this->command === AssistantProtocol::COMMAND_CHECK); + } + + public function getCommand(): string + { + return $this->command; + } + + public function getAccountId(): string + { + return $this->accountId; + } + + public function getTransactionId(): string + { + return $this->transactionId; + } + + public function getOperationId(): string + { + return $this->operationId; + } + + /** + * Ñóììà êàê ïðèøëà — òîëüêî äëÿ ïîäïèñè; äëÿ ñâåðêè — {@see self::getAmount()}. + */ + public function getRawAmount(): string + { + return $this->rawAmount; + } + + public function getAmount(): ?Money + { + return $this->amount; + } + + public function getCurrency(): string + { + return $this->currency; + } + + public function getSubscriberId(): string + { + return $this->subscriberId; + } + + public function getRawTestMode(): string + { + return $this->rawTestMode; + } + + public function isTestMode(): bool + { + return $this->rawTestMode === '1'; + } + + public function isSignedBy(Signature $signature): bool + { + return Signature::equals($signature->forNotification($this), $this->signature); + } + + /** + * @param array $fields + */ + private static function required(array $fields, string $name, int $maxLength): string + { + $value = self::optional($fields, $name, $maxLength); + if ($value === '') { + throw new InvalidArgumentException("Íå ïåðåäàíî ïîëå {$name}."); + } + + return $value; + } + + /** + * @param array $fields + */ + private static function optional(array $fields, string $name, int $maxLength): string + { + if (!array_key_exists($name, $fields)) { + return ''; + } + + $value = $fields[$name]; + if (!is_string($value)) { + throw new InvalidArgumentException("Ïîëå {$name} äîëæíî áûòü ñòðîêîé."); + } + if (strlen($value) > $maxLength) { + throw new InvalidArgumentException("Ïîëå {$name} äëèííåå äîïóñòèìîãî."); + } + + return $value; + } +} diff --git a/moneta/class/protocol/CallbackResponse.php b/moneta/class/protocol/CallbackResponse.php new file mode 100644 index 0000000..8150622 --- /dev/null +++ b/moneta/class/protocol/CallbackResponse.php @@ -0,0 +1,55 @@ +contentType = $contentType; + $this->body = $body; + } + + /** + * Îòêàç áåç ïîäðîáíîñòåé: ïðè÷èíà ïèøåòñÿ òîëüêî â æóðíàë ìîäóëÿ. + */ + public static function fail(): self + { + return new self(self::CONTENT_TYPE_TEXT, AssistantProtocol::TEXT_FAIL); + } + + public static function xml(string $body): self + { + return new self(self::CONTENT_TYPE_XML, $body); + } + + public static function json(string $body): self + { + return new self(self::CONTENT_TYPE_JSON, $body); + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getBody(): string + { + return $this->body; + } +} diff --git a/moneta/class/protocol/CheckResponse.php b/moneta/class/protocol/CheckResponse.php new file mode 100644 index 0000000..748cfc9 --- /dev/null +++ b/moneta/class/protocol/CheckResponse.php @@ -0,0 +1,108 @@ +accountId = $accountId; + $this->transactionId = $transactionId; + $this->amount = $amount; + $this->resultCode = $resultCode; + $this->signature = $signature; + $this->cms = $cms; + } + + public function getResultCode(): ResultCode + { + return $this->resultCode; + } + + /** + * @param array $attributes `INVENTORY`, `CLIENT`, `CUSTOMER`, `PHONE` — êàê ó Pay URL + */ + public function toXml(array $attributes = []): CallbackResponse + { + return CallbackResponse::xml( + MntResponseXml::build( + $this->accountId, + $this->transactionId, + $this->resultCode, + $this->amount, + $this->sign(), + $this->cms, + $attributes, + ), + ); + } + + /** + * @param array $receipt îáúåêò `receipt` ñõåìû (`client`, `items`); äåíåæíûå + * ïîëÿ — ÷åðåç {@see JsonNumber::placeholder()}, ñòðîêè â UTF-8 + */ + public function toJson(array $receipt): CallbackResponse + { + $payload = [ + 'id' => $this->accountId, + 'transactionId' => $this->transactionId, + ]; + + if ($this->amount !== null) { + $payload['amount'] = JsonNumber::placeholder($this->amount); + } + + $payload += [ + 'signature' => $this->sign(), + 'resultCode' => (string) $this->resultCode->getValue(), + 'description' => $this->resultCode->description(), + 'cms' => $this->cms, + 'receipt' => $receipt, + ]; + + return CallbackResponse::json(JsonNumber::encode($payload)); + } + + private function sign(): string + { + return $this->signature->forResponse($this->resultCode, $this->accountId, $this->transactionId); + } +} diff --git a/moneta/class/protocol/JsonNumber.php b/moneta/class/protocol/JsonNumber.php new file mode 100644 index 0000000..edb947e --- /dev/null +++ b/moneta/class/protocol/JsonNumber.php @@ -0,0 +1,65 @@ +toDecimal(); + } + + /** + * Êîëè÷åñòâî ïîçèöèè: öåëîå — êàê åñòü (`2`), äðîáíîå — ìåòêîé, â JSON ñòàíåò `2.5`. + * + * @return int|string + */ + public static function quantity(Quantity $quantity) + { + return $quantity->isWhole() ? $quantity->getPieces() : self::QUANTITY_PREFIX . $quantity->toDecimal(); + } + + /** + * @param array $payload ñòðîêè â UTF-8 + */ + public static function encode(array $payload): string + { + $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Íå óäàëîñü ñåðèàëèçîâàòü JSON: ' . json_last_error_msg()); + } + + $result = preg_replace( + [ + '/"' . preg_quote(self::PREFIX, '/') . '([0-9]+\.[0-9]{2})"/', + '/"' . preg_quote(self::QUANTITY_PREFIX, '/') . '([0-9]+\.[0-9]{1,3})"/', + ], + '$1', + $json, + ); + if ($result === null) { + throw new RuntimeException('Íå óäàëîñü ïîäñòàâèòü ñóììû â JSON.'); + } + + return $result; + } +} diff --git a/moneta/class/protocol/MntResponseXml.php b/moneta/class/protocol/MntResponseXml.php new file mode 100644 index 0000000..f345b23 --- /dev/null +++ b/moneta/class/protocol/MntResponseXml.php @@ -0,0 +1,76 @@ + $attributes ïàðû KEY -> VALUE äëÿ `MNT_ATTRIBUTES`, ñòðîêè â UTF-8 + * @throws \DOMException + */ + public static function build( + string $accountId, + string $transactionId, + ResultCode $resultCode, + ?Money $amount, + string $signature, + string $cms, + array $attributes = [] + ): string { + $document = new DOMDocument('1.0', 'UTF-8'); + + $response = $document->appendChild($document->createElement('MNT_RESPONSE')); + + $elements = [ + 'MNT_ID' => $accountId, + 'MNT_TRANSACTION_ID' => $transactionId, + 'MNT_RESULT_CODE' => (string) $resultCode->getValue(), + 'MNT_DESCRIPTION' => $resultCode->description(), + 'MNT_AMOUNT' => $amount !== null ? $amount->toDecimal() : null, + 'MNT_SIGNATURE' => $signature, + 'MNT_CMS' => $cms, + ]; + foreach ($elements as $name => $value) { + if ($value !== null) { + self::text($document, $response, $name, $value); + } + } + + if ($attributes !== []) { + $container = $response->appendChild($document->createElement('MNT_ATTRIBUTES')); + foreach ($attributes as $key => $value) { + $attribute = $container->appendChild($document->createElement('ATTRIBUTE')); + self::text($document, $attribute, 'KEY', (string) $key); + self::text($document, $attribute, 'VALUE', $value); + } + } + + $xml = $document->saveXML(); + if ($xml === false) { + throw new RuntimeException('Íå óäàëîñü ñåðèàëèçîâàòü MNT_RESPONSE.'); + } + + return $xml; + } + + private static function text(DOMDocument $document, DOMNode $parent, string $name, string $value): void + { + $parent + ->appendChild($document->createElement($name)) + ->appendChild($document->createTextNode($value)); + } +} diff --git a/moneta/class/protocol/PayResponse.php b/moneta/class/protocol/PayResponse.php new file mode 100644 index 0000000..204ead4 --- /dev/null +++ b/moneta/class/protocol/PayResponse.php @@ -0,0 +1,70 @@ +accountId = $accountId; + $this->transactionId = $transactionId; + $this->amount = $amount; + $this->resultCode = $resultCode; + $this->signature = $signature; + $this->cms = $cms; + } + + /** + * @param array $attributes `INVENTORY`, `CLIENT`, `CUSTOMER`, `PHONE` + * @throws \DOMException + */ + public function toXml(array $attributes = []): CallbackResponse + { + return CallbackResponse::xml( + MntResponseXml::build( + $this->accountId, + $this->transactionId, + $this->resultCode, + $this->amount, + $this->signature->forResponse($this->resultCode, $this->accountId, $this->transactionId), + $this->cms, + $attributes, + ), + ); + } +} diff --git a/moneta/class/protocol/PaymentServer.php b/moneta/class/protocol/PaymentServer.php new file mode 100644 index 0000000..271179f --- /dev/null +++ b/moneta/class/protocol/PaymentServer.php @@ -0,0 +1,48 @@ + 'https://www.payanyway.ru/assistant.htm', + self::DEMO => 'https://demo.moneta.ru/assistant.htm', + ]; + + /** @var string */ + private $value; + + private function __construct(string $value) + { + $this->value = $value; + } + + public static function fromSetting(?string $value): self + { + return new self(isset(self::URLS[(string) $value]) ? (string) $value : self::PROD); + } + + public function getValue(): string + { + return $this->value; + } + + public function isDemo(): bool + { + return ($this->value === self::DEMO); + } + + public function url(): string + { + return self::URLS[$this->value]; + } +} diff --git a/moneta/class/protocol/ResultCode.php b/moneta/class/protocol/ResultCode.php new file mode 100644 index 0000000..3a9305e --- /dev/null +++ b/moneta/class/protocol/ResultCode.php @@ -0,0 +1,105 @@ + 'Order amount', + self::PAID => 'Order is paid', + self::IN_PROGRESS => 'Order is being processed', + self::AWAITING_PAYMENT => 'Order created, but not paid', + self::REJECTED => 'Order is not available', + ]; + + /** @var int */ + private $value; + + private function __construct(int $value) + { + $this->value = $value; + } + + public static function fromValue(int $value): self + { + if (!array_key_exists($value, self::DESCRIPTIONS)) { + throw new \InvalidArgumentException('Íåèçâåñòíûé êîä MNT_RESULT_CODE.'); + } + + return new self($value); + } + + public static function withAmount(): self + { + return new self(self::WITH_AMOUNT); + } + + public static function paid(): self + { + return new self(self::PAID); + } + + public static function inProgress(): self + { + return new self(self::IN_PROGRESS); + } + + public static function awaitingPayment(): self + { + return new self(self::AWAITING_PAYMENT); + } + + public static function rejected(): self + { + return new self(self::REJECTED); + } + + public function getValue(): int + { + return $this->value; + } + + public function description(): string + { + return self::DESCRIPTIONS[$this->value]; + } + + public function equals(self $other): bool + { + return ($this->value === $other->value); + } + + public function __toString(): string + { + return (string) $this->value; + } +} diff --git a/moneta/class/protocol/Signature.php b/moneta/class/protocol/Signature.php new file mode 100644 index 0000000..7c57c46 --- /dev/null +++ b/moneta/class/protocol/Signature.php @@ -0,0 +1,116 @@ +integrityCode = $integrityCode; + } + + /** + * Ôîðìóëà 1 — ïëàò¸æíàÿ ôîðìà (ìîäóëü -> ñåðâèñ). + * + * md5(MNT_ID + MNT_TRANSACTION_ID + MNT_AMOUNT + MNT_CURRENCY_CODE + MNT_SUBSCRIBER_ID + MNT_TEST_MODE + ÊÎÄ) + */ + public function forPaymentForm( + string $accountId, + string $transactionId, + Money $amount, + string $currency, + string $subscriberId, + bool $testMode + ): string { + return $this->hash( + $accountId + . $transactionId + . $amount->toDecimal() + . $currency + . $subscriberId + . AssistantProtocol::testModeFlag($testMode), + ); + } + + /** + * Ôîðìóëà 2 — âõîäÿùèé çàïðîñ íà Check URL / Pay URL (ñåðâèñ -> ìîäóëü). + * + * md5(MNT_COMMAND + MNT_ID + MNT_TRANSACTION_ID + MNT_OPERATION_ID + MNT_AMOUNT + * + MNT_CURRENCY_CODE + MNT_SUBSCRIBER_ID + MNT_TEST_MODE + ÊÎÄ) + * + * Çíà÷åíèÿ áåðóòñÿ èç çàïðîñà êàê ïðèøëè: ïîäïèñü ñâåðÿåòñÿ ñ òåì, ÷òî + * ïîäïèñàë ñåðâèñ, à íå ñ íàøèì ïðåäñòàâëåíèåì î çàêàçå. + */ + public function forNotification(CallbackNotification $notification): string + { + return $this->hash( + $notification->getCommand() + . $notification->getAccountId() + . $notification->getTransactionId() + . $notification->getOperationId() + . $notification->getRawAmount() + . $notification->getCurrency() + . $notification->getSubscriberId() + . $notification->getRawTestMode(), + ); + } + + /** + * Ôîðìóëà 3 — îòâåò ìîäóëÿ íà Check URL è Pay URL (XML è JSON). + * + * md5(MNT_RESULT_CODE + MNT_ID + MNT_TRANSACTION_ID + ÊÎÄ) + */ + public function forResponse(ResultCode $resultCode, string $accountId, string $transactionId): string + { + return $this->hash((string) $resultCode->getValue() . $accountId . $transactionId); + } + + /** + * Ñðàâíåíèå áåç óòå÷êè ïî âðåìåíè; ðåãèñòð hex-ñòðîêè íå âàæåí. + */ + public static function equals(string $expected, string $received): bool + { + if (preg_match(self::PATTERN, $received) !== 1) { + return false; + } + + return hash_equals(strtolower($expected), strtolower($received)); + } + + /** + * Êîä ïðîâåðêè öåëîñòíîñòè íå âûâîäèòñÿ â var_dump()/print_r() è æóðíàëû. + * + * @return array + */ + public function __debugInfo(): array + { + return ['integrityCode' => '***']; + } + + private function hash(string $payload): string + { + return md5($payload . $this->integrityCode); + } +} diff --git a/moneta/class/protocol/TransactionId.php b/moneta/class/protocol/TransactionId.php new file mode 100644 index 0000000..e48f6f9 --- /dev/null +++ b/moneta/class/protocol/TransactionId.php @@ -0,0 +1,118 @@ +_<ñóììà>_UTC`: íîìåð çàêàçà PHPShop + * (`phpshop_orders.uid`, íàïðèìåð `4-36`), ñóììà ôîðìû â êîïåéêàõ è ìåòêà âûäà÷è ôîðìû â UTC, + * `4-36_1600000_20260930113005UTC`. + */ +final class TransactionId +{ + /** + * Íîìåð çàêàçà PHPShop. Øòàòíî `<÷èñëî>-<ïðåôèêñ>` (`PHPShopDone::setNum()`), + * íî õóê `setNum` äðóãîãî ìîäóëÿ ìîæåò åãî ïåðåîïðåäåëèòü, ïîýòîìó äîïóñêàþòñÿ + * ëàòèíèöà, öèôðû è äåôèñ â ïðåäåëàõ `phpshop_orders.uid varchar(64)` — áåç `_`. + */ + public const ORDER_UID = '[0-9A-Za-z](?:[0-9A-Za-z-]{0,62}[0-9A-Za-z])?'; + + public const SEPARATOR = '_'; + + /** Ñóììà â êîïåéêàõ áåç âåäóùèõ íóëåé; 16 öèôð âìåùàþò {@see Money::MAX}. */ + private const AMOUNT = '0|[1-9][0-9]{0,15}'; + + /** + * Ïîÿñ ìåòêè — âñåãäà UTC, áóêâàìè. + */ + private const ZONE = 'UTC'; + + private const PATTERN = '/\A + (?' . self::ORDER_UID . ') # íîìåð çàêàçà PHPShop + ' . self::SEPARATOR . ' + (?' . self::AMOUNT . ') # ñóììà ôîðìû â êîïåéêàõ + ' . self::SEPARATOR . ' + [0-9]{14} # ìåòêà âðåìåíè YmdHis + ' . self::ZONE . ' # ïîÿñ ìåòêè + \z/x'; + + /** @var string */ + private $orderUid; + + /** @var Money */ + private $amount; + + private function __construct(string $orderUid, Money $amount) + { + $this->orderUid = $orderUid; + $this->amount = $amount; + } + + /** + * @param Money $amount ñóììà ôîðìû (`MNT_AMOUNT`) + * @param DateTimeImmutable $issuedAt ìîìåíò âûäà÷è ôîðìû, â ëþáîì ïîÿñå — ìåòêà ïèøåòñÿ â UTC + */ + public static function build(string $orderUid, Money $amount, DateTimeImmutable $issuedAt): string + { + if (preg_match('/\A' . self::ORDER_UID . '\z/', $orderUid) !== 1) { + throw new InvalidArgumentException('Íîìåð çàêàçà íå ïîäõîäèò äëÿ MNT_TRANSACTION_ID.'); + } + + return $orderUid + . self::SEPARATOR . $amount->toMinorUnits() + . self::SEPARATOR . $issuedAt->setTimezone(new DateTimeZone(self::ZONE))->format('YmdHis') . self::ZONE; + } + + /** + * Íîìåð çàêàçà è ñóììà ôîðìû èç ïðèøåäøåãî `MNT_TRANSACTION_ID` èëè null, åñëè ñòðîêà íå íàøåãî ôîðìàòà. + */ + public static function parse(string $raw): ?self + { + if (preg_match(self::PATTERN, $raw, $matches) !== 1) { + return null; + } + + try { + $amount = Money::fromMinorUnits((int) $matches['amount']); + } catch (InvalidArgumentException $exception) { + return null; + } + + return new self($matches['uid'], $amount); + } + + /** + * Íîìåð çàêàçà èç òîãî, ÷òî ââ¸ë àäìèíèñòðàòîð (ôèëüòð æóðíàëà îïåðàöèé): ñàì íîìåð (`33-57`) + * èëè öåëèêîì `MNT_TRANSACTION_ID`, ñêîïèðîâàííûé èç êàáèíåòà MONETA.RU. Èíà÷å — null. + */ + public static function orderUidFromInput(string $input): ?string + { + $input = trim($input); + if (preg_match('/\A' . self::ORDER_UID . '\z/', $input) === 1) { + return $input; + } + + $transactionId = self::parse($input); + + return ($transactionId === null) ? null : $transactionId->getOrderUid(); + } + + public function getOrderUid(): string + { + return $this->orderUid; + } + + /** + * Ñóììà ïëàò¸æíîé ôîðìû, âûïóñòèâøåé ýòîò íîìåð. + */ + public function getAmount(): Money + { + return $this->amount; + } +} diff --git a/moneta/class/receipt/Measure.php b/moneta/class/receipt/Measure.php new file mode 100644 index 0000000..65f3e87 --- /dev/null +++ b/moneta/class/receipt/Measure.php @@ -0,0 +1,73 @@ + âàðèàíòû íàïèñàíèÿ (áåç ïðîáåëîâ, â íèæíåì ðåãèñòðå). */ + private const VARIANTS = [ + self::UNIT => ['øòóêà', 'øòóêè', 'øòóê', 'øò', 'pc', 'pce', 'pcs', 'nmb'], + 'gram' => ['ãðàìì', 'ãðàììû', 'ã', 'ãð', 'g', 'grm'], + 'kilogram' => ['êèëîãðàìì', 'êèëîãðàììû', 'êã', 'kg', 'kgm'], + 'ton' => ['òîííà', 'òîííû', 'ò', 't', 'tne'], + 'centimeter' => ['ñàíòèìåòð', 'ñì', 'cm', 'cmt'], + 'decimeter' => ['äåöèìåòð', 'äì', 'dm', 'dmt'], + 'meter' => ['ìåòð', 'ìåòðû', 'ì', 'm', 'mtr', 'ïì', 'ïîã.ì'], + 'square_centimeter' => ['êâàäðàòíûéñàíòèìåòð', 'êâ.ñì', 'ñì2', 'cm2', 'cmk'], + 'square_decimeter' => ['êâàäðàòíûéäåöèìåòð', 'êâ.äì', 'äì2', 'dm2', 'dmk'], + 'square_meter' => ['êâàäðàòíûéìåòð', 'êâ.ì', 'ì2', 'm2', 'mtk'], + 'milliliter' => ['ìèëëèëèòð', 'ìë', 'ml', 'mlt'], + 'liter' => ['ëèòð', 'ëèòðû', 'ë', 'l', 'ltr'], + 'cubic_meter' => ['êóáè÷åñêèéìåòð', 'êóá.ì', 'ì3', 'm3', 'mtq'], + 'kilowatt_hour' => ['êèëîâàòò÷àñ', 'êâò*÷', 'êâò-÷', 'êâò÷', 'kwh'], + 'gigacalorie' => ['ãèãàêàëîðèÿ', 'ãêàë', 'ãèãàêàë'], + 'day' => ['ñóòêè', 'ñóò', 'äåíü', 'äí', 'd', 'day'], + 'hour' => ['÷àñ', '÷', 'h', 'hur'], + 'minute' => ['ìèíóòà', 'ìèí', 'min'], + 'second' => ['ñåêóíäà', 'ñåê', 'ñ', 's', 'sec'], + 'kilobyte' => ['êèëîáàéò', 'êáàéò', 'êá', 'kib', 'êèá', 'kb'], + 'megabyte' => ['ìåãàáàéò', 'ìáàéò', 'ìá', 'mib', 'ìèá', 'mb'], + 'gigabyte' => ['ãèãàáàéò', 'ãáàéò', 'ãá', 'gib', 'ãèá', 'gb'], + 'terabyte' => ['òåðàáàéò', 'òáàéò', 'òá', 'tib', 'òèá', 'tb'], + ]; + + private function __construct() {} + + /** + * @param string $unit åäèíèöà òîâàðà â UTF-8 + * @param Closure(string): string $toUtf8 ëèòåðàëû ìîäóëÿ -> UTF-8 + */ + public static function fromUnit(string $unit, Closure $toUtf8): string + { + // Ïðîáåëû íå çíà÷èìû («êâ. ì»), òî÷êà íà êîíöå — ñîêðàùåíèå («øò.»). + $unit = rtrim((string) preg_replace('/\s+/u', '', $unit), '.'); + if ($unit === '') { + return self::UNIT; + } + foreach (self::VARIANTS as $measure => $variants) { + foreach ($variants as $variant) { + if (preg_match('/\A' . preg_quote($toUtf8($variant), '/') . '\z/iu', $unit) === 1) { + return $measure; + } + } + } + + return self::UNIT; + } +} diff --git a/moneta/class/receipt/PaymentMethod.php b/moneta/class/receipt/PaymentMethod.php new file mode 100644 index 0000000..bb630d5 --- /dev/null +++ b/moneta/class/receipt/PaymentMethod.php @@ -0,0 +1,59 @@ + ïîäïèñü â íàñòðîéêàõ, ïîðÿäîê — êàê â `kassaspecification.pdf`. */ + public const LABELS = [ + self::FULL_PREPAYMENT => 'Ïîëíàÿ ïðåäîïëàòà', + self::PREPAYMENT => '×àñòè÷íàÿ ïðåäîïëàòà', + self::ADVANCE => 'Àâàíñ', + self::FULL_PAYMENT => 'Ïîëíûé ðàñ÷¸ò', + self::PARTIAL_PAYMENT => '×àñòè÷íûé ðàñ÷¸ò è êðåäèò', + self::CREDIT => 'Êðåäèò', + self::CREDIT_PAYMENT => 'Âûïëàòà ïî êðåäèòó', + ]; + + /** @var string */ + private $value; + + private function __construct(string $value) + { + $this->value = $value; + } + + /** + * Çíà÷åíèå èç íàñòðîåê; íåèçâåñòíîå èëè ïóñòîå — ïîëíûé ðàñ÷¸ò (äåôîëò êàññû). + */ + public static function fromSetting(?string $value): self + { + return new self(isset(self::LABELS[(string) $value]) ? (string) $value : self::FULL_PAYMENT); + } + + public static function fullPayment(): self + { + return new self(self::FULL_PAYMENT); + } + + public function getValue(): string + { + return $this->value; + } +} diff --git a/moneta/class/receipt/PaymentObject.php b/moneta/class/receipt/PaymentObject.php new file mode 100644 index 0000000..c15c141 --- /dev/null +++ b/moneta/class/receipt/PaymentObject.php @@ -0,0 +1,71 @@ + ïîäïèñü â íàñòðîéêàõ. */ + public const LABELS = [ + self::COMMODITY => 'Òîâàð', + 'excise' => 'Ïîäàêöèçíûé òîâàð', + 'job' => 'Ðàáîòà', + self::SERVICE => 'Óñëóãà', + 'gambling_bet' => 'Ñòàâêà àçàðòíîé èãðû', + 'gambling_prize' => 'Âûèãðûø àçàðòíîé èãðû', + 'lottery' => 'Ëîòåðåéíûé áèëåò', + 'lottery_prize' => 'Âûèãðûø ëîòåðåè', + 'intellectual_activity' => 'Ïðåäîñòàâëåíèå ðåçóëüòàòîâ èíòåëëåêòóàëüíîé äåÿòåëüíîñòè', + 'payment' => 'Ïëàò¸æ', + 'agent_commission' => 'Àãåíòñêîå âîçíàãðàæäåíèå', + 'award' => 'Âûïëàòà (âçíîñ, ïåíè, øòðàô, âîçíàãðàæäåíèå, áîíóñ)', + 'composite' => 'Ñîñòàâíîé ïðåäìåò ðàñ÷¸òà', + 'another' => 'Äðóãîå', + 'property_right' => 'Èìóùåñòâåííîå ïðàâî', + 'non_operating' => 'Âíåðåàëèçàöèîííûé äîõîä', + 'insurance_premium' => 'Ñòðàõîâîé ñáîð', + 'sales_tax' => 'Òîðãîâûé ñáîð', + 'resort_fee' => 'Êóðîðòíûé ñáîð', + ]; + + /** @var string */ + private $value; + + private function __construct(string $value) + { + $this->value = $value; + } + + /** + * Çíà÷åíèå èç íàñòðîåê; íåèçâåñòíîå èëè ïóñòîå — òîâàð (äåôîëò êàññû). + */ + public static function fromSetting(?string $value): self + { + return new self(isset(self::LABELS[(string) $value]) ? (string) $value : self::COMMODITY); + } + + public static function commodity(): self + { + return new self(self::COMMODITY); + } + + public static function service(): self + { + return new self(self::SERVICE); + } + + public function getValue(): string + { + return $this->value; + } +} diff --git a/moneta/class/receipt/Quantity.php b/moneta/class/receipt/Quantity.php new file mode 100644 index 0000000..5178edf --- /dev/null +++ b/moneta/class/receipt/Quantity.php @@ -0,0 +1,111 @@ + self::MAX_THOUSANDTHS)) { + throw new InvalidArgumentException('Êîëè÷åñòâî ïîçèöèè ÷åêà âíå ãðàíèö êàññû.'); + } + + $this->thousandths = $thousandths; + } + + public static function pieces(int $pieces): self + { + return new self($pieces * self::SCALE); + } + + public static function fromThousandths(int $thousandths): self + { + return new self($thousandths); + } + + /** + * Êîëè÷åñòâî èç êîðçèíû çàêàçà (`num`): ÷èñëî èëè ÷èñëîâàÿ ñòðîêà, îêðóãëÿåòñÿ äî òûñÿ÷íûõ. + * Íîëü, îòðèöàòåëüíîå, íå ÷èñëî èëè áîëüøå ïîòîëêà êàññû — null. + * + * @param mixed $raw + */ + public static function fromCart($raw): ?self + { + if (is_string($raw)) { + $raw = trim($raw); + } + if (!is_int($raw) && !is_float($raw) && !(is_string($raw) && is_numeric($raw))) { + return null; + } + $thousandths = (int) round((float) $raw * self::SCALE); + if (($thousandths < 1) || ($thousandths > self::MAX_THOUSANDTHS)) { + return null; + } + + return new self($thousandths); + } + + public function getThousandths(): int + { + return $this->thousandths; + } + + /** + * Öåëîå ëè êîëè÷åñòâî: òîëüêî òàêóþ ñòðîêó ìîæíî ðåçàòü íà äâå ñ ðàçíèöåé öåí â êîïåéêó. + */ + public function isWhole(): bool + { + return $this->thousandths % self::SCALE === 0; + } + + /** + * Öåëîå ÷èñëî øòóê; òîëüêî äëÿ {@see self::isWhole()}. + */ + public function getPieces(): int + { + return intdiv($this->thousandths, self::SCALE); + } + + /** + * Äåñÿòè÷íàÿ çàïèñü áåç ëèøíèõ íóëåé: `2`, `2.5`, `0.125`. + */ + public function toDecimal(): string + { + if ($this->isWhole()) { + return (string) $this->getPieces(); + } + + return rtrim( + sprintf('%d.%03d', intdiv($this->thousandths, self::SCALE), $this->thousandths % self::SCALE), + '0', + ); + } + + /** + * Ñóììà ïîçèöèè: öåíà x êîëè÷åñòâî, îêðóãëåíèå äî êîïåéêè (ïîëîâèíà — ââåðõ). + */ + public function totalOf(Money $price): Money + { + return Money::fromMinorUnits( + intdiv(($price->toMinorUnits() * $this->thousandths) + intdiv(self::SCALE, 2), self::SCALE), + ); + } +} diff --git a/moneta/class/receipt/Receipt.php b/moneta/class/receipt/Receipt.php new file mode 100644 index 0000000..6e20c5e --- /dev/null +++ b/moneta/class/receipt/Receipt.php @@ -0,0 +1,111 @@ + */ + private $items; + + /** + * @param array $items + */ + public function __construct(?ReceiptClient $client, array $items) + { + if ($items === []) { + throw new InvalidArgumentException('×åê áåç ïîçèöèé.'); + } + + $this->client = $client; + $this->items = array_values($items); + } + + public function getClient(): ?ReceiptClient + { + return $this->client; + } + + /** + * @return array + */ + public function getItems(): array + { + return $this->items; + } + + public function total(): Money + { + $total = Money::fromMinorUnits(0); + foreach ($this->items as $item) { + $total = $total->add($item->total()); + } + + return $total; + } + + /** + * `MNT_ATTRIBUTES`: `INVENTORY` âñåãäà; `CLIENT`, `CUSTOMER` (email), `PHONE` (áåç `+`) — + * åñëè åñòü êîíòàêò; `SNO` — ïðè ôèñêàëèçàöèè, åñëè ñèñòåìà íàëîãîîáëîæåíèÿ âûáðàíà. + * `DELIVERY` íå ïåðåäà¸òñÿ: äîñòàâêà — ïîçèöèåé `INVENTORY`. + * Áåç ôèñêàëèçàöèè ïîçèöèè óðåçàíû ({@see ReceiptItem::toInventoryPosition()}). + * + * @return array + */ + public function toAttributes(bool $fiscalization = true, ?TaxSystem $taxSystem = null): array + { + $attributes = [ + 'INVENTORY' => JsonNumber::encode( + array_map(static function (ReceiptItem $item) use ($fiscalization): array { + return $item->toInventoryPosition($fiscalization); + }, $this->items), + ), + ]; + + $client = ($this->client !== null) ? $this->client->toArray(ReceiptClient::KASSA_PHONE_MAX_LENGTH) : []; + if ($client !== []) { + $attributes['CLIENT'] = JsonNumber::encode([$client]); + if (isset($client['email'])) { + $attributes['CUSTOMER'] = $client['email']; + } + if (isset($client['phone'])) { + $attributes['PHONE'] = ltrim($client['phone'], '+'); + } + } + + if ($fiscalization && ($taxSystem !== null) && $taxSystem->isPassed()) { + $attributes['SNO'] = $taxSystem->getValue(); + } + + return $attributes; + } + + /** + * Îáúåêò `receipt` JSON-îòâåòà Check URL. Áåç êîíòàêòà — ïóñòîé `client` (`{}`). + * + * @return array + */ + public function toJsonReceipt(): array + { + $client = $this->client !== null ? $this->client->toArray(ReceiptClient::JSON_PHONE_MAX_LENGTH) : []; + + return [ + 'client' => $client === [] ? new \stdClass() : $client, + 'items' => array_map(static function (ReceiptItem $item): array { + return $item->toCheckItem(); + }, $this->items), + ]; + } +} diff --git a/moneta/class/receipt/ReceiptBuilder.php b/moneta/class/receipt/ReceiptBuilder.php new file mode 100644 index 0000000..97c83ab --- /dev/null +++ b/moneta/class/receipt/ReceiptBuilder.php @@ -0,0 +1,360 @@ + UTF-8 + * @param PaymentMethod $paymentMethod ñïîñîá ðàñ÷¸òà âñåõ ïîçèöèé — íàñòðîéêà ìîäóëÿ + * @param PaymentObject|null $goodsObject ïðåäìåò ðàñ÷¸òà òîâàðîâ — íàñòðîéêà ìîäóëÿ; null — òîâàð. + * Ó äîñòàâêè âñåãäà óñëóãà. + * @param Vat|null $paymentVat ñòàâêà çàïàñíîé ïîçèöèè «Îïëàòà çàêàçà»: ôèêñèðîâàííàÿ «ÍÄÑ íà òîâàðû»; + * null — áåç ÍÄÑ + */ + public function __construct( + Closure $toUtf8, + PaymentMethod $paymentMethod, + ?PaymentObject $goodsObject = null, + ?Vat $paymentVat = null + ) { + $this->toUtf8 = $toUtf8; + $this->paymentMethod = $paymentMethod; + $this->goodsObject = $goodsObject ?? PaymentObject::commodity(); + $this->paymentVat = $paymentVat ?? Vat::none(); + } + + /** + * @param Vat $goodsVat ñèñòåìíàÿ ñòàâêà PHPShop äëÿ òîâàðîâ + * @param Vat $deliveryVat ñòàâêà ñïîñîáà äîñòàâêè + * @param string $fallbackEmail íàñòðîéêà «Email äëÿ ÷åêîâ», ìîæåò áûòü ïóñòîé + */ + public function build(Order $order, Vat $goodsVat, Vat $deliveryVat, string $fallbackEmail): Receipt + { + $client = ReceiptClient::fromContacts( + $order->getEmail(), + $order->getPhone(), + $this->utf8($order->getCustomerName()), + $fallbackEmail, + ); + + $amount = $order->getAmount()->toMinorUnits(); + + $items = $this->items($this->balance($this->lines($order, $goodsVat, $deliveryVat), $amount)); + if ($items === null) { + $items = [ + new ReceiptItem( + $this->utf8(self::PAYMENT_NAME . $order->getUid()), + $order->getAmount(), + Quantity::pieces(1), + $this->paymentVat, + $this->goodsObject, + $this->paymentMethod, + ), + ]; + } + + return new Receipt($client, $items); + } + + /** + * Ñòðîêè ÷åêà ïî ïðàâèëàì ÿäðà (`PHPShopDone::send_to_order()`): ñêèäêà ïîêóïàòåëÿ — êî âñåì + * òîâàðàì áåç ïðîìî-öåíû, äîñòàâêà èç êîðçèíû çàêàçà. Êîëè÷åñòâî — êàê â êîðçèíå, â òîì ÷èñëå + * äðîáíîå (âåñîâîé òîâàð), åäèíèöà èçìåðåíèÿ — èç `ed_izm` òîâàðà. + * + * @return array + */ + private function lines(Order $order, Vat $goodsVat, Vat $deliveryVat): array + { + $cart = $order->getCart(); + $lines = []; + $products = isset($cart['cart']) && is_array($cart['cart']) ? $cart['cart'] : []; + $discount = $order->getDiscountPercent(); + foreach ($products as $product) { + if (!is_array($product)) { + continue; + } + + $price = (float) ($product['price'] ?? 0); + if (($discount > 0) && empty($product['promo_price'])) { + $price -= $price * $discount / 100; + } + + $quantity = Quantity::fromCart($product['num'] ?? null); + if (($quantity === null) || ($price <= 0)) { + continue; + } + + $lines[] = [ + 'name' => $this->name((string) ($product['name'] ?? '')), + 'unit' => Money::fromFloat($price)->toMinorUnits(), + 'quantity' => $quantity, + 'measure' => Measure::fromUnit($this->utf8((string) ($product['ed_izm'] ?? '')), $this->toUtf8), + 'vat' => $goodsVat, + 'delivery' => false, + ]; + } + + $delivery = (float) ($cart['dostavka'] ?? 0); + if ($delivery > 0) { + $lines[] = [ + 'name' => $this->utf8(self::DELIVERY_NAME), + 'unit' => Money::fromFloat($delivery)->toMinorUnits(), + 'quantity' => Quantity::pieces(1), + 'measure' => Measure::UNIT, + 'vat' => $deliveryVat, + 'delivery' => true, + ]; + } + + return $lines; + } + + /** + * Ïîäãîíêà ñóììû ñòðîê ïîä ñóììó çàêàçà â êîïåéêàõ. Ñíà÷àëà ðàñõîæäåíèå áåðóò øòó÷íûå òîâàðû; + * åñëè íå óêëàäûâàåòñÿ — âñå øòó÷íûå ñòðîêè âìåñòå ñ äîñòàâêîé; åñëè øòó÷íûõ ñòðîê íåò — + * è âåñîâûå, ïîäáîðîì öåíû çà åäèíèöó. Ñòðîêó âåñîâîãî òîâàðà íà äâå íå ðàçðåçàòü, ïîýòîìó + * å¸ òðîãàþò ïîñëåäíåé. Ïóñòîé ðåçóëüòàò — íå âûøëî. + * + * @param array $lines + * + * @return array + */ + private function balance(array $lines, int $target): array + { + if (($target <= 0) || ($lines === [])) { + return []; + } + + $totals = []; + foreach ($lines as $index => $line) { + $totals[$index] = self::lineTotal($line); + } + if (array_sum($totals) === $target) { + return $lines; + } + + $stages = [ + static function (array $line): bool { + return !$line['delivery'] && $line['quantity']->isWhole(); + }, + static function (array $line): bool { + return $line['quantity']->isWhole(); + }, + static function (array $line): bool { + return true; + }, + ]; + foreach ($stages as $isAdjustable) { + $adjustable = []; + $fixed = 0; + foreach ($lines as $index => $line) { + if ($isAdjustable($line)) { + $adjustable[] = $index; + } else { + $fixed += $totals[$index]; + } + } + if (($adjustable === []) || ($target - $fixed <= 0)) { + continue; + } + + return $this->distribute($lines, $totals, $adjustable, $target - $fixed); + } + + return []; + } + + /** + * Ðàçíîñ ñóììû `$goal` ïî ñòðîêàì `$adjustable` ïðîïîðöèîíàëüíî èõ ñóììå. + * + * @param array $lines + * @param array $totals ñóììû ñòðîê â êîïåéêàõ + * @param array $adjustable èíäåêñû ñòðîê, êîòîðûå ìîæíî ìåíÿòü + * + * @return array + */ + private function distribute(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 = $line['quantity']->isWhole() + ? $this->split($line, $shares[$index]) + : $this->reprice($line, $shares[$index]); + if ($parts === []) { + return []; + } + foreach ($parts as $part) { + $balanced[] = $part; + } + } + + return $balanced; + } + + /** + * Øòó÷íàÿ ñòðîêà ñ íîâîé ñóììîé: öåëàÿ öåíà çà øòóêó, îñòàòîê êîïååê — âòîðîé ñòðîêîé òîé æå + * ïîçèöèè ñ öåíîé íà êîïåéêó âûøå. Íóëåâûå ÷àñòè îòáðàñûâàþòñÿ. + * + * @param array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool} $line + * + * @return array + */ + private function split(array $line, int $total): array + { + $pieces = $line['quantity']->getPieces(); + $unit = intdiv($total, $pieces); + $rest = $total - ($unit * $pieces); + + $parts = []; + if ($rest > 0) { + $parts[] = ['unit' => $unit + 1, 'quantity' => Quantity::pieces($rest)] + $line; + } + if (($unit > 0) && ($pieces - $rest > 0)) { + $parts[] = ['unit' => $unit, 'quantity' => Quantity::pieces($pieces - $rest)] + $line; + } + + return $parts; + } + + /** + * Âåñîâàÿ ñòðîêà ñ íîâîé ñóììîé: öåíà çà åäèíèöó, ïðè êîòîðîé öåíà x êîëè÷åñòâî äî êîïåéêè + * äà¸ò ðîâíî `$total`. Òàêîé öåíû ìîæåò íå áûòü (íà 2.5 êã øàã ñóììû — 2–3 êîïåéêè) — òîãäà ïóñòî. + * + * @param array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool} $line + * + * @return array + */ + private function reprice(array $line, int $total): array + { + $thousandths = $line['quantity']->getThousandths(); + $estimate = intdiv(($total * 1000) + intdiv($thousandths, 2), $thousandths); + foreach ([$estimate, $estimate - 1, $estimate + 1] as $unit) { + if ($unit > 0 && self::lineTotal(['unit' => $unit] + $line) === $total) { + return [['unit' => $unit] + $line]; + } + } + + return []; + } + + /** + * @param array{unit: int, quantity: Quantity} $line + */ + private static function lineTotal(array $line): int + { + return $line['quantity']->totalOf(Money::fromMinorUnits($line['unit']))->toMinorUnits(); + } + + /** + * @param array $lines + * + * @return array|null null — ñòðîê íåò èëè ñòðîêà íå ïðîõîäèò îãðàíè÷åíèÿ êàññû + */ + private function items(array $lines): ?array + { + if ($lines === []) { + return null; + } + + $items = []; + try { + foreach ($lines as $line) { + $items[] = new ReceiptItem( + $line['name'], + Money::fromMinorUnits($line['unit']), + $line['quantity'], + $line['vat'], + $line['delivery'] ? PaymentObject::service() : $this->goodsObject, + $this->paymentMethod, + $line['measure'], + ); + } + } catch (InvalidArgumentException $exception) { + return null; + } + + return $items; + } + + private function name(string $raw): string + { + $name = ReceiptText::clean($this->utf8($raw), ReceiptItem::NAME_MAX_LENGTH); + + return ($name !== '') ? $name : $this->utf8(self::ITEM_FALLBACK_NAME); + } + + private function utf8(string $value): string + { + return ($this->toUtf8)($value); + } +} diff --git a/moneta/class/receipt/ReceiptClient.php b/moneta/class/receipt/ReceiptClient.php new file mode 100644 index 0000000..9d6a1ce --- /dev/null +++ b/moneta/class/receipt/ReceiptClient.php @@ -0,0 +1,107 @@ +name = $name; + $this->email = $email; + $this->phone = $phone; + } + + /** + * Ïîêóïàòåëü èç äàííûõ çàêàçà èëè null, åñëè ÷åêó íåêóäà óéòè. «Email äëÿ ÷åêîâ» ìåð÷àíòà + * ïîäñòàâëÿåòñÿ, òîëüêî êîãäà ó ïîêóïàòåëÿ íåò íè email, íè òåëåôîíà; ÔÈÎ îñòà¸òñÿ. + */ + public static function fromContacts(string $email, string $phone, string $name, string $fallbackEmail): ?self + { + $phone = PhoneNumber::normalize($phone) ?? ''; + if (strlen($phone) > self::KASSA_PHONE_MAX_LENGTH) { + $phone = ''; + } + + $email = self::email($email); + if (($email === '') && ($phone === '')) { + $email = self::email($fallbackEmail); + } + if (($email === '') && ($phone === '')) { + return null; + } + + return new self(ReceiptText::clean($name, self::NAME_MAX_LENGTH), $email, $phone); + } + + public function getEmail(): string + { + return $this->email; + } + + public function getPhone(): string + { + return $this->phone; + } + + /** + * Îáúåêò `client`; ïóñòûå êëþ÷è íå ïåðåäàþòñÿ. + * + * @param int $phoneMaxLength {@see self::JSON_PHONE_MAX_LENGTH} èëè {@see self::KASSA_PHONE_MAX_LENGTH} + * + * @return array + */ + public function toArray(int $phoneMaxLength): array + { + $phone = strlen($this->phone) <= $phoneMaxLength ? $this->phone : ''; + if (($this->email === '') && ($phone === '')) { + // Òåëåôîí äëèííåå ñõåìû, à email íåò — áåç êîíòàêòà ÔÈÎ êàññà íå ïðèìåò. + return []; + } + + return array_filter( + ['name' => $this->name, 'email' => $this->email, 'phone' => $phone], + static function (string $value): bool { + return ($value !== ''); + }, + ); + } + + private static function email(string $email): string + { + $email = trim($email); + if ( + strlen($email) > self::EMAIL_MAX_LENGTH + || filter_var($email, FILTER_VALIDATE_EMAIL) === false + ) { + return ''; + } + + return $email; + } +} diff --git a/moneta/class/receipt/ReceiptItem.php b/moneta/class/receipt/ReceiptItem.php new file mode 100644 index 0000000..ecc68ce --- /dev/null +++ b/moneta/class/receipt/ReceiptItem.php @@ -0,0 +1,168 @@ +name = ReceiptText::clean($name, self::NAME_MAX_LENGTH); + if ($this->name === '') { + throw new InvalidArgumentException('Ïóñòîå íàçâàíèå ïîçèöèè ÷åêà.'); + } + $this->price = $price; + $this->quantity = $quantity; + $this->measure = $measure; + $this->vat = $vat; + $this->paymentObject = $paymentObject ?? PaymentObject::commodity(); + $this->paymentMethod = $paymentMethod ?? PaymentMethod::fullPayment(); + + $max = Money::fromDecimal(self::PRICE_MAX)->toMinorUnits(); + if (($price->toMinorUnits() > $max) || ($this->total()->toMinorUnits() > $max)) { + throw new InvalidArgumentException('Ñóììà ïîçèöèè ÷åêà âûøå ïîòîëêà êàññû.'); + } + } + + public function getName(): string + { + return $this->name; + } + + public function getPrice(): Money + { + return $this->price; + } + + public function getQuantity(): Quantity + { + return $this->quantity; + } + + public function getMeasure(): string + { + return $this->measure; + } + + public function getVat(): Vat + { + return $this->vat; + } + + public function getPaymentObject(): PaymentObject + { + return $this->paymentObject; + } + + public function getPaymentMethod(): PaymentMethod + { + return $this->paymentMethod; + } + + /** + * Ñóììà ïîçèöèè òàê, êàê å¸ ïîñ÷èòàåò êàññà: öåíà x êîëè÷åñòâî äî êîïåéêè. + */ + public function total(): Money + { + return $this->quantity->totalOf($this->price); + } + + /** + * Ýëåìåíò `items` JSON-îòâåòà Check URL; öåíà — ìåòêîé {@see JsonNumber}. + * + * @return array + */ + public function toCheckItem(): array + { + return [ + 'name' => $this->name, + 'price' => JsonNumber::placeholder($this->price), + 'quantity' => JsonNumber::quantity($this->quantity), + 'measure' => $this->measure, + 'paymentMethod' => $this->paymentMethod->getValue(), + 'paymentObject' => $this->paymentObject->getValue(), + 'vat' => $this->vat->getCode(), + ]; + } + + /** + * Îáúåêò `inventPositions` äëÿ àòðèáóòà `INVENTORY`. Áåç ôèñêàëèçàöèè — òîëüêî òî, ÷òî + * MONETA.RU ñîõðàíèò â îïåðàöèè: áåç `vatTag`, `pm`, `po`. + * + * @return array + */ + public function toInventoryPosition(bool $fiscalization = true): array + { + $position = [ + 'name' => $this->name, + 'price' => JsonNumber::placeholder($this->price), + 'quantity' => JsonNumber::quantity($this->quantity), + 'measure' => $this->measure, + ]; + if (!$fiscalization) { + return $position; + } + + return $position + [ + 'vatTag' => $this->vat->getTag(), + 'pm' => $this->paymentMethod->getValue(), + 'po' => $this->paymentObject->getValue(), + ]; + } +} diff --git a/moneta/class/receipt/ReceiptText.php b/moneta/class/receipt/ReceiptText.php new file mode 100644 index 0000000..77c399e --- /dev/null +++ b/moneta/class/receipt/ReceiptText.php @@ -0,0 +1,39 @@ + ïîäïèñü â íàñòðîéêàõ, êîäû — ïî `kassaspecification.pdf`. */ + public const LABELS = [ + self::KASSA => 'Íå ïåðåäàâàòü (èç íàñòðîåê êàññû)', + '1' => 'Îáùàÿ (ÎÑÍÎ)', + '2' => 'Óïðîù¸ííàÿ (ÓÑÍ) äîõîä', + '3' => 'Óïðîù¸ííàÿ (ÓÑÍ) äîõîä ìèíóñ ðàñõîä', + '4' => 'Åäèíûé íàëîã íà âìåí¸ííûé äîõîä (ÅÍÂÄ)', + '5' => 'Åäèíûé ñåëüñêîõîçÿéñòâåííûé íàëîã (ÅÑÕÍ)', + '6' => 'Ïàòåíòíàÿ (ÏÑÍ)', + ]; + + /** @var string */ + private $value; + + private function __construct(string $value) + { + $this->value = $value; + } + + /** + * Çíà÷åíèå èç íàñòðîåê; íåèçâåñòíîå èëè ïóñòîå — íå ïåðåäàâàòü. + */ + public static function fromSetting(?string $value): self + { + $value = (string) $value; + + return new self(isset(self::LABELS[$value]) ? $value : self::KASSA); + } + + public static function kassa(): self + { + return new self(self::KASSA); + } + + public function getValue(): string + { + return $this->value; + } + + public function isPassed(): bool + { + return ($this->value !== self::KASSA); + } +} diff --git a/moneta/class/receipt/Vat.php b/moneta/class/receipt/Vat.php new file mode 100644 index 0000000..22cad5b --- /dev/null +++ b/moneta/class/receipt/Vat.php @@ -0,0 +1,107 @@ + êîä `vat`. */ + private const BY_PERCENT = [ + 0 => 'vat0', + 5 => 'vat5', + 7 => 'vat7', + 10 => 'vat10', + 20 => 'vat20', + 22 => 'vat22', + ]; + + /** + * Êîä `vat` (`cmsspecification.pdf`) -> `vatTag` (`kassaspecification.pdf`). + */ + private const TAGS = [ + self::NONE => '1105', + 'vat0' => '1104', + 'vat5' => '1108', + 'vat7' => '1109', + 'vat10' => '1103', + 'vat20' => '1102', + 'vat22' => '1113', + 'vat105' => '1110', + 'vat107' => '1111', + 'vat110' => '1107', + 'vat120' => '1106', + 'vat122' => '1114', + ]; + + /** @var string */ + private $code; + + private function __construct(string $code) + { + $this->code = $code; + } + + public static function none(): self + { + return new self(self::NONE); + } + + /** + * Ñòàâêà ïî êîäó `vat`; íåèçâåñòíûé êîä — null. + */ + public static function fromCode(string $code): ?self + { + return isset(self::TAGS[$code]) ? new self($code) : null; + } + + /** + * Ñòàâêà èç íàñòðîåê PHPShop — ñâîáîäíîå ÷èñëî ïðîöåíòîâ (`nds`, `ofd_nds` äîñòàâêè). + * Ïóñòî èëè ñòàâêè íåò â ïåðå÷íå êàññû — «áåç ÍÄÑ»: íåèçâåñòíóþ ñòàâêó íå óãàäûâàåì. + */ + public static function fromPercent(string $percent): self + { + return self::tryFromPercent($percent) ?? self::none(); + } + + /** + * Êàê {@see self::fromPercent()}, íî ïóñòîå èëè íåèçâåñòíîå çíà÷åíèå — null: âûçûâàþùèé + * ïðåäóïðåæäàåò ìåð÷àíòà, ÷òî ïîçèöèÿ óéä¸ò â ÷åê «áåç ÍÄÑ». + */ + public static function tryFromPercent(string $percent): ?self + { + $percent = trim($percent); + if (preg_match('/\A[0-9]{1,3}(?:[.,]0+)?\z/', $percent) !== 1) { + return null; + } + $value = (int) $percent; + + return isset(self::BY_PERCENT[$value]) ? new self(self::BY_PERCENT[$value]) : null; + } + + public function getCode(): string + { + return $this->code; + } + + public function getTag(): string + { + return self::TAGS[$this->code]; + } + + public function equals(self $other): bool + { + return ($this->code === $other->code); + } +} diff --git a/moneta/class/receipt/VatRateWarnings.php b/moneta/class/receipt/VatRateWarnings.php new file mode 100644 index 0000000..0d8f6e9 --- /dev/null +++ b/moneta/class/receipt/VatRateWarnings.php @@ -0,0 +1,63 @@ + $deliveries ñïîñîáû äîñòàâêè: [íàçâàíèå, «Çíà÷åíèå ÍÄÑ» + * (`ofd_nds`, ïóñòî — ñèñòåìíàÿ ñòàâêà)] + * + * @return array íåðàñïîçíàííûå ñòàâêè: `ñèñòåìíàÿ ñòàâêà PHPShop (òîâàðû): 23` + */ + public static function find( + bool $fiscalization, + bool $vatEnabled, + VatSetting $goodsVat, + VatSetting $deliveryVat, + string $systemPercent, + array $deliveries + ): array { + if (!$fiscalization || !$vatEnabled) { + return []; + } + + $warnings = []; + if ($goodsVat->getFixed() === null && Vat::tryFromPercent($systemPercent) === null) { + $warnings[] = 'ñèñòåìíàÿ ñòàâêà PHPShop (òîâàðû): ' . self::shown($systemPercent); + } + if ($deliveryVat->getFixed() === null) { + foreach ($deliveries as [$name, $percent]) { + $percent = (trim($percent) !== '') ? $percent : $systemPercent; + if (Vat::tryFromPercent($percent) === null) { + $warnings[] = 'äîñòàâêà «' . $name . '»: ' . self::shown($percent); + } + } + } + + return $warnings; + } + + /** + * Çíà÷åíèå äëÿ ñîîáùåíèÿ: ïóñòîå — ñëîâîì, èíà÷å êàê ââ¸ë ìåð÷àíò. + */ + public static function shown(string $percent): string + { + $percent = trim($percent); + + return ($percent === '') ? 'íå çàäàíà' : $percent . '%'; + } +} diff --git a/moneta/class/receipt/VatSetting.php b/moneta/class/receipt/VatSetting.php new file mode 100644 index 0000000..e90f6bc --- /dev/null +++ b/moneta/class/receipt/VatSetting.php @@ -0,0 +1,79 @@ + ïîäïèñü â íàñòðîéêàõ. */ + public const FIXED_LABELS = [ + Vat::NONE => 'Áåç ÍÄÑ', + 'vat0' => 'ÍÄÑ 0%', + 'vat5' => 'ÍÄÑ 5%', + 'vat7' => 'ÍÄÑ 7%', + 'vat10' => 'ÍÄÑ 10%', + 'vat20' => 'ÍÄÑ 20%', + 'vat22' => 'ÍÄÑ 22%', + 'vat105' => 'ÍÄÑ 5% (ðàñ÷¸òíàÿ ñòàâêà 5/105)', + 'vat107' => 'ÍÄÑ 7% (ðàñ÷¸òíàÿ ñòàâêà 7/107)', + 'vat110' => 'ÍÄÑ 10% (ðàñ÷¸òíàÿ ñòàâêà 10/110)', + 'vat120' => 'ÍÄÑ 20% (ðàñ÷¸òíàÿ ñòàâêà 20/120)', + 'vat122' => 'ÍÄÑ 22% (ðàñ÷¸òíàÿ ñòàâêà 22/122)', + ]; + + /** @var Vat|null */ + private $fixed; + + private function __construct(?Vat $fixed) + { + $this->fixed = $fixed; + } + + /** + * Çíà÷åíèå èç íàñòðîåê; ïóñòîå, {@see self::PLATFORM} èëè íåèçâåñòíîå — ñòàâêà èç PHPShop. + */ + public static function fromSetting(?string $value): self + { + $value = (string) $value; + + return new self(isset(self::FIXED_LABELS[$value]) ? Vat::fromCode($value) : null); + } + + public static function platform(): self + { + return new self(null); + } + + /** + * Ôèêñèðîâàííàÿ ñòàâêà èëè null — áðàòü èç PHPShop. + */ + public function getFixed(): ?Vat + { + return $this->fixed; + } + + public function getValue(): string + { + return ($this->fixed !== null) ? $this->fixed->getCode() : self::PLATFORM; + } + + /** + * Ïîäïèñè äëÿ âûïàäàþùåãî ñïèñêà: ïåðâûì — ñòàâêà èç PHPShop ñ ïîäïèñüþ `$platformLabel`. + * + * @return array + */ + public static function labels(string $platformLabel): array + { + return [self::PLATFORM => $platformLabel] + self::FIXED_LABELS; + } +} diff --git a/moneta/core/moneta.core.php b/moneta/core/moneta.core.php new file mode 100644 index 0000000..12f7871 --- /dev/null +++ b/moneta/core/moneta.core.php @@ -0,0 +1,118 @@ +`. + */ +class PHPShopMoneta extends PHPShopCore +{ + public function __construct() + { + parent::__construct(); + } + + public function index() + { + $result = self::query('result'); + $transactionId = self::query('MNT_TRANSACTION_ID'); + $attempt = max(0, min(ReturnMessage::MAX_ATTEMPTS, (int) self::query('attempt'))); + + $message = $this->returnMessage($result, $transactionId, $attempt); + if ($message === null) { + $this->setError404(); + + return; + } + + $orderUid = $message->getOrderUid(); + $refreshUrl = $message->isWaiting() + ? '?' . http_build_query([ + 'result' => $result, + 'MNT_TRANSACTION_ID' => $transactionId, + 'attempt' => $attempt + 1, + ]) + : ''; + + $this->title = HostCharset::toPage($message->getTitle()); + $this->set('monetaReturnIcon', $message->getIcon()); + $this->set('monetaReturnTitle', self::escape($message->getTitle())); + $this->set('monetaReturnText', self::escape($message->getText())); + $this->set('monetaReturnOrderUrl', self::escape('/users/order.html?order_info=' . rawurlencode($orderUid) . '#Order')); + $this->set('monetaReturnOrderHide', $orderUid === '' ? 'moneta-hide' : ''); + $this->set('monetaReturnRefreshUrl', self::escape($refreshUrl)); + $this->set('monetaReturnRefreshDelay', (string) ReturnMessage::REFRESH_DELAY_MS); + + // ßäðî ÷èòàåò øàáëîí ìîäóëÿ êàê åñòü (cp1251) è íå ïåðåêîäèðóåò åãî: íà UTF-8-ìàãàçèíå + // ïåðåìåííûå è òåêñò øàáëîíà ïåðåêîäèðóþòñÿ âìåñòå, îäèí ðàç, óæå ïîñëå ðàçáîðà. + $this->parseTemplate($GLOBALS['SysValue']['templates']['moneta']['moneta_return_forma'], true); + $this->Disp = HostCharset::toPage((string) $this->Disp); + } + + /** + * Çàêàç èùåòñÿ ïî íîìåðó èç `MNT_TRANSACTION_ID` è ïîêàçûâàåòñÿ òîëüêî âëàäåëüöó + * ({@see ReturnAccess::findOrder()}). Ñáîé ÁÄ ñòðàíèöó íå ðîíÿåò: ïîêóïàòåëü óâèäèò òåêñò ïî `result`. + */ + private function returnMessage(string $result, string $transactionId, int $attempt): ?ReturnMessage + { + $order = null; + $paidByJournal = false; + $settings = null; + $transactionGiven = $transactionId !== ''; + + try { + $settings = (new SettingsRepository())->load(); + if ($transactionGiven) { + $order = ReturnAccess::findOrder( + $transactionId, + new OrderRepository(), + (int) ($_SESSION['UsersId'] ?? 0), + $_SESSION[ReturnAccess::SESSION_KEY] ?? null, + ); + if ($order !== null) { + $paidByJournal = (new OperationRepository(Database::fromHost()))->findPaidOperation($order->getId()) !== null; + } + } + } catch (\Throwable $exception) { + (new ErrorLogLogger())->error( + 'Ñòðàíèöà âîçâðàòà áåç ñòàòóñà çàêàçà: ' . get_class($exception) . ': ' . $exception->getMessage(), + ); + $order = null; + $transactionGiven = false; + } + + return ReturnMessage::resolve( + $result, + $transactionGiven, + $order, + $paidByJournal, + $settings ?? Settings::fromRow([]), + $attempt, + ); + } + + private static function query(string $name): string + { + return isset($_GET[$name]) && is_string($_GET[$name]) ? $_GET[$name] : ''; + } + + private static function escape(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES, 'Windows-1251'); + } +} diff --git a/moneta/hook/done_moneta.hook.php b/moneta/hook/done_moneta.hook.php new file mode 100644 index 0000000..da678ee --- /dev/null +++ b/moneta/hook/done_moneta.hook.php @@ -0,0 +1,46 @@ + $value $_POST ôîðìû çàêàçà + * @param string $rout òî÷êà ïåðåõâàòà: START | MIDDLE | END + */ +function send_to_order_mod_moneta_hook($obj, $value, $rout) +{ + require_once dirname(__DIR__) . '/class/autoload.php'; + + if (($rout !== 'END') || empty($obj->orderId)) { + return null; + } + + if ((int) ($value['order_metod'] ?? 0) !== \MonetaPhpShop\ModuleInfo::PAYMENT_METHOD_ID) { + return null; + } + + $block = \MonetaPhpShop\host\CheckoutView::create()->afterCheckout((int) $obj->orderId); + if ($block->isEmpty()) { + $obj->set('orderMesage', ''); + + return null; + } + + $obj->set('payment_info', $block->getInfo()); + $obj->set('payment_info_hide', $block->getInfo() === '' ? 'moneta-hide' : ''); + $obj->set('payment_forma', $block->getForm()); + $obj->set('moneta_total', $block->getTotal()); + $obj->set('moneta_total_hide', $block->getTotal() === '' ? 'moneta-hide' : ''); + $html = ParseTemplateReturn($GLOBALS['SysValue']['templates']['moneta']['moneta_payment_forma'], true); + $obj->set('orderMesage', \MonetaPhpShop\host\HostCharset::toPage((string) $html)); + + return null; +} + +$addHandler = [ + 'send_to_order' => 'send_to_order_mod_moneta_hook', +]; diff --git a/moneta/hook/fail_moneta.hook.php b/moneta/hook/fail_moneta.hook.php new file mode 100644 index 0000000..49bc7f6 --- /dev/null +++ b/moneta/hook/fail_moneta.hook.php @@ -0,0 +1,29 @@ +returnPagePath())) + ->withTransaction(\MonetaPhpShop\form\ReturnUrls::RESULT_FAIL, $_GET['MNT_TRANSACTION_ID'] ?? null); + if ($url === null) { + return null; + } + + header('Location: ' . $url, true, 302); + exit; +} + +$addHandler = [ + 'index' => 'fail_mod_moneta_hook', +]; diff --git a/moneta/hook/success_moneta.hook.php b/moneta/hook/success_moneta.hook.php new file mode 100644 index 0000000..a52c96a --- /dev/null +++ b/moneta/hook/success_moneta.hook.php @@ -0,0 +1,29 @@ +returnPagePath())) + ->withTransaction(\MonetaPhpShop\form\ReturnUrls::RESULT_SUCCESS, $_GET['MNT_TRANSACTION_ID'] ?? null); + if ($url === null) { + return null; + } + + header('Location: ' . $url, true, 302); + exit; +} + +$addHandler = [ + 'index' => 'success_mod_moneta_hook', +]; diff --git a/moneta/hook/userorderpaymentlink_moneta.hook.php b/moneta/hook/userorderpaymentlink_moneta.hook.php new file mode 100644 index 0000000..3123430 --- /dev/null +++ b/moneta/hook/userorderpaymentlink_moneta.hook.php @@ -0,0 +1,29 @@ +order_metod_id !== \MonetaPhpShop\ModuleInfo::PAYMENT_METHOD_ID) { + return null; + } + + //  ëè÷íîì êàáèíåòå ÿäðî ñîçäà¸ò PHPShopOrderFunction èç ãîòîâîé ñòðîêè çàêàçà, áåç objID. + $orderId = (int) ($PHPShopOrderFunction->objRow['id'] ?? $PHPShopOrderFunction->objID ?? 0); + + return \MonetaPhpShop\host\CheckoutView::create()->inAccount($orderId); +} + +$addHandler = [ + 'userorderpaymentlink' => 'userorderpaymentlink_mod_moneta_hook', +]; diff --git a/moneta/inc/config.ini b/moneta/inc/config.ini new file mode 100644 index 0000000..bf069a2 --- /dev/null +++ b/moneta/inc/config.ini @@ -0,0 +1,21 @@ +; Êîíôèãóðàöèÿ ìîäóëÿ «Ìîíåòà» äëÿ ÿäðà PHPShop (PHPShopModules::getIni()). +; Òàáëèöû èç [base] ÿäðî óäàëÿåò ïðè âûêëþ÷åíèè ìîäóëÿ. + +[base] +moneta_system="phpshop_modules_moneta_system"; +moneta_operations="phpshop_modules_moneta_operations"; +moneta_log_throttle="phpshop_modules_moneta_log_throttle"; + +[hook] +phpshopdone="./phpshop/modules/moneta/hook/done_moneta.hook.php"; +userorderpaymentlink="./phpshop/modules/moneta/hook/userorderpaymentlink_moneta.hook.php"; +; Âîçâðàò ïîêóïàòåëÿ íà /success è /fail ÿäðà — ñ÷åòà ìåð÷àíòîâ ñî âñòðîåííûì ìîäóëåì. +phpshopsuccess="./phpshop/modules/moneta/hook/success_moneta.hook.php"; +phpshopfail="./phpshop/modules/moneta/hook/fail_moneta.hook.php"; + +[core] +moneta="./phpshop/modules/moneta/core/moneta.core.php"; + +[templates] +moneta_payment_forma="./phpshop/modules/moneta/templates/payment_forma.tpl"; +moneta_return_forma="./phpshop/modules/moneta/templates/return_forma.tpl"; diff --git a/moneta/install/module.sql b/moneta/install/module.sql new file mode 100644 index 0000000..b0535db --- /dev/null +++ b/moneta/install/module.sql @@ -0,0 +1,63 @@ +DROP TABLE IF EXISTS `phpshop_modules_moneta_system`; +-- title_end — «Îïèñàíèå îïëàòû», êàê ó âñòðîåííîãî ìîäóëÿ è øòàòíûõ ìîäóëåé PHPShop: òåêñò íàä +-- ñóììîé è êíîïêîé îïëàòû. Ó îáíîâèâøèõñÿ ñî âñòðîåííîãî ìîäóëÿ çíà÷åíèå ñîõðàíÿåòñÿ. +CREATE TABLE IF NOT EXISTS `phpshop_modules_moneta_system` ( + `id` int(11) NOT NULL auto_increment, + `account_id` varchar(20) NOT NULL default '', + `integrity_code` varchar(255) NOT NULL default '', + `payment_server` varchar(8) NOT NULL default 'PROD', + `test_mode` enum('0','1') NOT NULL default '0', + `fiscalization` enum('0','1') NOT NULL default '0', + `receipt_email` varchar(64) NOT NULL default '', + `sno` varchar(1) NOT NULL default '', + `payment_method` varchar(32) NOT NULL default 'full_payment', + `payment_object` varchar(32) NOT NULL default 'commodity', + `goods_vat` varchar(16) NOT NULL default 'platform', + `delivery_vat` varchar(16) NOT NULL default 'platform', + `status` int(11) NOT NULL default '0', + `paid_status` int(11) NOT NULL default '101', + `title_end` text, + `version` varchar(64) NOT NULL default '2.0', + PRIMARY KEY (`id`) +) ENGINE=MyISAM DEFAULT CHARSET=cp1251; + +-- Æóðíàë îïåðàöèé: ñòðîêà íà êàæäîå ïîäïèñàííîå óâåäîìëåíèå Check/Pay URL. Ó çà÷ò¸ííîé +-- îïëàòû çàïîëíåí paid_order_id — UNIQUE íå äà¸ò çà÷åñòü çàêàç äâàæäû (MyISAM, òðàíçàêöèé íåò). +DROP TABLE IF EXISTS `phpshop_modules_moneta_operations`; +CREATE TABLE IF NOT EXISTS `phpshop_modules_moneta_operations` ( + `id` int(11) NOT NULL auto_increment, + `created_at` int(11) NOT NULL default '0', + `command` varchar(8) NOT NULL default '', + `transaction_id` varchar(255) NOT NULL default '', + `operation_id` varchar(255) NOT NULL default '', + `order_id` int(11) NOT NULL default '0', + `order_uid` varchar(64) NOT NULL default '', + `amount` varchar(20) NOT NULL default '', + `test_mode` enum('0','1') NOT NULL default '0', + `result_code` int(11) default NULL, + `event` varchar(32) NOT NULL default '', + `paid_order_id` int(11) default NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `paid_order_id` (`paid_order_id`), + KEY `order_id` (`order_id`) +) ENGINE=MyISAM DEFAULT CHARSET=cp1251; + +DROP TABLE IF EXISTS `phpshop_modules_moneta_log_throttle`; +-- Ñ÷¸ò÷èê æóðíàëà îòêàçîâ êîëáýêà äî ïðîâåðêè ïîäïèñè: íå ÷àùå ñòðîêè â ìèíóòó, îäíà ñòðîêà. +-- Îòäåëüíî îò íàñòðîåê — çàïèñü â MyISAM áëîêèðóåò òàáëèöó, à íàñòðîéêè ÷èòàåò êàæäûé êîëáýê. +CREATE TABLE IF NOT EXISTS `phpshop_modules_moneta_log_throttle` ( + `id` int(11) NOT NULL default '1', + `logged_at` int(11) NOT NULL default '0', + `skipped` int(11) NOT NULL default '0', + PRIMARY KEY (`id`) +) ENGINE=MyISAM DEFAULT CHARSET=cp1251; +INSERT IGNORE INTO `phpshop_modules_moneta_log_throttle` (`id`, `logged_at`, `skipped`) VALUES (1, 0, 0); + +INSERT INTO `phpshop_modules_moneta_system` (`id`, `title_end`, `version`) VALUES +(1, 'Îïëàòèòå ïîæàëóéñòà ñâîé çàêàç', '2.0'); + +-- num — «Ïðèîðèòåò» ñïîñîáà îïëàòû, ïî íåìó ÿäðî ñîðòèðóåò ñïèñîê ïðè îôîðìëåíèè çàêàçà. +-- Ó øòàòíûõ ñïîñîáîâ 0, ïðè ðàâåíñòâå ïîðÿäîê íå çàäàí è íîâûé ñïîñîá îêàçûâàåòñÿ ïîñëåäíèì, +-- ïîýòîìó -1 — ïåðâûì â ñïèñêå. Ìåð÷àíò ìåíÿåò ïðèîðèòåò â êàðòî÷êå ñïîñîáà îïëàòû. +INSERT IGNORE INTO `phpshop_payment_systems` (`id`, `name`, `path`, `enabled`, `num`, `message`, `message_header`, `yur_data_flag`, `icon`) VALUES +(10048, 'Ïðè¸ì ïëàòåæåé áàíêîâñêèìè êàðòàìè, ÷åðåç ÑÁÏ è Pay-ñåðâèñû (Ìîíåòà)', 'modules', '0', -1, '', '', '0', 'phpshop/modules/moneta/templates/moneta.svg'); diff --git a/moneta/install/module.xml b/moneta/install/module.xml new file mode 100644 index 0000000..be7d9bf --- /dev/null +++ b/moneta/install/module.xml @@ -0,0 +1,15 @@ + + + 2.0 + Ìîíåòà + Ïðè¸ì ïëàòåæåé áàíêîâñêèìè êàðòàìè, ÷åðåç ÑÁÏ è Pay-ñåðâèñû + payment + + Ìîíåòà + true + + Æóðíàë îïåðàöèé + dir.moneta + + + diff --git a/moneta/payment/callback.php b/moneta/payment/callback.php new file mode 100644 index 0000000..5a375f4 --- /dev/null +++ b/moneta/payment/callback.php @@ -0,0 +1,89 @@ + 0) { + ob_end_clean(); + } + header('Content-Type: ' . $response->getContentType()); + echo $response->getBody(); +} + +$logger = new ErrorLogLogger(); + +// checkInstall() ÿäðà äåëàåò exit ñ òåêñòîì — âûêëþ÷åííûé ìîäóëü îòâå÷àåì ïðîòîêîëîì. +// Òàáëèö âûêëþ÷åííîãî ìîäóëÿ íåò, ñ÷¸ò÷èêà ÷àñòîòû òîæå: òàêèå îòêàçû ïèøóòñÿ âñå. +if (empty($PHPShopModules->ModValue['base'][ModuleInfo::PATH])) { + $logger->error('Óâåäîìëåíèå îòêëîíåíî: ìîäóëü âûêëþ÷åí â PHPShop.'); + moneta_callback_send(CallbackResponse::fail()); + exit; +} + +try { + $database = Database::fromHost(); + $shopContext = new ShopContext(); + $settings = (new SettingsRepository())->load(); + $controller = new CallbackController( + $settings, + $shopContext->orderCurrency(), + $shopContext->cms(), + new OrderRepository(), + new OperationRepository($database), + new MysqlOrderLock($database), + new HostReceiptSource($settings, $database, $logger), + $logger, + new ThrottledLogger($logger, new MysqlLogThrottle($database)) + ); + // Ìåòîä (GET èëè POST) çàäà¸òñÿ â íàñòðîéêàõ ñ÷¸òà MONETA.RU — ÷èòàåì îáà, ïðèîðèòåò ó POST. + $response = $controller->handle(CallbackController::requestFields($_GET, $_POST)); +} catch (\Throwable $exception) { + $logger->error('Ñáîé êîëáýêà: ' . get_class($exception) . ': ' . $exception->getMessage()); + $response = CallbackResponse::fail(); +} + +moneta_callback_send($response); diff --git a/moneta/payment/result.php b/moneta/payment/result.php new file mode 100644 index 0000000..e3c4454 --- /dev/null +++ b/moneta/payment/result.php @@ -0,0 +1,7 @@ + + + + diff --git a/moneta/templates/moneta.css b/moneta/templates/moneta.css new file mode 100644 index 0000000..0bac882 --- /dev/null +++ b/moneta/templates/moneta.css @@ -0,0 +1,168 @@ +/* Êàðòî÷êà îïëàòû íà ñòðàíèöå «Çàêàç îôîðìëåí» (templates/payment_forma.tpl). + Ñâîè êëàññû, áåç îïîðû íà Bootstrap òåìû: òåìû PHPShop áûâàþò íà Bootstrap 3 è 4/5. */ +.moneta-checkout { + max-width: 30rem; + margin: 1rem auto; + padding: 1.5rem; + border: 1px solid #dee2e6; + border-radius: 1.25rem; + background: #fff; + text-align: left; +} + +.moneta-checkout-brand { + display: inline-block; + margin-bottom: 1.25rem; +} + +.moneta-checkout-logo { + display: block; + height: 36px; + width: auto; + max-width: 100%; +} + +.moneta-checkout-info { + margin: 0 0 1rem; +} + +.moneta-checkout-total { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.25rem 1rem; + margin-bottom: 1.5rem; + padding: 1.25rem 0; + border-top: 1px solid #dee2e6; + border-bottom: 1px solid #dee2e6; +} + +.moneta-checkout-total-label { + color: #6c757d; + font-size: 1.125rem; +} + +.moneta-checkout-cost { + margin-left: auto; + font-size: 2rem; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; +} + +.moneta-checkout .moneta-payment-button { + display: block; + width: 100%; + padding: 0.75rem 1rem; + border-radius: 0.75rem; + font-size: 1.125rem; + font-weight: 600; +} + +.moneta-checkout-note { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin-top: 1.25rem; + color: #6c757d; + font-size: 0.875rem; +} + +.moneta-checkout-lock { + flex-shrink: 0; + margin-top: 0.2em; +} + +/* Ñêðûòü ïóñòîé áëîê: êëàññû hidden (Bootstrap 3) è d-none (4/5) åñòü íå âî âñåõ òåìàõ. */ +.moneta-checkout .moneta-hide { + display: none; +} + +@media (max-width: 575.98px) { + .moneta-checkout { + padding: 1.25rem; + } + + .moneta-checkout-cost { + font-size: 1.5rem; + } +} + +/* Ñòðàíèöà âîçâðàòà /moneta/ (templates/return_forma.tpl): èêîíêà ïî ñîñòîÿíèþ, çàãîëîâîê, + òåêñò è êíîïêè — êàê ñòðàíèöû ðåçóëüòàòà â ìîäóëå 1Ñ-Áèòðèêñ. */ +.moneta-result { + max-width: 40rem; + margin: 2rem auto 3rem; + padding: 0 1rem; + text-align: center; +} + +.moneta-result-icon { + margin-bottom: 1.5rem; +} + +.moneta-result-icon svg { + display: none; + margin: 0 auto; +} + +.moneta-result--success .moneta-result-icon-success, +.moneta-result--error .moneta-result-icon-error, +.moneta-result--wait .moneta-result-icon-wait, +.moneta-result--info .moneta-result-icon-info, +.moneta-result--warning .moneta-result-icon-warning { + display: block; +} + +.moneta-result-spinner { + animation: moneta-spin 1.2s linear infinite; +} + +@keyframes moneta-spin { + to { + transform: rotate(360deg); + } +} + +.moneta-result-title { + margin: 0 0 0.75rem; + font-size: 1.75rem; + font-weight: 700; + line-height: 1.3; +} + +.moneta-result-text { + margin: 0 0 2rem; + color: #555; + font-size: 1.0625rem; + line-height: 1.5; +} + +.moneta-result-buttons { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; +} + +.moneta-result-buttons .btn { + flex: 1 1 12rem; + max-width: 16rem; + padding: 0.625rem 1.25rem; + border-radius: 0.5rem; +} + +.moneta-result .moneta-hide { + display: none; +} + +@media (max-width: 575.98px) { + .moneta-result-title { + font-size: 1.375rem; + } + + .moneta-result-buttons .btn { + max-width: none; + } +} diff --git a/moneta/templates/moneta.svg b/moneta/templates/moneta.svg new file mode 100644 index 0000000..39f4c07 --- /dev/null +++ b/moneta/templates/moneta.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/moneta/templates/payment_forma.tpl b/moneta/templates/payment_forma.tpl new file mode 100644 index 0000000..fdfa9df --- /dev/null +++ b/moneta/templates/payment_forma.tpl @@ -0,0 +1,16 @@ + +
+ + + +

@payment_info@

+
+ Ê îïëàòå + @moneta_total@ +
+ @payment_forma@ +
+ + Âû ïåðåéä¸òå íà çàùèù¸ííóþ ñòðàíèöó îïëàòû. +
+
diff --git a/moneta/templates/return_forma.tpl b/moneta/templates/return_forma.tpl new file mode 100644 index 0000000..16d9af9 --- /dev/null +++ b/moneta/templates/return_forma.tpl @@ -0,0 +1,26 @@ + +
+ +

@monetaReturnTitle@

+

@monetaReturnText@

+ +
+ diff --git a/moneta/uninstall/module.sql b/moneta/uninstall/module.sql new file mode 100644 index 0000000..4edae5e --- /dev/null +++ b/moneta/uninstall/module.sql @@ -0,0 +1 @@ +DELETE FROM `phpshop_payment_systems` WHERE `id` = 10048; diff --git a/moneta/updates/1.2/update_module.sql b/moneta/updates/1.2/update_module.sql new file mode 100644 index 0000000..37f419a --- /dev/null +++ b/moneta/updates/1.2/update_module.sql @@ -0,0 +1,75 @@ +-- Ìèãðàöèÿ ñî âñòðîåííîãî ìîäóëÿ «Ìîíåòà» PHPShop 6.7.0 (â òàáëèöå íàñòðîåê âåðñèÿ 1.2). +-- ßäðî çàïóñêàåò ôàéë êíîïêîé «Îáíîâèòü ìîäóëü» (PHPShopModules::getUpdate()): ðåæåò òåêñò +-- ïî òî÷êå ñ çàïÿòîé è âûïîëíÿåò çàïðîñû ñ ïîäàâëåíèåì îøèáîê, ïîòîì ïèøåò âåðñèþ èç module.xml. +-- Ïîýòîìó òî÷êè ñ çàïÿòîé — òîëüêî â êîíöå çàïðîñîâ, à ïîâòîðíûé çàïóñê íè÷åãî íå ïîðòèò: +-- ALTER íà ñóùåñòâóþùóþ êîëîíêó ìîë÷à ïàäàåò, UPDATE òðîãàþò òîëüêî íåçàïîëíåííûå ïîëÿ. +-- Ñòàðûå êîëîíêè (merchant_id, merchant_sig, title, serial) íå óäàëÿþòñÿ, merchant_sig î÷èùàåòñÿ ïîñëå ïåðåíîñà êîäà. + +ALTER TABLE `phpshop_modules_moneta_system` + ADD COLUMN `account_id` varchar(20) NOT NULL default '' AFTER `id`, + ADD COLUMN `integrity_code` varchar(255) NOT NULL default '' AFTER `account_id`, + ADD COLUMN `payment_server` varchar(8) NOT NULL default 'PROD' AFTER `integrity_code`, + ADD COLUMN `test_mode` enum('0','1') NOT NULL default '0' AFTER `payment_server`, + ADD COLUMN `fiscalization` enum('0','1') NOT NULL default '0' AFTER `test_mode`, + ADD COLUMN `receipt_email` varchar(64) NOT NULL default '' AFTER `fiscalization`, + ADD COLUMN `sno` varchar(1) NOT NULL default '' AFTER `receipt_email`, + ADD COLUMN `payment_method` varchar(32) NOT NULL default 'full_payment' AFTER `sno`, + ADD COLUMN `payment_object` varchar(32) NOT NULL default 'commodity' AFTER `payment_method`, + ADD COLUMN `goods_vat` varchar(16) NOT NULL default 'platform' AFTER `payment_object`, + ADD COLUMN `delivery_vat` varchar(16) NOT NULL default 'platform' AFTER `goods_vat`, + ADD COLUMN `paid_status` int(11) NOT NULL default '101' AFTER `status`; + +-- Íîìåð ñ÷¸òà: âñòðîåííûé ìîäóëü õðàíèë åãî â merchant_id áåç ïðîâåðêè, ïåðåíîñèì òîëüêî öèôðû. +UPDATE `phpshop_modules_moneta_system` SET `account_id` = TRIM(`merchant_id`) + WHERE `account_id` = '' AND TRIM(`merchant_id`) REGEXP '^[0-9]{1,20}$'; + +-- «Ñåêðåòíîå ñëîâî» âñòðîåííîãî ìîäóëÿ ïîäïèñûâàëî ôîðìó îïëàòû — ýòî êîä ïðîâåðêè öåëîñòíîñòè. +UPDATE `phpshop_modules_moneta_system` SET `integrity_code` = TRIM(`merchant_sig`) + WHERE `integrity_code` = '' AND TRIM(`merchant_sig`) <> ''; +-- Ñåêðåò íå äîëæåí îñòàâàòüñÿ âòîðûì ýêçåìïëÿðîì â ñòàðîé êîëîíêå: ïîñëå ñìåíû êîäà â íàñòðîéêàõ +-- ïðåæíèé ëåæàë áû òàì äàëüøå. Î÷èùàåì, òîëüêî êîãäà êîä óæå ïåðåíåñ¸í. +UPDATE `phpshop_modules_moneta_system` SET `merchant_sig` = '' + WHERE `integrity_code` <> '' AND `merchant_sig` <> ''; + +-- «Îïëàòà ïðè ñòàòóñå»: ó âñòðîåííîãî ìîäóëÿ êîëîíêà äîïóñêàëà NULL, ó íàñ 0 — «Íîâûé çàêàç». +UPDATE `phpshop_modules_moneta_system` SET `status` = 0 WHERE `status` IS NULL; +ALTER TABLE `phpshop_modules_moneta_system` MODIFY `status` int(11) NOT NULL default '0'; + +-- Æóðíàë îïåðàöèé: ñòðîêà íà êàæäîå ïîäïèñàííîå óâåäîìëåíèå Check/Pay URL. Ó çà÷ò¸ííîé +-- îïëàòû çàïîëíåí paid_order_id — UNIQUE íå äà¸ò çà÷åñòü çàêàç äâàæäû (MyISAM, òðàíçàêöèé íåò). +CREATE TABLE IF NOT EXISTS `phpshop_modules_moneta_operations` ( + `id` int(11) NOT NULL auto_increment, + `created_at` int(11) NOT NULL default '0', + `command` varchar(8) NOT NULL default '', + `transaction_id` varchar(255) NOT NULL default '', + `operation_id` varchar(255) NOT NULL default '', + `order_id` int(11) NOT NULL default '0', + `order_uid` varchar(64) NOT NULL default '', + `amount` varchar(20) NOT NULL default '', + `test_mode` enum('0','1') NOT NULL default '0', + `result_code` int(11) default NULL, + `event` varchar(32) NOT NULL default '', + `paid_order_id` int(11) default NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `paid_order_id` (`paid_order_id`), + KEY `order_id` (`order_id`) +) ENGINE=MyISAM DEFAULT CHARSET=cp1251; + +-- Ñ÷¸ò÷èê æóðíàëà îòêàçîâ êîëáýêà äî ïðîâåðêè ïîäïèñè: íå ÷àùå ñòðîêè â ìèíóòó, îäíà ñòðîêà. +-- Îòäåëüíî îò íàñòðîåê — çàïèñü â MyISAM áëîêèðóåò òàáëèöó, à íàñòðîéêè ÷èòàåò êàæäûé êîëáýê. +CREATE TABLE IF NOT EXISTS `phpshop_modules_moneta_log_throttle` ( + `id` int(11) NOT NULL default '1', + `logged_at` int(11) NOT NULL default '0', + `skipped` int(11) NOT NULL default '0', + PRIMARY KEY (`id`) +) ENGINE=MyISAM DEFAULT CHARSET=cp1251; +INSERT IGNORE INTO `phpshop_modules_moneta_log_throttle` (`id`, `logged_at`, `skipped`) VALUES (1, 0, 0); + +-- Ñïîñîá îïëàòû 10048: íàçâàíèå è èêîíêó âñòðîåííîãî ìîäóëÿ ìåíÿåì íà íîâûå, òîëüêî åñëè ìåð÷àíò +-- èõ íå ïðàâèë. Êèðèëëèöà — ñ ïðåôèêñîì _cp1251: ôàéë â cp1251, à «Îáíîâèòü ìîäóëü» âûïîëíÿåò +-- åãî áåç ïåðåêîäèðîâêè (â îòëè÷èå îò install/module.sql ïðè «Âêëþ÷èòü»), è íà ìàãàçèíå ñ áàçîé +-- utf-8 ëèòåðàë áåç ïðåôèêñà ÷èòàëñÿ áû â êîäèðîâêå ñîåäèíåíèÿ è íå ñîâïàë áû ñ íàçâàíèåì. +UPDATE `phpshop_payment_systems` SET `name` = _cp1251'Ïðè¸ì ïëàòåæåé áàíêîâñêèìè êàðòàìè, ÷åðåç ÑÁÏ è Pay-ñåðâèñû (Ìîíåòà)' + WHERE `id` = 10048 AND `name` = _cp1251'Ìèð, Visa, Mastercard (Ìîíåòà)'; +UPDATE `phpshop_payment_systems` SET `icon` = 'phpshop/modules/moneta/templates/moneta.svg' + WHERE `id` = 10048 AND `icon` = '/UserFiles/Image/Payments/moneta.png';