feat: paygw_moneta 1.0.0, MONETA.Assistant payment gateway for Moodle

This commit is contained in:
2026-09-25 16:42:38 +03:00
parent 70464af858
commit 14b386a550
65 changed files with 5377 additions and 59 deletions
+110
View File
@@ -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;
}
}
+40
View File
@@ -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;
}
}
+23
View File
@@ -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';
}
+32
View File
@@ -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';
}
+156
View File
@@ -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);
}
}
+127
View File
@@ -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'] ?? '')),
);
}
}
+38
View File
@@ -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);
}
}
+60
View File
@@ -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;
}
}