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
@@ -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);
}
}