feat: paygw_moneta 1.0.0, MONETA.Assistant payment gateway for Moodle
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user