Files

195 lines
6.5 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}