| в поясе сервера, без суммы. private static string $legacyTransactionIdPattern = '/^([1-9][0-9]*)\\|([0-9]{14})$/D'; private static string $operationIdPattern = '/^[0-9]{1,20}$/D'; private static int $orderLockTimeoutSeconds = 10; private static string $moduleCode = 'payanyway'; private static string $moduleVersion = '1.0.4'; private static string $defaultCmsName = 'OpenCart'; private static int $clientEmailMaxLength = 64; private static int $clientPhoneMaxLength = 19; private static string $defaultPaymentMethod = 'full_payment'; private static string $defaultPaymentObject = 'commodity'; private static array $defaultCanceledOrderStatusIds = [7, 9, 16]; private static string $coreOrderMailFile = '/catalog/controller/mail/order.php'; private int $mntId; private string $integrityCode; private string $server; private string $vatRate; private string $currency; private int $pendingOrderStatusId; private int $successOrderStatusId; private int $errorOrderStatusId; private bool $testMode; private bool $fiscalization; private string $receiptEmail; private string $paymentMethod; private string $paymentObject; /** @var int[] */ private array $canceledOrderStatusIds; private string $cmsName; private ?int $orderId = null; private array $orderInfo = []; private function initializeModule(): void { $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 = (string)$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'); $this->fiscalization = !$this->config->has('payment_payanyway_fiscalization') || (bool)$this->config->get('payment_payanyway_fiscalization'); $this->receiptEmail = $this->normalizeEmail($this->config->get('payment_payanyway_receipt_email')) ?? ''; $paymentMethod = (string)$this->config->get('payment_payanyway_payment_method'); $this->paymentMethod = ('' !== $paymentMethod) ? $paymentMethod : self::$defaultPaymentMethod; $paymentObject = (string)$this->config->get('payment_payanyway_payment_object'); $this->paymentObject = ('' !== $paymentObject) ? $paymentObject : self::$defaultPaymentObject; $this->canceledOrderStatusIds = $this->config->has('payment_payanyway_canceled_order_status_ids') ? array_map('intval', (array)$this->config->get('payment_payanyway_canceled_order_status_ids')) : self::$defaultCanceledOrderStatusIds; $this->cmsName = $this->detectCmsName(); } private function detectCmsName(): string { $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::$defaultCmsName; } private function getCms(): string { return sprintf('%s v%s_PHP %s_%s v%s', $this->cmsName, defined('VERSION') ? constant('VERSION') : 'unknown', PHP_VERSION, self::$moduleCode, self::$moduleVersion ); } private function loadOrder(?int $orderId): void { $this->orderId = $orderId; $this->orderInfo = []; if (null === $orderId) { return; } $this->load->model('checkout/order'); $this->orderInfo = $this->model_checkout_order->getOrder($orderId); } private function validateString(string $value, int $maxLength): string { $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 ? '...' : ''); } private function sanitizeString(string $value): string { $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)); } private function formatPrice(float $price): string { return number_format($price, 2, '.', ''); } private function toKopecks(float|int|string $amount): int { $currencyValue = (float)($this->orderInfo['currency_value'] ?? 0); if ($currencyValue <= 0) { $currencyValue = 1.0; } return (int)round((float)$amount * $currencyValue * 100); } private function getOrderAmount(): string { return $this->formatPrice($this->toKopecks($this->orderInfo['total'] ?? 0) / 100); } /** * @return array */ private function getCallbackRequestData(): array { return array_merge($_GET, $_POST); } /** * @return array{order_id: int, amount: ?string, issued_at: string}|null */ private function parseTransactionId(mixed $transactionId): ?array { if (!is_string($transactionId)) { return null; } if (preg_match(self::$transactionIdPattern, $transactionId, $matches)) { $kopecks = str_pad($matches[2], 3, '0', STR_PAD_LEFT); return [ 'order_id' => (int)$matches[1], 'amount' => substr($kopecks, 0, -2) . '.' . substr($kopecks, -2), 'issued_at' => $matches[3], ]; } if (preg_match(self::$legacyTransactionIdPattern, $transactionId, $matches)) { return ['order_id' => (int)$matches[1], 'amount' => null, 'issued_at' => $matches[2]]; } return null; } private function getOrderIdFromSession(): ?int { return isset($this->session->data['order_id']) ? (int)$this->session->data['order_id'] : null; } private function isPaidWithModule(): bool { $code = $this->orderInfo['payment_method']['code'] ?? ''; return is_string($code) && (self::$moduleCode === strtok($code, '.')); } /** * @param string $amount MNT_AMOUNT формы — с двумя знаками после точки * @throws \DateInvalidTimeZoneException */ private function createTransactionId(int $orderId, string $amount): string { $kopecks = ltrim(str_replace('.', '', $amount), '0'); $issuedAt = new \DateTimeImmutable('now', new \DateTimeZone(self::$transactionIdZone)); return $orderId . self::$transactionIdDelimiter . (('' !== $kopecks) ? $kopecks : '0') . self::$transactionIdDelimiter . $issuedAt->format('YmdHis') . self::$transactionIdZone; } private function addOrderHistory(int $orderId, int $orderStatusId, string $comment): void { $this->load->model('checkout/order'); // OpenCart 4.0.2.x: письмо о заказе (событие mail/order до addHistory) читает // shipping_method['name'] у заказа без доставки — Warning, а обработчик ядра выводит его // в ответ или уходит на error_page с exit: confirm() отвечает не JSON, колбэк — не XML, // статус не меняется. Глушится только это предупреждение, письмо уходит без способа доставки. $previousHandler = set_error_handler(static function (int $severity, string $message, string $file, int $line) use (&$previousHandler): bool { if ((E_WARNING === $severity) && str_ends_with(str_replace('\\', '/', $file), self::$coreOrderMailFile) && str_starts_with($message, 'Trying to access array offset on') ) { return true; } return (null !== $previousHandler) && (false !== $previousHandler(...self::errorHandlerArguments($previousHandler, [$severity, $message, $file, $line]))); }); try { $this->model_checkout_order->addHistory($orderId, $orderStatusId, $comment); } finally { restore_error_handler(); } } /** * Аргументы для прежнего обработчика ошибок под его сигнатуру: из файла со strict_types * int в string-параметр не приводится. Обработчик ядра 4.0.x — error(string $code, …, * string $line), 4.1 — (int $code, …, int $line). * * @param array{0: int, 1: string, 2: string, 3: int} $arguments * @return array * @throws \ReflectionException */ private static function errorHandlerArguments(callable $handler, array $arguments): array { $parameters = (new \ReflectionFunction(\Closure::fromCallable($handler)))->getParameters(); foreach ($parameters as $index => $parameter) { $type = $parameter->getType(); if (isset($arguments[$index]) && ($type instanceof \ReflectionNamedType) && ('string' === $type->getName())) { $arguments[$index] = (string)$arguments[$index]; } } return $arguments; } private function isAwaitingPayment(int $orderStatusId): bool { return in_array($orderStatusId, [0, $this->pendingOrderStatusId, $this->errorOrderStatusId], true); } /** @return non-empty-string|null */ private function getClientName(): ?string { $name = trim(($this->orderInfo['firstname'] ?? '') . ' ' . ($this->orderInfo['lastname'] ?? '')); return ('' !== $name) ? $name : null; } /** @return non-empty-string|null */ private function getClientEmail(): ?string { return $this->normalizeEmail($this->orderInfo['email'] ?? ''); } /** @return non-empty-string|null */ private function getClientPhone(): ?string { $digits = preg_replace('/\D/', '', trim((string)($this->orderInfo['telephone'] ?? ''))); return (('' !== $digits) && (strlen($digits) + 1 <= self::$clientPhoneMaxLength)) ? '+' . $digits : null; } private function normalizeEmail(mixed $email): ?string { $email = trim((string)$email); if (('' === $email) || (strlen($email) > self::$clientEmailMaxLength) || (false === filter_var($email, FILTER_VALIDATE_EMAIL))) { return null; } return $email; } }