370 lines
14 KiB
PHP
370 lines
14 KiB
PHP
<?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();
|
|
}
|
|
}
|