326 lines
11 KiB
PHP
Executable File
326 lines
11 KiB
PHP
Executable File
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Opencart\Catalog\Library\Extension\PayAnyWay\Traits;
|
||
|
||
trait PayAnyWay
|
||
{
|
||
// Статические свойства, а не константы: константы в трейтах — только с PHP 8.2, модуль — 8.0+.
|
||
|
||
private static string $transactionIdDelimiter = '_';
|
||
private static string $transactionIdZone = 'UTC';
|
||
private static string $transactionIdPattern = '/^([1-9][0-9]*)_(0|[1-9][0-9]{0,15})_([0-9]{14})UTC$/D';
|
||
// Номер модуля 1.0.2 и раньше: <номер заказа>|<YmdHis> в поясе сервера, без суммы.
|
||
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.3';
|
||
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<string, mixed>
|
||
*/
|
||
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<int, int|string>
|
||
* @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;
|
||
}
|
||
|
||
/** @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
|
||
{
|
||
$email = trim((string)($this->orderInfo['email'] ?? ''));
|
||
return ('' !== $email) ? filter_var($email, FILTER_SANITIZE_EMAIL) : null;
|
||
}
|
||
|
||
/** @return non-empty-string|null */
|
||
private function getClientPhone(): ?string
|
||
{
|
||
$digits = preg_replace('/\D/', '', trim((string)($this->orderInfo['telephone'] ?? '')));
|
||
return ('' !== $digits) ? '+' . $digits : null;
|
||
}
|
||
}
|