feat: paygw_moneta 1.0.0, MONETA.Assistant payment gateway for Moodle

This commit is contained in:
2026-09-25 16:42:38 +03:00
parent 70464af858
commit 14b386a550
65 changed files with 5377 additions and 59 deletions
+117
View File
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local;
use core\message\message;
use core_payment\helper;
use core_user;
use paygw_moneta\local\order\Order;
/**
* Сообщения покупателю через систему сообщений Moodle (`db/messages.php`):
* «оплата принята» (`payment_received`) и «ссылка на оплату» (`payment_link`).
* Плагин не выбирает канал и не шлёт почту сам: веб-уведомление и/или письмо
* через исходящую почту сайта — по настройкам уведомлений пользователя.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class BuyerNotifier
{
public const PROVIDER = 'payment_received';
public const PROVIDER_LINK = 'payment_link';
public function __construct(
private readonly Logger $logger = new ErrorLogLogger(),
) {}
/**
* Сообщение по оплаченному заказу; сбой отправки не должен ломать ответ на
* Pay URL, поэтому ошибки только логируются.
*
* @return int|false id сообщения либо false, если отправка не состоялась
*/
public function notifyPaid(Order $order): int|false
{
return $this->send(
order: $order,
provider: self::PROVIDER,
stringKey: 'message:paid',
extra: static fn(Order $order): array => [
'operationid' => (string) $order->providerTransactionId,
'url' => helper::get_success_url($order->component, $order->paymentArea, $order->itemId)->out(false),
],
);
}
/**
* Ссылка на оплату только что созданного заказа (настройка шлюза «Отправлять
* ссылку оплаты на почту»): покупатель может оплатить позже, со страницы
* `checkout.php`. Сбой отправки не мешает перейти к оплате сейчас.
*
* @return int|false id сообщения либо false, если отправка не состоялась
*/
public function notifyPaymentLink(Order $order): int|false
{
return $this->send(
order: $order,
provider: self::PROVIDER_LINK,
stringKey: 'message:link',
extra: static fn(Order $order): array => [
'url' => PaymentFormPage::resumeUrl($order)->out(false),
],
);
}
/**
* @param callable(Order): array<string, string> $extra поля строки сообщения сверх общих; `url` обязателен
*/
private function send(Order $order, string $provider, string $stringKey, callable $extra): int|false
{
try {
$user = core_user::get_user($order->userId);
if ($user === false || !empty($user->deleted) || isguestuser($user)) {
return false;
}
// Значения для подстановок {$a->…} в строках message:* (имя $a задаёт get_string()).
$placeholders = (object) ([
'firstname' => $user->firstname,
'fullname' => fullname($user),
'description' => $order->snapshot->description,
'amount' => helper::get_cost_as_string((float) $order->amount->toDecimal(), $order->currency),
'sitename' => format_string(get_site()->fullname),
] + $extra($order));
$body = get_string($stringKey, 'paygw_moneta', $placeholders);
$bodyForHtml = get_string(
$stringKey,
'paygw_moneta',
(object) (['url' => '<' . $placeholders->url . '>'] + (array) $placeholders),
);
$message = new message();
$message->component = 'paygw_moneta';
$message->name = $provider;
$message->userfrom = core_user::get_noreply_user();
$message->userto = $user;
$message->subject = get_string($stringKey . ':subject', 'paygw_moneta', $placeholders);
$message->fullmessage = $body;
$message->fullmessageformat = FORMAT_MARKDOWN;
$message->fullmessagehtml = markdown_to_html($bodyForHtml);
$message->smallmessage = $message->subject;
$message->notification = 1;
$message->contexturl = $placeholders->url;
$message->contexturlname = $order->snapshot->description;
return message_send($message);
} catch (\Throwable $exception) {
$this->logger->error('Сообщение покупателю не отправлено: ' . get_class($exception));
return false;
}
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local;
/**
* Идентификатор CMS и модуля для `MNT_CMS`:
* `Moodle v<release>|PHP <version>|Moneta v<release>`.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class CmsInfo
{
private const CMS_NAME = 'Moodle';
private const MODULE_NAME = 'Moneta';
private function __construct() {}
public static function getCmsModuleVersion(): string
{
global $CFG;
$pluginInfo = \core_plugin_manager::instance()->get_plugin_info('paygw_moneta');
$moduleVersion = $pluginInfo?->release ?? (string) ($pluginInfo?->versiondisk ?? 'unknown');
return self::format(
cmsVersion: (string) ($CFG->release ?? 'unknown'),
phpVersion: PHP_VERSION,
moduleVersion: (string) $moduleVersion,
);
}
public static function format(string $cmsVersion, string $phpVersion, string $moduleVersion): string
{
return sprintf(
'%s v%s|PHP %s|%s v%s',
self::CMS_NAME,
$cmsVersion,
$phpVersion,
self::MODULE_NAME,
$moduleVersion,
);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local;
/**
* Символ валюты для показа сумм администратору («120.25 ₽»).
* Известен только рубль; любой другой код остаётся кодом («USD»),
* пустой — пустой строкой.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class CurrencySymbol
{
private const SYMBOLS = [
'RUB' => '₽',
];
private function __construct() {}
public static function of(?string $currencyCode): string
{
if ($currencyCode === null || $currencyCode === '') {
return '';
}
return self::SYMBOLS[strtoupper($currencyCode)] ?? $currencyCode;
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local;
/**
* Журнал в `error_log` PHP. Не `debugging()`: тот пишет только при включённой
* отладке, а на странице (`pay.php`) при отладке с Whoops сам бросает
* исключение и роняет переход к оплате.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class ErrorLogLogger implements Logger
{
public function error(string $message): void
{
error_log('paygw_moneta: ' . $message);
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local;
use paygw_moneta\local\protocol\PaymentServer;
use paygw_moneta\local\protocol\Signature;
use paygw_moneta\local\receipt\Client;
use paygw_moneta\local\receipt\Vat;
/**
* Настройки шлюза Moneta у платёжного аккаунта Moodle (`payment_gateways.config`);
* читает их из БД {@see GatewayConfigRepository}.
* Единственный читатель кода проверки целостности: дальше он живёт только
* внутри {@see Signature}.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class GatewayConfig
{
public const GATEWAY = 'moneta';
public function __construct(
public readonly string $accountNumber,
#[\SensitiveParameter]
private readonly string $integrityCode,
public readonly PaymentServer $paymentServer,
public readonly bool $testMode,
public readonly bool $fiscalization,
public readonly Vat $vat,
public readonly bool $enabled,
public readonly ?string $receiptEmail = null,
public readonly bool $sendPaymentLink = false,
) {}
/**
* @param array<string, mixed> $config значения формы {@see \paygw_moneta\Gateway}
*/
public static function fromArray(array $config, bool $enabled = true): self
{
return new self(
trim((string) ($config['accountnumber'] ?? '')),
(string) ($config['integritycode'] ?? ''),
PaymentServer::fromSetting($config['paymentserver'] ?? null),
!empty($config['testmode']),
!empty($config['fiscalization']),
Vat::tryFrom((string) ($config['vat'] ?? '')) ?? Vat::None,
$enabled,
Client::normalizeEmail((string) ($config['receiptemail'] ?? '')),
!empty($config['sendpaymentlink']),
);
}
/**
* Можно ли создавать новые заказы: реквизиты и «Email для чеков», без
* которого чек покупателя без контактов не собрать. Колбэки проверяют
* только {@see isComplete()} — уже оплаченный заказ должен дойти до
* зачисления, даже если настройку потом очистили.
*/
public function acceptsNewOrders(): bool
{
return $this->isComplete() && $this->enabled && $this->receiptEmail !== null;
}
public function isComplete(): bool
{
return $this->accountNumber !== '' && $this->integrityCode !== '';
}
public function signature(): Signature
{
return new Signature($this->integrityCode);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local;
use core_payment\helper;
/**
* Загрузка {@see GatewayConfig} из платёжных аккаунтов Moodle
* (`payment_gateways`). Сам `GatewayConfig` — только значения, без обращения
* к БД; здесь — всё, что его читает.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class GatewayConfigRepository
{
/**
* Настройки для оплачиваемого объекта — тот же путь, что у ядра при показе модального окна.
*/
public function forPayable(string $component, string $paymentArea, int $itemId): GatewayConfig
{
return GatewayConfig::fromArray(
helper::get_gateway_configuration($component, $paymentArea, $itemId, GatewayConfig::GATEWAY),
);
}
/**
* Настройки аккаунта заказа — для колбэков и ссылки на оплату, где
* оплачиваемый объект уже известен по заказу.
*/
public function forAccount(int $accountId): ?GatewayConfig
{
global $DB;
$record = $DB->get_record('payment_gateways', ['accountid' => $accountId, 'gateway' => GatewayConfig::GATEWAY]);
if ($record === false) {
return null;
}
return self::fromRecord($record);
}
/**
* Заполненные настройки с данным номером счёта MONETA.RU — чтобы проверить
* подпись уведомления по заказу, которого нет в БД.
*/
public function findByAccountNumber(string $accountNumber): ?GatewayConfig
{
global $DB;
foreach ($DB->get_records('payment_gateways', ['gateway' => GatewayConfig::GATEWAY]) as $record) {
$config = self::fromRecord($record);
if ($config->isComplete() && $config->accountNumber === $accountNumber) {
return $config;
}
}
return null;
}
private static function fromRecord(\stdClass $record): GatewayConfig
{
$config = json_decode((string) $record->config, true);
return GatewayConfig::fromArray(is_array($config) ? $config : [], (bool) $record->enabled);
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local;
/**
* Единая точка журнала модуля. Только события без секретов: ни кода проверки,
* ни `MNT_SIGNATURE`, ни набора параметров запроса — классы исключений, коды
* отказов и номера заказов/операций.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
interface Logger
{
public function error(string $message): void;
}
+117
View File
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local;
use InvalidArgumentException;
/**
* Денежная сумма в формате MONETA.Assistant: десятичная строка ровно с двумя
* знаками после точки (`120.25`, `100.00`), без знака, без разделителей тысяч.
*
* Внутри — только строка и целые копейки; `float` появляется лишь на границе
* с хостом ({@see self::fromFloat()}), где Moodle отдаёт стоимость числом.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class Money
{
/** Верхняя граница суммы по JSON-схеме Check URL (`number_amount.maximum`). */
public const MAX = '10000000000000.00';
/** Целая часть — до 14 знаков без ведущих нулей, дробная — ровно два. */
private const PATTERN = '/\A(?:0|[1-9][0-9]{0,13})\.[0-9]{2}\z/';
private function __construct(private string $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() > self::fromMax()->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));
}
/**
* Граница с хостом: Moodle считает стоимость `float` (`helper::get_rounded_cost`).
* Округление до копеек делается здесь один раз, дальше `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->toMinorUnits() > 0;
}
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;
}
private static function fromMax(): self
{
return new self(self::MAX);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local;
use core_payment\helper;
use moodle_url;
use paygw_moneta\local\order\CurrentPrice;
use paygw_moneta\local\order\Order;
use paygw_moneta\local\protocol\AssistantProtocol;
use paygw_moneta\local\protocol\AssistantTransactionId;
use paygw_moneta\local\protocol\PaymentRequest;
/**
* Контекст шаблона `paygw_moneta/payment_form` — подписанная форма перехода на
* MONETA.Assistant для заказа. Общий для `pay.php` (новый заказ из модального
* окна) и `checkout.php` (ссылка на оплату из письма).
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class PaymentFormPage
{
private const RETURN_PATH = '/payment/gateway/moneta/return.php';
private function __construct() {}
/**
* @return array{action: string, cost: string, description: string, iscourse: bool, logourl: string, fields: list<array{name: string, value: string}>}
*/
public static function context(Order $order, GatewayConfig $config): array
{
global $CFG, $OUTPUT;
$request = new PaymentRequest(
accountId: $config->accountNumber,
transactionId: AssistantTransactionId::build($order->merchantOrderId),
amount: $order->amount,
subscriberId: $order->subscriberId(),
testMode: $order->snapshot->testMode,
signature: $config->signature(),
description: $order->snapshot->description,
successUrl: (new moodle_url(self::RETURN_PATH, ['outcome' => 'success']))->out(false),
failUrl: (new moodle_url(self::RETURN_PATH, ['outcome' => 'fail']))->out(false),
returnUrl: (new moodle_url(self::RETURN_PATH, ['outcome' => 'cancel']))->out(false),
server: $config->paymentServer,
locale: AssistantProtocol::normalizeLocale(current_language()),
cms: CmsInfo::getCmsModuleVersion(),
);
$fields = [];
foreach ($request->getFields() as $name => $value) {
$fields[] = ['name' => $name, 'value' => $value];
}
return [
'action' => empty($CFG->paygw_moneta_assistant_url)
? $request->getActionUrl()
: (string) $CFG->paygw_moneta_assistant_url,
// Сумма для покупателя — как в остальном Moodle: «120,25 ₽».
'cost' => helper::get_cost_as_string((float) $order->amount->toDecimal(), $order->currency),
'description' => $order->snapshot->description,
'iscourse' => $order->component === 'enrol_fee',
'logourl' => $OUTPUT->image_url('img', 'paygw_moneta')->out(false),
'fields' => $fields,
];
}
/**
* Можно ли продолжить оплату открытого заказа по ссылке из письма: реквизиты,
* тестовый режим и цена те же, что при создании, — иначе Pay URL всё равно
* отклонит оплату (`PayHandler`).
*/
public static function canResume(Order $order, ?GatewayConfig $config, CurrentPrice $currentPrice): bool
{
return $order->status->isOpen()
&& $config !== null
&& $config->acceptsNewOrders()
&& $config->accountNumber === $order->snapshot->accountNumber
&& $config->testMode === $order->snapshot->testMode
&& $currentPrice->matches($order);
}
/**
* Адрес страницы продолжения оплаты — его получает покупатель в письме.
*/
public static function resumeUrl(Order $order): moodle_url
{
return new moodle_url('/payment/gateway/moneta/checkout.php', ['order' => $order->merchantOrderId]);
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local;
use html_writer;
use moodle_url;
use paygw_moneta\local\order\TransactionRepository;
use paygw_moneta\local\order\TransactionStatus;
use stdClass;
use table_sql;
/**
* Таблица заказов для администратора (Администрирование → Платежи → Заказы Монета).
* Показывает статусы и коды последних отказов — первое, о чём спрашивает мерчант.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class ReportTable extends table_sql
{
public function __construct(string $uniqueId, moodle_url $baseUrl)
{
parent::__construct($uniqueId);
$this->define_columns(
[
'timecreated',
'user',
'description',
'amount',
'status',
'providertransactionid',
'lasterror',
],
);
$this->define_headers([
get_string('report:time', 'paygw_moneta'),
get_string('report:user', 'paygw_moneta'),
get_string('report:description', 'paygw_moneta'),
get_string('report:amount', 'paygw_moneta'),
get_string('report:status', 'paygw_moneta'),
get_string('report:operation', 'paygw_moneta'),
get_string('report:lasterror', 'paygw_moneta'),
]);
$this->define_baseurl($baseUrl);
$this->sortable(true, 'timecreated', SORT_DESC);
$this->no_sorting('description');
$this->no_sorting('lasterror');
$this->collapsible(false);
$this->pageable(true);
$userFields = \core_user\fields::for_name()->get_sql('u')->selects;
$this->set_sql(
't.*' . $userFields,
'{' . TransactionRepository::TABLE . '} t JOIN {user} u ON u.id = t.userid',
'1 = 1',
);
}
public function col_timecreated(stdClass $row): string
{
return userdate((int) $row->timecreated, get_string('strftimedatetimeshort', 'langconfig'));
}
public function col_user(stdClass $row): string
{
return html_writer::link(new moodle_url('/user/profile.php', ['id' => $row->userid]), fullname($row));
}
public function col_description(stdClass $row): string
{
$snapshot = json_decode((string) $row->snapshot, true);
return s((string) ($snapshot['description'] ?? ''));
}
public function col_amount(stdClass $row): string
{
return Money::fromFloat((float) $row->amount)->toDecimal() . ' ' . s(CurrencySymbol::of((string) $row->currency));
}
public function col_status(stdClass $row): string
{
$status = TransactionStatus::tryFrom((string) $row->status);
$label = $status === null ? s((string) $row->status) : get_string('status:' . $status->value, 'paygw_moneta');
$class = match ($status) {
TransactionStatus::Paid => 'badge bg-success text-white',
TransactionStatus::Failed => 'badge bg-danger text-white',
TransactionStatus::Canceled => 'badge bg-secondary text-white',
default => 'badge bg-info text-white',
};
return html_writer::span($label, $class);
}
public function col_providertransactionid(stdClass $row): string
{
return s((string) ($row->providertransactionid ?? ''));
}
public function col_lasterror(stdClass $row): string
{
return s((string) ($row->lasterror ?? ''));
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\callback;
use core\lock\lock_config;
use core_payment\helper;
use paygw_moneta\local\BuyerNotifier;
use paygw_moneta\local\CmsInfo;
use paygw_moneta\local\ErrorLogLogger;
use paygw_moneta\local\GatewayConfig;
use paygw_moneta\local\GatewayConfigRepository;
use paygw_moneta\local\Logger;
use paygw_moneta\local\Money;
use paygw_moneta\local\order\CurrentPrice;
use paygw_moneta\local\order\Order;
use paygw_moneta\local\order\RejectionReason;
use paygw_moneta\local\order\TransactionRepository;
use paygw_moneta\local\protocol\AssistantTransactionId;
use paygw_moneta\local\protocol\CallbackNotification;
use paygw_moneta\local\protocol\CallbackResponse;
use paygw_moneta\local\protocol\CheckResponse;
use paygw_moneta\local\protocol\ResultCode;
/**
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class CallbackHandler
{
private const LOCK_TIMEOUT = 10;
private readonly NotificationHandler $check;
private readonly NotificationHandler $pay;
/**
* @param \Closure(string, string, int, int, int): bool|null $deliverOrder
*/
public function __construct(
private readonly TransactionRepository $repository = new TransactionRepository(),
?\Closure $deliverOrder = null,
BuyerNotifier $notifier = new BuyerNotifier(),
private readonly Logger $logger = new ErrorLogLogger(),
private readonly GatewayConfigRepository $configs = new GatewayConfigRepository(),
CurrentPrice $currentPrice = new CurrentPrice(),
) {
$this->check = new CheckHandler($repository);
$this->pay = new PayHandler(
repository: $repository,
deliverOrder: $deliverOrder ?? helper::deliver_order(...),
notifier: $notifier,
currentPrice: $currentPrice,
logger: $logger,
);
}
/**
* @param array<string, mixed> $fields
*/
public function handle(array $fields): CallbackResponse
{
try {
$notification = new CallbackNotification($fields);
} catch (\InvalidArgumentException) {
return CallbackResponse::fail();
}
$merchantOrderId = AssistantTransactionId::parse($notification->getTransactionId());
if ($merchantOrderId === null) {
return CallbackResponse::fail();
}
$order = $this->repository->findByMerchantOrderId($merchantOrderId);
if ($order === null) {
return $this->unknownOrder($notification);
}
$config = $this->configs->forAccount($order->accountId);
if ($config === null || !$config->isComplete()) {
return CallbackResponse::fail();
}
if (
$notification->getAccountId() !== $config->accountNumber
|| $notification->getAccountId() !== $order->snapshot->accountNumber
|| !$notification->isSignedBy($config->signature())
) {
$this->logger->error('Уведомление отклонено: подпись или номер счёта не совпадают.');
return CallbackResponse::fail();
}
$lock = lock_config::get_lock_factory('paygw_moneta')->get_lock($order->merchantOrderId, self::LOCK_TIMEOUT);
if (!$lock) {
return CallbackResponse::fail();
}
try {
$order = $this->repository->findById($order->id) ?? $order;
return $this->handleSigned($notification, $order, $config);
} finally {
$lock->release();
}
}
private function handleSigned(CallbackNotification $notification, Order $order, GatewayConfig $config): CallbackResponse
{
$error = $this->validate($notification, $order);
if ($error !== null) {
$this->repository->noteError($order, $error);
return CallbackResponse::fail();
}
$handler = $notification->isCheck() ? $this->check : $this->pay;
return $handler->handle($notification, $order, $config);
}
private function validate(CallbackNotification $notification, Order $order): ?RejectionReason
{
if ($notification->getCurrency() !== $order->currency) {
return RejectionReason::Currency;
}
if ($notification->isTestMode() !== $order->snapshot->testMode) {
return RejectionReason::TestMode;
}
if ($notification->getSubscriberId() !== '' && $notification->getSubscriberId() !== $order->subscriberId()) {
return RejectionReason::Subscriber;
}
$amount = $notification->getAmount();
if ($amount !== null && !$amount->equals($order->amount)) {
return RejectionReason::Amount;
}
return null;
}
private function unknownOrder(CallbackNotification $notification): CallbackResponse
{
$config = $this->configs->findByAccountNumber($notification->getAccountId());
if ($config === null || !$notification->isSignedBy($config->signature())) {
return CallbackResponse::fail();
}
if (!$notification->isCheck()) {
$this->logger->error(
sprintf(
'Оплачено уведомление по неизвестному заказу %s (операция %s).',
$notification->getTransactionId(),
$notification->getOperationId(),
),
);
return CallbackResponse::fail();
}
$response = new CheckResponse(
accountId: $config->accountNumber,
transactionId: $notification->getTransactionId(),
amount: $notification->getAmount() ?? Money::fromMinorUnits(0),
resultCode: ResultCode::Rejected,
signature: $config->signature(),
cms: CmsInfo::getCmsModuleVersion(),
);
return $response->toXml();
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\callback;
use paygw_moneta\local\CmsInfo;
use paygw_moneta\local\GatewayConfig;
use paygw_moneta\local\order\Order;
use paygw_moneta\local\order\RejectionReason;
use paygw_moneta\local\order\TransactionRepository;
use paygw_moneta\local\order\TransactionStatus;
use paygw_moneta\local\protocol\CallbackNotification;
use paygw_moneta\local\protocol\CallbackResponse;
use paygw_moneta\local\protocol\CheckResponse;
use paygw_moneta\local\protocol\ResultCode;
/**
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class CheckHandler implements NotificationHandler
{
public function __construct(
private readonly TransactionRepository $repository,
) {}
public function handle(CallbackNotification $notification, Order $order, GatewayConfig $config): CallbackResponse
{
if ($order->status->isOpen() && !$config->enabled) {
$order = $this->repository->markCanceled($order, RejectionReason::GatewayDisabled);
}
$code = match ($order->status) {
TransactionStatus::Paid => ResultCode::Paid,
// Failed — оплата у сервиса прошла, не удалась доставка: новую оплату
// по этому заказу не начинаем, повтор Pay URL её доставит.
TransactionStatus::Canceled, TransactionStatus::Failed => ResultCode::Rejected,
TransactionStatus::New, TransactionStatus::Pending => $notification->getAmount() === null
? ResultCode::WithAmount
: ResultCode::AwaitingPayment,
};
if ($order->status === TransactionStatus::New) {
$order = $this->repository->markPending($order);
}
$response = new CheckResponse(
accountId: $config->accountNumber,
transactionId: $notification->getTransactionId(),
amount: $order->amount,
resultCode: $code,
signature: $config->signature(),
cms: CmsInfo::getCmsModuleVersion(),
);
if (!$order->snapshot->fiscalization) {
return $response->toXml();
}
try {
return $response->toJson($order->snapshot->receipt);
} catch (\InvalidArgumentException) {
$this->repository->noteError($order, RejectionReason::Receipt);
return CallbackResponse::fail();
}
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\callback;
use paygw_moneta\local\GatewayConfig;
use paygw_moneta\local\order\Order;
use paygw_moneta\local\protocol\CallbackNotification;
use paygw_moneta\local\protocol\CallbackResponse;
/**
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
interface NotificationHandler
{
public function handle(CallbackNotification $notification, Order $order, GatewayConfig $config): CallbackResponse;
}
+153
View File
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\callback;
use core_payment\helper;
use paygw_moneta\local\BuyerNotifier;
use paygw_moneta\local\CmsInfo;
use paygw_moneta\local\GatewayConfig;
use paygw_moneta\local\Logger;
use paygw_moneta\local\order\CurrentPrice;
use paygw_moneta\local\order\Order;
use paygw_moneta\local\order\RejectionReason;
use paygw_moneta\local\order\TransactionRepository;
use paygw_moneta\local\order\TransactionStatus;
use paygw_moneta\local\protocol\CallbackNotification;
use paygw_moneta\local\protocol\CallbackResponse;
use paygw_moneta\local\protocol\PayResponse;
use paygw_moneta\local\protocol\ResultCode;
use paygw_moneta\local\receipt\Receipt;
/**
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class PayHandler implements NotificationHandler
{
/**
* @param \Closure(string, string, int, int, int): bool $deliverOrder
*/
public function __construct(
private readonly TransactionRepository $repository,
private readonly \Closure $deliverOrder,
private readonly BuyerNotifier $notifier,
private readonly CurrentPrice $currentPrice,
private readonly Logger $logger,
) {}
public function handle(CallbackNotification $notification, Order $order, GatewayConfig $config): CallbackResponse
{
if ($order->status === TransactionStatus::Paid) {
// Повторная доставка того же уведомления — тот же ответ, без второго зачисления.
if ($order->providerTransactionId === $notification->getOperationId()) {
return $this->paid($notification, $order, $config, ResultCode::Paid, $order->snapshot->receipt);
}
// Вторая операция по оплаченному заказу: деньги списаны дважды — нужен человек.
$this->repository->noteError($order, RejectionReason::DuplicateOperation);
return CallbackResponse::fail();
}
if ($order->status === TransactionStatus::Canceled) {
// Оплата отменённого заказа: повторы бессмысленны, но след должен остаться.
return $this->paid($notification, $order, $config, ResultCode::Rejected, null);
}
// Снимок защищает легитимный заказ от смены настроек, но не должен позволять
// дожать сохранённую форму после того, как администратор выключил тестовый
// режим или поднял цену: сверяемся ещё и с текущим состоянием.
if ($notification->isTestMode() !== $config->testMode) {
$this->repository->noteError($order, RejectionReason::TestMode);
return CallbackResponse::fail();
}
if (!$config->enabled) {
$this->repository->noteError($order, RejectionReason::GatewayDisabled);
return CallbackResponse::fail();
}
if (!$this->currentPrice->matches($order)) {
$this->repository->noteError($order, RejectionReason::AmountChanged);
return CallbackResponse::fail();
}
$paid = $this->deliver($notification, $order);
if ($paid === null) {
return CallbackResponse::fail();
}
$this->notifier->notifyPaid($paid);
return $this->paid($notification, $paid, $config, ResultCode::Paid, $paid->snapshot->receipt);
}
/**
* Платёж ядра, доставка заказа и статус — одной транзакцией БД. При сбое
* заказ помечается `Failed`, чтобы повтор уведомления попробовал ещё раз.
*/
private function deliver(CallbackNotification $notification, Order $order): ?Order
{
global $DB;
$transaction = $DB->start_delegated_transaction();
try {
$paymentId = helper::save_payment(
$order->accountId,
$order->component,
$order->paymentArea,
$order->itemId,
$order->userId,
(float) $order->amount->toDecimal(),
$order->currency,
GatewayConfig::GATEWAY,
);
if (!($this->deliverOrder)(
$order->component,
$order->paymentArea,
$order->itemId,
$paymentId,
$order->userId,
)) {
throw new \RuntimeException('Доставка заказа вернула false.');
}
$paid = $this->repository->markPaid($order, $notification->getOperationId(), $paymentId);
$transaction->allow_commit();
return $paid;
} catch (\Throwable $exception) {
try {
// rollback() перебрасывает исключение — гасим его здесь, причина уходит в lasterror.
$transaction->rollback($exception);
} catch (\Throwable) {
// Откат выполнен, исключение уже обработано.
}
$this->repository->markFailed($order, RejectionReason::Delivery);
$this->logger->error('Доставка заказа не удалась: ' . get_class($exception));
}
return null;
}
private function paid(
CallbackNotification $notification,
Order $order,
GatewayConfig $config,
ResultCode $code,
?Receipt $receipt,
): CallbackResponse {
$payResponse = new PayResponse(
accountId: $config->accountNumber,
transactionId: $notification->getTransactionId(),
amount: $order->amount,
resultCode: $code,
signature: $config->signature(),
cms: CmsInfo::getCmsModuleVersion(),
receipt: $receipt,
);
return $payResponse->toXml();
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\order;
use core_payment\helper;
use paygw_moneta\local\ErrorLogLogger;
use paygw_moneta\local\GatewayConfig;
use paygw_moneta\local\Logger;
use paygw_moneta\local\Money;
/**
* Текущая цена оплачиваемого объекта (с наценкой шлюза) против суммы заказа:
* Pay URL и ссылка на оплату не должны давать дожать заказ, если цена
* изменилась после его создания.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class CurrentPrice
{
public function __construct(
private readonly Logger $logger = new ErrorLogLogger(),
) {}
/**
* Сумма заказа равна текущей стоимости объекта. Объект мог исчезнуть —
* тогда и оплачивать нечего.
*/
public function matches(Order $order): bool
{
try {
$payable = helper::get_payable($order->component, $order->paymentArea, $order->itemId);
$current = Money::fromFloat(
helper::get_rounded_cost(
$payable->get_amount(),
$payable->get_currency(),
helper::get_gateway_surcharge(GatewayConfig::GATEWAY),
),
);
} catch (\Throwable $exception) {
$this->logger->error('Оплачиваемый объект недоступен: ' . get_class($exception));
return false;
}
return $payable->get_currency() === $order->currency && $current->equals($order->amount);
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\order;
use paygw_moneta\local\Money;
use stdClass;
/**
* Локальный заказ — строка `paygw_moneta_transactions` в типизированном виде.
* Создаётся до ухода покупателя на платёжную форму и живёт до Pay URL;
* у ядра Moodle записи о платеже до его подтверждения нет.
*
* Объект неизменяемый; переходы статусов делает {@see TransactionRepository}.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class Order
{
public function __construct(
public readonly int $id,
public readonly int $accountId,
public readonly int $userId,
public readonly string $component,
public readonly string $paymentArea,
public readonly int $itemId,
public readonly string $merchantOrderId,
public readonly ?string $providerTransactionId,
public readonly ?int $paymentId,
public readonly Money $amount,
public readonly string $currency,
public readonly TransactionStatus $status,
public readonly OrderSnapshot $snapshot,
public readonly ?RejectionReason $lastError,
public readonly int $timeCreated,
public readonly int $timeModified,
public readonly ?int $timeCompleted,
) {}
public static function fromRecord(stdClass $record): self
{
return new self(
(int) $record->id,
(int) $record->accountid,
(int) $record->userid,
(string) $record->component,
(string) $record->paymentarea,
(int) $record->itemid,
(string) $record->merchantorderid,
$record->providertransactionid === null ? null : (string) $record->providertransactionid,
$record->paymentid === null ? null : (int) $record->paymentid,
// Единственное место, где сумма приходит из БД числом (NUMBER(20,5)).
Money::fromFloat((float) $record->amount),
(string) $record->currency,
TransactionStatus::from((string) $record->status),
OrderSnapshot::fromJson((string) $record->snapshot),
$record->lasterror === null ? null : RejectionReason::tryFrom((string) $record->lasterror),
(int) $record->timecreated,
(int) $record->timemodified,
$record->timecompleted === null ? null : (int) $record->timecompleted,
);
}
/**
* `MNT_SUBSCRIBER_ID`, с которым заказ ушёл на платёжную форму.
*/
public function subscriberId(): string
{
return $this->snapshot->subscriberId;
}
public function isPaid(): bool
{
return $this->status === TransactionStatus::Paid;
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\order;
use core_payment\helper;
use moodle_exception;
use paygw_moneta\local\GatewayConfig;
use paygw_moneta\local\Money;
use paygw_moneta\local\protocol\AssistantProtocol;
use paygw_moneta\local\receipt\Client;
use paygw_moneta\local\receipt\Receipt;
use paygw_moneta\local\receipt\ReceiptText;
use stdClass;
/**
* Создание заказа для оплачиваемого объекта Moodle: сумма от ядра, снимок
* настроек шлюза, чек с одной позицией, покупатель из профиля.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class OrderFactory
{
public function __construct(
private readonly TransactionRepository $repository,
) {}
/**
* @param stdClass $user покупатель (`$USER`): id, email, phone1, phone2, ФИО
* @param GatewayConfig $config настройки шлюза для этого объекта (`GatewayConfigRepository::forPayable`)
* @param bool|null $created true — заказ создан сейчас, false — переиспользован открытый
*/
public function start(
string $component,
string $paymentArea,
int $itemId,
stdClass $user,
GatewayConfig $config,
?bool &$created = null,
): Order {
if (!in_array(
GatewayConfig::GATEWAY,
helper::get_available_gateways($component, $paymentArea, $itemId),
true,
)) {
throw new moodle_exception('gatewaynotfound', 'payment');
}
if (!$config->acceptsNewOrders()) {
throw new moodle_exception('gatewaynotfound', 'payment');
}
// Оплата у сервиса прошла, доставка сорвалась: новый заказ создавать нельзя,
// иначе покупатель заплатит дважды, а повтор уведомления по старому доставит его.
if ($this->repository->findFailedForItem((int) $user->id, $component, $paymentArea, $itemId) !== null) {
throw new moodle_exception('error:orderinprogress', 'paygw_moneta');
}
$payable = helper::get_payable($component, $paymentArea, $itemId);
$currency = $payable->get_currency();
if ($currency !== AssistantProtocol::CURRENCY) {
throw new moodle_exception('error:unsupportedcurrency', 'paygw_moneta');
}
$surcharge = helper::get_gateway_surcharge(GatewayConfig::GATEWAY);
$amount = Money::fromFloat(helper::get_rounded_cost($payable->get_amount(), $currency, $surcharge));
if (!$amount->isPositive()) {
throw new moodle_exception('error:invalidamount', 'paygw_moneta');
}
$itemName = self::itemName($component, $paymentArea, $itemId);
$client = Client::fromContacts(
email: (string) ($user->email ?? ''),
phone: self::phone($user),
name: self::fullName($user),
fallbackEmail: $config->receiptEmail,
) ?? throw new moodle_exception('gatewaynotfound', 'payment');
$snapshot = new OrderSnapshot(
accountNumber: $config->accountNumber,
testMode: $config->testMode,
fiscalization: $config->fiscalization,
vat: $config->vat,
receipt: Receipt::forCourse($itemName, $amount, $client, $config->vat),
description: $itemName,
subscriberId: self::subscriberId($user),
);
return $this->repository->obtain(
accountId: $payable->get_account_id(),
userId: (int) $user->id,
component: $component,
paymentArea: $paymentArea,
itemId: $itemId,
amount: $amount,
currency: $currency,
snapshot: $snapshot,
created: $created,
);
}
private static function itemName(string $component, string $paymentArea, int $itemId): string
{
global $DB;
if ($component === 'enrol_fee' && $paymentArea === 'fee') {
$courseId = $DB->get_field('enrol', 'courseid', ['id' => $itemId, 'enrol' => 'fee']);
$name = $courseId ? $DB->get_field('course', 'fullname', ['id' => $courseId]) : false;
if (is_string($name) && ReceiptText::sanitize($name) !== '') {
return $name;
}
}
return get_string('genericitem', 'paygw_moneta');
}
public static function subscriberId(stdClass $user): string
{
$email = trim((string) ($user->email ?? ''));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) !== false) {
return $email;
}
return Client::normalizePhone(self::phone($user)) ?? (string) $user->id;
}
/**
* Первый заполненный телефон профиля: рабочий, затем мобильный.
*/
private static function phone(stdClass $user): string
{
$phone = trim((string) ($user->phone1 ?? ''));
return $phone !== '' ? $phone : trim((string) ($user->phone2 ?? ''));
}
/**
* ФИО для чека в порядке «Фамилия Имя Отчество» — как `name` объекта
* `client` в `kassaspecification.pdf`, независимо от настройки отображения имён.
*/
private static function fullName(stdClass $user): string
{
$parts = array_filter(
array_map(
static fn(string $field): string => trim((string) ($user->$field ?? '')),
['lastname', 'firstname', 'middlename'],
),
static fn(string $part): bool => $part !== '',
);
return implode(' ', $parts);
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\order;
use InvalidArgumentException;
use paygw_moneta\local\receipt\Receipt;
use paygw_moneta\local\receipt\Vat;
/**
* Снимок настроек счёта и данных чека на момент создания заказа.
*
* Колбэки сверяют уведомление с тем, что реально было запрошено (сумма, режим,
* данные чека, `MNT_SUBSCRIBER_ID`), а не с текущей ценой курса и профилем. Номер счёта в снимке —
* дополнительная защита: он обязан совпадать и с текущей настройкой, потому что
* ключ подписи всегда берётся из неё; заказы, созданные до смены счёта,
* оплатить нельзя. Секрет (код проверки целостности) в снимок не попадает.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class OrderSnapshot
{
public function __construct(
public readonly string $accountNumber,
public readonly bool $testMode,
public readonly bool $fiscalization,
public readonly Vat $vat,
public readonly Receipt $receipt,
public readonly string $description,
public readonly string $subscriberId,
) {
if ($accountNumber === '') {
throw new InvalidArgumentException('В снимке заказа нет номера счёта.');
}
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'accountnumber' => $this->accountNumber,
'testmode' => $this->testMode,
'fiscalization' => $this->fiscalization,
'vat' => $this->vat->value,
'receipt' => $this->receipt->toArray(),
'description' => $this->description,
'subscriberid' => $this->subscriberId,
];
}
public function toJson(): string
{
return json_encode($this->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
accountNumber: (string) ($data['accountnumber'] ?? ''),
testMode: (bool) ($data['testmode'] ?? false),
fiscalization: (bool) ($data['fiscalization'] ?? false),
vat: Vat::from((string) ($data['vat'] ?? '')),
receipt: Receipt::fromArray(is_array($data['receipt'] ?? null) ? $data['receipt'] : []),
description: (string) ($data['description'] ?? ''),
subscriberId: (string) ($data['subscriberid'] ?? ''),
);
}
public static function fromJson(string $json): self
{
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($data)) {
throw new InvalidArgumentException('Снимок заказа повреждён.');
}
return self::fromArray($data);
}
/**
* Совпадение реквизитов и чека; описание не сравнивается — оно не влияет
* на оплату и не должно плодить заказы.
*/
public function equals(self $other): bool
{
$mine = $this->toArray();
$theirs = $other->toArray();
unset($mine['description'], $theirs['description']);
return $mine === $theirs;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\order;
/**
* Причина отказа по подписанному уведомлению — пишется в `lasterror` заказа и
* показывается администратору в отчёте.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
enum RejectionReason: string
{
/** Сумма уведомления не совпала с суммой заказа. */
case Amount = 'AMOUNT';
/** Цена оплачиваемого объекта изменилась после создания заказа. */
case AmountChanged = 'AMOUNT_CHANGED';
case Currency = 'CURRENCY';
/** Тестовый режим уведомления не совпал со снимком или с текущими настройками. */
case TestMode = 'TEST_MODE';
case Subscriber = 'SUBSCRIBER';
/** Вторая операция по уже оплаченному заказу: деньги списаны дважды. */
case DuplicateOperation = 'DUPLICATE_OPERATION';
/** Оплата подтверждена, но зачисление не удалось. */
case Delivery = 'DELIVERY';
/** Чек из снимка не собрался в JSON-ответ Check URL. */
case Receipt = 'RECEIPT';
case GatewayDisabled = 'GATEWAY_DISABLED';
}
@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\order;
use InvalidArgumentException;
use paygw_moneta\local\Money;
use paygw_moneta\local\protocol\AssistantProtocol;
use paygw_moneta\local\protocol\AssistantTransactionId;
/**
* Хранилище заказов. Единственное место, где меняется `status`: хендлеры
* зовут именованные переходы, а не пишут поля напрямую.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class TransactionRepository
{
public const TABLE = 'paygw_moneta_transactions';
/**
* Свой номер заказа — UUID в нижнем регистре ({@see self::create()}); в
* `MNT_TRANSACTION_ID` он уходит с меткой времени ({@see AssistantTransactionId}).
*/
public static function isMerchantOrderId(string $value): bool
{
return preg_match('/\A' . AssistantTransactionId::ORDER_ID . '\z/', $value) === 1;
}
/**
* Находит открытый заказ покупателя по тому же товару с той же суммой и
* настройками либо создаёт новый. Повторное нажатие «Оплатить» не должно
* плодить заказы: у сервиса на каждый `MNT_TRANSACTION_ID` своя операция.
*
* @param bool|null $created true — заказ создан сейчас, false — переиспользован
*/
public function obtain(
int $accountId,
int $userId,
string $component,
string $paymentArea,
int $itemId,
Money $amount,
string $currency,
OrderSnapshot $snapshot,
?bool &$created = null,
): Order {
$open = $this->findOpenForItem($userId, $component, $paymentArea, $itemId);
$created = false;
if ($open !== null
&& $open->accountId === $accountId
&& $open->amount->equals($amount)
&& $open->snapshot->equals($snapshot)
) {
return $open;
}
$created = true;
return $this->create($accountId, $userId, $component, $paymentArea, $itemId, $amount, $currency, $snapshot);
}
public function create(
int $accountId,
int $userId,
string $component,
string $paymentArea,
int $itemId,
Money $amount,
string $currency,
OrderSnapshot $snapshot,
): Order {
global $DB;
if (strtoupper($currency) !== AssistantProtocol::CURRENCY) {
throw new InvalidArgumentException(get_string('error:unsupportedcurrency', 'paygw_moneta'));
}
if (!$amount->isPositive()) {
throw new InvalidArgumentException(get_string('error:invalidamount', 'paygw_moneta'));
}
$now = time();
$record = (object) [
'paymentid' => null,
'accountid' => $accountId,
'userid' => $userId,
'component' => $component,
'paymentarea' => $paymentArea,
'itemid' => $itemId,
'merchantorderid' => \core\uuid::generate(),
'providertransactionid' => null,
'amount' => $amount->toDecimal(),
'currency' => AssistantProtocol::CURRENCY,
'status' => TransactionStatus::New->value,
'lasterror' => null,
'snapshot' => $snapshot->toJson(),
'timecreated' => $now,
'timemodified' => $now,
'timecompleted' => null,
];
$record->id = $DB->insert_record(self::TABLE, $record);
return Order::fromRecord($record);
}
public function findByMerchantOrderId(string $merchantOrderId): ?Order
{
global $DB;
$record = $DB->get_record(self::TABLE, ['merchantorderid' => $merchantOrderId]);
return $record === false ? null : Order::fromRecord($record);
}
public function findById(int $id): ?Order
{
global $DB;
$record = $DB->get_record(self::TABLE, ['id' => $id]);
return $record === false ? null : Order::fromRecord($record);
}
public function findOpenForItem(int $userId, string $component, string $paymentArea, int $itemId): ?Order
{
global $DB;
[$statusSql, $params] = $DB->get_in_or_equal(
[TransactionStatus::New->value, TransactionStatus::Pending->value],
SQL_PARAMS_NAMED,
'status',
);
$records = $DB->get_records_select(
self::TABLE,
"userid = :userid AND component = :component AND paymentarea = :paymentarea AND itemid = :itemid AND status {$statusSql}",
$params + [
'userid' => $userId,
'component' => $component,
'paymentarea' => $paymentArea,
'itemid' => $itemId,
],
'timecreated DESC, id DESC',
'*',
0,
1,
);
$record = reset($records);
return $record === false ? null : Order::fromRecord($record);
}
/**
* Заказ покупателя по товару, у которого оплата прошла, а доставка сорвалась:
* новую оплату начинать нельзя — повтор уведомления доставит этот.
*/
public function findFailedForItem(int $userId, string $component, string $paymentArea, int $itemId): ?Order
{
global $DB;
$records = $DB->get_records(
self::TABLE,
[
'userid' => $userId,
'component' => $component,
'paymentarea' => $paymentArea,
'itemid' => $itemId,
'status' => TransactionStatus::Failed->value,
],
'timecreated DESC, id DESC',
'*',
0,
1,
);
$record = reset($records);
return $record === false ? null : Order::fromRecord($record);
}
/**
* Сервис начал оплату: пришёл проверочный запрос.
*/
public function markPending(Order $order): Order
{
if ($order->status !== TransactionStatus::New) {
return $order;
}
return $this->update($order, ['status' => TransactionStatus::Pending->value]);
}
/**
* Оплата подтверждена и доставлена: фиксируем операцию сервиса и платёж ядра.
*/
public function markPaid(Order $order, string $providerTransactionId, int $paymentId): Order
{
if (!$order->status->isPayable()) {
throw new InvalidArgumentException('Оплатить можно только неоплаченный и не отменённый заказ.');
}
return $this->update($order, [
'status' => TransactionStatus::Paid->value,
'providertransactionid' => $providerTransactionId,
'paymentid' => $paymentId,
'lasterror' => null,
'timecompleted' => time(),
]);
}
public function markCanceled(Order $order, RejectionReason $reason): Order
{
if (!$order->status->isOpen()) {
throw new InvalidArgumentException('Отменить можно только открытый заказ.');
}
return $this->update($order, ['status' => TransactionStatus::Canceled->value, 'lasterror' => $reason->value]);
}
/**
* Зачисление сорвалось после подтверждённой оплаты — заказ требует внимания администратора.
*/
public function markFailed(Order $order, RejectionReason $reason): Order
{
if ($order->status === TransactionStatus::Paid) {
throw new InvalidArgumentException('Оплаченный заказ нельзя пометить ошибочным.');
}
return $this->update($order, ['status' => TransactionStatus::Failed->value, 'lasterror' => $reason->value]);
}
/**
* Код причины последнего отказа без смены статуса (для отчёта администратора).
*/
public function noteError(Order $order, RejectionReason $reason): Order
{
return $this->update($order, ['lasterror' => $reason->value]);
}
/**
* @param array<string, mixed> $fields
*/
private function update(Order $order, array $fields): Order
{
global $DB;
$record = (object) ($fields + ['id' => $order->id, 'timemodified' => time()]);
$DB->update_record(self::TABLE, $record);
$fresh = $DB->get_record(self::TABLE, ['id' => $order->id], '*', MUST_EXIST);
return Order::fromRecord($fresh);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\order;
/**
* Статус локального заказа.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
enum TransactionStatus: string
{
/** Заказ создан, покупатель отправлен на платёжную форму. */
case New = 'new';
/** Сервис прислал проверочный запрос — оплата идёт. */
case Pending = 'pending';
/** Оплата подтверждена Pay URL, доступ выдан. */
case Paid = 'paid';
/** Заказ отменён на нашей стороне; сервису отвечаем 500. */
case Canceled = 'canceled';
/** Подтверждение оплаты не удалось довести до конца (ошибка зачисления). */
case Failed = 'failed';
/** Заказ ещё не ушёл в оплату: его переиспользует повторное нажатие «Оплатить». */
public function isOpen(): bool
{
return $this === self::New || $this === self::Pending;
}
/** Уведомление об оплате по такому заказу зачисляется (в том числе повтор после сбоя доставки). */
public function isPayable(): bool
{
return $this->isOpen() || $this === self::Failed;
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\protocol;
/**
* Константы протокола MONETA.Assistant, общие для формы и колбэков.
* Адреса платёжной формы — в {@see PaymentServer}.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class AssistantProtocol
{
public const CURRENCY = 'RUB';
public const COMMAND_CHECK = 'CHECK';
public const TEXT_SUCCESS = 'SUCCESS';
public const TEXT_FAIL = 'FAIL';
private function __construct() {}
/**
* `MNT_TEST_MODE` передаётся как «1» или строковый «0» (`cmsspecification.pdf`).
*/
public static function testModeFlag(bool $testMode): string
{
return $testMode ? '1' : '0';
}
/**
* Язык интерфейса Moodle → `moneta.locale` платёжной формы (ru/en).
*/
public static function normalizeLocale(string $language): string
{
return str_starts_with(strtolower($language), 'ru') ? 'ru' : 'en';
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\protocol;
use core_date;
use DateTimeImmutable;
/**
* `MNT_TRANSACTION_ID` — идентификатор заказа, который модуль отдаёт в платёжной
* форме и получает обратно в Check URL, Pay URL и на странице возврата: UUID заказа
* плюс метка времени, `0192b4c1-…_20260924143005MSK`. Метка — только для людей
* (в личном кабинете Moneta и в переписке с поддержкой по ней видно, когда открыта форма),
* поэтому она в часовом поясе сайта, а не в UTC; обратно она не читается, parse()
* берёт только UUID.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class AssistantTransactionId
{
/**
* Номер заказа — UUID в нижнем регистре ({@see \paygw_moneta\local\order\TransactionRepository::create()}).
* Фрагмент шаблона без якорей: его же проверяет
* {@see \paygw_moneta\local\order\TransactionRepository::isMerchantOrderId()}.
*/
public const ORDER_ID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
public const SEPARATOR = '_';
private const PATTERN = '/\A
(?<id>' . self::ORDER_ID . ') # UUID заказа
' . self::SEPARATOR . ' # разделитель
[0-9]{14} # метка времени YmdHis
(?:[A-Z0-9+\-]{1,6})? # пояс: MSK, +03 — не обязателен
\z/x';
private function __construct() {}
public static function build(string $orderId, ?DateTimeImmutable $now = null): string
{
$now ??= new DateTimeImmutable('now', core_date::get_server_timezone_object());
return $orderId . self::SEPARATOR . $now->format('YmdHisT');
}
public static function parse(string $raw): ?string
{
return preg_match(self::PATTERN, $raw, $matches) === 1 ? $matches['id'] : null;
}
}
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\protocol;
use InvalidArgumentException;
use paygw_moneta\local\Money;
/**
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class CallbackNotification
{
private const ACCOUNT_ID_MAX_LENGTH = 20;
private const TRANSACTION_ID_MAX_LENGTH = 255;
private const OPERATION_ID_MAX_LENGTH = 255;
private const SUBSCRIBER_ID_MAX_LENGTH = 255;
private readonly string $command;
private readonly string $accountId;
private readonly string $transactionId;
private readonly string $operationId;
private readonly string $rawAmount;
private readonly ?Money $amount;
private readonly string $currency;
private readonly string $subscriberId;
private readonly string $rawTestMode;
private readonly string $signature;
/**
* @param array<string, mixed> $fields
*/
public function __construct(array $fields)
{
$this->command = self::optional($fields, 'MNT_COMMAND');
if (!in_array($this->command, ['', AssistantProtocol::COMMAND_CHECK], true)) {
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');
$this->amount = $this->rawAmount === '' ? null : Money::fromDecimal($this->rawAmount);
$this->currency = self::required($fields, 'MNT_CURRENCY_CODE', 3);
$this->subscriberId = self::optional($fields, 'MNT_SUBSCRIBER_ID', self::SUBSCRIBER_ID_MAX_LENGTH);
$this->rawTestMode = self::required($fields, 'MNT_TEST_MODE', 1);
if (!in_array($this->rawTestMode, ['0', '1'], true)) {
throw new InvalidArgumentException('Недопустимое значение MNT_TEST_MODE.');
}
$this->signature = self::required($fields, 'MNT_SIGNATURE', 32);
if (($this->amount === null || $this->operationId === '') && !$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;
}
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<string, mixed> $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<string, mixed> $fields
*/
private static function optional(array $fields, string $name, int $maxLength = 255): 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;
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\protocol;
/**
* Тело и тип ответа модуля на Check URL / Pay URL.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class CallbackResponse
{
public const CONTENT_TYPE_XML = 'application/xml; charset=UTF-8';
public const CONTENT_TYPE_JSON = 'application/json; charset=UTF-8';
public const CONTENT_TYPE_TEXT = 'text/plain; charset=UTF-8';
public function __construct(
public readonly string $contentType,
public readonly string $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);
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\protocol;
use InvalidArgumentException;
use paygw_moneta\local\Money;
use paygw_moneta\local\receipt\JsonNumber;
use paygw_moneta\local\receipt\Receipt;
/**
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class CheckResponse
{
public function __construct(
private readonly string $accountId,
private readonly string $transactionId,
private readonly Money $amount,
private readonly ResultCode $resultCode,
private readonly Signature $signature,
private readonly string $cms,
) {}
public function getResultCode(): ResultCode
{
return $this->resultCode;
}
/**
* XML без атрибутов — для мерчанта без кассового сервиса.
*/
public function toXml(): CallbackResponse
{
return CallbackResponse::xml(
MntResponseXml::build(
accountId: $this->accountId,
transactionId: $this->transactionId,
resultCode: $this->resultCode,
amount: $this->amount,
signature: $this->sign(),
cms: $this->cms,
),
);
}
/**
* JSON с чеком — для мерчанта с подключённой кассой.
*/
public function toJson(Receipt $receipt): CallbackResponse
{
if (!$receipt->matchesAmount($this->amount)) {
throw new InvalidArgumentException('Сумма позиций чека не совпадает с суммой заказа.');
}
return CallbackResponse::json(JsonNumber::encode([
'id' => $this->accountId,
'transactionId' => $this->transactionId,
'amount' => JsonNumber::placeholder($this->amount),
'signature' => $this->sign(),
'resultCode' => (string) $this->resultCode->value,
'description' => $this->resultCode->description(),
'cms' => $this->cms,
'receipt' => $receipt->toCheckReceipt(),
]));
}
private function sign(): string
{
return $this->signature->forResponse($this->resultCode, $this->accountId, $this->transactionId);
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\protocol;
use DOMDocument;
use DOMNode;
use paygw_moneta\local\Money;
/**
* Сборка `MNT_RESPONSE` (`MONETA.Assistant.ru.pdf`, гл. 4–5). Общая часть
* ответов Check URL и Pay URL: обязательные элементы, подпись по формуле 3
* и необязательные `MNT_ATTRIBUTES`.
*
* `MNT_CMS` в схеме ответа нет — это решение разработчика (2026-09-12,
* `docs/moneta/_pdf/README.md`): идентификатор CMS передаётся везде, где
* модуль отвечает Moneta; лишние элементы приёму не мешают.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class MntResponseXml
{
private function __construct() {}
/**
* @param array<string, string> $attributes пары KEY → VALUE для `MNT_ATTRIBUTES`
* @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->value,
'MNT_DESCRIPTION' => $resultCode->description(),
'MNT_AMOUNT' => $amount->toDecimal(),
'MNT_SIGNATURE' => $signature,
'MNT_CMS' => $cms,
];
foreach ($elements as $name => $value) {
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', $key);
self::text($document, $attribute, 'VALUE', $value);
}
}
$xml = $document->saveXML();
if ($xml === false) {
throw new \RuntimeException('Не удалось сериализовать MNT_RESPONSE.');
}
return $xml;
}
/**
* Текстовый узел, а не `createElement($name, $value)`: второй не экранирует `&`.
*/
private static function text(DOMDocument $document, DOMNode $parent, string $name, string $value): void
{
$parent->appendChild($document->createElement($name))->appendChild($document->createTextNode($value));
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\protocol;
use paygw_moneta\local\Money;
use paygw_moneta\local\receipt\Receipt;
/**
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class PayResponse
{
public function __construct(
private readonly string $accountId,
private readonly string $transactionId,
private readonly Money $amount,
private readonly ResultCode $resultCode,
private readonly Signature $signature,
private readonly string $cms,
private readonly ?Receipt $receipt = null,
) {}
/**
* @throws \DOMException
* @throws \JsonException
*/
public function toXml(): CallbackResponse
{
$attributes = [];
if ($this->receipt !== null) {
$attributes['INVENTORY'] = $this->receipt->toInventoryAttribute();
$attributes['CLIENT'] = $this->receipt->toClientAttribute();
$duplicates = [
'CUSTOMER' => $this->receipt->toCustomerAttribute(),
'PHONE' => $this->receipt->toPhoneAttribute(),
];
$attributes += array_filter($duplicates, static fn(?string $value): bool => $value !== null);
}
return CallbackResponse::xml(
MntResponseXml::build(
accountId: $this->accountId,
transactionId: $this->transactionId,
resultCode: $this->resultCode,
amount: $this->amount,
signature: $this->signature->forResponse($this->resultCode, $this->accountId, $this->transactionId),
cms: $this->cms,
attributes: $attributes,
),
);
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\protocol;
use InvalidArgumentException;
use paygw_moneta\local\Money;
/**
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class PaymentRequest
{
public const DESCRIPTION_MAX_LENGTH = 500;
/**
* @param string $accountId номер расширенного счёта (`MNT_ID`)
* @param string $transactionId идентификатор заказа в модуле (`MNT_TRANSACTION_ID`)
* @param Money $amount сумма к оплате
* @param string $subscriberId идентификатор покупателя (`MNT_SUBSCRIBER_ID`)
* @param bool $testMode тестовый режим счёта
* @param Signature $signature подпись с кодом проверки целостности счёта
* @param string $description описание заказа для покупателя
* @param string $successUrl возврат покупателя после успешной оплаты
* @param string $failUrl возврат после отказа
* @param string $returnUrl возврат при отмене покупателем
* @param PaymentServer $server боевой сервер или демо-площадка
* @param string $locale язык платёжной формы (`moneta.locale`)
* @param string $cms идентификатор CMS и модуля (`MNT_CMS`)
*/
public function __construct(
private readonly string $accountId,
private readonly string $transactionId,
private readonly Money $amount,
private readonly string $subscriberId,
private readonly bool $testMode,
private readonly Signature $signature,
private readonly string $description,
private readonly string $successUrl,
private readonly string $failUrl,
private readonly string $returnUrl,
private readonly PaymentServer $server,
private readonly string $locale,
private readonly string $cms,
) {
if ($accountId === '' || $transactionId === '') {
throw new InvalidArgumentException('Номер счёта и идентификатор заказа обязательны.');
}
if (!$amount->isPositive()) {
throw new InvalidArgumentException('Сумма платежа должна быть больше нуля.');
}
}
public function getActionUrl(): string
{
return $this->server->url();
}
/**
* @return array<string, string>
*/
public function getFields(): array
{
return [
'MNT_ID' => $this->accountId,
'MNT_TRANSACTION_ID' => $this->transactionId,
'MNT_AMOUNT' => $this->amount->toDecimal(),
'MNT_CURRENCY_CODE' => AssistantProtocol::CURRENCY,
'MNT_SUBSCRIBER_ID' => $this->subscriberId,
'MNT_TEST_MODE' => AssistantProtocol::testModeFlag($this->testMode),
'MNT_DESCRIPTION' => mb_substr($this->description, 0, self::DESCRIPTION_MAX_LENGTH),
'MNT_SUCCESS_URL' => $this->successUrl,
'MNT_FAIL_URL' => $this->failUrl,
'MNT_RETURN_URL' => $this->returnUrl,
'moneta.locale' => $this->locale,
'MNT_CMS' => $this->cms,
'MNT_SIGNATURE' => $this->signature->forPaymentForm(
accountId: $this->accountId,
transactionId: $this->transactionId,
amount: $this->amount,
currency: AssistantProtocol::CURRENCY,
subscriberId: $this->subscriberId,
testMode: $this->testMode,
),
];
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\protocol;
/**
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
enum PaymentServer: string
{
case Prod = 'PROD';
case Demo = 'DEMO';
/**
* Адрес платёжной формы MONETA.Assistant.
*/
public function url(): string
{
return match ($this) {
self::Prod => 'https://www.payanyway.ru/assistant.htm',
self::Demo => 'https://demo.moneta.ru/assistant.htm',
};
}
/**
* Ключ строки интерфейса (`server_prod`, `server_demo`).
*/
public function label(): string
{
return 'server_' . strtolower($this->value);
}
public static function fromSetting(?string $value): self
{
return self::tryFrom((string) $value) ?? self::Prod;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\protocol;
/**
* Коды `MNT_RESULT_CODE` ответов модуля (`MONETA.Assistant.ru.pdf`, гл. 4–5).
*
* На Check URL код описывает состояние заказа; на Pay URL сервис считает
* уведомление доставленным только при 200, при 100/302/402 повторяет его,
* при 500 прекращает попытки.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
enum ResultCode: int
{
/** Ответ содержит сумму заказа — в проверочном запросе `MNT_AMOUNT` не было. */
case WithAmount = 100;
/** Заказ оплачен, уведомление доставлено. */
case Paid = 200;
/** Заказ в обработке, точный статус определить нельзя. */
case InProgress = 302;
/** Заказ создан и готов к оплате. */
case AwaitingPayment = 402;
/** Заказ неактуален (отменён, не найден); сервис прекращает повторы. */
case Rejected = 500;
/**
* Текст для `MNT_DESCRIPTION` / `description`: без кавычек, `&`, `$`, `#`
* и слэшей — требование `cmsspecification.pdf` к значениям ответа.
*/
public function description(): string
{
return match ($this) {
self::WithAmount => 'Order amount',
self::Paid => 'Order is paid',
self::InProgress => 'Order is being processed',
self::AwaitingPayment => 'Order created, but not paid',
self::Rejected => 'Order is not available',
};
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\protocol;
use InvalidArgumentException;
use paygw_moneta\local\Money;
/**
* Три формулы подписи MONETA.Assistant (`MONETA.Assistant.ru.pdf`, гл. 3–5).
*
* Везде MD5 от конкатенации значений без разделителей плюс код проверки
* целостности; отсутствующий параметр — пустая строка. Экземпляр держит код,
* чтобы он не гулял по сигнатурам методов.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class Signature
{
private const PATTERN = '/\A[0-9a-f]{32}\z/i';
public function __construct(
#[\SensitiveParameter]
private readonly string $integrityCode,
) {
if ($integrityCode === '') {
throw new InvalidArgumentException('Код проверки целостности не задан.');
}
}
/**
* Формула 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.
*
* md5(MNT_RESULT_CODE + MNT_ID + MNT_TRANSACTION_ID + КОД)
*/
public function forResponse(ResultCode $resultCode, string $accountId, string $transactionId): string
{
return $this->hash((string) $resultCode->value . $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));
}
private function hash(string $payload): string
{
return md5($payload . $this->integrityCode);
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\receipt;
use InvalidArgumentException;
/**
* Покупатель в чеке — объект `client` из `kassaspecification.pdf`: обязательно
* хотя бы одно из `email`/`phone`; при обоих чек уходит только на e-mail.
* `name` — ФИО покупателя, передаётся только вместе с контактом.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class Client
{
public const NAME_MAX_LENGTH = 256;
public const EMAIL_MAX_LENGTH = 64;
/** По `kassaspecification.pdf` (принимающая сторона); JSON-схема Check URL даёт 13. */
public const PHONE_MAX_LENGTH = 19;
private function __construct(
public readonly ?string $name,
public readonly ?string $email,
public readonly ?string $phone,
) {}
/**
* Строит покупателя из «сырых» данных профиля; невалидные значения
* отбрасываются. Если у покупателя нет ни e-mail, ни телефона, подставляется
* `$fallbackEmail` — настройка «Email для чеков» (чек уходит продавцу, ФИО
* покупателя остаётся); без неё покупателя нет (`null`).
*/
public static function fromContacts(
?string $email,
?string $phone,
?string $name = null,
?string $fallbackEmail = null,
): ?self {
$email = self::normalizeEmail($email);
$phone = self::normalizePhone($phone);
if ($email === null && $phone === null) {
$email = self::normalizeEmail($fallbackEmail);
}
if ($email === null && $phone === null) {
return null;
}
return new self(self::normalizeName($name), $email, $phone);
}
/**
* @param array{name?: ?string, email?: ?string, phone?: ?string} $data
*/
public static function fromArray(array $data): self
{
$client = self::fromContacts($data['email'] ?? null, $data['phone'] ?? null, $data['name'] ?? null);
if ($client === null) {
throw new InvalidArgumentException('У покупателя нет ни e-mail, ни телефона.');
}
return $client;
}
/**
* @return array<string, string>
*/
public function toArray(): array
{
return array_filter(
['name' => $this->name, 'email' => $this->email, 'phone' => $this->phone],
static fn(?string $value): bool => $value !== null,
);
}
public static function normalizeEmail(?string $email): ?string
{
$email = trim((string) $email);
if (
$email === ''
|| strlen($email) > self::EMAIL_MAX_LENGTH
|| filter_var($email, FILTER_VALIDATE_EMAIL) === false
) {
return null;
}
return $email;
}
public static function normalizePhone(?string $phone): ?string
{
$digits = preg_replace('/[^0-9]/', '', (string) $phone) ?? '';
if ($digits === '' || strlen($digits) + 1 > self::PHONE_MAX_LENGTH) {
return null;
}
return '+' . $digits;
}
private static function normalizeName(?string $name): ?string
{
$name = ReceiptText::validate((string) $name, self::NAME_MAX_LENGTH);
return $name === '' ? null : $name;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\receipt;
use paygw_moneta\local\Money;
/**
* Денежные числа в JSON без `float`: спецификация требует «два знака после
* точки, даже если это нули», а `json_encode(100.0)` даст `100.0`. Сумма
* кодируется меткой и после `json_encode` заменяется на десятичную строку без
* кавычек — получается литерал `100.00`, валидный JSON-number.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class JsonNumber
{
private const PREFIX = '__paygw_moneta_number:';
private function __construct() {}
public static function placeholder(Money $amount): string
{
return self::PREFIX . $amount->toDecimal();
}
/**
* @param array<string, mixed> $payload
* @throws \JsonException
*/
public static function encode(array $payload): string
{
$json = json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return preg_replace('/"' . preg_quote(self::PREFIX, '/') . '([0-9]+\.[0-9]{2})"/', '$1', $json) ?? $json;
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\receipt;
/**
* Признак способа расчёта (`pm` / `paymentMethod`), перечень из `cmsspecification.pdf`.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
enum PaymentMethod: string
{
case FullPrepayment = 'full_prepayment';
case Prepayment = 'prepayment';
case Advance = 'advance';
case FullPayment = 'full_payment';
case PartialPayment = 'partial_payment';
case Credit = 'credit';
case CreditPayment = 'credit_payment';
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\receipt;
/**
* Признак предмета расчёта (`po` / `paymentObject`), перечень из `cmsspecification.pdf`.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
enum PaymentObject: string
{
case Commodity = 'commodity';
case Excise = 'excise';
case Job = 'job';
case Service = 'service';
case GamblingBet = 'gambling_bet';
case GamblingPrize = 'gambling_prize';
case Lottery = 'lottery';
case LotteryPrize = 'lottery_prize';
case IntellectualActivity = 'intellectual_activity';
case Payment = 'payment';
case AgentCommission = 'agent_commission';
case Composite = 'composite';
case Another = 'another';
case PropertyRight = 'property_right';
case SalesTax = 'sales_tax';
case ResortFee = 'resort_fee';
}
+156
View File
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\receipt;
use InvalidArgumentException;
use paygw_moneta\local\Money;
/**
* Чек 54-ФЗ: покупатель и позиции. Сумма позиций обязана совпадать с суммой
* операции до копейки — иначе номенклатура не пройдёт валидацию кассы.
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class Receipt
{
/** @var list<ReceiptItem> */
public readonly array $items;
/**
* @param list<ReceiptItem> $items
*/
public function __construct(
public readonly Client $client,
array $items,
) {
if ($items === []) {
throw new InvalidArgumentException('Чек без позиций.');
}
foreach ($items as $item) {
if (!$item instanceof ReceiptItem) {
throw new InvalidArgumentException('Позиция чека должна быть ReceiptItem.');
}
}
$this->items = array_values($items);
}
/**
* Единственная позиция «доступ к курсу» на всю сумму заказа.
*/
public static function forCourse(string $courseName, Money $amount, Client $client, Vat $vat): self
{
return new self($client, [
new ReceiptItem(
name: $courseName,
price: $amount,
quantity: 1,
vat: $vat,
paymentMethod: PaymentMethod::FullPayment,
paymentObject: PaymentObject::Service,
),
]);
}
public function total(): Money
{
$total = Money::fromMinorUnits(0);
foreach ($this->items as $item) {
$total = $total->add($item->total());
}
return $total;
}
public function matchesAmount(Money $amount): bool
{
return $this->total()->equals($amount);
}
/**
* Объект `receipt` JSON-ответа Check URL.
*
* @return array{client: array<string, string>, items: list<array<string, mixed>>}
*/
public function toCheckReceipt(): array
{
return [
'client' => $this->client->toArray(),
'items' => array_map(static fn(ReceiptItem $item): array => $item->toCheckItem(), $this->items),
];
}
/**
* Значение атрибута `INVENTORY` — JSON-массив позиций.
*
* @throws \JsonException
*/
public function toInventoryAttribute(): string
{
return json_encode(
array_map(static fn(ReceiptItem $item): array => $item->toInventoryPosition(), $this->items),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
);
}
/**
* Значение атрибута `CLIENT` — JSON-массив из одного объекта.
*
* @throws \JsonException
*/
public function toClientAttribute(): string
{
return json_encode(
[$this->client->toArray()],
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
);
}
/**
* Значение атрибута `CUSTOMER` — e-mail покупателя строкой, по
* `cmsspecification.pdf`; дублирует `CLIENT.email`, чтобы чек принимался при
* любой трактовке документов. `null` — у покупателя
* только телефон.
*/
public function toCustomerAttribute(): ?string
{
return $this->client->email;
}
/**
* Значение атрибута `PHONE` — телефон покупателя без `+`, как в примере
* `cmsspecification.pdf`; дублирует `CLIENT.phone`. `null` — телефона нет.
*/
public function toPhoneAttribute(): ?string
{
$phone = $this->client->phone;
return $phone === null ? null : ltrim($phone, '+');
}
/**
* @return array{client: array<string, string>, items: list<array<string, string|int>>}
*/
public function toArray(): array
{
return [
'client' => $this->client->toArray(),
'items' => array_map(static fn(ReceiptItem $item): array => $item->toArray(), $this->items),
];
}
/**
* @param array{client?: array<string, ?string>, items?: list<array<string, mixed>>} $data
*/
public static function fromArray(array $data): self
{
$client = Client::fromArray(is_array($data['client'] ?? null) ? $data['client'] : []);
$items = array_map(static fn(array $item): ReceiptItem => ReceiptItem::fromArray($item), $data['items'] ?? []);
return new self($client, $items);
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\receipt;
use InvalidArgumentException;
use paygw_moneta\local\Money;
/**
* Позиция чека — объект `inventPositions` (`kassaspecification.pdf`) и элемент
* `items` JSON-ответа Check URL (`cmsspecification.pdf`).
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class ReceiptItem
{
public const NAME_MAX_LENGTH = 128;
/** Максимальная цена позиции и произведения `price * quantity`. */
public const PRICE_MAX = '42949672.95';
/** Пустое имя касса не примет; остаётся, если после чистки ничего нет. */
private const FALLBACK_NAME = 'Услуга';
/** Единица измерения из перечня кассы; для доступа к курсу иной не бывает. */
private const MEASURE = 'unit';
public readonly string $name;
public function __construct(
string $name,
public readonly Money $price,
public readonly int $quantity,
public readonly Vat $vat,
public readonly PaymentMethod $paymentMethod,
public readonly PaymentObject $paymentObject,
) {
$name = ReceiptText::validate($name, self::NAME_MAX_LENGTH);
$this->name = ($name === '') ? self::FALLBACK_NAME : $name;
if ($quantity < 1 || $quantity > 99999) {
throw new InvalidArgumentException('Количество позиции вне допустимых границ.');
}
$max = Money::fromDecimal(self::PRICE_MAX)->toMinorUnits();
if ($price->toMinorUnits() > $max || $this->total()->toMinorUnits() > $max) {
throw new InvalidArgumentException('Цена позиции превышает потолок кассы.');
}
}
public function total(): Money
{
return $this->price->multiply($this->quantity);
}
/**
* Элемент `items` для JSON-ответа Check URL: числа — числа, ставка — строкой `vat`.
* Цена подставляется меткой, чтобы в JSON осталось ровно два знака (`100.00`).
*
* @return array<string, mixed>
*/
public function toCheckItem(): array
{
return [
'name' => $this->name,
'price' => JsonNumber::placeholder($this->price),
'quantity' => $this->quantity,
'measure' => self::MEASURE,
'paymentMethod' => $this->paymentMethod->value,
'paymentObject' => $this->paymentObject->value,
'vat' => $this->vat->value,
];
}
/**
* Объект `inventPositions` для атрибута `INVENTORY`: цена и количество —
* строками, как в примере спецификации Moneta.
*
* @return array<string, string>
*/
public function toInventoryPosition(): array
{
return [
'name' => $this->name,
'price' => $this->price->toDecimal(),
'quantity' => (string) $this->quantity,
'measure' => self::MEASURE,
'vatTag' => $this->vat->tag(),
'pm' => $this->paymentMethod->value,
'po' => $this->paymentObject->value,
];
}
/**
* @return array<string, string|int>
*/
public function toArray(): array
{
return [
'name' => $this->name,
'price' => $this->price->toDecimal(),
'quantity' => $this->quantity,
'vat' => $this->vat->value,
'paymentMethod' => $this->paymentMethod->value,
'paymentObject' => $this->paymentObject->value,
];
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
name: (string) ($data['name'] ?? ''),
price: Money::fromDecimal((string) ($data['price'] ?? '')),
quantity: (int) ($data['quantity'] ?? 0),
vat: Vat::from((string) ($data['vat'] ?? '')),
paymentMethod: PaymentMethod::from((string) ($data['paymentMethod'] ?? '')),
paymentObject: PaymentObject::from((string) ($data['paymentObject'] ?? '')),
);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\receipt;
/**
* Текстовые поля чека: «значения не должны содержать кавычек, знаков &, $, #,
* обратных и прямых слэшей» (`cmsspecification.pdf`).
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class ReceiptText
{
private function __construct() {}
public static function validate(string $value, int $maxLength): string
{
$maxLength = max(0, $maxLength);
$value = self::sanitize($value);
if (mb_strlen($value, 'UTF-8') <= $maxLength) {
return $value;
}
return mb_substr($value, 0, max(0, $maxLength - 3), 'UTF-8') . ($maxLength > 3 ? '...' : '');
}
public static function sanitize(string $value): string
{
$decoded = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$whitelisted = preg_replace('/[^\p{L}\p{N}\s.,()_№+-]/u', '', $decoded) ?? '';
$collapsed = preg_replace('/\s+/u', ' ', $whitelisted) ?? $whitelisted;
return trim($collapsed);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace paygw_moneta\local\receipt;
/**
* Ставка НДС позиции чека. Значение case — код `vat` для JSON-ответа Check URL
* (`cmsspecification.pdf`), оно же хранится в настройке шлюза и снимке заказа;
* {@see self::tag()} даёт тег `vatTag` для `INVENTORY` Pay URL
* (`kassaspecification.pdf`).
*
* @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
enum Vat: string
{
case None = 'none';
case Vat0 = 'vat0';
case Vat5 = 'vat5';
case Vat7 = 'vat7';
case Vat10 = 'vat10';
case Vat20 = 'vat20';
case Vat22 = 'vat22';
case Vat105 = 'vat105';
case Vat107 = 'vat107';
case Vat110 = 'vat110';
case Vat120 = 'vat120';
case Vat122 = 'vat122';
/**
* Тег `vatTag` для `INVENTORY` Pay URL (`kassaspecification.pdf`).
*/
public function tag(): string
{
return match ($this) {
self::None => '1105',
self::Vat0 => '1104',
self::Vat5 => '1108',
self::Vat7 => '1109',
self::Vat10 => '1103',
self::Vat20 => '1102',
self::Vat22 => '1113',
self::Vat105 => '1110',
self::Vat107 => '1111',
self::Vat110 => '1107',
self::Vat120 => '1106',
self::Vat122 => '1114',
};
}
/**
* Ключ строки в `lang/{ru,en}/paygw_moneta.php`.
*/
public function langKey(): string
{
return 'vat:' . $this->value;
}
}