4 Commits
Author SHA1 Message Date
ivan.sadovin 9886c97876 Merge branch 'main' into 'release'
feat: paygw_moneta 1.0.2, receipt attributes by fiscalization, pm/po settings

See merge request products/cashier/cms/Moodle!3
2026-10-12 13:33:28 +03:00
ivan.sadovin e4e04236cc feat: paygw_moneta 1.0.2, receipt attributes by fiscalization, pm/po settings 2026-10-12 13:24:13 +03:00
ivan.sadovin b4ff4c1ea9 Merge branch 'main' into 'release'
fix: paygw_moneta 1.0.1, simplify checkout card and VAT field

See merge request products/cashier/cms/Moodle!2
2026-10-01 16:23:06 +03:00
ivan.sadovin 2be833119b fix: paygw_moneta 1.0.1, simplify checkout card and VAT field 2026-10-01 16:08:05 +03:00
20 changed files with 239 additions and 108 deletions
+14 -12
View File
@@ -6,7 +6,7 @@
> **© Правообладатель:** НКО «МОНЕТА» (ООО) > **© Правообладатель:** НКО «МОНЕТА» (ООО)
> **📦 Версия модуля: 1.0.0** > **📦 Версия модуля: 1.0.2**
> **ℹ️ Требования:** > **ℹ️ Требования:**
> PHP 8.1+ > PHP 8.1+
@@ -100,17 +100,19 @@
### Реквизиты шлюза в платёжном аккаунте ### Реквизиты шлюза в платёжном аккаунте
| Поле | Значение | | Поле | Значение |
|---------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |---------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Номер счёта** | Обязательно. Ваш номер расширенного счёта в системе [MONETA.RU](https://moneta.ru/) | | **Номер счёта** | Обязательно. Ваш номер расширенного счёта в системе [MONETA.RU](https://moneta.ru/) |
| **Код проверки целостности данных** | Обязательно. Код проверки целостности данных, указанный в настройках расширенного счёта в системе [MONETA.RU](https://moneta.ru/) | | **Код проверки целостности данных** | Обязательно. Код проверки целостности данных, указанный в настройках расширенного счёта в системе [MONETA.RU](https://moneta.ru/) |
| **Сервер платежей** | `Боевой` (по умолчанию) / `Демо` (demo.moneta.ru, отдельный номер счёта) | | **Сервер платежей** | `Боевой` (по умолчанию) / `Демо` (demo.moneta.ru, отдельный номер счёта) |
| **Тестовый режим** | Должен совпадать с флагом «Тестовый режим» в настройках счёта | | **Тестовый режим** | Должен совпадать с флагом «Тестовый режим» в настройках счёта |
| **Email для чеков** | Обязательно. Подставляется в чек, если у покупателя нет ни email, ни телефона — чек придёт на этот адрес, а ФИО покупателя в чеке сохранится | | **Фискализация чеков** | Фискализация чеков по 54-ФЗ средствами системы [MONETA.RU](https://moneta.ru/) сервисом [kassa.payanyway.ru](https://kassa.payanyway.ru/) |
| **Фискализация чеков** | Фискализация чеков по 54-ФЗ средствами системы [MONETA.RU](https://moneta.ru/) сервисом [kassa.payanyway.ru](https://kassa.payanyway.ru/) | | **Email для чеков** | Обязательно при фискализации (без неё поле скрыто). Подставляется в чек, если у покупателя нет ни email, ни телефона — чек придёт на этот адрес, а ФИО покупателя в чеке сохранится |
| **Ставка НДС** | Ставка для позиции «доступ к курсу» в чеке | | **Ставка НДС** | Ставка для позиции чека при фискализации (без неё поле скрыто); по умолчанию «Без НДС» |
| **Отправлять ссылку оплаты на почту** | При создании заказа покупателю приходит сообщение со ссылкой на оплату — можно оплатить позже (по умолчанию включено) | | **Признак способа расчёта** | Для чека при фискализации (без неё поле скрыто); по умолчанию «Полный расчёт» |
| **URL для уведомлений** | `https://<сайт>/payment/gateway/moneta/callback.php` — его нужно указать в настройках расширенного счёта `Check URL` / `Pay URL` в системе [MONETA.RU](https://moneta.ru/) | | **Признак предмета расчёта** | Для чека при фискализации (без неё поле скрыто); по умолчанию «Услуга». Курсы и сертификаты можно разнести по разным платёжным аккаунтам |
| **Отправлять ссылку оплаты на почту** | При создании заказа покупателю приходит сообщение со ссылкой на оплату — можно оплатить позже (по умолчанию включено) |
| **URL для уведомлений** | `https://<сайт>/payment/gateway/moneta/callback.php` — его нужно указать в настройках расширенного счёта `Check URL` / `Pay URL` в системе [MONETA.RU](https://moneta.ru/) |
## 🔧 Настройка счёта в личном кабинете [MONETA.RU](https://moneta.ru/) ## 🔧 Настройка счёта в личном кабинете [MONETA.RU](https://moneta.ru/)
+32 -8
View File
@@ -8,6 +8,8 @@ use core_payment\form\account_gateway;
use paygw_moneta\local\protocol\AssistantProtocol; use paygw_moneta\local\protocol\AssistantProtocol;
use paygw_moneta\local\protocol\PaymentServer; use paygw_moneta\local\protocol\PaymentServer;
use paygw_moneta\local\receipt\Client; use paygw_moneta\local\receipt\Client;
use paygw_moneta\local\receipt\PaymentMethod;
use paygw_moneta\local\receipt\PaymentObject;
use paygw_moneta\local\receipt\Vat; use paygw_moneta\local\receipt\Vat;
use stdClass; use stdClass;
@@ -42,7 +44,7 @@ class gateway extends \core_payment\gateway
$servers = []; $servers = [];
foreach (PaymentServer::cases() as $server) { foreach (PaymentServer::cases() as $server) {
$servers[$server->value] = get_string($server->label(), 'paygw_moneta'); $servers[$server->value] = get_string($server->langKey(), 'paygw_moneta');
} }
$mform->addElement('select', 'paymentserver', get_string('paymentserver', 'paygw_moneta'), $servers); $mform->addElement('select', 'paymentserver', get_string('paymentserver', 'paygw_moneta'), $servers);
$mform->setDefault('paymentserver', PaymentServer::Prod->value); $mform->setDefault('paymentserver', PaymentServer::Prod->value);
@@ -51,21 +53,41 @@ class gateway extends \core_payment\gateway
$mform->addElement('advcheckbox', 'testmode', get_string('testmode', 'paygw_moneta')); $mform->addElement('advcheckbox', 'testmode', get_string('testmode', 'paygw_moneta'));
$mform->addHelpButton('testmode', 'testmode', 'paygw_moneta'); $mform->addHelpButton('testmode', 'testmode', 'paygw_moneta');
$mform->addElement('text', 'receiptemail', get_string('receiptemail', 'paygw_moneta'));
$mform->setType('receiptemail', PARAM_RAW_TRIMMED);
$mform->addRule('receiptemail', null, 'required', null, 'client');
$mform->addHelpButton('receiptemail', 'receiptemail', 'paygw_moneta');
$mform->addElement('advcheckbox', 'fiscalization', get_string('fiscalization', 'paygw_moneta')); $mform->addElement('advcheckbox', 'fiscalization', get_string('fiscalization', 'paygw_moneta'));
$mform->addHelpButton('fiscalization', 'fiscalization', 'paygw_moneta'); $mform->addHelpButton('fiscalization', 'fiscalization', 'paygw_moneta');
// Поля чека нужны только при фискализации. Скрываются, а не блокируются:
// заблокированное поле браузер не отправляет, и значение потерялось бы.
$mform->addElement('text', 'receiptemail', get_string('receiptemail', 'paygw_moneta'));
$mform->setType('receiptemail', PARAM_RAW_TRIMMED);
$mform->addHelpButton('receiptemail', 'receiptemail', 'paygw_moneta');
$mform->hideIf('receiptemail', 'fiscalization', 'notchecked');
$vatOptions = []; $vatOptions = [];
foreach (Vat::cases() as $vat) { foreach (Vat::cases() as $vat) {
$vatOptions[$vat->value] = get_string($vat->langKey(), 'paygw_moneta'); $vatOptions[$vat->value] = get_string($vat->langKey(), 'paygw_moneta');
} }
$mform->addElement('select', 'vat', get_string('vat', 'paygw_moneta'), $vatOptions); $mform->addElement('select', 'vat', get_string('vat', 'paygw_moneta'), $vatOptions);
$mform->addHelpButton('vat', 'vat', 'paygw_moneta');
$mform->setDefault('vat', Vat::None->value); $mform->setDefault('vat', Vat::None->value);
$mform->hideIf('vat', 'fiscalization', 'notchecked');
$methods = [];
foreach (PaymentMethod::cases() as $method) {
$methods[$method->value] = get_string($method->langKey(), 'paygw_moneta');
}
$mform->addElement('select', 'paymentmethod', get_string('paymentmethod', 'paygw_moneta'), $methods);
$mform->setDefault('paymentmethod', PaymentMethod::FullPayment->value);
$mform->addHelpButton('paymentmethod', 'paymentmethod', 'paygw_moneta');
$mform->hideIf('paymentmethod', 'fiscalization', 'notchecked');
$objects = [];
foreach (PaymentObject::cases() as $object) {
$objects[$object->value] = get_string($object->langKey(), 'paygw_moneta');
}
$mform->addElement('select', 'paymentobject', get_string('paymentobject', 'paygw_moneta'), $objects);
$mform->setDefault('paymentobject', PaymentObject::Service->value);
$mform->addHelpButton('paymentobject', 'paymentobject', 'paygw_moneta');
$mform->hideIf('paymentobject', 'fiscalization', 'notchecked');
$mform->addElement('advcheckbox', 'sendpaymentlink', get_string('sendpaymentlink', 'paygw_moneta')); $mform->addElement('advcheckbox', 'sendpaymentlink', get_string('sendpaymentlink', 'paygw_moneta'));
$mform->addHelpButton('sendpaymentlink', 'sendpaymentlink', 'paygw_moneta'); $mform->addHelpButton('sendpaymentlink', 'sendpaymentlink', 'paygw_moneta');
@@ -88,8 +110,10 @@ class gateway extends \core_payment\gateway
$errors['accountnumber'] = get_string('error:accountnumber', 'paygw_moneta'); $errors['accountnumber'] = get_string('error:accountnumber', 'paygw_moneta');
} }
// «Email для чеков» обязателен только при фискализации, но мусор не сохраняем и без неё.
$receiptEmail = trim((string) ($data->receiptemail ?? '')); $receiptEmail = trim((string) ($data->receiptemail ?? ''));
if (Client::normalizeEmail($receiptEmail) === null) { $required = !empty($data->fiscalization);
if (($required || $receiptEmail !== '') && Client::normalizeEmail($receiptEmail) === null) {
$errors['receiptemail'] = get_string('error:receiptemail', 'paygw_moneta'); $errors['receiptemail'] = get_string('error:receiptemail', 'paygw_moneta');
} }
+13 -5
View File
@@ -7,6 +7,8 @@ namespace paygw_moneta\local;
use paygw_moneta\local\protocol\PaymentServer; use paygw_moneta\local\protocol\PaymentServer;
use paygw_moneta\local\protocol\Signature; use paygw_moneta\local\protocol\Signature;
use paygw_moneta\local\receipt\Client; use paygw_moneta\local\receipt\Client;
use paygw_moneta\local\receipt\PaymentMethod;
use paygw_moneta\local\receipt\PaymentObject;
use paygw_moneta\local\receipt\Vat; use paygw_moneta\local\receipt\Vat;
/** /**
@@ -34,6 +36,8 @@ final class GatewayConfig
public readonly bool $enabled, public readonly bool $enabled,
public readonly ?string $receiptEmail = null, public readonly ?string $receiptEmail = null,
public readonly bool $sendPaymentLink = false, public readonly bool $sendPaymentLink = false,
public readonly PaymentMethod $paymentMethod = PaymentMethod::FullPayment,
public readonly PaymentObject $paymentObject = PaymentObject::Service,
) {} ) {}
/** /**
@@ -51,18 +55,22 @@ final class GatewayConfig
$enabled, $enabled,
Client::normalizeEmail((string) ($config['receiptemail'] ?? '')), Client::normalizeEmail((string) ($config['receiptemail'] ?? '')),
!empty($config['sendpaymentlink']), !empty($config['sendpaymentlink']),
PaymentMethod::tryFrom((string) ($config['paymentmethod'] ?? '')) ?? PaymentMethod::FullPayment,
PaymentObject::tryFrom((string) ($config['paymentobject'] ?? '')) ?? PaymentObject::Service,
); );
} }
/** /**
* Можно ли создавать новые заказы: реквизиты и «Email для чеков», без * Можно ли создавать новые заказы: реквизиты, а при фискализации ещё и
* которого чек покупателя без контактов не собрать. Колбэки проверяют * «Email для чеков», без которого чек покупателя без контактов не собрать.
* только {@see isComplete()} — уже оплаченный заказ должен дойти до * Колбэки проверяют только {@see isComplete()} — уже оплаченный заказ
* зачисления, даже если настройку потом очистили. * должен дойти до зачисления, даже если настройку потом очистили.
*/ */
public function acceptsNewOrders(): bool public function acceptsNewOrders(): bool
{ {
return $this->isComplete() && $this->enabled && $this->receiptEmail !== null; return $this->isComplete()
&& $this->enabled
&& (!$this->fiscalization || $this->receiptEmail !== null);
} }
public function isComplete(): bool public function isComplete(): bool
+1 -2
View File
@@ -28,7 +28,7 @@ final class PaymentFormPage
private function __construct() {} private function __construct() {}
/** /**
* @return array{action: string, cost: string, description: string, iscourse: bool, logourl: string, fields: list<array{name: string, value: string}>} * @return array{action: string, cost: string, description: string, logourl: string, fields: list<array{name: string, value: string}>}
*/ */
public static function context(Order $order, GatewayConfig $config): array public static function context(Order $order, GatewayConfig $config): array
{ {
@@ -62,7 +62,6 @@ final class PaymentFormPage
// Сумма для покупателя — как в остальном Moodle: «120,25 ₽». // Сумма для покупателя — как в остальном Moodle: «120,25 ₽».
'cost' => helper::get_cost_as_string((float) $order->amount->toDecimal(), $order->currency), 'cost' => helper::get_cost_as_string((float) $order->amount->toDecimal(), $order->currency),
'description' => $order->snapshot->description, 'description' => $order->snapshot->description,
'iscourse' => $order->component === 'enrol_fee',
'logourl' => $OUTPUT->image_url('img', 'paygw_moneta')->out(false), 'logourl' => $OUTPUT->image_url('img', 'paygw_moneta')->out(false),
'fields' => $fields, 'fields' => $fields,
]; ];
+1 -1
View File
@@ -56,7 +56,7 @@ final class CheckHandler implements NotificationHandler
); );
if (!$order->snapshot->fiscalization) { if (!$order->snapshot->fiscalization) {
return $response->toXml(); return $response->toXml($order->snapshot->receipt);
} }
try { try {
+1
View File
@@ -146,6 +146,7 @@ final class PayHandler implements NotificationHandler
signature: $config->signature(), signature: $config->signature(),
cms: CmsInfo::getCmsModuleVersion(), cms: CmsInfo::getCmsModuleVersion(),
receipt: $receipt, receipt: $receipt,
fiscalization: $order->snapshot->fiscalization,
); );
return $payResponse->toXml(); return $payResponse->toXml();
+10 -3
View File
@@ -76,15 +76,22 @@ final class OrderFactory
email: (string) ($user->email ?? ''), email: (string) ($user->email ?? ''),
phone: self::phone($user), phone: self::phone($user),
name: self::fullName($user), name: self::fullName($user),
fallbackEmail: $config->receiptEmail, fallbackEmail: $config->fiscalization ? $config->receiptEmail : null,
) ?? throw new moodle_exception('gatewaynotfound', 'payment'); );
$snapshot = new OrderSnapshot( $snapshot = new OrderSnapshot(
accountNumber: $config->accountNumber, accountNumber: $config->accountNumber,
testMode: $config->testMode, testMode: $config->testMode,
fiscalization: $config->fiscalization, fiscalization: $config->fiscalization,
vat: $config->vat, vat: $config->vat,
receipt: Receipt::forCourse($itemName, $amount, $client, $config->vat), receipt: Receipt::forCourse(
$itemName,
$amount,
$client,
$config->vat,
$config->paymentMethod,
$config->paymentObject,
),
description: $itemName, description: $itemName,
subscriberId: self::subscriberId($user), subscriberId: self::subscriberId($user),
); );
+6 -2
View File
@@ -31,9 +31,12 @@ final class CheckResponse
} }
/** /**
* XML без атрибутов — для мерчанта без кассового сервиса. * XML — для мерчанта без кассового сервиса
*
* @throws \DOMException
* @throws \JsonException
*/ */
public function toXml(): CallbackResponse public function toXml(?Receipt $receipt = null): CallbackResponse
{ {
return CallbackResponse::xml( return CallbackResponse::xml(
MntResponseXml::build( MntResponseXml::build(
@@ -43,6 +46,7 @@ final class CheckResponse
amount: $this->amount, amount: $this->amount,
signature: $this->sign(), signature: $this->sign(),
cms: $this->cms, cms: $this->cms,
attributes: $receipt?->toResponseAttributes(fiscalization: false) ?? [],
), ),
); );
} }
+2 -12
View File
@@ -22,6 +22,7 @@ final class PayResponse
private readonly Signature $signature, private readonly Signature $signature,
private readonly string $cms, private readonly string $cms,
private readonly ?Receipt $receipt = null, private readonly ?Receipt $receipt = null,
private readonly bool $fiscalization = false,
) {} ) {}
/** /**
@@ -30,17 +31,6 @@ final class PayResponse
*/ */
public function toXml(): CallbackResponse 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( return CallbackResponse::xml(
MntResponseXml::build( MntResponseXml::build(
accountId: $this->accountId, accountId: $this->accountId,
@@ -49,7 +39,7 @@ final class PayResponse
amount: $this->amount, amount: $this->amount,
signature: $this->signature->forResponse($this->resultCode, $this->accountId, $this->transactionId), signature: $this->signature->forResponse($this->resultCode, $this->accountId, $this->transactionId),
cms: $this->cms, cms: $this->cms,
attributes: $attributes, attributes: $this->receipt?->toResponseAttributes($this->fiscalization) ?? [],
), ),
); );
} }
+2 -2
View File
@@ -26,9 +26,9 @@ enum PaymentServer: string
} }
/** /**
* Ключ строки интерфейса (`server_prod`, `server_demo`). * Ключ строки в `lang/{ru,en}/paygw_moneta.php` (`server_prod`, `server_demo`).
*/ */
public function label(): string public function langKey(): string
{ {
return 'server_' . strtolower($this->value); return 'server_' . strtolower($this->value);
} }
+6 -1
View File
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace paygw_moneta\local\receipt; namespace paygw_moneta\local\receipt;
/** /**
* Признак способа расчёта (`pm` / `paymentMethod`), перечень из `cmsspecification.pdf`. * Признак способа расчёта (`pm` / `paymentMethod`), перечень из `kassaspecification.pdf`.
* *
* @package paygw_moneta * @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/} * @copyright 2026 Moneta Labs {@link https://moneta.ru/}
@@ -20,4 +20,9 @@ enum PaymentMethod: string
case PartialPayment = 'partial_payment'; case PartialPayment = 'partial_payment';
case Credit = 'credit'; case Credit = 'credit';
case CreditPayment = 'credit_payment'; case CreditPayment = 'credit_payment';
public function langKey(): string
{
return 'paymentmethod:' . $this->value;
}
} }
+12 -1
View File
@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace paygw_moneta\local\receipt; namespace paygw_moneta\local\receipt;
/** /**
* Признак предмета расчёта (`po` / `paymentObject`), перечень из `cmsspecification.pdf`. * Признак предмета расчёта (`po` / `paymentObject`): перечень `kassaspecification.pdf`
* плюс `composite` из `cmsspecification.pdf`.
* Внереализационный доход — `non_operating`, как у кассы, а не
* `non-operating_gain` из `cmsspecification.pdf`.
* *
* @package paygw_moneta * @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/} * @copyright 2026 Moneta Labs {@link https://moneta.ru/}
@@ -29,4 +32,12 @@ enum PaymentObject: string
case PropertyRight = 'property_right'; case PropertyRight = 'property_right';
case SalesTax = 'sales_tax'; case SalesTax = 'sales_tax';
case ResortFee = 'resort_fee'; case ResortFee = 'resort_fee';
case Award = 'award';
case NonOperating = 'non_operating';
case InsurancePremium = 'insurance_premium';
public function langKey(): string
{
return 'paymentobject:' . $this->value;
}
} }
+57 -19
View File
@@ -10,6 +10,8 @@ use paygw_moneta\local\Money;
/** /**
* Чек 54-ФЗ: покупатель и позиции. Сумма позиций обязана совпадать с суммой * Чек 54-ФЗ: покупатель и позиции. Сумма позиций обязана совпадать с суммой
* операции до копейки — иначе номенклатура не пройдёт валидацию кассы. * операции до копейки — иначе номенклатура не пройдёт валидацию кассы.
* Покупателя нет (`null`), если у него нет ни e-mail, ни телефона, а «Email
* для чеков» не подставлялся: атрибуты покупателя тогда опускаются.
* *
* @package paygw_moneta * @package paygw_moneta
* @copyright 2026 Moneta Labs {@link https://moneta.ru/} * @copyright 2026 Moneta Labs {@link https://moneta.ru/}
@@ -24,7 +26,7 @@ final class Receipt
* @param list<ReceiptItem> $items * @param list<ReceiptItem> $items
*/ */
public function __construct( public function __construct(
public readonly Client $client, public readonly ?Client $client,
array $items, array $items,
) { ) {
if ($items === []) { if ($items === []) {
@@ -40,18 +42,25 @@ final class Receipt
} }
/** /**
* Единственная позиция «доступ к курсу» на всю сумму заказа. * Единственная позиция на всю сумму заказа — доступ к курсу или иной
* оплачиваемый объект Moodle; признаки расчёта — из настроек шлюза.
*/ */
public static function forCourse(string $courseName, Money $amount, Client $client, Vat $vat): self public static function forCourse(
{ string $courseName,
Money $amount,
?Client $client,
Vat $vat,
PaymentMethod $paymentMethod = PaymentMethod::FullPayment,
PaymentObject $paymentObject = PaymentObject::Service,
): self {
return new self($client, [ return new self($client, [
new ReceiptItem( new ReceiptItem(
name: $courseName, name: $courseName,
price: $amount, price: $amount,
quantity: 1, quantity: 1,
vat: $vat, vat: $vat,
paymentMethod: PaymentMethod::FullPayment, paymentMethod: $paymentMethod,
paymentObject: PaymentObject::Service, paymentObject: $paymentObject,
), ),
]); ]);
} }
@@ -72,38 +81,67 @@ final class Receipt
} }
/** /**
* Объект `receipt` JSON-ответа Check URL. * Объект `receipt` JSON-ответа Check URL; без покупателя `client` — пустой
* объект `{}`, как требует схема.
* *
* @return array{client: array<string, string>, items: list<array<string, mixed>>} * @return array{client: array<string, string>|\stdClass, items: list<array<string, mixed>>}
*/ */
public function toCheckReceipt(): array public function toCheckReceipt(): array
{ {
return [ return [
'client' => $this->client->toArray(), 'client' => $this->client?->toArray() ?? new \stdClass(),
'items' => array_map(static fn(ReceiptItem $item): array => $item->toCheckItem(), $this->items), 'items' => array_map(static fn(ReceiptItem $item): array => $item->toCheckItem(), $this->items),
]; ];
} }
/**
* `MNT_ATTRIBUTES` XML-ответа на Check URL и Pay URL: `INVENTORY`, `CLIENT`
* и дубли покупателя `CUSTOMER`/`PHONE`. Без фискализации позиции идут без
* `vatTag`/`pm`/`po`.
*
* @return array<string, string>
* @throws \JsonException
*/
public function toResponseAttributes(bool $fiscalization): array
{
$attributes = [
'INVENTORY' => $this->toInventoryAttribute($fiscalization),
'CLIENT' => $this->toClientAttribute(),
'CUSTOMER' => $this->toCustomerAttribute(),
'PHONE' => $this->toPhoneAttribute(),
];
return array_filter($attributes, static fn(?string $value): bool => $value !== null);
}
/** /**
* Значение атрибута `INVENTORY` — JSON-массив позиций. * Значение атрибута `INVENTORY` — JSON-массив позиций.
* *
* @throws \JsonException * @throws \JsonException
*/ */
public function toInventoryAttribute(): string public function toInventoryAttribute(bool $fiscalization): string
{ {
return json_encode( return json_encode(
array_map(static fn(ReceiptItem $item): array => $item->toInventoryPosition(), $this->items), array_map(
static fn(ReceiptItem $item): array => $item->toInventoryPosition($fiscalization),
$this->items,
),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
); );
} }
/** /**
* Значение атрибута `CLIENT` — JSON-массив из одного объекта. * Значение атрибута `CLIENT` — JSON-массив из одного объекта. `null` —
* покупателя нет.
* *
* @throws \JsonException * @throws \JsonException
*/ */
public function toClientAttribute(): string public function toClientAttribute(): ?string
{ {
if ($this->client === null) {
return null;
}
return json_encode( return json_encode(
[$this->client->toArray()], [$this->client->toArray()],
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
@@ -114,11 +152,11 @@ final class Receipt
* Значение атрибута `CUSTOMER` — e-mail покупателя строкой, по * Значение атрибута `CUSTOMER` — e-mail покупателя строкой, по
* `cmsspecification.pdf`; дублирует `CLIENT.email`, чтобы чек принимался при * `cmsspecification.pdf`; дублирует `CLIENT.email`, чтобы чек принимался при
* любой трактовке документов. `null` — у покупателя * любой трактовке документов. `null` — у покупателя
* только телефон. * только телефон или покупателя нет.
*/ */
public function toCustomerAttribute(): ?string public function toCustomerAttribute(): ?string
{ {
return $this->client->email; return $this->client?->email;
} }
/** /**
@@ -127,18 +165,18 @@ final class Receipt
*/ */
public function toPhoneAttribute(): ?string public function toPhoneAttribute(): ?string
{ {
$phone = $this->client->phone; $phone = $this->client?->phone;
return $phone === null ? null : ltrim($phone, '+'); return $phone === null ? null : ltrim($phone, '+');
} }
/** /**
* @return array{client: array<string, string>, items: list<array<string, string|int>>} * @return array{client: ?array<string, string>, items: list<array<string, string|int>>}
*/ */
public function toArray(): array public function toArray(): array
{ {
return [ return [
'client' => $this->client->toArray(), 'client' => $this->client?->toArray(),
'items' => array_map(static fn(ReceiptItem $item): array => $item->toArray(), $this->items), 'items' => array_map(static fn(ReceiptItem $item): array => $item->toArray(), $this->items),
]; ];
} }
@@ -148,7 +186,7 @@ final class Receipt
*/ */
public static function fromArray(array $data): self public static function fromArray(array $data): self
{ {
$client = Client::fromArray(is_array($data['client'] ?? null) ? $data['client'] : []); $client = is_array($data['client'] ?? null) ? Client::fromArray($data['client']) : null;
$items = array_map(static fn(array $item): ReceiptItem => ReceiptItem::fromArray($item), $data['items'] ?? []); $items = array_map(static fn(array $item): ReceiptItem => ReceiptItem::fromArray($item), $data['items'] ?? []);
return new self($client, $items); return new self($client, $items);
+11 -3
View File
@@ -77,17 +77,25 @@ final class ReceiptItem
/** /**
* Объект `inventPositions` для атрибута `INVENTORY`: цена и количество — * Объект `inventPositions` для атрибута `INVENTORY`: цена и количество —
* строками, как в примере спецификации Moneta. * строками, как в примере спецификации Moneta. `vatTag`/`pm`/`po` — только
* при фискализации: без кассы чек не пробивается, Moneta лишь сохраняет
* позиции в операции.
* *
* @return array<string, string> * @return array<string, string>
*/ */
public function toInventoryPosition(): array public function toInventoryPosition(bool $fiscalization): array
{ {
return [ $position = [
'name' => $this->name, 'name' => $this->name,
'price' => $this->price->toDecimal(), 'price' => $this->price->toDecimal(),
'quantity' => (string) $this->quantity, 'quantity' => (string) $this->quantity,
'measure' => self::MEASURE, 'measure' => self::MEASURE,
];
if (!$fiscalization) {
return $position;
}
return $position + [
'vatTag' => $this->vat->tag(), 'vatTag' => $this->vat->tag(),
'pm' => $this->paymentMethod->value, 'pm' => $this->paymentMethod->value,
'po' => $this->paymentObject->value, 'po' => $this->paymentObject->value,
+1 -1
View File
@@ -168,7 +168,7 @@ class provider implements
'amount' => $order->amount->toDecimal() . ' ' . $order->currency, 'amount' => $order->amount->toDecimal() . ' ' . $order->currency,
'status' => $order->status->value, 'status' => $order->status->value,
'description' => $order->snapshot->description, 'description' => $order->snapshot->description,
'client' => $order->snapshot->receipt->client->toArray(), 'client' => $order->snapshot->receipt->client?->toArray() ?? [],
'timecreated' => transform::datetime($order->timeCreated), 'timecreated' => transform::datetime($order->timeCreated),
'timecompleted' => $order->timeCompleted === null ? null : transform::datetime($order->timeCompleted), 'timecompleted' => $order->timeCompleted === null ? null : transform::datetime($order->timeCompleted),
]; ];
+33 -7
View File
@@ -41,12 +41,11 @@ $string['server_prod'] = 'Production';
$string['testmode'] = 'Test mode'; $string['testmode'] = 'Test mode';
$string['testmode_help'] = 'Simulated payment without charging money. Must match the account setting in MONETA.RU'; $string['testmode_help'] = 'Simulated payment without charging money. Must match the account setting in MONETA.RU';
$string['receiptemail'] = 'Receipt email'; $string['receiptemail'] = 'Receipt email';
$string['receiptemail_help'] = 'Used in the receipt only when the buyer has neither an email nor a phone in their profile: the fiscal service rejects a receipt without a contact, so it is sent to this address instead (the buyer name stays in the receipt)'; $string['receiptemail_help'] = 'Required with fiscal receipts. Used in the receipt only when the buyer has neither an email nor a phone in their profile: the fiscal service rejects a receipt without a contact, so it is sent to this address instead (the buyer name stays in the receipt)';
$string['error:receiptemail'] = 'Enter a valid email address of at most 64 characters'; $string['error:receiptemail'] = 'Enter a valid email address of at most 64 characters';
$string['fiscalization'] = 'Fiscal receipts'; $string['fiscalization'] = 'Fiscal receipts';
$string['fiscalization_help'] = 'Enable only if the kassa.payanyway.ru service is connected to the account: the check request response will contain receipt data'; $string['fiscalization_help'] = 'Enable only if the kassa.payanyway.ru service is connected to the account';
$string['vat'] = 'VAT rate'; $string['vat'] = 'VAT rate';
$string['vat_help'] = 'VAT rate for the "course access" receipt line';
$string['vat:none'] = 'No VAT'; $string['vat:none'] = 'No VAT';
$string['vat:vat0'] = 'VAT 0%'; $string['vat:vat0'] = 'VAT 0%';
$string['vat:vat5'] = 'VAT 5%'; $string['vat:vat5'] = 'VAT 5%';
@@ -59,16 +58,43 @@ $string['vat:vat107'] = 'VAT 7% (calculated rate 7/107)';
$string['vat:vat110'] = 'VAT 10% (calculated rate 10/110)'; $string['vat:vat110'] = 'VAT 10% (calculated rate 10/110)';
$string['vat:vat120'] = 'VAT 20% (calculated rate 20/120)'; $string['vat:vat120'] = 'VAT 20% (calculated rate 20/120)';
$string['vat:vat122'] = 'VAT 22% (calculated rate 22/122)'; $string['vat:vat122'] = 'VAT 22% (calculated rate 22/122)';
$string['paymentmethod'] = 'Payment method';
$string['paymentmethod_help'] = 'Sent in the fiscal receipt. A Moodle payment always covers the full price: usually "Full payment"; "Full prepayment" or "Advance" if the item is delivered after payment';
$string['paymentmethod:full_prepayment'] = 'Full prepayment';
$string['paymentmethod:prepayment'] = 'Partial prepayment';
$string['paymentmethod:advance'] = 'Advance';
$string['paymentmethod:full_payment'] = 'Full payment';
$string['paymentmethod:partial_payment'] = 'Partial payment and credit';
$string['paymentmethod:credit'] = 'Credit';
$string['paymentmethod:credit_payment'] = 'Credit payment';
$string['paymentobject'] = 'Payment object';
$string['paymentobject_help'] = 'Sent in the fiscal receipt. Course access is usually "Service". Different kinds of items (courses, certificates) can be split across Moodle payment accounts, each with its own value';
$string['paymentobject:commodity'] = 'Commodity';
$string['paymentobject:excise'] = 'Excise commodity';
$string['paymentobject:job'] = 'Job';
$string['paymentobject:service'] = 'Service';
$string['paymentobject:gambling_bet'] = 'Gambling bet';
$string['paymentobject:gambling_prize'] = 'Gambling prize';
$string['paymentobject:lottery'] = 'Lottery ticket';
$string['paymentobject:lottery_prize'] = 'Lottery prize';
$string['paymentobject:intellectual_activity'] = 'Intellectual property';
$string['paymentobject:payment'] = 'Payment (advance, deposit, prepayment, credit)';
$string['paymentobject:agent_commission'] = 'Agent commission';
$string['paymentobject:composite'] = 'Composite payment object';
$string['paymentobject:another'] = 'Other payment object';
$string['paymentobject:property_right'] = 'Property right';
$string['paymentobject:sales_tax'] = 'Sales tax';
$string['paymentobject:resort_fee'] = 'Resort fee';
$string['paymentobject:award'] = 'Contribution, penalty, fine, reward, bonus';
$string['paymentobject:non_operating'] = 'Non-operating income';
$string['paymentobject:insurance_premium'] = 'Insurance premium';
$string['sendpaymentlink'] = 'Send payment link by email'; $string['sendpaymentlink'] = 'Send payment link by email';
$string['sendpaymentlink_help'] = 'If enabled, the buyer receives a message with a payment link when an order is created, so they can pay later. The link works while the order is unpaid and the price and credentials are unchanged. The channel (email, site notification) follows Moodle notification preferences'; $string['sendpaymentlink_help'] = 'If enabled, the buyer receives a message with a payment link when an order is created, so they can pay later. The link works while the order is unpaid and the price and credentials are unchanged. The channel (email, site notification) follows Moodle notification preferences';
$string['callbackurl'] = 'Notification URL'; $string['callbackurl'] = 'Notification URL';
$string['callbackurl:copied'] = 'Notification URL copied'; $string['callbackurl:copied'] = 'Notification URL copied';
$string['callbackurl_help'] = 'Set this address as both Pay URL and Check URL in the MONETA.RU account settings. It must be reachable from the internet over HTTPS'; $string['callbackurl_help'] = 'Set this address as both Pay URL and Check URL in the MONETA.RU account settings. It must be reachable from the internet over HTTPS';
$string['checkout:access'] = 'Access to the course opens automatically after payment.'; $string['checkout:secure'] = "You will be taken to the secure {$string['pluginname']} payment page";
$string['checkout:course'] = 'Course payment';
$string['checkout:item'] = 'Payment';
$string['checkout:secure'] = "You will be taken to the secure {$string['pluginname']} payment page.";
$string['checkout:total'] = 'Amount due'; $string['checkout:total'] = 'Amount due';
$string['confirmpayment'] = "Proceeding to {$string['pluginname']} payment"; $string['confirmpayment'] = "Proceeding to {$string['pluginname']} payment";
$string['pay'] = 'Pay {$a}'; $string['pay'] = 'Pay {$a}';
+33 -7
View File
@@ -41,12 +41,11 @@ $string['server_prod'] = 'Боевой';
$string['testmode'] = 'Тестовый режим'; $string['testmode'] = 'Тестовый режим';
$string['testmode_help'] = 'Имитация оплаты без реального списания средств. Режим должен совпадать с настройкой счёта в MONETA.RU'; $string['testmode_help'] = 'Имитация оплаты без реального списания средств. Режим должен совпадать с настройкой счёта в MONETA.RU';
$string['receiptemail'] = 'Email для чеков'; $string['receiptemail'] = 'Email для чеков';
$string['receiptemail_help'] = 'Подставляется в чек, только если у покупателя в профиле нет ни e-mail, ни телефона: без контакта касса чек не примет, поэтому он уйдёт на этот адрес (ФИО покупателя в чеке остаётся)'; $string['receiptemail_help'] = 'Обязателен при фискализации. Подставляется в чек, только если у покупателя в профиле нет ни e-mail, ни телефона: без контакта касса чек не примет, поэтому он уйдёт на этот адрес (ФИО покупателя в чеке остаётся)';
$string['error:receiptemail'] = 'Укажите корректный e-mail не длиннее 64 символов'; $string['error:receiptemail'] = 'Укажите корректный e-mail не длиннее 64 символов';
$string['fiscalization'] = 'Фискализация чеков'; $string['fiscalization'] = 'Фискализация чеков';
$string['fiscalization_help'] = 'Включайте только если к счёту подключён кассовый сервис kassa.payanyway.ru: ответ на проверочный запрос будет содержать данные чека по 54-ФЗ'; $string['fiscalization_help'] = 'Включайте только если к счёту подключён кассовый сервис kassa.payanyway.ru';
$string['vat'] = 'Ставка НДС'; $string['vat'] = 'Ставка НДС';
$string['vat_help'] = 'Ставка НДС для позиции «доступ к курсу» в кассовом чеке';
$string['vat:none'] = 'Без НДС'; $string['vat:none'] = 'Без НДС';
$string['vat:vat0'] = 'НДС 0%'; $string['vat:vat0'] = 'НДС 0%';
$string['vat:vat5'] = 'НДС 5%'; $string['vat:vat5'] = 'НДС 5%';
@@ -59,16 +58,43 @@ $string['vat:vat107'] = 'НДС 7% (расчётная ставка 7/107)';
$string['vat:vat110'] = 'НДС 10% (расчётная ставка 10/110)'; $string['vat:vat110'] = 'НДС 10% (расчётная ставка 10/110)';
$string['vat:vat120'] = 'НДС 20% (расчётная ставка 20/120)'; $string['vat:vat120'] = 'НДС 20% (расчётная ставка 20/120)';
$string['vat:vat122'] = 'НДС 22% (расчётная ставка 22/122)'; $string['vat:vat122'] = 'НДС 22% (расчётная ставка 22/122)';
$string['paymentmethod'] = 'Признак способа расчёта';
$string['paymentmethod_help'] = 'Передаётся в чек при фискализации. Оплата в Moodle всегда на полную стоимость: обычно «Полный расчёт»; «Полная предоплата» или «Аванс» — если предмет расчёта передаётся позже оплаты';
$string['paymentmethod:full_prepayment'] = 'Полная предоплата';
$string['paymentmethod:prepayment'] = 'Частичная предоплата';
$string['paymentmethod:advance'] = 'Аванс';
$string['paymentmethod:full_payment'] = 'Полный расчёт';
$string['paymentmethod:partial_payment'] = 'Частичный расчёт и кредит';
$string['paymentmethod:credit'] = 'Передача в кредит';
$string['paymentmethod:credit_payment'] = 'Оплата кредита';
$string['paymentobject'] = 'Признак предмета расчёта';
$string['paymentobject_help'] = 'Передаётся в чек при фискализации. Для доступа к курсу обычно «Услуга». Разные виды товаров (курсы, сертификаты) можно разнести по разным платёжным аккаунтам Moodle со своим признаком в каждом';
$string['paymentobject:commodity'] = 'Товар';
$string['paymentobject:excise'] = 'Подакцизный товар';
$string['paymentobject:job'] = 'Работа';
$string['paymentobject:service'] = 'Услуга';
$string['paymentobject:gambling_bet'] = 'Ставка азартной игры';
$string['paymentobject:gambling_prize'] = 'Выигрыш азартной игры';
$string['paymentobject:lottery'] = 'Лотерейный билет';
$string['paymentobject:lottery_prize'] = 'Выигрыш лотереи';
$string['paymentobject:intellectual_activity'] = 'Результаты интеллектуальной деятельности';
$string['paymentobject:payment'] = 'Платёж (аванс, задаток, предоплата, кредит)';
$string['paymentobject:agent_commission'] = 'Агентское вознаграждение';
$string['paymentobject:composite'] = 'Составной предмет расчёта';
$string['paymentobject:another'] = 'Иной предмет расчёта';
$string['paymentobject:property_right'] = 'Имущественное право';
$string['paymentobject:sales_tax'] = 'Торговый сбор';
$string['paymentobject:resort_fee'] = 'Курортный сбор';
$string['paymentobject:award'] = 'Взнос в счёт оплаты, пени, штраф, вознаграждение, бонус';
$string['paymentobject:non_operating'] = 'Внереализационный доход';
$string['paymentobject:insurance_premium'] = 'Страховой сбор';
$string['sendpaymentlink'] = 'Отправлять ссылку оплаты на почту'; $string['sendpaymentlink'] = 'Отправлять ссылку оплаты на почту';
$string['sendpaymentlink_help'] = 'Если включено, при создании заказа покупателю приходит сообщение со ссылкой на оплату — оплатить можно позже. Ссылка действует, пока заказ не оплачен и не изменились цена или реквизиты. Канал (почта, уведомление на сайте) — по настройкам уведомлений Moodle'; $string['sendpaymentlink_help'] = 'Если включено, при создании заказа покупателю приходит сообщение со ссылкой на оплату — оплатить можно позже. Ссылка действует, пока заказ не оплачен и не изменились цена или реквизиты. Канал (почта, уведомление на сайте) — по настройкам уведомлений Moodle';
$string['callbackurl'] = 'URL для уведомлений'; $string['callbackurl'] = 'URL для уведомлений';
$string['callbackurl:copied'] = 'URL для уведомлений скопирован'; $string['callbackurl:copied'] = 'URL для уведомлений скопирован';
$string['callbackurl_help'] = 'Укажите этот адрес в настройках счёта MONETA.RU как Pay URL и Check URL. Он должен быть доступен из интернета по HTTPS'; $string['callbackurl_help'] = 'Укажите этот адрес в настройках счёта MONETA.RU как Pay URL и Check URL. Он должен быть доступен из интернета по HTTPS';
$string['checkout:access'] = 'Доступ к курсу откроется автоматически после оплаты.'; $string['checkout:secure'] = "Вы перейдёте на защищённую страницу оплаты {$string['pluginname']}";
$string['checkout:course'] = 'Оплата курса';
$string['checkout:item'] = 'Оплата';
$string['checkout:secure'] = "Вы перейдёте на защищённую страницу оплаты {$string['pluginname']}.";
$string['checkout:total'] = 'К оплате'; $string['checkout:total'] = 'К оплате';
$string['confirmpayment'] = "Переход к оплате через {$string['pluginname']}"; $string['confirmpayment'] = "Переход к оплате через {$string['pluginname']}";
$string['pay'] = 'Оплатить {$a}'; $string['pay'] = 'Оплатить {$a}';
-9
View File
@@ -23,15 +23,6 @@
max-width: 100%; max-width: 100%;
} }
.paygw-moneta-checkout-label {
margin-bottom: 0.5rem;
color: var(--bs-secondary-color, #6c757d);
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* Название курса бывает длинным: переносим по словам, а слишком длинное слово — где угодно. */ /* Название курса бывает длинным: переносим по словам, а слишком длинное слово — где угодно. */
.paygw-moneta-checkout-title { .paygw-moneta-checkout-title {
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
+2 -11
View File
@@ -18,7 +18,7 @@
@template paygw_moneta/payment_form @template paygw_moneta/payment_form
Карточка перехода на платёжную страницу MONETA.Assistant: логотип со Карточка перехода на платёжную страницу MONETA.Assistant: логотип со
ссылкой на moneta.ru, что оплачивается, сумма, кнопка и пояснение. Сама не ссылкой на moneta.ru, название, сумма, кнопка и пояснение. Сама не
уходит — покупатель нажимает «Оплатить»; после нажатия кнопка блокируется, уходит — покупатель нажимает «Оплатить»; после нажатия кнопка блокируется,
чтобы повторный клик не отправил форму дважды. Название может быть длинным: чтобы повторный клик не отправил форму дважды. Название может быть длинным:
переносится, не растягивая карточку. переносится, не растягивая карточку.
@@ -27,7 +27,6 @@
* action - адрес платёжной формы * action - адрес платёжной формы
* cost - сумма с валютой для показа * cost - сумма с валютой для показа
* description - описание заказа * description - описание заказа
* iscourse - оплачивается запись на курс (enrol_fee)
* logourl - адрес логотипа * logourl - адрес логотипа
* fields - подписанные поля формы: name, value * fields - подписанные поля формы: name, value
@@ -36,7 +35,6 @@
"action": "https://demo.moneta.ru/assistant.htm", "action": "https://demo.moneta.ru/assistant.htm",
"cost": "120,25 ₽", "cost": "120,25 ₽",
"description": "Монета", "description": "Монета",
"iscourse": true,
"logourl": "https://example.test/theme/image.php/boost/paygw_moneta/1/img", "logourl": "https://example.test/theme/image.php/boost/paygw_moneta/1/img",
"fields": [{"name": "MNT_ID", "value": "54600817"}] "fields": [{"name": "MNT_ID", "value": "54600817"}]
} }
@@ -47,10 +45,6 @@
<img class="paygw-moneta-checkout-logo" src="{{logourl}}" alt="{{#str}}pluginname, paygw_moneta{{/str}}"> <img class="paygw-moneta-checkout-logo" src="{{logourl}}" alt="{{#str}}pluginname, paygw_moneta{{/str}}">
<span class="sr-only visually-hidden">{{#str}}opensinnewwindow{{/str}}</span> <span class="sr-only visually-hidden">{{#str}}opensinnewwindow{{/str}}</span>
</a> </a>
<div class="paygw-moneta-checkout-label">
{{#iscourse}}{{#str}}checkout:course, paygw_moneta{{/str}}{{/iscourse}}
{{^iscourse}}{{#str}}checkout:item, paygw_moneta{{/str}}{{/iscourse}}
</div>
<h3 class="paygw-moneta-checkout-title">{{description}}</h3> <h3 class="paygw-moneta-checkout-title">{{description}}</h3>
<div class="paygw-moneta-checkout-total"> <div class="paygw-moneta-checkout-total">
<span class="text-muted">{{#str}}checkout:total, paygw_moneta{{/str}}</span> <span class="text-muted">{{#str}}checkout:total, paygw_moneta{{/str}}</span>
@@ -64,10 +58,7 @@
</form> </form>
<div class="paygw-moneta-checkout-note text-muted small"> <div class="paygw-moneta-checkout-note text-muted small">
{{#pix}}i/lock, core{{/pix}} {{#pix}}i/lock, core{{/pix}}
<div> <div>{{#str}}checkout:secure, paygw_moneta{{/str}}</div>
<div>{{#str}}checkout:secure, paygw_moneta{{/str}}</div>
{{#iscourse}}<div class="mt-1">{{#str}}checkout:access, paygw_moneta{{/str}}</div>{{/iscourse}}
</div>
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -25,7 +25,7 @@
defined('MOODLE_INTERNAL') || die(); defined('MOODLE_INTERNAL') || die();
$plugin->component = 'paygw_moneta'; $plugin->component = 'paygw_moneta';
$plugin->version = 2026092500; $plugin->version = 2026100802;
$plugin->requires = 2023100900; $plugin->requires = 2023100900;
$plugin->maturity = MATURITY_STABLE; $plugin->maturity = MATURITY_STABLE;
$plugin->release = '1.0.0'; $plugin->release = '1.0.2';