Files
Moodle/classes/local/order/TransactionRepository.php
T

254 lines
8.9 KiB
PHP

<?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);
}
}