feat: paygw_moneta 1.0.0, MONETA.Assistant payment gateway for Moodle
This commit is contained in:
@@ -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