status === TransactionStatus::Paid) { // Повторная доставка того же уведомления — тот же ответ, без второго зачисления. if ($order->providerTransactionId === $notification->getOperationId()) { return $this->paid($notification, $order, $config, ResultCode::Paid, $order->snapshot->receipt); } // Вторая операция по оплаченному заказу: деньги списаны дважды — нужен человек. $this->repository->noteError($order, RejectionReason::DuplicateOperation); return CallbackResponse::fail(); } if ($order->status === TransactionStatus::Canceled) { // Оплата отменённого заказа: повторы бессмысленны, но след должен остаться. return $this->paid($notification, $order, $config, ResultCode::Rejected, null); } // Снимок защищает легитимный заказ от смены настроек, но не должен позволять // дожать сохранённую форму после того, как администратор выключил тестовый // режим или поднял цену: сверяемся ещё и с текущим состоянием. if ($notification->isTestMode() !== $config->testMode) { $this->repository->noteError($order, RejectionReason::TestMode); return CallbackResponse::fail(); } if (!$config->enabled) { $this->repository->noteError($order, RejectionReason::GatewayDisabled); return CallbackResponse::fail(); } if (!$this->currentPrice->matches($order)) { $this->repository->noteError($order, RejectionReason::AmountChanged); return CallbackResponse::fail(); } $paid = $this->deliver($notification, $order); if ($paid === null) { return CallbackResponse::fail(); } $this->notifier->notifyPaid($paid); return $this->paid($notification, $paid, $config, ResultCode::Paid, $paid->snapshot->receipt); } /** * Платёж ядра, доставка заказа и статус — одной транзакцией БД. При сбое * заказ помечается `Failed`, чтобы повтор уведомления попробовал ещё раз. */ private function deliver(CallbackNotification $notification, Order $order): ?Order { global $DB; $transaction = $DB->start_delegated_transaction(); try { $paymentId = helper::save_payment( $order->accountId, $order->component, $order->paymentArea, $order->itemId, $order->userId, (float) $order->amount->toDecimal(), $order->currency, GatewayConfig::GATEWAY, ); if (!($this->deliverOrder)( $order->component, $order->paymentArea, $order->itemId, $paymentId, $order->userId, )) { throw new \RuntimeException('Доставка заказа вернула false.'); } $paid = $this->repository->markPaid($order, $notification->getOperationId(), $paymentId); $transaction->allow_commit(); return $paid; } catch (\Throwable $exception) { try { // rollback() перебрасывает исключение — гасим его здесь, причина уходит в lasterror. $transaction->rollback($exception); } catch (\Throwable) { // Откат выполнен, исключение уже обработано. } $this->repository->markFailed($order, RejectionReason::Delivery); $this->logger->error('Доставка заказа не удалась: ' . get_class($exception)); } return null; } private function paid( CallbackNotification $notification, Order $order, GatewayConfig $config, ResultCode $code, ?Receipt $receipt, ): CallbackResponse { $payResponse = new PayResponse( accountId: $config->accountNumber, transactionId: $notification->getTransactionId(), amount: $order->amount, resultCode: $code, signature: $config->signature(), cms: CmsInfo::getCmsModuleVersion(), receipt: $receipt, ); return $payResponse->toXml(); } }