254 lines
8.9 KiB
PHP
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);
|
|
}
|
|
}
|