feat: paygw_moneta 1.0.0, MONETA.Assistant payment gateway for Moodle
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\callback;
|
||||
|
||||
use core\lock\lock_config;
|
||||
use core_payment\helper;
|
||||
use paygw_moneta\local\BuyerNotifier;
|
||||
use paygw_moneta\local\CmsInfo;
|
||||
use paygw_moneta\local\ErrorLogLogger;
|
||||
use paygw_moneta\local\GatewayConfig;
|
||||
use paygw_moneta\local\GatewayConfigRepository;
|
||||
use paygw_moneta\local\Logger;
|
||||
use paygw_moneta\local\Money;
|
||||
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\protocol\AssistantTransactionId;
|
||||
use paygw_moneta\local\protocol\CallbackNotification;
|
||||
use paygw_moneta\local\protocol\CallbackResponse;
|
||||
use paygw_moneta\local\protocol\CheckResponse;
|
||||
use paygw_moneta\local\protocol\ResultCode;
|
||||
|
||||
/**
|
||||
* @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 CallbackHandler
|
||||
{
|
||||
private const LOCK_TIMEOUT = 10;
|
||||
|
||||
private readonly NotificationHandler $check;
|
||||
private readonly NotificationHandler $pay;
|
||||
|
||||
/**
|
||||
* @param \Closure(string, string, int, int, int): bool|null $deliverOrder
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly TransactionRepository $repository = new TransactionRepository(),
|
||||
?\Closure $deliverOrder = null,
|
||||
BuyerNotifier $notifier = new BuyerNotifier(),
|
||||
private readonly Logger $logger = new ErrorLogLogger(),
|
||||
private readonly GatewayConfigRepository $configs = new GatewayConfigRepository(),
|
||||
CurrentPrice $currentPrice = new CurrentPrice(),
|
||||
) {
|
||||
$this->check = new CheckHandler($repository);
|
||||
$this->pay = new PayHandler(
|
||||
repository: $repository,
|
||||
deliverOrder: $deliverOrder ?? helper::deliver_order(...),
|
||||
notifier: $notifier,
|
||||
currentPrice: $currentPrice,
|
||||
logger: $logger,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fields
|
||||
*/
|
||||
public function handle(array $fields): CallbackResponse
|
||||
{
|
||||
try {
|
||||
$notification = new CallbackNotification($fields);
|
||||
} catch (\InvalidArgumentException) {
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
$merchantOrderId = AssistantTransactionId::parse($notification->getTransactionId());
|
||||
if ($merchantOrderId === null) {
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
$order = $this->repository->findByMerchantOrderId($merchantOrderId);
|
||||
if ($order === null) {
|
||||
return $this->unknownOrder($notification);
|
||||
}
|
||||
|
||||
$config = $this->configs->forAccount($order->accountId);
|
||||
if ($config === null || !$config->isComplete()) {
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
if (
|
||||
$notification->getAccountId() !== $config->accountNumber
|
||||
|| $notification->getAccountId() !== $order->snapshot->accountNumber
|
||||
|| !$notification->isSignedBy($config->signature())
|
||||
) {
|
||||
$this->logger->error('Уведомление отклонено: подпись или номер счёта не совпадают.');
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
$lock = lock_config::get_lock_factory('paygw_moneta')->get_lock($order->merchantOrderId, self::LOCK_TIMEOUT);
|
||||
if (!$lock) {
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
try {
|
||||
$order = $this->repository->findById($order->id) ?? $order;
|
||||
|
||||
return $this->handleSigned($notification, $order, $config);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function handleSigned(CallbackNotification $notification, Order $order, GatewayConfig $config): CallbackResponse
|
||||
{
|
||||
$error = $this->validate($notification, $order);
|
||||
if ($error !== null) {
|
||||
$this->repository->noteError($order, $error);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
$handler = $notification->isCheck() ? $this->check : $this->pay;
|
||||
|
||||
return $handler->handle($notification, $order, $config);
|
||||
}
|
||||
|
||||
private function validate(CallbackNotification $notification, Order $order): ?RejectionReason
|
||||
{
|
||||
if ($notification->getCurrency() !== $order->currency) {
|
||||
return RejectionReason::Currency;
|
||||
}
|
||||
|
||||
if ($notification->isTestMode() !== $order->snapshot->testMode) {
|
||||
return RejectionReason::TestMode;
|
||||
}
|
||||
|
||||
if ($notification->getSubscriberId() !== '' && $notification->getSubscriberId() !== $order->subscriberId()) {
|
||||
return RejectionReason::Subscriber;
|
||||
}
|
||||
|
||||
$amount = $notification->getAmount();
|
||||
if ($amount !== null && !$amount->equals($order->amount)) {
|
||||
return RejectionReason::Amount;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function unknownOrder(CallbackNotification $notification): CallbackResponse
|
||||
{
|
||||
$config = $this->configs->findByAccountNumber($notification->getAccountId());
|
||||
if ($config === null || !$notification->isSignedBy($config->signature())) {
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
if (!$notification->isCheck()) {
|
||||
$this->logger->error(
|
||||
sprintf(
|
||||
'Оплачено уведомление по неизвестному заказу %s (операция %s).',
|
||||
$notification->getTransactionId(),
|
||||
$notification->getOperationId(),
|
||||
),
|
||||
);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
$response = new CheckResponse(
|
||||
accountId: $config->accountNumber,
|
||||
transactionId: $notification->getTransactionId(),
|
||||
amount: $notification->getAmount() ?? Money::fromMinorUnits(0),
|
||||
resultCode: ResultCode::Rejected,
|
||||
signature: $config->signature(),
|
||||
cms: CmsInfo::getCmsModuleVersion(),
|
||||
);
|
||||
|
||||
return $response->toXml();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\callback;
|
||||
|
||||
use paygw_moneta\local\CmsInfo;
|
||||
use paygw_moneta\local\GatewayConfig;
|
||||
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\CheckResponse;
|
||||
use paygw_moneta\local\protocol\ResultCode;
|
||||
|
||||
/**
|
||||
* @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 CheckHandler implements NotificationHandler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TransactionRepository $repository,
|
||||
) {}
|
||||
|
||||
public function handle(CallbackNotification $notification, Order $order, GatewayConfig $config): CallbackResponse
|
||||
{
|
||||
if ($order->status->isOpen() && !$config->enabled) {
|
||||
$order = $this->repository->markCanceled($order, RejectionReason::GatewayDisabled);
|
||||
}
|
||||
|
||||
$code = match ($order->status) {
|
||||
TransactionStatus::Paid => ResultCode::Paid,
|
||||
// Failed — оплата у сервиса прошла, не удалась доставка: новую оплату
|
||||
// по этому заказу не начинаем, повтор Pay URL её доставит.
|
||||
TransactionStatus::Canceled, TransactionStatus::Failed => ResultCode::Rejected,
|
||||
TransactionStatus::New, TransactionStatus::Pending => $notification->getAmount() === null
|
||||
? ResultCode::WithAmount
|
||||
: ResultCode::AwaitingPayment,
|
||||
};
|
||||
|
||||
if ($order->status === TransactionStatus::New) {
|
||||
$order = $this->repository->markPending($order);
|
||||
}
|
||||
|
||||
$response = new CheckResponse(
|
||||
accountId: $config->accountNumber,
|
||||
transactionId: $notification->getTransactionId(),
|
||||
amount: $order->amount,
|
||||
resultCode: $code,
|
||||
signature: $config->signature(),
|
||||
cms: CmsInfo::getCmsModuleVersion(),
|
||||
);
|
||||
|
||||
if (!$order->snapshot->fiscalization) {
|
||||
return $response->toXml();
|
||||
}
|
||||
|
||||
try {
|
||||
return $response->toJson($order->snapshot->receipt);
|
||||
} catch (\InvalidArgumentException) {
|
||||
$this->repository->noteError($order, RejectionReason::Receipt);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\callback;
|
||||
|
||||
use paygw_moneta\local\GatewayConfig;
|
||||
use paygw_moneta\local\order\Order;
|
||||
use paygw_moneta\local\protocol\CallbackNotification;
|
||||
use paygw_moneta\local\protocol\CallbackResponse;
|
||||
|
||||
/**
|
||||
* @package paygw_moneta
|
||||
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
interface NotificationHandler
|
||||
{
|
||||
public function handle(CallbackNotification $notification, Order $order, GatewayConfig $config): CallbackResponse;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user