feat: paygw_moneta 1.0.0, MONETA.Assistant payment gateway for Moodle
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\order;
|
||||
|
||||
use core_payment\helper;
|
||||
use paygw_moneta\local\ErrorLogLogger;
|
||||
use paygw_moneta\local\GatewayConfig;
|
||||
use paygw_moneta\local\Logger;
|
||||
use paygw_moneta\local\Money;
|
||||
|
||||
/**
|
||||
* Текущая цена оплачиваемого объекта (с наценкой шлюза) против суммы заказа:
|
||||
* Pay URL и ссылка на оплату не должны давать дожать заказ, если цена
|
||||
* изменилась после его создания.
|
||||
*
|
||||
* @package paygw_moneta
|
||||
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class CurrentPrice
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Logger $logger = new ErrorLogLogger(),
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Сумма заказа равна текущей стоимости объекта. Объект мог исчезнуть —
|
||||
* тогда и оплачивать нечего.
|
||||
*/
|
||||
public function matches(Order $order): bool
|
||||
{
|
||||
try {
|
||||
$payable = helper::get_payable($order->component, $order->paymentArea, $order->itemId);
|
||||
$current = Money::fromFloat(
|
||||
helper::get_rounded_cost(
|
||||
$payable->get_amount(),
|
||||
$payable->get_currency(),
|
||||
helper::get_gateway_surcharge(GatewayConfig::GATEWAY),
|
||||
),
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logger->error('Оплачиваемый объект недоступен: ' . get_class($exception));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $payable->get_currency() === $order->currency && $current->equals($order->amount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\order;
|
||||
|
||||
use paygw_moneta\local\Money;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* Локальный заказ — строка `paygw_moneta_transactions` в типизированном виде.
|
||||
* Создаётся до ухода покупателя на платёжную форму и живёт до Pay URL;
|
||||
* у ядра Moodle записи о платеже до его подтверждения нет.
|
||||
*
|
||||
* Объект неизменяемый; переходы статусов делает {@see TransactionRepository}.
|
||||
*
|
||||
* @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 Order
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $id,
|
||||
public readonly int $accountId,
|
||||
public readonly int $userId,
|
||||
public readonly string $component,
|
||||
public readonly string $paymentArea,
|
||||
public readonly int $itemId,
|
||||
public readonly string $merchantOrderId,
|
||||
public readonly ?string $providerTransactionId,
|
||||
public readonly ?int $paymentId,
|
||||
public readonly Money $amount,
|
||||
public readonly string $currency,
|
||||
public readonly TransactionStatus $status,
|
||||
public readonly OrderSnapshot $snapshot,
|
||||
public readonly ?RejectionReason $lastError,
|
||||
public readonly int $timeCreated,
|
||||
public readonly int $timeModified,
|
||||
public readonly ?int $timeCompleted,
|
||||
) {}
|
||||
|
||||
public static function fromRecord(stdClass $record): self
|
||||
{
|
||||
return new self(
|
||||
(int) $record->id,
|
||||
(int) $record->accountid,
|
||||
(int) $record->userid,
|
||||
(string) $record->component,
|
||||
(string) $record->paymentarea,
|
||||
(int) $record->itemid,
|
||||
(string) $record->merchantorderid,
|
||||
$record->providertransactionid === null ? null : (string) $record->providertransactionid,
|
||||
$record->paymentid === null ? null : (int) $record->paymentid,
|
||||
// Единственное место, где сумма приходит из БД числом (NUMBER(20,5)).
|
||||
Money::fromFloat((float) $record->amount),
|
||||
(string) $record->currency,
|
||||
TransactionStatus::from((string) $record->status),
|
||||
OrderSnapshot::fromJson((string) $record->snapshot),
|
||||
$record->lasterror === null ? null : RejectionReason::tryFrom((string) $record->lasterror),
|
||||
(int) $record->timecreated,
|
||||
(int) $record->timemodified,
|
||||
$record->timecompleted === null ? null : (int) $record->timecompleted,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `MNT_SUBSCRIBER_ID`, с которым заказ ушёл на платёжную форму.
|
||||
*/
|
||||
public function subscriberId(): string
|
||||
{
|
||||
return $this->snapshot->subscriberId;
|
||||
}
|
||||
|
||||
public function isPaid(): bool
|
||||
{
|
||||
return $this->status === TransactionStatus::Paid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\order;
|
||||
|
||||
use core_payment\helper;
|
||||
use moodle_exception;
|
||||
use paygw_moneta\local\GatewayConfig;
|
||||
use paygw_moneta\local\Money;
|
||||
use paygw_moneta\local\protocol\AssistantProtocol;
|
||||
use paygw_moneta\local\receipt\Client;
|
||||
use paygw_moneta\local\receipt\Receipt;
|
||||
use paygw_moneta\local\receipt\ReceiptText;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* Создание заказа для оплачиваемого объекта Moodle: сумма от ядра, снимок
|
||||
* настроек шлюза, чек с одной позицией, покупатель из профиля.
|
||||
*
|
||||
* @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 OrderFactory
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TransactionRepository $repository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param stdClass $user покупатель (`$USER`): id, email, phone1, phone2, ФИО
|
||||
* @param GatewayConfig $config настройки шлюза для этого объекта (`GatewayConfigRepository::forPayable`)
|
||||
* @param bool|null $created true — заказ создан сейчас, false — переиспользован открытый
|
||||
*/
|
||||
public function start(
|
||||
string $component,
|
||||
string $paymentArea,
|
||||
int $itemId,
|
||||
stdClass $user,
|
||||
GatewayConfig $config,
|
||||
?bool &$created = null,
|
||||
): Order {
|
||||
if (!in_array(
|
||||
GatewayConfig::GATEWAY,
|
||||
helper::get_available_gateways($component, $paymentArea, $itemId),
|
||||
true,
|
||||
)) {
|
||||
throw new moodle_exception('gatewaynotfound', 'payment');
|
||||
}
|
||||
|
||||
if (!$config->acceptsNewOrders()) {
|
||||
throw new moodle_exception('gatewaynotfound', 'payment');
|
||||
}
|
||||
|
||||
// Оплата у сервиса прошла, доставка сорвалась: новый заказ создавать нельзя,
|
||||
// иначе покупатель заплатит дважды, а повтор уведомления по старому доставит его.
|
||||
if ($this->repository->findFailedForItem((int) $user->id, $component, $paymentArea, $itemId) !== null) {
|
||||
throw new moodle_exception('error:orderinprogress', 'paygw_moneta');
|
||||
}
|
||||
|
||||
$payable = helper::get_payable($component, $paymentArea, $itemId);
|
||||
$currency = $payable->get_currency();
|
||||
if ($currency !== AssistantProtocol::CURRENCY) {
|
||||
throw new moodle_exception('error:unsupportedcurrency', 'paygw_moneta');
|
||||
}
|
||||
$surcharge = helper::get_gateway_surcharge(GatewayConfig::GATEWAY);
|
||||
$amount = Money::fromFloat(helper::get_rounded_cost($payable->get_amount(), $currency, $surcharge));
|
||||
if (!$amount->isPositive()) {
|
||||
throw new moodle_exception('error:invalidamount', 'paygw_moneta');
|
||||
}
|
||||
|
||||
$itemName = self::itemName($component, $paymentArea, $itemId);
|
||||
|
||||
$client = Client::fromContacts(
|
||||
email: (string) ($user->email ?? ''),
|
||||
phone: self::phone($user),
|
||||
name: self::fullName($user),
|
||||
fallbackEmail: $config->receiptEmail,
|
||||
) ?? throw new moodle_exception('gatewaynotfound', 'payment');
|
||||
|
||||
$snapshot = new OrderSnapshot(
|
||||
accountNumber: $config->accountNumber,
|
||||
testMode: $config->testMode,
|
||||
fiscalization: $config->fiscalization,
|
||||
vat: $config->vat,
|
||||
receipt: Receipt::forCourse($itemName, $amount, $client, $config->vat),
|
||||
description: $itemName,
|
||||
subscriberId: self::subscriberId($user),
|
||||
);
|
||||
|
||||
return $this->repository->obtain(
|
||||
accountId: $payable->get_account_id(),
|
||||
userId: (int) $user->id,
|
||||
component: $component,
|
||||
paymentArea: $paymentArea,
|
||||
itemId: $itemId,
|
||||
amount: $amount,
|
||||
currency: $currency,
|
||||
snapshot: $snapshot,
|
||||
created: $created,
|
||||
);
|
||||
}
|
||||
|
||||
private static function itemName(string $component, string $paymentArea, int $itemId): string
|
||||
{
|
||||
global $DB;
|
||||
|
||||
if ($component === 'enrol_fee' && $paymentArea === 'fee') {
|
||||
$courseId = $DB->get_field('enrol', 'courseid', ['id' => $itemId, 'enrol' => 'fee']);
|
||||
$name = $courseId ? $DB->get_field('course', 'fullname', ['id' => $courseId]) : false;
|
||||
if (is_string($name) && ReceiptText::sanitize($name) !== '') {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
return get_string('genericitem', 'paygw_moneta');
|
||||
}
|
||||
|
||||
public static function subscriberId(stdClass $user): string
|
||||
{
|
||||
$email = trim((string) ($user->email ?? ''));
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) !== false) {
|
||||
return $email;
|
||||
}
|
||||
|
||||
return Client::normalizePhone(self::phone($user)) ?? (string) $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Первый заполненный телефон профиля: рабочий, затем мобильный.
|
||||
*/
|
||||
private static function phone(stdClass $user): string
|
||||
{
|
||||
$phone = trim((string) ($user->phone1 ?? ''));
|
||||
|
||||
return $phone !== '' ? $phone : trim((string) ($user->phone2 ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* ФИО для чека в порядке «Фамилия Имя Отчество» — как `name` объекта
|
||||
* `client` в `kassaspecification.pdf`, независимо от настройки отображения имён.
|
||||
*/
|
||||
private static function fullName(stdClass $user): string
|
||||
{
|
||||
$parts = array_filter(
|
||||
array_map(
|
||||
static fn(string $field): string => trim((string) ($user->$field ?? '')),
|
||||
['lastname', 'firstname', 'middlename'],
|
||||
),
|
||||
static fn(string $part): bool => $part !== '',
|
||||
);
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\order;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use paygw_moneta\local\receipt\Receipt;
|
||||
use paygw_moneta\local\receipt\Vat;
|
||||
|
||||
/**
|
||||
* Снимок настроек счёта и данных чека на момент создания заказа.
|
||||
*
|
||||
* Колбэки сверяют уведомление с тем, что реально было запрошено (сумма, режим,
|
||||
* данные чека, `MNT_SUBSCRIBER_ID`), а не с текущей ценой курса и профилем. Номер счёта в снимке —
|
||||
* дополнительная защита: он обязан совпадать и с текущей настройкой, потому что
|
||||
* ключ подписи всегда берётся из неё; заказы, созданные до смены счёта,
|
||||
* оплатить нельзя. Секрет (код проверки целостности) в снимок не попадает.
|
||||
*
|
||||
* @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 OrderSnapshot
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $accountNumber,
|
||||
public readonly bool $testMode,
|
||||
public readonly bool $fiscalization,
|
||||
public readonly Vat $vat,
|
||||
public readonly Receipt $receipt,
|
||||
public readonly string $description,
|
||||
public readonly string $subscriberId,
|
||||
) {
|
||||
if ($accountNumber === '') {
|
||||
throw new InvalidArgumentException('В снимке заказа нет номера счёта.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'accountnumber' => $this->accountNumber,
|
||||
'testmode' => $this->testMode,
|
||||
'fiscalization' => $this->fiscalization,
|
||||
'vat' => $this->vat->value,
|
||||
'receipt' => $this->receipt->toArray(),
|
||||
'description' => $this->description,
|
||||
'subscriberid' => $this->subscriberId,
|
||||
];
|
||||
}
|
||||
|
||||
public function toJson(): string
|
||||
{
|
||||
return json_encode($this->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
accountNumber: (string) ($data['accountnumber'] ?? ''),
|
||||
testMode: (bool) ($data['testmode'] ?? false),
|
||||
fiscalization: (bool) ($data['fiscalization'] ?? false),
|
||||
vat: Vat::from((string) ($data['vat'] ?? '')),
|
||||
receipt: Receipt::fromArray(is_array($data['receipt'] ?? null) ? $data['receipt'] : []),
|
||||
description: (string) ($data['description'] ?? ''),
|
||||
subscriberId: (string) ($data['subscriberid'] ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
public static function fromJson(string $json): self
|
||||
{
|
||||
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($data)) {
|
||||
throw new InvalidArgumentException('Снимок заказа повреждён.');
|
||||
}
|
||||
|
||||
return self::fromArray($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Совпадение реквизитов и чека; описание не сравнивается — оно не влияет
|
||||
* на оплату и не должно плодить заказы.
|
||||
*/
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
$mine = $this->toArray();
|
||||
$theirs = $other->toArray();
|
||||
unset($mine['description'], $theirs['description']);
|
||||
|
||||
return $mine === $theirs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\order;
|
||||
|
||||
/**
|
||||
* Причина отказа по подписанному уведомлению — пишется в `lasterror` заказа и
|
||||
* показывается администратору в отчёте.
|
||||
*
|
||||
* @package paygw_moneta
|
||||
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
enum RejectionReason: string
|
||||
{
|
||||
/** Сумма уведомления не совпала с суммой заказа. */
|
||||
case Amount = 'AMOUNT';
|
||||
/** Цена оплачиваемого объекта изменилась после создания заказа. */
|
||||
case AmountChanged = 'AMOUNT_CHANGED';
|
||||
case Currency = 'CURRENCY';
|
||||
/** Тестовый режим уведомления не совпал со снимком или с текущими настройками. */
|
||||
case TestMode = 'TEST_MODE';
|
||||
case Subscriber = 'SUBSCRIBER';
|
||||
/** Вторая операция по уже оплаченному заказу: деньги списаны дважды. */
|
||||
case DuplicateOperation = 'DUPLICATE_OPERATION';
|
||||
/** Оплата подтверждена, но зачисление не удалось. */
|
||||
case Delivery = 'DELIVERY';
|
||||
/** Чек из снимка не собрался в JSON-ответ Check URL. */
|
||||
case Receipt = 'RECEIPT';
|
||||
case GatewayDisabled = 'GATEWAY_DISABLED';
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\order;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use paygw_moneta\local\Money;
|
||||
use paygw_moneta\local\protocol\AssistantProtocol;
|
||||
use paygw_moneta\local\protocol\AssistantTransactionId;
|
||||
|
||||
/**
|
||||
* Хранилище заказов. Единственное место, где меняется `status`: хендлеры
|
||||
* зовут именованные переходы, а не пишут поля напрямую.
|
||||
*
|
||||
* @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 TransactionRepository
|
||||
{
|
||||
public const TABLE = 'paygw_moneta_transactions';
|
||||
|
||||
/**
|
||||
* Свой номер заказа — UUID в нижнем регистре ({@see self::create()}); в
|
||||
* `MNT_TRANSACTION_ID` он уходит с меткой времени ({@see AssistantTransactionId}).
|
||||
*/
|
||||
public static function isMerchantOrderId(string $value): bool
|
||||
{
|
||||
return preg_match('/\A' . AssistantTransactionId::ORDER_ID . '\z/', $value) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит открытый заказ покупателя по тому же товару с той же суммой и
|
||||
* настройками либо создаёт новый. Повторное нажатие «Оплатить» не должно
|
||||
* плодить заказы: у сервиса на каждый `MNT_TRANSACTION_ID` своя операция.
|
||||
*
|
||||
* @param bool|null $created true — заказ создан сейчас, false — переиспользован
|
||||
*/
|
||||
public function obtain(
|
||||
int $accountId,
|
||||
int $userId,
|
||||
string $component,
|
||||
string $paymentArea,
|
||||
int $itemId,
|
||||
Money $amount,
|
||||
string $currency,
|
||||
OrderSnapshot $snapshot,
|
||||
?bool &$created = null,
|
||||
): Order {
|
||||
$open = $this->findOpenForItem($userId, $component, $paymentArea, $itemId);
|
||||
$created = false;
|
||||
if ($open !== null
|
||||
&& $open->accountId === $accountId
|
||||
&& $open->amount->equals($amount)
|
||||
&& $open->snapshot->equals($snapshot)
|
||||
) {
|
||||
return $open;
|
||||
}
|
||||
$created = true;
|
||||
|
||||
return $this->create($accountId, $userId, $component, $paymentArea, $itemId, $amount, $currency, $snapshot);
|
||||
}
|
||||
|
||||
public function create(
|
||||
int $accountId,
|
||||
int $userId,
|
||||
string $component,
|
||||
string $paymentArea,
|
||||
int $itemId,
|
||||
Money $amount,
|
||||
string $currency,
|
||||
OrderSnapshot $snapshot,
|
||||
): Order {
|
||||
global $DB;
|
||||
|
||||
if (strtoupper($currency) !== AssistantProtocol::CURRENCY) {
|
||||
throw new InvalidArgumentException(get_string('error:unsupportedcurrency', 'paygw_moneta'));
|
||||
}
|
||||
if (!$amount->isPositive()) {
|
||||
throw new InvalidArgumentException(get_string('error:invalidamount', 'paygw_moneta'));
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$record = (object) [
|
||||
'paymentid' => null,
|
||||
'accountid' => $accountId,
|
||||
'userid' => $userId,
|
||||
'component' => $component,
|
||||
'paymentarea' => $paymentArea,
|
||||
'itemid' => $itemId,
|
||||
'merchantorderid' => \core\uuid::generate(),
|
||||
'providertransactionid' => null,
|
||||
'amount' => $amount->toDecimal(),
|
||||
'currency' => AssistantProtocol::CURRENCY,
|
||||
'status' => TransactionStatus::New->value,
|
||||
'lasterror' => null,
|
||||
'snapshot' => $snapshot->toJson(),
|
||||
'timecreated' => $now,
|
||||
'timemodified' => $now,
|
||||
'timecompleted' => null,
|
||||
];
|
||||
$record->id = $DB->insert_record(self::TABLE, $record);
|
||||
|
||||
return Order::fromRecord($record);
|
||||
}
|
||||
|
||||
public function findByMerchantOrderId(string $merchantOrderId): ?Order
|
||||
{
|
||||
global $DB;
|
||||
|
||||
$record = $DB->get_record(self::TABLE, ['merchantorderid' => $merchantOrderId]);
|
||||
|
||||
return $record === false ? null : Order::fromRecord($record);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Order
|
||||
{
|
||||
global $DB;
|
||||
|
||||
$record = $DB->get_record(self::TABLE, ['id' => $id]);
|
||||
|
||||
return $record === false ? null : Order::fromRecord($record);
|
||||
}
|
||||
|
||||
public function findOpenForItem(int $userId, string $component, string $paymentArea, int $itemId): ?Order
|
||||
{
|
||||
global $DB;
|
||||
|
||||
[$statusSql, $params] = $DB->get_in_or_equal(
|
||||
[TransactionStatus::New->value, TransactionStatus::Pending->value],
|
||||
SQL_PARAMS_NAMED,
|
||||
'status',
|
||||
);
|
||||
$records = $DB->get_records_select(
|
||||
self::TABLE,
|
||||
"userid = :userid AND component = :component AND paymentarea = :paymentarea AND itemid = :itemid AND status {$statusSql}",
|
||||
$params + [
|
||||
'userid' => $userId,
|
||||
'component' => $component,
|
||||
'paymentarea' => $paymentArea,
|
||||
'itemid' => $itemId,
|
||||
],
|
||||
'timecreated DESC, id DESC',
|
||||
'*',
|
||||
0,
|
||||
1,
|
||||
);
|
||||
$record = reset($records);
|
||||
|
||||
return $record === false ? null : Order::fromRecord($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Заказ покупателя по товару, у которого оплата прошла, а доставка сорвалась:
|
||||
* новую оплату начинать нельзя — повтор уведомления доставит этот.
|
||||
*/
|
||||
public function findFailedForItem(int $userId, string $component, string $paymentArea, int $itemId): ?Order
|
||||
{
|
||||
global $DB;
|
||||
|
||||
$records = $DB->get_records(
|
||||
self::TABLE,
|
||||
[
|
||||
'userid' => $userId,
|
||||
'component' => $component,
|
||||
'paymentarea' => $paymentArea,
|
||||
'itemid' => $itemId,
|
||||
'status' => TransactionStatus::Failed->value,
|
||||
],
|
||||
'timecreated DESC, id DESC',
|
||||
'*',
|
||||
0,
|
||||
1,
|
||||
);
|
||||
$record = reset($records);
|
||||
|
||||
return $record === false ? null : Order::fromRecord($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сервис начал оплату: пришёл проверочный запрос.
|
||||
*/
|
||||
public function markPending(Order $order): Order
|
||||
{
|
||||
if ($order->status !== TransactionStatus::New) {
|
||||
return $order;
|
||||
}
|
||||
|
||||
return $this->update($order, ['status' => TransactionStatus::Pending->value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Оплата подтверждена и доставлена: фиксируем операцию сервиса и платёж ядра.
|
||||
*/
|
||||
public function markPaid(Order $order, string $providerTransactionId, int $paymentId): Order
|
||||
{
|
||||
if (!$order->status->isPayable()) {
|
||||
throw new InvalidArgumentException('Оплатить можно только неоплаченный и не отменённый заказ.');
|
||||
}
|
||||
|
||||
return $this->update($order, [
|
||||
'status' => TransactionStatus::Paid->value,
|
||||
'providertransactionid' => $providerTransactionId,
|
||||
'paymentid' => $paymentId,
|
||||
'lasterror' => null,
|
||||
'timecompleted' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markCanceled(Order $order, RejectionReason $reason): Order
|
||||
{
|
||||
if (!$order->status->isOpen()) {
|
||||
throw new InvalidArgumentException('Отменить можно только открытый заказ.');
|
||||
}
|
||||
|
||||
return $this->update($order, ['status' => TransactionStatus::Canceled->value, 'lasterror' => $reason->value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Зачисление сорвалось после подтверждённой оплаты — заказ требует внимания администратора.
|
||||
*/
|
||||
public function markFailed(Order $order, RejectionReason $reason): Order
|
||||
{
|
||||
if ($order->status === TransactionStatus::Paid) {
|
||||
throw new InvalidArgumentException('Оплаченный заказ нельзя пометить ошибочным.');
|
||||
}
|
||||
|
||||
return $this->update($order, ['status' => TransactionStatus::Failed->value, 'lasterror' => $reason->value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Код причины последнего отказа без смены статуса (для отчёта администратора).
|
||||
*/
|
||||
public function noteError(Order $order, RejectionReason $reason): Order
|
||||
{
|
||||
return $this->update($order, ['lasterror' => $reason->value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fields
|
||||
*/
|
||||
private function update(Order $order, array $fields): Order
|
||||
{
|
||||
global $DB;
|
||||
|
||||
$record = (object) ($fields + ['id' => $order->id, 'timemodified' => time()]);
|
||||
$DB->update_record(self::TABLE, $record);
|
||||
$fresh = $DB->get_record(self::TABLE, ['id' => $order->id], '*', MUST_EXIST);
|
||||
|
||||
return Order::fromRecord($fresh);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\order;
|
||||
|
||||
/**
|
||||
* Статус локального заказа.
|
||||
*
|
||||
* @package paygw_moneta
|
||||
* @copyright 2026 Moneta Labs {@link https://moneta.ru/}
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
enum TransactionStatus: string
|
||||
{
|
||||
/** Заказ создан, покупатель отправлен на платёжную форму. */
|
||||
case New = 'new';
|
||||
|
||||
/** Сервис прислал проверочный запрос — оплата идёт. */
|
||||
case Pending = 'pending';
|
||||
|
||||
/** Оплата подтверждена Pay URL, доступ выдан. */
|
||||
case Paid = 'paid';
|
||||
|
||||
/** Заказ отменён на нашей стороне; сервису отвечаем 500. */
|
||||
case Canceled = 'canceled';
|
||||
|
||||
/** Подтверждение оплаты не удалось довести до конца (ошибка зачисления). */
|
||||
case Failed = 'failed';
|
||||
|
||||
/** Заказ ещё не ушёл в оплату: его переиспользует повторное нажатие «Оплатить». */
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return $this === self::New || $this === self::Pending;
|
||||
}
|
||||
|
||||
/** Уведомление об оплате по такому заказу зачисляется (в том числе повтор после сбоя доставки). */
|
||||
public function isPayable(): bool
|
||||
{
|
||||
return $this->isOpen() || $this === self::Failed;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user