195 lines
6.5 KiB
PHP
195 lines
6.5 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace paygw_moneta\local\receipt;
|
||
|
||
use InvalidArgumentException;
|
||
use paygw_moneta\local\Money;
|
||
|
||
/**
|
||
* Чек 54-ФЗ: покупатель и позиции. Сумма позиций обязана совпадать с суммой
|
||
* операции до копейки — иначе номенклатура не пройдёт валидацию кассы.
|
||
* Покупателя нет (`null`), если у него нет ни e-mail, ни телефона, а «Email
|
||
* для чеков» не подставлялся: атрибуты покупателя тогда опускаются.
|
||
*
|
||
* @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);
|
||
}
|
||
|
||
/**
|
||
* Единственная позиция на всю сумму заказа — доступ к курсу или иной
|
||
* оплачиваемый объект Moodle; признаки расчёта — из настроек шлюза.
|
||
*/
|
||
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, [
|
||
new ReceiptItem(
|
||
name: $courseName,
|
||
price: $amount,
|
||
quantity: 1,
|
||
vat: $vat,
|
||
paymentMethod: $paymentMethod,
|
||
paymentObject: $paymentObject,
|
||
),
|
||
]);
|
||
}
|
||
|
||
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; без покупателя `client` — пустой
|
||
* объект `{}`, как требует схема.
|
||
*
|
||
* @return array{client: array<string, string>|\stdClass, items: list<array<string, mixed>>}
|
||
*/
|
||
public function toCheckReceipt(): array
|
||
{
|
||
return [
|
||
'client' => $this->client?->toArray() ?? new \stdClass(),
|
||
'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-массив позиций.
|
||
*
|
||
* @throws \JsonException
|
||
*/
|
||
public function toInventoryAttribute(bool $fiscalization): string
|
||
{
|
||
return json_encode(
|
||
array_map(
|
||
static fn(ReceiptItem $item): array => $item->toInventoryPosition($fiscalization),
|
||
$this->items,
|
||
),
|
||
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Значение атрибута `CLIENT` — JSON-массив из одного объекта. `null` —
|
||
* покупателя нет.
|
||
*
|
||
* @throws \JsonException
|
||
*/
|
||
public function toClientAttribute(): ?string
|
||
{
|
||
if ($this->client === null) {
|
||
return null;
|
||
}
|
||
|
||
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 = is_array($data['client'] ?? null) ? Client::fromArray($data['client']) : null;
|
||
$items = array_map(static fn(array $item): ReceiptItem => ReceiptItem::fromArray($item), $data['items'] ?? []);
|
||
|
||
return new self($client, $items);
|
||
}
|
||
}
|