Files
PHPShop/moneta/class/callback/CallbackController.php
T

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();
}
}