*/ private $orderInfo; /** @throws \Exception */ public function __construct($registry) { parent::__construct($registry); $this->initializeModule(); $this->orderId = $this->getOrderIdFromSession(); $this->orderInfo = $this->getOrderInfo(); } /** * @return string * @throws \Exception */ public function index() { $this->load->language('extension/payment/payanyway'); $this->document->setTitle($this->language->get('heading_title')); $data['action'] = (self::PAYMENT_SERVER_PROD === $this->server) ? self::PAYMENT_URL_PROD : self::PAYMENT_URL_DEMO; $data['MNT_ID'] = $this->mntId; $data['MNT_AMOUNT'] = $this->getOrderAmount(); $data['MNT_TRANSACTION_ID'] = $this->createTransactionId($data['MNT_AMOUNT']); $data['MNT_CURRENCY_CODE'] = $this->currency; $data['MNT_TEST_MODE'] = $this->testMode ? '1' : '0'; $data['MNT_DESCRIPTION'] = $this->getDescription(); $data['MNT_SUBSCRIBER_ID'] = $this->getSubscriberId(); $data['MNT_SIGNATURE'] = $this->buildFormSignature($data); $data['MNT_SUCCESS_URL'] = $this->url->link('extension/payment/payanyway/success', '', true); $data['MNT_FAIL_URL'] = $this->url->link('extension/payment/payanyway/fail', '', true); $data['MNT_RETURN_URL'] = $this->url->link('checkout/checkout', '', true); $data['moneta_locale'] = $this->language->get('text_locale'); $data['MNT_CMS'] = $this->getCms(); $data['btn_confirm'] = $this->language->get('text_paw_pay'); $data['confirm_url'] = $this->url->link('extension/payment/payanyway/confirm', '', true); return $this->load->view('extension/payment/payanyway', $data); } /** * @param array $data * @return string */ private function buildFormSignature(array $data) { return md5( $data['MNT_ID'] . $data['MNT_TRANSACTION_ID'] . $data['MNT_AMOUNT'] . $data['MNT_CURRENCY_CODE'] . $data['MNT_SUBSCRIBER_ID'] . $data['MNT_TEST_MODE'] . $this->integrityCode ); } /** * @return void * @throws \Exception */ public function confirm() { $json = array(); $this->load->language('extension/payment/payanyway'); $json['success'] = $this->changeStatusUnderOrderLock(true, $this->pendingOrderStatusId, $this->language->get('text_order_confirmed')); $this->response->addHeader('Content-Type: application/json'); $this->response->setOutput(json_encode($json)); } /** * @return void * @throws \Exception */ public function success() { $this->load->language('extension/payment/payanyway'); $this->changeStatusUnderOrderLock(false, $this->pendingOrderStatusId, $this->language->get('text_payment_processing')); $this->response->redirect($this->url->link('checkout/success', '', true)); } /** * @return void * @throws \Exception */ public function fail() { $this->load->language('extension/payment/payanyway'); $this->changeStatusUnderOrderLock(false, $this->errorOrderStatusId, $this->language->get('text_payment_error')); $this->response->redirect($this->url->link('checkout/failure', '', true)); } /** * @return false|array * @throws \Exception */ private function getOrderInfo() { if (null === $this->orderId) { return array(); } $this->load->model('checkout/order'); return $this->model_checkout_order->getOrder($this->orderId); } /** * @param string $amount * @return string * @throws Exception */ private function createTransactionId($amount) { $kopecks = ltrim(str_replace('.', '', $amount), '0'); $issuedAt = new \DateTime('now', new \DateTimeZone(self::TRANSACTION_ID_ZONE)); return $this->orderId . self::TRANSACTION_ID_DELIMITER . (('' !== $kopecks) ? $kopecks : '0') . self::TRANSACTION_ID_DELIMITER . $issuedAt->format('YmdHis') . self::TRANSACTION_ID_ZONE; } /** * @param float $price * @return string */ private function formatPrice($price) { return number_format($price, 2, '.', ''); } /** * @return string */ private function getOrderAmount() { return $this->formatPrice($this->toKopecks(isset($this->orderInfo['total']) ? $this->orderInfo['total'] : 0) / 100); } /** * @return string */ private function getCms() { return sprintf('%s v%s_PHP %s_%s v%s', $this->cmsName, defined('VERSION') ? constant('VERSION') : 'unknown', PHP_VERSION, self::MODULE_NAME, self::MODULE_VERSION ); } /** @return string */ private function getDescription() { $orderId = isset($this->orderInfo['order_id']) ? $this->orderInfo['order_id'] : null; $description = $this->language->get('text_description'); if (!empty($orderId)) { $description .= ' ' . sprintf($this->language->get('text_description_number'), $orderId); } $clientName = $this->getClientName(); if (null !== $clientName) { $description .= ' ' . sprintf($this->language->get('text_description_customer'), $clientName); } $comment = isset($this->orderInfo['comment']) ? $this->orderInfo['comment'] : ''; if ('' !== $comment) { $description .= ': ' . htmlspecialchars($comment, ENT_QUOTES, 'UTF-8'); } return $this->validateString($description, self::DESCRIPTION_MAX_LENGTH); } /** * @param string $value * @param int $maxLength * @return string */ private function validateString($value, $maxLength) { $maxLength = max(0, $maxLength); $value = $this->sanitizeString($value); if (mb_strlen($value, 'UTF-8') <= $maxLength) { return $value; } $trimLength = max(0, $maxLength - 3); return mb_substr($value, 0, $trimLength, 'UTF-8') . ($maxLength > 3 ? '...' : ''); } /** * @param string $value * @return string */ private function sanitizeString($value) { $decoded = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $cleaned = preg_replace('/[^\p{L}\p{N}\s:.,()_№+-]/u', '', $decoded); $cleaned = str_replace(['&', '/', '\\', ';', '%', '#', '"', "'"], '', $cleaned); return trim(preg_replace('/\s+/', ' ', $cleaned)); } /** @return string */ private function getSubscriberId() { $email = $this->getClientEmail(); if (null !== $email) { return $email; } $phone = $this->getClientPhone(); return (null !== $phone) ? $phone : ''; } /** @return bool */ private function isPaidWithModule() { return isset($this->orderInfo['payment_code']) && (self::MODULE_NAME === $this->orderInfo['payment_code']); } /** * @param int $orderStatusId * @return bool */ private function isAwaitingPayment($orderStatusId) { return in_array($orderStatusId, array(0, $this->pendingOrderStatusId, $this->errorOrderStatusId), true); } /** * @param bool $allowFailed подтверждение формы допускает повторную попытку после «Неуспешного» * @param int $orderStatusId * @param string $comment * @return bool * @throws \Exception */ private function changeStatusUnderOrderLock($allowFailed, $orderStatusId, $comment) { if (!$this->isStatusChangeableByCustomer($allowFailed)) { return false; } $this->load->model('extension/payment/payanyway'); if (!$this->model_extension_payment_payanyway->acquireOrderLock($this->orderId, self::ORDER_LOCK_TIMEOUT_SECONDS)) { return false; } $this->orderInfo = $this->getOrderInfo(); $changeable = $this->isStatusChangeableByCustomer($allowFailed) && !$this->model_extension_payment_payanyway->hasOrderStatusInHistory($this->orderId, $this->successOrderStatusId); if ($changeable) { $this->addOrderHistory($orderStatusId, $comment); } $this->model_extension_payment_payanyway->releaseOrderLock($this->orderId); return $changeable; } /** * @param bool $allowFailed * @return bool */ private function isStatusChangeableByCustomer($allowFailed) { if (empty($this->orderInfo) || !$this->isPaidWithModule()) { return false; } $orderStatusId = (int)$this->orderInfo['order_status_id']; return $allowFailed ? $this->isAwaitingPayment($orderStatusId) : in_array($orderStatusId, array(0, $this->pendingOrderStatusId), true); } /** * @param int $orderStatusId * @param string $comment * @return void * @throws \Exception */ private function addOrderHistory($orderStatusId, $comment) { $this->load->model('checkout/order'); $this->model_checkout_order->addOrderHistory($this->orderId, $orderStatusId, $comment); } /** * @return void * @throws \Exception */ public function callback() { try { $callback = $this->getCallbackData($this->getCallbackRequestData()); } catch (\InvalidArgumentException $exception) { $this->log->write('PayAnyWay callback: ' . $exception->getMessage()); $this->sendResponse(self::FAIL_RESPONSE); } $this->validateCallback($callback); if (!$this->hasRequiredOperationId($callback)) { $this->log->write('PayAnyWay callback: MNT_OPERATION_ID is missing or not numeric'); $this->sendResponse(self::FAIL_RESPONSE); } if (!$this->checkSignature($callback, $callback['MNT_SIGNATURE'])) { $this->log->write('PayAnyWay callback: MNT_SIGNATURE does not match'); $this->sendResponse(self::FAIL_RESPONSE); } $transaction = $this->parseTransactionId($callback['MNT_TRANSACTION_ID']); $this->orderId = (null !== $transaction) ? $transaction['order_id'] : null; if (null === $this->orderId) { $this->log->write('PayAnyWay callback: MNT_TRANSACTION_ID has unexpected format'); $this->sendResponse(self::FAIL_RESPONSE); } $isCheck = 'CHECK' === $callback['MNT_COMMAND']; $this->load->model('extension/payment/payanyway'); if (!$isCheck && !$this->model_extension_payment_payanyway->acquireOrderLock($this->orderId, self::ORDER_LOCK_TIMEOUT_SECONDS)) { $this->log->write('PayAnyWay callback: order is locked by another request'); $this->sendResponse(self::FAIL_RESPONSE); } $this->orderInfo = $this->getOrderInfo(); if (empty($this->orderInfo)) { $this->sendResponse($isCheck ? $this->buildUnknownOrderResponse($callback) : self::FAIL_RESPONSE); } if (!$this->isPaidWithModule()) { $this->log->write('PayAnyWay callback: order is not paid with PayAnyWay'); $this->sendResponse($isCheck ? $this->buildUnknownOrderResponse($callback) : self::FAIL_RESPONSE); } $isCheck ? $this->handleCheckCallback($callback) : $this->handlePayCallback($callback); } /** * @param array $requestData * @return array */ private function getCallbackData(array $requestData) { return array( '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'), ); } /** * @return array */ private function getCallbackRequestData() { return array_merge($_GET, $_POST); } /** * @param mixed $transactionId * @return array{order_id: int, amount: string|null, issued_at: string}|null */ private function parseTransactionId($transactionId) { if (!is_string($transactionId)) { return null; } if (preg_match(self::TRANSACTION_ID_PATTERN, $transactionId, $matches)) { $kopecks = str_pad($matches[2], 3, '0', STR_PAD_LEFT); return array( 'order_id' => (int)$matches[1], 'amount' => substr($kopecks, 0, -2) . '.' . substr($kopecks, -2), 'issued_at' => $matches[3], ); } if (preg_match(self::LEGACY_TRANSACTION_ID_PATTERN, $transactionId, $matches) || (preg_match(self::LEGACY_ZONED_TRANSACTION_ID_PATTERN, $transactionId, $matches) && (!isset($matches[3]) || !preg_match('/^[+-]/', $matches[3]) || ($matches[3] === date('T')))) ) { return array('order_id' => (int)$matches[1], 'amount' => null, 'issued_at' => $matches[2]); } return null; } /** @return int|null */ private function getOrderIdFromSession() { return isset($this->session->data['order_id']) ? (int)$this->session->data['order_id'] : null; } /** * @param array|string $response * @param int $statusCode * @return void */ private function sendResponse($response, $statusCode = 200) { 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(); } /** * @param array $callback * @return void */ private function validateCallback(array $callback) { if (empty($this->mntId) || empty($this->integrityCode)) { $this->sendResponse(self::FAIL_RESPONSE); } if (!$this->isOwnAccount($callback)) { $this->sendResponse(self::FAIL_RESPONSE); } if ($callback['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 $callback * @return bool */ private function isOwnAccount(array $callback) { return (string)$this->mntId === $callback['MNT_ID']; } /** * @param array $callback * @return bool */ private function hasRequiredOperationId(array $callback) { return ('CHECK' === $callback['MNT_COMMAND']) || (1 === preg_match(self::OPERATION_ID_PATTERN, $callback['MNT_OPERATION_ID'])); } /** * @param array $data * @return bool */ private function checkSignature(array $data, $callbackSignature) { $baseFields = array( '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, function ($carry, $field) use ($data) { return $carry . $data[$field]; }, '') . $this->integrityCode; return hash_equals(md5($signatureString), $callbackSignature); } /** * @param array $callbackData * @return void * @throws \Exception */ private function handleCheckCallback(array $callbackData) { if (!$this->isOrderPaid()) { $this->rejectPaymentMismatch($callbackData); } list($resultCode, $description) = $this->determineCheckResult($callbackData); $responseData = $this->buildBaseResponseData($callbackData, $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())); } /** * @param array $callbackData * @return void * @throws \Exception */ private function handlePayCallback(array $callbackData) { $isPayedOrder = $this->isOrderPaid(); if (!$isPayedOrder) { $this->rejectPaymentMismatch($callbackData); $this->rejectCanceledOrder($callbackData); $this->load->language('extension/payment/payanyway'); $this->addOrderHistory($this->successOrderStatusId, $this->getPaymentCompletedComment($callbackData['MNT_OPERATION_ID'])); } $this->model_extension_payment_payanyway->releaseOrderLock($this->orderId); $resultCode = 200; $description = $isPayedOrder ? 'Order already paid' : 'Order success paid'; $responseData = $this->buildBaseResponseData($callbackData, $resultCode, $description); $this->sendResponse($this->buildXmlResponse($responseData + $this->getResponseAttributes())); } /** * @param array $callbackData * @return void * @throws \Exception */ private function rejectPaymentMismatch(array $callbackData) { $mismatch = $this->getPaymentMismatch($callbackData); if (null !== $mismatch) { $this->log->write('PayAnyWay callback: ' . $mismatch); $this->sendResponse(self::FAIL_RESPONSE); } } /** * Заказ отменили до оплаты — оплата его не проводит: подписанный 500 без атрибутов прекращает * повторы, номер операции в логе нужен администратору для возврата в кабинете MONETA.RU. * Как Check URL и остальные модули. * * @param array $callbackData * @return void * @throws \Exception */ private function rejectCanceledOrder(array $callbackData) { $response = $this->buildCanceledOrderPayResponse($callbackData); if (null !== $response) { $this->log->write('PayAnyWay callback: order ' . $this->orderId . ' is canceled, payment ' . $callbackData['MNT_OPERATION_ID'] . ' must be refunded'); $this->sendResponse($response); } } /** * @param array $callbackData * @return array{data: string, contentType: string}|null * @throws \Exception */ private function buildCanceledOrderPayResponse(array $callbackData) { $orderStatusId = isset($this->orderInfo['order_status_id']) ? (int)$this->orderInfo['order_status_id'] : 0; if (!in_array($orderStatusId, $this->canceledOrderStatusIds, true)) { return null; } return $this->buildXmlResponse($this->buildBaseResponseData($callbackData, 500, 'Order status is ' . $this->getOrderStatusById($orderStatusId)), false); } /** * @param array $callbackData * @return string|null * @throws \Exception */ private function getPaymentMismatch(array $callbackData) { $transaction = $this->parseTransactionId($callbackData['MNT_TRANSACTION_ID']); if ((null === $transaction) || ($transaction['order_id'] !== $this->orderId)) { return 'MNT_TRANSACTION_ID has unexpected format'; } if (null === $transaction['amount']) { $issuedAt = \DateTime::createFromFormat('!YmdHis', $transaction['issued_at']); if ((false === $issuedAt) || ($issuedAt->modify('+' . self::LEGACY_TRANSACTION_MAX_AGE_DAYS . ' days') < new \DateTime())) { return 'MNT_TRANSACTION_ID of the previous format has expired'; } } elseif (('' !== $callbackData['MNT_AMOUNT']) && ($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' === $callbackData['MNT_COMMAND']; if ((!$isCheck || ('' !== $callbackData['MNT_AMOUNT'])) && !$this->isOrderAmountMatched($callbackData)) { return 'MNT_AMOUNT or MNT_CURRENCY_CODE does not match the order'; } return null; } /** * @param string $operationId * @return string */ private function getPaymentCompletedComment($operationId) { $comment = $this->language->get('text_payment_completed'); if (preg_match('/^[0-9]{1,20}$/', $operationId)) { $comment .= '. ' . sprintf($this->language->get('text_payment_operation'), $operationId); } return $comment; } /** * @return array * @throws \Exception */ private function getResponseAttributes() { $inventory = $this->getInventoryJson($this->getReceiptLines()); $client = $this->getClientInfo($this->fiscalization); return array( 'inventory' => $inventory ?: null, 'client' => $client ? json_encode(array($client), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null, 'customer' => isset($client['email']) ? $client['email'] : null, 'phone' => isset($client['phone']) ? ltrim($client['phone'], '+') : null, ); } /** * @param array $callbackData * @param int $resultCode * @param string $description * @return array * @throws \Exception */ private function buildBaseResponseData(array $callbackData, $resultCode, $description) { $data = array( 'MNT_ID' => $this->mntId, 'MNT_TRANSACTION_ID' => $callbackData['MNT_TRANSACTION_ID'], 'MNT_RESULT_CODE' => $resultCode, 'MNT_DESCRIPTION' => $this->validateString($description, self::DESCRIPTION_MAX_LENGTH), 'MNT_AMOUNT' => $this->getOrderAmount(), 'MNT_CURRENCY_CODE' => $callbackData['MNT_CURRENCY_CODE'], 'MNT_CMS' => $this->getCms(), ); $signatureSource = $resultCode . $this->mntId . $callbackData['MNT_TRANSACTION_ID'] . $this->integrityCode; $data['MNT_SIGNATURE'] = md5($signatureSource); return $data; } /** * @param array $callbackData * @return array * @throws \Exception */ private function buildUnknownOrderResponse(array $callbackData) { $responseData = $this->buildBaseResponseData($callbackData, 500, 'Order not found'); unset($responseData['MNT_AMOUNT']); return $this->buildXmlResponse($responseData, false); } /** * @param array $callbackData * @return array * @throws \Exception */ private function determineCheckResult(array $callbackData) { $orderStatusId = isset($this->orderInfo['order_status_id']) ? (int)$this->orderInfo['order_status_id'] : null; if (null === $orderStatusId) { return array(500, 'Order status not set'); } $orderStatus = $this->getOrderStatusById($orderStatusId); if (empty($callbackData['MNT_AMOUNT'])) { return array(100, "Order status is {$orderStatus}"); } if ($this->isOrderPaid()) { return array(200, 'Order Paid'); } if (in_array($orderStatusId, $this->canceledOrderStatusIds, true)) { return array(500, "Order status is {$orderStatus}"); } return array(402, 'Order created, but not paid'); } /** * @return bool * @throws \Exception */ private function isOrderPaid() { if (!isset($this->orderInfo['order_status_id'])) { return false; } if ($this->successOrderStatusId === (int)$this->orderInfo['order_status_id']) { return true; } $this->load->model('extension/payment/payanyway'); return $this->model_extension_payment_payanyway->hasOrderStatusInHistory((int)$this->orderId, $this->successOrderStatusId); } /** * @param array $callbackData * @return bool * @throws \Exception */ private function isOrderAmountMatched(array $callbackData) { $currencyCode = isset($this->orderInfo['currency_code']) ? (string)$this->orderInfo['currency_code'] : ''; return ($callbackData['MNT_AMOUNT'] === $this->getOrderAmount()) && ($callbackData['MNT_CURRENCY_CODE'] === $currencyCode); } /** * @return array цены в копейках * @throws \Exception */ private function getReceiptLines() { $target = $this->toKopecks(isset($this->orderInfo['total']) ? $this->orderInfo['total'] : 0); if ($target <= 0) { return array(); } $lines = $this->balanceReceiptLines($this->collectReceiptLines(), $target); if (!empty($lines)) { return $lines; } return array( array( '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() { $this->load->model('account/order'); $lines = array(); foreach ($this->model_account_order->getOrderProducts($this->orderId) as $product) { $unit = $this->toKopecks((float)$product['price'] + (float)$product['tax']); $quantity = (int)$product['quantity']; if (($unit > 0) && ($quantity > 0)) { $lines[] = array( 'name' => $product['name'], 'unit' => $unit, 'quantity' => $quantity, 'pm' => $this->paymentMethod, 'po' => $this->paymentObject, 'goods' => true ); } } foreach ($this->model_account_order->getOrderVouchers($this->orderId) as $voucher) { $unit = $this->toKopecks($voucher['amount']); if ($unit > 0) { $lines[] = array( 'name' => $voucher['description'], 'unit' => $unit, 'quantity' => 1, 'pm' => self::VOUCHER_PAYMENT_METHOD, 'po' => self::VOUCHER_PAYMENT_OBJECT, 'goods' => false ); } } foreach ($this->model_account_order->getOrderTotals($this->orderId) as $orderTotal) { if (in_array($orderTotal['code'], array('sub_total', 'tax', 'total'), true)) { continue; } $unit = $this->toKopecks($orderTotal['value']); if ($unit > 0) { $lines[] = array( 'name' => $orderTotal['title'], 'unit' => $unit, 'quantity' => 1, 'pm' => $this->paymentMethod, 'po' => self::SERVICE_PAYMENT_OBJECT, 'goods' => false ); } } return $lines; } /** * @param array $lines * @param int $target сумма заказа в копейках * @return array */ private function balanceReceiptLines(array $lines, $target) { if (empty($lines)) { return array(); } $totals = array(); foreach ($lines as $index => $line) { $totals[$index] = $line['unit'] * $line['quantity']; } if (array_sum($totals) === $target) { return $lines; } foreach (array(true, false) as $goodsOnly) { $adjustable = array(); $fixed = 0; foreach ($lines as $index => $line) { if (!$goodsOnly || $line['goods']) { $adjustable[] = $index; } else { $fixed += $totals[$index]; } } if (empty($adjustable) || ($target - $fixed <= 0)) { continue; } return $this->distributeReceiptLines($lines, $totals, $adjustable, $target - $fixed); } return array(); } /** * Разнос суммы $goal по строкам $adjustable пропорционально их сумме, методом наибольших * остатков: доли округляются вниз, недостающие копейки — строкам с большим остатком. * Целочисленное деление без intdiv() — модуль работает на PHP 5.6. * * @param array $lines * @param array $totals суммы строк в копейках * @param array $adjustable индексы строк, которые можно менять * @param int $goal * @return array */ private function distributeReceiptLines(array $lines, array $totals, array $adjustable, $goal) { $base = 0; foreach ($adjustable as $index) { $base += $totals[$index]; } if ($base <= 0) { return array(); } $shares = array(); $remainders = array(); foreach ($adjustable as $index) { if ($totals[$index] > (int)(PHP_INT_MAX / $goal)) { return array(); } $product = $totals[$index] * $goal; $remainders[$index] = $product % $base; $shares[$index] = ($product - $remainders[$index]) / $base; } arsort($remainders); $left = $goal - array_sum($shares); foreach (array_keys($remainders) as $index) { if ($left <= 0) { break; } $shares[$index]++; $left--; } $balanced = array(); foreach ($lines as $index => $line) { if (!isset($shares[$index])) { $balanced[] = $line; continue; } $parts = $this->splitReceiptLine($line, $shares[$index]); if (empty($parts)) { return array(); } foreach ($parts as $part) { $balanced[] = $part; } } return $balanced; } /** * Строка с новой суммой: целая цена за штуку, остаток копеек — второй строкой той же * позиции с ценой на копейку выше. Нулевые части отбрасываются. * * @param array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool} $line * @param int $total * @return array */ private function splitReceiptLine(array $line, $total) { $pieces = $line['quantity']; $rest = $total % $pieces; $unit = ($total - $rest) / $pieces; $parts = array(); if ($rest > 0) { $parts[] = array('unit' => $unit + 1, 'quantity' => $rest) + $line; } if (($unit > 0) && ($pieces - $rest > 0)) { $parts[] = array('unit' => $unit, 'quantity' => $pieces - $rest) + $line; } return $parts; } /** * @param float|string $amount * @return int */ private function toKopecks($amount) { $currencyValue = isset($this->orderInfo['currency_value']) ? (float)$this->orderInfo['currency_value'] : 0.0; if ($currencyValue <= 0) { $currencyValue = 1.0; } return (int)round((float)$amount * $currencyValue * 100); } /** * @param array $lines * @return false|string */ private function getInventoryJson(array $lines) { if (empty($lines)) { return false; } $inventory = array(); foreach ($lines as $line) { $position = array( 'name' => $this->validateString($line['name'], self::ITEM_NAME_MAX_LENGTH), 'price' => $this->formatPrice($line['unit'] / 100), 'quantity' => $this->formatQuantity($line['quantity']), 'measure' => self::DEFAULT_MEASURE, ); if ($this->fiscalization) { $position += array( 'vatTag' => $this->vatRate, 'pm' => $line['pm'], 'po' => $line['po'], ); } $inventory[] = $position; } return json_encode($inventory, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } /** * Покупатель в чеке: хотя бы один из email/phone, ФИО — только вместе с контактом. * Нет ни email, ни телефона — «Email для чеков» (если разрешено и задан), иначе пусто. * * @param bool $withReceiptEmail * @return array */ private function getClientInfo($withReceiptEmail) { $email = $this->getClientEmail(); $phoneNumber = $this->getClientPhone(); if ( (null === $email) && (null === $phoneNumber) && $withReceiptEmail && ('' !== $this->receiptEmail) ) { $email = $this->receiptEmail; } if ((null === $email) && (null === $phoneNumber)) { return array(); } $clientInfo = array(); $name = $this->getClientName(); if (null !== $name) { $clientInfo['name'] = $name; } if (null !== $email) { $clientInfo['email'] = $email; } if (null !== $phoneNumber) { $clientInfo['phone'] = $phoneNumber; } return $clientInfo; } /** * @param array $lines * @return array */ private function getCheckItems(array $lines) { $checkItems = array(); foreach ($lines as $line) { $checkItems[] = array( 'name' => $this->validateString($line['name'], self::ITEM_NAME_MAX_LENGTH), 'price' => $this->jsonNumber($line['unit']), 'quantity' => $line['quantity'], 'vat' => $this->getVat($this->vatRate), 'measure' => self::DEFAULT_MEASURE, 'paymentMethod' => $line['pm'], 'paymentObject' => $line['po'], ); } return $checkItems; } /** * @param string $vatRate * @return string */ private function getVat($vatRate) { $map = array( '1104' => 'vat0', '1108' => 'vat5', '1109' => 'vat7', '1103' => 'vat10', '1102' => 'vat20', '1113' => 'vat22', '1110' => 'vat105', '1111' => 'vat107', '1107' => 'vat110', '1106' => 'vat120', '1114' => 'vat122', ); return isset($map[$vatRate]) ? $map[$vatRate] : 'none'; } /** * @param int $orderStatusId * @return string * @throws \Exception */ private function getOrderStatusById($orderStatusId) { $this->load->model('localisation/order_status'); /**@var array $orderStatuses */ $orderStatuses = $this->model_localisation_order_status->getOrderStatuses(); foreach ($orderStatuses as $orderStatus) { $statusId = isset($orderStatus['order_status_id']) ? (int)$orderStatus['order_status_id'] : null; if ($statusId === $orderStatusId) { return $orderStatus['name']; } } return 'unknown'; } /** * @param float $quantity * @return string */ private function formatQuantity($quantity) { $quantityString = number_format($quantity, 3, '.', ''); return rtrim(rtrim($quantityString, '0'), '.'); } /** * @param array $data * @return array * @throws \JsonException */ private function buildJsonResponse(array $data) { $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 = array(); } $jsonData = array( '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' => array( 'client' => (object)$client, 'items' => $data['items'], ), ); $json = json_encode($jsonData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (false !== $json) { $json = preg_replace('/"(' . self::JSON_NUMBER_KEYS . ')":"' . preg_quote(self::JSON_NUMBER_PREFIX, '/') . '([0-9]+\.[0-9]{2})"/', '"$1":$2', $json); } return array( 'data' => $json ?: '', 'contentType' => self::JSON_CONTENT_TYPE, ); } /** * @param int $kopecks * @return string */ private function jsonNumber($kopecks) { return self::JSON_NUMBER_PREFIX . $this->formatPrice($kopecks / 100); } /** * @param array $data * @param bool $withAttributes * @return array * @throws \Exception */ private function buildXmlResponse(array $data, $withAttributes = true) { $dom = new \DOMDocument('1.0', 'UTF-8'); $dom->formatOutput = false; $root = $dom->createElement('MNT_RESPONSE'); $dom->appendChild($root); $requiredFields = array( '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) { return $this->saveXmlResponse($dom); } $attributes = $dom->createElement('MNT_ATTRIBUTES'); $root->appendChild($attributes); $attributeMap = array( 'INVENTORY' => isset($data['inventory']) ? $data['inventory'] : null, 'CLIENT' => isset($data['client']) ? $data['client'] : null, 'CUSTOMER' => isset($data['customer']) ? $data['customer'] : null, 'PHONE' => isset($data['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); } } return $this->saveXmlResponse($dom); } /** * createElement($name, $value) не экранирует «&» — текст только через createTextNode(). * * @param \DOMDocument $dom * @param string $name * @param string $value * @return \DOMElement * @throws DOMException */ private function createTextElement(\DOMDocument $dom, $name, $value) { $element = $dom->createElement($name); $element->appendChild($dom->createTextNode($value)); return $element; } /** * @param \DOMDocument $dom * @return array */ private function saveXmlResponse(\DOMDocument $dom) { $xml = $dom->saveXML(); return array( 'data' => (false !== $xml) ? $xml : self::FAIL_RESPONSE, 'contentType' => (false !== $xml) ? self::XML_CONTENT_TYPE : self::TEXT_CONTENT_TYPE, ); } /** * @return non-empty-string|null */ private function getClientName() { $firstname = isset($this->orderInfo['firstname']) ? $this->orderInfo['firstname'] : ''; $lastname = isset($this->orderInfo['lastname']) ? $this->orderInfo['lastname'] : ''; $name = trim($firstname . ' ' . $lastname); return ('' !== $name) ? $this->validateString($name, self::CLIENT_NAME_MAX_LENGTH) : null; } /** * @return non-empty-string|null */ private function getClientEmail() { $email = isset($this->orderInfo['email']) ? $this->orderInfo['email'] : ''; return $this->normalizeEmail($email); } /** * @return non-empty-string|null */ private function getClientPhone() { $telephone = isset($this->orderInfo['telephone']) ? $this->orderInfo['telephone'] : ''; $digits = preg_replace('/\D/', '', trim((string)$telephone)); return (('' !== $digits) && (strlen($digits) + 1 <= self::CLIENT_PHONE_MAX_LENGTH)) ? '+' . $digits : null; } /** * @param mixed $email * @return string|null */ private function normalizeEmail($email) { $email = trim((string)$email); if ( ('' === $email) || (strlen($email) > self::CLIENT_EMAIL_MAX_LENGTH) || (false === filter_var($email, FILTER_VALIDATE_EMAIL)) ) { return null; } return $email; } /** * @param array $params * @param string $key * @param string $default * @return string */ private function getOptionalStringWithDefault(array $params, $key, $default = '') { $optionalString = $this->getOptionalString($params, $key); if (null !== $optionalString) { return $optionalString; } return $default; } /** * @param array $params * @param string $key * @return string|null */ private function getOptionalString(array $params, $key) { $value = isset($params[$key]) ? $params[$key] : null; if (!is_string($value) || trim($value) === '') { return null; } return $value; } /** * @param array $params * @param string $key * @return string * @throws \InvalidArgumentException */ private function getRequiredString(array $params, $key) { 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; } private function initializeModule() { $this->mntId = (int)$this->config->get('payment_payanyway_account'); $this->integrityCode = (string)$this->config->get('payment_payanyway_integrity_code'); $this->server = (string)$this->config->get('payment_payanyway_server'); $this->vatRate = (string)$this->config->get('payment_payanyway_vat_rate'); $this->currency = $this->config->get('payment_payanyway_currency'); $this->pendingOrderStatusId = (int)$this->config->get('payment_payanyway_pending_order_status_id'); $this->successOrderStatusId = (int)$this->config->get('payment_payanyway_success_order_status_id'); $this->errorOrderStatusId = (int)$this->config->get('payment_payanyway_error_order_status_id'); $this->testMode = (bool)$this->config->get('payment_payanyway_test_mode'); $fiscalization = $this->config->get('payment_payanyway_fiscalization'); $this->fiscalization = (null === $fiscalization) ? true : (bool)$fiscalization; $receiptEmail = $this->normalizeEmail($this->config->get('payment_payanyway_receipt_email')); $this->receiptEmail = (null !== $receiptEmail) ? $receiptEmail : ''; $paymentMethod = (string)$this->config->get('payment_payanyway_payment_method'); $this->paymentMethod = ('' !== $paymentMethod) ? $paymentMethod : self::DEFAULT_PAYMENT_METHOD; $paymentObject = (string)$this->config->get('payment_payanyway_payment_object'); $this->paymentObject = ('' !== $paymentObject) ? $paymentObject : self::DEFAULT_PAYMENT_OBJECT; $canceled = $this->config->get('payment_payanyway_canceled_order_status_ids'); if (null === $canceled) { $canceled = explode(',', self::DEFAULT_CANCELED_ORDER_STATUS_IDS); } $this->canceledOrderStatusIds = array_map('intval', (array)$canceled); $this->cmsName = $this->detectCmsName(); } /** * @return string */ private function detectCmsName() { $cmsName = (string)$this->config->get('payment_payanyway_cms_name'); if ('' !== $cmsName) { return $cmsName; } $legacy = (string)$this->config->get('payment_payanyway_cms_module_version'); if (preg_match('/^(.+?) v/', $legacy, $matches)) { return $matches[1]; } return self::CMS_NAME; } }