Files
OpenCart4/catalog/controller/payment/callback.php
T

918 lines
28 KiB
PHP
Executable File
Raw 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 Opencart\Catalog\Controller\Extension\PayAnyWay\Payment;
require_once DIR_EXTENSION . '/payanyway/catalog/library/payanyway/traits/payanyway.php';
/**
* @property \Opencart\System\Engine\Config $config
*/
class Callback extends \Opencart\System\Engine\Controller
{
use \Opencart\Catalog\Library\Extension\PayAnyWay\Traits\PayAnyWay;
private const FAIL_RESPONSE = 'FAIL';
private const XML_CONTENT_TYPE = 'application/xml';
private const JSON_CONTENT_TYPE = 'application/json';
private const TEXT_CONTENT_TYPE = 'text/plain; charset=UTF-8';
public const DESCRIPTION_MAX_LENGTH = 500;
private const ITEM_NAME_MAX_LENGTH = 128;
private const CLIENT_NAME_MAX_LENGTH = 256;
private const CHECK_CLIENT_PHONE_MAX_LENGTH = 13;
private const SERVICE_PAYMENT_OBJECT = 'service';
private const VOUCHER_PAYMENT_METHOD = 'advance';
private const VOUCHER_PAYMENT_OBJECT = 'payment';
private const LEGACY_TRANSACTION_MAX_AGE_DAYS = 14;
private const PAYMENT_ITEM_NAME = 'Оплата заказа №';
private const DEFAULT_MEASURE = 'unit';
private const JSON_NUMBER_PREFIX = '__payanyway_number:';
private const JSON_NUMBER_KEYS = ['amount', 'price'];
private array $callbackData = [];
public function __construct(\Opencart\System\Engine\Registry $registry)
{
parent::__construct($registry);
$this->initializeModule();
}
/** @throws \Exception */
public function index(): void
{
try {
$this->callbackData = $this->getCallbackData($this->getCallbackRequestData());
} catch (\InvalidArgumentException $exception) {
$this->log->write('PayAnyWay callback: ' . $exception->getMessage());
$this->sendResponse(self::FAIL_RESPONSE);
}
$this->validateCallback();
$isCheck = 'CHECK' === $this->callbackData['MNT_COMMAND'];
if (!$this->hasRequiredOperationId()) {
$this->log->write('PayAnyWay callback: MNT_OPERATION_ID is missing or not numeric');
$this->sendResponse(self::FAIL_RESPONSE);
}
if (!$this->checkSignature($this->callbackData)) {
$this->log->write('PayAnyWay callback: MNT_SIGNATURE does not match');
$this->sendResponse(self::FAIL_RESPONSE);
}
$orderId = $this->parseTransactionId($this->callbackData['MNT_TRANSACTION_ID'])['order_id'] ?? null;
if (null === $orderId) {
$this->log->write('PayAnyWay callback: MNT_TRANSACTION_ID has unexpected format');
$this->sendResponse(self::FAIL_RESPONSE);
}
$this->load->model('extension/payanyway/payment/payanyway');
if (!$isCheck && !$this->model_extension_payanyway_payment_payanyway->acquireOrderLock($orderId, self::$orderLockTimeoutSeconds)) {
$this->log->write('PayAnyWay callback: order is locked by another request');
$this->sendResponse(self::FAIL_RESPONSE);
}
$this->loadOrder($orderId);
if ([] === $this->orderInfo) {
$this->sendResponse($isCheck ? $this->buildUnknownOrderResponse() : self::FAIL_RESPONSE);
}
if (!$this->isPaidWithModule()) {
$this->log->write('PayAnyWay callback: order is not paid with PayAnyWay');
$this->sendResponse($isCheck ? $this->buildUnknownOrderResponse() : self::FAIL_RESPONSE);
}
$isCheck ? $this->handleCheckCallback() : $this->handlePayCallback();
}
/**
* @param array<string, mixed> $requestData
* @return array<string, string>
*/
private function getCallbackData(array $requestData): array
{
return [
'MNT_COMMAND' => $this->getOptionalStringWithDefault($requestData, 'MNT_COMMAND'),
'MNT_ID' => $this->getRequiredString($requestData, 'MNT_ID'),
'MNT_TRANSACTION_ID' => $this->getRequiredString($requestData, 'MNT_TRANSACTION_ID'),
'MNT_OPERATION_ID' => $this->getOptionalStringWithDefault($requestData, 'MNT_OPERATION_ID'),
'MNT_AMOUNT' => $this->getOptionalStringWithDefault($requestData, 'MNT_AMOUNT'),
'MNT_CURRENCY_CODE' => $this->getRequiredString($requestData, 'MNT_CURRENCY_CODE'),
'MNT_SUBSCRIBER_ID' => $this->getOptionalStringWithDefault($requestData, 'MNT_SUBSCRIBER_ID'),
'MNT_TEST_MODE' => $this->getRequiredString($requestData, 'MNT_TEST_MODE'),
'MNT_SIGNATURE' => $this->getRequiredString($requestData, 'MNT_SIGNATURE'),
];
}
private function validateCallback(): void
{
if (empty($this->mntId) || empty($this->integrityCode)) {
$this->sendResponse(self::FAIL_RESPONSE);
}
if (!$this->isOwnAccount()) {
$this->sendResponse(self::FAIL_RESPONSE);
}
if ($this->callbackData['MNT_TEST_MODE'] !== ($this->testMode ? '1' : '0')) {
$this->log->write('PayAnyWay callback: MNT_TEST_MODE does not match module settings');
$this->sendResponse(self::FAIL_RESPONSE);
}
}
/**
* @param array{data: string, contentType: string}|string $response
*/
private function sendResponse(array|string $response, int $statusCode = 200): void
{
http_response_code($statusCode);
if (is_string($response)) {
header('Content-Type: ' . self::TEXT_CONTENT_TYPE);
echo $response;
} else {
header('Content-Type: ' . $response['contentType']);
echo $response['data'];
}
exit();
}
/** @throws \Exception */
private function handleCheckCallback(): void
{
if (!$this->isOrderPaid()) {
$this->rejectPaymentMismatch();
}
[$resultCode, $description] = $this->determineCheckResult();
$responseData = $this->buildBaseResponseData($resultCode, $description);
if ($this->fiscalization) {
$responseData['client'] = $this->getClientInfo(true);
$responseData['items'] = $this->getCheckItems($this->getReceiptLines());
$this->sendResponse($this->buildJsonResponse($responseData));
}
$this->sendResponse($this->buildXmlResponse($responseData + $this->getResponseAttributes()));
}
/**
* @return array{0: int, 1: string}
* @throws \Exception
*/
private function determineCheckResult(): array
{
$orderStatusId = isset($this->orderInfo['order_status_id']) ? (int)$this->orderInfo['order_status_id'] : null;
if (null === $orderStatusId) {
return [500, 'Order status not set'];
}
$orderStatus = $this->getOrderStatusById($orderStatusId);
if ('' === $this->callbackData['MNT_AMOUNT']) {
return [100, "Order status is {$orderStatus}"];
}
if ($this->isOrderPaid()) {
return [200, 'Order Paid'];
}
if (in_array($orderStatusId, $this->canceledOrderStatusIds, true)) {
return [500, "Order status is {$orderStatus}"];
}
return [402, 'Order created, but not paid'];
}
/** @throws \Exception */
private function handlePayCallback(): void
{
$isPayedOrder = $this->isOrderPaid();
if (!$isPayedOrder) {
$this->rejectPaymentMismatch();
$this->rejectCanceledOrder();
$this->load->language('extension/payanyway/payment/payanyway');
$this->addOrderHistory($this->orderId, $this->successOrderStatusId, $this->getPaymentCompletedComment($this->callbackData['MNT_OPERATION_ID']));
}
$this->model_extension_payanyway_payment_payanyway->releaseOrderLock($this->orderId);
$description = $isPayedOrder ? 'Order already paid' : 'Order success paid';
$responseData = $this->buildBaseResponseData(200, $description);
$this->sendResponse($this->buildXmlResponse($responseData + $this->getResponseAttributes()));
}
/** Уведомление не согласовано с номером транзакции или заказом — FAIL и запись в лог. */
private function rejectPaymentMismatch(): void
{
$mismatch = $this->getPaymentMismatch();
if (null !== $mismatch) {
$this->log->write('PayAnyWay callback: ' . $mismatch);
$this->sendResponse(self::FAIL_RESPONSE);
}
}
/**
* Заказ отменили до оплаты (менеджер, аннулирование ядром при правке заказа) — оплата его не
* проводит: подписанный 500 без атрибутов прекращает повторы, номер операции в логе нужен
* администратору для возврата в кабинете MONETA.RU. Как Check URL и остальные модули.
*/
private function rejectCanceledOrder(): void
{
$response = $this->buildCanceledOrderPayResponse();
if (null !== $response) {
$this->log->write('PayAnyWay callback: order ' . $this->orderId . ' is canceled, payment ' . $this->callbackData['MNT_OPERATION_ID'] . ' must be refunded');
$this->sendResponse($response);
}
}
/**
* @return array{data: string, contentType: string}|null
* @throws \Exception
*/
private function buildCanceledOrderPayResponse(): ?array
{
$orderStatusId = (int)($this->orderInfo['order_status_id'] ?? 0);
if (!in_array($orderStatusId, $this->canceledOrderStatusIds, true)) {
return null;
}
return $this->buildXmlResponse($this->buildBaseResponseData(500, 'Order status is ' . $this->getOrderStatusById($orderStatusId)), false);
}
private function getPaymentMismatch(): ?string
{
// Форма уходит на MONETA.RU только после confirm(), который переводит заказ в «Ожидание».
// Заказ в статусе 0 ядро правит без блокировки (checkout/confirm) — его не проводим.
if (0 === (int)($this->orderInfo['order_status_id'] ?? 0)) {
return 'order was not confirmed before payment';
}
$transaction = $this->parseTransactionId($this->callbackData['MNT_TRANSACTION_ID']);
if ((null === $transaction) || ($transaction['order_id'] !== $this->orderId)) {
return 'MNT_TRANSACTION_ID has unexpected format';
}
if (null === $transaction['amount']) {
$issuedAt = \DateTimeImmutable::createFromFormat('!YmdHis', $transaction['issued_at']);
if ((false === $issuedAt) || ($issuedAt->modify('+' . self::LEGACY_TRANSACTION_MAX_AGE_DAYS . ' days') < new \DateTimeImmutable())) {
return 'MNT_TRANSACTION_ID of the previous format has expired';
}
} elseif (('' !== $this->callbackData['MNT_AMOUNT']) && ($this->callbackData['MNT_AMOUNT'] !== $transaction['amount'])) {
return 'MNT_AMOUNT does not match MNT_TRANSACTION_ID';
} elseif ($transaction['amount'] !== $this->getOrderAmount()) {
return 'order amount changed after the payment form was issued';
}
$isCheck = 'CHECK' === $this->callbackData['MNT_COMMAND'];
if ((!$isCheck || ('' !== $this->callbackData['MNT_AMOUNT'])) && !$this->isOrderAmountMatched()) {
return 'MNT_AMOUNT or MNT_CURRENCY_CODE does not match the order';
}
return null;
}
private function getPaymentCompletedComment(string $operationId): string
{
$comment = $this->language->get('text_payment_completed');
if (preg_match(self::$operationIdPattern, $operationId)) {
$comment .= '. ' . sprintf($this->language->get('text_payment_operation'), $operationId);
}
return $comment;
}
/**
* @return array<string, string|null>
* @throws \Exception
*/
private function getResponseAttributes(): array
{
$inventory = $this->getInventoryJson($this->getReceiptLines());
$client = $this->getClientInfo($this->fiscalization);
return [
'inventory' => $inventory ?: null,
'client' => $client ? json_encode([$client], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
'customer' => $client['email'] ?? null,
'phone' => isset($client['phone']) ? ltrim($client['phone'], '+') : null,
];
}
/**
* @return array<string, mixed>
* @throws \Exception
*/
private function buildBaseResponseData(int $resultCode, string $description): array
{
$data = [
'MNT_ID' => $this->mntId,
'MNT_TRANSACTION_ID' => $this->callbackData['MNT_TRANSACTION_ID'],
'MNT_RESULT_CODE' => $resultCode,
'MNT_DESCRIPTION' => $this->validateString($description, self::DESCRIPTION_MAX_LENGTH),
'MNT_AMOUNT' => $this->getOrderAmount(),
'MNT_CURRENCY_CODE' => $this->callbackData['MNT_CURRENCY_CODE'],
'MNT_CMS' => $this->getCms(),
];
$signatureSource = $resultCode . $this->mntId . $this->callbackData['MNT_TRANSACTION_ID'] . $this->integrityCode;
$data['MNT_SIGNATURE'] = md5($signatureSource);
return $data;
}
/**
* @return array{data: string, contentType: string}
* @throws \Exception
*/
private function buildUnknownOrderResponse(): array
{
$responseData = $this->buildBaseResponseData(500, 'Order not found');
unset($responseData['MNT_AMOUNT']);
return $this->buildXmlResponse($responseData, false);
}
/**
* Оплата уже проведена: заказ в «Успешном» статусе или был в нём раньше — администратор мог
* перевести оплаченный заказ дальше (Complete, Refunded, Canceled). Повтор уведомления такой
* заказ не трогает.
*/
private function isOrderPaid(): bool
{
if ($this->successOrderStatusId === (int)($this->orderInfo['order_status_id'] ?? 0)) {
return true;
}
$this->load->model('extension/payanyway/payment/payanyway');
return $this->model_extension_payanyway_payment_payanyway->hasOrderStatusInHistory((int)$this->orderId, $this->successOrderStatusId);
}
private function isOwnAccount(): bool
{
return (string)$this->mntId === $this->callbackData['MNT_ID'];
}
private function hasRequiredOperationId(): bool
{
return ('CHECK' === $this->callbackData['MNT_COMMAND'])
|| (1 === preg_match(self::$operationIdPattern, $this->callbackData['MNT_OPERATION_ID']));
}
private function isOrderAmountMatched(): bool
{
return ($this->callbackData['MNT_AMOUNT'] === $this->getOrderAmount())
&& ($this->callbackData['MNT_CURRENCY_CODE'] === (string)($this->orderInfo['currency_code'] ?? ''));
}
/** @param array<string, mixed> $data */
private function checkSignature(array $data): bool
{
$baseFields = [
'MNT_ID',
'MNT_TRANSACTION_ID',
'MNT_OPERATION_ID',
'MNT_AMOUNT',
'MNT_CURRENCY_CODE',
'MNT_SUBSCRIBER_ID',
'MNT_TEST_MODE',
];
$fields = isset($data['MNT_COMMAND']) && ('CHECK' === $data['MNT_COMMAND'])
? array_merge(['MNT_COMMAND'], $baseFields)
: $baseFields;
$signatureString = array_reduce($fields, static function ($carry, $field) use ($data) {
return $carry . $data[$field];
}, '') . $this->integrityCode;
return hash_equals(md5($signatureString), $this->callbackData['MNT_SIGNATURE']);
}
/**
* @return array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}> цены в копейках
* @throws \Exception
*/
private function getReceiptLines(): array
{
$target = $this->toKopecks($this->orderInfo['total'] ?? 0);
if ($target <= 0) {
return [];
}
$lines = $this->balanceReceiptLines($this->collectReceiptLines(), $target);
if ([] !== $lines) {
return $lines;
}
return [
[
'name' => self::PAYMENT_ITEM_NAME . $this->orderId,
'unit' => $target,
'quantity' => 1,
'pm' => $this->paymentMethod,
'po' => $this->paymentObject,
'goods' => true,
],
];
}
/**
* @return array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}>
* @throws \Exception
*/
private function collectReceiptLines(): array
{
$this->load->model('account/order');
$lines = [];
foreach ($this->model_account_order->getProducts($this->orderId) as $product) {
$unit = $this->toKopecks((float)($product['price'] ?? 0) + (float)($product['tax'] ?? 0));
$quantity = (int)($product['quantity'] ?? 0);
if (($unit > 0) && ($quantity > 0)) {
$lines[] = [
'name' => (string)($product['name'] ?? ''),
'unit' => $unit,
'quantity' => $quantity,
'pm' => $this->paymentMethod,
'po' => $this->paymentObject,
'goods' => true,
];
}
}
foreach ($this->getOrderVouchers() as $voucher) {
$unit = $this->toKopecks($voucher['amount'] ?? 0);
if ($unit > 0) {
$lines[] = [
'name' => (string)($voucher['description'] ?? ''),
'unit' => $unit,
'quantity' => 1,
'pm' => self::VOUCHER_PAYMENT_METHOD,
'po' => self::VOUCHER_PAYMENT_OBJECT,
'goods' => false,
];
}
}
foreach ($this->model_account_order->getTotals($this->orderId) as $orderTotal) {
if (in_array($orderTotal['code'] ?? '', ['sub_total', 'tax', 'total'], true)) {
continue;
}
$unit = $this->toKopecks($orderTotal['value'] ?? 0);
if ($unit > 0) {
$lines[] = [
'name' => (string)($orderTotal['title'] ?? ''),
'unit' => $unit,
'quantity' => 1,
'pm' => $this->paymentMethod,
'po' => self::SERVICE_PAYMENT_OBJECT,
'goods' => false,
];
}
}
return $lines;
}
/**
* Проданные в заказе подарочные сертификаты. В OpenCart 4.1.0.0 сертификаты убраны из ядра
* вместе с getVouchers() модели заказа — там их нет
*
* @return array<int, array<string, mixed>>
*/
private function getOrderVouchers(): array
{
$model = $this->model_account_order;
if (!isset($model->getVouchers) && !method_exists($model, 'getVouchers')) {
return [];
}
return $model->getVouchers($this->orderId);
}
/**
* @param array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}> $lines
* @return array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}>
*/
private function balanceReceiptLines(array $lines, int $target): array
{
if ([] === $lines) {
return [];
}
$totals = [];
foreach ($lines as $index => $line) {
$totals[$index] = $line['unit'] * $line['quantity'];
}
if (array_sum($totals) === $target) {
return $lines;
}
foreach ([true, false] as $goodsOnly) {
$adjustable = [];
$fixed = 0;
foreach ($lines as $index => $line) {
if (!$goodsOnly || $line['goods']) {
$adjustable[] = $index;
} else {
$fixed += $totals[$index];
}
}
if (([] === $adjustable) || ($target - $fixed <= 0)) {
continue;
}
return $this->distributeReceiptLines($lines, $totals, $adjustable, $target - $fixed);
}
return [];
}
/**
* Разнос суммы $goal по строкам $adjustable пропорционально их сумме, методом наибольших
* остатков: доли округляются вниз, недостающие копейки — строкам с большим остатком.
*
* @param array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}> $lines
* @param array<int, int> $totals суммы строк в копейках
* @param array<int, int> $adjustable индексы строк, которые можно менять
* @return array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}>
*/
private function distributeReceiptLines(array $lines, array $totals, array $adjustable, int $goal): array
{
$base = 0;
foreach ($adjustable as $index) {
$base += $totals[$index];
}
if ($base <= 0) {
return [];
}
$shares = [];
$remainders = [];
foreach ($adjustable as $index) {
if ($totals[$index] > intdiv(PHP_INT_MAX, $goal)) {
return [];
}
$product = $totals[$index] * $goal;
$shares[$index] = intdiv($product, $base);
$remainders[$index] = $product % $base;
}
arsort($remainders);
$left = $goal - array_sum($shares);
foreach (array_keys($remainders) as $index) {
if ($left <= 0) {
break;
}
$shares[$index]++;
$left--;
}
$balanced = [];
foreach ($lines as $index => $line) {
if (!isset($shares[$index])) {
$balanced[] = $line;
continue;
}
$parts = $this->splitReceiptLine($line, $shares[$index]);
if ([] === $parts) {
return [];
}
foreach ($parts as $part) {
$balanced[] = $part;
}
}
return $balanced;
}
/**
* Строка с новой суммой: целая цена за штуку, остаток копеек — второй строкой той же
* позиции с ценой на копейку выше. Нулевые части отбрасываются.
*
* @param array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool} $line
* @return array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}>
*/
private function splitReceiptLine(array $line, int $total): array
{
$pieces = $line['quantity'];
$unit = intdiv($total, $pieces);
$rest = $total - ($unit * $pieces);
$parts = [];
if ($rest > 0) {
$parts[] = ['unit' => $unit + 1, 'quantity' => $rest] + $line;
}
if (($unit > 0) && ($pieces - $rest > 0)) {
$parts[] = ['unit' => $unit, 'quantity' => $pieces - $rest] + $line;
}
return $parts;
}
/**
* Покупатель в чеке: хотя бы один из email/phone, ФИО — только вместе с контактом.
* Нет ни email, ни телефона — «Email для чеков» (если разрешено и задан), иначе пусто.
*
* @return array<string, string>
*/
private function getClientInfo(bool $withReceiptEmail): array
{
$email = $this->getClientEmail();
$phone = $this->getClientPhone();
if ((null === $email) && (null === $phone) && $withReceiptEmail && ('' !== $this->receiptEmail)) {
$email = $this->receiptEmail;
}
if ((null === $email) && (null === $phone)) {
return [];
}
$clientInfo = [];
$name = $this->getClientName();
if (null !== $name) {
$clientInfo['name'] = $this->validateString($name, self::CLIENT_NAME_MAX_LENGTH);
}
if (null !== $email) {
$clientInfo['email'] = $email;
}
if (null !== $phone) {
$clientInfo['phone'] = $phone;
}
return $clientInfo;
}
/**
* @param array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}> $lines
* @return array<int, array<string, mixed>>
*/
private function getCheckItems(array $lines): array
{
$checkItems = [];
foreach ($lines as $line) {
$checkItems[] = [
'name' => $this->validateString($line['name'], self::ITEM_NAME_MAX_LENGTH),
'price' => self::JSON_NUMBER_PREFIX . $this->formatPrice($line['unit'] / 100),
'quantity' => $line['quantity'],
'vat' => $this->getVat($this->vatRate),
'measure' => self::DEFAULT_MEASURE,
'paymentMethod' => $line['pm'],
'paymentObject' => $line['po'],
];
}
return $checkItems;
}
/**
* @param array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}> $lines
*/
private function getInventoryJson(array $lines): false|string
{
if ([] === $lines) {
return false;
}
$inventory = [];
foreach ($lines as $line) {
$position = [
'name' => $this->validateString($line['name'], self::ITEM_NAME_MAX_LENGTH),
'price' => $this->formatPrice($line['unit'] / 100),
'quantity' => (string)$line['quantity'],
'measure' => self::DEFAULT_MEASURE,
];
if ($this->fiscalization) {
$position += [
'vatTag' => $this->vatRate,
'pm' => $line['pm'],
'po' => $line['po'],
];
}
$inventory[] = $position;
}
return json_encode($inventory, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
/** @throws \Exception */
private function getOrderStatusById(int $orderStatusId): string
{
$this->load->model('localisation/order_status');
/** @var array<int, array{order_status_id: string, name: string}> $orderStatuses */
$orderStatuses = $this->model_localisation_order_status->getOrderStatuses();
foreach ($orderStatuses as $orderStatus) {
if ((int)($orderStatus['order_status_id'] ?? 0) === $orderStatusId) {
return (string)$orderStatus['name'];
}
}
return 'unknown';
}
/**
* @param array<non-empty-string, mixed> $data
* @return array
*/
public function buildJsonResponse(array $data): array
{
$jsonData = [
'id' => $data['MNT_ID'],
'transactionId' => $data['MNT_TRANSACTION_ID'],
'amount' => $data['MNT_AMOUNT'],
'signature' => $data['MNT_SIGNATURE'],
'resultCode' => $data['MNT_RESULT_CODE'],
'description' => $data['MNT_DESCRIPTION'],
'externalId' => $data['MNT_TRANSACTION_ID'],
'receipt' => [
'client' => $data['client'],
'items' => $data['items'],
],
];
$json = json_encode($jsonData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return [
'data' => $json ?: '',
'contentType' => self::JSON_CONTENT_TYPE,
];
}
/**
* @param array<string, mixed> $data
* @return array{data: string, contentType: string}
*/
private function buildJsonResponse(array $data): array
{
$client = $data['client'];
if (isset($client['phone']) && (strlen($client['phone']) > self::CHECK_CLIENT_PHONE_MAX_LENGTH)) {
unset($client['phone']);
}
if (!isset($client['email']) && !isset($client['phone'])) {
$client = [];
}
$jsonData = [
'id' => (string)$data['MNT_ID'],
'transactionId' => $data['MNT_TRANSACTION_ID'],
'amount' => self::JSON_NUMBER_PREFIX . $data['MNT_AMOUNT'],
'signature' => $data['MNT_SIGNATURE'],
'resultCode' => (string)$data['MNT_RESULT_CODE'],
'description' => $data['MNT_DESCRIPTION'],
'externalId' => $data['MNT_TRANSACTION_ID'],
'cms' => $data['MNT_CMS'],
'receipt' => [
'client' => (object)$client,
'items' => $data['items'],
],
];
$json = json_encode($jsonData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (false !== $json) {
$keys = implode('|', self::JSON_NUMBER_KEYS);
$json = preg_replace('/"(' . $keys . ')":"' . preg_quote(self::JSON_NUMBER_PREFIX, '/') . '([0-9]+\.[0-9]{2})"/', '"$1":$2', $json);
}
return [
'data' => (string)$json,
'contentType' => self::JSON_CONTENT_TYPE,
];
}
/**
* @param array<string, mixed> $data
* @return array{data: string, contentType: string}
* @throws \Exception
*/
private function buildXmlResponse(array $data, bool $withAttributes = true): array
{
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->formatOutput = false;
$root = $dom->createElement('MNT_RESPONSE');
$dom->appendChild($root);
$requiredFields = [
'MNT_ID',
'MNT_TRANSACTION_ID',
'MNT_RESULT_CODE',
'MNT_DESCRIPTION',
'MNT_AMOUNT',
'MNT_CURRENCY_CODE',
'MNT_SIGNATURE',
'MNT_CMS',
];
foreach ($requiredFields as $field) {
if (isset($data[$field]) && ($data[$field] !== '')) {
$root->appendChild($this->createTextElement($dom, $field, (string)$data[$field]));
}
}
if ($withAttributes) {
$attributes = $dom->createElement('MNT_ATTRIBUTES');
$root->appendChild($attributes);
$attributeMap = [
'INVENTORY' => $data['inventory'] ?? null,
'CLIENT' => $data['client'] ?? null,
'CUSTOMER' => $data['customer'] ?? null,
'PHONE' => $data['phone'] ?? null,
];
foreach ($attributeMap as $key => $value) {
if (!empty($value)) {
$attrElement = $dom->createElement('ATTRIBUTE');
$attrElement->appendChild($this->createTextElement($dom, 'KEY', $key));
$attrElement->appendChild($this->createTextElement($dom, 'VALUE', (string)$value));
$attributes->appendChild($attrElement);
}
}
}
$xml = $dom->saveXML();
return [
'data' => (false !== $xml) ? $xml : self::FAIL_RESPONSE,
'contentType' => (false !== $xml) ? self::XML_CONTENT_TYPE : self::TEXT_CONTENT_TYPE,
];
}
/** createElement($name, $value) не экранирует «&» — текст только через createTextNode(). */
private function createTextElement(\DOMDocument $dom, string $name, string $value): \DOMElement
{
$element = $dom->createElement($name);
$element->appendChild($dom->createTextNode($value));
return $element;
}
private function getVat($vatRate): string
{
return match ($vatRate) {
'1104' => 'vat0',
'1108' => 'vat5',
'1109' => 'vat7',
'1103' => 'vat10',
'1102' => 'vat20',
'1113' => 'vat22',
'1110' => 'vat105',
'1111' => 'vat107',
'1107' => 'vat110',
'1106' => 'vat120',
'1114' => 'vat122',
default => 'none'
};
}
/** @param array<string, mixed> $params */
private function getOptionalStringWithDefault(array $params, string $key, string $default = ''): string
{
return $this->getOptionalString($params, $key) ?? $default;
}
/** @param array<string, mixed> $params */
private function getOptionalString(array $params, string $key): ?string
{
$value = $params[$key] ?? null;
if (!is_string($value) || trim($value) === '') {
return null;
}
return $value;
}
/**
* @param array<string, mixed> $params
* @throws \InvalidArgumentException
*/
private function getRequiredString(array $params, string $key): string
{
if (!isset($params[$key])) {
throw new \InvalidArgumentException('Missing required field: ' . $key);
}
$value = $params[$key];
if (!is_string($value)) {
throw new \InvalidArgumentException(
sprintf('Field "%s" must be a string, %s given', $key, gettype($value)),
);
}
if ('' === $value) {
throw new \InvalidArgumentException(sprintf('Field "%s" must be a non-empty string', $key));
}
return $value;
}
}