feat: moneta 2.0, MONETA.Assistant payment module for PHPShop 6.7+
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user