Files
OpenCart3/upload/catalog/controller/extension/payment/payanyway.php
T

1255 lines
36 KiB
PHP
Executable File

<?php
/**
* @property ModelCheckoutOrder $model_checkout_order
* @property ModelAccountOrder $model_account_order
* @property ModelLocalisationOrderStatus $model_localisation_order_status
*/
class ControllerExtensionPaymentPayanyway extends Controller {
const MODULE_NAME = 'payanyway';
const MODULE_VERSION = '1.0.5';
const CMS_NAME = 'OpenCart';
const PAYMENT_SERVER_PROD = 'prod';
const PAYMENT_URL_PROD = 'https://www.payanyway.ru/assistant.htm';
const PAYMENT_URL_DEMO = 'https://demo.moneta.ru/assistant.htm';
const FAIL_RESPONSE = 'FAIL';
const XML_CONTENT_TYPE = 'application/xml';
const JSON_CONTENT_TYPE = 'application/json';
const TEXT_CONTENT_TYPE = 'text/plain; charset=UTF-8';
const TRANSACTION_ID_DELIMITER = '_';
const LEGACY_TRANSACTION_ID_DELIMITER = '|';
const TRANSACTION_ID_PATTERN = '/^([1-9][0-9]*)[%s]([0-9]{14})([A-Z0-9+-]{1,6})?$/';
const DESCRIPTION_MAX_LENGTH = 500;
const CLIENT_NAME_MAX_LENGTH = 256;
const CLIENT_EMAIL_MAX_LENGTH = 64;
const CLIENT_PHONE_MAX_LENGTH = 19;
const CHECK_CLIENT_PHONE_MAX_LENGTH = 13;
const ITEM_NAME_MAX_LENGTH = 128;
const DEFAULT_PAYMENT_METHOD = 'full_payment';
const DEFAULT_PAYMENT_OBJECT = 'commodity';
const SERVICE_PAYMENT_OBJECT = 'service';
const VOUCHER_PAYMENT_METHOD = 'advance';
const VOUCHER_PAYMENT_OBJECT = 'payment';
const PAYMENT_ITEM_NAME = 'Оплата заказа №';
const DEFAULT_MEASURE = 'unit';
const DEFAULT_CANCELED_ORDER_STATUS_IDS = '7,9,16';
const JSON_NUMBER_PREFIX = '__payanyway_number:';
/** @var int */
private $mntId;
/** @var string */
private $integrityCode;
/** @var string */
private $server;
/** @var string */
private $vatRate;
/** @var string */
private $currency;
/** @var int */
private $pendingOrderStatusId;
/** @var int */
private $successOrderStatusId;
/** @var int */
private $errorOrderStatusId;
/** @var bool */
private $testMode;
/** @var bool */
private $fiscalization;
/** @var string «Email для чеков»: подставляется, если у покупателя нет ни email, ни телефона */
private $receiptEmail;
/** @var string */
private $paymentMethod;
/** @var string */
private $paymentObject;
/** @var int[] */
private $canceledOrderStatusIds;
/** @var string */
private $cmsName;
/** @var int|null */
private $orderId;
/** @var array<string, mixed> */
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_TRANSACTION_ID'] = $this->createTransactionId();
$data['MNT_AMOUNT'] = $this->getOrderAmount();
$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<string, mixed> $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');
$orderStatusId = isset($this->orderInfo['order_status_id']) ? (int)$this->orderInfo['order_status_id'] : null;
$paymentCode = isset($this->orderInfo['payment_code']) ? $this->orderInfo['payment_code'] : '';
if (self::MODULE_NAME !== $paymentCode) {
$json['success'] = false;
} else {
if ((null !== $orderStatusId) && ($orderStatusId !== $this->successOrderStatusId)) {
$this->addOrderHistory($this->pendingOrderStatusId, $this->language->get('text_order_confirmed'));
}
$json['success'] = true;
}
$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');
$orderStatusId = isset($this->orderInfo['order_status_id']) ? (int)$this->orderInfo['order_status_id'] : null;
if ((null !== $orderStatusId) && ($orderStatusId !== $this->successOrderStatusId)) {
$this->addOrderHistory($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');
$orderStatusId = isset($this->orderInfo['order_status_id']) ? (int)$this->orderInfo['order_status_id'] : null;
if ((null !== $orderStatusId) && ($orderStatusId !== $this->successOrderStatusId)) {
$this->addOrderHistory($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);
}
/** @return string */
private function createTransactionId() {
return $this->orderId . self::TRANSACTION_ID_DELIMITER . date('YmdHisT');
}
/**
* @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 : '';
}
/**
* @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);
$isCheck = 'CHECK' === $callback['MNT_COMMAND'];
if ($isCheck && !$this->checkSignature($callback, $callback['MNT_SIGNATURE'])) {
$this->sendResponse(self::FAIL_RESPONSE);
}
$this->orderId = $this->parseOrderIdFromTransactionId($callback['MNT_TRANSACTION_ID']);
if (null === $this->orderId) {
$this->log->write('PayAnyWay callback: MNT_TRANSACTION_ID has unexpected format');
$this->sendResponse(self::FAIL_RESPONSE);
}
$this->orderInfo = $this->getOrderInfo();
if (empty($this->orderInfo)) {
$this->sendResponse($isCheck ? $this->buildUnknownOrderResponse($callback) : self::FAIL_RESPONSE);
}
$isCheck ? $this->handleCheckCallback($callback) : $this->handlePayCallback($callback);
}
/**
* @param array<string, mixed> $requestData
* @return array<string, mixed>
*/
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<string, mixed>
*/
private function getCallbackRequestData() {
return array_merge($_GET, $_POST);
}
/**
* @param mixed $transactionId
* @return int|null
*/
private function parseOrderIdFromTransactionId($transactionId) {
if (!is_string($transactionId)) {
return null;
}
$delimiters = preg_quote(self::TRANSACTION_ID_DELIMITER . self::LEGACY_TRANSACTION_ID_DELIMITER, '/');
if (!preg_match(sprintf(self::TRANSACTION_ID_PATTERN, $delimiters), $transactionId, $matches)) {
return null;
}
return (int)$matches[1];
}
/** @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->mntId !== (int)$callback['MNT_ID']) {
$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<string, mixed> $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<string, mixed> $callbackData
* @return void
* @throws \Exception
*/
private function handleCheckCallback(array $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<string, mixed> $callbackData
* @return void
* @throws \Exception
*/
private function handlePayCallback(array $callbackData) {
$currencyCode = isset($this->orderInfo['currency_code']) ? $this->orderInfo['currency_code'] : 'RUB';
$shopData = array(
'MNT_ID' => $this->mntId,
'MNT_TRANSACTION_ID' => $callbackData['MNT_TRANSACTION_ID'],
'MNT_OPERATION_ID' => $callbackData['MNT_OPERATION_ID'],
'MNT_AMOUNT' => $this->getOrderAmount(),
'MNT_CURRENCY_CODE' => $currencyCode,
'MNT_SUBSCRIBER_ID' => $callbackData['MNT_SUBSCRIBER_ID'],
'MNT_TEST_MODE' => $callbackData['MNT_TEST_MODE'],
);
if (!$this->checkSignature($shopData, $callbackData['MNT_SIGNATURE'])) {
$this->sendResponse(self::FAIL_RESPONSE);
}
$isPayedOrder = $this->isOrderPaid();
if (!$isPayedOrder) {
$this->load->language('extension/payment/payanyway');
$this->addOrderHistory($this->successOrderStatusId, $this->getPaymentCompletedComment($callbackData['MNT_OPERATION_ID']));
}
$resultCode = 200;
$description = $isPayedOrder ? 'Order already paid' : 'Order success paid';
$responseData = $this->buildBaseResponseData($callbackData, $resultCode, $description);
$this->sendResponse($this->buildXmlResponse($responseData + $this->getResponseAttributes()));
}
/**
* @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<string, string|null>
* @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<string, mixed>
* @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<string, mixed> $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<string, mixed> $callbackData
* @return array<int|string>
* @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->successOrderStatusId === $orderStatusId) {
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 */
private function isOrderPaid() {
if (!isset($this->orderInfo['order_status_id'])) {
return false;
}
return $this->successOrderStatusId === (int)$this->orderInfo['order_status_id'];
}
/**
* @return array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}> цены в копейках
* @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<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}>
* @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<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}> $lines
* @param int $target сумма заказа в копейках
* @return array<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}>
*/
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<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 индексы строк, которые можно менять
* @param int $goal
* @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, $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<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}>
*/
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<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}> $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<string, string>
*/
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<int, array{name: string, unit: int, quantity: int, pm: string, po: string, goods: bool}> $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
*/
public 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
*/
public function getOrderStatusById($orderStatusId) {
$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) {
$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<non-empty-string, mixed> $data
* @return array
* @throws \JsonException
*/
public 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('/"' . preg_quote(self::JSON_NUMBER_PREFIX, '/') . '([0-9]+\.[0-9]{2})"/', '$1', $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<string, mixed> $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($dom->createElement($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($dom->createElement('KEY', $key));
$attrElement->appendChild($dom->createElement('VALUE', (string)$value));
$attributes->appendChild($attrElement);
}
}
return $this->saveXmlResponse($dom);
}
/**
* @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<string, mixed> $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<string, mixed> $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<string, mixed> $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;
}
}