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 $query `$_GET` * @param array $body `$_POST` * * @return array */ public static function requestFields(array $query, array $body): array { return array_replace($query, $body); } /** * @param array $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(); } }