feat: moneta 2.0, MONETA.Assistant payment module for PHPShop 6.7+

This commit is contained in:
2026-10-27 11:45:22 +03:00
parent 78b7405637
commit f8c12ce44a
82 changed files with 7387 additions and 58 deletions
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\protocol;
/**
* Êîíñòàíòû ïðîòîêîëà MONETA.Assistant (`MONETA.Assistant.ru.pdf`).
*/
final class AssistantProtocol
{
/** `MNT_COMMAND` ïðîâåðî÷íîãî çàïðîñà íà Check URL; íà Pay URL ïàðàìåòðà íåò. */
public const COMMAND_CHECK = 'CHECK';
/** Òåêñòîâûé îòêàç íà Check/Pay URL: ñåðâèñ ïîâòîðèò óâåäîìëåíèå. */
public const TEXT_FAIL = 'FAIL';
/** Åäèíñòâåííàÿ âàëþòà ìîäóëÿ (ISO 4217). */
public const CURRENCY_RUB = 'RUB';
private function __construct() {}
/**
* ISO-êîä âàëþòû ìàãàçèíà -> `MNT_CURRENCY_CODE`; null — âàëþòà ìîäóëåì íå ïîääåðæèâàåòñÿ.
* `RUR` — óñòàðåâøèé êîä ðóáëÿ, êîòîðûé åù¸ âñòðå÷àåòñÿ â ñïðàâî÷íèêàõ âàëþò ìàãàçèíîâ.
*/
public static function normalizeCurrency(string $iso): ?string
{
$iso = strtoupper(trim($iso));
return (($iso === self::CURRENCY_RUB) || ($iso === 'RUR')) ? self::CURRENCY_RUB : null;
}
/**
* Çíà÷åíèå `MNT_TEST_MODE` äëÿ ôîðìû è ïîäïèñè.
*/
public static function testModeFlag(bool $testMode): string
{
return $testMode ? '1' : '0';
}
/**
* ßçûê âèòðèíû PHPShop (`russian`, `russian_utf`, `english`, `english_utf`) ->
* `moneta.locale` ïëàò¸æíîé ôîðìû. Ìîíåòà çíàåò òîëüêî `ru` è `en`
* (`MONETA.Assistant.ru.pdf`, ïàðàìåòðû çàïðîñà): àíãëèéñêàÿ âèòðèíà — `en`, îñòàëüíûå — `ru`.
*/
public static function normalizeLocale(string $shopLocale): string
{
return strncmp(strtolower(trim($shopLocale)), 'english', 7) === 0 ? 'en' : 'ru';
}
}
@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\protocol;
use InvalidArgumentException;
use MonetaPhpShop\Money;
/**
* Âõîäÿùèé çàïðîñ ñåðâèñà íà Check URL (`MNT_COMMAND=CHECK`) èëè Pay URL.
*/
final class CallbackNotification
{
private const ACCOUNT_ID_MAX_LENGTH = 20;
private const TRANSACTION_ID_MAX_LENGTH = 255;
private const OPERATION_ID_MAX_LENGTH = 255;
/**
* Íîìåð îïåðàöèè MONETA.RU â Pay URL — òîëüêî öèôðû
*/
private const PAY_OPERATION_ID = '/\A[0-9]{1,20}\z/';
private const SUBSCRIBER_ID_MAX_LENGTH = 255;
private const CURRENCY_LENGTH = 3;
private const SIGNATURE_LENGTH = 32;
/** @var string */
private $command;
/** @var string */
private $accountId;
/** @var string */
private $transactionId;
/** @var string */
private $operationId;
/** @var string */
private $rawAmount;
/** @var Money|null */
private $amount;
/** @var string */
private $currency;
/** @var string */
private $subscriberId;
/** @var string */
private $rawTestMode;
/** @var string */
private $signature;
/**
* @param array<string, mixed> $fields ïàðàìåòðû çàïðîñà ($_POST èëè $_GET — ìåòîä çàäà¸òñÿ â íàñòðîéêàõ ñ÷¸òà)
*/
public function __construct(array $fields)
{
$this->command = self::optional($fields, 'MNT_COMMAND', strlen(AssistantProtocol::COMMAND_CHECK));
if (($this->command !== '') && ($this->command !== AssistantProtocol::COMMAND_CHECK)) {
throw new InvalidArgumentException('Íåèçâåñòíàÿ êîìàíäà óâåäîìëåíèÿ.');
}
$this->accountId = self::required($fields, 'MNT_ID', self::ACCOUNT_ID_MAX_LENGTH);
$this->transactionId = self::required($fields, 'MNT_TRANSACTION_ID', self::TRANSACTION_ID_MAX_LENGTH);
$this->operationId = self::optional($fields, 'MNT_OPERATION_ID', self::OPERATION_ID_MAX_LENGTH);
$this->rawAmount = self::optional($fields, 'MNT_AMOUNT', strlen(Money::MAX));
$this->amount = $this->rawAmount === '' ? null : Money::fromDecimal($this->rawAmount);
$this->currency = self::required($fields, 'MNT_CURRENCY_CODE', self::CURRENCY_LENGTH);
$this->subscriberId = self::optional($fields, 'MNT_SUBSCRIBER_ID', self::SUBSCRIBER_ID_MAX_LENGTH);
$this->rawTestMode = self::required($fields, 'MNT_TEST_MODE', 1);
if (($this->rawTestMode !== '0') && ($this->rawTestMode !== '1')) {
throw new InvalidArgumentException('Íåäîïóñòèìîå çíà÷åíèå MNT_TEST_MODE.');
}
$this->signature = self::required($fields, 'MNT_SIGNATURE', self::SIGNATURE_LENGTH);
if (
(($this->amount === null) || (preg_match(self::PAY_OPERATION_ID, $this->operationId) !== 1))
&& !$this->isCheck()
) {
throw new InvalidArgumentException('Óâåäîìëåíèþ îá îïëàòå íóæíû ñóììà è íîìåð îïåðàöèè èç öèôð.');
}
}
public function isCheck(): bool
{
return ($this->command === AssistantProtocol::COMMAND_CHECK);
}
public function getCommand(): string
{
return $this->command;
}
public function getAccountId(): string
{
return $this->accountId;
}
public function getTransactionId(): string
{
return $this->transactionId;
}
public function getOperationId(): string
{
return $this->operationId;
}
/**
* Ñóììà êàê ïðèøëà — òîëüêî äëÿ ïîäïèñè; äëÿ ñâåðêè — {@see self::getAmount()}.
*/
public function getRawAmount(): string
{
return $this->rawAmount;
}
public function getAmount(): ?Money
{
return $this->amount;
}
public function getCurrency(): string
{
return $this->currency;
}
public function getSubscriberId(): string
{
return $this->subscriberId;
}
public function getRawTestMode(): string
{
return $this->rawTestMode;
}
public function isTestMode(): bool
{
return $this->rawTestMode === '1';
}
public function isSignedBy(Signature $signature): bool
{
return Signature::equals($signature->forNotification($this), $this->signature);
}
/**
* @param array<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): 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,55 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\protocol;
/**
* Òåëî è òèï îòâåòà ìîäóëÿ íà Check URL / Pay URL.
*/
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';
/** @var string */
private $contentType;
/** @var string */
private $body;
private function __construct(string $contentType, string $body)
{
$this->contentType = $contentType;
$this->body = $body;
}
/**
* Îòêàç áåç ïîäðîáíîñòåé: ïðè÷èíà ïèøåòñÿ òîëüêî â æóðíàë ìîäóëÿ.
*/
public static function fail(): self
{
return new self(self::CONTENT_TYPE_TEXT, AssistantProtocol::TEXT_FAIL);
}
public static function xml(string $body): self
{
return new self(self::CONTENT_TYPE_XML, $body);
}
public static function json(string $body): self
{
return new self(self::CONTENT_TYPE_JSON, $body);
}
public function getContentType(): string
{
return $this->contentType;
}
public function getBody(): string
{
return $this->body;
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\protocol;
use MonetaPhpShop\Money;
/**
* Îòâåò íà ïðîâåðî÷íûé çàïðîñ (Check URL).
*
* Ôîðìàò âûáèðàåò íàñòðîéêà «Ôèñêàëèçàöèÿ ÷åêîâ»:
* âûêëþ÷åíà — XML `MNT_RESPONSE` ñ `MNT_ATTRIBUTES`, âêëþ÷åíà — JSON ïî `cmsspecification.pdf` ñ ÷åêîì.
*/
final class CheckResponse
{
/** @var string */
private $accountId;
/** @var string */
private $transactionId;
/** @var Money|null */
private $amount;
/** @var ResultCode */
private $resultCode;
/** @var Signature */
private $signature;
/** @var string */
private $cms;
/**
* @param string $transactionId ðîâíî òî, ÷òî ïðèñëàë ñåðâèñ
* @param Money|null $amount ñóììà çàêàçà; null — çàêàç íå íàéäåí
*/
public function __construct(
string $accountId,
string $transactionId,
?Money $amount,
ResultCode $resultCode,
Signature $signature,
string $cms
) {
$this->accountId = $accountId;
$this->transactionId = $transactionId;
$this->amount = $amount;
$this->resultCode = $resultCode;
$this->signature = $signature;
$this->cms = $cms;
}
public function getResultCode(): ResultCode
{
return $this->resultCode;
}
/**
* @param array<string, string> $attributes `INVENTORY`, `CLIENT`, `CUSTOMER`, `PHONE` — êàê ó Pay URL
*/
public function toXml(array $attributes = []): CallbackResponse
{
return CallbackResponse::xml(
MntResponseXml::build(
$this->accountId,
$this->transactionId,
$this->resultCode,
$this->amount,
$this->sign(),
$this->cms,
$attributes,
),
);
}
/**
* @param array<string, mixed> $receipt îáúåêò `receipt` ñõåìû (`client`, `items`); äåíåæíûå
* ïîëÿ — ÷åðåç {@see JsonNumber::placeholder()}, ñòðîêè â UTF-8
*/
public function toJson(array $receipt): CallbackResponse
{
$payload = [
'id' => $this->accountId,
'transactionId' => $this->transactionId,
];
if ($this->amount !== null) {
$payload['amount'] = JsonNumber::placeholder($this->amount);
}
$payload += [
'signature' => $this->sign(),
'resultCode' => (string) $this->resultCode->getValue(),
'description' => $this->resultCode->description(),
'cms' => $this->cms,
'receipt' => $receipt,
];
return CallbackResponse::json(JsonNumber::encode($payload));
}
private function sign(): string
{
return $this->signature->forResponse($this->resultCode, $this->accountId, $this->transactionId);
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\protocol;
use MonetaPhpShop\Money;
use MonetaPhpShop\receipt\Quantity;
use RuntimeException;
/**
* Äåíåæíûå ÷èñëà â JSON áåç `float`: `cmsspecification.pdf` òðåáóåò «äâà çíàêà
* ïîñëå òî÷êè, äàæå åñëè ýòî íóëè», à `json_encode(100.0)` äàñò `100.0`. Ñóììà
* êîäèðóåòñÿ ìåòêîé è ïîñëå `json_encode` çàìåíÿåòñÿ íà äåñÿòè÷íóþ ñòðîêó áåç
* êàâû÷åê — ïîëó÷àåòñÿ ëèòåðàë `100.00`, âàëèäíûé JSON-number. Òàê æå — äðîáíîå êîëè÷åñòâî
* ïîçèöèè (`2.5`, äî òð¸õ çíàêîâ): `float` äàë áû `2.4999999…` íà íåóäà÷íûõ çíà÷åíèÿõ.
*/
final class JsonNumber
{
private const PREFIX = '__moneta_phpshop_number:';
private const QUANTITY_PREFIX = '__moneta_phpshop_quantity:';
private function __construct() {}
public static function placeholder(Money $amount): string
{
return self::PREFIX . $amount->toDecimal();
}
/**
* Êîëè÷åñòâî ïîçèöèè: öåëîå — êàê åñòü (`2`), äðîáíîå — ìåòêîé, â JSON ñòàíåò `2.5`.
*
* @return int|string
*/
public static function quantity(Quantity $quantity)
{
return $quantity->isWhole() ? $quantity->getPieces() : self::QUANTITY_PREFIX . $quantity->toDecimal();
}
/**
* @param array<mixed> $payload ñòðîêè â UTF-8
*/
public static function encode(array $payload): string
{
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Íå óäàëîñü ñåðèàëèçîâàòü JSON: ' . json_last_error_msg());
}
$result = preg_replace(
[
'/"' . preg_quote(self::PREFIX, '/') . '([0-9]+\.[0-9]{2})"/',
'/"' . preg_quote(self::QUANTITY_PREFIX, '/') . '([0-9]+\.[0-9]{1,3})"/',
],
'$1',
$json,
);
if ($result === null) {
throw new RuntimeException('Íå óäàëîñü ïîäñòàâèòü ñóììû â JSON.');
}
return $result;
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\protocol;
use DOMDocument;
use DOMNode;
use MonetaPhpShop\Money;
use RuntimeException;
/**
* Ñáîðêà `MNT_RESPONSE` (`MONETA.Assistant.ru.pdf`, ãë. 4–5) — îáùàÿ ÷àñòü
* XML-îòâåòîâ Check URL è Pay URL: îáÿçàòåëüíûå ýëåìåíòû, ïîäïèñü è `MNT_ATTRIBUTES`.
*/
final class MntResponseXml
{
private function __construct() {}
/**
* @param Money|null $amount null — ñóììà íåèçâåñòíà (çàêàç íå íàéäåí), ýëåìåíò íå âûâîäèòñÿ
* @param array<string, string> $attributes ïàðû KEY -> VALUE äëÿ `MNT_ATTRIBUTES`, ñòðîêè â UTF-8
* @throws \DOMException
*/
public static function build(
string $accountId,
string $transactionId,
ResultCode $resultCode,
?Money $amount,
string $signature,
string $cms,
array $attributes = []
): string {
$document = new DOMDocument('1.0', 'UTF-8');
$response = $document->appendChild($document->createElement('MNT_RESPONSE'));
$elements = [
'MNT_ID' => $accountId,
'MNT_TRANSACTION_ID' => $transactionId,
'MNT_RESULT_CODE' => (string) $resultCode->getValue(),
'MNT_DESCRIPTION' => $resultCode->description(),
'MNT_AMOUNT' => $amount !== null ? $amount->toDecimal() : null,
'MNT_SIGNATURE' => $signature,
'MNT_CMS' => $cms,
];
foreach ($elements as $name => $value) {
if ($value !== null) {
self::text($document, $response, $name, $value);
}
}
if ($attributes !== []) {
$container = $response->appendChild($document->createElement('MNT_ATTRIBUTES'));
foreach ($attributes as $key => $value) {
$attribute = $container->appendChild($document->createElement('ATTRIBUTE'));
self::text($document, $attribute, 'KEY', (string) $key);
self::text($document, $attribute, 'VALUE', $value);
}
}
$xml = $document->saveXML();
if ($xml === false) {
throw new RuntimeException('Íå óäàëîñü ñåðèàëèçîâàòü MNT_RESPONSE.');
}
return $xml;
}
private static function text(DOMDocument $document, DOMNode $parent, string $name, string $value): void
{
$parent
->appendChild($document->createElement($name))
->appendChild($document->createTextNode($value));
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\protocol;
use MonetaPhpShop\Money;
/**
* Îòâåò íà óâåäîìëåíèå îá îïëàòå (Pay URL): âñåãäà XML `MNT_RESPONSE`, íåçàâèñèìî îò íàñòðîéêè
* ôèñêàëèçàöèè. `MNT_ATTRIBUTES` — òîëüêî â óñïåøíîì îòâåòå, ñîñòàâ çàâèñèò îò ôèñêàëèçàöèè.
*/
final class PayResponse
{
/** @var string */
private $accountId;
/** @var string */
private $transactionId;
/** @var Money */
private $amount;
/** @var ResultCode */
private $resultCode;
/** @var Signature */
private $signature;
/** @var string */
private $cms;
/**
* @param string $transactionId ðîâíî òî, ÷òî ïðèñëàë ñåðâèñ
*/
public function __construct(
string $accountId,
string $transactionId,
Money $amount,
ResultCode $resultCode,
Signature $signature,
string $cms
) {
$this->accountId = $accountId;
$this->transactionId = $transactionId;
$this->amount = $amount;
$this->resultCode = $resultCode;
$this->signature = $signature;
$this->cms = $cms;
}
/**
* @param array<string, string> $attributes `INVENTORY`, `CLIENT`, `CUSTOMER`, `PHONE`
* @throws \DOMException
*/
public function toXml(array $attributes = []): CallbackResponse
{
return CallbackResponse::xml(
MntResponseXml::build(
$this->accountId,
$this->transactionId,
$this->resultCode,
$this->amount,
$this->signature->forResponse($this->resultCode, $this->accountId, $this->transactionId),
$this->cms,
$attributes,
),
);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\protocol;
/**
* Ñåðâåð ïëàò¸æíîé ôîðìû MONETA.Assistant. Ó äåìî-ïëîùàäêè ñâîé íîìåð ñ÷¸òà.
* Çíà÷åíèÿ `PROD`/`DEMO` õðàíÿòñÿ â íàñòðîéêàõ ìîäóëÿ — íå ïåðåèìåíîâûâàòü.
*/
final class PaymentServer
{
public const PROD = 'PROD';
public const DEMO = 'DEMO';
private const URLS = [
self::PROD => 'https://www.payanyway.ru/assistant.htm',
self::DEMO => 'https://demo.moneta.ru/assistant.htm',
];
/** @var string */
private $value;
private function __construct(string $value)
{
$this->value = $value;
}
public static function fromSetting(?string $value): self
{
return new self(isset(self::URLS[(string) $value]) ? (string) $value : self::PROD);
}
public function getValue(): string
{
return $this->value;
}
public function isDemo(): bool
{
return ($this->value === self::DEMO);
}
public function url(): string
{
return self::URLS[$this->value];
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\protocol;
/**
* Êîäû `MNT_RESULT_CODE` îòâåòîâ ìîäóëÿ (`MONETA.Assistant.ru.pdf`, ãë. 4–5;
* `cmsspecification.pdf`, «Ïàðàìåòðû îòâåòà»).
*
* Íà Check URL êîä îïèñûâàåò ñîñòîÿíèå çàêàçà; íà Pay URL ñåðâèñ ñ÷èòàåò
* óâåäîìëåíèå äîñòàâëåííûì òîëüêî ïðè 200, ïðè 100/302/402 ïîâòîðÿåò åãî,
* ïðè 500 ïðåêðàùàåò ïîïûòêè. Value-îáúåêò âìåñòî enum — ìîäóëü ðàáîòàåò íà PHP 7.4.
*/
final class ResultCode
{
/** Îòâåò ñîäåðæèò ñóììó çàêàçà — â ïðîâåðî÷íîì çàïðîñå `MNT_AMOUNT` íå áûëî. */
public const WITH_AMOUNT = 100;
/** Çàêàç îïëà÷åí, óâåäîìëåíèå äîñòàâëåíî. */
public const PAID = 200;
/** Çàêàç â îáðàáîòêå, òî÷íûé ñòàòóñ îïðåäåëèòü íåëüçÿ. */
public const IN_PROGRESS = 302;
/** Çàêàç ñîçäàí è ãîòîâ ê îïëàòå. */
public const AWAITING_PAYMENT = 402;
/** Çàêàç íåàêòóàëåí (îòìåí¸í, íå íàéäåí); ñåðâèñ ïðåêðàùàåò ïîâòîðû. */
public const REJECTED = 500;
/**
* Òåêñò äëÿ `MNT_DESCRIPTION` / `description`: áåç êàâû÷åê, `&`, `$`, `#`
* è ñëýøåé — òðåáîâàíèå `cmsspecification.pdf` ê çíà÷åíèÿì îòâåòà.
*/
private const DESCRIPTIONS = [
self::WITH_AMOUNT => 'Order amount',
self::PAID => 'Order is paid',
self::IN_PROGRESS => 'Order is being processed',
self::AWAITING_PAYMENT => 'Order created, but not paid',
self::REJECTED => 'Order is not available',
];
/** @var int */
private $value;
private function __construct(int $value)
{
$this->value = $value;
}
public static function fromValue(int $value): self
{
if (!array_key_exists($value, self::DESCRIPTIONS)) {
throw new \InvalidArgumentException('Íåèçâåñòíûé êîä MNT_RESULT_CODE.');
}
return new self($value);
}
public static function withAmount(): self
{
return new self(self::WITH_AMOUNT);
}
public static function paid(): self
{
return new self(self::PAID);
}
public static function inProgress(): self
{
return new self(self::IN_PROGRESS);
}
public static function awaitingPayment(): self
{
return new self(self::AWAITING_PAYMENT);
}
public static function rejected(): self
{
return new self(self::REJECTED);
}
public function getValue(): int
{
return $this->value;
}
public function description(): string
{
return self::DESCRIPTIONS[$this->value];
}
public function equals(self $other): bool
{
return ($this->value === $other->value);
}
public function __toString(): string
{
return (string) $this->value;
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\protocol;
use InvalidArgumentException;
use MonetaPhpShop\Money;
/**
* Òðè ôîðìóëû ïîäïèñè MONETA.Assistant (`MONETA.Assistant.ru.pdf`, ãë. 3–5).
*
* Âåçäå MD5 îò êîíêàòåíàöèè çíà÷åíèé áåç ðàçäåëèòåëåé ïëþñ êîä ïðîâåðêè
* öåëîñòíîñòè; îòñóòñòâóþùèé ïàðàìåòð — ïóñòàÿ ñòðîêà. Ýêçåìïëÿð äåðæèò êîä,
* ÷òîáû îí íå ãóëÿë ïî ñèãíàòóðàì ìåòîäîâ è íå ïîïàäàë â òðàññèðîâêè.
*/
final class Signature
{
private const PATTERN = '/\A[0-9a-f]{32}\z/i';
/** @var string */
private $integrityCode;
public function __construct(
#[\SensitiveParameter]
string $integrityCode
) {
if ($integrityCode === '') {
throw new InvalidArgumentException('Êîä ïðîâåðêè öåëîñòíîñòè íå çàäàí.');
}
$this->integrityCode = $integrityCode;
}
/**
* Ôîðìóëà 1 — ïëàò¸æíàÿ ôîðìà (ìîäóëü -> ñåðâèñ).
*
* md5(MNT_ID + MNT_TRANSACTION_ID + MNT_AMOUNT + MNT_CURRENCY_CODE + MNT_SUBSCRIBER_ID + MNT_TEST_MODE + ÊÎÄ)
*/
public function forPaymentForm(
string $accountId,
string $transactionId,
Money $amount,
string $currency,
string $subscriberId,
bool $testMode
): string {
return $this->hash(
$accountId
. $transactionId
. $amount->toDecimal()
. $currency
. $subscriberId
. AssistantProtocol::testModeFlag($testMode),
);
}
/**
* Ôîðìóëà 2 — âõîäÿùèé çàïðîñ íà Check URL / Pay URL (ñåðâèñ -> ìîäóëü).
*
* md5(MNT_COMMAND + MNT_ID + MNT_TRANSACTION_ID + MNT_OPERATION_ID + MNT_AMOUNT
* + MNT_CURRENCY_CODE + MNT_SUBSCRIBER_ID + MNT_TEST_MODE + ÊÎÄ)
*
* Çíà÷åíèÿ áåðóòñÿ èç çàïðîñà êàê ïðèøëè: ïîäïèñü ñâåðÿåòñÿ ñ òåì, ÷òî
* ïîäïèñàë ñåðâèñ, à íå ñ íàøèì ïðåäñòàâëåíèåì î çàêàçå.
*/
public function forNotification(CallbackNotification $notification): string
{
return $this->hash(
$notification->getCommand()
. $notification->getAccountId()
. $notification->getTransactionId()
. $notification->getOperationId()
. $notification->getRawAmount()
. $notification->getCurrency()
. $notification->getSubscriberId()
. $notification->getRawTestMode(),
);
}
/**
* Ôîðìóëà 3 — îòâåò ìîäóëÿ íà Check URL è Pay URL (XML è JSON).
*
* md5(MNT_RESULT_CODE + MNT_ID + MNT_TRANSACTION_ID + ÊÎÄ)
*/
public function forResponse(ResultCode $resultCode, string $accountId, string $transactionId): string
{
return $this->hash((string) $resultCode->getValue() . $accountId . $transactionId);
}
/**
* Ñðàâíåíèå áåç óòå÷êè ïî âðåìåíè; ðåãèñòð hex-ñòðîêè íå âàæåí.
*/
public static function equals(string $expected, string $received): bool
{
if (preg_match(self::PATTERN, $received) !== 1) {
return false;
}
return hash_equals(strtolower($expected), strtolower($received));
}
/**
* Êîä ïðîâåðêè öåëîñòíîñòè íå âûâîäèòñÿ â var_dump()/print_r() è æóðíàëû.
*
* @return array<string, string>
*/
public function __debugInfo(): array
{
return ['integrityCode' => '***'];
}
private function hash(string $payload): string
{
return md5($payload . $this->integrityCode);
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\protocol;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
use MonetaPhpShop\Money;
/**
* `MNT_TRANSACTION_ID` — ôîðìàò `<íîìåð>_<ñóììà>_<YmdHis>UTC`: íîìåð çàêàçà PHPShop
* (`phpshop_orders.uid`, íàïðèìåð `4-36`), ñóììà ôîðìû â êîïåéêàõ è ìåòêà âûäà÷è ôîðìû â UTC,
* `4-36_1600000_20260930113005UTC`.
*/
final class TransactionId
{
/**
* Íîìåð çàêàçà PHPShop. Øòàòíî `<÷èñëî>-<ïðåôèêñ>` (`PHPShopDone::setNum()`),
* íî õóê `setNum` äðóãîãî ìîäóëÿ ìîæåò åãî ïåðåîïðåäåëèòü, ïîýòîìó äîïóñêàþòñÿ
* ëàòèíèöà, öèôðû è äåôèñ â ïðåäåëàõ `phpshop_orders.uid varchar(64)` — áåç `_`.
*/
public const ORDER_UID = '[0-9A-Za-z](?:[0-9A-Za-z-]{0,62}[0-9A-Za-z])?';
public const SEPARATOR = '_';
/** Ñóììà â êîïåéêàõ áåç âåäóùèõ íóëåé; 16 öèôð âìåùàþò {@see Money::MAX}. */
private const AMOUNT = '0|[1-9][0-9]{0,15}';
/**
* Ïîÿñ ìåòêè — âñåãäà UTC, áóêâàìè.
*/
private const ZONE = 'UTC';
private const PATTERN = '/\A
(?<uid>' . self::ORDER_UID . ') # íîìåð çàêàçà PHPShop
' . self::SEPARATOR . '
(?<amount>' . self::AMOUNT . ') # ñóììà ôîðìû â êîïåéêàõ
' . self::SEPARATOR . '
[0-9]{14} # ìåòêà âðåìåíè YmdHis
' . self::ZONE . ' # ïîÿñ ìåòêè
\z/x';
/** @var string */
private $orderUid;
/** @var Money */
private $amount;
private function __construct(string $orderUid, Money $amount)
{
$this->orderUid = $orderUid;
$this->amount = $amount;
}
/**
* @param Money $amount ñóììà ôîðìû (`MNT_AMOUNT`)
* @param DateTimeImmutable $issuedAt ìîìåíò âûäà÷è ôîðìû, â ëþáîì ïîÿñå — ìåòêà ïèøåòñÿ â UTC
*/
public static function build(string $orderUid, Money $amount, DateTimeImmutable $issuedAt): string
{
if (preg_match('/\A' . self::ORDER_UID . '\z/', $orderUid) !== 1) {
throw new InvalidArgumentException('Íîìåð çàêàçà íå ïîäõîäèò äëÿ MNT_TRANSACTION_ID.');
}
return $orderUid
. self::SEPARATOR . $amount->toMinorUnits()
. self::SEPARATOR . $issuedAt->setTimezone(new DateTimeZone(self::ZONE))->format('YmdHis') . self::ZONE;
}
/**
* Íîìåð çàêàçà è ñóììà ôîðìû èç ïðèøåäøåãî `MNT_TRANSACTION_ID` èëè null, åñëè ñòðîêà íå íàøåãî ôîðìàòà.
*/
public static function parse(string $raw): ?self
{
if (preg_match(self::PATTERN, $raw, $matches) !== 1) {
return null;
}
try {
$amount = Money::fromMinorUnits((int) $matches['amount']);
} catch (InvalidArgumentException $exception) {
return null;
}
return new self($matches['uid'], $amount);
}
/**
* Íîìåð çàêàçà èç òîãî, ÷òî ââ¸ë àäìèíèñòðàòîð (ôèëüòð æóðíàëà îïåðàöèé): ñàì íîìåð (`33-57`)
* èëè öåëèêîì `MNT_TRANSACTION_ID`, ñêîïèðîâàííûé èç êàáèíåòà MONETA.RU. Èíà÷å — null.
*/
public static function orderUidFromInput(string $input): ?string
{
$input = trim($input);
if (preg_match('/\A' . self::ORDER_UID . '\z/', $input) === 1) {
return $input;
}
$transactionId = self::parse($input);
return ($transactionId === null) ? null : $transactionId->getOrderUid();
}
public function getOrderUid(): string
{
return $this->orderUid;
}
/**
* Ñóììà ïëàò¸æíîé ôîðìû, âûïóñòèâøåé ýòîò íîìåð.
*/
public function getAmount(): Money
{
return $this->amount;
}
}