feat: add fiscalization settings, POST callbacks and fix callback issues

This commit is contained in:
2026-10-26 17:54:23 +03:00
committed by Git Moneta
parent a9bc7f315c
commit b1acb1a1de
13 changed files with 1585 additions and 335 deletions
+234 -20
View File
@@ -6,34 +6,116 @@ 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 = $this->config->get('payment_payanyway_currency');
$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->cmsModuleVersion = (string)$this->config->get('payment_payanyway_cms_module_version');
$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 validateOrder(): bool
private function detectCmsName(): string
{
$this->orderId = $this->getOrderIdFromTransactionId() ?? $this->getOrderIdFromSession();
if (null === $this->orderId) {
return false;
$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($this->orderId);
return ([] !== $this->orderInfo);
$this->orderInfo = $this->model_checkout_order->getOrder($orderId);
}
private string $transactionIdDelimiter = '|';
private function validateString(string $value, int $maxLength): string
{
$maxLength = max(0, $maxLength);
@@ -50,7 +132,7 @@ trait PayAnyWay
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 = preg_replace('/[^\p{L}\p{N}\s:.,()_№+-]/u', '', $decoded);
$cleaned = str_replace(['&', '/', '\\', ';', '%', '#', '"', "'"], '', $cleaned);
return trim(preg_replace('/\s+/', ' ', $cleaned));
@@ -61,14 +143,53 @@ trait PayAnyWay
return number_format($price, 2, '.', '');
}
private function getOrderIdFromTransactionId(): ?int
private function toKopecks(float|int|string $amount): int
{
if (!isset($_REQUEST['MNT_TRANSACTION_ID'])) {
$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;
}
$transactionIdData = explode($this->transactionIdDelimiter, $_REQUEST['MNT_TRANSACTION_ID'], 2);
return isset($transactionIdData[0]) ? (int)$transactionIdData[0] : 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
@@ -76,16 +197,109 @@ trait PayAnyWay
return isset($this->session->data['order_id']) ? (int)$this->session->data['order_id'] : null;
}
private function createTransactionId(int $orderId): string
private function isPaidWithModule(): bool
{
$date = (new \DateTimeImmutable())->format('YmdHis');
return $orderId . $this->transactionIdDelimiter . $date;
$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');
$this->model_checkout_order->addHistory($orderId, $orderStatusId, $comment);
// 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 */