feat: paygw_moneta 1.0.0, MONETA.Assistant payment gateway for Moodle
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local;
|
||||
|
||||
use core\message\message;
|
||||
use core_payment\helper;
|
||||
use core_user;
|
||||
use paygw_moneta\local\order\Order;
|
||||
|
||||
/**
|
||||
* Сообщения покупателю через систему сообщений Moodle (`db/messages.php`):
|
||||
* «оплата принята» (`payment_received`) и «ссылка на оплату» (`payment_link`).
|
||||
* Плагин не выбирает канал и не шлёт почту сам: веб-уведомление и/или письмо
|
||||
* через исходящую почту сайта — по настройкам уведомлений пользователя.
|
||||
*
|
||||
* @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 BuyerNotifier
|
||||
{
|
||||
public const PROVIDER = 'payment_received';
|
||||
public const PROVIDER_LINK = 'payment_link';
|
||||
|
||||
public function __construct(
|
||||
private readonly Logger $logger = new ErrorLogLogger(),
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Сообщение по оплаченному заказу; сбой отправки не должен ломать ответ на
|
||||
* Pay URL, поэтому ошибки только логируются.
|
||||
*
|
||||
* @return int|false id сообщения либо false, если отправка не состоялась
|
||||
*/
|
||||
public function notifyPaid(Order $order): int|false
|
||||
{
|
||||
return $this->send(
|
||||
order: $order,
|
||||
provider: self::PROVIDER,
|
||||
stringKey: 'message:paid',
|
||||
extra: static fn(Order $order): array => [
|
||||
'operationid' => (string) $order->providerTransactionId,
|
||||
'url' => helper::get_success_url($order->component, $order->paymentArea, $order->itemId)->out(false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ссылка на оплату только что созданного заказа (настройка шлюза «Отправлять
|
||||
* ссылку оплаты на почту»): покупатель может оплатить позже, со страницы
|
||||
* `checkout.php`. Сбой отправки не мешает перейти к оплате сейчас.
|
||||
*
|
||||
* @return int|false id сообщения либо false, если отправка не состоялась
|
||||
*/
|
||||
public function notifyPaymentLink(Order $order): int|false
|
||||
{
|
||||
return $this->send(
|
||||
order: $order,
|
||||
provider: self::PROVIDER_LINK,
|
||||
stringKey: 'message:link',
|
||||
extra: static fn(Order $order): array => [
|
||||
'url' => PaymentFormPage::resumeUrl($order)->out(false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(Order): array<string, string> $extra поля строки сообщения сверх общих; `url` обязателен
|
||||
*/
|
||||
private function send(Order $order, string $provider, string $stringKey, callable $extra): int|false
|
||||
{
|
||||
try {
|
||||
$user = core_user::get_user($order->userId);
|
||||
if ($user === false || !empty($user->deleted) || isguestuser($user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Значения для подстановок {$a->…} в строках message:* (имя $a задаёт get_string()).
|
||||
$placeholders = (object) ([
|
||||
'firstname' => $user->firstname,
|
||||
'fullname' => fullname($user),
|
||||
'description' => $order->snapshot->description,
|
||||
'amount' => helper::get_cost_as_string((float) $order->amount->toDecimal(), $order->currency),
|
||||
'sitename' => format_string(get_site()->fullname),
|
||||
] + $extra($order));
|
||||
$body = get_string($stringKey, 'paygw_moneta', $placeholders);
|
||||
|
||||
$bodyForHtml = get_string(
|
||||
$stringKey,
|
||||
'paygw_moneta',
|
||||
(object) (['url' => '<' . $placeholders->url . '>'] + (array) $placeholders),
|
||||
);
|
||||
|
||||
$message = new message();
|
||||
$message->component = 'paygw_moneta';
|
||||
$message->name = $provider;
|
||||
$message->userfrom = core_user::get_noreply_user();
|
||||
$message->userto = $user;
|
||||
$message->subject = get_string($stringKey . ':subject', 'paygw_moneta', $placeholders);
|
||||
$message->fullmessage = $body;
|
||||
$message->fullmessageformat = FORMAT_MARKDOWN;
|
||||
$message->fullmessagehtml = markdown_to_html($bodyForHtml);
|
||||
$message->smallmessage = $message->subject;
|
||||
$message->notification = 1;
|
||||
$message->contexturl = $placeholders->url;
|
||||
$message->contexturlname = $order->snapshot->description;
|
||||
|
||||
return message_send($message);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logger->error('Сообщение покупателю не отправлено: ' . get_class($exception));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local;
|
||||
|
||||
/**
|
||||
* Идентификатор CMS и модуля для `MNT_CMS`:
|
||||
* `Moodle v<release>|PHP <version>|Moneta v<release>`.
|
||||
*
|
||||
* @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 CmsInfo
|
||||
{
|
||||
private const CMS_NAME = 'Moodle';
|
||||
private const MODULE_NAME = 'Moneta';
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function getCmsModuleVersion(): string
|
||||
{
|
||||
global $CFG;
|
||||
|
||||
$pluginInfo = \core_plugin_manager::instance()->get_plugin_info('paygw_moneta');
|
||||
$moduleVersion = $pluginInfo?->release ?? (string) ($pluginInfo?->versiondisk ?? 'unknown');
|
||||
|
||||
return self::format(
|
||||
cmsVersion: (string) ($CFG->release ?? 'unknown'),
|
||||
phpVersion: PHP_VERSION,
|
||||
moduleVersion: (string) $moduleVersion,
|
||||
);
|
||||
}
|
||||
|
||||
public static function format(string $cmsVersion, string $phpVersion, string $moduleVersion): string
|
||||
{
|
||||
return sprintf(
|
||||
'%s v%s|PHP %s|%s v%s',
|
||||
self::CMS_NAME,
|
||||
$cmsVersion,
|
||||
$phpVersion,
|
||||
self::MODULE_NAME,
|
||||
$moduleVersion,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local;
|
||||
|
||||
/**
|
||||
* Символ валюты для показа сумм администратору («120.25 ₽»).
|
||||
* Известен только рубль; любой другой код остаётся кодом («USD»),
|
||||
* пустой — пустой строкой.
|
||||
*
|
||||
* @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 CurrencySymbol
|
||||
{
|
||||
private const SYMBOLS = [
|
||||
'RUB' => '₽',
|
||||
];
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function of(?string $currencyCode): string
|
||||
{
|
||||
if ($currencyCode === null || $currencyCode === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return self::SYMBOLS[strtoupper($currencyCode)] ?? $currencyCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local;
|
||||
|
||||
/**
|
||||
* Журнал в `error_log` PHP. Не `debugging()`: тот пишет только при включённой
|
||||
* отладке, а на странице (`pay.php`) при отладке с Whoops сам бросает
|
||||
* исключение и роняет переход к оплате.
|
||||
*
|
||||
* @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 ErrorLogLogger implements Logger
|
||||
{
|
||||
public function error(string $message): void
|
||||
{
|
||||
error_log('paygw_moneta: ' . $message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local;
|
||||
|
||||
use paygw_moneta\local\protocol\PaymentServer;
|
||||
use paygw_moneta\local\protocol\Signature;
|
||||
use paygw_moneta\local\receipt\Client;
|
||||
use paygw_moneta\local\receipt\Vat;
|
||||
|
||||
/**
|
||||
* Настройки шлюза Moneta у платёжного аккаунта Moodle (`payment_gateways.config`);
|
||||
* читает их из БД {@see GatewayConfigRepository}.
|
||||
* Единственный читатель кода проверки целостности: дальше он живёт только
|
||||
* внутри {@see Signature}.
|
||||
*
|
||||
* @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 GatewayConfig
|
||||
{
|
||||
public const GATEWAY = 'moneta';
|
||||
|
||||
public function __construct(
|
||||
public readonly string $accountNumber,
|
||||
#[\SensitiveParameter]
|
||||
private readonly string $integrityCode,
|
||||
public readonly PaymentServer $paymentServer,
|
||||
public readonly bool $testMode,
|
||||
public readonly bool $fiscalization,
|
||||
public readonly Vat $vat,
|
||||
public readonly bool $enabled,
|
||||
public readonly ?string $receiptEmail = null,
|
||||
public readonly bool $sendPaymentLink = false,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config значения формы {@see \paygw_moneta\Gateway}
|
||||
*/
|
||||
public static function fromArray(array $config, bool $enabled = true): self
|
||||
{
|
||||
return new self(
|
||||
trim((string) ($config['accountnumber'] ?? '')),
|
||||
(string) ($config['integritycode'] ?? ''),
|
||||
PaymentServer::fromSetting($config['paymentserver'] ?? null),
|
||||
!empty($config['testmode']),
|
||||
!empty($config['fiscalization']),
|
||||
Vat::tryFrom((string) ($config['vat'] ?? '')) ?? Vat::None,
|
||||
$enabled,
|
||||
Client::normalizeEmail((string) ($config['receiptemail'] ?? '')),
|
||||
!empty($config['sendpaymentlink']),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Можно ли создавать новые заказы: реквизиты и «Email для чеков», без
|
||||
* которого чек покупателя без контактов не собрать. Колбэки проверяют
|
||||
* только {@see isComplete()} — уже оплаченный заказ должен дойти до
|
||||
* зачисления, даже если настройку потом очистили.
|
||||
*/
|
||||
public function acceptsNewOrders(): bool
|
||||
{
|
||||
return $this->isComplete() && $this->enabled && $this->receiptEmail !== null;
|
||||
}
|
||||
|
||||
public function isComplete(): bool
|
||||
{
|
||||
return $this->accountNumber !== '' && $this->integrityCode !== '';
|
||||
}
|
||||
|
||||
public function signature(): Signature
|
||||
{
|
||||
return new Signature($this->integrityCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local;
|
||||
|
||||
use core_payment\helper;
|
||||
|
||||
/**
|
||||
* Загрузка {@see GatewayConfig} из платёжных аккаунтов Moodle
|
||||
* (`payment_gateways`). Сам `GatewayConfig` — только значения, без обращения
|
||||
* к БД; здесь — всё, что его читает.
|
||||
*
|
||||
* @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 GatewayConfigRepository
|
||||
{
|
||||
/**
|
||||
* Настройки для оплачиваемого объекта — тот же путь, что у ядра при показе модального окна.
|
||||
*/
|
||||
public function forPayable(string $component, string $paymentArea, int $itemId): GatewayConfig
|
||||
{
|
||||
return GatewayConfig::fromArray(
|
||||
helper::get_gateway_configuration($component, $paymentArea, $itemId, GatewayConfig::GATEWAY),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Настройки аккаунта заказа — для колбэков и ссылки на оплату, где
|
||||
* оплачиваемый объект уже известен по заказу.
|
||||
*/
|
||||
public function forAccount(int $accountId): ?GatewayConfig
|
||||
{
|
||||
global $DB;
|
||||
|
||||
$record = $DB->get_record('payment_gateways', ['accountid' => $accountId, 'gateway' => GatewayConfig::GATEWAY]);
|
||||
if ($record === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::fromRecord($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Заполненные настройки с данным номером счёта MONETA.RU — чтобы проверить
|
||||
* подпись уведомления по заказу, которого нет в БД.
|
||||
*/
|
||||
public function findByAccountNumber(string $accountNumber): ?GatewayConfig
|
||||
{
|
||||
global $DB;
|
||||
|
||||
foreach ($DB->get_records('payment_gateways', ['gateway' => GatewayConfig::GATEWAY]) as $record) {
|
||||
$config = self::fromRecord($record);
|
||||
if ($config->isComplete() && $config->accountNumber === $accountNumber) {
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function fromRecord(\stdClass $record): GatewayConfig
|
||||
{
|
||||
$config = json_decode((string) $record->config, true);
|
||||
|
||||
return GatewayConfig::fromArray(is_array($config) ? $config : [], (bool) $record->enabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local;
|
||||
|
||||
/**
|
||||
* Единая точка журнала модуля. Только события без секретов: ни кода проверки,
|
||||
* ни `MNT_SIGNATURE`, ни набора параметров запроса — классы исключений, коды
|
||||
* отказов и номера заказов/операций.
|
||||
*
|
||||
* @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 Logger
|
||||
{
|
||||
public function error(string $message): void;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Денежная сумма в формате MONETA.Assistant: десятичная строка ровно с двумя
|
||||
* знаками после точки (`120.25`, `100.00`), без знака, без разделителей тысяч.
|
||||
*
|
||||
* Внутри — только строка и целые копейки; `float` появляется лишь на границе
|
||||
* с хостом ({@see self::fromFloat()}), где 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 Money
|
||||
{
|
||||
/** Верхняя граница суммы по JSON-схеме Check URL (`number_amount.maximum`). */
|
||||
public const MAX = '10000000000000.00';
|
||||
|
||||
/** Целая часть — до 14 знаков без ведущих нулей, дробная — ровно два. */
|
||||
private const PATTERN = '/\A(?:0|[1-9][0-9]{0,13})\.[0-9]{2}\z/';
|
||||
|
||||
private function __construct(private string $value) {}
|
||||
|
||||
/**
|
||||
* Строгий разбор десятичной строки; всё, что не `^\d+\.\d{2}$`, отклоняется.
|
||||
*/
|
||||
public static function fromDecimal(string $value): self
|
||||
{
|
||||
if (preg_match(self::PATTERN, $value) !== 1) {
|
||||
throw new InvalidArgumentException('Сумма должна быть десятичной строкой с двумя знаками после точки.');
|
||||
}
|
||||
|
||||
$money = new self($value);
|
||||
if ($money->toMinorUnits() > self::fromMax()->toMinorUnits()) {
|
||||
throw new InvalidArgumentException('Сумма превышает допустимый максимум.');
|
||||
}
|
||||
|
||||
return $money;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сумма в копейках; безопасный путь для арифметики.
|
||||
*/
|
||||
public static function fromMinorUnits(int $minorUnits): self
|
||||
{
|
||||
if ($minorUnits < 0) {
|
||||
throw new InvalidArgumentException('Сумма не может быть отрицательной.');
|
||||
}
|
||||
|
||||
return self::fromDecimal(sprintf('%d.%02d', intdiv($minorUnits, 100), $minorUnits % 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Граница с хостом: Moodle считает стоимость `float` (`helper::get_rounded_cost`).
|
||||
* Округление до копеек делается здесь один раз, дальше `float` не используется.
|
||||
*/
|
||||
public static function fromFloat(float $amount): self
|
||||
{
|
||||
if ($amount < 0 || !is_finite($amount)) {
|
||||
throw new InvalidArgumentException('Сумма хоста должна быть конечным неотрицательным числом.');
|
||||
}
|
||||
|
||||
return self::fromDecimal(number_format($amount, 2, '.', ''));
|
||||
}
|
||||
|
||||
public function toDecimal(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function toMinorUnits(): int
|
||||
{
|
||||
[$units, $cents] = explode('.', $this->value);
|
||||
|
||||
return ((int) $units * 100) + (int) $cents;
|
||||
}
|
||||
|
||||
public function isPositive(): bool
|
||||
{
|
||||
return $this->toMinorUnits() > 0;
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
|
||||
public function add(self $other): self
|
||||
{
|
||||
return self::fromMinorUnits($this->toMinorUnits() + $other->toMinorUnits());
|
||||
}
|
||||
|
||||
public function multiply(int $quantity): self
|
||||
{
|
||||
if ($quantity < 0) {
|
||||
throw new InvalidArgumentException('Количество не может быть отрицательным.');
|
||||
}
|
||||
|
||||
return self::fromMinorUnits($this->toMinorUnits() * $quantity);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
private static function fromMax(): self
|
||||
{
|
||||
return new self(self::MAX);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local;
|
||||
|
||||
use core_payment\helper;
|
||||
use moodle_url;
|
||||
use paygw_moneta\local\order\CurrentPrice;
|
||||
use paygw_moneta\local\order\Order;
|
||||
use paygw_moneta\local\protocol\AssistantProtocol;
|
||||
use paygw_moneta\local\protocol\AssistantTransactionId;
|
||||
use paygw_moneta\local\protocol\PaymentRequest;
|
||||
|
||||
/**
|
||||
* Контекст шаблона `paygw_moneta/payment_form` — подписанная форма перехода на
|
||||
* MONETA.Assistant для заказа. Общий для `pay.php` (новый заказ из модального
|
||||
* окна) и `checkout.php` (ссылка на оплату из письма).
|
||||
*
|
||||
* @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 PaymentFormPage
|
||||
{
|
||||
private const RETURN_PATH = '/payment/gateway/moneta/return.php';
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* @return array{action: string, cost: string, description: string, iscourse: bool, logourl: string, fields: list<array{name: string, value: string}>}
|
||||
*/
|
||||
public static function context(Order $order, GatewayConfig $config): array
|
||||
{
|
||||
global $CFG, $OUTPUT;
|
||||
|
||||
$request = new PaymentRequest(
|
||||
accountId: $config->accountNumber,
|
||||
transactionId: AssistantTransactionId::build($order->merchantOrderId),
|
||||
amount: $order->amount,
|
||||
subscriberId: $order->subscriberId(),
|
||||
testMode: $order->snapshot->testMode,
|
||||
signature: $config->signature(),
|
||||
description: $order->snapshot->description,
|
||||
successUrl: (new moodle_url(self::RETURN_PATH, ['outcome' => 'success']))->out(false),
|
||||
failUrl: (new moodle_url(self::RETURN_PATH, ['outcome' => 'fail']))->out(false),
|
||||
returnUrl: (new moodle_url(self::RETURN_PATH, ['outcome' => 'cancel']))->out(false),
|
||||
server: $config->paymentServer,
|
||||
locale: AssistantProtocol::normalizeLocale(current_language()),
|
||||
cms: CmsInfo::getCmsModuleVersion(),
|
||||
);
|
||||
|
||||
$fields = [];
|
||||
foreach ($request->getFields() as $name => $value) {
|
||||
$fields[] = ['name' => $name, 'value' => $value];
|
||||
}
|
||||
|
||||
return [
|
||||
'action' => empty($CFG->paygw_moneta_assistant_url)
|
||||
? $request->getActionUrl()
|
||||
: (string) $CFG->paygw_moneta_assistant_url,
|
||||
// Сумма для покупателя — как в остальном Moodle: «120,25 ₽».
|
||||
'cost' => helper::get_cost_as_string((float) $order->amount->toDecimal(), $order->currency),
|
||||
'description' => $order->snapshot->description,
|
||||
'iscourse' => $order->component === 'enrol_fee',
|
||||
'logourl' => $OUTPUT->image_url('img', 'paygw_moneta')->out(false),
|
||||
'fields' => $fields,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Можно ли продолжить оплату открытого заказа по ссылке из письма: реквизиты,
|
||||
* тестовый режим и цена те же, что при создании, — иначе Pay URL всё равно
|
||||
* отклонит оплату (`PayHandler`).
|
||||
*/
|
||||
public static function canResume(Order $order, ?GatewayConfig $config, CurrentPrice $currentPrice): bool
|
||||
{
|
||||
return $order->status->isOpen()
|
||||
&& $config !== null
|
||||
&& $config->acceptsNewOrders()
|
||||
&& $config->accountNumber === $order->snapshot->accountNumber
|
||||
&& $config->testMode === $order->snapshot->testMode
|
||||
&& $currentPrice->matches($order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Адрес страницы продолжения оплаты — его получает покупатель в письме.
|
||||
*/
|
||||
public static function resumeUrl(Order $order): moodle_url
|
||||
{
|
||||
return new moodle_url('/payment/gateway/moneta/checkout.php', ['order' => $order->merchantOrderId]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local;
|
||||
|
||||
use html_writer;
|
||||
use moodle_url;
|
||||
use paygw_moneta\local\order\TransactionRepository;
|
||||
use paygw_moneta\local\order\TransactionStatus;
|
||||
use stdClass;
|
||||
use table_sql;
|
||||
|
||||
/**
|
||||
* Таблица заказов для администратора (Администрирование → Платежи → Заказы Монета).
|
||||
* Показывает статусы и коды последних отказов — первое, о чём спрашивает мерчант.
|
||||
*
|
||||
* @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 ReportTable extends table_sql
|
||||
{
|
||||
public function __construct(string $uniqueId, moodle_url $baseUrl)
|
||||
{
|
||||
parent::__construct($uniqueId);
|
||||
|
||||
$this->define_columns(
|
||||
[
|
||||
'timecreated',
|
||||
'user',
|
||||
'description',
|
||||
'amount',
|
||||
'status',
|
||||
'providertransactionid',
|
||||
'lasterror',
|
||||
],
|
||||
);
|
||||
|
||||
$this->define_headers([
|
||||
get_string('report:time', 'paygw_moneta'),
|
||||
get_string('report:user', 'paygw_moneta'),
|
||||
get_string('report:description', 'paygw_moneta'),
|
||||
get_string('report:amount', 'paygw_moneta'),
|
||||
get_string('report:status', 'paygw_moneta'),
|
||||
get_string('report:operation', 'paygw_moneta'),
|
||||
get_string('report:lasterror', 'paygw_moneta'),
|
||||
]);
|
||||
$this->define_baseurl($baseUrl);
|
||||
$this->sortable(true, 'timecreated', SORT_DESC);
|
||||
$this->no_sorting('description');
|
||||
$this->no_sorting('lasterror');
|
||||
$this->collapsible(false);
|
||||
$this->pageable(true);
|
||||
|
||||
$userFields = \core_user\fields::for_name()->get_sql('u')->selects;
|
||||
$this->set_sql(
|
||||
't.*' . $userFields,
|
||||
'{' . TransactionRepository::TABLE . '} t JOIN {user} u ON u.id = t.userid',
|
||||
'1 = 1',
|
||||
);
|
||||
}
|
||||
|
||||
public function col_timecreated(stdClass $row): string
|
||||
{
|
||||
return userdate((int) $row->timecreated, get_string('strftimedatetimeshort', 'langconfig'));
|
||||
}
|
||||
|
||||
public function col_user(stdClass $row): string
|
||||
{
|
||||
return html_writer::link(new moodle_url('/user/profile.php', ['id' => $row->userid]), fullname($row));
|
||||
}
|
||||
|
||||
public function col_description(stdClass $row): string
|
||||
{
|
||||
$snapshot = json_decode((string) $row->snapshot, true);
|
||||
|
||||
return s((string) ($snapshot['description'] ?? ''));
|
||||
}
|
||||
|
||||
public function col_amount(stdClass $row): string
|
||||
{
|
||||
return Money::fromFloat((float) $row->amount)->toDecimal() . ' ' . s(CurrencySymbol::of((string) $row->currency));
|
||||
}
|
||||
|
||||
public function col_status(stdClass $row): string
|
||||
{
|
||||
$status = TransactionStatus::tryFrom((string) $row->status);
|
||||
$label = $status === null ? s((string) $row->status) : get_string('status:' . $status->value, 'paygw_moneta');
|
||||
$class = match ($status) {
|
||||
TransactionStatus::Paid => 'badge bg-success text-white',
|
||||
TransactionStatus::Failed => 'badge bg-danger text-white',
|
||||
TransactionStatus::Canceled => 'badge bg-secondary text-white',
|
||||
default => 'badge bg-info text-white',
|
||||
};
|
||||
|
||||
return html_writer::span($label, $class);
|
||||
}
|
||||
|
||||
public function col_providertransactionid(stdClass $row): string
|
||||
{
|
||||
return s((string) ($row->providertransactionid ?? ''));
|
||||
}
|
||||
|
||||
public function col_lasterror(stdClass $row): string
|
||||
{
|
||||
return s((string) ($row->lasterror ?? ''));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\protocol;
|
||||
|
||||
/**
|
||||
* Константы протокола MONETA.Assistant, общие для формы и колбэков.
|
||||
* Адреса платёжной формы — в {@see PaymentServer}.
|
||||
*
|
||||
* @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 AssistantProtocol
|
||||
{
|
||||
public const CURRENCY = 'RUB';
|
||||
public const COMMAND_CHECK = 'CHECK';
|
||||
public const TEXT_SUCCESS = 'SUCCESS';
|
||||
public const TEXT_FAIL = 'FAIL';
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* `MNT_TEST_MODE` передаётся как «1» или строковый «0» (`cmsspecification.pdf`).
|
||||
*/
|
||||
public static function testModeFlag(bool $testMode): string
|
||||
{
|
||||
return $testMode ? '1' : '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Язык интерфейса Moodle → `moneta.locale` платёжной формы (ru/en).
|
||||
*/
|
||||
public static function normalizeLocale(string $language): string
|
||||
{
|
||||
return str_starts_with(strtolower($language), 'ru') ? 'ru' : 'en';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\protocol;
|
||||
|
||||
use core_date;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* `MNT_TRANSACTION_ID` — идентификатор заказа, который модуль отдаёт в платёжной
|
||||
* форме и получает обратно в Check URL, Pay URL и на странице возврата: UUID заказа
|
||||
* плюс метка времени, `0192b4c1-…_20260924143005MSK`. Метка — только для людей
|
||||
* (в личном кабинете Moneta и в переписке с поддержкой по ней видно, когда открыта форма),
|
||||
* поэтому она в часовом поясе сайта, а не в UTC; обратно она не читается, parse()
|
||||
* берёт только UUID.
|
||||
*
|
||||
* @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 AssistantTransactionId
|
||||
{
|
||||
/**
|
||||
* Номер заказа — UUID в нижнем регистре ({@see \paygw_moneta\local\order\TransactionRepository::create()}).
|
||||
* Фрагмент шаблона без якорей: его же проверяет
|
||||
* {@see \paygw_moneta\local\order\TransactionRepository::isMerchantOrderId()}.
|
||||
*/
|
||||
public const ORDER_ID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
|
||||
|
||||
public const SEPARATOR = '_';
|
||||
|
||||
private const PATTERN = '/\A
|
||||
(?<id>' . self::ORDER_ID . ') # UUID заказа
|
||||
' . self::SEPARATOR . ' # разделитель
|
||||
[0-9]{14} # метка времени YmdHis
|
||||
(?:[A-Z0-9+\-]{1,6})? # пояс: MSK, +03 — не обязателен
|
||||
\z/x';
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function build(string $orderId, ?DateTimeImmutable $now = null): string
|
||||
{
|
||||
$now ??= new DateTimeImmutable('now', core_date::get_server_timezone_object());
|
||||
|
||||
return $orderId . self::SEPARATOR . $now->format('YmdHisT');
|
||||
}
|
||||
|
||||
public static function parse(string $raw): ?string
|
||||
{
|
||||
return preg_match(self::PATTERN, $raw, $matches) === 1 ? $matches['id'] : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\protocol;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use paygw_moneta\local\Money;
|
||||
|
||||
/**
|
||||
* @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 CallbackNotification
|
||||
{
|
||||
private const ACCOUNT_ID_MAX_LENGTH = 20;
|
||||
private const TRANSACTION_ID_MAX_LENGTH = 255;
|
||||
private const OPERATION_ID_MAX_LENGTH = 255;
|
||||
private const SUBSCRIBER_ID_MAX_LENGTH = 255;
|
||||
|
||||
private readonly string $command;
|
||||
private readonly string $accountId;
|
||||
private readonly string $transactionId;
|
||||
private readonly string $operationId;
|
||||
private readonly string $rawAmount;
|
||||
private readonly ?Money $amount;
|
||||
private readonly string $currency;
|
||||
private readonly string $subscriberId;
|
||||
private readonly string $rawTestMode;
|
||||
private readonly string $signature;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fields
|
||||
*/
|
||||
public function __construct(array $fields)
|
||||
{
|
||||
$this->command = self::optional($fields, 'MNT_COMMAND');
|
||||
if (!in_array($this->command, ['', AssistantProtocol::COMMAND_CHECK], true)) {
|
||||
throw new InvalidArgumentException('Неизвестная команда уведомления.');
|
||||
}
|
||||
$this->accountId = self::required($fields, 'MNT_ID', self::ACCOUNT_ID_MAX_LENGTH);
|
||||
$this->transactionId = self::required($fields, 'MNT_TRANSACTION_ID', self::TRANSACTION_ID_MAX_LENGTH);
|
||||
$this->operationId = self::optional($fields, 'MNT_OPERATION_ID', self::OPERATION_ID_MAX_LENGTH);
|
||||
$this->rawAmount = self::optional($fields, 'MNT_AMOUNT');
|
||||
$this->amount = $this->rawAmount === '' ? null : Money::fromDecimal($this->rawAmount);
|
||||
$this->currency = self::required($fields, 'MNT_CURRENCY_CODE', 3);
|
||||
$this->subscriberId = self::optional($fields, 'MNT_SUBSCRIBER_ID', self::SUBSCRIBER_ID_MAX_LENGTH);
|
||||
$this->rawTestMode = self::required($fields, 'MNT_TEST_MODE', 1);
|
||||
if (!in_array($this->rawTestMode, ['0', '1'], true)) {
|
||||
throw new InvalidArgumentException('Недопустимое значение MNT_TEST_MODE.');
|
||||
}
|
||||
$this->signature = self::required($fields, 'MNT_SIGNATURE', 32);
|
||||
if (($this->amount === null || $this->operationId === '') && !$this->isCheck()) {
|
||||
throw new InvalidArgumentException('Уведомлению об оплате нужны сумма и номер операции.');
|
||||
}
|
||||
}
|
||||
|
||||
public function isCheck(): bool
|
||||
{
|
||||
return $this->command === AssistantProtocol::COMMAND_CHECK;
|
||||
}
|
||||
|
||||
public function getCommand(): string
|
||||
{
|
||||
return $this->command;
|
||||
}
|
||||
|
||||
public function getAccountId(): string
|
||||
{
|
||||
return $this->accountId;
|
||||
}
|
||||
|
||||
public function getTransactionId(): string
|
||||
{
|
||||
return $this->transactionId;
|
||||
}
|
||||
|
||||
public function getOperationId(): string
|
||||
{
|
||||
return $this->operationId;
|
||||
}
|
||||
|
||||
public function getRawAmount(): string
|
||||
{
|
||||
return $this->rawAmount;
|
||||
}
|
||||
|
||||
public function getAmount(): ?Money
|
||||
{
|
||||
return $this->amount;
|
||||
}
|
||||
|
||||
public function getCurrency(): string
|
||||
{
|
||||
return $this->currency;
|
||||
}
|
||||
|
||||
public function getSubscriberId(): string
|
||||
{
|
||||
return $this->subscriberId;
|
||||
}
|
||||
|
||||
public function getRawTestMode(): string
|
||||
{
|
||||
return $this->rawTestMode;
|
||||
}
|
||||
|
||||
public function isTestMode(): bool
|
||||
{
|
||||
return $this->rawTestMode === '1';
|
||||
}
|
||||
|
||||
public function isSignedBy(Signature $signature): bool
|
||||
{
|
||||
return Signature::equals($signature->forNotification($this), $this->signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fields
|
||||
*/
|
||||
private static function required(array $fields, string $name, int $maxLength): string
|
||||
{
|
||||
$value = self::optional($fields, $name, $maxLength);
|
||||
if ($value === '') {
|
||||
throw new InvalidArgumentException("Не передано поле {$name}.");
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fields
|
||||
*/
|
||||
private static function optional(array $fields, string $name, int $maxLength = 255): string
|
||||
{
|
||||
if (!array_key_exists($name, $fields)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = $fields[$name];
|
||||
if (!is_string($value)) {
|
||||
throw new InvalidArgumentException("Поле {$name} должно быть строкой.");
|
||||
}
|
||||
if (strlen($value) > $maxLength) {
|
||||
throw new InvalidArgumentException("Поле {$name} длиннее допустимого.");
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\protocol;
|
||||
|
||||
/**
|
||||
* Тело и тип ответа модуля на Check URL / 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
|
||||
*/
|
||||
final class CallbackResponse
|
||||
{
|
||||
public const CONTENT_TYPE_XML = 'application/xml; charset=UTF-8';
|
||||
public const CONTENT_TYPE_JSON = 'application/json; charset=UTF-8';
|
||||
public const CONTENT_TYPE_TEXT = 'text/plain; charset=UTF-8';
|
||||
|
||||
public function __construct(
|
||||
public readonly string $contentType,
|
||||
public readonly string $body,
|
||||
) {}
|
||||
|
||||
public static function fail(): self
|
||||
{
|
||||
return new self(self::CONTENT_TYPE_TEXT, AssistantProtocol::TEXT_FAIL);
|
||||
}
|
||||
|
||||
public static function xml(string $body): self
|
||||
{
|
||||
return new self(self::CONTENT_TYPE_XML, $body);
|
||||
}
|
||||
|
||||
public static function json(string $body): self
|
||||
{
|
||||
return new self(self::CONTENT_TYPE_JSON, $body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\protocol;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use paygw_moneta\local\Money;
|
||||
use paygw_moneta\local\receipt\JsonNumber;
|
||||
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 CheckResponse
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $accountId,
|
||||
private readonly string $transactionId,
|
||||
private readonly Money $amount,
|
||||
private readonly ResultCode $resultCode,
|
||||
private readonly Signature $signature,
|
||||
private readonly string $cms,
|
||||
) {}
|
||||
|
||||
public function getResultCode(): ResultCode
|
||||
{
|
||||
return $this->resultCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* XML без атрибутов — для мерчанта без кассового сервиса.
|
||||
*/
|
||||
public function toXml(): CallbackResponse
|
||||
{
|
||||
return CallbackResponse::xml(
|
||||
MntResponseXml::build(
|
||||
accountId: $this->accountId,
|
||||
transactionId: $this->transactionId,
|
||||
resultCode: $this->resultCode,
|
||||
amount: $this->amount,
|
||||
signature: $this->sign(),
|
||||
cms: $this->cms,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON с чеком — для мерчанта с подключённой кассой.
|
||||
*/
|
||||
public function toJson(Receipt $receipt): CallbackResponse
|
||||
{
|
||||
if (!$receipt->matchesAmount($this->amount)) {
|
||||
throw new InvalidArgumentException('Сумма позиций чека не совпадает с суммой заказа.');
|
||||
}
|
||||
|
||||
return CallbackResponse::json(JsonNumber::encode([
|
||||
'id' => $this->accountId,
|
||||
'transactionId' => $this->transactionId,
|
||||
'amount' => JsonNumber::placeholder($this->amount),
|
||||
'signature' => $this->sign(),
|
||||
'resultCode' => (string) $this->resultCode->value,
|
||||
'description' => $this->resultCode->description(),
|
||||
'cms' => $this->cms,
|
||||
'receipt' => $receipt->toCheckReceipt(),
|
||||
]));
|
||||
}
|
||||
|
||||
private function sign(): string
|
||||
{
|
||||
return $this->signature->forResponse($this->resultCode, $this->accountId, $this->transactionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\protocol;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMNode;
|
||||
use paygw_moneta\local\Money;
|
||||
|
||||
/**
|
||||
* Сборка `MNT_RESPONSE` (`MONETA.Assistant.ru.pdf`, гл. 4–5). Общая часть
|
||||
* ответов Check URL и Pay URL: обязательные элементы, подпись по формуле 3
|
||||
* и необязательные `MNT_ATTRIBUTES`.
|
||||
*
|
||||
* `MNT_CMS` в схеме ответа нет — это решение разработчика (2026-09-12,
|
||||
* `docs/moneta/_pdf/README.md`): идентификатор CMS передаётся везде, где
|
||||
* модуль отвечает Moneta; лишние элементы приёму не мешают.
|
||||
*
|
||||
* @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 MntResponseXml
|
||||
{
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $attributes пары KEY → VALUE для `MNT_ATTRIBUTES`
|
||||
* @throws \DOMException
|
||||
*/
|
||||
public static function build(
|
||||
string $accountId,
|
||||
string $transactionId,
|
||||
ResultCode $resultCode,
|
||||
Money $amount,
|
||||
string $signature,
|
||||
string $cms,
|
||||
array $attributes = [],
|
||||
): string {
|
||||
$document = new DOMDocument('1.0', 'UTF-8');
|
||||
$response = $document->appendChild($document->createElement('MNT_RESPONSE'));
|
||||
$elements = [
|
||||
'MNT_ID' => $accountId,
|
||||
'MNT_TRANSACTION_ID' => $transactionId,
|
||||
'MNT_RESULT_CODE' => (string) $resultCode->value,
|
||||
'MNT_DESCRIPTION' => $resultCode->description(),
|
||||
'MNT_AMOUNT' => $amount->toDecimal(),
|
||||
'MNT_SIGNATURE' => $signature,
|
||||
'MNT_CMS' => $cms,
|
||||
];
|
||||
foreach ($elements as $name => $value) {
|
||||
self::text($document, $response, $name, $value);
|
||||
}
|
||||
if ($attributes !== []) {
|
||||
$container = $response->appendChild($document->createElement('MNT_ATTRIBUTES'));
|
||||
foreach ($attributes as $key => $value) {
|
||||
$attribute = $container->appendChild($document->createElement('ATTRIBUTE'));
|
||||
self::text($document, $attribute, 'KEY', $key);
|
||||
self::text($document, $attribute, 'VALUE', $value);
|
||||
}
|
||||
}
|
||||
|
||||
$xml = $document->saveXML();
|
||||
if ($xml === false) {
|
||||
throw new \RuntimeException('Не удалось сериализовать MNT_RESPONSE.');
|
||||
}
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Текстовый узел, а не `createElement($name, $value)`: второй не экранирует `&`.
|
||||
*/
|
||||
private static function text(DOMDocument $document, DOMNode $parent, string $name, string $value): void
|
||||
{
|
||||
$parent->appendChild($document->createElement($name))->appendChild($document->createTextNode($value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\protocol;
|
||||
|
||||
use paygw_moneta\local\Money;
|
||||
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 PayResponse
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $accountId,
|
||||
private readonly string $transactionId,
|
||||
private readonly Money $amount,
|
||||
private readonly ResultCode $resultCode,
|
||||
private readonly Signature $signature,
|
||||
private readonly string $cms,
|
||||
private readonly ?Receipt $receipt = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws \DOMException
|
||||
* @throws \JsonException
|
||||
*/
|
||||
public function toXml(): CallbackResponse
|
||||
{
|
||||
$attributes = [];
|
||||
if ($this->receipt !== null) {
|
||||
$attributes['INVENTORY'] = $this->receipt->toInventoryAttribute();
|
||||
$attributes['CLIENT'] = $this->receipt->toClientAttribute();
|
||||
$duplicates = [
|
||||
'CUSTOMER' => $this->receipt->toCustomerAttribute(),
|
||||
'PHONE' => $this->receipt->toPhoneAttribute(),
|
||||
];
|
||||
$attributes += array_filter($duplicates, static fn(?string $value): bool => $value !== null);
|
||||
}
|
||||
|
||||
return CallbackResponse::xml(
|
||||
MntResponseXml::build(
|
||||
accountId: $this->accountId,
|
||||
transactionId: $this->transactionId,
|
||||
resultCode: $this->resultCode,
|
||||
amount: $this->amount,
|
||||
signature: $this->signature->forResponse($this->resultCode, $this->accountId, $this->transactionId),
|
||||
cms: $this->cms,
|
||||
attributes: $attributes,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\protocol;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use paygw_moneta\local\Money;
|
||||
|
||||
/**
|
||||
* @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 PaymentRequest
|
||||
{
|
||||
public const DESCRIPTION_MAX_LENGTH = 500;
|
||||
|
||||
/**
|
||||
* @param string $accountId номер расширенного счёта (`MNT_ID`)
|
||||
* @param string $transactionId идентификатор заказа в модуле (`MNT_TRANSACTION_ID`)
|
||||
* @param Money $amount сумма к оплате
|
||||
* @param string $subscriberId идентификатор покупателя (`MNT_SUBSCRIBER_ID`)
|
||||
* @param bool $testMode тестовый режим счёта
|
||||
* @param Signature $signature подпись с кодом проверки целостности счёта
|
||||
* @param string $description описание заказа для покупателя
|
||||
* @param string $successUrl возврат покупателя после успешной оплаты
|
||||
* @param string $failUrl возврат после отказа
|
||||
* @param string $returnUrl возврат при отмене покупателем
|
||||
* @param PaymentServer $server боевой сервер или демо-площадка
|
||||
* @param string $locale язык платёжной формы (`moneta.locale`)
|
||||
* @param string $cms идентификатор CMS и модуля (`MNT_CMS`)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $accountId,
|
||||
private readonly string $transactionId,
|
||||
private readonly Money $amount,
|
||||
private readonly string $subscriberId,
|
||||
private readonly bool $testMode,
|
||||
private readonly Signature $signature,
|
||||
private readonly string $description,
|
||||
private readonly string $successUrl,
|
||||
private readonly string $failUrl,
|
||||
private readonly string $returnUrl,
|
||||
private readonly PaymentServer $server,
|
||||
private readonly string $locale,
|
||||
private readonly string $cms,
|
||||
) {
|
||||
if ($accountId === '' || $transactionId === '') {
|
||||
throw new InvalidArgumentException('Номер счёта и идентификатор заказа обязательны.');
|
||||
}
|
||||
if (!$amount->isPositive()) {
|
||||
throw new InvalidArgumentException('Сумма платежа должна быть больше нуля.');
|
||||
}
|
||||
}
|
||||
|
||||
public function getActionUrl(): string
|
||||
{
|
||||
return $this->server->url();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getFields(): array
|
||||
{
|
||||
return [
|
||||
'MNT_ID' => $this->accountId,
|
||||
'MNT_TRANSACTION_ID' => $this->transactionId,
|
||||
'MNT_AMOUNT' => $this->amount->toDecimal(),
|
||||
'MNT_CURRENCY_CODE' => AssistantProtocol::CURRENCY,
|
||||
'MNT_SUBSCRIBER_ID' => $this->subscriberId,
|
||||
'MNT_TEST_MODE' => AssistantProtocol::testModeFlag($this->testMode),
|
||||
'MNT_DESCRIPTION' => mb_substr($this->description, 0, self::DESCRIPTION_MAX_LENGTH),
|
||||
'MNT_SUCCESS_URL' => $this->successUrl,
|
||||
'MNT_FAIL_URL' => $this->failUrl,
|
||||
'MNT_RETURN_URL' => $this->returnUrl,
|
||||
'moneta.locale' => $this->locale,
|
||||
'MNT_CMS' => $this->cms,
|
||||
'MNT_SIGNATURE' => $this->signature->forPaymentForm(
|
||||
accountId: $this->accountId,
|
||||
transactionId: $this->transactionId,
|
||||
amount: $this->amount,
|
||||
currency: AssistantProtocol::CURRENCY,
|
||||
subscriberId: $this->subscriberId,
|
||||
testMode: $this->testMode,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\protocol;
|
||||
|
||||
/**
|
||||
* @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 PaymentServer: string
|
||||
{
|
||||
case Prod = 'PROD';
|
||||
case Demo = 'DEMO';
|
||||
|
||||
/**
|
||||
* Адрес платёжной формы MONETA.Assistant.
|
||||
*/
|
||||
public function url(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Prod => 'https://www.payanyway.ru/assistant.htm',
|
||||
self::Demo => 'https://demo.moneta.ru/assistant.htm',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ключ строки интерфейса (`server_prod`, `server_demo`).
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return 'server_' . strtolower($this->value);
|
||||
}
|
||||
|
||||
public static function fromSetting(?string $value): self
|
||||
{
|
||||
return self::tryFrom((string) $value) ?? self::Prod;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\protocol;
|
||||
|
||||
/**
|
||||
* Коды `MNT_RESULT_CODE` ответов модуля (`MONETA.Assistant.ru.pdf`, гл. 4–5).
|
||||
*
|
||||
* На Check URL код описывает состояние заказа; на Pay URL сервис считает
|
||||
* уведомление доставленным только при 200, при 100/302/402 повторяет его,
|
||||
* при 500 прекращает попытки.
|
||||
*
|
||||
* @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 ResultCode: int
|
||||
{
|
||||
/** Ответ содержит сумму заказа — в проверочном запросе `MNT_AMOUNT` не было. */
|
||||
case WithAmount = 100;
|
||||
|
||||
/** Заказ оплачен, уведомление доставлено. */
|
||||
case Paid = 200;
|
||||
|
||||
/** Заказ в обработке, точный статус определить нельзя. */
|
||||
case InProgress = 302;
|
||||
|
||||
/** Заказ создан и готов к оплате. */
|
||||
case AwaitingPayment = 402;
|
||||
|
||||
/** Заказ неактуален (отменён, не найден); сервис прекращает повторы. */
|
||||
case Rejected = 500;
|
||||
|
||||
/**
|
||||
* Текст для `MNT_DESCRIPTION` / `description`: без кавычек, `&`, `$`, `#`
|
||||
* и слэшей — требование `cmsspecification.pdf` к значениям ответа.
|
||||
*/
|
||||
public function description(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::WithAmount => 'Order amount',
|
||||
self::Paid => 'Order is paid',
|
||||
self::InProgress => 'Order is being processed',
|
||||
self::AwaitingPayment => 'Order created, but not paid',
|
||||
self::Rejected => 'Order is not available',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\protocol;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use paygw_moneta\local\Money;
|
||||
|
||||
/**
|
||||
* Три формулы подписи MONETA.Assistant (`MONETA.Assistant.ru.pdf`, гл. 3–5).
|
||||
*
|
||||
* Везде MD5 от конкатенации значений без разделителей плюс код проверки
|
||||
* целостности; отсутствующий параметр — пустая строка. Экземпляр держит код,
|
||||
* чтобы он не гулял по сигнатурам методов.
|
||||
*
|
||||
* @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 Signature
|
||||
{
|
||||
private const PATTERN = '/\A[0-9a-f]{32}\z/i';
|
||||
|
||||
public function __construct(
|
||||
#[\SensitiveParameter]
|
||||
private readonly string $integrityCode,
|
||||
) {
|
||||
if ($integrityCode === '') {
|
||||
throw new InvalidArgumentException('Код проверки целостности не задан.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Формула 1 — платёжная форма (модуль → сервис).
|
||||
*
|
||||
* md5(MNT_ID + MNT_TRANSACTION_ID + MNT_AMOUNT + MNT_CURRENCY_CODE + MNT_SUBSCRIBER_ID + MNT_TEST_MODE + КОД)
|
||||
*/
|
||||
public function forPaymentForm(
|
||||
string $accountId,
|
||||
string $transactionId,
|
||||
Money $amount,
|
||||
string $currency,
|
||||
string $subscriberId,
|
||||
bool $testMode,
|
||||
): string {
|
||||
return $this->hash(
|
||||
$accountId
|
||||
. $transactionId
|
||||
. $amount->toDecimal()
|
||||
. $currency
|
||||
. $subscriberId
|
||||
. AssistantProtocol::testModeFlag($testMode),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Формула 2 — входящий запрос на Check URL / Pay URL (сервис → модуль).
|
||||
*
|
||||
* md5(MNT_COMMAND + MNT_ID + MNT_TRANSACTION_ID + MNT_OPERATION_ID + MNT_AMOUNT
|
||||
* + MNT_CURRENCY_CODE + MNT_SUBSCRIBER_ID + MNT_TEST_MODE + КОД)
|
||||
*
|
||||
* Значения берутся из запроса как пришли: подпись сверяется с тем, что
|
||||
* подписал сервис, а не с нашим представлением о заказе.
|
||||
*/
|
||||
public function forNotification(CallbackNotification $notification): string
|
||||
{
|
||||
return $this->hash(
|
||||
$notification->getCommand()
|
||||
. $notification->getAccountId()
|
||||
. $notification->getTransactionId()
|
||||
. $notification->getOperationId()
|
||||
. $notification->getRawAmount()
|
||||
. $notification->getCurrency()
|
||||
. $notification->getSubscriberId()
|
||||
. $notification->getRawTestMode(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Формула 3 — ответ модуля на Check URL и Pay URL.
|
||||
*
|
||||
* md5(MNT_RESULT_CODE + MNT_ID + MNT_TRANSACTION_ID + КОД)
|
||||
*/
|
||||
public function forResponse(ResultCode $resultCode, string $accountId, string $transactionId): string
|
||||
{
|
||||
return $this->hash((string) $resultCode->value . $accountId . $transactionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сравнение без утечки по времени; регистр hex-строки не важен.
|
||||
*/
|
||||
public static function equals(string $expected, string $received): bool
|
||||
{
|
||||
if (preg_match(self::PATTERN, $received) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals(strtolower($expected), strtolower($received));
|
||||
}
|
||||
|
||||
private function hash(string $payload): string
|
||||
{
|
||||
return md5($payload . $this->integrityCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\receipt;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Покупатель в чеке — объект `client` из `kassaspecification.pdf`: обязательно
|
||||
* хотя бы одно из `email`/`phone`; при обоих чек уходит только на e-mail.
|
||||
* `name` — ФИО покупателя, передаётся только вместе с контактом.
|
||||
*
|
||||
* @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 Client
|
||||
{
|
||||
public const NAME_MAX_LENGTH = 256;
|
||||
public const EMAIL_MAX_LENGTH = 64;
|
||||
|
||||
/** По `kassaspecification.pdf` (принимающая сторона); JSON-схема Check URL даёт 13. */
|
||||
public const PHONE_MAX_LENGTH = 19;
|
||||
|
||||
private function __construct(
|
||||
public readonly ?string $name,
|
||||
public readonly ?string $email,
|
||||
public readonly ?string $phone,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Строит покупателя из «сырых» данных профиля; невалидные значения
|
||||
* отбрасываются. Если у покупателя нет ни e-mail, ни телефона, подставляется
|
||||
* `$fallbackEmail` — настройка «Email для чеков» (чек уходит продавцу, ФИО
|
||||
* покупателя остаётся); без неё покупателя нет (`null`).
|
||||
*/
|
||||
public static function fromContacts(
|
||||
?string $email,
|
||||
?string $phone,
|
||||
?string $name = null,
|
||||
?string $fallbackEmail = null,
|
||||
): ?self {
|
||||
$email = self::normalizeEmail($email);
|
||||
$phone = self::normalizePhone($phone);
|
||||
if ($email === null && $phone === null) {
|
||||
$email = self::normalizeEmail($fallbackEmail);
|
||||
}
|
||||
if ($email === null && $phone === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new self(self::normalizeName($name), $email, $phone);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{name?: ?string, email?: ?string, phone?: ?string} $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$client = self::fromContacts($data['email'] ?? null, $data['phone'] ?? null, $data['name'] ?? null);
|
||||
if ($client === null) {
|
||||
throw new InvalidArgumentException('У покупателя нет ни e-mail, ни телефона.');
|
||||
}
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_filter(
|
||||
['name' => $this->name, 'email' => $this->email, 'phone' => $this->phone],
|
||||
static fn(?string $value): bool => $value !== null,
|
||||
);
|
||||
}
|
||||
|
||||
public static function normalizeEmail(?string $email): ?string
|
||||
{
|
||||
$email = trim((string) $email);
|
||||
if (
|
||||
$email === ''
|
||||
|| strlen($email) > self::EMAIL_MAX_LENGTH
|
||||
|| filter_var($email, FILTER_VALIDATE_EMAIL) === false
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
public static function normalizePhone(?string $phone): ?string
|
||||
{
|
||||
$digits = preg_replace('/[^0-9]/', '', (string) $phone) ?? '';
|
||||
if ($digits === '' || strlen($digits) + 1 > self::PHONE_MAX_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '+' . $digits;
|
||||
}
|
||||
|
||||
private static function normalizeName(?string $name): ?string
|
||||
{
|
||||
$name = ReceiptText::validate((string) $name, self::NAME_MAX_LENGTH);
|
||||
|
||||
return $name === '' ? null : $name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\receipt;
|
||||
|
||||
use paygw_moneta\local\Money;
|
||||
|
||||
/**
|
||||
* Денежные числа в JSON без `float`: спецификация требует «два знака после
|
||||
* точки, даже если это нули», а `json_encode(100.0)` даст `100.0`. Сумма
|
||||
* кодируется меткой и после `json_encode` заменяется на десятичную строку без
|
||||
* кавычек — получается литерал `100.00`, валидный JSON-number.
|
||||
*
|
||||
* @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 JsonNumber
|
||||
{
|
||||
private const PREFIX = '__paygw_moneta_number:';
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function placeholder(Money $amount): string
|
||||
{
|
||||
return self::PREFIX . $amount->toDecimal();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @throws \JsonException
|
||||
*/
|
||||
public static function encode(array $payload): string
|
||||
{
|
||||
$json = json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return preg_replace('/"' . preg_quote(self::PREFIX, '/') . '([0-9]+\.[0-9]{2})"/', '$1', $json) ?? $json;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\receipt;
|
||||
|
||||
/**
|
||||
* Признак способа расчёта (`pm` / `paymentMethod`), перечень из `cmsspecification.pdf`.
|
||||
*
|
||||
* @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 PaymentMethod: string
|
||||
{
|
||||
case FullPrepayment = 'full_prepayment';
|
||||
case Prepayment = 'prepayment';
|
||||
case Advance = 'advance';
|
||||
case FullPayment = 'full_payment';
|
||||
case PartialPayment = 'partial_payment';
|
||||
case Credit = 'credit';
|
||||
case CreditPayment = 'credit_payment';
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\receipt;
|
||||
|
||||
/**
|
||||
* Признак предмета расчёта (`po` / `paymentObject`), перечень из `cmsspecification.pdf`.
|
||||
*
|
||||
* @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 PaymentObject: string
|
||||
{
|
||||
case Commodity = 'commodity';
|
||||
case Excise = 'excise';
|
||||
case Job = 'job';
|
||||
case Service = 'service';
|
||||
case GamblingBet = 'gambling_bet';
|
||||
case GamblingPrize = 'gambling_prize';
|
||||
case Lottery = 'lottery';
|
||||
case LotteryPrize = 'lottery_prize';
|
||||
case IntellectualActivity = 'intellectual_activity';
|
||||
case Payment = 'payment';
|
||||
case AgentCommission = 'agent_commission';
|
||||
case Composite = 'composite';
|
||||
case Another = 'another';
|
||||
case PropertyRight = 'property_right';
|
||||
case SalesTax = 'sales_tax';
|
||||
case ResortFee = 'resort_fee';
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\receipt;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use paygw_moneta\local\Money;
|
||||
|
||||
/**
|
||||
* Чек 54-ФЗ: покупатель и позиции. Сумма позиций обязана совпадать с суммой
|
||||
* операции до копейки — иначе номенклатура не пройдёт валидацию кассы.
|
||||
*
|
||||
* @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 Receipt
|
||||
{
|
||||
/** @var list<ReceiptItem> */
|
||||
public readonly array $items;
|
||||
|
||||
/**
|
||||
* @param list<ReceiptItem> $items
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Client $client,
|
||||
array $items,
|
||||
) {
|
||||
if ($items === []) {
|
||||
throw new InvalidArgumentException('Чек без позиций.');
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (!$item instanceof ReceiptItem) {
|
||||
throw new InvalidArgumentException('Позиция чека должна быть ReceiptItem.');
|
||||
}
|
||||
}
|
||||
$this->items = array_values($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Единственная позиция «доступ к курсу» на всю сумму заказа.
|
||||
*/
|
||||
public static function forCourse(string $courseName, Money $amount, Client $client, Vat $vat): self
|
||||
{
|
||||
return new self($client, [
|
||||
new ReceiptItem(
|
||||
name: $courseName,
|
||||
price: $amount,
|
||||
quantity: 1,
|
||||
vat: $vat,
|
||||
paymentMethod: PaymentMethod::FullPayment,
|
||||
paymentObject: PaymentObject::Service,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function total(): Money
|
||||
{
|
||||
$total = Money::fromMinorUnits(0);
|
||||
foreach ($this->items as $item) {
|
||||
$total = $total->add($item->total());
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
public function matchesAmount(Money $amount): bool
|
||||
{
|
||||
return $this->total()->equals($amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Объект `receipt` JSON-ответа Check URL.
|
||||
*
|
||||
* @return array{client: array<string, string>, items: list<array<string, mixed>>}
|
||||
*/
|
||||
public function toCheckReceipt(): array
|
||||
{
|
||||
return [
|
||||
'client' => $this->client->toArray(),
|
||||
'items' => array_map(static fn(ReceiptItem $item): array => $item->toCheckItem(), $this->items),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Значение атрибута `INVENTORY` — JSON-массив позиций.
|
||||
*
|
||||
* @throws \JsonException
|
||||
*/
|
||||
public function toInventoryAttribute(): string
|
||||
{
|
||||
return json_encode(
|
||||
array_map(static fn(ReceiptItem $item): array => $item->toInventoryPosition(), $this->items),
|
||||
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Значение атрибута `CLIENT` — JSON-массив из одного объекта.
|
||||
*
|
||||
* @throws \JsonException
|
||||
*/
|
||||
public function toClientAttribute(): string
|
||||
{
|
||||
return json_encode(
|
||||
[$this->client->toArray()],
|
||||
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Значение атрибута `CUSTOMER` — e-mail покупателя строкой, по
|
||||
* `cmsspecification.pdf`; дублирует `CLIENT.email`, чтобы чек принимался при
|
||||
* любой трактовке документов. `null` — у покупателя
|
||||
* только телефон.
|
||||
*/
|
||||
public function toCustomerAttribute(): ?string
|
||||
{
|
||||
return $this->client->email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Значение атрибута `PHONE` — телефон покупателя без `+`, как в примере
|
||||
* `cmsspecification.pdf`; дублирует `CLIENT.phone`. `null` — телефона нет.
|
||||
*/
|
||||
public function toPhoneAttribute(): ?string
|
||||
{
|
||||
$phone = $this->client->phone;
|
||||
|
||||
return $phone === null ? null : ltrim($phone, '+');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{client: array<string, string>, items: list<array<string, string|int>>}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'client' => $this->client->toArray(),
|
||||
'items' => array_map(static fn(ReceiptItem $item): array => $item->toArray(), $this->items),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{client?: array<string, ?string>, items?: list<array<string, mixed>>} $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$client = Client::fromArray(is_array($data['client'] ?? null) ? $data['client'] : []);
|
||||
$items = array_map(static fn(array $item): ReceiptItem => ReceiptItem::fromArray($item), $data['items'] ?? []);
|
||||
|
||||
return new self($client, $items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\receipt;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use paygw_moneta\local\Money;
|
||||
|
||||
/**
|
||||
* Позиция чека — объект `inventPositions` (`kassaspecification.pdf`) и элемент
|
||||
* `items` JSON-ответа Check URL (`cmsspecification.pdf`).
|
||||
*
|
||||
* @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 ReceiptItem
|
||||
{
|
||||
public const NAME_MAX_LENGTH = 128;
|
||||
|
||||
/** Максимальная цена позиции и произведения `price * quantity`. */
|
||||
public const PRICE_MAX = '42949672.95';
|
||||
|
||||
/** Пустое имя касса не примет; остаётся, если после чистки ничего нет. */
|
||||
private const FALLBACK_NAME = 'Услуга';
|
||||
|
||||
/** Единица измерения из перечня кассы; для доступа к курсу иной не бывает. */
|
||||
private const MEASURE = 'unit';
|
||||
|
||||
public readonly string $name;
|
||||
|
||||
public function __construct(
|
||||
string $name,
|
||||
public readonly Money $price,
|
||||
public readonly int $quantity,
|
||||
public readonly Vat $vat,
|
||||
public readonly PaymentMethod $paymentMethod,
|
||||
public readonly PaymentObject $paymentObject,
|
||||
) {
|
||||
$name = ReceiptText::validate($name, self::NAME_MAX_LENGTH);
|
||||
$this->name = ($name === '') ? self::FALLBACK_NAME : $name;
|
||||
|
||||
if ($quantity < 1 || $quantity > 99999) {
|
||||
throw new InvalidArgumentException('Количество позиции вне допустимых границ.');
|
||||
}
|
||||
|
||||
$max = Money::fromDecimal(self::PRICE_MAX)->toMinorUnits();
|
||||
if ($price->toMinorUnits() > $max || $this->total()->toMinorUnits() > $max) {
|
||||
throw new InvalidArgumentException('Цена позиции превышает потолок кассы.');
|
||||
}
|
||||
}
|
||||
|
||||
public function total(): Money
|
||||
{
|
||||
return $this->price->multiply($this->quantity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Элемент `items` для JSON-ответа Check URL: числа — числа, ставка — строкой `vat`.
|
||||
* Цена подставляется меткой, чтобы в JSON осталось ровно два знака (`100.00`).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toCheckItem(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->name,
|
||||
'price' => JsonNumber::placeholder($this->price),
|
||||
'quantity' => $this->quantity,
|
||||
'measure' => self::MEASURE,
|
||||
'paymentMethod' => $this->paymentMethod->value,
|
||||
'paymentObject' => $this->paymentObject->value,
|
||||
'vat' => $this->vat->value,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Объект `inventPositions` для атрибута `INVENTORY`: цена и количество —
|
||||
* строками, как в примере спецификации Moneta.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function toInventoryPosition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->name,
|
||||
'price' => $this->price->toDecimal(),
|
||||
'quantity' => (string) $this->quantity,
|
||||
'measure' => self::MEASURE,
|
||||
'vatTag' => $this->vat->tag(),
|
||||
'pm' => $this->paymentMethod->value,
|
||||
'po' => $this->paymentObject->value,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string|int>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->name,
|
||||
'price' => $this->price->toDecimal(),
|
||||
'quantity' => $this->quantity,
|
||||
'vat' => $this->vat->value,
|
||||
'paymentMethod' => $this->paymentMethod->value,
|
||||
'paymentObject' => $this->paymentObject->value,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
name: (string) ($data['name'] ?? ''),
|
||||
price: Money::fromDecimal((string) ($data['price'] ?? '')),
|
||||
quantity: (int) ($data['quantity'] ?? 0),
|
||||
vat: Vat::from((string) ($data['vat'] ?? '')),
|
||||
paymentMethod: PaymentMethod::from((string) ($data['paymentMethod'] ?? '')),
|
||||
paymentObject: PaymentObject::from((string) ($data['paymentObject'] ?? '')),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\receipt;
|
||||
|
||||
/**
|
||||
* Текстовые поля чека: «значения не должны содержать кавычек, знаков &, $, #,
|
||||
* обратных и прямых слэшей» (`cmsspecification.pdf`).
|
||||
*
|
||||
* @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 ReceiptText
|
||||
{
|
||||
private function __construct() {}
|
||||
|
||||
public static function validate(string $value, int $maxLength): string
|
||||
{
|
||||
$maxLength = max(0, $maxLength);
|
||||
$value = self::sanitize($value);
|
||||
if (mb_strlen($value, 'UTF-8') <= $maxLength) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return mb_substr($value, 0, max(0, $maxLength - 3), 'UTF-8') . ($maxLength > 3 ? '...' : '');
|
||||
}
|
||||
|
||||
public static function sanitize(string $value): string
|
||||
{
|
||||
$decoded = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$whitelisted = preg_replace('/[^\p{L}\p{N}\s.,()_№+-]/u', '', $decoded) ?? '';
|
||||
$collapsed = preg_replace('/\s+/u', ' ', $whitelisted) ?? $whitelisted;
|
||||
|
||||
return trim($collapsed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace paygw_moneta\local\receipt;
|
||||
|
||||
/**
|
||||
* Ставка НДС позиции чека. Значение case — код `vat` для JSON-ответа Check URL
|
||||
* (`cmsspecification.pdf`), оно же хранится в настройке шлюза и снимке заказа;
|
||||
* {@see self::tag()} даёт тег `vatTag` для `INVENTORY` Pay URL
|
||||
* (`kassaspecification.pdf`).
|
||||
*
|
||||
* @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 Vat: string
|
||||
{
|
||||
case None = 'none';
|
||||
case Vat0 = 'vat0';
|
||||
case Vat5 = 'vat5';
|
||||
case Vat7 = 'vat7';
|
||||
case Vat10 = 'vat10';
|
||||
case Vat20 = 'vat20';
|
||||
case Vat22 = 'vat22';
|
||||
case Vat105 = 'vat105';
|
||||
case Vat107 = 'vat107';
|
||||
case Vat110 = 'vat110';
|
||||
case Vat120 = 'vat120';
|
||||
case Vat122 = 'vat122';
|
||||
|
||||
/**
|
||||
* Тег `vatTag` для `INVENTORY` Pay URL (`kassaspecification.pdf`).
|
||||
*/
|
||||
public function tag(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::None => '1105',
|
||||
self::Vat0 => '1104',
|
||||
self::Vat5 => '1108',
|
||||
self::Vat7 => '1109',
|
||||
self::Vat10 => '1103',
|
||||
self::Vat20 => '1102',
|
||||
self::Vat22 => '1113',
|
||||
self::Vat105 => '1110',
|
||||
self::Vat107 => '1111',
|
||||
self::Vat110 => '1107',
|
||||
self::Vat120 => '1106',
|
||||
self::Vat122 => '1114',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ключ строки в `lang/{ru,en}/paygw_moneta.php`.
|
||||
*/
|
||||
public function langKey(): string
|
||||
{
|
||||
return 'vat:' . $this->value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user