154 lines
6.2 KiB
PHP
154 lines
6.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace paygw_moneta\local\callback;
|
|
|
|
use core_payment\helper;
|
|
use paygw_moneta\local\BuyerNotifier;
|
|
use paygw_moneta\local\CmsInfo;
|
|
use paygw_moneta\local\GatewayConfig;
|
|
use paygw_moneta\local\Logger;
|
|
use paygw_moneta\local\order\CurrentPrice;
|
|
use paygw_moneta\local\order\Order;
|
|
use paygw_moneta\local\order\RejectionReason;
|
|
use paygw_moneta\local\order\TransactionRepository;
|
|
use paygw_moneta\local\order\TransactionStatus;
|
|
use paygw_moneta\local\protocol\CallbackNotification;
|
|
use paygw_moneta\local\protocol\CallbackResponse;
|
|
use paygw_moneta\local\protocol\PayResponse;
|
|
use paygw_moneta\local\protocol\ResultCode;
|
|
use paygw_moneta\local\receipt\Receipt;
|
|
|
|
/**
|
|
* @package paygw_moneta
|
|
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
|
|
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
|
*/
|
|
final class PayHandler implements NotificationHandler
|
|
{
|
|
/**
|
|
* @param \Closure(string, string, int, int, int): bool $deliverOrder
|
|
*/
|
|
public function __construct(
|
|
private readonly TransactionRepository $repository,
|
|
private readonly \Closure $deliverOrder,
|
|
private readonly BuyerNotifier $notifier,
|
|
private readonly CurrentPrice $currentPrice,
|
|
private readonly Logger $logger,
|
|
) {}
|
|
|
|
public function handle(CallbackNotification $notification, Order $order, GatewayConfig $config): CallbackResponse
|
|
{
|
|
if ($order->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();
|
|
}
|
|
}
|