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 $requestData * @return array */ 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 * @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 * @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 $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 цены в копейках * @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 * @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> */ 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 $lines * @return array */ 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 $lines * @param array $totals суммы строк в копейках * @param array $adjustable индексы строк, которые можно менять * @return array */ 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 */ 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 */ 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 $lines * @return array> */ 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 $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 $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 $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 $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 $params */ private function getOptionalStringWithDefault(array $params, string $key, string $default = ''): string { return $this->getOptionalString($params, $key) ?? $default; } /** @param array $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 $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; } }