feat: moneta 2.0, MONETA.Assistant payment module for PHPShop 6.7+
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\callback;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use MonetaPhpShop\ModuleInfo;
|
||||
use MonetaPhpShop\order\Order;
|
||||
use MonetaPhpShop\order\PaymentEligibility;
|
||||
use MonetaPhpShop\protocol\AssistantProtocol;
|
||||
use MonetaPhpShop\protocol\CallbackNotification;
|
||||
use MonetaPhpShop\protocol\CallbackResponse;
|
||||
use MonetaPhpShop\protocol\CheckResponse;
|
||||
use MonetaPhpShop\protocol\PayResponse;
|
||||
use MonetaPhpShop\protocol\ResultCode;
|
||||
use MonetaPhpShop\protocol\TransactionId;
|
||||
use MonetaPhpShop\Settings;
|
||||
use Throwable;
|
||||
|
||||
final class CallbackController
|
||||
{
|
||||
/** @var Settings */
|
||||
private $settings;
|
||||
|
||||
/** @var string */
|
||||
private $shopCurrency;
|
||||
|
||||
/** @var string */
|
||||
private $cms;
|
||||
|
||||
/** @var OrderGateway */
|
||||
private $orderGateway;
|
||||
|
||||
/** @var OperationJournal */
|
||||
private $operationJournal;
|
||||
|
||||
/** @var OrderLock */
|
||||
private $orderLock;
|
||||
|
||||
/** @var ReceiptSource */
|
||||
private $receiptSource;
|
||||
|
||||
/** @var Logger */
|
||||
private $logger;
|
||||
|
||||
/** @var Logger */
|
||||
private $rejectionLogger;
|
||||
|
||||
/**
|
||||
* @param string $shopCurrency ISO-êîä âàëþòû ìàãàçèíà (`getDefaultValutaIso(true)`)
|
||||
* @param string $cms ñòðîêà `MNT_CMS`
|
||||
* @param Logger|null $rejectionLogger îòêàçû äî ïðîâåðêè ïîäïèñè — êòî óãîäíî ìîæåò ñëàòü èõ
|
||||
* ïîòîêîì, ïîýòîìó æóðíàë äëÿ íèõ îáû÷íî ñ îãðàíè÷åíèåì ÷àñòîòû ({@see ThrottledLogger});
|
||||
* ïî óìîë÷àíèþ — `$logger`
|
||||
*/
|
||||
public function __construct(
|
||||
Settings $settings,
|
||||
string $shopCurrency,
|
||||
string $cms,
|
||||
OrderGateway $orderGateway,
|
||||
OperationJournal $operationJournal,
|
||||
OrderLock $orderLock,
|
||||
ReceiptSource $receiptSource,
|
||||
Logger $logger,
|
||||
?Logger $rejectionLogger = null
|
||||
) {
|
||||
$this->settings = $settings;
|
||||
$this->shopCurrency = $shopCurrency;
|
||||
$this->cms = $cms;
|
||||
$this->orderGateway = $orderGateway;
|
||||
$this->operationJournal = $operationJournal;
|
||||
$this->orderLock = $orderLock;
|
||||
$this->receiptSource = $receiptSource;
|
||||
$this->logger = $logger;
|
||||
$this->rejectionLogger = $rejectionLogger ?? $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ïàðàìåòðû óâåäîìëåíèÿ èç àäðåñà è òåëà âìåñòå, ïðè ñîâïàäåíèè èì¸í — èç òåëà.
|
||||
* Ìåòîä çàäà¸òñÿ â íàñòðîéêàõ ñ÷¸òà MONETA.RU, à ïðè POST MONETA.RU ïåðåíîñèò â òåëî è
|
||||
* ïàðàìåòðû èç àäðåñà Check URL.
|
||||
* Ëèøíåå èç àäðåñà íå îïàñíî: êàæäîå ïîëå ïîäïèñè âñ¸ ðàâíî ñâåðÿåòñÿ.
|
||||
*
|
||||
* @param array<mixed> $query `$_GET`
|
||||
* @param array<mixed> $body `$_POST`
|
||||
*
|
||||
* @return array<mixed>
|
||||
*/
|
||||
public static function requestFields(array $query, array $body): array
|
||||
{
|
||||
return array_replace($query, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $fields ïàðàìåòðû çàïðîñà ({@see requestFields()})
|
||||
*/
|
||||
public function handle(array $fields): CallbackResponse
|
||||
{
|
||||
if (!$this->settings->isConfigured()) {
|
||||
$this->rejectionLogger->error(
|
||||
'Óâåäîìëåíèå îòêëîíåíî: ìîäóëü íå íàñòðîåí (íîìåð ñ÷¸òà, êîä ïðîâåðêè öåëîñòíîñòè äàííûõ).',
|
||||
);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
try {
|
||||
$notification = new CallbackNotification($fields);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
$this->rejectionLogger->error('Óâåäîìëåíèå îòêëîíåíî: ' . $exception->getMessage());
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
if (
|
||||
$notification->getAccountId() !== $this->settings->getAccountId()
|
||||
|| !$notification->isSignedBy($this->settings->signature())
|
||||
) {
|
||||
$this->rejectionLogger->error('Óâåäîìëåíèå îòêëîíåíî: íîìåð ñ÷¸òà èëè ïîäïèñü íå ñîâïàäàþò.');
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
$transactionId = TransactionId::parse($notification->getTransactionId());
|
||||
$order = ($transactionId === null) ? null : $this->orderGateway->findByUid($transactionId->getOrderUid());
|
||||
if ($transactionId === null || $order === null) {
|
||||
return $this->unknownOrder($notification);
|
||||
}
|
||||
|
||||
if (!$this->orderLock->acquire($order->getId())) {
|
||||
$this->operationJournal->note($notification, $order, CallbackEvent::LOCK_TIMEOUT, null);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
try {
|
||||
// Âíóòðè áëîêèðîâêè — ñâåæèé ñíèìîê: ïàðàëëåëüíîå óâåäîìëåíèå ìîãëî åãî èçìåíèòü.
|
||||
$order = $this->orderGateway->findByUid($transactionId->getOrderUid()) ?? $order;
|
||||
|
||||
return $this->handleLocked($notification, $transactionId, $order);
|
||||
} finally {
|
||||
$this->orderLock->release($order->getId());
|
||||
}
|
||||
}
|
||||
|
||||
private function handleLocked(
|
||||
CallbackNotification $notification,
|
||||
TransactionId $transactionId,
|
||||
Order $order
|
||||
): CallbackResponse {
|
||||
$mismatch = $this->mismatch($notification, $transactionId, $order);
|
||||
if ($mismatch !== null) {
|
||||
$this->operationJournal->note($notification, $order, $mismatch, null);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
return $notification->isCheck()
|
||||
? $this->check($notification, $order)
|
||||
: $this->pay($notification, $order);
|
||||
}
|
||||
|
||||
private function mismatch(CallbackNotification $notification, TransactionId $transactionId, Order $order): ?string
|
||||
{
|
||||
if ($notification->getCurrency() !== AssistantProtocol::CURRENCY_RUB) {
|
||||
return CallbackEvent::CURRENCY_MISMATCH;
|
||||
}
|
||||
|
||||
if ($notification->isTestMode() !== $this->settings->isTestMode()) {
|
||||
return CallbackEvent::TEST_MODE_MISMATCH;
|
||||
}
|
||||
|
||||
// Ñóììà ôîðìû çàøèòà â íîìåð è çàêðûòà ïîäïèñüþ ({@see TransactionId}): ñ íåé ñâåðÿåòñÿ
|
||||
// è ñóììà óâåäîìëåíèÿ, è çàêàç — èíà÷å ïåðâûå öèôðû ñóììû ôîðìû, ïåðåíåñ¸ííûå â
|
||||
// MNT_OPERATION_ID, ïðîâåëè áû çàêàç, ïîäåøåâåâøèé ïîñëå ïîêàçà ôîðìû.
|
||||
$amount = $notification->getAmount();
|
||||
if ($amount !== null && !$amount->equals($transactionId->getAmount())) {
|
||||
return CallbackEvent::AMOUNT_MISMATCH;
|
||||
}
|
||||
if (!$transactionId->getAmount()->equals($order->getAmount())) {
|
||||
return CallbackEvent::ORDER_AMOUNT_CHANGED;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function check(CallbackNotification $notification, Order $order): CallbackResponse
|
||||
{
|
||||
$event = CallbackEvent::CHECKED;
|
||||
if ($this->isPaid($order)) {
|
||||
$resultCode = ResultCode::paid();
|
||||
} else {
|
||||
$eligibility = PaymentEligibility::check($this->settings, $order, $this->shopCurrency);
|
||||
if ($eligibility === PaymentEligibility::PAYABLE) {
|
||||
$resultCode = ($notification->getAmount() === null)
|
||||
? ResultCode::withAmount()
|
||||
: ResultCode::awaitingPayment();
|
||||
} else {
|
||||
$resultCode = ResultCode::rejected();
|
||||
$event = $eligibility;
|
||||
}
|
||||
}
|
||||
|
||||
$response = new CheckResponse(
|
||||
$this->settings->getAccountId(),
|
||||
$notification->getTransactionId(),
|
||||
$order->getAmount(),
|
||||
$resultCode,
|
||||
$this->settings->signature(),
|
||||
$this->cms,
|
||||
);
|
||||
|
||||
if (!$this->settings->isFiscalization()) {
|
||||
$this->operationJournal->note($notification, $order, $event, $resultCode);
|
||||
|
||||
return $response->toXml($this->receiptSource->attributes($order));
|
||||
}
|
||||
|
||||
$receipt = $this->receiptSource->jsonReceipt($order);
|
||||
if ($receipt === null) {
|
||||
$this->operationJournal->note($notification, $order, CallbackEvent::RECEIPT_UNAVAILABLE, null);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
$this->operationJournal->note($notification, $order, $event, $resultCode);
|
||||
|
||||
return $response->toJson($receipt);
|
||||
}
|
||||
|
||||
private function pay(CallbackNotification $notification, Order $order): CallbackResponse
|
||||
{
|
||||
$paidOperation = $this->operationJournal->findPaidOperation($order->getId());
|
||||
if ($paidOperation !== null) {
|
||||
if ($paidOperation === $notification->getOperationId()) {
|
||||
return $this->payResponse($notification, $order, CallbackEvent::REPEAT, ResultCode::paid());
|
||||
}
|
||||
// Âòîðàÿ îïåðàöèÿ ïî óæå îïëà÷åííîìó çàêàçó: ïîêóïàòåëü çàïëàòèë äâàæäû, äåíüãè ñïèñàíû.
|
||||
// Îòâå÷àåì FAIL — ìàãàçèí íå ïîäòâåðæäàåò óâåäîìëåíèå: îïåðàöèÿ â MONETA.RU íå ïåðåõîäèò
|
||||
// â «Âûïîëíåíî», îñòà¸òñÿ «Óâåäîìëåíèå íå îòïðàâëåíî», Pay URL ïîâòîðÿåòñÿ äî ñóòîê
|
||||
// (MONETA.Assistant.ru.pdf) — òàê å¸ âèäíî â êàáèíåòå ñðåäè îáû÷íûõ îïëàò. Çà÷åñòü å¸ ìîäóëü
|
||||
// íå ìîæåò; ñòðîêà â æóðíàëå — ñèãíàë àäìèíèñòðàòîðó ñäåëàòü âîçâðàò â êàáèíåòå MONETA.RU.
|
||||
$this->operationJournal->note($notification, $order, CallbackEvent::DUPLICATE_OPERATION, null);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
if ($order->getStatusId() === PaymentEligibility::STATUS_CANCELLED) {
|
||||
// Îòìåí¸ííûé çàêàç: ïîâòîðû áåññìûñëåííû, íî ñëåä â æóðíàëå îñòà¸òñÿ.
|
||||
return $this->payResponse($notification, $order, PaymentEligibility::CANCELLED, ResultCode::rejected());
|
||||
}
|
||||
if ($order->getPaymentMethodId() !== ModuleInfo::PAYMENT_METHOD_ID) {
|
||||
// Ïîêà ïîêóïàòåëü ïëàòèë, â çàêàçå ñìåíèëè ñïîñîá îïëàòû íà äðóãîé. Îòâå÷àåì FAIL:
|
||||
// MONETA.RU áóäåò ïîâòîðÿòü óâåäîìëåíèå, è îïëàòà çà÷ò¸òñÿ, êîãäà àäìèíèñòðàòîð
|
||||
// âåðí¸ò çàêàçó ñïîñîá îïëàòû «Ìîíåòà».
|
||||
$this->operationJournal->note($notification, $order, PaymentEligibility::OTHER_PAYMENT_METHOD, null);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
if (AssistantProtocol::normalizeCurrency($this->shopCurrency) === null) {
|
||||
// Âàëþòó âèòðèíû ñìåíèëè ñ ðóáëåé ïîñëå ïîêàçà ôîðìû: ñóììà çàêàçà óæå íå â ðóáëÿõ.
|
||||
// Òàê æå, êàê ñî ñïîñîáîì îïëàòû, — FAIL, ïîâòîðû è ñòðîêà â æóðíàëå äëÿ àäìèíèñòðàòîðà.
|
||||
$this->operationJournal->note($notification, $order, PaymentEligibility::UNSUPPORTED_CURRENCY, null);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
$event = CallbackEvent::ALREADY_PAID_IN_SHOP;
|
||||
if (!$this->isPaid($order)) {
|
||||
try {
|
||||
$this->orderGateway->markPaid($order, $this->settings->getPaidStatus(), $order->getAmount());
|
||||
} catch (Throwable $exception) {
|
||||
$this->logger->error(
|
||||
sprintf(
|
||||
'Çàêàç %s íå ïåðåâåä¸í â îïëà÷åííûå: %s: %s',
|
||||
$order->getUid(),
|
||||
get_class($exception),
|
||||
$exception->getMessage(),
|
||||
),
|
||||
);
|
||||
$this->operationJournal->note($notification, $order, CallbackEvent::ORDER_UPDATE_FAILED, null);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
$event = CallbackEvent::PAID;
|
||||
}
|
||||
|
||||
// Îòìåòêó «îïëàòà çà÷òåíà» ïèøåì â æóðíàë òîëüêî ïîñëå òîãî, êàê çàêàç ñòàë îïëà÷åííûì.
|
||||
// Åñëè çàïèñü â æóðíàë íå óäàëàñü, îòâå÷àåì FAIL, è MONETA.RU ïîâòîðèò óâåäîìëåíèå.
|
||||
// Ïîâòîð óâèäèò, ÷òî çàêàç óæå îïëà÷åí, è òîëüêî äîïèøåò îòìåòêó
|
||||
// (ñîáûòèå ALREADY_PAID_IN_SHOP): ñòàòóñ íå ìåíÿåòñÿ, âòîðîãî ïèñüìà ïîêóïàòåëþ íåò.
|
||||
if (!$this->operationJournal->recordPaid($notification, $order, $event)) {
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
return $this->response($notification, $order, ResultCode::paid());
|
||||
}
|
||||
|
||||
/**
|
||||
* Îïëà÷åí ïî äàííûì ìàãàçèíà èëè ïî æóðíàëó ìîäóëÿ (ìàðêåð ìîã çàïèñàòüñÿ,
|
||||
* à ñòàòóñ ïîòîì ñìåíèë ìåíåäæåð).
|
||||
*/
|
||||
private function isPaid(Order $order): bool
|
||||
{
|
||||
return PaymentEligibility::isPaid($this->settings, $order)
|
||||
|| $this->operationJournal->findPaidOperation($order->getId()) !== null;
|
||||
}
|
||||
|
||||
private function payResponse(
|
||||
CallbackNotification $notification,
|
||||
Order $order,
|
||||
string $event,
|
||||
ResultCode $resultCode
|
||||
): CallbackResponse {
|
||||
$this->operationJournal->note($notification, $order, $event, $resultCode);
|
||||
|
||||
return $this->response($notification, $order, $resultCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Îòâåò íà Pay URL: `MNT_ATTRIBUTES` — òîëüêî â óñïåøíîì (`200`), íåóñïåøíûé — áåç íèõ.
|
||||
*/
|
||||
private function response(
|
||||
CallbackNotification $notification,
|
||||
Order $order,
|
||||
ResultCode $resultCode
|
||||
): CallbackResponse {
|
||||
$attributes = $resultCode->equals(ResultCode::paid()) ? $this->receiptSource->attributes($order) : [];
|
||||
|
||||
return (new PayResponse(
|
||||
$this->settings->getAccountId(),
|
||||
$notification->getTransactionId(),
|
||||
$order->getAmount(),
|
||||
$resultCode,
|
||||
$this->settings->signature(),
|
||||
$this->cms,
|
||||
))->toXml($attributes);
|
||||
}
|
||||
|
||||
private function unknownOrder(CallbackNotification $notification): CallbackResponse
|
||||
{
|
||||
if (!$notification->isCheck()) {
|
||||
$this->operationJournal->note($notification, null, CallbackEvent::UNKNOWN_ORDER, null);
|
||||
$this->logger->error(
|
||||
sprintf(
|
||||
'Îïëàòà ïî íåèçâåñòíîìó çàêàçó %s, îïåðàöèÿ %s.',
|
||||
$notification->getTransactionId(),
|
||||
$notification->getOperationId(),
|
||||
),
|
||||
);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
$resultCode = ResultCode::rejected();
|
||||
$this->operationJournal->note($notification, null, CallbackEvent::UNKNOWN_ORDER, $resultCode);
|
||||
|
||||
// XML è ïðè âêëþ÷¸ííîé ôèñêàëèçàöèè: ÷åêà ó íåèçâåñòíîãî çàêàçà íåò, à JSON-ñõåìà
|
||||
// `cmsspecification.pdf` òðåáóåò õîòÿ áû îäíó ïîçèöèþ.
|
||||
return (new CheckResponse(
|
||||
$this->settings->getAccountId(),
|
||||
$notification->getTransactionId(),
|
||||
null,
|
||||
$resultCode,
|
||||
$this->settings->signature(),
|
||||
$this->cms,
|
||||
))->toXml();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user