From 0f929c0405d5cd62bb68959b09bb4bf8b7dc0fb4 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Tue, 14 Jul 2026 10:12:00 +0300 Subject: [PATCH 01/30] =?UTF-8?q?build:=20project=20infrastructure=20?= =?UTF-8?q?=E2=80=94=20Composer,=20autoload,=20QA=20tooling=20and=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 23 ++++++-------- .github/workflows/ci.yml | 58 ++++++++++++++++++++++++++++++++++ .gitignore | 8 ++--- .php-cs-fixer.dist.php | 17 ++++++++++ LICENSE.md | 2 +- composer.json | 43 ++++++++++++++++++++++++-- phpmd.xml | 67 ++++++++++++++++++++++++++++++++++++++++ phpstan.neon | 8 +++++ phpunit.xml | 12 +++++++ 9 files changed, 216 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .php-cs-fixer.dist.php create mode 100644 phpmd.xml create mode 100644 phpstan.neon create mode 100644 phpunit.xml diff --git a/.gitattributes b/.gitattributes index 62feb38..650d880 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,13 +1,10 @@ -# https://git-scm.com/book/en/v2/Customizing-Git-Git-Attributes - -# A list of files and folders those will be excluded from archives and the -# Composer package (for purposes of making it smaller). -/.coveralls.yml export-ignore -/.github export-ignore -/.gitattributes export-ignore -/.travis.yml export-ignore -/.vscode export-ignore -/examples export-ignore -/phpunit.xml export-ignore -/phpunit.no_autoload.xml export-ignore -/tests export-ignore \ No newline at end of file +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.vscode export-ignore +/.php-cs-fixer.dist.php export-ignore +/examples export-ignore +/phpmd.xml export-ignore +/phpstan.neon export-ignore +/phpunit.xml export-ignore +/tests export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4b670e5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + +jobs: + tests: + name: Tests (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: json, curl + coverage: none + + - name: Install dependencies + run: composer update --prefer-dist --no-progress + + - name: Lint + run: composer lint + + - name: Tests + run: composer test + + quality: + name: Static analysis & code style + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: json, curl + coverage: none + + - name: Install dependencies + run: composer update --prefer-dist --no-progress + + - name: Code style (php-cs-fixer) + run: composer cs-check + + - name: Static analysis (PHPStan) + run: composer stan + + - name: Mess detection (PHPMD) + run: composer md diff --git a/.gitignore b/.gitignore index 5c13bcc..18c0b19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,6 @@ -.idea +.idea/ .DS_Store /vendor/ composer.lock +.php-cs-fixer.cache clover.xml -.php_cs -.php_cs.cache -.phpstan.neon -.phpdoc/* -phpdoc.xml \ No newline at end of file diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..dcb7fdc --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,17 @@ +in([__DIR__ . '/tests', __DIR__ . '/examples']) + ->append([__DIR__ . '/UnitPay.php']); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(false) + // Dev machine runs PHP 8.5 while the project targets PHP >=7.4; allow the + // newer runtime instead of exporting the deprecated PHP_CS_FIXER_IGNORE_ENV. + ->setUnsupportedPhpVersionAllowed(true) + ->setRules([ + '@PSR12' => true, + 'array_syntax' => ['syntax' => 'short'], + 'no_unused_imports' => true, + ]) + ->setFinder($finder); diff --git a/LICENSE.md b/LICENSE.md index a8dcb64..1c6b06f 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ The MIT License -Copyright (c) 2013-2021 Unitpay (https://unitpay.ru) +Copyright (c) 2013-2026 Unitpay (https://unitpay.ru) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/composer.json b/composer.json index 44acce8..0c5fc6a 100644 --- a/composer.json +++ b/composer.json @@ -12,12 +12,51 @@ } ], "require":{ - "php": ">=5.6.0", + "php": ">=7.4", "ext-json": "*" }, + "require-dev": { + "phpunit/phpunit": "^9.6", + "phpstan/phpstan": "^2.2", + "php-parallel-lint/php-parallel-lint": "^1.4", + "friendsofphp/php-cs-fixer": "^3.95", + "phpmd/phpmd": "^2.15" + }, + "suggest": { + "ext-curl": "Enables the default cURL transport for api(): connect/read timeouts and no dependency on allow_url_fopen. Falls back to file_get_contents when absent." + }, "autoload":{ "classmap":[ "./UnitPay.php" ] + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "lint": "parallel-lint UnitPay.php examples tests", + "stan": "phpstan analyse --no-progress", + "cs-check": "php-cs-fixer fix --dry-run --diff", + "cs-fix": "php-cs-fixer fix", + "md": "@php -d error_reporting=\"E_ALL & ~E_DEPRECATED\" vendor/bin/phpmd UnitPay.php text phpmd.xml", + "check": [ + "@lint", + "@cs-check", + "@stan", + "@md", + "@test" + ] + }, + "scripts-descriptions": { + "test": "Run the PHPUnit test suite", + "lint": "Syntax-lint every PHP file in parallel", + "stan": "Run PHPStan static analysis", + "cs-check": "Report code-style violations without changing files", + "cs-fix": "Apply code-style fixes in place", + "md": "Run PHPMD mess detection on UnitPay.php", + "check": "Run lint, cs-check, stan, md and test in sequence" } -} \ No newline at end of file +} diff --git a/phpmd.xml b/phpmd.xml new file mode 100644 index 0000000..5167167 --- /dev/null +++ b/phpmd.xml @@ -0,0 +1,67 @@ + + + + + Curated PHPMD ruleset for the single-file, global-namespace Unitpay SDK. + A few default rules are excluded or retuned because they conflict with the + SDK's deliberate design rather than pointing at real problems. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..61d4679 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,8 @@ +parameters: + # Level 5: strong analysis without demanding full scalar type declarations on + # the legacy single-file SDK (typed-everything is deferred — see F014 in the + # tech-debt audit). Raise once UnitPay.php gets real type declarations. + level: 5 + paths: + - UnitPay.php + - tests diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..ab754e3 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,12 @@ + + + + + tests + + + From 60e44912d3fa2b1ad4e8f308e753faef058a2d16 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Wed, 15 Jul 2026 09:47:00 +0300 Subject: [PATCH 02/30] feat: 54-FZ receipts, full REST API coverage, typed exceptions and cURL transport --- UnitPay.php | 860 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 760 insertions(+), 100 deletions(-) diff --git a/UnitPay.php b/UnitPay.php index 1f56bfe..9b54a0b 100644 --- a/UnitPay.php +++ b/UnitPay.php @@ -1,60 +1,196 @@ name = $name; - $this->count = $count; - $this->price = $price; + // Keep the numeric value as-is (int or float): fractional quantities are valid + // for weight/volume goods (MEASURE_KG/G/L, ...) and the backend rounds count to + // 3 decimals, so truncating to int would silently corrupt the receipt. + $this->count = $count + 0; + $this->price = (float) $price; $this->nds = $nds; $this->type = $type; $this->paymentMethod = $paymentMethod; @@ -97,7 +251,7 @@ public function getName() } /** - * @return int + * @return int|float */ public function getCount() { @@ -135,6 +289,265 @@ public function getPaymentMethod() { return $this->paymentMethod; } + + /** + * Итоговая сумма позиции. Если не задана, бэкенд считает её как price * count. + * Не может превышать round(price * count, 2). + * @param float $sum + * @return $this + */ + public function setSum($sum) + { + $this->sum = $sum; + return $this; + } + + /** + * @return float|null + */ + public function getSum() + { + return $this->sum; + } + + /** + * Валюта позиции (ISO 4217). По умолчанию на бэкенде RUB. + * @param string $currency + * @return $this + */ + public function setCurrency($currency) + { + $this->currency = $currency; + return $this; + } + + /** + * @return string|null + */ + public function getCurrency() + { + return $this->currency; + } + + /** + * Единица измерения, одна из констант MEASURE_*. + * @param int $measure + * @return $this + */ + public function setMeasure($measure) + { + $this->measure = $measure; + return $this; + } + + /** + * @return int|null + */ + public function getMeasure() + { + return $this->measure; + } + + /** + * Код товарной номенклатуры (маркировка). + * @param string $nomenclatureCode + * @return $this + */ + public function setNomenclatureCode($nomenclatureCode) + { + $this->nomenclatureCode = $nomenclatureCode; + return $this; + } + + /** + * @return string|null + */ + public function getNomenclatureCode() + { + return $this->nomenclatureCode; + } + + /** + * Код маркировки товара. + * @param string $markCode + * @return $this + */ + public function setMarkCode($markCode) + { + $this->markCode = $markCode; + return $this; + } + + /** + * @return string|null + */ + public function getMarkCode() + { + return $this->markCode; + } + + /** + * Дробное количество маркированного товара. + * Допускается только при measure = MEASURE_ITEM и count = 1. + * @param int $numerator числитель + * @param int $denominator знаменатель + * @return $this + */ + public function setMarkQuantity($numerator, $denominator) + { + if ((int) $numerator <= 0) { + throw new UnitpayValidationException('CashItem markQuantity numerator must be a positive integer'); + } + if ((int) $denominator <= 0) { + throw new UnitpayValidationException('CashItem markQuantity denominator must be a positive integer'); + } + $this->markQuantity = [ + 'numerator' => (int) $numerator, + 'denominator' => (int) $denominator, + ]; + return $this; + } + + /** + * @return array|null + */ + public function getMarkQuantity() + { + return $this->markQuantity; + } + + /** + * Текст перед позицией в чеке. + * @param string $preText + * @return $this + */ + public function setPreText($preText) + { + $this->preText = $preText; + return $this; + } + + /** + * @return string|null + */ + public function getPreText() + { + return $this->preText; + } + + /** + * Текст после позиции в чеке. + * @param string $postText + * @return $this + */ + public function setPostText($postText) + { + $this->postText = $postText; + return $this; + } + + /** + * @return string|null + */ + public function getPostText() + { + return $this->postText; + } +} + +/** + * IP allowlist matcher: exact addresses and CIDR subnets (IPv4 and IPv6). + * Extracted from UnitPay so the range-matching logic stays cohesive and testable. + */ +final class UnitpayIpAllowlist +{ + private $entries; + + /** + * @param string[] $entries exact IPs and/or CIDR ranges (e.g. "77.75.153.0/25") + */ + public function __construct(array $entries) + { + $this->entries = $entries; + } + + /** + * @param string $ip + * @return bool + */ + public function contains($ip) + { + // Convert the client IP to its packed form once, not once per CIDR entry. + $ipBin = $this->toBinary($ip); + foreach ($this->entries as $entry) { + if (strpos($entry, '/') === false) { + if ($entry === $ip) { + return true; + } + continue; + } + if ($ipBin !== null && $this->cidrContains($entry, $ipBin)) { + return true; + } + } + return false; + } + + /** + * @param string $cidr + * @param string $ipBin packed in_addr of the client IP (from toBinary()) + * @return bool + */ + private function cidrContains($cidr, $ipBin) + { + // Only entries containing '/' reach this method, so explode() always yields + // exactly two elements — no array_pad default is needed. + list($subnet, $bits) = explode('/', $cidr, 2); + if (!ctype_digit($bits)) { + return false; + } + $subnetBin = $this->toBinary($subnet); + if ($subnetBin === null || strlen($ipBin) !== strlen($subnetBin)) { + return false; + } + return $this->prefixMatches($ipBin, $subnetBin, (int) $bits); + } + + /** + * @param string $ip + * @return string|null packed in_addr, or null when $ip is not a valid address + */ + private function toBinary($ip) + { + if (filter_var($ip, FILTER_VALIDATE_IP) === false) { + return null; + } + $binary = inet_pton($ip); + return $binary === false ? null : $binary; + } + + /** + * @param string $ipBin + * @param string $subnetBin + * @param int $bits + * @return bool + */ + private function prefixMatches($ipBin, $subnetBin, $bits) + { + if ($bits > strlen($ipBin) * 8) { + return false; + } + $whole = intdiv($bits, 8); + if ($whole > 0 && strncmp($ipBin, $subnetBin, $whole) !== 0) { + return false; + } + $rest = $bits % 8; + if ($rest === 0) { + return true; + } + $mask = chr((0xff << (8 - $rest)) & 0xff); + return ($ipBin[$whole] & $mask) === ($subnetBin[$whole] & $mask); + } } /** @@ -142,30 +555,85 @@ public function getPaymentMethod() */ class UnitPay { - private $supportedUnitpayMethods = ['initPayment', 'getPayment']; + // The supported api() methods are exactly the keys of this map; secretKey is + // injected and validated by api(), so it is not listed among the required params. private $requiredUnitpayMethodsParams = [ - 'initPayment' => ['desc', 'account', 'sum'], - 'getPayment' => ['paymentId'], + 'initPayment' => ['account', 'sum', 'projectId', 'paymentType'], + 'getPayment' => ['paymentId'], + 'refundPayment' => ['paymentId'], + 'confirmPayment' => ['paymentId'], + 'cancelPayment' => ['paymentId'], + 'listSubscriptions' => ['projectId'], + 'getSubscription' => ['subscriptionId'], + 'closeSubscription' => ['subscriptionId'], + 'getMethodsAvailable' => ['projectId'], + 'getCommissions' => ['projectId', 'login'], + 'getCurrencyCourses' => ['login'], + 'getPartner' => ['login'], + 'offsetAdvance' => ['login', 'paymentId'], + 'massPayment' => ['login', 'transactionId', 'sum', 'purse', 'paymentType'], + 'massPaymentStatus' => ['login', 'transactionId'], + 'massPaymentAvailableAmount' => ['login', 'sum', 'purse', 'paymentType'], + 'massPaymentCommissions' => ['login'], + 'getSbpBankList' => ['login'], + 'getBinInfo' => ['login', 'bin'], ]; - private $supportedPartnerMethods = ['check', 'pay', 'error']; + // Webhook methods Unitpay sends to the handler. 'preauth' is the two-stage + // hold notification (funds blocked, not yet captured) — see payment-handler + // docs; it must verify like the others, not be rejected as unsupported. + private $supportedPartnerMethods = ['check', 'pay', 'preauth', 'error']; + // Unitpay's published outbound IPs. 127.0.0.1 is deliberately NOT here: behind a + // same-host reverse proxy REMOTE_ADDR is 127.0.0.1, which would make the IP gate a + // no-op. Add it explicitly via setAllowedIps() for local debugging only. private $supportedUnitpayIp = [ '31.186.100.49', '51.250.20.9', - '52.29.152.23', - '52.19.56.234', - '127.0.0.1' // for debug ]; private $secretKey; private $params = []; private $apiUrl; private $formUrl; + private $transport; + private $request; + private $clientIp; + private $handlerMethod; + private $handlerParams; + private $ipAllowlist; - public function __construct($domain, $secretKey = null) + /** + * @param string $domain Host only, e.g. "unitpay.ru" — no scheme or path (it becomes "https://$domain/api"). + * @param string|null $secretKey + * @param callable|null $transport Outbound HTTP transport used by api(): fn(string $url): string|false. + * Defaults to file_get_contents(). Inject to test api() without the network. + * @param array|null $request Inbound webhook request array read by checkHandlerRequest(). + * Defaults to $_GET. Inject to test the handler without superglobals. + * @param string|null $clientIp Source IP used by getIp(). Defaults to $_SERVER['REMOTE_ADDR']. + * Inject to test the IP allowlist without superglobals. + */ + public function __construct($domain, $secretKey = null, ?callable $transport = null, ?array $request = null, ?string $clientIp = null) { $this->secretKey = $secretKey; $this->apiUrl = "https://$domain/api"; $this->formUrl = "https://$domain/pay/"; + $this->transport = $transport; + $this->request = $request; + $this->clientIp = $clientIp; + } + + /** + * Override the list of Unitpay IP addresses allowed to call the handler. + * Replaces the built-in default entirely. Use it to keep the SDK in sync + * when the Unitpay infrastructure changes without waiting for a release. + * @link https://help.unitpay.ru/book-of-reference/ip-addresses + * @param string[] $ips + * @return $this + */ + public function setAllowedIps(array $ips) + { + $this->supportedUnitpayIp = $ips; + $this->ipAllowlist = null; // rebuild the matcher lazily on the next check + return $this; } /** @@ -176,7 +644,12 @@ public function __construct($domain, $secretKey = null) */ public function getSignature(array $params, $method = null) { - unset($params['signature']); + // Strip caller-supplied signature keys and guard the auto-append index: + // a crafted params[PHP_INT_MAX] would make the secretKey append below a + // silent no-op, dropping the secret from the hash and making the + // signature forgeable (bypass on PHP <8, fatal Error/DoS on PHP >=8). + // Do NOT remove these unsets — regression previously introduced in 7835fb4. + unset($params['sign'], $params['signature'], $params[PHP_INT_MAX]); ksort($params); $params[] = $this->secretKey; @@ -184,6 +657,16 @@ public function getSignature(array $params, $method = null) array_unshift($params, $method); } + // A crafted webhook can inject an array value (e.g. params[x][]=1); coerce + // non-scalars to '' so implode() cannot emit an "Array to string conversion" + // warning. The check still fails closed — the secret is appended regardless. + $params = array_map(static function ($value) { + if (is_float($value)) { + return self::floatToString($value); + } + return is_scalar($value) ? $value : ''; + }, $params); + return hash('sha256', implode('{up}', $params)); } @@ -193,7 +676,70 @@ public function getSignature(array $params, $method = null) */ protected function getIp() { - return $_SERVER['REMOTE_ADDR']; + return $this->clientIp !== null ? $this->clientIp : $_SERVER['REMOTE_ADDR']; + } + + /** + * Whether $ip may call the handler. Matches exact addresses and CIDR subnets + * (IPv4/IPv6) via UnitpayIpAllowlist, so setAllowedIps(['77.75.153.0/25']) + * works (F024). Override for proxy-aware logic. + * @param string $ip + * @return bool + */ + protected function isAllowedIp($ip) + { + if ($this->ipAllowlist === null) { + $this->ipAllowlist = new UnitpayIpAllowlist($this->supportedUnitpayIp); + } + return $this->ipAllowlist->contains($ip); + } + + /** + * Perform the outbound HTTP GET used by api(). + * Resolution order: injected $transport -> cURL (if ext-curl present) -> file_get_contents. + * cURL adds connect/read timeouts and does not require allow_url_fopen; both fallbacks + * carry a timeout too. Returns the response body, or false on a transport failure + * (which api() turns into a "Temporary server error"). + * @param string $url + * @return string|false + */ + protected function httpGet($url) + { + if ($this->transport !== null) { + return call_user_func($this->transport, $url); + } + + if (function_exists('curl_init')) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 10, + // CURLOPT_SSL_VERIFYPEER defaults to true — TLS verification stays on. + ]); + $body = curl_exec($ch); + // On PHP >=8.0 curl_close() is a deprecated no-op (the handle is a + // CurlHandle object freed by GC), so skip it to avoid the E_DEPRECATED + // notice on 8.5. On PHP <8.0 the handle is a resource — close it + // explicitly for deterministic cleanup (though refcounting would also + // free it when $ch leaves scope on return). + if (\PHP_VERSION_ID < 80000) { + curl_close($ch); + } + return $body; + } + + $context = stream_context_create(['http' => ['timeout' => 10]]); + // Swallow the transport warning without the '@' operator (the QA ruleset forbids + // it): that warning would otherwise embed the secret-bearing URL in the error log. + set_error_handler(static function () { + return true; + }); + try { + return file_get_contents($url, false, $context); + } finally { + restore_error_handler(); + } } /** @@ -208,18 +754,23 @@ protected function getIp() */ public function form($publicKey, $sum, $account, $desc, $currency = 'RUB', $locale = 'ru') { - $vitalParams = [ + if (empty($this->secretKey)) { + throw new UnitpayValidationException('SecretKey is null'); + } + $vitalParams = self::stringifyFloats([ 'account' => $account, 'currency' => $currency, 'desc' => $desc, - 'sum' => $sum - ]; - $this->params = array_merge($this->params, $vitalParams); - if ($this->secretKey) { - $this->params['signature'] = $this->getSignature($vitalParams); - } - $this->params['locale'] = $locale; - return $this->formUrl . $publicKey . '?' . http_build_query($this->params); + 'sum' => $sum, + ]); + // Build the URL from a local array and consume the fluent-setter params: a + // reused instance must not carry this call's params (or form()'s vital params + // and signature) into the next form()/api() call. + $params = array_merge($this->params, $vitalParams); + $params['signature'] = $this->getSignature($vitalParams); + $params['locale'] = $locale; + $this->params = []; + return $this->formUrl . $publicKey . '?' . http_build_query($params); } /** @@ -251,19 +802,43 @@ public function setCustomerPhone($phone) */ public function setCashItems(array $items) { - $this->params['cashItems'] = base64_encode( - json_encode( - /** @var CashItem $item */ - array_map(static function ($item) { - return [ - 'name' => $item->getName(), - 'count' => $item->getCount(), - 'price' => $item->getPrice(), - 'nds' => $item->getNds(), - 'type' => $item->getType(), - 'paymentMethod' => $item->getPaymentMethod(), - ]; - }, $items))); + $cashItems = array_map(static function ($item) { + /** @var CashItem $item */ + $cashItem = [ + 'name' => $item->getName(), + 'count' => $item->getCount(), + 'price' => $item->getPrice(), + 'nds' => $item->getNds(), + 'type' => $item->getType(), + 'paymentMethod' => $item->getPaymentMethod(), + ]; + + // Optional fields: serialize only the ones that were actually set. + $optional = [ + 'sum' => $item->getSum(), + 'currency' => $item->getCurrency(), + 'measure' => $item->getMeasure(), + 'nomenclatureCode' => $item->getNomenclatureCode(), + 'markCode' => $item->getMarkCode(), + 'markQuantity' => $item->getMarkQuantity(), + 'pre_text' => $item->getPreText(), + 'post_text' => $item->getPostText(), + ]; + foreach ($optional as $key => $value) { + if ($value !== null) { + $cashItem[$key] = $value; + } + } + + return $cashItem; + }, $items); + + $json = json_encode($cashItems); + if ($json === false) { + // e.g. a non-UTF-8 (Windows-1251) product name — never ship an empty receipt. + throw new UnitpayValidationException('Failed to encode cashItems: ' . json_last_error_msg()); + } + $this->params['cashItems'] = base64_encode($json); return $this; } @@ -290,33 +865,53 @@ public function setBackUrl($backUrl) */ public function api($method, array $params = []) { - if (!in_array($method, $this->supportedUnitpayMethods, true)) { - throw new UnexpectedValueException('Method is not supported'); + if (!isset($this->requiredUnitpayMethodsParams[$method])) { + throw new UnitpayUnsupportedMethodException('Method is not supported'); } - if (isset($this->requiredUnitpayMethodsParams[$method])) { - foreach ($this->requiredUnitpayMethodsParams[$method] as $rParam) { - if (!isset($params[$rParam])) { - throw new InvalidArgumentException('Param ' . $rParam . ' is null'); - } + // Fold in the fluent-setter params (setCashItems/setCustomerEmail/…) so + // setCashItems()->api('initPayment', ...) sends the receipt. Only the setters + // write to $this->params — form() builds its URL locally — so folding the whole + // bucket is safe and no new setter can be silently dropped. Explicit $params win. + // The bucket is consumed on success (below) so a reused instance never bleeds + // one call's params into an unrelated next call. + $params = array_merge($this->params, $params); + + foreach ($this->requiredUnitpayMethodsParams[$method] as $rParam) { + if (!isset($params[$rParam])) { + throw new UnitpayValidationException('Param ' . $rParam . ' is null'); } } - $params['secretKey'] = $this->secretKey; + // The instance key is the default; an explicit NON-EMPTY secretKey in $params wins + // so account-level methods (getPartner, getCommissions, payouts, ...) can use the + // account key. A falsy value (e.g. an unset getenv()) falls back to the instance key. if (empty($params['secretKey'])) { - throw new InvalidArgumentException('SecretKey is null'); + $params['secretKey'] = $this->secretKey; } + if (empty($params['secretKey'])) { + throw new UnitpayValidationException('SecretKey is null'); + } + + $params = self::stringifyFloats($params); - $requestUrl = $this->apiUrl . '?' . http_build_query([ - 'method' => $method, - 'params' => $params, - ], null, '&', PHP_QUERY_RFC3986); + // Union (+) keeps 'method' authoritative if a param key collides with it. + $requestUrl = $this->apiUrl . '?' . http_build_query( + ['method' => $method] + $params, + '', + '&', + PHP_QUERY_RFC3986 + ); - $response = json_decode(file_get_contents($requestUrl)); + $response = json_decode($this->httpGet($requestUrl)); if (!is_object($response)) { - throw new InvalidArgumentException('Temporary server error. Please try again later.'); + throw new UnitpayTransportException('Temporary server error. Please try again later.'); } + // Consume the fluent-setter params only after a fully successful call, so a + // failed or retried call keeps them, but the next unrelated call starts clean. + $this->params = []; + return $response; } @@ -330,34 +925,99 @@ public function api($method, array $params = []) public function checkHandlerRequest() { $ip = $this->getIp(); - if (!isset($_GET['method'])) { - throw new InvalidArgumentException('Method is null'); + if (empty($this->secretKey)) { + throw new UnitpayValidationException('SecretKey is null'); } - if (!isset($_GET['params'])) { - throw new InvalidArgumentException('Params is null'); + $request = $this->request !== null ? $this->request : $_GET; + + if (!isset($request['method'])) { + throw new UnitpayValidationException('Method is null'); + } + + if (!isset($request['params'])) { + throw new UnitpayValidationException('Params is null'); } - list($method, $params) = [$_GET['method'], $_GET['params']]; + list($method, $params) = [$request['method'], $request['params']]; if (!in_array($method, $this->supportedPartnerMethods, true)) { - throw new UnexpectedValueException('Method is not supported'); + throw new UnitpayUnsupportedMethodException('Method is not supported'); } - if (!isset($params['signature']) || $params['signature'] !== $this->getSignature($params, $method)) { - throw new InvalidArgumentException('Wrong signature'); + if (!isset($params['signature']) || !is_string($params['signature']) + || !hash_equals($this->getSignature($params, $method), $params['signature'])) { + throw new UnitpaySignatureException('Wrong signature'); } /** * IP address check * @link https://help.unitpay.ru/book-of-reference/ip-addresses */ - if (!in_array($ip, $this->supportedUnitpayIp, true)) { - throw new InvalidArgumentException('IP address Error'); + if (!$this->isAllowedIp($ip)) { + throw new UnitpayIpException('IP address Error'); } + + // Expose the validated method/params so consumers read them from here + // (works with an injected $request) instead of re-reading $_GET. + $this->handlerMethod = $method; + $this->handlerParams = $params; + return true; } + /** + * Webhook method validated by the last successful checkHandlerRequest() + * ('check' | 'pay' | 'error'). Read this instead of $_GET so an injected + * request is honoured. Null before a successful validation. + * @return string|null + */ + public function getHandlerMethod() + { + return $this->handlerMethod; + } + + /** + * Webhook params validated by the last successful checkHandlerRequest(). + * Null before a successful validation. + * @return array|null + */ + public function getHandlerParams() + { + return $this->handlerParams; + } + + /** + * Render float params as locale-independent decimal strings so the signature + * and the request URL stay identical on PHP <8.0 (where (string)$float honours + * LC_NUMERIC and would emit "100,5" in comma-decimal locales). Non-floats pass through. + * @param array $params + * @return array + */ + private static function stringifyFloats(array $params) + { + foreach ($params as $key => $value) { + if (is_float($value)) { + $params[$key] = self::floatToString($value); + } + } + + return $params; + } + + /** + * Render a float as a locale-independent decimal string with no trailing zeros. + * (string) $float honours LC_NUMERIC on PHP <8.0 and would emit "100,5" in + * comma-decimal locales, breaking signature/URL consistency. Shared by + * getSignature() and stringifyFloats() so both sign and transmit the same form. + * @param float $value + * @return string + */ + private static function floatToString($value) + { + return rtrim(rtrim(sprintf('%.8F', $value), '0'), '.'); + } + /** * Response for Unitpay if handle success * @param string $message From 8078c0f7778d7be2e856b651053ab2d07170e2fa Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Wed, 15 Jul 2026 16:20:00 +0300 Subject: [PATCH 03/30] =?UTF-8?q?test:=20baseline=20coverage=20=E2=80=94?= =?UTF-8?q?=20signature,=20form,=20API,=20handler,=20CashItem,=20responses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/CashItemTest.php | 134 +++++++++++++++ tests/UnitPayApiTest.php | 296 +++++++++++++++++++++++++++++++++ tests/UnitPayCashItemsTest.php | 103 ++++++++++++ tests/UnitPayFormTest.php | 102 ++++++++++++ tests/UnitPayHandlerTest.php | 205 +++++++++++++++++++++++ tests/UnitPayResponseTest.php | 33 ++++ tests/UnitPaySignatureTest.php | 104 ++++++++++++ 7 files changed, 977 insertions(+) create mode 100644 tests/CashItemTest.php create mode 100644 tests/UnitPayApiTest.php create mode 100644 tests/UnitPayCashItemsTest.php create mode 100644 tests/UnitPayFormTest.php create mode 100644 tests/UnitPayHandlerTest.php create mode 100644 tests/UnitPayResponseTest.php create mode 100644 tests/UnitPaySignatureTest.php diff --git a/tests/CashItemTest.php b/tests/CashItemTest.php new file mode 100644 index 0000000..487b994 --- /dev/null +++ b/tests/CashItemTest.php @@ -0,0 +1,134 @@ +assertSame('Coffee', $item->getName()); + $this->assertSame(2, $item->getCount()); + $this->assertSame(150.5, $item->getPrice()); + $this->assertSame(CashItem::NDS_NONE, $item->getNds()); + $this->assertSame(CashItem::PAYMENT_OBJECT_COMMODITY, $item->getType()); + $this->assertSame(CashItem::PAYMENT_METHOD_PREPAYMENT_FULL, $item->getPaymentMethod()); + } + + public function testConstructorAcceptsExplicitFiscalFields() + { + $item = new CashItem( + 'Service', + 1, + 999.99, + CashItem::NDS_20, + CashItem::PAYMENT_OBJECT_SERVICE, + CashItem::PAYMENT_METHOD_PAYMENT_FULL + ); + + $this->assertSame(CashItem::NDS_20, $item->getNds()); + $this->assertSame(CashItem::PAYMENT_OBJECT_SERVICE, $item->getType()); + $this->assertSame(CashItem::PAYMENT_METHOD_PAYMENT_FULL, $item->getPaymentMethod()); + } + + public function testOptionalGettersDefaultToNull() + { + $item = new CashItem('X', 1, 1.0); + + $this->assertNull($item->getSum()); + $this->assertNull($item->getCurrency()); + $this->assertNull($item->getMeasure()); + $this->assertNull($item->getNomenclatureCode()); + $this->assertNull($item->getMarkCode()); + $this->assertNull($item->getMarkQuantity()); + $this->assertNull($item->getPreText()); + $this->assertNull($item->getPostText()); + } + + public function testFluentSettersReturnSelfAndStoreValues() + { + $item = new CashItem('X', 1, 1.0); + + $this->assertSame($item, $item->setSum(100.5)); + $this->assertSame($item, $item->setCurrency('USD')); + $this->assertSame($item, $item->setMeasure(CashItem::MEASURE_KG)); + $this->assertSame($item, $item->setNomenclatureCode('04620034587217')); + $this->assertSame($item, $item->setMarkCode('mark-1')); + $this->assertSame($item, $item->setPreText('before')); + $this->assertSame($item, $item->setPostText('after')); + + $this->assertSame(100.5, $item->getSum()); + $this->assertSame('USD', $item->getCurrency()); + $this->assertSame(CashItem::MEASURE_KG, $item->getMeasure()); + $this->assertSame('04620034587217', $item->getNomenclatureCode()); + $this->assertSame('mark-1', $item->getMarkCode()); + $this->assertSame('before', $item->getPreText()); + $this->assertSame('after', $item->getPostText()); + } + + public function testSetMarkQuantityStoresIntegerFraction() + { + $item = new CashItem('X', 1, 1.0); + + $this->assertSame($item, $item->setMarkQuantity(1, 3)); + $this->assertSame(['numerator' => 1, 'denominator' => 3], $item->getMarkQuantity()); + } + + /** A zero denominator (or non-positive fraction) is rejected, not silently stored. */ + public function testSetMarkQuantityRejectsNonPositiveValues() + { + $item = new CashItem('X', 1, 1.0); + + $this->expectException(\InvalidArgumentException::class); + $item->setMarkQuantity(1, 0); + } + + /** F016: count must be a positive number. */ + public function testConstructorRejectsNonPositiveCount() + { + $this->expectException(\InvalidArgumentException::class); + new CashItem('X', 0, 10.0); + } + + /** F016: price must be non-negative. */ + public function testConstructorRejectsNegativePrice() + { + $this->expectException(\InvalidArgumentException::class); + new CashItem('X', 1, -5.0); + } + + /** A non-numeric count must be rejected, not slip past the range check. */ + public function testConstructorRejectsNonNumericCount() + { + $this->expectException(\InvalidArgumentException::class); + new CashItem('X', 'abc', 10.0); + } + + /** A non-numeric price must be rejected, not slip past the range check. */ + public function testConstructorRejectsNonNumericPrice() + { + $this->expectException(\InvalidArgumentException::class); + new CashItem('X', 1, 'xyz'); + } + + /** Numeric strings are accepted and normalized to int/float. */ + public function testConstructorNormalizesNumericStrings() + { + $item = new CashItem('X', '3', '9.5'); + + $this->assertSame(3, $item->getCount()); + $this->assertSame(9.5, $item->getPrice()); + } + + /** Fractional quantities (weight/volume goods) are preserved, not truncated to int. */ + public function testConstructorPreservesFractionalCount() + { + $item = new CashItem('Cheese', 1.5, 500.0); + + $this->assertSame(1.5, $item->getCount()); + } +} diff --git a/tests/UnitPayApiTest.php b/tests/UnitPayApiTest.php new file mode 100644 index 0000000..e0c3612 --- /dev/null +++ b/tests/UnitPayApiTest.php @@ -0,0 +1,296 @@ +api('initPayment', [ + 'account' => 1, + 'sum' => 100, + 'projectId' => 7, + 'paymentType' => 'card', + ]); + + $this->assertSame(42, $response->result->receiptId); + } + + public function testRequestUrlCarriesMethodParamsAndSecret() + { + $captured = null; + $transport = static function ($url) use (&$captured) { + $captured = $url; + return '{"result":{}}'; + }; + $unitPay = new UnitPay('unitpay.test', 'my-secret', $transport); + + $unitPay->api('getPayment', ['paymentId' => 555]); + + $this->assertStringStartsWith('https://unitpay.test/api?', $captured); + $this->assertStringContainsString('method=getPayment', $captured); + $this->assertStringContainsString('paymentId', $captured); + $this->assertStringContainsString('555', $captured); + $this->assertStringContainsString('my-secret', $captured); + } + + public function testRequestUrlUsesFlatParamsNotNested() + { + $captured = null; + $transport = static function ($url) use (&$captured) { + $captured = $url; + return '{"result":{}}'; + }; + $unitPay = new UnitPay('unitpay.test', 'my-secret', $transport); + + $unitPay->api('getPayment', ['paymentId' => 555]); + + // Unitpay accepts flat query params since 05/2026 — no legacy params[...] nesting. + $this->assertStringContainsString('paymentId=555', $captured); + $this->assertStringContainsString('secretKey=my-secret', $captured); + $this->assertStringNotContainsString('params%5B', $captured); + $this->assertStringNotContainsString('params[', $captured); + } + + public function testPayoutRequestUrlUsesFlatParams() + { + $captured = null; + $transport = static function ($url) use (&$captured) { + $captured = $url; + return '{"result":{}}'; + }; + $unitPay = new UnitPay('unitpay.test', 'my-secret', $transport); + + $unitPay->api('massPayment', [ + 'login' => 'partner@example.com', + 'transactionId' => 1782, + 'sum' => 10, + 'purse' => '79510000071', + 'paymentType' => 'sbp', + ]); + + $this->assertStringContainsString('method=massPayment', $captured); + $this->assertStringContainsString('transactionId=1782', $captured); + $this->assertStringContainsString('purse=79510000071', $captured); + $this->assertStringNotContainsString('params%5B', $captured); + } + + /** + * Params accumulated via the fluent setters (setCashItems/setCustomerEmail/…) + * must reach the api() request, not just form(). Regression guard: api() used + * to build the URL from its $params argument only and silently dropped them. + */ + public function testCashItemsFromSetterAreSentByApi() + { + $captured = null; + $transport = static function ($url) use (&$captured) { + $captured = $url; + return '{"result":{}}'; + }; + $unitPay = new UnitPay('unitpay.test', 'my-secret', $transport); + $unitPay->setCashItems([new CashItem('Coffee', 1, 100.0)]) + ->setCustomerEmail('buyer@example.com'); + + $unitPay->api('initPayment', [ + 'account' => 1, + 'sum' => 100, + 'projectId' => 7, + 'paymentType' => 'card', + ]); + + $this->assertStringContainsString('cashItems=', $captured); + $this->assertStringContainsString('customerEmail=', $captured); + + parse_str((string) parse_url($captured, PHP_URL_QUERY), $q); + $items = json_decode(base64_decode($q['cashItems']), true); + $this->assertSame('Coffee', $items[0]['name']); + } + + /** Explicit api() params win over anything set through the fluent setters. */ + public function testExplicitApiParamOverridesAccumulatedParam() + { + $captured = null; + $transport = static function ($url) use (&$captured) { + $captured = $url; + return '{"result":{}}'; + }; + $unitPay = new UnitPay('unitpay.test', 'my-secret', $transport); + $unitPay->setBackUrl('https://old.example/back'); + + $unitPay->api('initPayment', [ + 'account' => 1, + 'sum' => 100, + 'projectId' => 7, + 'paymentType' => 'card', + 'backUrl' => 'https://new.example/back', + ]); + + parse_str((string) parse_url($captured, PHP_URL_QUERY), $q); + $this->assertSame('https://new.example/back', $q['backUrl']); + } + + /** + * Fluent-setter params are consumed by a successful api() call and must not bleed + * into the next call on a reused instance (regression: a stale cashItems receipt + * or customerEmail would otherwise ship with an unrelated later order). + */ + public function testFluentSetterParamsDoNotBleedIntoNextApiCall() + { + $urls = []; + $transport = static function ($url) use (&$urls) { + $urls[] = $url; + return '{"result":{}}'; + }; + $unitPay = new UnitPay('unitpay.test', 'my-secret', $transport); + + $unitPay->setCashItems([new CashItem('Coffee', 1, 100.0)]) + ->setCustomerEmail('buyer@example.com'); + $unitPay->api('initPayment', [ + 'account' => 1, + 'sum' => 100, + 'projectId' => 7, + 'paymentType' => 'card', + ]); + + // Second call, without re-setting the receipt/customer, must be clean. + $unitPay->api('getPayment', ['paymentId' => 555]); + + $this->assertStringContainsString('cashItems=', $urls[0]); + $this->assertStringNotContainsString('cashItems=', $urls[1]); + $this->assertStringNotContainsString('customerEmail=', $urls[1]); + } + + public function testNonObjectResponseIsReportedAsTemporaryServerError() + { + $transport = static function () { + return 'this is not json'; + }; + $unitPay = new UnitPay('unitpay.test', 'secret', $transport); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Temporary server error'); + $unitPay->api('getPayment', ['paymentId' => 1]); + } + + public function testUnsupportedMethodThrows() + { + $unitPay = new UnitPay('unitpay.test', 'secret', static function () { + return '{"result":{}}'; + }); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Method is not supported'); + $unitPay->api('doesNotExist'); + } + + public function testMissingRequiredParamThrows() + { + $unitPay = new UnitPay('unitpay.test', 'secret', static function () { + return '{"result":{}}'; + }); + + $this->expectException(InvalidArgumentException::class); + // initPayment requires account, sum, projectId, paymentType + $unitPay->api('initPayment', ['account' => 1]); + } + + public function testMissingSecretThrows() + { + $unitPay = new UnitPay('unitpay.test', null, static function () { + return '{"result":{}}'; + }); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('SecretKey is null'); + $unitPay->api('getPayment', ['paymentId' => 1]); + } + + public function testPayoutMethodsAreSupportedAndValidateRequiredParams() + { + $unitPay = new UnitPay('unitpay.test', 'secret', static function () { + return '{"result":{}}'; + }); + + $methods = [ + 'massPayment', + 'massPaymentStatus', + 'massPaymentAvailableAmount', + 'massPaymentCommissions', + 'getSbpBankList', + 'getBinInfo', + ]; + + foreach ($methods as $method) { + try { + $unitPay->api($method, []); + $this->fail($method . ' should require params'); + } catch (UnexpectedValueException $e) { + $this->fail($method . ' is not in the allowlist'); + } catch (InvalidArgumentException $e) { + // every payout method requires login first + $this->assertStringContainsString('login', $e->getMessage()); + } + } + } + + /** F009: a transport failure is a typed exception still catchable as InvalidArgumentException. */ + public function testTransportFailureThrowsTypedTransportException() + { + $unitPay = new UnitPay('unitpay.test', 'secret', static function () { + return false; // simulate a transport failure + }); + + try { + $unitPay->api('getPayment', ['paymentId' => 1]); + $this->fail('expected a transport exception'); + } catch (\UnitpayTransportException $e) { + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertStringContainsString('Temporary server error', $e->getMessage()); + } + } + + /** F009: unsupported method throws a typed exception still catchable as UnexpectedValueException. */ + public function testUnsupportedMethodThrowsTypedException() + { + $unitPay = new UnitPay('unitpay.test', 'secret', static function () { + return '{"result":{}}'; + }); + + try { + $unitPay->api('doesNotExist'); + $this->fail('expected an unsupported-method exception'); + } catch (\UnitpayUnsupportedMethodException $e) { + $this->assertInstanceOf(UnexpectedValueException::class, $e); + } + } + + /** Account-level methods can override the project key with the account secretKey. */ + public function testExplicitSecretKeyOverridesInstanceKey() + { + $captured = null; + $transport = static function ($url) use (&$captured) { + $captured = $url; + return '{"result":{}}'; + }; + $unitPay = new UnitPay('unitpay.test', 'project-key', $transport); + + $unitPay->api('getPartner', [ + 'login' => 'partner@example.com', + 'secretKey' => 'account-key', + ]); + + parse_str((string) parse_url($captured, PHP_URL_QUERY), $q); + $this->assertSame('account-key', $q['secretKey']); + } +} diff --git a/tests/UnitPayCashItemsTest.php b/tests/UnitPayCashItemsTest.php new file mode 100644 index 0000000..84ab70e --- /dev/null +++ b/tests/UnitPayCashItemsTest.php @@ -0,0 +1,103 @@ +form('pk', 1, 'acc', 'desc'); + parse_str((string) parse_url($url, PHP_URL_QUERY), $q); + + return json_decode(base64_decode($q['cashItems']), true); + } + + public function testRequiredFieldsAreAlwaysSerialized() + { + $unitPay = new UnitPay('unitpay.ru', 'secret'); + $unitPay->setCashItems([ + new CashItem( + 'Coffee', + 2, + 150.5, + CashItem::NDS_20, + CashItem::PAYMENT_OBJECT_COMMODITY, + CashItem::PAYMENT_METHOD_PAYMENT_FULL + ), + ]); + + $items = $this->serializedItems($unitPay); + + $this->assertCount(1, $items); + $this->assertSame('Coffee', $items[0]['name']); + $this->assertSame(2, $items[0]['count']); + $this->assertSame(150.5, $items[0]['price']); + $this->assertSame('vat20', $items[0]['nds']); + $this->assertSame('commodity', $items[0]['type']); + $this->assertSame('full_payment', $items[0]['paymentMethod']); + } + + public function testOptionalFieldsAreOmittedWhenNotSet() + { + $unitPay = new UnitPay('unitpay.ru', 'secret'); + $unitPay->setCashItems([new CashItem('X', 1, 10.0)]); + + $items = $this->serializedItems($unitPay); + + foreach (['sum', 'currency', 'measure', 'nomenclatureCode', 'markCode', 'markQuantity', 'pre_text', 'post_text'] as $optional) { + $this->assertArrayNotHasKey($optional, $items[0], "Optional key '$optional' must be absent when unset"); + } + } + + public function testOptionalFieldsAreSerializedWhenSet() + { + $item = new CashItem('Y', 1, 10.5); + $item->setSum(10.5) + ->setCurrency('USD') + ->setMeasure(CashItem::MEASURE_KG) + ->setNomenclatureCode('NC-1') + ->setMarkCode('MC-1') + ->setPreText('pre') + ->setPostText('post') + ->setMarkQuantity(1, 2); + + $unitPay = new UnitPay('unitpay.ru', 'secret'); + $unitPay->setCashItems([$item]); + + $items = $this->serializedItems($unitPay); + + $this->assertSame(10.5, $items[0]['sum']); + $this->assertSame('USD', $items[0]['currency']); + $this->assertSame(11, $items[0]['measure']); + $this->assertSame('NC-1', $items[0]['nomenclatureCode']); + $this->assertSame('MC-1', $items[0]['markCode']); + $this->assertSame(['numerator' => 1, 'denominator' => 2], $items[0]['markQuantity']); + $this->assertSame('pre', $items[0]['pre_text']); + $this->assertSame('post', $items[0]['post_text']); + } + + public function testMultipleItemsKeepTheirOrder() + { + $unitPay = new UnitPay('unitpay.ru', 'secret'); + $unitPay->setCashItems([ + new CashItem('A', 1, 1.5), + new CashItem('B', 2, 2.5), + ]); + + $items = $this->serializedItems($unitPay); + + $this->assertCount(2, $items); + $this->assertSame('A', $items[0]['name']); + $this->assertSame('B', $items[1]['name']); + } +} diff --git a/tests/UnitPayFormTest.php b/tests/UnitPayFormTest.php new file mode 100644 index 0000000..feca192 --- /dev/null +++ b/tests/UnitPayFormTest.php @@ -0,0 +1,102 @@ +form('public-key', 100, 'user@example.com', 'Order #1'); + + $this->assertStringStartsWith('https://unitpay.ru/pay/public-key?', $url); + + $q = $this->queryOf($url); + $this->assertSame('user@example.com', $q['account']); + $this->assertSame('RUB', $q['currency']); + $this->assertSame('Order #1', $q['desc']); + $this->assertSame('100', $q['sum']); + $this->assertSame('ru', $q['locale']); + } + + public function testFormIncludesSignatureOverVitalParamsWhenSecretIsSet() + { + $unitPay = new UnitPay('unitpay.ru', 'secret'); + + $q = $this->queryOf($unitPay->form('pk', 100, 'acc', 'desc')); + + $this->assertArrayHasKey('signature', $q); + $this->assertSame( + $unitPay->getSignature([ + 'account' => 'acc', + 'currency' => 'RUB', + 'desc' => 'desc', + 'sum' => 100, + ]), + $q['signature'] + ); + } + + public function testFormThrowsWithoutSecret() + { + $unitPay = new UnitPay('unitpay.ru'); + + $this->expectException(\UnitpayValidationException::class); + $unitPay->form('pk', 100, 'acc', 'desc'); + } + + public function testFormHonoursCurrencyAndLocaleOverrides() + { + $unitPay = new UnitPay('unitpay.ru', 'secret'); + + $q = $this->queryOf($unitPay->form('pk', 100, 'acc', 'desc', 'USD', 'en')); + + $this->assertSame('USD', $q['currency']); + $this->assertSame('en', $q['locale']); + } + + public function testChainedSettersLandInTheFormUrl() + { + $unitPay = new UnitPay('unitpay.ru', 'secret'); + $unitPay->setBackUrl('https://shop.example/back') + ->setCustomerEmail('customer@example.com') + ->setCustomerPhone('+79990000000') + ->setCashItems([new CashItem('X', 1, 100.0)]); + + $q = $this->queryOf($unitPay->form('pk', 100, 'acc', 'desc')); + + $this->assertSame('https://shop.example/back', $q['backUrl']); + $this->assertSame('customer@example.com', $q['customerEmail']); + $this->assertSame('+79990000000', $q['customerPhone']); + $this->assertArrayHasKey('cashItems', $q); + } + + /** The form signature must cover ONLY the four vital params, not the setter params. */ + public function testFormSignatureExcludesSetterParams() + { + $unitPay = new UnitPay('unitpay.ru', 'secret'); + $unitPay->setCustomerEmail('customer@example.com') + ->setCashItems([new CashItem('X', 1, 100.0)]); + + $q = $this->queryOf($unitPay->form('pk', 100, 'acc', 'desc')); + + $expected = (new UnitPay('unitpay.ru', 'secret'))->getSignature([ + 'account' => 'acc', + 'currency' => 'RUB', + 'desc' => 'desc', + 'sum' => 100, + ]); + $this->assertSame($expected, $q['signature']); + } +} diff --git a/tests/UnitPayHandlerTest.php b/tests/UnitPayHandlerTest.php new file mode 100644 index 0000000..adcd3d3 --- /dev/null +++ b/tests/UnitPayHandlerTest.php @@ -0,0 +1,205 @@ + '42', + 'orderSum' => '100.00', + 'orderCurrency' => 'RUB', + 'date' => '2026-07-20 12:00:00', + 'payerSum' => '100.00', + 'unitpayId' => '999', + ], $overrides); + + $params['signature'] = $this->sign($params, $method); + + return ['method' => $method, 'params' => $params]; + } + + private function sign(array $params, $method) + { + return (new UnitPay('unitpay.ru', self::SECRET))->getSignature($params, $method); + } + + private function handler(array $request, $ip = self::ALLOWED_IP, $secret = self::SECRET) + { + return new UnitPay('unitpay.ru', $secret, null, $request, $ip); + } + + public function testValidSignatureAndAllowedIpPass() + { + $this->assertTrue($this->handler($this->validRequest('pay'))->checkHandlerRequest()); + } + + public function testTamperedParamsAreRejected() + { + $request = $this->validRequest('pay'); + $request['params']['orderSum'] = '0.01'; // changed after signing + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Wrong signature'); + $this->handler($request)->checkHandlerRequest(); + } + + public function testDisallowedIpIsRejected() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('IP address Error'); + $this->handler($this->validRequest('pay'), '8.8.8.8')->checkHandlerRequest(); + } + + public function testEmptySecretIsRejected() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('SecretKey is null'); + $this->handler($this->validRequest('pay'), self::ALLOWED_IP, null)->checkHandlerRequest(); + } + + public function testMissingMethodIsRejected() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Method is null'); + $this->handler(['params' => ['x' => '1']])->checkHandlerRequest(); + } + + public function testMissingParamsIsRejected() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Params is null'); + $this->handler(['method' => 'pay'])->checkHandlerRequest(); + } + + public function testUnsupportedPartnerMethodIsRejected() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Method is not supported'); + $this->handler($this->validRequest('refund'))->checkHandlerRequest(); + } + + /** + * A two-stage (preauth) hold notification is a valid webhook method Unitpay + * sends (method = check | pay | preauth | error). It must pass verification, + * not be rejected as unsupported. + */ + public function testPreauthPartnerMethodIsSupported() + { + $request = $this->validRequest('preauth', ['isPreauth' => '1']); + + $unitPay = $this->handler($request); + + $this->assertTrue($unitPay->checkHandlerRequest()); + $this->assertSame('preauth', $unitPay->getHandlerMethod()); + } + + /** + * F002: a non-string signature (e.g. an array injected via $_GET) must be + * rejected cleanly as "Wrong signature", not blow up with a TypeError. + */ + public function testArraySignatureIsRejectedCleanly() + { + $request = $this->validRequest('pay'); + $request['params']['signature'] = ['not', 'a', 'string']; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Wrong signature'); + $this->handler($request)->checkHandlerRequest(); + } + + /** + * F001: a crafted params[PHP_INT_MAX] must not crash verification and must + * still verify correctly (the key is stripped on both signing and checking). + */ + public function testPhpIntMaxKeyInParamsDoesNotBreakVerification() + { + $params = [ + 'account' => '42', + 'orderSum' => '100.00', + 'orderCurrency' => 'RUB', + ]; + $params[PHP_INT_MAX] = 'injected'; + $params['signature'] = $this->sign($params, 'pay'); + + $request = ['method' => 'pay', 'params' => $params]; + + $this->assertTrue($this->handler($request)->checkHandlerRequest()); + } + + public function testSetAllowedIpsOverridesTheDefaultAllowlist() + { + $customIp = '203.0.113.7'; // TEST-NET-3, not in the shipped default list + $unitPay = $this->handler($this->validRequest('pay'), $customIp); + $unitPay->setAllowedIps([$customIp]); + + $this->assertTrue($unitPay->checkHandlerRequest()); + } + + /** 127.0.0.1 is NOT trusted by default: behind a same-host proxy it would neuter the IP gate. */ + public function testLocalhostIsRejectedByDefault() + { + $unitPay = $this->handler($this->validRequest('pay'), '127.0.0.1'); + + $this->expectException(\UnitpayIpException::class); + $unitPay->checkHandlerRequest(); + } + + /** F024: setAllowedIps accepts CIDR subnets, not just exact IPs. */ + public function testCidrAllowlistMatchesAddressInRange() + { + $unitPay = $this->handler($this->validRequest('pay'), '203.0.113.55'); + $unitPay->setAllowedIps(['203.0.113.0/24']); + + $this->assertTrue($unitPay->checkHandlerRequest()); + } + + public function testCidrAllowlistRejectsAddressOutOfRange() + { + $unitPay = $this->handler($this->validRequest('pay'), '203.0.114.1'); + $unitPay->setAllowedIps(['203.0.113.0/24']); + + $this->expectException(\UnitpayIpException::class); + $unitPay->checkHandlerRequest(); + } + + /** F024: CIDR matching also works for IPv6 (binary compare via inet_pton). */ + public function testCidrAllowlistMatchesIpv6InRange() + { + $unitPay = $this->handler($this->validRequest('pay'), '2001:db8::1'); + $unitPay->setAllowedIps(['2001:db8::/32']); + + $this->assertTrue($unitPay->checkHandlerRequest()); + } + + /** F009: typed exception that still extends the historical SPL type + marker interface. */ + public function testSignatureFailureThrowsTypedExceptionStillCatchableAsInvalidArgument() + { + $request = $this->validRequest('pay'); + $request['params']['orderSum'] = '0.01'; // changed after signing + + try { + $this->handler($request)->checkHandlerRequest(); + $this->fail('expected a signature exception'); + } catch (\UnitpaySignatureException $e) { + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertInstanceOf(\UnitpayExceptionInterface::class, $e); + } + } +} diff --git a/tests/UnitPayResponseTest.php b/tests/UnitPayResponseTest.php new file mode 100644 index 0000000..d4e2beb --- /dev/null +++ b/tests/UnitPayResponseTest.php @@ -0,0 +1,33 @@ +unitPay = new UnitPay('unitpay.ru', 'secret'); + } + + public function testSuccessHandlerResponseShape() + { + $this->assertSame( + '{"result":{"message":"ok"}}', + $this->unitPay->getSuccessHandlerResponse('ok') + ); + } + + public function testErrorHandlerResponseShape() + { + $this->assertSame( + '{"error":{"message":"bad"}}', + $this->unitPay->getErrorHandlerResponse('bad') + ); + } +} diff --git a/tests/UnitPaySignatureTest.php b/tests/UnitPaySignatureTest.php new file mode 100644 index 0000000..b8f7941 --- /dev/null +++ b/tests/UnitPaySignatureTest.php @@ -0,0 +1,104 @@ +unitPay = new UnitPay('unitpay.ru', 'secret'); + } + + public function testSignatureMatchesDocumentedFormula() + { + // sha256( {up}secretKey ) + $this->assertSame( + hash('sha256', '1{up}secret'), + $this->unitPay->getSignature(['a' => '1']) + ); + } + + public function testSignatureIsIndependentOfKeyOrder() + { + $this->assertSame( + $this->unitPay->getSignature(['a' => '1', 'b' => '2']), + $this->unitPay->getSignature(['b' => '2', 'a' => '1']) + ); + } + + /** + * Pin the sort DIRECTION to a literal: ksort is ascending by key, so keys + * c,a,b become values 1,2,3. A krsort/asort refactor would change this digest + * and break every multi-param production signature — this test would catch it. + */ + public function testSignaturePinsAscendingKeyOrder() + { + $this->assertSame( + hash('sha256', 'pay{up}1{up}2{up}3{up}secret'), + $this->unitPay->getSignature(['c' => '3', 'a' => '1', 'b' => '2'], 'pay') + ); + } + + public function testMethodIsPrependedToPayload() + { + $this->assertSame( + hash('sha256', 'pay{up}1{up}secret'), + $this->unitPay->getSignature(['a' => '1'], 'pay') + ); + $this->assertNotSame( + $this->unitPay->getSignature(['a' => '1']), + $this->unitPay->getSignature(['a' => '1'], 'pay') + ); + } + + public function testCallerSuppliedSignatureKeysAreStripped() + { + $this->assertSame( + $this->unitPay->getSignature(['a' => '1']), + $this->unitPay->getSignature(['a' => '1', 'sign' => 'x', 'signature' => 'y']) + ); + } + + /** + * Regression for F001: a crafted params[PHP_INT_MAX] must be stripped so it + * cannot knock the auto-appended secretKey out of the hash (forgeable + * signature on PHP <8, fatal Error on PHP >=8). Must not throw, and the + * resulting signature must equal the one without the malicious key. + */ + public function testPhpIntMaxKeyIsStrippedAndSecretRetained() + { + $this->assertSame( + $this->unitPay->getSignature(['a' => '1']), + $this->unitPay->getSignature([PHP_INT_MAX => 'evil', 'a' => '1']) + ); + } + + /** + * An injected array value (e.g. webhook params[x][]=1) must not raise an + * "Array to string conversion" warning; the array is coerced to '' and the + * check simply fails to match a legitimate signature. + */ + public function testArrayValuedParamDoesNotEmitWarning() + { + set_error_handler(static function ($errno, $errstr) { + throw new \RuntimeException($errstr, $errno); + }); + try { + $signature = $this->unitPay->getSignature(['a' => ['nested']], 'pay'); + } finally { + restore_error_handler(); + } + + // '' substituted for the array, so it matches an empty-valued param. + $this->assertSame( + $this->unitPay->getSignature(['a' => ''], 'pay'), + $signature + ); + } +} From 133c26fff0763ffacb0adec6238f727aacc41485 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Thu, 16 Jul 2026 11:05:00 +0300 Subject: [PATCH 04/30] =?UTF-8?q?docs(examples):=20scenarios=20for=20the?= =?UTF-8?q?=20new=20API=20methods=20=E2=80=94=20refund,=20two-stage,=20sub?= =?UTF-8?q?scriptions,=20payouts,=20account?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/accountApi.php | 40 +++++++++++++++++++++++++++++++ examples/handler.php | 21 +++++++++------- examples/initPaymentApi.php | 14 +++++++++-- examples/orderInfo.php | 11 +++++++-- examples/paymentInfo.php | 2 +- examples/payout.php | 46 ++++++++++++++++++++++++++++++++++++ examples/refund.php | 25 ++++++++++++++++++++ examples/subscription.php | 34 ++++++++++++++++++++++++++ examples/twoStagePayment.php | 31 ++++++++++++++++++++++++ 9 files changed, 210 insertions(+), 14 deletions(-) create mode 100644 examples/accountApi.php create mode 100644 examples/payout.php create mode 100644 examples/refund.php create mode 100644 examples/subscription.php create mode 100644 examples/twoStagePayment.php diff --git a/examples/accountApi.php b/examples/accountApi.php new file mode 100644 index 0000000..38fca25 --- /dev/null +++ b/examples/accountApi.php @@ -0,0 +1,40 @@ + $login, 'secretKey' => $accountSecretKey]; + +// Account balance and amount available for payout: +var_dump($unitpay->api('getPartner', $account)->result ?? null); + +// Acquiring commissions for a project (account key + projectId): +var_dump($unitpay->api('getCommissions', $account + ['projectId' => $projectId])->result ?? null); + +// Currency conversion rates (in / out): +var_dump($unitpay->api('getCurrencyCourses', $account)->result ?? null); + +// Card info by BIN (first 6 digits of the card number): +var_dump($unitpay->api('getBinInfo', $account + ['bin' => 424242])->result ?? null); + +// Advance-offset fiscal receipt for an earlier prepayment (creates a receipt): +var_dump($unitpay->api('offsetAdvance', $account + ['paymentId' => 3403575])->result ?? null); + +// Payment methods available on the project — project key, no login: +var_dump($unitpay->api('getMethodsAvailable', ['projectId' => $projectId])->result ?? null); diff --git a/examples/handler.php b/examples/handler.php index 61a71d2..091cdd3 100644 --- a/examples/handler.php +++ b/examples/handler.php @@ -15,7 +15,9 @@ // Validate request (check ip address, signature and etc) $unitpay->checkHandlerRequest(); - list($method, $params) = [$_GET['method'], $_GET['params']]; + // Read the validated request from the SDK (honours an injected request, not $_GET). + $method = $unitpay->getHandlerMethod(); + $params = $unitpay->getHandlerParams(); // Very important! Validate request with your order data, before complete order if ( @@ -33,23 +35,24 @@ case 'check': print $unitpay->getSuccessHandlerResponse('Check Success. Ready to pay.'); break; - // Method Pay means that the money received + // Method Pay means that the money received case 'pay': // Please complete order print $unitpay->getSuccessHandlerResponse('Pay Success'); break; - // Method Error means that an error has occurred. + // Method Preauth: two-stage payment — the money is only BLOCKED, not + // captured yet. Do NOT deliver goods/services here; wait for 'pay'. + // Just acknowledge so the notification isn't treated as failed. + case 'preauth': + print $unitpay->getSuccessHandlerResponse('Preauth received. Funds held, awaiting capture.'); + break; + // Method Error means that an error has occurred. case 'error': // Please log error text. print $unitpay->getSuccessHandlerResponse('Error logged'); break; - // Method Refund means that the money returned to the client - case 'refund': - // Please cancel the order - print $unitpay->getSuccessHandlerResponse('Order canceled'); - break; } -// Oops! Something went wrong. + // Oops! Something went wrong. } catch (Exception $e) { print $unitpay->getErrorHandlerResponse($e->getMessage()); } diff --git a/examples/initPaymentApi.php b/examples/initPaymentApi.php index a17a186..12d48a4 100644 --- a/examples/initPaymentApi.php +++ b/examples/initPaymentApi.php @@ -43,7 +43,7 @@ // User redirect header("Location: " . $redirectUrl); -// If without redirect (invoice) + // If without redirect (invoice) } elseif (isset($response->result->type) && $response->result->type === 'invoice') { // Url on receipt page in Unitpay @@ -55,7 +55,17 @@ // User redirect header("Location: " . $receiptUrl); -// If error during api request + // If processed without redirect (e.g. recurring/subscription charge) +} elseif (isset($response->result->type) + && $response->result->type === 'response') { + // Payment ID in Unitpay (you can save it) + $paymentId = $response->result->paymentId; + // Human-readable result message + $message = $response->result->message; + // Optional status page in Unitpay: $response->result->statusUrl + print $message; + + // If error during api request } elseif (isset($response->error->message)) { $error = $response->error->message; print 'Error: '.$error; diff --git a/examples/orderInfo.php b/examples/orderInfo.php index 34c7243..6204a7e 100644 --- a/examples/orderInfo.php +++ b/examples/orderInfo.php @@ -7,7 +7,8 @@ // Project Data $domain = 'unitpay.ru'; $projectId = 1; -$secretKey = '2907b9e4a48d9450b6f125b8f184be8a'; +// Never hardcode secrets. Read them from the environment (or your config/secret store). +$secretKey = getenv('UNITPAY_SECRET_KEY') ?: 'set-me-in-env'; $publicId = '15155-ae12d'; // My item Info @@ -17,4 +18,10 @@ $orderId = 'a183f94-1434-1e44'; $orderSum = 900; $orderDesc = 'Payment for item "' . $itemName . '"'; -$orderCurrency = 'RUB'; \ No newline at end of file +$orderCurrency = 'RUB'; + +// Account-level API (payouts, getPartner, commissions, currency rates, BIN, +// offsetAdvance) authenticates with the ACCOUNT key + login (profile), not the +// project key. Pass these explicitly in api() to override the project key. +$login = getenv('UNITPAY_LOGIN') ?: 'partner@example.com'; +$accountSecretKey = getenv('UNITPAY_ACCOUNT_SECRET_KEY') ?: 'set-account-key-in-env'; diff --git a/examples/paymentInfo.php b/examples/paymentInfo.php index c58eb02..da6d735 100644 --- a/examples/paymentInfo.php +++ b/examples/paymentInfo.php @@ -22,7 +22,7 @@ // Payment Info $paymentInfo = $response->result; var_dump($paymentInfo); -// If error during api request + // If error during api request } elseif (isset($response->error->message)) { $error = $response->error->message; print 'Error: '.$error; diff --git a/examples/payout.php b/examples/payout.php new file mode 100644 index 0000000..b134165 --- /dev/null +++ b/examples/payout.php @@ -0,0 +1,46 @@ + $login, 'secretKey' => $accountSecretKey]; + +// SBP participant banks — memberId is required for SBP payouts: +$banks = $unitpay->api('getSbpBankList', $account); +var_dump($banks->result ?? $banks->error ?? $banks); + +$transactionId = 'payout-1782'; // unique on your side + +// Create a payout to an SBP recipient: +$response = $unitpay->api('massPayment', $account + [ + 'transactionId' => $transactionId, + 'sum' => 100, + 'purse' => '79510000071', + 'paymentType' => 'sbp', + 'memberId' => '100000000004', // from getSbpBankList; SBP only +]); + +if (isset($response->result)) { + $payoutId = $response->result->payoutId; + $status = $response->result->status; // success | not_completed + + // Later — check the payout status by your transactionId: + $info = $unitpay->api('massPaymentStatus', $account + ['transactionId' => $transactionId]); + var_dump($info->result ?? $info->error ?? $info); +} elseif (isset($response->error->message)) { + print 'Error: ' . $response->error->message; +} diff --git a/examples/refund.php b/examples/refund.php new file mode 100644 index 0000000..aff0030 --- /dev/null +++ b/examples/refund.php @@ -0,0 +1,25 @@ +api('refundPayment', [ + 'paymentId' => 3403575, + // 'sum' => 100, // optional: partial refund; omit for a full refund +]); + +if (isset($response->result->message)) { + print $response->result->message; +} elseif (isset($response->error->message)) { + print 'Error: ' . $response->error->message; +} diff --git a/examples/subscription.php b/examples/subscription.php new file mode 100644 index 0000000..08cc813 --- /dev/null +++ b/examples/subscription.php @@ -0,0 +1,34 @@ + 1 to include every status): +$list = $unitpay->api('listSubscriptions', ['projectId' => $projectId]); +var_dump($list->result ?? $list->error ?? $list); + +$subscriptionId = 12345; + +// Details of one subscription: +$info = $unitpay->api('getSubscription', ['subscriptionId' => $subscriptionId]); +var_dump($info->result ?? $info->error ?? $info); + +// Close it (stops charges, unlinks the card — irreversible): +$closed = $unitpay->api('closeSubscription', ['subscriptionId' => $subscriptionId]); +if (isset($closed->result->message)) { + print $closed->result->message; +} elseif (isset($closed->error->message)) { + print 'Error: ' . $closed->error->message; +} diff --git a/examples/twoStagePayment.php b/examples/twoStagePayment.php new file mode 100644 index 0000000..be9a072 --- /dev/null +++ b/examples/twoStagePayment.php @@ -0,0 +1,31 @@ +message`. + * + * @link https://help.unitpay.ru/api/confirm-payment + * @link https://help.unitpay.ru/api/cancel-payment + */ + +require_once('./orderInfo.php'); +require_once('../UnitPay.php'); + +$unitpay = new UnitPay($domain, $secretKey); + +$paymentId = 3403575; + +// Capture the held funds: +$response = $unitpay->api('confirmPayment', ['paymentId' => $paymentId]); + +// ...or release them without charging: +// $response = $unitpay->api('cancelPayment', ['paymentId' => $paymentId]); + +if (isset($response->message)) { + print $response->message; +} elseif (isset($response->error->message)) { + print 'Error: ' . $response->error->message; +} From b1a061da0754a187e402f70b3b665b482c46d723 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Fri, 17 Jul 2026 14:38:00 +0300 Subject: [PATCH 05/30] docs: README and CHANGELOG for the 2.1.0 feature set --- CHANGELOG.md | 82 +++++++++++++++++++++++- README.md | 177 +++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 239 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f8e16b..b750f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,83 @@ # Changelog -## 2.0.4 - 2021-03-17 +### v2.1.0 от 21.07.2026 +* CashItem: synced the 54-FZ dictionaries with the current backend + * Added VAT rates: vat5, vat7, vat22 and the computed rates vat105, vat107, vat110, vat120, vat122 + * Added payment objects: payment_2, deposit, expense, pension_insurance_ip, pension_insurance, medical_insurance_ip, medical_insurance, social_insurance, casino_payment, issuance_bank, commodity_without_mark, commodity_mark + * Deprecated (kept for BC, to be removed in 3.0) the values rejected by the public API: excise, gambling_bet, gambling_prize, lottery_prize, composite +* CashItem: added optional fields supported by the backend — sum, currency, measure (with MEASURE_* constants), nomenclatureCode, markCode, markQuantity, pre_text, post_text; setCashItems() now serializes them only when set +* api(): added coverage for refundPayment, confirmPayment, cancelPayment, listSubscriptions, getSubscription, closeSubscription, getMethodsAvailable, getCommissions, getCurrencyCourses, getPartner, offsetAdvance — with per-method required-param validation (secretKey injected automatically) +* api(): added payout coverage — massPayment, massPaymentStatus, massPaymentAvailableAmount, massPaymentCommissions, getSbpBankList, getBinInfo (all require login + account secretKey) +* api(): an explicit secretKey in the call params now overrides the constructor key, so account-level and payout methods can be called with the account key instead of the project key +* examples: added refund.php, twoStagePayment.php, subscription.php, payout.php and accountApi.php; orderInfo.php now also carries the account login + key for account-level/payout calls +* api(): aligned initPayment required params with the backend — account, sum, projectId, paymentType (secretKey is still enforced separately); desc is no longer forced +* api(): now sends flat query params (method=X&account=…&secretKey=…), the format Unitpay documents and accepts since 05/2026, instead of the legacy params[...] nesting (still accepted by the backend, so this is not a breaking change); the inbound webhook handler is unaffected and keeps reading params[...] +* api(): params set through the fluent setters (setCashItems, setCustomerEmail, setCustomerPhone, setBackUrl) now reach api() calls too, not only form() — so setCashItems()->api('initPayment', ...) actually sends the receipt; explicit api() params override the accumulated ones +* CashItem: the constructor now also rejects a non-numeric count or price (previously only 0/negative were caught) and normalizes numeric strings to int/float +* handler: reduced the IP whitelist to the officially published addresses (31.186.100.49, 51.250.20.9); 127.0.0.1 is NOT trusted by default (behind a same-host reverse proxy it would neuter the IP gate) — add it via setAllowedIps() for local debug; added setAllowedIps() to override the list +* handler: checkHandlerRequest() now accepts the `preauth` webhook (the two-stage hold notification Unitpay sends when funds are blocked but not yet captured) — it was previously rejected as an unsupported method, so two-stage/subscription handlers could never verify it +* examples/README: handle the initPayment "response" result type (e.g. recurring/subscription charges without a redirect) +* Added a PHPUnit test suite and injectable seams (getIp/API transport) for testing +* Added QA tooling: phpstan, php-cs-fixer, phpmd and parallel-lint +* Raised the minimum PHP requirement to 7.4 +* handler: isAllowedIp() now matches CIDR subnets (IPv4/IPv6) as well as exact IPs, so setAllowedIps(['77.75.153.0/25']) works +* Added typed exceptions (UnitpaySignatureException, UnitpayIpException, UnitpayTransportException, UnitpayUnsupportedMethodException) implementing UnitpayExceptionInterface; each still extends the SPL exception it used to throw, so existing catch blocks keep working +* CashItem: the constructor now rejects a non-positive count or a negative price (behavioural change) +* api(): optional cURL transport with connect/read timeouts and no allow_url_fopen dependency (falls back to file_get_contents); ext-curl added to composer "suggest" +* api(): the cURL transport does not call curl_close() on PHP 8.0+, where it is a deprecated no-op that emits an E_DEPRECATED notice on PHP 8.5 on every API call; on PHP <8.0 the handle (a resource) is closed explicitly via a PHP_VERSION_ID guard for deterministic cleanup +* examples: removed the unreachable "refund" handler branch; added a "preauth" branch (two-stage hold notification — acknowledge receipt without delivering goods, which must wait for "pay"); orderInfo.php now reads the secret from the UNITPAY_SECRET_KEY environment variable instead of hardcoding it +* code-review hardening: + * api(): folds only the fluent-setter params (cashItems/customerEmail/customerPhone/backUrl), not the whole param bag — a reused instance no longer leaks form()'s vital params or a stale signature into an unrelated api() call + * setCashItems(): throws on a json_encode failure (e.g. a non-UTF-8 product name) instead of silently attaching an empty 54-FZ receipt + * CashItem: keeps a fractional count (weight/volume goods) instead of truncating it to int + * form(): throws when the secret is empty instead of returning an unsigned URL, consistent with api()/checkHandlerRequest() + * getSignature()/api()/form(): format float params locale-independently so a comma-decimal locale on PHP <8.0 cannot corrupt the signature or amount + * api(): a falsy explicit secretKey (e.g. an unset getenv()) falls back to the instance key instead of throwing + * httpGet(): suppresses the file_get_contents failure warning so the secret-bearing URL is never written to the error log + * checkHandlerRequest(): exposes the validated method/params via getHandlerMethod()/getHandlerParams() so consumers no longer re-read $_GET + * every SDK exception now implements UnitpayExceptionInterface (added UnitpayValidationException for the missing-param/secret/method cases) + +### v2.0.6 от 14.05.2025 +* Added a new supported Unitpay IP address +* Updated README.md + +### v2.0.5 от 04.02.2022 +* Updated the list of Unitpay IP addresses +* Updated documentation links +* Code quality and structure cleanup + +### v2.0.4 от 17.03.2021 * Updated getSignature method (2Garin) -* Changelog started \ No newline at end of file + +### v2.0.3 от 20.02.2021 +* Filter signature input parameters (strip sign/signature fields before signing) + +### v2.0.2 от 31.08.2020 +* Added nds, type and paymentMethod parameters to CashItem + +### v2.0.1 от 03.03.2020 +* Added domain selection to the examples + +### v2.0.0 от 03.03.2020 +* Added domain selection (configurable API domain) +* Updated the documentation URL + +### v1.1.2 от 15.06.2018 +* Fixed an array_merge exception ("Argument #1 is not an array") when no cash items are set + +### v1.1.1 от 08.02.2018 +* Added the LICENSE file +* Fixed the composer file + +### v1.1.0 от 01.08.2017 +* Added customerEmail, customerPhone and cashItems to the payment form + +### v1.0.0 от 10.04.2017 +* Initial public release of the Unitpay PHP SDK +* Switched to SHA-256 signatures for all methods (MD5 support removed) +* Added the getPayment API method and the orderInfo.php sample +* secretKey is now a required parameter for API calls +* Renamed billingCode to paymentType +* Deprecated statusUrl in favour of receiptUrl +* Added support for the partner handler method "error" +* Added an overridable getIp() method diff --git a/README.md b/README.md index 20f98d6..293cbcc 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,42 @@ # Unitpay PHP SDK +[![CI](https://github.com/unitpay/php-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/unitpay/php-sdk/actions/workflows/ci.yml) +[![Latest Stable Version](https://img.shields.io/packagist/v/unitpay/php-sdk.svg)](https://packagist.org/packages/unitpay/php-sdk) +[![PHP Version](https://img.shields.io/packagist/php-v/unitpay/php-sdk.svg)](https://packagist.org/packages/unitpay/php-sdk) +[![Total Downloads](https://img.shields.io/packagist/dt/unitpay/php-sdk.svg)](https://packagist.org/packages/unitpay/php-sdk) +[![License](https://img.shields.io/packagist/l/unitpay/php-sdk.svg)](LICENSE.md) + PHP SDK for [Unitpay.ru](https://unitpay.ru). -Documentation https://help.unitpay.ru +Documentation: [help.unitpay.ru](https://help.unitpay.ru) + +## Requirements + +* PHP >= 7.4 +* ext-json -## Examples ## +No runtime dependencies. The whole SDK is a single file — [`UnitPay.php`](UnitPay.php) — +exposing two classes in the **global namespace**: `UnitPay` and `CashItem`. -These are just some quick examples. Check out the samples -in [`/examples`](https://github.com/unitpay/php-sdk/blob/master/examples). +## Examples + +These are just some quick examples. The [`examples/`](examples) folder has +runnable samples for every method group: + +* [`initPaymentForm.php`](examples/initPaymentForm.php) / [`initPaymentApi.php`](examples/initPaymentApi.php) — create a payment (form / API) +* [`paymentInfo.php`](examples/paymentInfo.php) — `getPayment` +* [`handler.php`](examples/handler.php) — webhook handler (`check` / `pay` / `error`) +* [`refund.php`](examples/refund.php) — `refundPayment` +* [`twoStagePayment.php`](examples/twoStagePayment.php) — `confirmPayment` / `cancelPayment` +* [`subscription.php`](examples/subscription.php) — list / info / close subscriptions +* [`payout.php`](examples/payout.php) — payouts (mass-payment) + SBP bank list +* [`accountApi.php`](examples/accountApi.php) — balance, commissions, rates, BIN, `offsetAdvance`, methods ### Payment integration using Unitpay form ```php form( header("Location: " . $redirectUrl); ``` +### Fiscal receipts (54-FZ) + +Attach receipt line items with `CashItem` and `setCashItems()` (works with both +`form()` and `api('initPayment', ...)`). The constructor takes the required +fields; optional fields are set via fluent setters and are serialized only when +set: + +```php +$item = new CashItem( + 'Iphone 6 Skin Cover', // name + 1, // count + 900, // price + CashItem::NDS_20, // VAT rate + CashItem::PAYMENT_OBJECT_COMMODITY, // payment object + CashItem::PAYMENT_METHOD_PAYMENT_FULL +); +$item->setMeasure(CashItem::MEASURE_ITEM); + +$unitpay->setCashItems([$item]); +``` + +VAT rates (`NDS_*`), payment objects (`PAYMENT_OBJECT_*`), payment methods +(`PAYMENT_METHOD_*`) and units of measure (`MEASURE_*`) are exposed as constants +on `CashItem`. + +> Since 2026 the backend fiscalizes `NDS_20` (`vat20`) as VAT **22%** — there is +> no separate path for "real" 20%. Pick the rate that matches the actual receipt +> (see [CHANGELOG.md](CHANGELOG.md)). + ### Payment integration using Unitpay API ```php @@ -63,7 +115,7 @@ header('Content-Type: text/html; charset=UTF-8'); * @link https://help.unitpay.ru/payments/create-payment */ -include ('../UnitPay.php'); +require __DIR__ . '/vendor/autoload.php'; // Project Data $domain = 'unitpay.ru';// Your working domain: unitpay.ru or address provided by unitpay support service @@ -102,7 +154,7 @@ $response = $unitpay->api('initPayment', [ // If need user redirect on Payment Gate if (isset($response->result->type) - && $response->result->type == 'redirect') { + && $response->result->type === 'redirect') { // Url on PaymentGate $redirectUrl = $response->result->redirectUrl; // Payment ID in Unitpay (you can save it) @@ -112,7 +164,7 @@ if (isset($response->result->type) // If without redirect (invoice) } elseif (isset($response->result->type) - && $response->result->type == 'invoice') { + && $response->result->type === 'invoice') { // Url on receipt page in Unitpay $receiptUrl = $response->result->receiptUrl; // Payment ID in Unitpay (you can save it) @@ -122,6 +174,16 @@ if (isset($response->result->type) // User redirect header("Location: " . $receiptUrl); +// If processed without redirect (e.g. recurring/subscription charge) +} elseif (isset($response->result->type) + && $response->result->type === 'response') { + // Payment ID in Unitpay (you can save it) + $paymentId = $response->result->paymentId; + // Human-readable result message + $message = $response->result->message; + // Optional status page in Unitpay: $response->result->statusUrl + print $message; + // If error during api request } elseif (isset($response->error->message)) { $error = $response->error->message; @@ -138,7 +200,7 @@ if (isset($response->result->type) * Demo handler for your projects * */ -include ('../UnitPay.php'); +require __DIR__ . '/vendor/autoload.php'; // Project Data $domain = 'unitpay.ru';// Your working domain: unitpay.ru or address provided by unitpay support service @@ -194,25 +256,104 @@ try { } ``` -## Installation +> The handler trusts a request only when the SHA-256 signature **and** the +> source IP both match. The built-in IP allowlist changes on Unitpay's side from +> time to time — override it with `$unitpay->setAllowedIps(['1.2.3.4', ...])` +> instead of waiting for a release, and override `getIp()` if you run behind a +> proxy. + +## API methods + +All methods are called through `api('', [...])`. `secretKey` is added +automatically from the constructor, so pass only the business params below. +Full parameters and response formats are in the +[official API documentation](https://help.unitpay.ru). + +| Method | Required params | Purpose | +| --- | --- | --- | +| `initPayment` | `account`, `sum`, `projectId`, `paymentType` | Create a payment | +| `getPayment` | `paymentId` | Payment info | +| `refundPayment` | `paymentId` (+ optional `sum`) | Refund a payment (full or partial) | +| `confirmPayment` | `paymentId` | Confirm (capture) a two-stage payment | +| `cancelPayment` | `paymentId` | Cancel (release) a two-stage payment | +| `listSubscriptions` | `projectId` (+ optional `all`) | List project subscriptions | +| `getSubscription` | `subscriptionId` | Subscription info | +| `closeSubscription` | `subscriptionId` | Close a subscription | +| `getMethodsAvailable` | `projectId` | Payment methods available on the project | +| `getCommissions` | `projectId`, `login` | Acquiring commissions for a project | +| `getCurrencyCourses` | `login` | Currency conversion rates | +| `getPartner` | `login` | Account balance | +| `offsetAdvance` | `login`, `paymentId` (+ optional `cashItems`) | Advance-offset fiscal receipt | +| `massPayment` | `login`, `transactionId`, `sum`, `purse`, `paymentType` (+ `memberId` for SBP) | Create a payout | +| `massPaymentStatus` | `login`, `transactionId` | Payout status | +| `massPaymentAvailableAmount` | `login`, `sum`, `purse`, `paymentType` | Balance available for payout | +| `massPaymentCommissions` | `login` | Payout commissions | +| `getSbpBankList` | `login` | SBP participant banks | +| `getBinInfo` | `login`, `bin` | Card info by BIN | + +For the account-level methods (`getCommissions`, `getCurrencyCourses`, +`getPartner`, `offsetAdvance` and all payout methods) the `secretKey` is the +**account** key (profile), not the project key, and `login` is the account email. +Pass the account key explicitly in the call — it overrides the constructor +(project) key: -### Install composer package -Set up `composer.json` in your project directory: +```php +$response = $unitpay->api('getPartner', [ + 'login' => 'partner@example.com', + 'secretKey' => $accountKey, // overrides the project key from the constructor +]); ``` -{ - "require":{"unitpay/php-sdk":"dev-master"} + +For SBP payouts pass `memberId` obtained from `getSbpBankList`. + +Example — refund a payment: + +```php +$response = $unitpay->api('refundPayment', [ + 'paymentId' => 123456, + // 'sum' => 100, // optional: partial refund +]); + +if (isset($response->result->message)) { + print $response->result->message; +} elseif (isset($response->error->message)) { + print 'Error: ' . $response->error->message; } ``` -Run [composer](https://getcomposer.org/doc/00-intro.md#installation): +Note: `confirmPayment` and `cancelPayment` return a top-level `message` +(`$response->message`), not `$response->result->message`. + +## Installation + +### Composer (recommended) + ```sh -$ php composer.phar install +composer require unitpay/php-sdk +``` + +Then load the Composer autoloader — its classmap registers both `UnitPay` and +`CashItem`: + +```php +require __DIR__ . '/vendor/autoload.php'; +``` + +To follow the default branch (latest changes) instead of the newest tag: + +```sh +composer require unitpay/php-sdk:dev-master ``` ### Direct download -Download [latest version](https://github.com/unitpay/php-sdk/archive/master.zip), unzip and copy to your project folder. +Download the [latest version](https://github.com/unitpay/php-sdk/archive/master.zip), +unzip it and `require` the single file directly: + +```php +require '/path/to/UnitPay.php'; +``` -## Contributing ## +## Contributing Please feel free to contribute to this project! Pull requests and feature requests welcome! From f021baa814e2f35a5cef86514daa4deb48007904 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Sat, 18 Jul 2026 10:26:00 +0300 Subject: [PATCH 06/30] feat: dynamic webhook IP allowlist and PAYMENT_TYPE_* constants --- UnitPay.php | 145 ++++++++++++++++++++++++++++++++++++++++++++++++++-- phpmd.xml | 15 ++++++ 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/UnitPay.php b/UnitPay.php index 9b54a0b..d5085d0 100644 --- a/UnitPay.php +++ b/UnitPay.php @@ -513,6 +513,48 @@ private function cidrContains($cidr, $ipBin) return $this->prefixMatches($ipBin, $subnetBin, (int) $bits); } + /** + * Whether $entry is a well-formed allowlist entry: an exact IPv4/IPv6 address + * or an "address/bits" CIDR range. Used to validate a fetched IP feed before + * it replaces the built-in list, so malformed JSON cannot empty the allowlist. + * @param string $entry + * @return bool + */ + public static function isValidEntry($entry) + { + if (strpos($entry, '/') === false) { + return filter_var($entry, FILTER_VALIDATE_IP) !== false; + } + list($subnet, $bits) = explode('/', $entry, 2); + return ctype_digit($bits) && filter_var($subnet, FILTER_VALIDATE_IP) !== false; + } + + /** + * Parse the published webhook IP feed body ({"webhooks":[...]}) into a + * validated, de-duplicated list of allowlist entries. Returns null on empty + * input, malformed JSON, a missing or non-array "webhooks" key, or when no + * entry is a well-formed IP/CIDR — so a bad feed can never empty the allowlist. + * @param string $body + * @return string[]|null + */ + public static function parseWebhooksFeed($body) + { + if ($body === '') { + return null; + } + $data = json_decode($body, true); + if (!is_array($data) || !isset($data['webhooks']) || !is_array($data['webhooks'])) { + return null; + } + $valid = []; + foreach ($data['webhooks'] as $entry) { + if (is_string($entry) && self::isValidEntry($entry)) { + $valid[] = $entry; + } + } + return $valid === [] ? null : array_values(array_unique($valid)); + } + /** * @param string $ip * @return string|null packed in_addr, or null when $ip is not a valid address @@ -555,6 +597,27 @@ private function prefixMatches($ipBin, $subnetBin, $bits) */ class UnitPay { + // Коды способов оплаты для параметра `paymentType` в api('initPayment', ...) + // и выплатах api('massPayment', ...). Источник истины — бэкенд; список кодов: + // https://help.unitpay.ru/book-of-reference/payment-system-codes + // paymentType здесь НЕ валидируется по этим значениям (как и словари CashItem), + // поэтому новый код оплаты не требует релиза SDK — константы дают лишь + // защиту от опечаток и автодополнение. + /** Пластиковые карты (приём по картам всего мира) */ + public const PAYMENT_TYPE_CARD = 'card'; + /** Зарубежные карты через форму банка-эквайера */ + public const PAYMENT_TYPE_CARD_INVOICE = 'cardInvoice'; + /** Система быстрых платежей (СБП) */ + public const PAYMENT_TYPE_SBP = 'sbp'; + /** SberPay */ + public const PAYMENT_TYPE_SBERPAY = 'sberpay'; + /** Tinkoff Pay */ + public const PAYMENT_TYPE_TINKOFFPAY = 'tinkoffpay'; + /** PayPal */ + public const PAYMENT_TYPE_PAYPAL = 'paypal'; + /** WebMoney (WMZ-кошельки) */ + public const PAYMENT_TYPE_WEBMONEY = 'webmoney'; + // The supported api() methods are exactly the keys of this map; secretKey is // injected and validated by api(), so it is not listed among the required params. private $requiredUnitpayMethodsParams = [ @@ -600,6 +663,10 @@ class UnitPay private $handlerMethod; private $handlerParams; private $ipAllowlist; + // Merchant-specific IPs added via addAllowedIps(), always applied on top of the + // Unitpay list and preserved across refreshAllowedIps()/setAllowedIps(). + private $customIps = []; + private $ipsUrl; /** * @param string $domain Host only, e.g. "unitpay.ru" — no scheme or path (it becomes "https://$domain/api"). @@ -616,15 +683,18 @@ public function __construct($domain, $secretKey = null, ?callable $transport = n $this->secretKey = $secretKey; $this->apiUrl = "https://$domain/api"; $this->formUrl = "https://$domain/pay/"; + $this->ipsUrl = "https://$domain/ips/ips_webhooks.json"; $this->transport = $transport; $this->request = $request; $this->clientIp = $clientIp; } /** - * Override the list of Unitpay IP addresses allowed to call the handler. - * Replaces the built-in default entirely. Use it to keep the SDK in sync - * when the Unitpay infrastructure changes without waiting for a release. + * Override the Unitpay IP addresses allowed to call the handler. Replaces the + * built-in default (or a previously fetched list) entirely, but does NOT touch + * the merchant IPs added via addAllowedIps(), which stay applied on top. Use it + * to keep the SDK in sync when the Unitpay infrastructure changes without + * waiting for a release, or to feed back a list you fetched and cached yourself. * @link https://help.unitpay.ru/book-of-reference/ip-addresses * @param string[] $ips * @return $this @@ -636,6 +706,71 @@ public function setAllowedIps(array $ips) return $this; } + /** + * Add merchant-specific IPs/CIDR ranges (e.g. your own proxy/relay) on top of + * the Unitpay list. Unlike setAllowedIps(), which replaces the Unitpay list, + * these persist across refreshAllowedIps()/setAllowedIps() calls. De-duplicated. + * @param string[] $ips exact IPs and/or CIDR ranges + * @return $this + */ + public function addAllowedIps(array $ips) + { + $this->customIps = array_values(array_unique(array_merge($this->customIps, $ips))); + $this->ipAllowlist = null; // rebuild the matcher lazily on the next check + return $this; + } + + /** + * Fetch Unitpay's currently published webhook IPs from + * https:///ips/ips_webhooks.json and use them as the allowlist. + * + * Best-effort and fail-safe: on any transport/parse/validation failure the + * previously configured Unitpay list (the built-in default, or whatever + * setAllowedIps() last set) is kept unchanged — this never empties the list + * and never throws, so it is safe to chain before checkHandlerRequest(). A + * successful fetch REPLACES the Unitpay list (so a decommissioned IP drops + * out); merchant IPs added via addAllowedIps() are preserved and always apply + * on top. + * + * Verified TLS matters here (httpGet keeps CURLOPT_SSL_VERIFYPEER / verify_peer + * on): an unverified or spoofed list would defeat the IP gate. + * + * This makes a blocking network request — call it periodically (e.g. a daily + * cron) and cache getAllowedIps() on your side; do NOT call it on every webhook. + * @return $this + */ + public function refreshAllowedIps() + { + $ips = $this->fetchUnitpayIps(); + if ($ips !== null) { + $this->supportedUnitpayIp = $ips; + $this->ipAllowlist = null; // rebuild the matcher lazily on the next check + } + return $this; + } + + /** + * Effective allowlist actually enforced by the handler: the Unitpay list plus + * the merchant additions, de-duplicated. Cache this after refreshAllowedIps() + * and feed it back via setAllowedIps() on webhook requests to avoid a network + * call per callback. + * @return string[] + */ + public function getAllowedIps() + { + return array_values(array_unique(array_merge($this->supportedUnitpayIp, $this->customIps))); + } + + /** + * Load and validate the published webhook IP feed. + * @return string[]|null validated non-empty list, or null on any failure + */ + private function fetchUnitpayIps() + { + $body = $this->httpGet($this->ipsUrl); + return is_string($body) ? UnitpayIpAllowlist::parseWebhooksFeed($body) : null; + } + /** * Create SHA-256 digital signature * @param array $params @@ -689,7 +824,9 @@ protected function getIp() protected function isAllowedIp($ip) { if ($this->ipAllowlist === null) { - $this->ipAllowlist = new UnitpayIpAllowlist($this->supportedUnitpayIp); + $this->ipAllowlist = new UnitpayIpAllowlist( + array_merge($this->supportedUnitpayIp, $this->customIps) + ); } return $this->ipAllowlist->contains($ip); } diff --git a/phpmd.xml b/phpmd.xml index 5167167..d6f0ad7 100644 --- a/phpmd.xml +++ b/phpmd.xml @@ -26,6 +26,21 @@ + + + + + + + + - + + + + + + + + From 2c48a9e31c28772a7e6f6e5706c73bdf660cf879 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Thu, 23 Jul 2026 21:46:29 +0300 Subject: [PATCH 18/30] =?UTF-8?q?refactor(types):=20native=20type=20declar?= =?UTF-8?q?ations=20across=20the=20SDK;=20PHPStan=205=E2=86=926?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add native param/return/property type declarations to CashItem, UnitpayIpAllowlist and UnitPay within the PHP 7.4 subset (no union types, no mixed, no strict_types). Money/quantity params (form() $sum, CashItem $count/$price, getCount) and httpGet()'s string|false stay untyped by design — their types remain in PHPDoc. Redundant type-only @param/@return tags dropped; prose, unions and element types kept. Raise PHPStan from level 5 to 6: UnitPay.php is clean; test methods gained : void and data-provider value types. No API or behavior change. --- CHANGELOG.md | 1 + UnitPay.php | 322 +++++++++++-------------------- phpstan.neon | 11 +- tests/CashItemTest.php | 26 +-- tests/UnitPayAllowedIpsTest.php | 15 +- tests/UnitPayApiTest.php | 32 +-- tests/UnitPayCashItemsTest.php | 14 +- tests/UnitPayFloatTest.php | 17 +- tests/UnitPayFormTest.php | 21 +- tests/UnitPayHandlerTest.php | 54 +++--- tests/UnitPayPaymentTypeTest.php | 4 +- tests/UnitPayResponseTest.php | 4 +- tests/UnitPaySignatureTest.php | 14 +- tests/UnitPayTelemetryTest.php | 16 +- tests/UnitpayIpAllowlistTest.php | 29 +-- 15 files changed, 256 insertions(+), 324 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7837df3..5446203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ * examples: удалена недостижимая ветка обработчика "refund"; добавлена ветка "preauth" (уведомление о холде — подтверждаем получение, но товар не выдаём, это ждёт "pay"); обработка ответа типа "response" у initPayment (например, рекуррентные/подписочные списания без редиректа) * Добавлен набор тестов PHPUnit и внедряемые точки (getIp/транспорт API) для тестирования * Добавлены инструменты QA: phpstan, php-cs-fixer, phpmd и parallel-lint +* Нативные объявления типов: параметры, возвраты и типизированные свойства во всех трёх классах (`CashItem`, `UnitpayIpAllowlist`, `UnitPay`) в границах PHP 7.4 (без union-типов, `mixed` и `declare(strict_types)`) — публичный API и поведение не изменены. Денежные и количественные параметры (`form()` `$sum`, `CashItem` `$count`/`$price`, а также возврат `httpGet()` `string|false`) намеренно оставлены нетипизированными, чтобы сохранить прежнюю «мягкую» скалярную эргономику; их типы по-прежнему описаны в PHPDoc. Проверка PHPStan поднята с level 5 до level 6 * Минимальная версия PHP поднята до 7.4 * Усиление по итогам код-ревью: * `api()`: сворачивает только параметры fluent-сеттеров (cashItems/customerEmail/customerPhone/backUrl), а не весь набор — переиспользуемый экземпляр больше не протаскивает ключевые параметры `form()` или устаревшую подпись в посторонний вызов `api()` diff --git a/UnitPay.php b/UnitPay.php index 89ab044..d93da23 100644 --- a/UnitPay.php +++ b/UnitPay.php @@ -191,20 +191,22 @@ final class CashItem /** Иная единица измерения */ public const MEASURE_OTHER = 255; - private $name; + private string $name; + /** @var int|float */ private $count; - private $price; - private $nds; - private $type; - private $paymentMethod; - private $sum; - private $currency; - private $measure; - private $nomenclatureCode; - private $markCode; - private $markQuantity; - private $preText; - private $postText; + private float $price; + private string $nds; + private string $type; + private string $paymentMethod; + private ?float $sum = null; + private ?string $currency = null; + private ?int $measure = null; + private ?string $nomenclatureCode = null; + private ?string $markCode = null; + /** @var array{numerator: int, denominator: int}|null */ + private ?array $markQuantity = null; + private ?string $preText = null; + private ?string $postText = null; /** * $count и $price проверяются через is_numeric() ДО проверки диапазона: в PHP 8 @@ -214,20 +216,16 @@ final class CashItem * весовых/объёмных товаров (MEASURE_KG/G/L, ...), а бэкенд округляет количество до * 3 знаков, поэтому приведение к int тихо испортило бы чек. * - * @param string $name * @param int|float|string $count положительное количество (дробное допустимо для веса/объёма) * @param float|int|string $price неотрицательная цена за единицу - * @param string $nds - * @param string $type - * @param string $paymentMethod */ public function __construct( - $name, + string $name, $count, $price, - $nds = self::NDS_NONE, - $type = self::PAYMENT_OBJECT_COMMODITY, - $paymentMethod = self::PAYMENT_METHOD_PREPAYMENT_FULL + string $nds = self::NDS_NONE, + string $type = self::PAYMENT_OBJECT_COMMODITY, + string $paymentMethod = self::PAYMENT_METHOD_PREPAYMENT_FULL ) { if (!is_numeric($count) || $count <= 0) { throw new UnitpayValidationException('CashItem count must be a positive number'); @@ -243,10 +241,7 @@ public function __construct( $this->paymentMethod = $paymentMethod; } - /** - * @return string - */ - public function getName() + public function getName(): string { return $this->name; } @@ -259,34 +254,22 @@ public function getCount() return $this->count; } - /** - * @return float - */ - public function getPrice() + public function getPrice(): float { return $this->price; } - /** - * @return string - */ - public function getNds() + public function getNds(): string { return $this->nds; } - /** - * @return string - */ - public function getType() + public function getType(): string { return $this->type; } - /** - * @return string - */ - public function getPaymentMethod() + public function getPaymentMethod(): string { return $this->paymentMethod; } @@ -294,95 +277,70 @@ public function getPaymentMethod() /** * Итоговая сумма позиции. Если не задана, бэкенд считает её как price * count. * Не может превышать round(price * count, 2). - * @param float $sum - * @return $this */ - public function setSum($sum) + public function setSum(float $sum): self { $this->sum = $sum; return $this; } - /** - * @return float|null - */ - public function getSum() + public function getSum(): ?float { return $this->sum; } /** * Валюта позиции (ISO 4217). По умолчанию на бэкенде RUB. - * @param string $currency - * @return $this */ - public function setCurrency($currency) + public function setCurrency(string $currency): self { $this->currency = $currency; return $this; } - /** - * @return string|null - */ - public function getCurrency() + public function getCurrency(): ?string { return $this->currency; } /** * Единица измерения, одна из констант MEASURE_*. - * @param int $measure - * @return $this */ - public function setMeasure($measure) + public function setMeasure(int $measure): self { $this->measure = $measure; return $this; } - /** - * @return int|null - */ - public function getMeasure() + public function getMeasure(): ?int { return $this->measure; } /** * Код товарной номенклатуры (маркировка). - * @param string $nomenclatureCode - * @return $this */ - public function setNomenclatureCode($nomenclatureCode) + public function setNomenclatureCode(string $nomenclatureCode): self { $this->nomenclatureCode = $nomenclatureCode; return $this; } - /** - * @return string|null - */ - public function getNomenclatureCode() + public function getNomenclatureCode(): ?string { return $this->nomenclatureCode; } /** * Код маркировки товара. - * @param string $markCode - * @return $this */ - public function setMarkCode($markCode) + public function setMarkCode(string $markCode): self { $this->markCode = $markCode; return $this; } - /** - * @return string|null - */ - public function getMarkCode() + public function getMarkCode(): ?string { return $this->markCode; } @@ -390,11 +348,8 @@ public function getMarkCode() /** * Дробное количество маркированного товара. * Допускается только при measure = MEASURE_ITEM и count = 1. - * @param int $numerator числитель - * @param int $denominator знаменатель - * @return $this */ - public function setMarkQuantity($numerator, $denominator) + public function setMarkQuantity(int $numerator, int $denominator): self { if ((int) $numerator <= 0) { throw new UnitpayValidationException('CashItem markQuantity numerator must be a positive integer'); @@ -410,47 +365,37 @@ public function setMarkQuantity($numerator, $denominator) } /** - * @return array|null + * @return array{numerator: int, denominator: int}|null */ - public function getMarkQuantity() + public function getMarkQuantity(): ?array { return $this->markQuantity; } /** * Текст перед позицией в чеке. - * @param string $preText - * @return $this */ - public function setPreText($preText) + public function setPreText(string $preText): self { $this->preText = $preText; return $this; } - /** - * @return string|null - */ - public function getPreText() + public function getPreText(): ?string { return $this->preText; } /** * Текст после позиции в чеке. - * @param string $postText - * @return $this */ - public function setPostText($postText) + public function setPostText(string $postText): self { $this->postText = $postText; return $this; } - /** - * @return string|null - */ - public function getPostText() + public function getPostText(): ?string { return $this->postText; } @@ -463,7 +408,8 @@ public function getPostText() */ final class UnitpayIpAllowlist { - private $entries; + /** @var string[] */ + private array $entries; /** * @param string[] $entries точные IP и/или CIDR-диапазоны (например, "77.75.153.0/25") @@ -473,11 +419,7 @@ public function __construct(array $entries) $this->entries = $entries; } - /** - * @param string $ip - * @return bool - */ - public function contains($ip) + public function contains(string $ip): bool { $ipBin = $this->toBinary($ip); foreach ($this->entries as $entry) { @@ -503,11 +445,9 @@ public function contains($ip) } /** - * @param string $cidr * @param string $ipBin упакованный in_addr клиентского IP (из toBinary()) - * @return bool */ - private function cidrContains($cidr, $ipBin) + private function cidrContains(string $cidr, string $ipBin): bool { list($subnet, $bits) = explode('/', $cidr, 2); if (!ctype_digit($bits)) { @@ -525,10 +465,8 @@ private function cidrContains($cidr, $ipBin) * или CIDR-диапазоном вида "адрес/биты". Используется для проверки загруженного * списка IP до того, как он заменит встроенный, чтобы некорректный JSON не мог * опустошить белый список. - * @param string $entry - * @return bool */ - public static function isValidEntry($entry) + public static function isValidEntry(string $entry): bool { if (strpos($entry, '/') === false) { return filter_var($entry, FILTER_VALIDATE_IP) !== false; @@ -549,10 +487,9 @@ public static function isValidEntry($entry) * некорректном JSON, отсутствующем или не-массивном ключе "webhooks" либо когда * ни одна запись не является корректным IP/CIDR — так плохой перечень не может * опустошить белый список. - * @param string $body * @return string[]|null */ - public static function parseWebhooksFeed($body) + public static function parseWebhooksFeed(string $body): ?array { if ($body === '') { return null; @@ -571,10 +508,9 @@ public static function parseWebhooksFeed($body) } /** - * @param string $ip * @return string|null упакованный in_addr, либо null, если $ip не является корректным адресом */ - private function toBinary($ip) + private function toBinary(string $ip): ?string { if (filter_var($ip, FILTER_VALIDATE_IP) === false) { return null; @@ -583,13 +519,7 @@ private function toBinary($ip) return $binary === false ? null : $binary; } - /** - * @param string $ipBin - * @param string $subnetBin - * @param int $bits - * @return bool - */ - private function prefixMatches($ipBin, $subnetBin, $bits) + private function prefixMatches(string $ipBin, string $subnetBin, int $bits): bool { if ($bits > strlen($ipBin) * 8) { return false; @@ -657,8 +587,9 @@ class UnitPay /** * Поддерживаемые методы api() и их обязательные параметры. secretKey * подставляется и проверяется в api(), поэтому здесь не перечислен. + * @var array */ - private $requiredUnitpayMethodsParams = [ + private array $requiredUnitpayMethodsParams = [ 'initPayment' => ['account', 'sum', 'projectId', 'paymentType'], 'getPayment' => ['paymentId'], 'refundPayment' => ['paymentId'], @@ -683,49 +614,55 @@ class UnitPay * Методы вебхуков, которые Unitpay шлёт обработчику. 'preauth' — уведомление о * двухстадийной блокировке средств (деньги заблокированы, но ещё не списаны): * должно проходить проверку как остальные, а не отклоняться как неподдерживаемое. + * @var string[] */ - private $supportedPartnerMethods = ['check', 'pay', 'preauth', 'error']; + private array $supportedPartnerMethods = ['check', 'pay', 'preauth', 'error']; /** * Опубликованные исходящие IP Unitpay. 127.0.0.1 здесь намеренно НЕТ: за * обратным прокси на том же хосте REMOTE_ADDR равен 127.0.0.1, что превратило бы * проверку IP в фикцию. Добавляйте его явно через setAllowedIps() только для * локальной отладки. + * @var string[] */ - private $supportedUnitpayIp = [ + private array $supportedUnitpayIp = [ '31.186.100.49', '51.250.20.9', ]; - private $secretKey; - private $params = []; - private $apiUrl; - private $formUrl; + private ?string $secretKey; + /** @var array */ + private array $params = []; + private string $apiUrl; + private string $formUrl; + /** @var callable|null */ private $transport; - private $request; - private $clientIp; - private $handlerMethod; - private $handlerParams; - private $ipAllowlist; + /** @var array|null */ + private ?array $request; + private ?string $clientIp; + private ?string $handlerMethod = null; + /** @var array|null */ + private ?array $handlerParams = null; + private ?UnitpayIpAllowlist $ipAllowlist = null; /** * IP самого мерчанта, добавленные через addAllowedIps(); всегда применяются * поверх списка Unitpay и сохраняются при refreshAllowedIps()/setAllowedIps(). + * @var string[] */ - private $customIps = []; - private $ipsUrl; - private $telemetryUrl; - private $telemetryEnabled = false; + private array $customIps = []; + private string $ipsUrl; + private string $telemetryUrl; + private bool $telemetryEnabled = false; /** * @param string $domain только хост, например "unitpay.ru" — без схемы и пути (станет "https://$domain/api"). - * @param string|null $secretKey * @param callable|null $transport исходящий HTTP-транспорт для api(): fn(string $url): string|false. * По умолчанию file_get_contents(). Подменяйте, чтобы тестировать api() без сети. - * @param array|null $request массив входящего вебхука, читаемый checkHandlerRequest(). + * @param array|null $request массив входящего вебхука, читаемый checkHandlerRequest(). * По умолчанию $_GET. Подменяйте, чтобы тестировать обработчик без суперглобальных переменных. * @param string|null $clientIp IP отправителя, используемый getIp(). По умолчанию $_SERVER['REMOTE_ADDR']. * Подменяйте, чтобы тестировать белый список IP без суперглобальных переменных. */ - public function __construct($domain, $secretKey = null, ?callable $transport = null, ?array $request = null, ?string $clientIp = null) + public function __construct(string $domain, ?string $secretKey = null, ?callable $transport = null, ?array $request = null, ?string $clientIp = null) { $this->secretKey = $secretKey; $this->apiUrl = "https://$domain/api"; @@ -746,9 +683,8 @@ public function __construct($domain, $secretKey = null, ?callable $transport = n * вы загрузили и закэшировали сами. * @link https://help.unitpay.ru/book-of-reference/ip-addresses * @param string[] $ips - * @return $this */ - public function setAllowedIps(array $ips) + public function setAllowedIps(array $ips): self { $this->supportedUnitpayIp = $ips; $this->ipAllowlist = null; @@ -761,9 +697,8 @@ public function setAllowedIps(array $ips) * Unitpay, эти сохраняются при вызовах refreshAllowedIps()/setAllowedIps(). * Дубликаты убираются. * @param string[] $ips точные IP и/или CIDR-диапазоны - * @return $this */ - public function addAllowedIps(array $ips) + public function addAllowedIps(array $ips): self { $this->customIps = array_values(array_unique(array_merge($this->customIps, $ips))); $this->ipAllowlist = null; @@ -789,9 +724,8 @@ public function addAllowedIps(array $ips) * Метод делает блокирующий сетевой запрос — вызывайте его периодически (например, * ежедневным cron) и кэшируйте getAllowedIps() у себя; НЕ вызывайте его на каждый * вебхук. - * @return $this */ - public function refreshAllowedIps() + public function refreshAllowedIps(): self { $ips = $this->fetchUnitpayIps(); if ($ips !== null) { @@ -808,7 +742,7 @@ public function refreshAllowedIps() * сетевой запрос на каждый вызов. * @return string[] */ - public function getAllowedIps() + public function getAllowedIps(): array { return array_values(array_unique(array_merge($this->supportedUnitpayIp, $this->customIps))); } @@ -817,7 +751,7 @@ public function getAllowedIps() * Загружает и проверяет опубликованный фид IP вебхуков. * @return string[]|null проверенный непустой список либо null при любой ошибке */ - private function fetchUnitpayIps() + private function fetchUnitpayIps(): ?array { $body = $this->httpGet($this->ipsUrl); return is_string($body) ? UnitpayIpAllowlist::parseWebhooksFeed($body) : null; @@ -837,11 +771,9 @@ private function fetchUnitpayIps() * приводятся к '' — implode() не выдаёт предупреждение, а проверка всё равно * проваливается, потому что секрет добавляется в любом случае. * - * @param array $params - * @param string|null $method - * @return string + * @param array $params */ - public function getSignature(array $params, $method = null) + public function getSignature(array $params, ?string $method = null): string { unset($params['sign'], $params['signature'], $params[PHP_INT_MAX]); ksort($params); @@ -863,9 +795,8 @@ public function getSignature(array $params, $method = null) /** * IP отправителя входящего запроса (подменённый clientIp либо $_SERVER['REMOTE_ADDR']). - * @return string */ - protected function getIp() + protected function getIp(): string { return $this->clientIp !== null ? $this->clientIp : ($_SERVER['REMOTE_ADDR'] ?? ''); } @@ -874,10 +805,8 @@ protected function getIp() * Разрешено ли $ip вызывать обработчик. Сопоставляет точные адреса и CIDR-подсети * (IPv4/IPv6) через UnitpayIpAllowlist, поэтому setAllowedIps(['77.75.153.0/25']) * работает. Переопределите для логики, учитывающей прокси. - * @param string $ip - * @return bool */ - protected function isAllowedIp($ip) + protected function isAllowedIp(string $ip): bool { if ($this->ipAllowlist === null) { $this->ipAllowlist = new UnitpayIpAllowlist( @@ -899,12 +828,11 @@ protected function isAllowedIp($ip) * file_get_contents гасит своё предупреждение транспорта через set_error_handler, * а не оператором '@' (который запрещён правилами QA) — иначе это предупреждение * записало бы в лог URL с секретом. - * @param string $url * @param string[] $headers HTTP-заголовки формата "Имя: значение" (фингерпринт Слоя A / beacon). * @param int|null $timeoutMs жёсткий таймаут в мс (beacon Слоя B); null — обычные таймауты api(). * @return string|false */ - protected function httpGet($url, array $headers = [], $timeoutMs = null) + protected function httpGet(string $url, array $headers = [], ?int $timeoutMs = null) { if ($this->transport !== null) { return call_user_func($this->transport, $url, $headers, $timeoutMs); @@ -955,15 +883,9 @@ protected function httpGet($url, array $headers = [], $timeoutMs = null) * заданные fluent-сеттерами (setCashItems/setCustomerEmail/setBackUrl/...), * подмешиваются и затем очищаются, поэтому повторно используемый экземпляр не * переносит параметры этого вызова в следующий form()/api(). - * @param string $publicKey * @param string|float|int $sum - * @param string $account - * @param string $desc - * @param string $currency - * @param string $locale - * @return string */ - public function form($publicKey, $sum, $account, $desc, $currency = 'RUB', $locale = 'ru') + public function form(string $publicKey, $sum, string $account, string $desc, string $currency = 'RUB', string $locale = 'ru'): string { if (empty($this->secretKey)) { throw new UnitpayValidationException('SecretKey is null'); @@ -984,10 +906,8 @@ public function form($publicKey, $sum, $account, $desc, $currency = 'RUB', $loca /** * Задаёт email покупателя. - * @param string $email - * @return $this */ - public function setCustomerEmail($email) + public function setCustomerEmail(string $email): self { $this->params['customerEmail'] = $email; return $this; @@ -995,10 +915,8 @@ public function setCustomerEmail($email) /** * Задаёт телефон покупателя. - * @param string $phone - * @return $this */ - public function setCustomerPhone($phone) + public function setCustomerPhone(string $phone): self { $this->params['customerPhone'] = $phone; return $this; @@ -1010,9 +928,8 @@ public function setCustomerPhone($phone) * исключение вместо отправки пустого чека, если json_encode не удался (например, * имя не в UTF-8 / в Windows-1251). * @param CashItem[] $items - * @return $this */ - public function setCashItems(array $items) + public function setCashItems(array $items): self { $cashItems = array_map(static function ($item) { /** @var CashItem $item */ @@ -1055,10 +972,8 @@ public function setCashItems(array $items) /** * Задаёт URL, на который Unitpay вернёт плательщика после оплаты. - * @param string $backUrl - * @return $this */ - public function setBackUrl($backUrl) + public function setBackUrl(string $backUrl): self { $this->params['backUrl'] = $backUrl; return $this; @@ -1074,14 +989,12 @@ public function setBackUrl($backUrl) * приоритет. Явный непустой secretKey в $params переопределяет ключ экземпляра, * поэтому методы уровня аккаунта (getPartner, getCommissions, выплаты, ...) могут * использовать ключ аккаунта. - * @param string $method - * @param array $params - * @return object + * @param array $params * * @throws InvalidArgumentException * @throws UnexpectedValueException */ - public function api($method, array $params = []) + public function api(string $method, array $params = []): object { if (!isset($this->requiredUnitpayMethodsParams[$method])) { $this->reportTelemetry(self::ERR_METHOD_NOT_SUPPORTED, $method); @@ -1129,12 +1042,11 @@ public function api($method, array $params = []) * время) и белый список IP отправителя. При успехе выставляет проверенные метод и * параметры, доступные через getHandlerMethod()/getHandlerParams() (учитывая * подменённый запрос, а не $_GET). - * @return bool * * @throws InvalidArgumentException * @throws UnexpectedValueException */ - public function checkHandlerRequest() + public function checkHandlerRequest(): bool { $ip = $this->getIp(); if (empty($this->secretKey)) { @@ -1183,9 +1095,8 @@ public function checkHandlerRequest() * Метод вебхука, проверенный последним успешным checkHandlerRequest() * ('check' | 'pay' | 'preauth' | 'error'). Читайте его вместо $_GET, чтобы * учитывался подменённый запрос. До успешной проверки — null. - * @return string|null */ - public function getHandlerMethod() + public function getHandlerMethod(): ?string { return $this->handlerMethod; } @@ -1193,9 +1104,9 @@ public function getHandlerMethod() /** * Параметры вебхука, проверенные последним успешным checkHandlerRequest(). * До успешной проверки — null. - * @return array|null + * @return array|null */ - public function getHandlerParams() + public function getHandlerParams(): ?array { return $this->handlerParams; } @@ -1204,9 +1115,8 @@ public function getHandlerParams() * Машиночитаемый токен фингерпринта для form()-URL: <платформа>_<версия SDK>_. * Только URL-safe символы (http_build_query их не кодирует); major.minor — чтобы не светить * точный патч PHP в видимом покупателю URL платёжной формы. - * @return string */ - private function getSdkToken() + private function getSdkToken(): string { return 'php_' . self::VERSION . '_' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION; } @@ -1214,9 +1124,8 @@ private function getSdkToken() /** * Строка самоидентификации SDK для заголовка User-Agent (полная версия PHP — * заголовок невидим для покупателя, полезен для диагностики). - * @return string */ - private function getUserAgent() + private function getUserAgent(): string { return 'unitpay-php-sdk/' . self::VERSION . ' php/' . PHP_VERSION; } @@ -1224,11 +1133,10 @@ private function getUserAgent() /** * JSON-фингерпринт для заголовка X-Unitpay-Client — машиночитаемая версия UA, * чтобы бэкенд не разбирал строку User-Agent регуляркой. - * @return string */ - private function getClientHeader() + private function getClientHeader(): string { - return json_encode([ + return (string) json_encode([ 'platform' => 'php', 'sdk_version' => self::VERSION, 'php_version' => PHP_VERSION, @@ -1239,7 +1147,7 @@ private function getClientHeader() * Заголовки фингерпринта для header-каналов (api() и beacon Слоя B). * @return string[] */ - private function fingerprintHeaders() + private function fingerprintHeaders(): array { return [ 'User-Agent: ' . $this->getUserAgent(), @@ -1252,9 +1160,8 @@ private function fingerprintHeaders() * Мерчант оперирует только флагом — URL эндпоинта выводится из $domain, передавать его * не нужно. Полностью заглушается переменной окружения UNITPAY_SDK_TELEMETRY_DISABLE * (1/true/yes) без правки кода. - * @return $this */ - public function enableTelemetry() + public function enableTelemetry(): self { $this->telemetryEnabled = true; return $this; @@ -1266,9 +1173,8 @@ public function enableTelemetry() * таймаут 300 мс; шлёт только не-PII поля (sdk, php, error, method). * @param string $code одна из ERR_* констант * @param string $method имя метода или 'unknown' - * @return void */ - private function reportTelemetry($code, $method) + private function reportTelemetry(string $code, string $method): void { if (!$this->telemetryEnabled) { return; @@ -1294,10 +1200,10 @@ private function reportTelemetry($code, $method) * Приводит float-параметры к локале-независимым десятичным строкам, чтобы подпись * и URL запроса совпадали на PHP <8.0 (где (string)$float учитывает LC_NUMERIC и в * локалях с запятой выдал бы "100,5"). Значения, не являющиеся float, проходят как есть. - * @param array $params - * @return array + * @param array $params + * @return array */ - private static function stringifyFloats(array $params) + private static function stringifyFloats(array $params): array { foreach ($params as $key => $value) { if (is_float($value)) { @@ -1313,31 +1219,25 @@ private static function stringifyFloats(array $params) * (string) $float учитывает LC_NUMERIC на PHP <8.0 и в локалях с запятой выдал бы * "100,5", ломая совпадение подписи и URL. Используется совместно getSignature() и * stringifyFloats(), чтобы подпись и передаваемое значение имели одинаковый вид. - * @param float $value - * @return string */ - private static function floatToString($value) + private static function floatToString(float $value): string { return rtrim(rtrim(sprintf('%.8F', $value), '0'), '.'); } /** * Строит JSON-ответ об успехе, который Unitpay ожидает от обработчика. - * @param string $message - * @return string */ - public function getSuccessHandlerResponse($message) + public function getSuccessHandlerResponse(string $message): string { - return json_encode(['result' => ['message' => $message]]); + return (string) json_encode(['result' => ['message' => $message]]); } /** * Строит JSON-ответ об ошибке, который Unitpay ожидает от обработчика. - * @param string $message - * @return string */ - public function getErrorHandlerResponse($message) + public function getErrorHandlerResponse(string $message): string { - return json_encode(['error' => ['message' => $message]]); + return (string) json_encode(['error' => ['message' => $message]]); } } diff --git a/phpstan.neon b/phpstan.neon index 61d4679..51a6398 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,8 +1,11 @@ parameters: - # Level 5: strong analysis without demanding full scalar type declarations on - # the legacy single-file SDK (typed-everything is deferred — see F014 in the - # tech-debt audit). Raise once UnitPay.php gets real type declarations. - level: 5 + # Level 6: raised from 5 once UnitPay.php gained native type declarations + # (params, returns, typed properties — see F014 in the tech-debt audit). Level 7+ + # would flag httpGet()'s string|false via curl_exec's string|bool stub, which + # only a code restructure (direct curl_setopt) or a looser contract would satisfy — + # deliberately not pursued. Money/quantity params stay untyped by design (docblock + # unions); declare(strict_types=1) is still deferred. + level: 6 paths: - UnitPay.php - tests diff --git a/tests/CashItemTest.php b/tests/CashItemTest.php index 6c60c20..9025297 100644 --- a/tests/CashItemTest.php +++ b/tests/CashItemTest.php @@ -7,7 +7,7 @@ final class CashItemTest extends TestCase { - public function testConstructorStoresRequiredFieldsAndFiscalDefaults() + public function testConstructorStoresRequiredFieldsAndFiscalDefaults(): void { $item = new CashItem('Coffee', 2, 150.5); @@ -19,7 +19,7 @@ public function testConstructorStoresRequiredFieldsAndFiscalDefaults() $this->assertSame(CashItem::PAYMENT_METHOD_PREPAYMENT_FULL, $item->getPaymentMethod()); } - public function testConstructorAcceptsExplicitFiscalFields() + public function testConstructorAcceptsExplicitFiscalFields(): void { $item = new CashItem( 'Service', @@ -35,7 +35,7 @@ public function testConstructorAcceptsExplicitFiscalFields() $this->assertSame(CashItem::PAYMENT_METHOD_PAYMENT_FULL, $item->getPaymentMethod()); } - public function testOptionalGettersDefaultToNull() + public function testOptionalGettersDefaultToNull(): void { $item = new CashItem('X', 1, 1.0); @@ -49,7 +49,7 @@ public function testOptionalGettersDefaultToNull() $this->assertNull($item->getPostText()); } - public function testFluentSettersReturnSelfAndStoreValues() + public function testFluentSettersReturnSelfAndStoreValues(): void { $item = new CashItem('X', 1, 1.0); @@ -70,7 +70,7 @@ public function testFluentSettersReturnSelfAndStoreValues() $this->assertSame('after', $item->getPostText()); } - public function testSetMarkQuantityStoresIntegerFraction() + public function testSetMarkQuantityStoresIntegerFraction(): void { $item = new CashItem('X', 1, 1.0); @@ -79,7 +79,7 @@ public function testSetMarkQuantityStoresIntegerFraction() } /** Нулевой знаменатель (или неположительная дробь) отклоняется, а не сохраняется молча. */ - public function testSetMarkQuantityRejectsNonPositiveValues() + public function testSetMarkQuantityRejectsNonPositiveValues(): void { $item = new CashItem('X', 1, 1.0); @@ -88,7 +88,7 @@ public function testSetMarkQuantityRejectsNonPositiveValues() } /** Неположительный числитель отклоняется отдельной проверкой (не только знаменатель). */ - public function testSetMarkQuantityRejectsNonPositiveNumerator() + public function testSetMarkQuantityRejectsNonPositiveNumerator(): void { $item = new CashItem('X', 1, 1.0); @@ -97,35 +97,35 @@ public function testSetMarkQuantityRejectsNonPositiveNumerator() } /** count должен быть положительным числом. */ - public function testConstructorRejectsNonPositiveCount() + public function testConstructorRejectsNonPositiveCount(): void { $this->expectException(\InvalidArgumentException::class); new CashItem('X', 0, 10.0); } /** price должен быть неотрицательным. */ - public function testConstructorRejectsNegativePrice() + public function testConstructorRejectsNegativePrice(): void { $this->expectException(\InvalidArgumentException::class); new CashItem('X', 1, -5.0); } /** Нечисловой count должен быть отклонён, а не проскочить проверку диапазона. */ - public function testConstructorRejectsNonNumericCount() + public function testConstructorRejectsNonNumericCount(): void { $this->expectException(\InvalidArgumentException::class); new CashItem('X', 'abc', 10.0); } /** Нечисловой price должен быть отклонён, а не проскочить проверку диапазона. */ - public function testConstructorRejectsNonNumericPrice() + public function testConstructorRejectsNonNumericPrice(): void { $this->expectException(\InvalidArgumentException::class); new CashItem('X', 1, 'xyz'); } /** Числовые строки принимаются и нормализуются в int/float. */ - public function testConstructorNormalizesNumericStrings() + public function testConstructorNormalizesNumericStrings(): void { $item = new CashItem('X', '3', '9.5'); @@ -134,7 +134,7 @@ public function testConstructorNormalizesNumericStrings() } /** Дробные количества (весовые/объёмные товары) сохраняются, а не усекаются до int. */ - public function testConstructorPreservesFractionalCount() + public function testConstructorPreservesFractionalCount(): void { $item = new CashItem('Cheese', 1.5, 500.0); diff --git a/tests/UnitPayAllowedIpsTest.php b/tests/UnitPayAllowedIpsTest.php index c950047..59d5758 100644 --- a/tests/UnitPayAllowedIpsTest.php +++ b/tests/UnitPayAllowedIpsTest.php @@ -18,7 +18,11 @@ final class UnitPayAllowedIpsTest extends TestCase /** Один из встроенных адресов по умолчанию. */ private const DEFAULT_IP = '31.186.100.49'; - /** Строит корректный подписанный вебхук 'pay'. */ + /** + * Строит корректный подписанный вебхук 'pay'. + * + * @return array{method: string, params: array} + */ private function validRequest(): array { $params = [ @@ -45,6 +49,9 @@ private function handlerWithTransport(callable $transport, string $ip): UnitPay return new UnitPay('unitpay.ru', self::SECRET, $transport, $this->validRequest(), $ip); } + /** + * @param string[] $ips + */ private function feed(array $ips): string { return json_encode(['webhooks' => $ips]); @@ -206,6 +213,9 @@ public function testIsValidEntryAcceptsWellFormedEntries(string $entry): void $this->assertTrue(UnitpayIpAllowlist::isValidEntry($entry)); } + /** + * @return array + */ public function validEntries(): array { return [ @@ -224,6 +234,9 @@ public function testIsValidEntryRejectsMalformedEntries(string $entry): void $this->assertFalse(UnitpayIpAllowlist::isValidEntry($entry)); } + /** + * @return array + */ public function invalidEntries(): array { return [ diff --git a/tests/UnitPayApiTest.php b/tests/UnitPayApiTest.php index 6e3413b..e9bc38f 100644 --- a/tests/UnitPayApiTest.php +++ b/tests/UnitPayApiTest.php @@ -10,7 +10,7 @@ final class UnitPayApiTest extends TestCase { - public function testInitPaymentReturnsDecodedResponseViaInjectedTransport() + public function testInitPaymentReturnsDecodedResponseViaInjectedTransport(): void { $transport = static function () { return '{"result":{"receiptId":42}}'; @@ -27,7 +27,7 @@ public function testInitPaymentReturnsDecodedResponseViaInjectedTransport() $this->assertSame(42, $response->result->receiptId); } - public function testRequestUrlCarriesMethodParamsAndSecret() + public function testRequestUrlCarriesMethodParamsAndSecret(): void { $captured = null; $transport = static function ($url) use (&$captured) { @@ -45,7 +45,7 @@ public function testRequestUrlCarriesMethodParamsAndSecret() $this->assertStringContainsString('my-secret', $captured); } - public function testRequestUrlUsesFlatParamsNotNested() + public function testRequestUrlUsesFlatParamsNotNested(): void { $captured = null; $transport = static function ($url) use (&$captured) { @@ -63,7 +63,7 @@ public function testRequestUrlUsesFlatParamsNotNested() $this->assertStringNotContainsString('params[', $captured); } - public function testPayoutRequestUrlUsesFlatParams() + public function testPayoutRequestUrlUsesFlatParams(): void { $captured = null; $transport = static function ($url) use (&$captured) { @@ -91,7 +91,7 @@ public function testPayoutRequestUrlUsesFlatParams() * должны попадать в запрос api(), а не только в form(). Защита от регресса: раньше * api() строил URL только из аргумента $params и молча их терял. */ - public function testCashItemsFromSetterAreSentByApi() + public function testCashItemsFromSetterAreSentByApi(): void { $captured = null; $transport = static function ($url) use (&$captured) { @@ -118,7 +118,7 @@ public function testCashItemsFromSetterAreSentByApi() } /** Явные параметры api() имеют приоритет над всем, что задано fluent-сеттерами. */ - public function testExplicitApiParamOverridesAccumulatedParam() + public function testExplicitApiParamOverridesAccumulatedParam(): void { $captured = null; $transport = static function ($url) use (&$captured) { @@ -145,7 +145,7 @@ public function testExplicitApiParamOverridesAccumulatedParam() * в следующий вызов на повторно используемом экземпляре (регресс: устаревший чек * cashItems или customerEmail иначе ушёл бы с несвязанным поздним заказом). */ - public function testFluentSetterParamsDoNotBleedIntoNextApiCall() + public function testFluentSetterParamsDoNotBleedIntoNextApiCall(): void { $urls = []; $transport = static function ($url) use (&$urls) { @@ -175,7 +175,7 @@ public function testFluentSetterParamsDoNotBleedIntoNextApiCall() * Параметры fluent-сеттеров очищаются только УСПЕШНЫМ вызовом api(). После сбоя * транспорта они сохраняются, чтобы повтор ушёл с тем же чеком, а не молча без него. */ - public function testFluentSetterParamsAreRetainedAfterFailedApiCall() + public function testFluentSetterParamsAreRetainedAfterFailedApiCall(): void { $urls = []; $calls = 0; @@ -202,7 +202,7 @@ public function testFluentSetterParamsAreRetainedAfterFailedApiCall() $this->assertStringContainsString('cashItems=', $urls[1]); } - public function testNonObjectResponseIsReportedAsTemporaryServerError() + public function testNonObjectResponseIsReportedAsTemporaryServerError(): void { $transport = static function () { return 'this is not json'; @@ -214,7 +214,7 @@ public function testNonObjectResponseIsReportedAsTemporaryServerError() $unitPay->api('getPayment', ['paymentId' => 1]); } - public function testUnsupportedMethodThrows() + public function testUnsupportedMethodThrows(): void { $unitPay = new UnitPay('unitpay.test', 'secret', static function () { return '{"result":{}}'; @@ -225,7 +225,7 @@ public function testUnsupportedMethodThrows() $unitPay->api('doesNotExist'); } - public function testMissingRequiredParamThrows() + public function testMissingRequiredParamThrows(): void { $unitPay = new UnitPay('unitpay.test', 'secret', static function () { return '{"result":{}}'; @@ -236,7 +236,7 @@ public function testMissingRequiredParamThrows() $unitPay->api('initPayment', ['account' => 1]); } - public function testMissingSecretThrows() + public function testMissingSecretThrows(): void { $unitPay = new UnitPay('unitpay.test', null, static function () { return '{"result":{}}'; @@ -247,7 +247,7 @@ public function testMissingSecretThrows() $unitPay->api('getPayment', ['paymentId' => 1]); } - public function testPayoutMethodsAreSupportedAndValidateRequiredParams() + public function testPayoutMethodsAreSupportedAndValidateRequiredParams(): void { $unitPay = new UnitPay('unitpay.test', 'secret', static function () { return '{"result":{}}'; @@ -276,7 +276,7 @@ public function testPayoutMethodsAreSupportedAndValidateRequiredParams() } /** Сбой транспорта — типизированное исключение, всё ещё перехватываемое как InvalidArgumentException. */ - public function testTransportFailureThrowsTypedTransportException() + public function testTransportFailureThrowsTypedTransportException(): void { $unitPay = new UnitPay('unitpay.test', 'secret', static function () { return false; // эмулируем сбой транспорта @@ -292,7 +292,7 @@ public function testTransportFailureThrowsTypedTransportException() } /** Неподдерживаемый метод бросает типизированное исключение, всё ещё перехватываемое как UnexpectedValueException. */ - public function testUnsupportedMethodThrowsTypedException() + public function testUnsupportedMethodThrowsTypedException(): void { $unitPay = new UnitPay('unitpay.test', 'secret', static function () { return '{"result":{}}'; @@ -307,7 +307,7 @@ public function testUnsupportedMethodThrowsTypedException() } /** Методы уровня аккаунта могут переопределить ключ проекта ключом аккаунта (secretKey). */ - public function testExplicitSecretKeyOverridesInstanceKey() + public function testExplicitSecretKeyOverridesInstanceKey(): void { $captured = null; $transport = static function ($url) use (&$captured) { diff --git a/tests/UnitPayCashItemsTest.php b/tests/UnitPayCashItemsTest.php index 2d89363..a5616bd 100644 --- a/tests/UnitPayCashItemsTest.php +++ b/tests/UnitPayCashItemsTest.php @@ -12,9 +12,9 @@ final class UnitPayCashItemsTest extends TestCase * setCashItems() хранит в params base64(json(...)); единственный публичный способ * прочитать это обратно — через строку запроса формы, поэтому декодируем оттуда. * - * @return array + * @return array> */ - private function serializedItems(UnitPay $unitPay) + private function serializedItems(UnitPay $unitPay): array { $url = $unitPay->form('pk', 1, 'acc', 'desc'); parse_str((string) parse_url($url, PHP_URL_QUERY), $q); @@ -22,7 +22,7 @@ private function serializedItems(UnitPay $unitPay) return json_decode(base64_decode($q['cashItems']), true); } - public function testRequiredFieldsAreAlwaysSerialized() + public function testRequiredFieldsAreAlwaysSerialized(): void { $unitPay = new UnitPay('unitpay.ru', 'secret'); $unitPay->setCashItems([ @@ -47,7 +47,7 @@ public function testRequiredFieldsAreAlwaysSerialized() $this->assertSame('full_payment', $items[0]['paymentMethod']); } - public function testOptionalFieldsAreOmittedWhenNotSet() + public function testOptionalFieldsAreOmittedWhenNotSet(): void { $unitPay = new UnitPay('unitpay.ru', 'secret'); $unitPay->setCashItems([new CashItem('X', 1, 10.0)]); @@ -59,7 +59,7 @@ public function testOptionalFieldsAreOmittedWhenNotSet() } } - public function testOptionalFieldsAreSerializedWhenSet() + public function testOptionalFieldsAreSerializedWhenSet(): void { $item = new CashItem('Y', 1, 10.5); $item->setSum(10.5) @@ -86,7 +86,7 @@ public function testOptionalFieldsAreSerializedWhenSet() $this->assertSame('post', $items[0]['post_text']); } - public function testMultipleItemsKeepTheirOrder() + public function testMultipleItemsKeepTheirOrder(): void { $unitPay = new UnitPay('unitpay.ru', 'secret'); $unitPay->setCashItems([ @@ -105,7 +105,7 @@ public function testMultipleItemsKeepTheirOrder() * Имя не в UTF-8 (например, из Windows-1251) обрушивает json_encode; setCashItems() * бросает исключение вместо тихой отправки пустого чека. */ - public function testSetCashItemsThrowsOnNonUtf8Name() + public function testSetCashItemsThrowsOnNonUtf8Name(): void { $unitPay = new UnitPay('unitpay.ru', 'secret'); diff --git a/tests/UnitPayFloatTest.php b/tests/UnitPayFloatTest.php index 48b3345..0751f8a 100644 --- a/tests/UnitPayFloatTest.php +++ b/tests/UnitPayFloatTest.php @@ -21,13 +21,16 @@ protected function setUp(): void $this->unitPay = new UnitPay('unitpay.ru', 'secret'); } - private function queryOf($url): array + /** + * @return array + */ + private function queryOf(string $url): array { parse_str((string) parse_url($url, PHP_URL_QUERY), $q); return $q; } - public function testSignatureRendersFloatAsCanonicalDecimalString() + public function testSignatureRendersFloatAsCanonicalDecimalString(): void { $this->assertSame( hash('sha256', '100.5{up}secret'), @@ -36,7 +39,7 @@ public function testSignatureRendersFloatAsCanonicalDecimalString() } /** Целый float («100.0») даёт «100» — то же, что каноническая строка, поэтому подпись совпадает независимо от типа. */ - public function testWholeFloatMatchesCanonicalStringSignature() + public function testWholeFloatMatchesCanonicalStringSignature(): void { $this->assertSame( $this->unitPay->getSignature(['sum' => '100']), @@ -44,7 +47,7 @@ public function testWholeFloatMatchesCanonicalStringSignature() ); } - public function testFormRendersFloatSumAsCanonicalDecimalString() + public function testFormRendersFloatSumAsCanonicalDecimalString(): void { $q = $this->queryOf($this->unitPay->form('pk', 100.5, 'acc', 'desc')); @@ -52,7 +55,7 @@ public function testFormRendersFloatSumAsCanonicalDecimalString() } /** Хвостовой ноль убирается: 100.0 в строке запроса становится «100», а не «100.00000000». */ - public function testFormStripsTrailingZeroFromWholeFloatSum() + public function testFormStripsTrailingZeroFromWholeFloatSum(): void { $q = $this->queryOf($this->unitPay->form('pk', 100.0, 'acc', 'desc')); @@ -64,7 +67,7 @@ public function testFormStripsTrailingZeroFromWholeFloatSum() * строку запроса. Регресс здесь (подписали float, отправили другое строковое представление) * сломал бы проверку подписи на бэкенде для любой дробной суммы. */ - public function testFormSignatureCoversTheExactStringSumSentInQuery() + public function testFormSignatureCoversTheExactStringSumSentInQuery(): void { $q = $this->queryOf($this->unitPay->form('pk', 100.5, 'acc', 'desc')); @@ -77,7 +80,7 @@ public function testFormSignatureCoversTheExactStringSumSentInQuery() $this->assertSame($expected, $q['signature']); } - public function testApiRendersFloatSumAsCanonicalDecimalString() + public function testApiRendersFloatSumAsCanonicalDecimalString(): void { $captured = null; $transport = static function ($url) use (&$captured) { diff --git a/tests/UnitPayFormTest.php b/tests/UnitPayFormTest.php index 8529e2d..7ee6ade 100644 --- a/tests/UnitPayFormTest.php +++ b/tests/UnitPayFormTest.php @@ -8,13 +8,16 @@ final class UnitPayFormTest extends TestCase { - private function queryOf($url) + /** + * @return array + */ + private function queryOf(string $url): array { parse_str((string) parse_url($url, PHP_URL_QUERY), $q); return $q; } - public function testFormBuildsHostedPaymentUrl() + public function testFormBuildsHostedPaymentUrl(): void { $unitPay = new UnitPay('unitpay.ru', 'secret'); @@ -30,7 +33,7 @@ public function testFormBuildsHostedPaymentUrl() $this->assertSame('ru', $q['locale']); } - public function testFormIncludesSignatureOverVitalParamsWhenSecretIsSet() + public function testFormIncludesSignatureOverVitalParamsWhenSecretIsSet(): void { $unitPay = new UnitPay('unitpay.ru', 'secret'); @@ -48,7 +51,7 @@ public function testFormIncludesSignatureOverVitalParamsWhenSecretIsSet() ); } - public function testFormThrowsWithoutSecret() + public function testFormThrowsWithoutSecret(): void { $unitPay = new UnitPay('unitpay.ru'); @@ -56,7 +59,7 @@ public function testFormThrowsWithoutSecret() $unitPay->form('pk', 100, 'acc', 'desc'); } - public function testFormHonoursCurrencyAndLocaleOverrides() + public function testFormHonoursCurrencyAndLocaleOverrides(): void { $unitPay = new UnitPay('unitpay.ru', 'secret'); @@ -66,7 +69,7 @@ public function testFormHonoursCurrencyAndLocaleOverrides() $this->assertSame('en', $q['locale']); } - public function testChainedSettersLandInTheFormUrl() + public function testChainedSettersLandInTheFormUrl(): void { $unitPay = new UnitPay('unitpay.ru', 'secret'); $unitPay->setBackUrl('https://shop.example/back') @@ -86,7 +89,7 @@ public function testChainedSettersLandInTheFormUrl() * form() очищает накопленные сеттерами параметры, поэтому повторно используемый * экземпляр не переносит backUrl/чек/покупателя предыдущего заказа в следующий вызов. */ - public function testFormClearsAccumulatedParamsAfterCall() + public function testFormClearsAccumulatedParamsAfterCall(): void { $unitPay = new UnitPay('unitpay.ru', 'secret'); $unitPay->setBackUrl('https://shop.example/back') @@ -101,7 +104,7 @@ public function testFormClearsAccumulatedParamsAfterCall() } /** Подпись формы должна покрывать ТОЛЬКО четыре ключевых параметра, а не параметры сеттеров. */ - public function testFormSignatureExcludesSetterParams() + public function testFormSignatureExcludesSetterParams(): void { $unitPay = new UnitPay('unitpay.ru', 'secret'); $unitPay->setCustomerEmail('customer@example.com') @@ -122,7 +125,7 @@ public function testFormSignatureExcludesSetterParams() * Слой A: form() добавляет машиночитаемый токен фингерпринта sdk (URL-safe, * major.minor PHP) — и он НЕ меняет подпись (стоит вне подписываемых параметров). */ - public function testFormCarriesSdkTokenWithoutBreakingSignature() + public function testFormCarriesSdkTokenWithoutBreakingSignature(): void { $unitPay = new UnitPay('unitpay.test', 'secret'); $url = $unitPay->form('pub', 100, 'order-1', 'Desc'); diff --git a/tests/UnitPayHandlerTest.php b/tests/UnitPayHandlerTest.php index 685e66a..576277d 100644 --- a/tests/UnitPayHandlerTest.php +++ b/tests/UnitPayHandlerTest.php @@ -15,11 +15,11 @@ final class UnitPayHandlerTest extends TestCase /** * Строит массив вебхука с корректной подписью его параметров. * - * @param string $method - * @param array $overrides параметры для добавления/переопределения перед подписью - * @return array{method: string, params: array} + * @param string $method + * @param array $overrides параметры для добавления/переопределения перед подписью + * @return array{method: string, params: array} */ - private function validRequest($method = 'pay', array $overrides = []) + private function validRequest(string $method = 'pay', array $overrides = []): array { $params = array_merge([ 'account' => '42', @@ -35,22 +35,28 @@ private function validRequest($method = 'pay', array $overrides = []) return ['method' => $method, 'params' => $params]; } - private function sign(array $params, $method) + /** + * @param array $params + */ + private function sign(array $params, string $method): string { return (new UnitPay('unitpay.ru', self::SECRET))->getSignature($params, $method); } - private function handler(array $request, $ip = self::ALLOWED_IP, $secret = self::SECRET) + /** + * @param array $request + */ + private function handler(array $request, string $ip = self::ALLOWED_IP, ?string $secret = self::SECRET): UnitPay { return new UnitPay('unitpay.ru', $secret, null, $request, $ip); } - public function testValidSignatureAndAllowedIpPass() + public function testValidSignatureAndAllowedIpPass(): void { $this->assertTrue($this->handler($this->validRequest('pay'))->checkHandlerRequest()); } - public function testTamperedParamsAreRejected() + public function testTamperedParamsAreRejected(): void { $request = $this->validRequest('pay'); $request['params']['orderSum'] = '0.01'; // изменено после подписи @@ -60,35 +66,35 @@ public function testTamperedParamsAreRejected() $this->handler($request)->checkHandlerRequest(); } - public function testDisallowedIpIsRejected() + public function testDisallowedIpIsRejected(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('IP address Error'); $this->handler($this->validRequest('pay'), '8.8.8.8')->checkHandlerRequest(); } - public function testEmptySecretIsRejected() + public function testEmptySecretIsRejected(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('SecretKey is null'); $this->handler($this->validRequest('pay'), self::ALLOWED_IP, null)->checkHandlerRequest(); } - public function testMissingMethodIsRejected() + public function testMissingMethodIsRejected(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Method is null'); $this->handler(['params' => ['x' => '1']])->checkHandlerRequest(); } - public function testMissingParamsIsRejected() + public function testMissingParamsIsRejected(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Params is null'); $this->handler(['method' => 'pay'])->checkHandlerRequest(); } - public function testUnsupportedPartnerMethodIsRejected() + public function testUnsupportedPartnerMethodIsRejected(): void { $this->expectException(UnexpectedValueException::class); $this->expectExceptionMessage('Method is not supported'); @@ -100,7 +106,7 @@ public function testUnsupportedPartnerMethodIsRejected() * который шлёт Unitpay (method = check | pay | preauth | error). Должно проходить * проверку, а не отклоняться как неподдерживаемое. */ - public function testPreauthPartnerMethodIsSupported() + public function testPreauthPartnerMethodIsSupported(): void { $request = $this->validRequest('preauth', ['isPreauth' => '1']); @@ -114,7 +120,7 @@ public function testPreauthPartnerMethodIsSupported() * Нестроковая подпись (например, массив, подсунутый через $_GET) должна быть * аккуратно отклонена как "Wrong signature", а не приводить к TypeError. */ - public function testArraySignatureIsRejectedCleanly() + public function testArraySignatureIsRejectedCleanly(): void { $request = $this->validRequest('pay'); $request['params']['signature'] = ['not', 'a', 'string']; @@ -128,7 +134,7 @@ public function testArraySignatureIsRejectedCleanly() * Подделанный params[PHP_INT_MAX] не должен ломать проверку и должен проходить * её корректно (ключ убирается и при подписи, и при проверке). */ - public function testPhpIntMaxKeyInParamsDoesNotBreakVerification() + public function testPhpIntMaxKeyInParamsDoesNotBreakVerification(): void { $params = [ 'account' => '42', @@ -143,7 +149,7 @@ public function testPhpIntMaxKeyInParamsDoesNotBreakVerification() $this->assertTrue($this->handler($request)->checkHandlerRequest()); } - public function testSetAllowedIpsOverridesTheDefaultAllowlist() + public function testSetAllowedIpsOverridesTheDefaultAllowlist(): void { $customIp = '203.0.113.7'; // TEST-NET-3, нет в списке по умолчанию $unitPay = $this->handler($this->validRequest('pay'), $customIp); @@ -153,7 +159,7 @@ public function testSetAllowedIpsOverridesTheDefaultAllowlist() } /** 127.0.0.1 по умолчанию НЕ доверенный: за прокси на том же хосте он обнулил бы проверку IP. */ - public function testLocalhostIsRejectedByDefault() + public function testLocalhostIsRejectedByDefault(): void { $unitPay = $this->handler($this->validRequest('pay'), '127.0.0.1'); @@ -162,7 +168,7 @@ public function testLocalhostIsRejectedByDefault() } /** setAllowedIps принимает CIDR-подсети, а не только точные IP. */ - public function testCidrAllowlistMatchesAddressInRange() + public function testCidrAllowlistMatchesAddressInRange(): void { $unitPay = $this->handler($this->validRequest('pay'), '203.0.113.55'); $unitPay->setAllowedIps(['203.0.113.0/24']); @@ -170,7 +176,7 @@ public function testCidrAllowlistMatchesAddressInRange() $this->assertTrue($unitPay->checkHandlerRequest()); } - public function testCidrAllowlistRejectsAddressOutOfRange() + public function testCidrAllowlistRejectsAddressOutOfRange(): void { $unitPay = $this->handler($this->validRequest('pay'), '203.0.114.1'); $unitPay->setAllowedIps(['203.0.113.0/24']); @@ -180,7 +186,7 @@ public function testCidrAllowlistRejectsAddressOutOfRange() } /** Сопоставление CIDR работает и для IPv6 (бинарное сравнение через inet_pton). */ - public function testCidrAllowlistMatchesIpv6InRange() + public function testCidrAllowlistMatchesIpv6InRange(): void { $unitPay = $this->handler($this->validRequest('pay'), '2001:db8::1'); $unitPay->setAllowedIps(['2001:db8::/32']); @@ -189,7 +195,7 @@ public function testCidrAllowlistMatchesIpv6InRange() } /** До первой успешной проверки геттеры проверенных данных возвращают null. */ - public function testHandlerGettersAreNullBeforeVerification() + public function testHandlerGettersAreNullBeforeVerification(): void { $unitPay = $this->handler($this->validRequest('pay')); @@ -198,7 +204,7 @@ public function testHandlerGettersAreNullBeforeVerification() } /** После успешной проверки getHandlerParams() отдаёт именно проверенные параметры вебхука. */ - public function testGetHandlerParamsReturnsVerifiedParams() + public function testGetHandlerParamsReturnsVerifiedParams(): void { $request = $this->validRequest('pay'); $unitPay = $this->handler($request); @@ -209,7 +215,7 @@ public function testGetHandlerParamsReturnsVerifiedParams() } /** Типизированное исключение, всё ещё наследующее исторический SPL-тип + маркерный интерфейс. */ - public function testSignatureFailureThrowsTypedExceptionStillCatchableAsInvalidArgument() + public function testSignatureFailureThrowsTypedExceptionStillCatchableAsInvalidArgument(): void { $request = $this->validRequest('pay'); $request['params']['orderSum'] = '0.01'; // изменено после подписи diff --git a/tests/UnitPayPaymentTypeTest.php b/tests/UnitPayPaymentTypeTest.php index 1cf8871..18c1844 100644 --- a/tests/UnitPayPaymentTypeTest.php +++ b/tests/UnitPayPaymentTypeTest.php @@ -12,7 +12,7 @@ */ final class UnitPayPaymentTypeTest extends TestCase { - public function testPaymentTypeConstantsMatchPublishedCodes() + public function testPaymentTypeConstantsMatchPublishedCodes(): void { $this->assertSame('card', UnitPay::PAYMENT_TYPE_CARD); $this->assertSame('cardInvoice', UnitPay::PAYMENT_TYPE_CARD_INVOICE); @@ -24,7 +24,7 @@ public function testPaymentTypeConstantsMatchPublishedCodes() } /** Константа способа оплаты принимается как есть в качестве paymentType для initPayment. */ - public function testConstantIsUsableAsInitPaymentType() + public function testConstantIsUsableAsInitPaymentType(): void { $captured = null; $transport = static function ($url) use (&$captured) { diff --git a/tests/UnitPayResponseTest.php b/tests/UnitPayResponseTest.php index d4e2beb..2897074 100644 --- a/tests/UnitPayResponseTest.php +++ b/tests/UnitPayResponseTest.php @@ -15,7 +15,7 @@ protected function setUp(): void $this->unitPay = new UnitPay('unitpay.ru', 'secret'); } - public function testSuccessHandlerResponseShape() + public function testSuccessHandlerResponseShape(): void { $this->assertSame( '{"result":{"message":"ok"}}', @@ -23,7 +23,7 @@ public function testSuccessHandlerResponseShape() ); } - public function testErrorHandlerResponseShape() + public function testErrorHandlerResponseShape(): void { $this->assertSame( '{"error":{"message":"bad"}}', diff --git a/tests/UnitPaySignatureTest.php b/tests/UnitPaySignatureTest.php index 8989e64..148e43c 100644 --- a/tests/UnitPaySignatureTest.php +++ b/tests/UnitPaySignatureTest.php @@ -15,7 +15,7 @@ protected function setUp(): void $this->unitPay = new UnitPay('unitpay.ru', 'secret'); } - public function testSignatureMatchesDocumentedFormula() + public function testSignatureMatchesDocumentedFormula(): void { // sha256( <значения, отсортированные ksort>{up}secretKey ) $this->assertSame( @@ -24,7 +24,7 @@ public function testSignatureMatchesDocumentedFormula() ); } - public function testSignatureIsIndependentOfKeyOrder() + public function testSignatureIsIndependentOfKeyOrder(): void { $this->assertSame( $this->unitPay->getSignature(['a' => '1', 'b' => '2']), @@ -38,7 +38,7 @@ public function testSignatureIsIndependentOfKeyOrder() * krsort/asort изменил бы этот хэш и сломал бы каждую боевую подпись с несколькими * параметрами — этот тест такое поймает. */ - public function testSignaturePinsAscendingKeyOrder() + public function testSignaturePinsAscendingKeyOrder(): void { $this->assertSame( hash('sha256', 'pay{up}1{up}2{up}3{up}secret'), @@ -46,7 +46,7 @@ public function testSignaturePinsAscendingKeyOrder() ); } - public function testMethodIsPrependedToPayload() + public function testMethodIsPrependedToPayload(): void { $this->assertSame( hash('sha256', 'pay{up}1{up}secret'), @@ -58,7 +58,7 @@ public function testMethodIsPrependedToPayload() ); } - public function testCallerSuppliedSignatureKeysAreStripped() + public function testCallerSuppliedSignatureKeysAreStripped(): void { $this->assertSame( $this->unitPay->getSignature(['a' => '1']), @@ -72,7 +72,7 @@ public function testCallerSuppliedSignatureKeysAreStripped() * PHP <8, фатальная Error на PHP >=8). Не должен бросать исключение, а полученная * подпись должна совпадать с подписью без вредоносного ключа. */ - public function testPhpIntMaxKeyIsStrippedAndSecretRetained() + public function testPhpIntMaxKeyIsStrippedAndSecretRetained(): void { $this->assertSame( $this->unitPay->getSignature(['a' => '1']), @@ -85,7 +85,7 @@ public function testPhpIntMaxKeyIsStrippedAndSecretRetained() * предупреждение "Array to string conversion"; массив приводится к '', и проверка * просто не совпадает с легитимной подписью. */ - public function testArrayValuedParamDoesNotEmitWarning() + public function testArrayValuedParamDoesNotEmitWarning(): void { set_error_handler(static function ($errno, $errstr) { throw new \RuntimeException($errstr, $errno); diff --git a/tests/UnitPayTelemetryTest.php b/tests/UnitPayTelemetryTest.php index de549d5..d4d0c7e 100644 --- a/tests/UnitPayTelemetryTest.php +++ b/tests/UnitPayTelemetryTest.php @@ -35,7 +35,7 @@ private function headerValue(array $headers, $name) return null; } - public function testApiSendsFingerprintHeaders() + public function testApiSendsFingerprintHeaders(): void { $calls = []; $unitPay = new UnitPay('unitpay.test', 'secret', $this->spy($calls)); @@ -52,7 +52,7 @@ public function testApiSendsFingerprintHeaders() $this->assertSame(PHP_VERSION, $decoded['php_version']); } - public function testTelemetryDisabledByDefaultSendsNoBeacon() + public function testTelemetryDisabledByDefaultSendsNoBeacon(): void { $calls = []; $unitPay = new UnitPay('unitpay.test', 'secret', $this->spy($calls)); @@ -64,7 +64,7 @@ public function testTelemetryDisabledByDefaultSendsNoBeacon() $this->assertSame([], $calls); } - public function testEnabledTelemetryFiresBeaconWithFieldsAndShortTimeout() + public function testEnabledTelemetryFiresBeaconWithFieldsAndShortTimeout(): void { $calls = []; $unitPay = new UnitPay('unitpay.test', 'secret', $this->spy($calls)); @@ -86,7 +86,7 @@ public function testEnabledTelemetryFiresBeaconWithFieldsAndShortTimeout() $this->assertStringNotContainsString('secret', $calls[0]['url']); } - public function testEnvKillSwitchSuppressesBeacon() + public function testEnvKillSwitchSuppressesBeacon(): void { putenv('UNITPAY_SDK_TELEMETRY_DISABLE=1'); try { @@ -103,7 +103,7 @@ public function testEnvKillSwitchSuppressesBeacon() } } - public function testWrongSignatureHandlerEmitsBeaconWithMethod() + public function testWrongSignatureHandlerEmitsBeaconWithMethod(): void { $calls = []; $unitPay = new UnitPay('unitpay.test', 'secret', $this->spy($calls), [ @@ -121,7 +121,7 @@ public function testWrongSignatureHandlerEmitsBeaconWithMethod() $this->assertSame('pay', $q['method']); } - public function testMissingMethodHandlerEmitsUnknownMethod() + public function testMissingMethodHandlerEmitsUnknownMethod(): void { $calls = []; $unitPay = new UnitPay('unitpay.test', 'secret', $this->spy($calls), [], '1.2.3.4'); @@ -136,7 +136,7 @@ public function testMissingMethodHandlerEmitsUnknownMethod() $this->assertSame('unknown', $q['method']); } - public function testApiUnreachableEmitsNoBeacon() + public function testApiUnreachableEmitsNoBeacon(): void { $calls = []; // Транспорт фейлит реальный запрос (false); любой beacon тоже попал бы в $calls. @@ -156,7 +156,7 @@ public function testApiUnreachableEmitsNoBeacon() $this->assertStringNotContainsString('/sdk/telemetry', $calls[0]); } - public function testTelemetryFailureNeverPropagates() + public function testTelemetryFailureNeverPropagates(): void { $throwing = static function () { throw new \RuntimeException('beacon down'); diff --git a/tests/UnitpayIpAllowlistTest.php b/tests/UnitpayIpAllowlistTest.php index eb2aa58..918b027 100644 --- a/tests/UnitpayIpAllowlistTest.php +++ b/tests/UnitpayIpAllowlistTest.php @@ -18,27 +18,27 @@ private function matcher(): UnitpayIpAllowlist return new UnitpayIpAllowlist(['31.186.100.49', '203.0.113.0/24', '2001:db8::/32']); } - public function testExactIpv4AddressMatches() + public function testExactIpv4AddressMatches(): void { $this->assertTrue($this->matcher()->contains('31.186.100.49')); } - public function testUnlistedIpv4AddressDoesNotMatch() + public function testUnlistedIpv4AddressDoesNotMatch(): void { $this->assertFalse($this->matcher()->contains('8.8.8.8')); } - public function testAddressInsideIpv4CidrMatches() + public function testAddressInsideIpv4CidrMatches(): void { $this->assertTrue($this->matcher()->contains('203.0.113.55')); } - public function testAddressOutsideIpv4CidrDoesNotMatch() + public function testAddressOutsideIpv4CidrDoesNotMatch(): void { $this->assertFalse($this->matcher()->contains('203.0.114.1')); } - public function testAddressInsideIpv6CidrMatches() + public function testAddressInsideIpv6CidrMatches(): void { $this->assertTrue($this->matcher()->contains('2001:db8::1')); } @@ -47,7 +47,7 @@ public function testAddressInsideIpv6CidrMatches() * Точная запись IPv6 матчится вне зависимости от текстовой формы (регистр, сжатие): * сравнение идёт по упакованному in_addr, а не по строке. */ - public function testExactIpv6MatchesRegardlessOfTextualForm() + public function testExactIpv6MatchesRegardlessOfTextualForm(): void { $upper = new UnitpayIpAllowlist(['2001:DB8::1']); $this->assertTrue($upper->contains('2001:db8::1')); @@ -57,7 +57,7 @@ public function testExactIpv6MatchesRegardlessOfTextualForm() } /** Некорректный клиентский IP не должен приводить к ложному совпадению. */ - public function testInvalidClientIpDoesNotMatch() + public function testInvalidClientIpDoesNotMatch(): void { $this->assertFalse($this->matcher()->contains('not-an-ip')); } @@ -66,7 +66,7 @@ public function testInvalidClientIpDoesNotMatch() * IPv4-клиент против исключительно IPv6-подсети: inet_pton даёт in_addr разной * длины, поэтому сравнение должно аккуратно провалиться, а не сматчиться по ошибке. */ - public function testIpv4ClientAgainstIpv6OnlySubnetDoesNotMatch() + public function testIpv4ClientAgainstIpv6OnlySubnetDoesNotMatch(): void { $matcher = new UnitpayIpAllowlist(['2001:db8::/32']); @@ -74,7 +74,7 @@ public function testIpv4ClientAgainstIpv6OnlySubnetDoesNotMatch() } /** Префикс длиннее самого адреса (/33 для IPv4) не может сматчить ничего. */ - public function testPrefixWiderThanAddressDoesNotMatch() + public function testPrefixWiderThanAddressDoesNotMatch(): void { $matcher = new UnitpayIpAllowlist(['203.0.113.0/33']); @@ -82,7 +82,7 @@ public function testPrefixWiderThanAddressDoesNotMatch() } /** Граница подсети /25: адрес выше верхней границы диапазона не попадает. */ - public function testCidrBoundaryIsRespected() + public function testCidrBoundaryIsRespected(): void { $matcher = new UnitpayIpAllowlist(['77.75.153.0/25']); @@ -92,14 +92,14 @@ public function testCidrBoundaryIsRespected() // --- parseWebhooksFeed() --------------------------------------------------- - public function testParseWebhooksFeedReturnsDedupedList() + public function testParseWebhooksFeedReturnsDedupedList(): void { $body = json_encode(['webhooks' => ['1.1.1.1', '1.1.1.1', '2.2.2.2']]); $this->assertSame(['1.1.1.1', '2.2.2.2'], UnitpayIpAllowlist::parseWebhooksFeed($body)); } - public function testParseWebhooksFeedKeepsOnlyValidEntries() + public function testParseWebhooksFeedKeepsOnlyValidEntries(): void { $body = json_encode(['webhooks' => ['203.0.113.0/24', 'garbage', '2001:db8::1']]); @@ -109,11 +109,14 @@ public function testParseWebhooksFeedKeepsOnlyValidEntries() /** * @dataProvider unusableFeeds */ - public function testParseWebhooksFeedReturnsNullForUnusableInput(string $body) + public function testParseWebhooksFeedReturnsNullForUnusableInput(string $body): void { $this->assertNull(UnitpayIpAllowlist::parseWebhooksFeed($body)); } + /** + * @return array + */ public function unusableFeeds(): array { return [ From 6a01d5fdc102c32021ef931a1bef9c2cd0a9d667 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Thu, 23 Jul 2026 21:47:41 +0300 Subject: [PATCH 19/30] chore(phpstan): drop config comments --- phpstan.neon | 6 ------ 1 file changed, 6 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 51a6398..3e84653 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,10 +1,4 @@ parameters: - # Level 6: raised from 5 once UnitPay.php gained native type declarations - # (params, returns, typed properties — see F014 in the tech-debt audit). Level 7+ - # would flag httpGet()'s string|false via curl_exec's string|bool stub, which - # only a code restructure (direct curl_setopt) or a looser contract would satisfy — - # deliberately not pursued. Money/quantity params stay untyped by design (docblock - # unions); declare(strict_types=1) is still deferred. level: 6 paths: - UnitPay.php From d0f95a4cd48930d1897bd9233e406ad0492bac18 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Thu, 23 Jul 2026 22:38:19 +0300 Subject: [PATCH 20/30] docs: translate all code comments to English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translate every code comment across the SDK from Russian to English — docblocks and inline comments in UnitPay.php, examples/ and tests/. Comments only: code, string literals (incl. example data 'Доставка'), README/CHANGELOG and docs/ are unchanged. Unit-of-measure glosses use American spelling for consistency. php -l, phpunit (126 tests), php-cs-fixer and phpstan all pass. --- UnitPay.php | 539 +++++++++++++++---------------- examples/accountInfo.php | 16 +- examples/config.php | 12 +- examples/initPaymentApi.php | 18 +- examples/offsetAdvance.php | 6 +- examples/order.php | 4 +- examples/paymentForm.php | 8 +- examples/paymentInfo.php | 2 +- examples/payout.php | 16 +- examples/receipt.php | 26 +- examples/refund.php | 4 +- examples/subscriptions.php | 6 +- examples/twoStagePayment.php | 10 +- examples/webhook.php | 44 +-- tests/CashItemTest.php | 16 +- tests/UnitPayAllowedIpsTest.php | 44 +-- tests/UnitPayApiTest.php | 41 +-- tests/UnitPayCashItemsTest.php | 8 +- tests/UnitPayFloatTest.php | 18 +- tests/UnitPayFormTest.php | 12 +- tests/UnitPayHandlerTest.php | 36 +-- tests/UnitPayPaymentTypeTest.php | 8 +- tests/UnitPaySignatureTest.php | 25 +- tests/UnitPayTelemetryTest.php | 14 +- tests/UnitpayIpAllowlistTest.php | 22 +- 25 files changed, 474 insertions(+), 481 deletions(-) diff --git a/UnitPay.php b/UnitPay.php index d93da23..472019d 100644 --- a/UnitPay.php +++ b/UnitPay.php @@ -1,194 +1,194 @@ toBinary($entry); if ($entryBin !== null && $entryBin === $ipBin) { @@ -445,7 +445,7 @@ public function contains(string $ip): bool } /** - * @param string $ipBin упакованный in_addr клиентского IP (из toBinary()) + * @param string $ipBin packed in_addr of the client IP (from toBinary()) */ private function cidrContains(string $cidr, string $ipBin): bool { @@ -461,10 +461,10 @@ private function cidrContains(string $cidr, string $ipBin): bool } /** - * Является ли $entry корректной записью белого списка: точным адресом IPv4/IPv6 - * или CIDR-диапазоном вида "адрес/биты". Используется для проверки загруженного - * списка IP до того, как он заменит встроенный, чтобы некорректный JSON не мог - * опустошить белый список. + * Whether $entry is a valid allowlist entry: an exact IPv4/IPv6 address or a + * CIDR range of the form "address/bits". Used to validate a fetched IP list + * before it replaces the built-in one, so malformed JSON cannot empty the + * allowlist. */ public static function isValidEntry(string $entry): bool { @@ -475,18 +475,17 @@ public static function isValidEntry(string $entry): bool if (!ctype_digit($bits) || filter_var($subnet, FILTER_VALIDATE_IP) === false) { return false; } - // Длина префикса не может превышать разрядность адреса (IPv4 = 32, IPv6 = 128), - // иначе запись валидна на вид, но не матчит ничего (prefixMatches вернёт false). + // The prefix length cannot exceed the address width (IPv4 = 32, IPv6 = 128), + // otherwise the entry looks valid but matches nothing (prefixMatches returns false). $maxBits = filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? 128 : 32; return (int) $bits <= $maxBits; } /** - * Разбирает тело опубликованного перечня IP вебхуков ({"webhooks":[...]}) в - * проверенный список записей без дубликатов. Возвращает null при пустом вводе, - * некорректном JSON, отсутствующем или не-массивном ключе "webhooks" либо когда - * ни одна запись не является корректным IP/CIDR — так плохой перечень не может - * опустошить белый список. + * Parses the body of the published webhook IP feed ({"webhooks":[...]}) into a + * validated, de-duplicated list of entries. Returns null on empty input, + * malformed JSON, a missing or non-array "webhooks" key, or when no entry is a + * valid IP/CIDR — so a bad feed cannot empty the allowlist. * @return string[]|null */ public static function parseWebhooksFeed(string $body): ?array @@ -508,7 +507,7 @@ public static function parseWebhooksFeed(string $body): ?array } /** - * @return string|null упакованный in_addr, либо null, если $ip не является корректным адресом + * @return string|null packed in_addr, or null if $ip is not a valid address */ private function toBinary(string $ip): ?string { @@ -538,27 +537,27 @@ private function prefixMatches(string $ipBin, string $subnetBin, int $bits): boo } /** - * Клиент платёжного REST API Unitpay: подпись и построение формы/URL, - * server-to-server вызовы API и проверка входящих вебхуков. + * Client for the Unitpay payment REST API: signing and form/URL building, + * server-to-server API calls, and inbound webhook verification. */ class UnitPay { - /** Версия SDK; шлётся в фингерпринте телеметрии. Держать в синхроне с git-тегом релиза. */ + /** SDK version; sent in the telemetry fingerprint. Keep in sync with the release git tag. */ public const VERSION = '2.1.0'; /** - * Коды способов оплаты для параметра `paymentType` в api('initPayment', ...) - * и выплатах api('massPayment', ...). Источник истины — бэкенд, список кодов: + * Payment method codes for the `paymentType` param in api('initPayment', ...) + * and payouts api('massPayment', ...). The source of truth is the backend; code list: * https://help.unitpay.ru/book-of-reference/payment-system-codes - * paymentType по этим значениям НЕ валидируется (как и словари CashItem), поэтому - * новый код оплаты не требует релиза SDK — константы дают лишь защиту от опечаток - * и автодополнение. + * paymentType is NOT validated against these values (like the CashItem dictionaries), so + * a new payment code does not require an SDK release — the constants only guard against + * typos and provide autocompletion. */ - /** Пластиковые карты (приём по картам всего мира) */ + /** Bank cards (worldwide card acceptance) */ public const PAYMENT_TYPE_CARD = 'card'; - /** Зарубежные карты через форму банка-эквайера */ + /** Foreign cards via the acquiring bank's form */ public const PAYMENT_TYPE_CARD_INVOICE = 'cardInvoice'; - /** Система быстрых платежей (СБП) */ + /** Faster Payments System (SBP) */ public const PAYMENT_TYPE_SBP = 'sbp'; /** SberPay */ public const PAYMENT_TYPE_SBERPAY = 'sberpay'; @@ -566,14 +565,14 @@ class UnitPay public const PAYMENT_TYPE_TINKOFFPAY = 'tinkoffpay'; /** PayPal */ public const PAYMENT_TYPE_PAYPAL = 'paypal'; - /** WebMoney (WMZ-кошельки) */ + /** WebMoney (WMZ wallets) */ public const PAYMENT_TYPE_WEBMONEY = 'webmoney'; /** - * Коды пре-флайт ошибок для опциональной телеметрии (Слой B). Стабильные, не-PII; - * только additive — не переименовывать/не удалять, чтобы серии на бэкенде оставались - * сравнимыми. ERR_API_UNREACHABLE определён, но НЕ отправляется: beacon шёл бы на тот - * же недоступный $domain (см. reportTelemetry / checkHandlerRequest wire-in). + * Pre-flight error codes for optional telemetry (Layer B). Stable, non-PII; + * additive-only — do not rename or remove, so the backend series stay comparable. + * ERR_API_UNREACHABLE is defined but NOT sent: the beacon would go to the same + * unreachable $domain (see reportTelemetry / checkHandlerRequest wire-in). */ public const ERR_METHOD_NOT_SUPPORTED = 'ERR_METHOD_NOT_SUPPORTED'; public const ERR_MISSING_REQUIRED_PARAM = 'ERR_MISSING_REQUIRED_PARAM'; @@ -585,8 +584,8 @@ class UnitPay public const ERR_IP_NOT_ALLOWED = 'ERR_IP_NOT_ALLOWED'; /** - * Поддерживаемые методы api() и их обязательные параметры. secretKey - * подставляется и проверяется в api(), поэтому здесь не перечислен. + * Supported api() methods and their required parameters. secretKey is + * injected and validated in api(), so it is not listed here. * @var array */ private array $requiredUnitpayMethodsParams = [ @@ -611,17 +610,16 @@ class UnitPay 'getBinInfo' => ['login', 'bin'], ]; /** - * Методы вебхуков, которые Unitpay шлёт обработчику. 'preauth' — уведомление о - * двухстадийной блокировке средств (деньги заблокированы, но ещё не списаны): - * должно проходить проверку как остальные, а не отклоняться как неподдерживаемое. + * Webhook methods that Unitpay sends to the handler. 'preauth' is a notification of + * a two-stage hold on funds (money is blocked but not yet captured): it must pass + * verification like the others rather than be rejected as unsupported. * @var string[] */ private array $supportedPartnerMethods = ['check', 'pay', 'preauth', 'error']; /** - * Опубликованные исходящие IP Unitpay. 127.0.0.1 здесь намеренно НЕТ: за - * обратным прокси на том же хосте REMOTE_ADDR равен 127.0.0.1, что превратило бы - * проверку IP в фикцию. Добавляйте его явно через setAllowedIps() только для - * локальной отладки. + * Published outbound Unitpay IPs. 127.0.0.1 is deliberately NOT here: behind a + * reverse proxy on the same host REMOTE_ADDR equals 127.0.0.1, which would turn the + * IP check into a sham. Add it explicitly via setAllowedIps() for local debugging only. * @var string[] */ private array $supportedUnitpayIp = [ @@ -644,8 +642,8 @@ class UnitPay private ?array $handlerParams = null; private ?UnitpayIpAllowlist $ipAllowlist = null; /** - * IP самого мерчанта, добавленные через addAllowedIps(); всегда применяются - * поверх списка Unitpay и сохраняются при refreshAllowedIps()/setAllowedIps(). + * The merchant's own IPs, added via addAllowedIps(); always applied on top of + * the Unitpay list and preserved across refreshAllowedIps()/setAllowedIps(). * @var string[] */ private array $customIps = []; @@ -654,13 +652,13 @@ class UnitPay private bool $telemetryEnabled = false; /** - * @param string $domain только хост, например "unitpay.ru" — без схемы и пути (станет "https://$domain/api"). - * @param callable|null $transport исходящий HTTP-транспорт для api(): fn(string $url): string|false. - * По умолчанию file_get_contents(). Подменяйте, чтобы тестировать api() без сети. - * @param array|null $request массив входящего вебхука, читаемый checkHandlerRequest(). - * По умолчанию $_GET. Подменяйте, чтобы тестировать обработчик без суперглобальных переменных. - * @param string|null $clientIp IP отправителя, используемый getIp(). По умолчанию $_SERVER['REMOTE_ADDR']. - * Подменяйте, чтобы тестировать белый список IP без суперглобальных переменных. + * @param string $domain host only, e.g. "unitpay.ru" — without scheme or path (becomes "https://$domain/api"). + * @param callable|null $transport outbound HTTP transport for api(): fn(string $url): string|false. + * Defaults to file_get_contents(). Override to test api() without the network. + * @param array|null $request inbound webhook array read by checkHandlerRequest(). + * Defaults to $_GET. Override to test the handler without superglobals. + * @param string|null $clientIp sender IP used by getIp(). Defaults to $_SERVER['REMOTE_ADDR']. + * Override to test the IP allowlist without superglobals. */ public function __construct(string $domain, ?string $secretKey = null, ?callable $transport = null, ?array $request = null, ?string $clientIp = null) { @@ -675,12 +673,11 @@ public function __construct(string $domain, ?string $secretKey = null, ?callable } /** - * Переопределяет список IP Unitpay, которым разрешено вызывать обработчик. - * Полностью заменяет встроенный список по умолчанию (или ранее загруженный), но - * НЕ трогает IP мерчанта, добавленные через addAllowedIps(), — они остаются - * поверх. Используйте, чтобы держать SDK в актуальном состоянии при смене - * инфраструктуры Unitpay, не дожидаясь релиза, или чтобы вернуть список, который - * вы загрузили и закэшировали сами. + * Overrides the list of Unitpay IPs allowed to call the handler. + * Fully replaces the built-in default (or previously fetched) list, but does + * NOT touch the merchant IPs added via addAllowedIps() — they remain on top. + * Use it to keep the SDK current when Unitpay's infrastructure changes without + * waiting for a release, or to restore a list you fetched and cached yourself. * @link https://help.unitpay.ru/book-of-reference/ip-addresses * @param string[] $ips */ @@ -692,11 +689,11 @@ public function setAllowedIps(array $ips): self } /** - * Добавляет IP/CIDR-диапазоны самого мерчанта (например, ваш прокси/релей) - * поверх списка Unitpay. В отличие от setAllowedIps(), который заменяет список - * Unitpay, эти сохраняются при вызовах refreshAllowedIps()/setAllowedIps(). - * Дубликаты убираются. - * @param string[] $ips точные IP и/или CIDR-диапазоны + * Adds the merchant's own IP/CIDR ranges (e.g. your proxy/relay) on top of the + * Unitpay list. Unlike setAllowedIps(), which replaces the Unitpay list, these + * are preserved across refreshAllowedIps()/setAllowedIps() calls. Duplicates + * are removed. + * @param string[] $ips exact IPs and/or CIDR ranges */ public function addAllowedIps(array $ips): self { @@ -706,24 +703,22 @@ public function addAllowedIps(array $ips): self } /** - * Загружает актуальные опубликованные IP вебхуков Unitpay с - * https:///ips/ips_webhooks.json и делает их белым списком. + * Fetches Unitpay's current published webhook IPs from + * https:///ips/ips_webhooks.json and makes them the allowlist. * - * Действует по возможности и безопасно для сбоев: при любой ошибке - * транспорта/разбора/проверки ранее настроенный список Unitpay (встроенный по - * умолчанию или заданный последним setAllowedIps()) остаётся без изменений — - * метод никогда не опустошает список и не бросает исключений, поэтому его - * безопасно вызывать в цепочке перед checkHandlerRequest(). Успешная загрузка - * ЗАМЕНЯЕТ список Unitpay (так выведенный из эксплуатации IP исчезает); IP - * мерчанта, добавленные через addAllowedIps(), сохраняются и всегда применяются - * поверх. + * Best-effort and fail-safe: on any transport/parse/validation error the + * previously configured Unitpay list (built-in default or the last + * setAllowedIps()) is left unchanged — the method never empties the list and + * never throws, so it is safe to call in a chain before checkHandlerRequest(). + * A successful fetch REPLACES the Unitpay list (so a decommissioned IP drops + * out); merchant IPs added via addAllowedIps() are preserved and always applied + * on top. * - * Проверка TLS здесь важна (httpGet оставляет CURLOPT_SSL_VERIFYPEER / verify_peer - * включёнными): непроверенный или подменённый список свёл бы на нет проверку IP. + * TLS verification matters here (httpGet keeps CURLOPT_SSL_VERIFYPEER / verify_peer + * enabled): an unverified or spoofed list would defeat the IP check. * - * Метод делает блокирующий сетевой запрос — вызывайте его периодически (например, - * ежедневным cron) и кэшируйте getAllowedIps() у себя; НЕ вызывайте его на каждый - * вебхук. + * The method makes a blocking network request — call it periodically (e.g. from a + * daily cron) and cache getAllowedIps() yourself; do NOT call it on every webhook. */ public function refreshAllowedIps(): self { @@ -736,10 +731,10 @@ public function refreshAllowedIps(): self } /** - * Итоговый белый список, реально применяемый обработчиком: список Unitpay плюс - * добавления мерчанта, без дубликатов. Кэшируйте его после refreshAllowedIps() и - * возвращайте через setAllowedIps() при обработке вебхуков, чтобы не делать - * сетевой запрос на каждый вызов. + * The effective allowlist actually applied by the handler: the Unitpay list plus + * the merchant additions, de-duplicated. Cache it after refreshAllowedIps() and + * feed it back via setAllowedIps() when handling webhooks, to avoid a network + * request on every call. * @return string[] */ public function getAllowedIps(): array @@ -748,8 +743,8 @@ public function getAllowedIps(): array } /** - * Загружает и проверяет опубликованный фид IP вебхуков. - * @return string[]|null проверенный непустой список либо null при любой ошибке + * Fetches and validates the published webhook IP feed. + * @return string[]|null validated non-empty list, or null on any error */ private function fetchUnitpayIps(): ?array { @@ -758,18 +753,17 @@ private function fetchUnitpayIps(): ?array } /** - * Строит подпись SHA-256: значения параметров, отсортированные ksort и - * объединённые буквальным разделителем "{up}", с $method в начале и secretKey в - * конце. + * Builds the SHA-256 signature: parameter values sorted with ksort and joined + * by the literal "{up}" delimiter, with $method prepended and secretKey + * appended. * - * Безопасность: unset() убирает переданные вызывающим ключи подписи И индекс - * PHP_INT_MAX — подделанный params[PHP_INT_MAX] превратил бы добавление secretKey - * в пустую операцию, выкинув секрет из хэша и сделав подпись подделываемой (обход - * на PHP <8, фатальная Error/DoS на PHP >=8). НЕ убирайте этот unset — защита - * однажды была потеряна в 7835fb4 и восстановлена. Подделанный вебхук может также - * подсунуть значение-массив (например, params[x][]=1), поэтому нескаляры - * приводятся к '' — implode() не выдаёт предупреждение, а проверка всё равно - * проваливается, потому что секрет добавляется в любом случае. + * Security: unset() strips the caller-supplied signature keys AND the PHP_INT_MAX + * index — a forged params[PHP_INT_MAX] would turn the secretKey append into a + * no-op, dropping the secret from the hash and making signatures forgeable (bypass + * on PHP <8, fatal Error/DoS on PHP >=8). Do NOT remove this unset — the guard was + * once lost in 7835fb4 and restored. A forged webhook may also inject an array + * value (e.g. params[x][]=1), so non-scalars are coerced to '' — implode() emits no + * warning and verification still fails, because the secret is appended regardless. * * @param array $params */ @@ -794,7 +788,7 @@ public function getSignature(array $params, ?string $method = null): string } /** - * IP отправителя входящего запроса (подменённый clientIp либо $_SERVER['REMOTE_ADDR']). + * Sender IP of the inbound request (the overridden clientIp or $_SERVER['REMOTE_ADDR']). */ protected function getIp(): string { @@ -802,9 +796,9 @@ protected function getIp(): string } /** - * Разрешено ли $ip вызывать обработчик. Сопоставляет точные адреса и CIDR-подсети - * (IPv4/IPv6) через UnitpayIpAllowlist, поэтому setAllowedIps(['77.75.153.0/25']) - * работает. Переопределите для логики, учитывающей прокси. + * Whether $ip is allowed to call the handler. Matches exact addresses and CIDR + * subnets (IPv4/IPv6) via UnitpayIpAllowlist, so setAllowedIps(['77.75.153.0/25']) + * works. Override for proxy-aware logic. */ protected function isAllowedIp(string $ip): bool { @@ -817,19 +811,18 @@ protected function isAllowedIp(string $ip): bool } /** - * Выполняет исходящий HTTP GET, используемый api(). - * Порядок выбора: подменённый $transport -> cURL (если есть ext-curl) -> file_get_contents. - * cURL добавляет таймауты соединения/чтения и не требует allow_url_fopen; у обоих - * запасных вариантов таймаут тоже есть. Возвращает тело ответа либо false при - * ошибке транспорта (которую api() превращает в "Temporary server error"). + * Performs the outbound HTTP GET used by api(). + * Selection order: overridden $transport -> cURL (if ext-curl is present) -> file_get_contents. + * cURL adds connect/read timeouts and does not require allow_url_fopen; both + * fallbacks have a timeout too. Returns the response body, or false on a + * transport error (which api() turns into "Temporary server error"). * - * Безопасность: проверка TLS остаётся включённой (cURL сохраняет - * CURLOPT_SSL_VERIFYPEER в значении по умолчанию true). Запасной вариант - * file_get_contents гасит своё предупреждение транспорта через set_error_handler, - * а не оператором '@' (который запрещён правилами QA) — иначе это предупреждение - * записало бы в лог URL с секретом. - * @param string[] $headers HTTP-заголовки формата "Имя: значение" (фингерпринт Слоя A / beacon). - * @param int|null $timeoutMs жёсткий таймаут в мс (beacon Слоя B); null — обычные таймауты api(). + * Security: TLS verification stays enabled (cURL keeps CURLOPT_SSL_VERIFYPEER at + * its default true). The file_get_contents fallback suppresses its transport + * warning via set_error_handler, not the '@' operator (which QA rules forbid) — + * otherwise that warning would log the URL together with the secret. + * @param string[] $headers HTTP headers of the form "Name: value" (Layer A fingerprint / beacon). + * @param int|null $timeoutMs hard timeout in ms (Layer B beacon); null uses api()'s normal timeouts. * @return string|false */ protected function httpGet(string $url, array $headers = [], ?int $timeoutMs = null) @@ -846,7 +839,7 @@ protected function httpGet(string $url, array $headers = [], ?int $timeoutMs = n CURLOPT_TIMEOUT => 10, ]; if ($timeoutMs !== null) { - // Миллисекундные таймауты + NOSIGNAL для best-effort beacon Слоя B (заменяют секундные). + // Millisecond timeouts + NOSIGNAL for the best-effort Layer B beacon (replacing the second-based ones). unset($opts[CURLOPT_CONNECTTIMEOUT], $opts[CURLOPT_TIMEOUT]); $opts[CURLOPT_NOSIGNAL] = true; $opts[CURLOPT_CONNECTTIMEOUT_MS] = $timeoutMs; @@ -879,10 +872,10 @@ protected function httpGet(string $url, array $headers = [], ?int $timeoutMs = n } /** - * Строит URL-переход на размещённую у Unitpay платёжную форму. Параметры, - * заданные fluent-сеттерами (setCashItems/setCustomerEmail/setBackUrl/...), - * подмешиваются и затем очищаются, поэтому повторно используемый экземпляр не - * переносит параметры этого вызова в следующий form()/api(). + * Builds the redirect URL to Unitpay's hosted payment form. Parameters set via + * the fluent setters (setCashItems/setCustomerEmail/setBackUrl/...) are merged in + * and then cleared, so a reused instance does not carry this call's parameters + * into the next form()/api(). * @param string|float|int $sum */ public function form(string $publicKey, $sum, string $account, string $desc, string $currency = 'RUB', string $locale = 'ru'): string @@ -899,13 +892,13 @@ public function form(string $publicKey, $sum, string $account, string $desc, str $params = array_merge($this->params, $vitalParams); $params['signature'] = $this->getSignature($vitalParams); $params['locale'] = $locale; - $params['sdk'] = $this->getSdkToken(); // вне подписи — на неё не влияет (Слой A) + $params['sdk'] = $this->getSdkToken(); // outside the signature — does not affect it (Layer A) $this->params = []; return $this->formUrl . $publicKey . '?' . http_build_query($params); } /** - * Задаёт email покупателя. + * Sets the customer's email. */ public function setCustomerEmail(string $email): self { @@ -914,7 +907,7 @@ public function setCustomerEmail(string $email): self } /** - * Задаёт телефон покупателя. + * Sets the customer's phone. */ public function setCustomerPhone(string $phone): self { @@ -923,10 +916,10 @@ public function setCustomerPhone(string $phone): self } /** - * Прикрепляет фискальный чек (позиции по 54-ФЗ) к следующему вызову form()/api(). - * Необязательные поля CashItem сериализуются только если заданы. Бросает - * исключение вместо отправки пустого чека, если json_encode не удался (например, - * имя не в UTF-8 / в Windows-1251). + * Attaches a fiscal receipt (54-FZ line items) to the next form()/api() call. + * Optional CashItem fields are serialized only when set. Throws instead of + * sending an empty receipt if json_encode fails (e.g. a name is not UTF-8 / + * is Windows-1251). * @param CashItem[] $items */ public function setCashItems(array $items): self @@ -971,7 +964,7 @@ public function setCashItems(array $items): self } /** - * Задаёт URL, на который Unitpay вернёт плательщика после оплаты. + * Sets the URL Unitpay will return the payer to after payment. */ public function setBackUrl(string $backUrl): self { @@ -980,15 +973,15 @@ public function setBackUrl(string $backUrl): self } /** - * Выполняет server-to-server вызов REST API Unitpay. Параметры fluent-сеттеров - * подмешиваются (чтобы setCashItems()->api('initPayment', ...) отправлял чек) и - * очищаются только УСПЕШНЫМ вызовом. Сбой транспорта их сохраняет, чтобы повтор - * ушёл с тем же чеком, — поэтому чистое состояние наступает лишь после успеха: - * несвязанный вызов сразу ПОСЛЕ сбоя унаследует накопленные параметры (сбросьте их - * явно или используйте новый экземпляр, если это нежелательно). Явные $params имеют - * приоритет. Явный непустой secretKey в $params переопределяет ключ экземпляра, - * поэтому методы уровня аккаунта (getPartner, getCommissions, выплаты, ...) могут - * использовать ключ аккаунта. + * Performs a server-to-server call to the Unitpay REST API. Fluent-setter params + * are merged in (so setCashItems()->api('initPayment', ...) sends the receipt) and + * cleared only by a SUCCESSFUL call. A transport failure keeps them, so a retry + * goes out with the same receipt — hence the state is clean only after success: + * an unrelated call right AFTER a failure inherits the accumulated params (reset + * them explicitly or use a new instance if that is undesirable). Explicit $params + * take precedence. An explicit non-empty secretKey in $params overrides the + * instance key, so account-level methods (getPartner, getCommissions, payouts, ...) + * can use the account key. * @param array $params * * @throws InvalidArgumentException @@ -1038,10 +1031,10 @@ public function api(string $method, array $params = []): object } /** - * Проверяет входящий вебхук: поддерживаемый метод, подпись SHA-256 (в постоянное - * время) и белый список IP отправителя. При успехе выставляет проверенные метод и - * параметры, доступные через getHandlerMethod()/getHandlerParams() (учитывая - * подменённый запрос, а не $_GET). + * Verifies the inbound webhook: supported method, SHA-256 signature (constant-time) + * and the sender IP allowlist. On success it sets the verified method and params, + * available via getHandlerMethod()/getHandlerParams() (honoring the overridden + * request, not $_GET). * * @throws InvalidArgumentException * @throws UnexpectedValueException @@ -1069,7 +1062,7 @@ public function checkHandlerRequest(): bool list($method, $params) = [$request['method'], $request['params']]; if (!in_array($method, $this->supportedPartnerMethods, true)) { - // method здесь — произвольный ввод отправителя; не эхоим его в телеметрию. + // method here is arbitrary sender input; do not echo it into telemetry. $this->reportTelemetry(self::ERR_METHOD_NOT_SUPPORTED, 'unknown'); throw new UnitpayUnsupportedMethodException('Method is not supported'); } @@ -1092,9 +1085,9 @@ public function checkHandlerRequest(): bool } /** - * Метод вебхука, проверенный последним успешным checkHandlerRequest() - * ('check' | 'pay' | 'preauth' | 'error'). Читайте его вместо $_GET, чтобы - * учитывался подменённый запрос. До успешной проверки — null. + * The webhook method verified by the last successful checkHandlerRequest() + * ('check' | 'pay' | 'preauth' | 'error'). Read it instead of $_GET so the + * overridden request is honored. null until a successful verification. */ public function getHandlerMethod(): ?string { @@ -1102,8 +1095,8 @@ public function getHandlerMethod(): ?string } /** - * Параметры вебхука, проверенные последним успешным checkHandlerRequest(). - * До успешной проверки — null. + * The webhook params verified by the last successful checkHandlerRequest(). + * null until a successful verification. * @return array|null */ public function getHandlerParams(): ?array @@ -1112,9 +1105,9 @@ public function getHandlerParams(): ?array } /** - * Машиночитаемый токен фингерпринта для form()-URL: <платформа>_<версия SDK>_. - * Только URL-safe символы (http_build_query их не кодирует); major.minor — чтобы не светить - * точный патч PHP в видимом покупателю URL платёжной формы. + * Machine-readable fingerprint token for the form() URL: __. + * URL-safe characters only (http_build_query leaves them unencoded); major.minor so the exact + * PHP patch is not exposed in the buyer-visible payment form URL. */ private function getSdkToken(): string { @@ -1122,8 +1115,8 @@ private function getSdkToken(): string } /** - * Строка самоидентификации SDK для заголовка User-Agent (полная версия PHP — - * заголовок невидим для покупателя, полезен для диагностики). + * SDK self-identification string for the User-Agent header (full PHP version — + * the header is invisible to the buyer and useful for diagnostics). */ private function getUserAgent(): string { @@ -1131,8 +1124,8 @@ private function getUserAgent(): string } /** - * JSON-фингерпринт для заголовка X-Unitpay-Client — машиночитаемая версия UA, - * чтобы бэкенд не разбирал строку User-Agent регуляркой. + * JSON fingerprint for the X-Unitpay-Client header — a machine-readable version of + * the UA, so the backend does not have to parse the User-Agent string with a regex. */ private function getClientHeader(): string { @@ -1144,7 +1137,7 @@ private function getClientHeader(): string } /** - * Заголовки фингерпринта для header-каналов (api() и beacon Слоя B). + * Fingerprint headers for the header channels (api() and the Layer B beacon). * @return string[] */ private function fingerprintHeaders(): array @@ -1156,10 +1149,10 @@ private function fingerprintHeaders(): array } /** - * Включает опциональную телеметрию пре-флайт ошибок (Слой B). По умолчанию выключена. - * Мерчант оперирует только флагом — URL эндпоинта выводится из $domain, передавать его - * не нужно. Полностью заглушается переменной окружения UNITPAY_SDK_TELEMETRY_DISABLE - * (1/true/yes) без правки кода. + * Enables optional pre-flight error telemetry (Layer B). Disabled by default. + * The merchant only toggles the flag — the endpoint URL is derived from $domain and + * need not be passed. Fully silenced by the UNITPAY_SDK_TELEMETRY_DISABLE environment + * variable (1/true/yes) with no code change. */ public function enableTelemetry(): self { @@ -1168,11 +1161,11 @@ public function enableTelemetry(): self } /** - * Best-effort beacon пре-флайт ошибки. Никогда не бросает и не влияет на платёжный - * поток: no-op при выключенной телеметрии или заданном env-kill-switch; жёсткий - * таймаут 300 мс; шлёт только не-PII поля (sdk, php, error, method). - * @param string $code одна из ERR_* констант - * @param string $method имя метода или 'unknown' + * Best-effort pre-flight error beacon. Never throws and never affects the payment + * flow: a no-op when telemetry is disabled or the env kill switch is set; hard + * 300 ms timeout; sends only non-PII fields (sdk, php, error, method). + * @param string $code one of the ERR_* constants + * @param string $method method name or 'unknown' */ private function reportTelemetry(string $code, string $method): void { @@ -1192,14 +1185,14 @@ private function reportTelemetry(string $code, string $method): void try { $this->httpGet($this->telemetryUrl . '?' . $query, $this->fingerprintHeaders(), 300); } catch (\Throwable $e) { - // проглатываем — телеметрия не должна влиять на платёжный поток + // swallow — telemetry must not affect the payment flow } } /** - * Приводит float-параметры к локале-независимым десятичным строкам, чтобы подпись - * и URL запроса совпадали на PHP <8.0 (где (string)$float учитывает LC_NUMERIC и в - * локалях с запятой выдал бы "100,5"). Значения, не являющиеся float, проходят как есть. + * Converts float params to locale-independent decimal strings so the signature and + * request URL match on PHP <8.0 (where (string)$float honors LC_NUMERIC and would + * yield "100,5" in comma locales). Non-float values pass through unchanged. * @param array $params * @return array */ @@ -1215,10 +1208,10 @@ private static function stringifyFloats(array $params): array } /** - * Приводит float к локале-независимой десятичной строке без хвостовых нулей. - * (string) $float учитывает LC_NUMERIC на PHP <8.0 и в локалях с запятой выдал бы - * "100,5", ломая совпадение подписи и URL. Используется совместно getSignature() и - * stringifyFloats(), чтобы подпись и передаваемое значение имели одинаковый вид. + * Converts a float to a locale-independent decimal string without trailing zeros. + * (string) $float honors LC_NUMERIC on PHP <8.0 and would yield "100,5" in comma + * locales, breaking the signature/URL match. Shared by getSignature() and + * stringifyFloats() so the signature and the transmitted value look identical. */ private static function floatToString(float $value): string { @@ -1226,7 +1219,7 @@ private static function floatToString(float $value): string } /** - * Строит JSON-ответ об успехе, который Unitpay ожидает от обработчика. + * Builds the JSON success response that Unitpay expects from the handler. */ public function getSuccessHandlerResponse(string $message): string { @@ -1234,7 +1227,7 @@ public function getSuccessHandlerResponse(string $message): string } /** - * Строит JSON-ответ об ошибке, который Unitpay ожидает от обработчика. + * Builds the JSON error response that Unitpay expects from the handler. */ public function getErrorHandlerResponse(string $message): string { diff --git a/examples/accountInfo.php b/examples/accountInfo.php index 4da2897..e62b8db 100644 --- a/examples/accountInfo.php +++ b/examples/accountInfo.php @@ -3,10 +3,10 @@ header('Content-Type: text/html; charset=UTF-8'); /** - * Справочные вызовы уровня кабинета (только для чтения): баланс, комиссии, курсы валют, - * информация по BIN. Аутентифицируются ключом КАБИНЕТА + login, переданными явно, чтобы - * переопределить ключ проекта из конструктора. getMethodsAvailable — уровня проекта и - * использует ключ проекта (без login). Изменяющий данные offsetAdvance — в offsetAdvance.php. + * Read-only account-level reference calls: balance, commissions, currency rates, + * BIN info. They authenticate with the ACCOUNT key + login, passed explicitly to + * override the project key from the constructor. getMethodsAvailable is project-level + * and uses the project key (no login). The data-changing offsetAdvance is in offsetAdvance.php. * * @link https://help.unitpay.ru/api/balance * @link https://help.unitpay.ru/api/commissions @@ -22,18 +22,18 @@ $account = ['login' => $login, 'secretKey' => $accountSecretKey]; try { - // Баланс кабинета и сумма, доступная к выводу. + // Account balance and the amount available for withdrawal. var_dump($unitpay->api('getPartner', $account)->result ?? null); - // Комиссии эквайринга по проекту. + // Acquiring commissions for the project. var_dump($unitpay->api('getCommissions', $account + ['projectId' => $projectId])->result ?? null); var_dump($unitpay->api('getCurrencyCourses', $account)->result ?? null); - // BIN — первые 6 цифр номера карты. + // BIN — the first 6 digits of the card number. var_dump($unitpay->api('getBinInfo', $account + ['bin' => 424242])->result ?? null); - // Способы оплаты на проекте: ключ проекта, без login. + // Payment methods available on the project: project key, no login. var_dump($unitpay->api('getMethodsAvailable', ['projectId' => $projectId])->result ?? null); } catch (UnitpayExceptionInterface $e) { print 'SDK error: ' . $e->getMessage(); diff --git a/examples/config.php b/examples/config.php index 244240e..4964e62 100644 --- a/examples/config.php +++ b/examples/config.php @@ -1,18 +1,18 @@ $projectId, ]); - // Ответ initPayment бывает трёх типов: redirect, invoice, response. + // The initPayment response comes in three types: redirect, invoice, response. switch ($response->result->type ?? null) { case 'redirect': - // paymentId — в $response->result->paymentId, сохраните у себя. + // paymentId is in $response->result->paymentId; save it on your side. if (isset($response->result->redirectUrl)) { header('Location: ' . $response->result->redirectUrl); exit; @@ -44,7 +44,7 @@ break; case 'invoice': - // Помимо receiptUrl доступны $response->result->paymentId и ->invoiceId. + // Besides receiptUrl, $response->result->paymentId and ->invoiceId are available. if (isset($response->result->receiptUrl)) { header('Location: ' . $response->result->receiptUrl); exit; @@ -53,12 +53,12 @@ break; case 'response': - // Без перенаправления (например, рекуррентное списание); статус — в ->statusUrl. + // No redirect (e.g. a recurring charge); the status is in ->statusUrl. print $response->result->message ?? ''; break; default: - // Типа нет — как правило, ошибка уровня API. + // No type — usually an API-level error. if (isset($response->error->message)) { print 'Error: ' . $response->error->message; } else { @@ -66,6 +66,6 @@ } } } catch (UnitpayExceptionInterface $e) { - // Сбой на стороне SDK: сеть, отключённый allow_url_fopen, битый JSON и т.п. + // SDK-side failure: network, disabled allow_url_fopen, malformed JSON, etc. print 'SDK error: ' . $e->getMessage(); } diff --git a/examples/offsetAdvance.php b/examples/offsetAdvance.php index 547d684..e545451 100644 --- a/examples/offsetAdvance.php +++ b/examples/offsetAdvance.php @@ -3,9 +3,9 @@ header('Content-Type: text/html; charset=UTF-8'); /** - * Чек зачёта аванса (offsetAdvance): по ранней предоплате создаёт фискальный чек. - * ВНИМАНИЕ: вызов создаёт чек, это не справочный метод только для чтения. API уровня кабинета: - * аутентифицируется ключом КАБИНЕТА + login, переданными явно. + * Advance-offset receipt (offsetAdvance): creates a fiscal receipt for an earlier prepayment. + * WARNING: the call creates a receipt, it is not a read-only reference method. Account-level API: + * authenticates with the ACCOUNT key + login, passed explicitly. */ require_once __DIR__ . '/config.php'; diff --git a/examples/order.php b/examples/order.php index 292a4bc..84143cc 100644 --- a/examples/order.php +++ b/examples/order.php @@ -1,8 +1,8 @@ $login, 'secretKey' => $accountSecretKey]; -$transactionId = 'payout-1782'; // уникальный на вашей стороне +$transactionId = 'payout-1782'; // unique on your side try { - // Банки — участники СБП: memberId обязателен для выплат по СБП. + // SBP member banks: memberId is required for SBP payouts. $banks = $unitpay->api('getSbpBankList', $account); var_dump($banks->result ?? $banks->error ?? $banks); - // Создаём выплату получателю по СБП. + // Create a payout to the recipient via SBP. $response = $unitpay->api('massPayment', $account + [ 'transactionId' => $transactionId, 'sum' => 100, 'purse' => '79510000071', 'paymentType' => 'sbp', - 'memberId' => '100000000004', // из getSbpBankList; только для СБП + 'memberId' => '100000000004', // from getSbpBankList; SBP only ]); if (isset($response->result)) { $payoutId = $response->result->payoutId; $status = $response->result->status; // success | not_completed - // Позже — проверяем статус выплаты по вашему transactionId. + // Later — check the payout status by your transactionId. $info = $unitpay->api('massPaymentStatus', $account + ['transactionId' => $transactionId]); var_dump($info->result ?? $info->error ?? $info); } elseif (isset($response->error->message)) { diff --git a/examples/receipt.php b/examples/receipt.php index 47530e2..a41dfdc 100644 --- a/examples/receipt.php +++ b/examples/receipt.php @@ -3,13 +3,13 @@ header('Content-Type: text/html; charset=UTF-8'); /** - * Фискальный чек по 54-ФЗ: позиции описываются объектами CashItem и прикрепляются к - * платежу через setCashItems(). Чек уходит вместе со следующим вызовом form()/api() и - * очищается после успешного вызова. Чтобы покупатель получил чек, укажите его контакт - * (email и/или телефон) через setCustomerEmail()/setCustomerPhone(). + * 54-FZ fiscal receipt: line items are described by CashItem objects and attached to + * the payment via setCashItems(). The receipt goes out with the next form()/api() call + * and is cleared after a successful call. For the customer to receive the receipt, set + * their contact (email and/or phone) via setCustomerEmail()/setCustomerPhone(). * - * Справочники ставок НДС (NDS_*), предметов расчёта (PAYMENT_OBJECT_*), способов - * расчёта (PAYMENT_METHOD_*) и единиц измерения (MEASURE_*) — это константы CashItem. + * The dictionaries of VAT rates (NDS_*), payment objects (PAYMENT_OBJECT_*), payment + * methods (PAYMENT_METHOD_*) and units of measure (MEASURE_*) are CashItem constants. * * @link https://help.unitpay.ru/payments/create-payment */ @@ -20,8 +20,8 @@ $unitpay = new UnitPay($domain, $secretKey); -// Позиция 1: товар. Аргументы конструктора: name, count, price, nds, предмет расчёта, -// способ расчёта. С 2026 бэкенд фискализирует vat20 как 22% — выбирайте по реальному чеку. +// Line item 1: a commodity. Constructor args: name, count, price, nds, payment object, +// payment method. Since 2026 the backend fiscalizes vat20 as 22% — pick per the real receipt. $item = new CashItem( $itemName, 1, @@ -30,10 +30,10 @@ CashItem::PAYMENT_OBJECT_COMMODITY, CashItem::PAYMENT_METHOD_PAYMENT_FULL ); -// Необязательные поля сериализуются, только если заданы (напр. единица измерения): +// Optional fields are serialized only when set (e.g. unit of measure): $item->setMeasure(CashItem::MEASURE_ITEM); -// Позиция 2: услуга (доставка), без НДС. +// Line item 2: a service (delivery), no VAT. $delivery = new CashItem( 'Доставка', 1, @@ -44,7 +44,7 @@ ); try { - // Сумма платежа должна совпадать с суммой позиций чека: 900 + 150 = 1050. + // The payment sum must match the sum of the receipt line items: 900 + 150 = 1050. $response = $unitpay ->setCustomerEmail('customer@example.com') ->setCashItems([$item, $delivery]) @@ -57,7 +57,7 @@ 'projectId' => $projectId, ]); - // Тот же чек можно прикрепить и к платёжной форме: + // The same receipt can also be attached to the payment form: // $url = $unitpay->setCashItems([$item, $delivery]) // ->form($publicId, 1050, $orderId, $orderDesc, $orderCurrency); @@ -70,6 +70,6 @@ var_dump($response); } } catch (UnitpayExceptionInterface $e) { - // UnitpayValidationException, если имя позиции не в UTF-8 (json_encode вернёт false). + // UnitpayValidationException if a line-item name is not UTF-8 (json_encode returns false). print 'SDK error: ' . $e->getMessage(); } diff --git a/examples/refund.php b/examples/refund.php index 5902f1d..bb071f0 100644 --- a/examples/refund.php +++ b/examples/refund.php @@ -3,7 +3,7 @@ header('Content-Type: text/html; charset=UTF-8'); /** - * Возврат платежа (полный или частичный) + * Payment refund (full or partial) * * @link https://help.unitpay.ru/api/payment-refund */ @@ -16,7 +16,7 @@ try { $response = $unitpay->api('refundPayment', [ 'paymentId' => 3403575, - // 'sum' => 100, // необязательно: частичный возврат; для полного не указывайте + // 'sum' => 100, // optional: partial refund; omit for a full refund ]); if (isset($response->result->message)) { diff --git a/examples/subscriptions.php b/examples/subscriptions.php index e52390b..5957a97 100644 --- a/examples/subscriptions.php +++ b/examples/subscriptions.php @@ -3,7 +3,7 @@ header('Content-Type: text/html; charset=UTF-8'); /** - * Подписки: список, информация, закрытие + * Subscriptions: list, info, close * * @link https://help.unitpay.ru/api/subscription-list * @link https://help.unitpay.ru/api/subscription-info @@ -18,14 +18,14 @@ $subscriptionId = 12345; try { - // Активные подписки проекта (добавьте 'all' => 1, чтобы включить все статусы). + // The project's active subscriptions (add 'all' => 1 to include all statuses). $list = $unitpay->api('listSubscriptions', ['projectId' => $projectId]); var_dump($list->result ?? $list->error ?? $list); $info = $unitpay->api('getSubscription', ['subscriptionId' => $subscriptionId]); var_dump($info->result ?? $info->error ?? $info); - // Закрываем (прекращает списания, отвязывает карту — необратимо). + // Close it (stops charges, detaches the card — irreversible). $closed = $unitpay->api('closeSubscription', ['subscriptionId' => $subscriptionId]); if (isset($closed->result->message)) { print $closed->result->message; diff --git a/examples/twoStagePayment.php b/examples/twoStagePayment.php index 60c6692..06873b3 100644 --- a/examples/twoStagePayment.php +++ b/examples/twoStagePayment.php @@ -3,9 +3,9 @@ header('Content-Type: text/html; charset=UTF-8'); /** - * Двухстадийные платежи: confirm (списание) или cancel (разблокировка) - * заблокированных средств. Внимание: confirmPayment/cancelPayment возвращают - * `message` на верхнем уровне, а не `result->message`. + * Two-stage payments: confirm (capture) or cancel (release) held funds. + * Note: confirmPayment/cancelPayment return `message` at the top level, + * not `result->message`. * * @link https://help.unitpay.ru/api/confirm-payment * @link https://help.unitpay.ru/api/cancel-payment @@ -19,10 +19,10 @@ $paymentId = 3403575; try { - // Списываем заблокированные средства. + // Capture the held funds. $response = $unitpay->api('confirmPayment', ['paymentId' => $paymentId]); - // ...или разблокируем без списания. + // ...or release without capturing. // $response = $unitpay->api('cancelPayment', ['paymentId' => $paymentId]); if (isset($response->message)) { diff --git a/examples/webhook.php b/examples/webhook.php index d868d12..7c385b3 100644 --- a/examples/webhook.php +++ b/examples/webhook.php @@ -1,7 +1,7 @@ refreshAllowedIps()->getAllowedIps(); -// затем передавайте закэшированный список сюда, плюс свои IP (прокси/релей): +// then pass the cached list here, plus your own IPs (proxy/relay): // $unitpay->setAllowedIps($cachedIps)->addAllowedIps(['1.2.3.4']); -// Только для локальной отладки: доверяем 127.0.0.1, чтобы повторять вебхуки с этого хоста. -// addAllowedIps() добавляет его ПОВЕРХ списка Unitpay (setAllowedIps() заменил бы его). -// 127.0.0.1 по умолчанию не доверенный — за прокси на том же хосте REMOTE_ADDR равен -// 127.0.0.1 и обнулил бы проверку IP, — поэтому включайте это явным флагом и НИКОГДА -// не включайте в продакшене. +// Local debugging only: trust 127.0.0.1 to replay webhooks from this host. +// addAllowedIps() adds it ON TOP of the Unitpay list (setAllowedIps() would replace it). +// 127.0.0.1 is untrusted by default — behind a proxy on the same host REMOTE_ADDR equals +// 127.0.0.1 and would nullify the IP check — so enable this with an explicit flag and NEVER +// enable it in production. if (getenv('UNITPAY_DEBUG_LOCAL') === '1') { $unitpay->addAllowedIps(['127.0.0.1']); } try { - // Проверяем запрос (IP отправителя, подпись, поддерживаемый метод). + // Verify the request (sender IP, signature, supported method). $unitpay->checkHandlerRequest(); - // Читаем проверенный запрос из SDK (учитывает подменённый запрос, а не $_GET). + // Read the verified request from the SDK (honors the overridden request, not $_GET). $method = $unitpay->getHandlerMethod(); $params = $unitpay->getHandlerParams(); - // Очень важно: сверьте вебхук со своими данными заказа до завершения заказа. + // Very important: reconcile the webhook against your order data before completing the order. if ( ($params['orderSum'] ?? null) != $orderSum || ($params['orderCurrency'] ?? null) != $orderCurrency || @@ -50,29 +50,29 @@ switch ($method) { case 'check': - // 'check' — проверяем, что заказ можно оплатить (статус сервера, заказ в БД, ...). + // 'check' — verify the order can be paid (server status, order in the DB, ...). print $unitpay->getSuccessHandlerResponse('Check Success. Ready to pay.'); break; case 'pay': - // 'pay' — деньги получены; здесь завершаем заказ. + // 'pay' — money received; complete the order here. print $unitpay->getSuccessHandlerResponse('Pay Success'); break; case 'preauth': - // 'preauth' — двухстадийный платёж: деньги только ЗАБЛОКИРОВАНЫ, ещё не списаны. - // НЕ выдавайте товары/услуги здесь; ждите 'pay'. Подтвердите приём, чтобы - // уведомление не считалось неуспешным. + // 'preauth' — two-stage payment: funds are only HELD, not yet captured. + // Do NOT deliver goods/services here; wait for 'pay'. Acknowledge receipt so + // the notification is not treated as failed. print $unitpay->getSuccessHandlerResponse('Preauth received. Funds held, awaiting capture.'); break; case 'error': - // 'error' — произошла ошибка; залогируйте её. + // 'error' — an error occurred; log it. print $unitpay->getSuccessHandlerResponse('Error logged'); break; default: - // Неизвестный метод: не оставляем пустой ответ (Unitpay счёл бы его неуспехом - // без диагностики) — отдаём ошибку через общий catch ниже. + // Unknown method: do not leave an empty response (Unitpay would treat it as a + // failure with no diagnostics) — return an error via the shared catch below. throw new InvalidArgumentException('Unexpected handler method: ' . $method); } } catch (Exception $e) { - // Любая ошибка (неверная подпись, недопустимый IP, расхождение заказа) вернёт ошибку в Unitpay. + // Any error (wrong signature, disallowed IP, order mismatch) returns an error to Unitpay. print $unitpay->getErrorHandlerResponse($e->getMessage()); } diff --git a/tests/CashItemTest.php b/tests/CashItemTest.php index 9025297..f264228 100644 --- a/tests/CashItemTest.php +++ b/tests/CashItemTest.php @@ -78,7 +78,7 @@ public function testSetMarkQuantityStoresIntegerFraction(): void $this->assertSame(['numerator' => 1, 'denominator' => 3], $item->getMarkQuantity()); } - /** Нулевой знаменатель (или неположительная дробь) отклоняется, а не сохраняется молча. */ + /** A zero denominator (or a non-positive fraction) is rejected rather than silently stored. */ public function testSetMarkQuantityRejectsNonPositiveValues(): void { $item = new CashItem('X', 1, 1.0); @@ -87,7 +87,7 @@ public function testSetMarkQuantityRejectsNonPositiveValues(): void $item->setMarkQuantity(1, 0); } - /** Неположительный числитель отклоняется отдельной проверкой (не только знаменатель). */ + /** A non-positive numerator is rejected by a separate check (not just the denominator). */ public function testSetMarkQuantityRejectsNonPositiveNumerator(): void { $item = new CashItem('X', 1, 1.0); @@ -96,35 +96,35 @@ public function testSetMarkQuantityRejectsNonPositiveNumerator(): void $item->setMarkQuantity(0, 3); } - /** count должен быть положительным числом. */ + /** count must be a positive number. */ public function testConstructorRejectsNonPositiveCount(): void { $this->expectException(\InvalidArgumentException::class); new CashItem('X', 0, 10.0); } - /** price должен быть неотрицательным. */ + /** price must be non-negative. */ public function testConstructorRejectsNegativePrice(): void { $this->expectException(\InvalidArgumentException::class); new CashItem('X', 1, -5.0); } - /** Нечисловой count должен быть отклонён, а не проскочить проверку диапазона. */ + /** A non-numeric count must be rejected rather than slip past the range check. */ public function testConstructorRejectsNonNumericCount(): void { $this->expectException(\InvalidArgumentException::class); new CashItem('X', 'abc', 10.0); } - /** Нечисловой price должен быть отклонён, а не проскочить проверку диапазона. */ + /** A non-numeric price must be rejected rather than slip past the range check. */ public function testConstructorRejectsNonNumericPrice(): void { $this->expectException(\InvalidArgumentException::class); new CashItem('X', 1, 'xyz'); } - /** Числовые строки принимаются и нормализуются в int/float. */ + /** Numeric strings are accepted and normalized to int/float. */ public function testConstructorNormalizesNumericStrings(): void { $item = new CashItem('X', '3', '9.5'); @@ -133,7 +133,7 @@ public function testConstructorNormalizesNumericStrings(): void $this->assertSame(9.5, $item->getPrice()); } - /** Дробные количества (весовые/объёмные товары) сохраняются, а не усекаются до int. */ + /** Fractional quantities (weight/volume goods) are preserved rather than truncated to int. */ public function testConstructorPreservesFractionalCount(): void { $item = new CashItem('Cheese', 1.5, 500.0); diff --git a/tests/UnitPayAllowedIpsTest.php b/tests/UnitPayAllowedIpsTest.php index 59d5758..8879ab8 100644 --- a/tests/UnitPayAllowedIpsTest.php +++ b/tests/UnitPayAllowedIpsTest.php @@ -8,18 +8,18 @@ use PHPUnit\Framework\TestCase; /** - * Динамический белый список IP вебхуков: refreshAllowedIps() загружает опубликованный - * перечень (https:///ips/ips_webhooks.json), addAllowedIps() добавляет IP мерчанта - * поверх, и каждый путь безопасен для сбоев (никогда не опустошает список, не бросает исключений). + * Dynamic webhook IP allowlist: refreshAllowedIps() fetches the published feed + * (https:///ips/ips_webhooks.json), addAllowedIps() adds merchant IPs on top, + * and every path is fail-safe (never empties the list, never throws). */ final class UnitPayAllowedIpsTest extends TestCase { private const SECRET = 'secret'; - /** Один из встроенных адресов по умолчанию. */ + /** One of the built-in default addresses. */ private const DEFAULT_IP = '31.186.100.49'; /** - * Строит корректный подписанный вебхук 'pay'. + * Builds a valid signed 'pay' webhook. * * @return array{method: string, params: array} */ @@ -35,7 +35,7 @@ private function validRequest(): array return ['method' => 'pay', 'params' => $params]; } - /** Обработчик, транспорт которого возвращает фиксированное тело для любого URL. */ + /** A handler whose transport returns a fixed body for any URL. */ private function handler(string $feedBody, string $ip): UnitPay { return $this->handlerWithTransport(static function () use ($feedBody) { @@ -43,7 +43,7 @@ private function handler(string $feedBody, string $ip): UnitPay }, $ip); } - /** Обработчик с заданным транспортом (для эмуляции сбоев / перехвата URL). */ + /** A handler with a given transport (to simulate failures / capture the URL). */ private function handlerWithTransport(callable $transport, string $ip): UnitPay { return new UnitPay('unitpay.ru', self::SECRET, $transport, $this->validRequest(), $ip); @@ -57,11 +57,11 @@ private function feed(array $ips): string return json_encode(['webhooks' => $ips]); } - // --- семантика замены ---------------------------------------------------- + // --- replace semantics ----------------------------------------------- public function testFetchedIpNotInDefaultBecomesAllowed(): void { - $ip = '203.0.113.7'; // TEST-NET-3, нет в списке по умолчанию + $ip = '203.0.113.7'; // TEST-NET-3, not in the default list $unitPay = $this->handler($this->feed([$ip]), $ip); $this->assertTrue($unitPay->refreshAllowedIps()->checkHandlerRequest()); @@ -69,7 +69,7 @@ public function testFetchedIpNotInDefaultBecomesAllowed(): void public function testDefaultIpDroppedByFetchIsRejected(): void { - // Перечень больше не содержит встроенный адрес → он должен перестать быть доверенным. + // The feed no longer contains the built-in address → it must stop being trusted. $unitPay = $this->handler($this->feed(['203.0.113.7']), self::DEFAULT_IP); $unitPay->refreshAllowedIps(); @@ -77,12 +77,12 @@ public function testDefaultIpDroppedByFetchIsRejected(): void $unitPay->checkHandlerRequest(); } - // --- безопасность при сбоях (откат к встроенному списку) ----------------- + // --- fail-safety (fall back to the built-in list) -------------------- public function testTransportFailureKeepsBuiltinList(): void { $unitPay = $this->handlerWithTransport(static function () { - return false; // сбой транспорта + return false; // transport failure }, self::DEFAULT_IP); $this->assertTrue($unitPay->refreshAllowedIps()->checkHandlerRequest()); @@ -114,16 +114,16 @@ public function testAllInvalidEntriesKeepBuiltinList(): void $unitPay = $this->handler($this->feed(['garbage', '999.999.999.999']), self::DEFAULT_IP); $this->assertTrue($unitPay->refreshAllowedIps()->checkHandlerRequest()); - // запасной список сохранил адреса по умолчанию, мусор не сохранён + // the fallback list kept the default addresses, junk was not stored $this->assertContains(self::DEFAULT_IP, $unitPay->getAllowedIps()); $this->assertNotContains('garbage', $unitPay->getAllowedIps()); } - // --- добавления мерчанта поверх ------------------------------------------ + // --- merchant additions on top --------------------------------------- public function testCustomIpSurvivesRefresh(): void { - $customIp = '198.51.100.5'; // TEST-NET-2, собственный релей мерчанта + $customIp = '198.51.100.5'; // TEST-NET-2, the merchant's own relay $unitPay = $this->handler($this->feed(['203.0.113.7']), $customIp); $unitPay->addAllowedIps([$customIp])->refreshAllowedIps(); @@ -131,7 +131,7 @@ public function testCustomIpSurvivesRefresh(): void $this->assertTrue($unitPay->checkHandlerRequest()); } - // --- URL перечня --------------------------------------------------------- + // --- feed URL -------------------------------------------------------- public function testRefreshFetchesTheCanonicalFeedUrl(): void { @@ -146,7 +146,7 @@ public function testRefreshFetchesTheCanonicalFeedUrl(): void $this->assertSame('https://unitpay.ru/ips/ips_webhooks.json', $captured); } - // --- CIDR из перечня ----------------------------------------------------- + // --- CIDR from the feed ---------------------------------------------- public function testCidrRangeFromFeedIsHonoured(): void { @@ -155,7 +155,7 @@ public function testCidrRangeFromFeedIsHonoured(): void $this->assertTrue($unitPay->refreshAllowedIps()->checkHandlerRequest()); } - // --- фильтрация мусора --------------------------------------------------- + // --- junk filtering -------------------------------------------------- public function testValidEntriesAppliedAndJunkDropped(): void { @@ -183,22 +183,22 @@ public function testGetAllowedIpsDefaultsToBuiltinList(): void $this->assertSame(['31.186.100.49', '51.250.20.9'], $unitPay->getAllowedIps()); } - // --- сброс кэша сопоставления -------------------------------------------- + // --- matcher cache reset --------------------------------------------- public function testAddAllowedIpsInvalidatesTheMatcherCache(): void { $customIp = '198.51.100.5'; $unitPay = $this->handler($this->feed([self::DEFAULT_IP]), $customIp); - // Первая проверка строит и кэширует сопоставление без добавленного IP → отклонение. + // The first check builds and caches the matcher without the added IP → rejection. try { $unitPay->checkHandlerRequest(); $this->fail('expected the custom IP to be rejected before it is added'); } catch (UnitpayIpException $e) { - // ожидаемо + // expected } - // Добавление IP должно сбросить кэш сопоставления, чтобы следующая проверка его увидела. + // Adding the IP must reset the matcher cache so the next check sees it. $unitPay->addAllowedIps([$customIp]); $this->assertTrue($unitPay->checkHandlerRequest()); } diff --git a/tests/UnitPayApiTest.php b/tests/UnitPayApiTest.php index e9bc38f..6158348 100644 --- a/tests/UnitPayApiTest.php +++ b/tests/UnitPayApiTest.php @@ -56,7 +56,7 @@ public function testRequestUrlUsesFlatParamsNotNested(): void $unitPay->api('getPayment', ['paymentId' => 555]); - // Unitpay принимает плоские параметры строки запроса с 05/2026 — без устаревшей вложенности params[...]. + // Unitpay accepts flat query-string params since 05/2026 — no legacy params[...] nesting. $this->assertStringContainsString('paymentId=555', $captured); $this->assertStringContainsString('secretKey=my-secret', $captured); $this->assertStringNotContainsString('params%5B', $captured); @@ -87,9 +87,9 @@ public function testPayoutRequestUrlUsesFlatParams(): void } /** - * Параметры, накопленные fluent-сеттерами (setCashItems/setCustomerEmail/…), - * должны попадать в запрос api(), а не только в form(). Защита от регресса: раньше - * api() строил URL только из аргумента $params и молча их терял. + * Params accumulated by the fluent setters (setCashItems/setCustomerEmail/…) must + * reach the api() request, not just form(). Regression guard: api() used to build the + * URL only from the $params argument and silently drop them. */ public function testCashItemsFromSetterAreSentByApi(): void { @@ -117,7 +117,7 @@ public function testCashItemsFromSetterAreSentByApi(): void $this->assertSame('Coffee', $items[0]['name']); } - /** Явные параметры api() имеют приоритет над всем, что задано fluent-сеттерами. */ + /** Explicit api() params take precedence over anything set by the fluent setters. */ public function testExplicitApiParamOverridesAccumulatedParam(): void { $captured = null; @@ -141,9 +141,9 @@ public function testExplicitApiParamOverridesAccumulatedParam(): void } /** - * Параметры fluent-сеттеров очищаются успешным вызовом api() и не должны протекать - * в следующий вызов на повторно используемом экземпляре (регресс: устаревший чек - * cashItems или customerEmail иначе ушёл бы с несвязанным поздним заказом). + * Fluent-setter params are cleared by a successful api() call and must not leak into + * the next call on a reused instance (regression: a stale cashItems receipt or + * customerEmail would otherwise go out with an unrelated later order). */ public function testFluentSetterParamsDoNotBleedIntoNextApiCall(): void { @@ -163,7 +163,7 @@ public function testFluentSetterParamsDoNotBleedIntoNextApiCall(): void 'paymentType' => 'card', ]); - // Второй вызов, без повторной установки чека/покупателя, должен быть чистым. + // The second call, without re-setting the receipt/customer, must be clean. $unitPay->api('getPayment', ['paymentId' => 555]); $this->assertStringContainsString('cashItems=', $urls[0]); @@ -172,14 +172,15 @@ public function testFluentSetterParamsDoNotBleedIntoNextApiCall(): void } /** - * Параметры fluent-сеттеров очищаются только УСПЕШНЫМ вызовом api(). После сбоя - * транспорта они сохраняются, чтобы повтор ушёл с тем же чеком, а не молча без него. + * Fluent-setter params are cleared only by a SUCCESSFUL api() call. After a transport + * failure they are retained, so a retry goes out with the same receipt rather than + * silently without it. */ public function testFluentSetterParamsAreRetainedAfterFailedApiCall(): void { $urls = []; $calls = 0; - // Первый вызов имитирует сбой транспорта (false), последующие — успех. + // The first call simulates a transport failure (false), later ones succeed. $transport = static function ($url) use (&$urls, &$calls) { $urls[] = $url; $calls++; @@ -192,12 +193,12 @@ public function testFluentSetterParamsAreRetainedAfterFailedApiCall(): void $unitPay->api('getPayment', ['paymentId' => 1]); $this->fail('expected a transport exception on the first call'); } catch (\UnitpayTransportException $e) { - // ожидаемо: транспорт вернул false + // expected: the transport returned false } $unitPay->api('getPayment', ['paymentId' => 2]); - // Чек уцелел после сбоя и ушёл с повтором. + // The receipt survived the failure and went out with the retry. $this->assertStringContainsString('cashItems=', $urls[0]); $this->assertStringContainsString('cashItems=', $urls[1]); } @@ -232,7 +233,7 @@ public function testMissingRequiredParamThrows(): void }); $this->expectException(InvalidArgumentException::class); - // initPayment требует account, sum, projectId, paymentType + // initPayment requires account, sum, projectId, paymentType $unitPay->api('initPayment', ['account' => 1]); } @@ -269,17 +270,17 @@ public function testPayoutMethodsAreSupportedAndValidateRequiredParams(): void } catch (UnexpectedValueException $e) { $this->fail($method . ' is not in the allowlist'); } catch (InvalidArgumentException $e) { - // каждый метод выплат сначала требует login + // each payout method requires login first $this->assertStringContainsString('login', $e->getMessage()); } } } - /** Сбой транспорта — типизированное исключение, всё ещё перехватываемое как InvalidArgumentException. */ + /** A transport failure is a typed exception, still catchable as InvalidArgumentException. */ public function testTransportFailureThrowsTypedTransportException(): void { $unitPay = new UnitPay('unitpay.test', 'secret', static function () { - return false; // эмулируем сбой транспорта + return false; // simulate a transport failure }); try { @@ -291,7 +292,7 @@ public function testTransportFailureThrowsTypedTransportException(): void } } - /** Неподдерживаемый метод бросает типизированное исключение, всё ещё перехватываемое как UnexpectedValueException. */ + /** An unsupported method throws a typed exception, still catchable as UnexpectedValueException. */ public function testUnsupportedMethodThrowsTypedException(): void { $unitPay = new UnitPay('unitpay.test', 'secret', static function () { @@ -306,7 +307,7 @@ public function testUnsupportedMethodThrowsTypedException(): void } } - /** Методы уровня аккаунта могут переопределить ключ проекта ключом аккаунта (secretKey). */ + /** Account-level methods can override the project key with the account key (secretKey). */ public function testExplicitSecretKeyOverridesInstanceKey(): void { $captured = null; diff --git a/tests/UnitPayCashItemsTest.php b/tests/UnitPayCashItemsTest.php index a5616bd..1bd3abe 100644 --- a/tests/UnitPayCashItemsTest.php +++ b/tests/UnitPayCashItemsTest.php @@ -9,8 +9,8 @@ final class UnitPayCashItemsTest extends TestCase { /** - * setCashItems() хранит в params base64(json(...)); единственный публичный способ - * прочитать это обратно — через строку запроса формы, поэтому декодируем оттуда. + * setCashItems() stores base64(json(...)) in params; the only public way to read + * it back is via the form's query string, so we decode it from there. * * @return array> */ @@ -102,8 +102,8 @@ public function testMultipleItemsKeepTheirOrder(): void } /** - * Имя не в UTF-8 (например, из Windows-1251) обрушивает json_encode; setCashItems() - * бросает исключение вместо тихой отправки пустого чека. + * A non-UTF-8 name (e.g. from Windows-1251) breaks json_encode; setCashItems() + * throws instead of silently sending an empty receipt. */ public function testSetCashItemsThrowsOnNonUtf8Name(): void { diff --git a/tests/UnitPayFloatTest.php b/tests/UnitPayFloatTest.php index 0751f8a..4c6a2e6 100644 --- a/tests/UnitPayFloatTest.php +++ b/tests/UnitPayFloatTest.php @@ -6,10 +6,10 @@ use PHPUnit\Framework\TestCase; /** - * Локале-независимая обработка float в подписи и URL (getSignature() ветка is_float, - * floatToString(), stringifyFloats()). Инвариант: подпись строится над теми же - * десятичными строками, что уходят в строку запроса, поэтому проверка на бэкенде совпадает - * даже в локали с запятой как десятичным разделителем. + * Locale-independent float handling in the signature and URL (the is_float branch of + * getSignature(), floatToString(), stringifyFloats()). Invariant: the signature is built + * over the same decimal strings that go into the query string, so backend verification + * matches even in a locale that uses a comma as the decimal separator. */ final class UnitPayFloatTest extends TestCase { @@ -38,7 +38,7 @@ public function testSignatureRendersFloatAsCanonicalDecimalString(): void ); } - /** Целый float («100.0») даёт «100» — то же, что каноническая строка, поэтому подпись совпадает независимо от типа. */ + /** A whole float ("100.0") yields "100" — the same as the canonical string, so the signature matches regardless of type. */ public function testWholeFloatMatchesCanonicalStringSignature(): void { $this->assertSame( @@ -54,7 +54,7 @@ public function testFormRendersFloatSumAsCanonicalDecimalString(): void $this->assertSame('100.5', $q['sum']); } - /** Хвостовой ноль убирается: 100.0 в строке запроса становится «100», а не «100.00000000». */ + /** The trailing zero is stripped: 100.0 becomes "100" in the query string, not "100.00000000". */ public function testFormStripsTrailingZeroFromWholeFloatSum(): void { $q = $this->queryOf($this->unitPay->form('pk', 100.0, 'acc', 'desc')); @@ -63,9 +63,9 @@ public function testFormStripsTrailingZeroFromWholeFloatSum(): void } /** - * Ключевой инвариант: подпись формы построена над той же строкой sum, что уходит в - * строку запроса. Регресс здесь (подписали float, отправили другое строковое представление) - * сломал бы проверку подписи на бэкенде для любой дробной суммы. + * Key invariant: the form signature is built over the same sum string that goes into + * the query string. A regression here (signing the float, sending a different string + * representation) would break backend signature verification for any fractional sum. */ public function testFormSignatureCoversTheExactStringSumSentInQuery(): void { diff --git a/tests/UnitPayFormTest.php b/tests/UnitPayFormTest.php index 7ee6ade..f4e2f63 100644 --- a/tests/UnitPayFormTest.php +++ b/tests/UnitPayFormTest.php @@ -86,8 +86,8 @@ public function testChainedSettersLandInTheFormUrl(): void } /** - * form() очищает накопленные сеттерами параметры, поэтому повторно используемый - * экземпляр не переносит backUrl/чек/покупателя предыдущего заказа в следующий вызов. + * form() clears the setter-accumulated params, so a reused instance does not carry + * the previous order's backUrl/receipt/customer into the next call. */ public function testFormClearsAccumulatedParamsAfterCall(): void { @@ -103,7 +103,7 @@ public function testFormClearsAccumulatedParamsAfterCall(): void $this->assertArrayNotHasKey('customerEmail', $second); } - /** Подпись формы должна покрывать ТОЛЬКО четыре ключевых параметра, а не параметры сеттеров. */ + /** The form signature must cover ONLY the four vital params, not the setter params. */ public function testFormSignatureExcludesSetterParams(): void { $unitPay = new UnitPay('unitpay.ru', 'secret'); @@ -122,8 +122,8 @@ public function testFormSignatureExcludesSetterParams(): void } /** - * Слой A: form() добавляет машиночитаемый токен фингерпринта sdk (URL-safe, - * major.minor PHP) — и он НЕ меняет подпись (стоит вне подписываемых параметров). + * Layer A: form() adds a machine-readable sdk fingerprint token (URL-safe, + * PHP major.minor) — and it does NOT change the signature (it sits outside the signed params). */ public function testFormCarriesSdkTokenWithoutBreakingSignature(): void { @@ -135,7 +135,7 @@ public function testFormCarriesSdkTokenWithoutBreakingSignature(): void 'php_' . UnitPay::VERSION . '_' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION, $q['sdk'] ); - // URL-safe: токен появляется в готовом URL дословно, без %-кодирования. + // URL-safe: the token appears in the final URL verbatim, without %-encoding. $this->assertStringContainsString('sdk=php_', $url); $expected = (new UnitPay('unitpay.test', 'secret'))->getSignature([ diff --git a/tests/UnitPayHandlerTest.php b/tests/UnitPayHandlerTest.php index 576277d..e479643 100644 --- a/tests/UnitPayHandlerTest.php +++ b/tests/UnitPayHandlerTest.php @@ -13,10 +13,10 @@ final class UnitPayHandlerTest extends TestCase public const ALLOWED_IP = '31.186.100.49'; /** - * Строит массив вебхука с корректной подписью его параметров. + * Builds a webhook array with a valid signature over its params. * * @param string $method - * @param array $overrides параметры для добавления/переопределения перед подписью + * @param array $overrides params to add/override before signing * @return array{method: string, params: array} */ private function validRequest(string $method = 'pay', array $overrides = []): array @@ -59,7 +59,7 @@ public function testValidSignatureAndAllowedIpPass(): void public function testTamperedParamsAreRejected(): void { $request = $this->validRequest('pay'); - $request['params']['orderSum'] = '0.01'; // изменено после подписи + $request['params']['orderSum'] = '0.01'; // changed after signing $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Wrong signature'); @@ -102,9 +102,9 @@ public function testUnsupportedPartnerMethodIsRejected(): void } /** - * Уведомление о двухстадийной блокировке (preauth) — валидный метод вебхука, - * который шлёт Unitpay (method = check | pay | preauth | error). Должно проходить - * проверку, а не отклоняться как неподдерживаемое. + * A two-stage hold notification (preauth) is a valid webhook method that Unitpay + * sends (method = check | pay | preauth | error). It must pass verification rather + * than be rejected as unsupported. */ public function testPreauthPartnerMethodIsSupported(): void { @@ -117,8 +117,8 @@ public function testPreauthPartnerMethodIsSupported(): void } /** - * Нестроковая подпись (например, массив, подсунутый через $_GET) должна быть - * аккуратно отклонена как "Wrong signature", а не приводить к TypeError. + * A non-string signature (e.g. an array injected via $_GET) must be cleanly rejected + * as "Wrong signature" rather than cause a TypeError. */ public function testArraySignatureIsRejectedCleanly(): void { @@ -131,8 +131,8 @@ public function testArraySignatureIsRejectedCleanly(): void } /** - * Подделанный params[PHP_INT_MAX] не должен ломать проверку и должен проходить - * её корректно (ключ убирается и при подписи, и при проверке). + * A forged params[PHP_INT_MAX] must not break verification and must pass it correctly + * (the key is stripped both when signing and when verifying). */ public function testPhpIntMaxKeyInParamsDoesNotBreakVerification(): void { @@ -151,14 +151,14 @@ public function testPhpIntMaxKeyInParamsDoesNotBreakVerification(): void public function testSetAllowedIpsOverridesTheDefaultAllowlist(): void { - $customIp = '203.0.113.7'; // TEST-NET-3, нет в списке по умолчанию + $customIp = '203.0.113.7'; // TEST-NET-3, not in the default list $unitPay = $this->handler($this->validRequest('pay'), $customIp); $unitPay->setAllowedIps([$customIp]); $this->assertTrue($unitPay->checkHandlerRequest()); } - /** 127.0.0.1 по умолчанию НЕ доверенный: за прокси на том же хосте он обнулил бы проверку IP. */ + /** 127.0.0.1 is NOT trusted by default: behind a proxy on the same host it would nullify the IP check. */ public function testLocalhostIsRejectedByDefault(): void { $unitPay = $this->handler($this->validRequest('pay'), '127.0.0.1'); @@ -167,7 +167,7 @@ public function testLocalhostIsRejectedByDefault(): void $unitPay->checkHandlerRequest(); } - /** setAllowedIps принимает CIDR-подсети, а не только точные IP. */ + /** setAllowedIps accepts CIDR subnets, not just exact IPs. */ public function testCidrAllowlistMatchesAddressInRange(): void { $unitPay = $this->handler($this->validRequest('pay'), '203.0.113.55'); @@ -185,7 +185,7 @@ public function testCidrAllowlistRejectsAddressOutOfRange(): void $unitPay->checkHandlerRequest(); } - /** Сопоставление CIDR работает и для IPv6 (бинарное сравнение через inet_pton). */ + /** CIDR matching works for IPv6 too (binary comparison via inet_pton). */ public function testCidrAllowlistMatchesIpv6InRange(): void { $unitPay = $this->handler($this->validRequest('pay'), '2001:db8::1'); @@ -194,7 +194,7 @@ public function testCidrAllowlistMatchesIpv6InRange(): void $this->assertTrue($unitPay->checkHandlerRequest()); } - /** До первой успешной проверки геттеры проверенных данных возвращают null. */ + /** Before the first successful verification, the verified-data getters return null. */ public function testHandlerGettersAreNullBeforeVerification(): void { $unitPay = $this->handler($this->validRequest('pay')); @@ -203,7 +203,7 @@ public function testHandlerGettersAreNullBeforeVerification(): void $this->assertNull($unitPay->getHandlerParams()); } - /** После успешной проверки getHandlerParams() отдаёт именно проверенные параметры вебхука. */ + /** After a successful verification, getHandlerParams() returns exactly the verified webhook params. */ public function testGetHandlerParamsReturnsVerifiedParams(): void { $request = $this->validRequest('pay'); @@ -214,11 +214,11 @@ public function testGetHandlerParamsReturnsVerifiedParams(): void $this->assertSame('42', $unitPay->getHandlerParams()['account']); } - /** Типизированное исключение, всё ещё наследующее исторический SPL-тип + маркерный интерфейс. */ + /** A typed exception that still extends the historical SPL type + the marker interface. */ public function testSignatureFailureThrowsTypedExceptionStillCatchableAsInvalidArgument(): void { $request = $this->validRequest('pay'); - $request['params']['orderSum'] = '0.01'; // изменено после подписи + $request['params']['orderSum'] = '0.01'; // changed after signing try { $this->handler($request)->checkHandlerRequest(); diff --git a/tests/UnitPayPaymentTypeTest.php b/tests/UnitPayPaymentTypeTest.php index 18c1844..a28812b 100644 --- a/tests/UnitPayPaymentTypeTest.php +++ b/tests/UnitPayPaymentTypeTest.php @@ -6,9 +6,9 @@ use UnitPay; /** - * Константы PAYMENT_TYPE_* должны оставаться в синхронизации с опубликованными кодами - * способов оплаты Unitpay: https://help.unitpay.ru/book-of-reference/payment-system-codes - * Устаревшие коды (qiwi, yandex, mc, alfaClick) намеренно отсутствуют. + * The PAYMENT_TYPE_* constants must stay in sync with Unitpay's published payment + * method codes: https://help.unitpay.ru/book-of-reference/payment-system-codes + * Deprecated codes (qiwi, yandex, mc, alfaClick) are deliberately absent. */ final class UnitPayPaymentTypeTest extends TestCase { @@ -23,7 +23,7 @@ public function testPaymentTypeConstantsMatchPublishedCodes(): void $this->assertSame('webmoney', UnitPay::PAYMENT_TYPE_WEBMONEY); } - /** Константа способа оплаты принимается как есть в качестве paymentType для initPayment. */ + /** A payment method constant is accepted as-is as the paymentType for initPayment. */ public function testConstantIsUsableAsInitPaymentType(): void { $captured = null; diff --git a/tests/UnitPaySignatureTest.php b/tests/UnitPaySignatureTest.php index 148e43c..13016ff 100644 --- a/tests/UnitPaySignatureTest.php +++ b/tests/UnitPaySignatureTest.php @@ -17,7 +17,7 @@ protected function setUp(): void public function testSignatureMatchesDocumentedFormula(): void { - // sha256( <значения, отсортированные ksort>{up}secretKey ) + // sha256( {up}secretKey ) $this->assertSame( hash('sha256', '1{up}secret'), $this->unitPay->getSignature(['a' => '1']) @@ -33,10 +33,9 @@ public function testSignatureIsIndependentOfKeyOrder(): void } /** - * Фиксирует НАПРАВЛЕНИЕ сортировки конкретным значением: ksort сортирует по ключу - * по возрастанию, поэтому ключи c,a,b дают значения 1,2,3. Рефакторинг на - * krsort/asort изменил бы этот хэш и сломал бы каждую боевую подпись с несколькими - * параметрами — этот тест такое поймает. + * Pins the sort DIRECTION with a concrete value: ksort sorts by key ascending, so + * keys c,a,b yield values 1,2,3. Refactoring to krsort/asort would change this hash + * and break every production signature with multiple params — this test catches that. */ public function testSignaturePinsAscendingKeyOrder(): void { @@ -67,10 +66,10 @@ public function testCallerSuppliedSignatureKeysAreStripped(): void } /** - * Регресс-тест: подделанный params[PHP_INT_MAX] должен быть убран, чтобы не - * вытеснить автоматически добавляемый secretKey из хэша (подделываемая подпись на - * PHP <8, фатальная Error на PHP >=8). Не должен бросать исключение, а полученная - * подпись должна совпадать с подписью без вредоносного ключа. + * Regression test: a forged params[PHP_INT_MAX] must be stripped so it cannot push the + * automatically appended secretKey out of the hash (forgeable signature on PHP <8, a + * fatal Error on PHP >=8). It must not throw, and the resulting signature must match + * the signature without the malicious key. */ public function testPhpIntMaxKeyIsStrippedAndSecretRetained(): void { @@ -81,9 +80,9 @@ public function testPhpIntMaxKeyIsStrippedAndSecretRetained(): void } /** - * Подсунутое значение-массив (например, вебхук params[x][]=1) не должно вызывать - * предупреждение "Array to string conversion"; массив приводится к '', и проверка - * просто не совпадает с легитимной подписью. + * An injected array value (e.g. a webhook params[x][]=1) must not raise an + * "Array to string conversion" warning; the array is coerced to '', and verification + * simply does not match a legitimate signature. */ public function testArrayValuedParamDoesNotEmitWarning(): void { @@ -96,7 +95,7 @@ public function testArrayValuedParamDoesNotEmitWarning(): void restore_error_handler(); } - // '' подставлено вместо массива, поэтому совпадает с параметром с пустым значением. + // '' is substituted for the array, so it matches a param with an empty value. $this->assertSame( $this->unitPay->getSignature(['a' => ''], 'pay'), $signature diff --git a/tests/UnitPayTelemetryTest.php b/tests/UnitPayTelemetryTest.php index d4d0c7e..5f99a03 100644 --- a/tests/UnitPayTelemetryTest.php +++ b/tests/UnitPayTelemetryTest.php @@ -8,7 +8,7 @@ final class UnitPayTelemetryTest extends TestCase { /** - * Транспорт-шпион: записывает url/headers/timeoutMs каждого вызова httpGet. + * Spy transport: records url/headers/timeoutMs of every httpGet call. * @param array,timeoutMs:int|null}> $calls * @return callable */ @@ -56,7 +56,7 @@ public function testTelemetryDisabledByDefaultSendsNoBeacon(): void { $calls = []; $unitPay = new UnitPay('unitpay.test', 'secret', $this->spy($calls)); - // Пре-флайт ошибка (нет обязательных параметров) — реальный запрос не ушёл; телеметрия выключена. + // Pre-flight error (missing required params) — the real request never went out; telemetry is off. try { $unitPay->api('initPayment', ['account' => 1]); } catch (\Throwable $e) { @@ -82,7 +82,7 @@ public function testEnabledTelemetryFiresBeaconWithFieldsAndShortTimeout(): void $this->assertSame('ERR_MISSING_REQUIRED_PARAM', $q['error']); $this->assertSame('initPayment', $q['method']); $this->assertSame(300, $calls[0]['timeoutMs']); - // Граница анонимности: секрет не утекает в beacon. + // Anonymity boundary: the secret does not leak into the beacon. $this->assertStringNotContainsString('secret', $calls[0]['url']); } @@ -139,7 +139,7 @@ public function testMissingMethodHandlerEmitsUnknownMethod(): void public function testApiUnreachableEmitsNoBeacon(): void { $calls = []; - // Транспорт фейлит реальный запрос (false); любой beacon тоже попал бы в $calls. + // The transport fails the real request (false); any beacon would also land in $calls. $transport = static function ($url, $headers = [], $timeoutMs = null) use (&$calls) { $calls[] = $url; return false; @@ -151,7 +151,7 @@ public function testApiUnreachableEmitsNoBeacon(): void } catch (\Throwable $e) { } - // Ровно один вызов — реальный api-запрос; beacon ERR_API_UNREACHABLE НЕ отправлен (тот же хост). + // Exactly one call — the real api request; the ERR_API_UNREACHABLE beacon is NOT sent (same host). $this->assertCount(1, $calls); $this->assertStringNotContainsString('/sdk/telemetry', $calls[0]); } @@ -163,8 +163,8 @@ public function testTelemetryFailureNeverPropagates(): void }; $unitPay = new UnitPay('unitpay.test', 'secret', $throwing); $unitPay->enableTelemetry(); - // Пре-флайт ошибка + падающий beacon-транспорт: наружу выходит доменное исключение, - // а не RuntimeException из телеметрии. + // Pre-flight error + a throwing beacon transport: the domain exception surfaces, + // not the RuntimeException from telemetry. $this->expectException(\UnitpayValidationException::class); $unitPay->api('initPayment', ['account' => 1]); } diff --git a/tests/UnitpayIpAllowlistTest.php b/tests/UnitpayIpAllowlistTest.php index 918b027..f1707ab 100644 --- a/tests/UnitpayIpAllowlistTest.php +++ b/tests/UnitpayIpAllowlistTest.php @@ -6,10 +6,10 @@ use PHPUnit\Framework\TestCase; /** - * Прямые тесты сопоставления IP с белым списком. contains() критичен для безопасности, но до - * сих пор проверялся только косвенно через checkHandlerRequest(); здесь фиксируем - * граничные случаи (несовпадение семейств адресов, чрезмерная длина префикса, - * некорректный клиентский IP), которые трудно выразить через полный путь обработчика. + * Direct tests of IP-to-allowlist matching. contains() is security-critical but has so + * far been exercised only indirectly through checkHandlerRequest(); here we pin down the + * edge cases (address-family mismatch, oversized prefix length, malformed client IP) + * that are hard to express through the full handler path. */ final class UnitpayIpAllowlistTest extends TestCase { @@ -44,8 +44,8 @@ public function testAddressInsideIpv6CidrMatches(): void } /** - * Точная запись IPv6 матчится вне зависимости от текстовой формы (регистр, сжатие): - * сравнение идёт по упакованному in_addr, а не по строке. + * An exact IPv6 entry matches regardless of textual form (case, compression): + * the comparison is over the packed in_addr, not the string. */ public function testExactIpv6MatchesRegardlessOfTextualForm(): void { @@ -56,15 +56,15 @@ public function testExactIpv6MatchesRegardlessOfTextualForm(): void $this->assertTrue($expanded->contains('2001:db8::1')); } - /** Некорректный клиентский IP не должен приводить к ложному совпадению. */ + /** A malformed client IP must not produce a false match. */ public function testInvalidClientIpDoesNotMatch(): void { $this->assertFalse($this->matcher()->contains('not-an-ip')); } /** - * IPv4-клиент против исключительно IPv6-подсети: inet_pton даёт in_addr разной - * длины, поэтому сравнение должно аккуратно провалиться, а не сматчиться по ошибке. + * An IPv4 client against an IPv6-only subnet: inet_pton yields in_addr of different + * lengths, so the comparison must fail cleanly rather than match by mistake. */ public function testIpv4ClientAgainstIpv6OnlySubnetDoesNotMatch(): void { @@ -73,7 +73,7 @@ public function testIpv4ClientAgainstIpv6OnlySubnetDoesNotMatch(): void $this->assertFalse($matcher->contains('203.0.113.55')); } - /** Префикс длиннее самого адреса (/33 для IPv4) не может сматчить ничего. */ + /** A prefix longer than the address itself (/33 for IPv4) cannot match anything. */ public function testPrefixWiderThanAddressDoesNotMatch(): void { $matcher = new UnitpayIpAllowlist(['203.0.113.0/33']); @@ -81,7 +81,7 @@ public function testPrefixWiderThanAddressDoesNotMatch(): void $this->assertFalse($matcher->contains('203.0.113.5')); } - /** Граница подсети /25: адрес выше верхней границы диапазона не попадает. */ + /** /25 subnet boundary: an address above the range's upper bound is not included. */ public function testCidrBoundaryIsRespected(): void { $matcher = new UnitpayIpAllowlist(['77.75.153.0/25']); From 71796a8482219a4cf31e1db982c10bb08d32e0b5 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Thu, 23 Jul 2026 22:49:46 +0300 Subject: [PATCH 21/30] =?UTF-8?q?refactor(telemetry):=20drop=20Layer=20B?= =?UTF-8?q?=20(opt-in=20beacon)=20=E2=80=94=20keep=20passive=20fingerprint?= =?UTF-8?q?=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handler pre-flight errors are already observable to Unitpay via the webhook error response it receives; api() pre-flight errors are dev-time integration mistakes. So the separate /sdk/telemetry endpoint, opt-in flag, reporter, env kill-switch, error codes and wire-in carried little value — removed. Keeps Layer A (User-Agent + X-Unitpay-Client on api(), sdk token in form()), matching the Stripe/AWS/MongoDB first-party piggyback pattern. Reverts the phpmd threshold bumps; folds the UA/client helpers into fingerprintHeaders. --- CHANGELOG.md | 2 +- README.md | 28 ++----- UnitPay.php | 118 ++++------------------------- phpmd.xml | 22 +----- tests/UnitPayTelemetryTest.php | 132 ++------------------------------- 5 files changed, 30 insertions(+), 272 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5446203..098c61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### v2.1.0 -* Телеметрия: пассивный анонимный фингерпринт версии (заголовки `User-Agent` и `X-Unitpay-Client` в `api()`, параметр `sdk` в URL `form()`) — самоидентификация SDK без дополнительных сетевых запросов и без PII; плюс опциональная (`enableTelemetry()`, по умолчанию выключена) best-effort отправка пре-флайт ошибок (неверная подпись, IP не в списке, нет обязательных параметров) на выводимый из `$domain` эндпоинт: шлёт только `sdk/php/error/method`, таймаут 300 мс, никогда не влияет на платёжный поток; глушится переменной окружения `UNITPAY_SDK_TELEMETRY_DISABLE=1`. Добавлена константа `UnitPay::VERSION` +* Телеметрия: пассивный анонимный фингерпринт версии (заголовки `User-Agent` и `X-Unitpay-Client` в `api()`, параметр `sdk` в URL `form()`) — самоидентификация SDK без дополнительных сетевых запросов и без PII; отдельного эндпоинта телеметрии нет. Добавлена константа `UnitPay::VERSION` * `CashItem`: справочники 54-ФЗ синхронизированы с бэкендом: * Добавлены ставки НДС: vat5, vat7, vat22 и расчётные vat105, vat107, vat110, vat120, vat122 * Добавлены признаки предмета расчёта: payment_2, deposit, expense, pension_insurance_ip, pension_insurance, medical_insurance_ip, medical_insurance, social_insurance, casino_payment, issuance_bank, commodity_without_mark, commodity_mark diff --git a/README.md b/README.md index eb414d2..d9f7918 100644 --- a/README.md +++ b/README.md @@ -347,34 +347,18 @@ Note: `confirmPayment` and `cancelPayment` return a top-level `message` ## Telemetry -The SDK reports a small, **anonymous** version fingerprint so Unitpay can see -which SDK/PHP versions are in the field. It never sends secrets, amounts, or -customer data. - -**Passive fingerprint (always on).** Standard SDK self-identification, like any -User-Agent — it adds **no extra network calls**: +The SDK adds a small, **anonymous** version fingerprint to the requests it +already makes, so Unitpay can see which SDK/PHP versions are in the field. This +is standard SDK self-identification, like any `User-Agent` — it makes **no extra +network calls** and never sends secrets, amounts, or customer data: * `api()` requests carry a `User-Agent: unitpay-php-sdk/ php/` header and an `X-Unitpay-Client` JSON header with the same facts. * `form()` URLs carry an `sdk=php__` query parameter (outside the signature — it does not affect it). -**Opt-in error telemetry (off by default).** A best-effort beacon that reports -only *pre-flight* validation errors the backend can't otherwise see (bad -signature, disallowed IP, missing params). Enable it with a single flag — no URL -to configure, the endpoint is derived from your `$domain`: - -```php -$unitpay = new UnitPay($domain, $secretKey); -$unitpay->enableTelemetry(); // opt-in; sends only: sdk, php, error code, method -``` - -It fires a 300 ms best-effort `GET` and never blocks or breaks your payment flow. -To disable it regardless of code (e.g. in a locked-down environment), set: - -```sh -UNITPAY_SDK_TELEMETRY_DISABLE=1 -``` +That is the whole of it — there is no separate telemetry endpoint, no opt-in +beacon, and nothing to configure. ## Installation diff --git a/UnitPay.php b/UnitPay.php index 472019d..6e53da8 100644 --- a/UnitPay.php +++ b/UnitPay.php @@ -568,21 +568,6 @@ class UnitPay /** WebMoney (WMZ wallets) */ public const PAYMENT_TYPE_WEBMONEY = 'webmoney'; - /** - * Pre-flight error codes for optional telemetry (Layer B). Stable, non-PII; - * additive-only — do not rename or remove, so the backend series stay comparable. - * ERR_API_UNREACHABLE is defined but NOT sent: the beacon would go to the same - * unreachable $domain (see reportTelemetry / checkHandlerRequest wire-in). - */ - public const ERR_METHOD_NOT_SUPPORTED = 'ERR_METHOD_NOT_SUPPORTED'; - public const ERR_MISSING_REQUIRED_PARAM = 'ERR_MISSING_REQUIRED_PARAM'; - public const ERR_MISSING_SECRET_KEY = 'ERR_MISSING_SECRET_KEY'; - public const ERR_API_UNREACHABLE = 'ERR_API_UNREACHABLE'; - public const ERR_MISSING_METHOD = 'ERR_MISSING_METHOD'; - public const ERR_MISSING_PARAMS = 'ERR_MISSING_PARAMS'; - public const ERR_WRONG_SIGNATURE = 'ERR_WRONG_SIGNATURE'; - public const ERR_IP_NOT_ALLOWED = 'ERR_IP_NOT_ALLOWED'; - /** * Supported api() methods and their required parameters. secretKey is * injected and validated in api(), so it is not listed here. @@ -648,8 +633,6 @@ class UnitPay */ private array $customIps = []; private string $ipsUrl; - private string $telemetryUrl; - private bool $telemetryEnabled = false; /** * @param string $domain host only, e.g. "unitpay.ru" — without scheme or path (becomes "https://$domain/api"). @@ -666,7 +649,6 @@ public function __construct(string $domain, ?string $secretKey = null, ?callable $this->apiUrl = "https://$domain/api"; $this->formUrl = "https://$domain/pay/"; $this->ipsUrl = "https://$domain/ips/ips_webhooks.json"; - $this->telemetryUrl = "https://$domain/sdk/telemetry"; $this->transport = $transport; $this->request = $request; $this->clientIp = $clientIp; @@ -821,14 +803,13 @@ protected function isAllowedIp(string $ip): bool * its default true). The file_get_contents fallback suppresses its transport * warning via set_error_handler, not the '@' operator (which QA rules forbid) — * otherwise that warning would log the URL together with the secret. - * @param string[] $headers HTTP headers of the form "Name: value" (Layer A fingerprint / beacon). - * @param int|null $timeoutMs hard timeout in ms (Layer B beacon); null uses api()'s normal timeouts. + * @param string[] $headers HTTP headers of the form "Name: value" (the SDK telemetry fingerprint on api()). * @return string|false */ - protected function httpGet(string $url, array $headers = [], ?int $timeoutMs = null) + protected function httpGet(string $url, array $headers = []) { if ($this->transport !== null) { - return call_user_func($this->transport, $url, $headers, $timeoutMs); + return call_user_func($this->transport, $url, $headers); } if (function_exists('curl_init')) { @@ -838,13 +819,6 @@ protected function httpGet(string $url, array $headers = [], ?int $timeoutMs = n CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 10, ]; - if ($timeoutMs !== null) { - // Millisecond timeouts + NOSIGNAL for the best-effort Layer B beacon (replacing the second-based ones). - unset($opts[CURLOPT_CONNECTTIMEOUT], $opts[CURLOPT_TIMEOUT]); - $opts[CURLOPT_NOSIGNAL] = true; - $opts[CURLOPT_CONNECTTIMEOUT_MS] = $timeoutMs; - $opts[CURLOPT_TIMEOUT_MS] = $timeoutMs; - } if ($headers !== []) { $opts[CURLOPT_HTTPHEADER] = $headers; } @@ -856,7 +830,7 @@ protected function httpGet(string $url, array $headers = [], ?int $timeoutMs = n return $body; } - $http = ['timeout' => $timeoutMs !== null ? $timeoutMs / 1000 : 10]; + $http = ['timeout' => 10]; if ($headers !== []) { $http['header'] = implode("\r\n", $headers); } @@ -892,7 +866,7 @@ public function form(string $publicKey, $sum, string $account, string $desc, str $params = array_merge($this->params, $vitalParams); $params['signature'] = $this->getSignature($vitalParams); $params['locale'] = $locale; - $params['sdk'] = $this->getSdkToken(); // outside the signature — does not affect it (Layer A) + $params['sdk'] = $this->getSdkToken(); // outside the signature — does not affect it $this->params = []; return $this->formUrl . $publicKey . '?' . http_build_query($params); } @@ -990,7 +964,6 @@ public function setBackUrl(string $backUrl): self public function api(string $method, array $params = []): object { if (!isset($this->requiredUnitpayMethodsParams[$method])) { - $this->reportTelemetry(self::ERR_METHOD_NOT_SUPPORTED, $method); throw new UnitpayUnsupportedMethodException('Method is not supported'); } @@ -998,7 +971,6 @@ public function api(string $method, array $params = []): object foreach ($this->requiredUnitpayMethodsParams[$method] as $rParam) { if (!isset($params[$rParam])) { - $this->reportTelemetry(self::ERR_MISSING_REQUIRED_PARAM, $method); throw new UnitpayValidationException('Param ' . $rParam . ' is null'); } } @@ -1007,7 +979,6 @@ public function api(string $method, array $params = []): object $params['secretKey'] = $this->secretKey; } if (empty($params['secretKey'])) { - $this->reportTelemetry(self::ERR_MISSING_SECRET_KEY, $method); throw new UnitpayValidationException('SecretKey is null'); } @@ -1043,38 +1014,31 @@ public function checkHandlerRequest(): bool { $ip = $this->getIp(); if (empty($this->secretKey)) { - $this->reportTelemetry(self::ERR_MISSING_SECRET_KEY, 'unknown'); throw new UnitpayValidationException('SecretKey is null'); } $request = $this->request !== null ? $this->request : $_GET; if (!isset($request['method'])) { - $this->reportTelemetry(self::ERR_MISSING_METHOD, 'unknown'); throw new UnitpayValidationException('Method is null'); } if (!isset($request['params'])) { - $this->reportTelemetry(self::ERR_MISSING_PARAMS, 'unknown'); throw new UnitpayValidationException('Params is null'); } list($method, $params) = [$request['method'], $request['params']]; if (!in_array($method, $this->supportedPartnerMethods, true)) { - // method here is arbitrary sender input; do not echo it into telemetry. - $this->reportTelemetry(self::ERR_METHOD_NOT_SUPPORTED, 'unknown'); throw new UnitpayUnsupportedMethodException('Method is not supported'); } if (!isset($params['signature']) || !is_string($params['signature']) || !hash_equals($this->getSignature($params, $method), $params['signature'])) { - $this->reportTelemetry(self::ERR_WRONG_SIGNATURE, $method); throw new UnitpaySignatureException('Wrong signature'); } if (!$this->isAllowedIp($ip)) { - $this->reportTelemetry(self::ERR_IP_NOT_ALLOWED, $method); throw new UnitpayIpException('IP address Error'); } @@ -1115,80 +1079,24 @@ private function getSdkToken(): string } /** - * SDK self-identification string for the User-Agent header (full PHP version — - * the header is invisible to the buyer and useful for diagnostics). - */ - private function getUserAgent(): string - { - return 'unitpay-php-sdk/' . self::VERSION . ' php/' . PHP_VERSION; - } - - /** - * JSON fingerprint for the X-Unitpay-Client header — a machine-readable version of - * the UA, so the backend does not have to parse the User-Agent string with a regex. + * SDK self-identification headers sent on api(): a conventional User-Agent (full PHP + * version — invisible to the buyer, useful for diagnostics) plus X-Unitpay-Client, a + * JSON version the backend can read without parsing the UA string. + * @return string[] */ - private function getClientHeader(): string + private function fingerprintHeaders(): array { - return (string) json_encode([ + $client = (string) json_encode([ 'platform' => 'php', 'sdk_version' => self::VERSION, 'php_version' => PHP_VERSION, ]); - } - - /** - * Fingerprint headers for the header channels (api() and the Layer B beacon). - * @return string[] - */ - private function fingerprintHeaders(): array - { return [ - 'User-Agent: ' . $this->getUserAgent(), - 'X-Unitpay-Client: ' . $this->getClientHeader(), + 'User-Agent: unitpay-php-sdk/' . self::VERSION . ' php/' . PHP_VERSION, + 'X-Unitpay-Client: ' . $client, ]; } - /** - * Enables optional pre-flight error telemetry (Layer B). Disabled by default. - * The merchant only toggles the flag — the endpoint URL is derived from $domain and - * need not be passed. Fully silenced by the UNITPAY_SDK_TELEMETRY_DISABLE environment - * variable (1/true/yes) with no code change. - */ - public function enableTelemetry(): self - { - $this->telemetryEnabled = true; - return $this; - } - - /** - * Best-effort pre-flight error beacon. Never throws and never affects the payment - * flow: a no-op when telemetry is disabled or the env kill switch is set; hard - * 300 ms timeout; sends only non-PII fields (sdk, php, error, method). - * @param string $code one of the ERR_* constants - * @param string $method method name or 'unknown' - */ - private function reportTelemetry(string $code, string $method): void - { - if (!$this->telemetryEnabled) { - return; - } - $disable = getenv('UNITPAY_SDK_TELEMETRY_DISABLE'); - if ($disable !== false && in_array(strtolower(trim($disable)), ['1', 'true', 'yes'], true)) { - return; - } - $query = http_build_query([ - 'sdk' => self::VERSION, - 'php' => PHP_VERSION, - 'error' => $code, - 'method' => $method, - ]); - try { - $this->httpGet($this->telemetryUrl . '?' . $query, $this->fingerprintHeaders(), 300); - } catch (\Throwable $e) { - // swallow — telemetry must not affect the payment flow - } - } - /** * Converts float params to locale-independent decimal strings so the signature and * request URL match on PHP <8.0 (where (string)$float honors LC_NUMERIC and would diff --git a/phpmd.xml b/phpmd.xml index 040fadd..d6f0ad7 100644 --- a/phpmd.xml +++ b/phpmd.xml @@ -27,33 +27,19 @@ - - - - - - - - - + diff --git a/tests/UnitPayTelemetryTest.php b/tests/UnitPayTelemetryTest.php index 5f99a03..9dcb9f4 100644 --- a/tests/UnitPayTelemetryTest.php +++ b/tests/UnitPayTelemetryTest.php @@ -8,24 +8,21 @@ final class UnitPayTelemetryTest extends TestCase { /** - * Spy transport: records url/headers/timeoutMs of every httpGet call. - * @param array,timeoutMs:int|null}> $calls - * @return callable + * Transport spy: records the url/headers of each httpGet call. + * @param array}> $calls */ - private function spy(&$calls) + private function spy(array &$calls): callable { - return static function ($url, $headers = [], $timeoutMs = null) use (&$calls) { - $calls[] = ['url' => $url, 'headers' => $headers, 'timeoutMs' => $timeoutMs]; + return static function (string $url, array $headers = []) use (&$calls): string { + $calls[] = ['url' => $url, 'headers' => $headers]; return '{"result":{}}'; }; } /** * @param array $headers - * @param string $name - * @return string|null */ - private function headerValue(array $headers, $name) + private function headerValue(array $headers, string $name): ?string { foreach ($headers as $h) { if (stripos($h, $name . ':') === 0) { @@ -51,121 +48,4 @@ public function testApiSendsFingerprintHeaders(): void $this->assertSame(UnitPay::VERSION, $decoded['sdk_version']); $this->assertSame(PHP_VERSION, $decoded['php_version']); } - - public function testTelemetryDisabledByDefaultSendsNoBeacon(): void - { - $calls = []; - $unitPay = new UnitPay('unitpay.test', 'secret', $this->spy($calls)); - // Pre-flight error (missing required params) — the real request never went out; telemetry is off. - try { - $unitPay->api('initPayment', ['account' => 1]); - } catch (\Throwable $e) { - } - $this->assertSame([], $calls); - } - - public function testEnabledTelemetryFiresBeaconWithFieldsAndShortTimeout(): void - { - $calls = []; - $unitPay = new UnitPay('unitpay.test', 'secret', $this->spy($calls)); - $this->assertSame($unitPay, $unitPay->enableTelemetry()); - try { - $unitPay->api('initPayment', ['account' => 1]); - } catch (\Throwable $e) { - } - - $this->assertCount(1, $calls); - $this->assertStringStartsWith('https://unitpay.test/sdk/telemetry?', $calls[0]['url']); - parse_str((string) parse_url($calls[0]['url'], PHP_URL_QUERY), $q); - $this->assertSame(UnitPay::VERSION, $q['sdk']); - $this->assertSame(PHP_VERSION, $q['php']); - $this->assertSame('ERR_MISSING_REQUIRED_PARAM', $q['error']); - $this->assertSame('initPayment', $q['method']); - $this->assertSame(300, $calls[0]['timeoutMs']); - // Anonymity boundary: the secret does not leak into the beacon. - $this->assertStringNotContainsString('secret', $calls[0]['url']); - } - - public function testEnvKillSwitchSuppressesBeacon(): void - { - putenv('UNITPAY_SDK_TELEMETRY_DISABLE=1'); - try { - $calls = []; - $unitPay = new UnitPay('unitpay.test', 'secret', $this->spy($calls)); - $unitPay->enableTelemetry(); - try { - $unitPay->api('initPayment', ['account' => 1]); - } catch (\Throwable $e) { - } - $this->assertSame([], $calls); - } finally { - putenv('UNITPAY_SDK_TELEMETRY_DISABLE'); - } - } - - public function testWrongSignatureHandlerEmitsBeaconWithMethod(): void - { - $calls = []; - $unitPay = new UnitPay('unitpay.test', 'secret', $this->spy($calls), [ - 'method' => 'pay', - 'params' => ['signature' => 'bad'], - ], '1.2.3.4'); - $unitPay->enableTelemetry(); - try { - $unitPay->checkHandlerRequest(); - } catch (\Throwable $e) { - } - - parse_str((string) parse_url($calls[0]['url'], PHP_URL_QUERY), $q); - $this->assertSame('ERR_WRONG_SIGNATURE', $q['error']); - $this->assertSame('pay', $q['method']); - } - - public function testMissingMethodHandlerEmitsUnknownMethod(): void - { - $calls = []; - $unitPay = new UnitPay('unitpay.test', 'secret', $this->spy($calls), [], '1.2.3.4'); - $unitPay->enableTelemetry(); - try { - $unitPay->checkHandlerRequest(); - } catch (\Throwable $e) { - } - - parse_str((string) parse_url($calls[0]['url'], PHP_URL_QUERY), $q); - $this->assertSame('ERR_MISSING_METHOD', $q['error']); - $this->assertSame('unknown', $q['method']); - } - - public function testApiUnreachableEmitsNoBeacon(): void - { - $calls = []; - // The transport fails the real request (false); any beacon would also land in $calls. - $transport = static function ($url, $headers = [], $timeoutMs = null) use (&$calls) { - $calls[] = $url; - return false; - }; - $unitPay = new UnitPay('unitpay.test', 'secret', $transport); - $unitPay->enableTelemetry(); - try { - $unitPay->api('getPayment', ['paymentId' => 1]); - } catch (\Throwable $e) { - } - - // Exactly one call — the real api request; the ERR_API_UNREACHABLE beacon is NOT sent (same host). - $this->assertCount(1, $calls); - $this->assertStringNotContainsString('/sdk/telemetry', $calls[0]); - } - - public function testTelemetryFailureNeverPropagates(): void - { - $throwing = static function () { - throw new \RuntimeException('beacon down'); - }; - $unitPay = new UnitPay('unitpay.test', 'secret', $throwing); - $unitPay->enableTelemetry(); - // Pre-flight error + a throwing beacon transport: the domain exception surfaces, - // not the RuntimeException from telemetry. - $this->expectException(\UnitpayValidationException::class); - $unitPay->api('initPayment', ['account' => 1]); - } } From 0bad9220f900410b0a357f72803590da03a0b931 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Fri, 24 Jul 2026 00:12:52 +0300 Subject: [PATCH 22/30] refactor(telemetry): API_VERSION + richer X-Unitpay-Client fields User-Agent now carries the targeted Unitpay API surface (api/) instead of the PHP version; X-Unitpay-Client reports sdk_version, api_version, lang, lang_version, platform (OS family only) and publisher. Adds UnitPay::API_VERSION; CHANGELOG and the telemetry test updated to match. --- CHANGELOG.md | 2 +- UnitPay.php | 22 +++++++++++++++------- tests/UnitPayTelemetryTest.php | 9 ++++++--- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 098c61d..97c2a86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### v2.1.0 -* Телеметрия: пассивный анонимный фингерпринт версии (заголовки `User-Agent` и `X-Unitpay-Client` в `api()`, параметр `sdk` в URL `form()`) — самоидентификация SDK без дополнительных сетевых запросов и без PII; отдельного эндпоинта телеметрии нет. Добавлена константа `UnitPay::VERSION` +* Телеметрия: пассивный анонимный фингерпринт версии (заголовки `User-Agent` и `X-Unitpay-Client` в `api()`, параметр `sdk` в URL `form()`) — самоидентификация SDK без дополнительных сетевых запросов и без PII; отдельного эндпоинта телеметрии нет. `User-Agent: unitpay-php-sdk/ api/` и JSON-заголовок `X-Unitpay-Client` с полями `sdk_version`, `api_version` (версия API Unitpay, к которой обращается SDK), `lang`, `lang_version`, `platform` (только семейство ОС), `publisher`. Добавлены константы `UnitPay::VERSION` и `UnitPay::API_VERSION` * `CashItem`: справочники 54-ФЗ синхронизированы с бэкендом: * Добавлены ставки НДС: vat5, vat7, vat22 и расчётные vat105, vat107, vat110, vat120, vat122 * Добавлены признаки предмета расчёта: payment_2, deposit, expense, pension_insurance_ip, pension_insurance, medical_insurance_ip, medical_insurance, social_insurance, casino_payment, issuance_bank, commodity_without_mark, commodity_mark diff --git a/UnitPay.php b/UnitPay.php index 6e53da8..bc91d8f 100644 --- a/UnitPay.php +++ b/UnitPay.php @@ -545,6 +545,9 @@ class UnitPay /** SDK version; sent in the telemetry fingerprint. Keep in sync with the release git tag. */ public const VERSION = '2.1.0'; + /** Unitpay API surface this SDK targets; sent in the telemetry fingerprint. Bump when moving to a new API version. */ + public const API_VERSION = 'v1'; + /** * Payment method codes for the `paymentType` param in api('initPayment', ...) * and payouts api('massPayment', ...). The source of truth is the backend; code list: @@ -1079,20 +1082,25 @@ private function getSdkToken(): string } /** - * SDK self-identification headers sent on api(): a conventional User-Agent (full PHP - * version — invisible to the buyer, useful for diagnostics) plus X-Unitpay-Client, a - * JSON version the backend can read without parsing the UA string. + * SDK self-identification headers sent on api(). Shape follows the common client- + * telemetry convention (short User-Agent plus X-Unitpay-Client, a JSON object the + * backend can read without parsing the UA string) with our own naming. api_version + * is the Unitpay API surface this SDK targets; platform is the coarse OS family only + * (no kernel/arch/uname); nothing carries secrets or PII. * @return string[] */ private function fingerprintHeaders(): array { $client = (string) json_encode([ - 'platform' => 'php', - 'sdk_version' => self::VERSION, - 'php_version' => PHP_VERSION, + 'sdk_version' => self::VERSION, + 'api_version' => self::API_VERSION, + 'lang' => 'php', + 'lang_version' => PHP_VERSION, + 'platform' => PHP_OS_FAMILY, + 'publisher' => 'unitpay', ]); return [ - 'User-Agent: unitpay-php-sdk/' . self::VERSION . ' php/' . PHP_VERSION, + 'User-Agent: unitpay-php-sdk/' . self::VERSION . ' api/' . self::API_VERSION, 'X-Unitpay-Client: ' . $client, ]; } diff --git a/tests/UnitPayTelemetryTest.php b/tests/UnitPayTelemetryTest.php index 9dcb9f4..d1e6404 100644 --- a/tests/UnitPayTelemetryTest.php +++ b/tests/UnitPayTelemetryTest.php @@ -42,10 +42,13 @@ public function testApiSendsFingerprintHeaders(): void $ua = $this->headerValue($headers, 'User-Agent'); $client = $this->headerValue($headers, 'X-Unitpay-Client'); - $this->assertSame('unitpay-php-sdk/' . UnitPay::VERSION . ' php/' . PHP_VERSION, $ua); + $this->assertSame('unitpay-php-sdk/' . UnitPay::VERSION . ' api/' . UnitPay::API_VERSION, $ua); $decoded = json_decode((string) $client, true); - $this->assertSame('php', $decoded['platform']); $this->assertSame(UnitPay::VERSION, $decoded['sdk_version']); - $this->assertSame(PHP_VERSION, $decoded['php_version']); + $this->assertSame(UnitPay::API_VERSION, $decoded['api_version']); + $this->assertSame('php', $decoded['lang']); + $this->assertSame(PHP_VERSION, $decoded['lang_version']); + $this->assertSame(PHP_OS_FAMILY, $decoded['platform']); + $this->assertSame('unitpay', $decoded['publisher']); } } From 787fa1ea8c40024e7e9a48e7b6344b77a30cb851 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Fri, 24 Jul 2026 00:13:06 +0300 Subject: [PATCH 23/30] docs(readme): fix webhook handler sample + sync telemetry section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Read the verified getHandlerMethod()/getHandlerParams() instead of $_GET after checkHandlerRequest() - Handle the preauth webhook (now accepted by the handler) and add a default branch, so an unknown method never returns an empty response — which Unitpay treats as a failed callback - Translate a stray Russian comment in the initPayment sample to English - Sync the Telemetry section with the new fingerprint header format --- README.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d9f7918..67d5020 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ $unitpay = new UnitPay($domain, $secretKey); /** * Base params: account, desc, sum, currency, projectId, paymentType - * paymentType — код способа оплаты из справочника (константы UnitPay::PAYMENT_TYPE_*): + * paymentType is a payment method code from the reference (UnitPay::PAYMENT_TYPE_* constants): * card, cardInvoice, sbp, sberpay, tinkoffpay, paypal, webmoney. * * @link https://help.unitpay.ru/payments/create-payment @@ -222,7 +222,9 @@ try { // Validate request (check ip address, signature and etc) $unitpay->checkHandlerRequest(); - list($method, $params) = [$_GET['method'], $_GET['params']]; + // Read the verified request from the SDK (honors the overridden request, not $_GET) + $method = $unitpay->getHandlerMethod(); + $params = $unitpay->getHandlerParams(); // Very important! Validate request with your order data, before complete order if ( @@ -244,11 +246,19 @@ try { // Please complete order echo $unitpay->getSuccessHandlerResponse('Pay Success'); break; + // Method Preauth means a two-stage hold: funds are only HELD, not captured yet. + case 'preauth': + // Do NOT deliver goods/services here; wait for 'pay'. Just acknowledge receipt. + echo $unitpay->getSuccessHandlerResponse('Preauth received. Funds held, awaiting capture.'); + break; // Method Error means that an error has occurred. case 'error': // Please log error text. echo $unitpay->getSuccessHandlerResponse('Error logged'); break; + // Unknown method: do not leave an empty response (Unitpay would treat it as a failure). + default: + throw new InvalidArgumentException('Unexpected handler method: ' . $method); } // Oops! Something went wrong. } catch (Exception $e) { @@ -352,8 +362,10 @@ already makes, so Unitpay can see which SDK/PHP versions are in the field. This is standard SDK self-identification, like any `User-Agent` — it makes **no extra network calls** and never sends secrets, amounts, or customer data: -* `api()` requests carry a `User-Agent: unitpay-php-sdk/ php/` header - and an `X-Unitpay-Client` JSON header with the same facts. +* `api()` requests carry a `User-Agent: unitpay-php-sdk/ api/` header and + an `X-Unitpay-Client` JSON header with `sdk_version`, `api_version` (the Unitpay + API surface targeted), `lang`, `lang_version`, `platform` (coarse OS family + only), `publisher`. * `form()` URLs carry an `sdk=php__` query parameter (outside the signature — it does not affect it). From 0a9fe171d364a30601db402b047009fc6c933359 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Fri, 24 Jul 2026 13:20:48 +0300 Subject: [PATCH 24/30] refactor: address code-review notes - Extract immutable api() method-param and webhook-method dictionaries into private const (REQUIRED_UNITPAY_METHODS_PARAMS, SUPPORTED_PARTNER_METHODS); they were never reassigned. - Type the $unitPay test properties natively (private UnitPay $unitPay). - Rename caught exception variable $e -> $exception in examples to match the project convention. --- UnitPay.php | 10 +++++----- examples/accountInfo.php | 4 ++-- examples/initPaymentApi.php | 4 ++-- examples/offsetAdvance.php | 4 ++-- examples/paymentForm.php | 4 ++-- examples/paymentInfo.php | 4 ++-- examples/payout.php | 4 ++-- examples/receipt.php | 4 ++-- examples/refund.php | 4 ++-- examples/subscriptions.php | 4 ++-- examples/twoStagePayment.php | 4 ++-- examples/webhook.php | 4 ++-- tests/UnitPayFloatTest.php | 3 +-- tests/UnitPayResponseTest.php | 3 +-- tests/UnitPaySignatureTest.php | 3 +-- 15 files changed, 30 insertions(+), 33 deletions(-) diff --git a/UnitPay.php b/UnitPay.php index bc91d8f..8e5f761 100644 --- a/UnitPay.php +++ b/UnitPay.php @@ -576,7 +576,7 @@ class UnitPay * injected and validated in api(), so it is not listed here. * @var array */ - private array $requiredUnitpayMethodsParams = [ + private const REQUIRED_UNITPAY_METHODS_PARAMS = [ 'initPayment' => ['account', 'sum', 'projectId', 'paymentType'], 'getPayment' => ['paymentId'], 'refundPayment' => ['paymentId'], @@ -603,7 +603,7 @@ class UnitPay * verification like the others rather than be rejected as unsupported. * @var string[] */ - private array $supportedPartnerMethods = ['check', 'pay', 'preauth', 'error']; + private const SUPPORTED_PARTNER_METHODS = ['check', 'pay', 'preauth', 'error']; /** * Published outbound Unitpay IPs. 127.0.0.1 is deliberately NOT here: behind a * reverse proxy on the same host REMOTE_ADDR equals 127.0.0.1, which would turn the @@ -966,13 +966,13 @@ public function setBackUrl(string $backUrl): self */ public function api(string $method, array $params = []): object { - if (!isset($this->requiredUnitpayMethodsParams[$method])) { + if (!isset(self::REQUIRED_UNITPAY_METHODS_PARAMS[$method])) { throw new UnitpayUnsupportedMethodException('Method is not supported'); } $params = array_merge($this->params, $params); - foreach ($this->requiredUnitpayMethodsParams[$method] as $rParam) { + foreach (self::REQUIRED_UNITPAY_METHODS_PARAMS[$method] as $rParam) { if (!isset($params[$rParam])) { throw new UnitpayValidationException('Param ' . $rParam . ' is null'); } @@ -1032,7 +1032,7 @@ public function checkHandlerRequest(): bool list($method, $params) = [$request['method'], $request['params']]; - if (!in_array($method, $this->supportedPartnerMethods, true)) { + if (!in_array($method, self::SUPPORTED_PARTNER_METHODS, true)) { throw new UnitpayUnsupportedMethodException('Method is not supported'); } diff --git a/examples/accountInfo.php b/examples/accountInfo.php index e62b8db..0017af4 100644 --- a/examples/accountInfo.php +++ b/examples/accountInfo.php @@ -35,6 +35,6 @@ // Payment methods available on the project: project key, no login. var_dump($unitpay->api('getMethodsAvailable', ['projectId' => $projectId])->result ?? null); -} catch (UnitpayExceptionInterface $e) { - print 'SDK error: ' . $e->getMessage(); +} catch (UnitpayExceptionInterface $exception) { + print 'SDK error: ' . $exception->getMessage(); } diff --git a/examples/initPaymentApi.php b/examples/initPaymentApi.php index 1eea509..709b0e8 100644 --- a/examples/initPaymentApi.php +++ b/examples/initPaymentApi.php @@ -65,7 +65,7 @@ var_dump($response); } } -} catch (UnitpayExceptionInterface $e) { +} catch (UnitpayExceptionInterface $exception) { // SDK-side failure: network, disabled allow_url_fopen, malformed JSON, etc. - print 'SDK error: ' . $e->getMessage(); + print 'SDK error: ' . $exception->getMessage(); } diff --git a/examples/offsetAdvance.php b/examples/offsetAdvance.php index e545451..ec584eb 100644 --- a/examples/offsetAdvance.php +++ b/examples/offsetAdvance.php @@ -18,6 +18,6 @@ try { $response = $unitpay->api('offsetAdvance', $account + ['paymentId' => 3403575]); var_dump($response->result ?? $response->error ?? $response); -} catch (UnitpayExceptionInterface $e) { - print 'SDK error: ' . $e->getMessage(); +} catch (UnitpayExceptionInterface $exception) { + print 'SDK error: ' . $exception->getMessage(); } diff --git a/examples/paymentForm.php b/examples/paymentForm.php index 3d7ab54..dfda6fd 100644 --- a/examples/paymentForm.php +++ b/examples/paymentForm.php @@ -30,6 +30,6 @@ header("Location: " . $redirectUrl); exit; -} catch (UnitpayExceptionInterface $e) { - print 'SDK error: ' . $e->getMessage(); +} catch (UnitpayExceptionInterface $exception) { + print 'SDK error: ' . $exception->getMessage(); } diff --git a/examples/paymentInfo.php b/examples/paymentInfo.php index 5230c61..960f311 100644 --- a/examples/paymentInfo.php +++ b/examples/paymentInfo.php @@ -23,6 +23,6 @@ } elseif (isset($response->error->message)) { print 'Error: ' . $response->error->message; } -} catch (UnitpayExceptionInterface $e) { - print 'SDK error: ' . $e->getMessage(); +} catch (UnitpayExceptionInterface $exception) { + print 'SDK error: ' . $exception->getMessage(); } diff --git a/examples/payout.php b/examples/payout.php index d06aa70..c6a1cd4 100644 --- a/examples/payout.php +++ b/examples/payout.php @@ -44,6 +44,6 @@ } elseif (isset($response->error->message)) { print 'Error: ' . $response->error->message; } -} catch (UnitpayExceptionInterface $e) { - print 'SDK error: ' . $e->getMessage(); +} catch (UnitpayExceptionInterface $exception) { + print 'SDK error: ' . $exception->getMessage(); } diff --git a/examples/receipt.php b/examples/receipt.php index a41dfdc..75a7cdf 100644 --- a/examples/receipt.php +++ b/examples/receipt.php @@ -69,7 +69,7 @@ } else { var_dump($response); } -} catch (UnitpayExceptionInterface $e) { +} catch (UnitpayExceptionInterface $exception) { // UnitpayValidationException if a line-item name is not UTF-8 (json_encode returns false). - print 'SDK error: ' . $e->getMessage(); + print 'SDK error: ' . $exception->getMessage(); } diff --git a/examples/refund.php b/examples/refund.php index bb071f0..a9803f9 100644 --- a/examples/refund.php +++ b/examples/refund.php @@ -24,6 +24,6 @@ } elseif (isset($response->error->message)) { print 'Error: ' . $response->error->message; } -} catch (UnitpayExceptionInterface $e) { - print 'SDK error: ' . $e->getMessage(); +} catch (UnitpayExceptionInterface $exception) { + print 'SDK error: ' . $exception->getMessage(); } diff --git a/examples/subscriptions.php b/examples/subscriptions.php index 5957a97..0f578df 100644 --- a/examples/subscriptions.php +++ b/examples/subscriptions.php @@ -32,6 +32,6 @@ } elseif (isset($closed->error->message)) { print 'Error: ' . $closed->error->message; } -} catch (UnitpayExceptionInterface $e) { - print 'SDK error: ' . $e->getMessage(); +} catch (UnitpayExceptionInterface $exception) { + print 'SDK error: ' . $exception->getMessage(); } diff --git a/examples/twoStagePayment.php b/examples/twoStagePayment.php index 06873b3..db211c9 100644 --- a/examples/twoStagePayment.php +++ b/examples/twoStagePayment.php @@ -30,6 +30,6 @@ } elseif (isset($response->error->message)) { print 'Error: ' . $response->error->message; } -} catch (UnitpayExceptionInterface $e) { - print 'SDK error: ' . $e->getMessage(); +} catch (UnitpayExceptionInterface $exception) { + print 'SDK error: ' . $exception->getMessage(); } diff --git a/examples/webhook.php b/examples/webhook.php index 7c385b3..549698f 100644 --- a/examples/webhook.php +++ b/examples/webhook.php @@ -72,7 +72,7 @@ // failure with no diagnostics) — return an error via the shared catch below. throw new InvalidArgumentException('Unexpected handler method: ' . $method); } -} catch (Exception $e) { +} catch (Exception $exception) { // Any error (wrong signature, disallowed IP, order mismatch) returns an error to Unitpay. - print $unitpay->getErrorHandlerResponse($e->getMessage()); + print $unitpay->getErrorHandlerResponse($exception->getMessage()); } diff --git a/tests/UnitPayFloatTest.php b/tests/UnitPayFloatTest.php index 4c6a2e6..bb72be2 100644 --- a/tests/UnitPayFloatTest.php +++ b/tests/UnitPayFloatTest.php @@ -13,8 +13,7 @@ */ final class UnitPayFloatTest extends TestCase { - /** @var UnitPay */ - private $unitPay; + private UnitPay $unitPay; protected function setUp(): void { diff --git a/tests/UnitPayResponseTest.php b/tests/UnitPayResponseTest.php index 2897074..bf28a0e 100644 --- a/tests/UnitPayResponseTest.php +++ b/tests/UnitPayResponseTest.php @@ -7,8 +7,7 @@ final class UnitPayResponseTest extends TestCase { - /** @var UnitPay */ - private $unitPay; + private UnitPay $unitPay; protected function setUp(): void { diff --git a/tests/UnitPaySignatureTest.php b/tests/UnitPaySignatureTest.php index 13016ff..fd6d456 100644 --- a/tests/UnitPaySignatureTest.php +++ b/tests/UnitPaySignatureTest.php @@ -7,8 +7,7 @@ final class UnitPaySignatureTest extends TestCase { - /** @var UnitPay */ - private $unitPay; + private UnitPay $unitPay; protected function setUp(): void { From a0b0d95295bbc6826097aae0a367e02985a7769d Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Fri, 24 Jul 2026 14:47:16 +0300 Subject: [PATCH 25/30] docs(changelog): translate to English --- CHANGELOG.md | 200 +++++++++++++++++++++++++-------------------------- 1 file changed, 100 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97c2a86..701e83a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,103 +2,103 @@ ### v2.1.0 -* Телеметрия: пассивный анонимный фингерпринт версии (заголовки `User-Agent` и `X-Unitpay-Client` в `api()`, параметр `sdk` в URL `form()`) — самоидентификация SDK без дополнительных сетевых запросов и без PII; отдельного эндпоинта телеметрии нет. `User-Agent: unitpay-php-sdk/ api/` и JSON-заголовок `X-Unitpay-Client` с полями `sdk_version`, `api_version` (версия API Unitpay, к которой обращается SDK), `lang`, `lang_version`, `platform` (только семейство ОС), `publisher`. Добавлены константы `UnitPay::VERSION` и `UnitPay::API_VERSION` -* `CashItem`: справочники 54-ФЗ синхронизированы с бэкендом: - * Добавлены ставки НДС: vat5, vat7, vat22 и расчётные vat105, vat107, vat110, vat120, vat122 - * Добавлены признаки предмета расчёта: payment_2, deposit, expense, pension_insurance_ip, pension_insurance, medical_insurance_ip, medical_insurance, social_insurance, casino_payment, issuance_bank, commodity_without_mark, commodity_mark - * Помечены устаревшими (сохранены для обратной совместимости, удаление в 3.0) значения, отклоняемые публичным API: excise, gambling_bet, gambling_prize, lottery_prize, composite -* `CashItem`: добавлены опциональные поля бэкенда — sum, currency, measure (константы `MEASURE_*`), nomenclatureCode, markCode, markQuantity, pre_text, post_text; `setCashItems()` сериализует их только когда они заданы -* `api()`: добавлена поддержка методов refundPayment, confirmPayment, cancelPayment, listSubscriptions, getSubscription, closeSubscription, getMethodsAvailable, getCommissions, getCurrencyCourses, getPartner, offsetAdvance — с проверкой обязательных параметров для каждого (secretKey подставляется автоматически) -* `api()`: добавлены методы массовых выплат — massPayment, massPaymentStatus, massPaymentAvailableAmount, massPaymentCommissions, getSbpBankList, getBinInfo (требуют login и secretKey кабинета) -* `api()`: добавлены константы `PAYMENT_TYPE_*` для актуальных способов оплаты Unitpay (card, cardInvoice, sbp, sberpay, tinkoffpay, paypal, webmoney) — только для удобства и защиты от опечаток, `paymentType` по-прежнему передаётся без валидации; в README и примере `initPaymentApi` они заменили устаревшие qiwi/yandex/mc/alfaClick -* `api()`: явный secretKey в параметрах вызова переопределяет ключ из конструктора — методы уровня кабинета и выплат можно вызывать с ключом кабинета вместо ключа проекта -* `api()`: обязательные параметры initPayment приведены в соответствие с бэкендом — account, sum, projectId, paymentType (secretKey проверяется отдельно); desc больше не обязателен -* `api()`: параметры теперь отправляются плоско (`method=X&account=…&secretKey=…`), как Unitpay документирует и принимает с 05.2026, вместо устаревшей вложенности `params[...]` (по-прежнему принимается бэкендом, так что это не ломающее изменение); обработчик входящих вебхуков не затронут и продолжает читать `params[...]` -* `api()`: параметры из fluent-сеттеров (setCashItems, setCustomerEmail, setCustomerPhone, setBackUrl) теперь попадают и в вызовы `api()`, не только в `form()`; явные параметры `api()` имеют приоритет над накопленными -* `CashItem`: конструктор теперь отклоняет нечисловые count и price (раньше отлавливались только 0/отрицательные) и нормализует числовые строки в int/float -* `CashItem`: конструктор отклоняет неположительный count и отрицательный price (изменение поведения) -* `CashItem`: сохраняет дробный count (весовой/объёмный товар) вместо усечения до int -* handler: IP-белый список сокращён до официально опубликованных адресов (31.186.100.49, 51.250.20.9); 127.0.0.1 по умолчанию не доверенный (за обратным прокси на том же хосте он бы обнулил проверку по IP) — добавьте его через `setAllowedIps()` для локальной отладки; добавлен сам `setAllowedIps()` для переопределения списка -* handler: `isAllowedIp()` теперь сопоставляет не только точные IP, но и подсети CIDR (IPv4/IPv6) — работает `setAllowedIps(['77.75.153.0/25'])` -* handler: добавлен `refreshAllowedIps()` — подтягивает актуальный список IP вебхуков из публичного фида `/ips/ips_webhooks.json` и заменяет им встроенный (выведенный из эксплуатации IP выпадает автоматически); безопасен для сбоев: при любой ошибке транспорта/парсинга/валидации сохраняет встроенный список и не бросает исключение, поэтому его можно вызывать перед `checkHandlerRequest()` -* handler: добавлен `addAllowedIps()` — добавляет собственные IP/CIDR мерчанта (например, свой прокси/релей) поверх списка Unitpay; в отличие от `setAllowedIps()`, они сохраняются при `refreshAllowedIps()`/`setAllowedIps()` -* handler: добавлен `getAllowedIps()` — возвращает итоговый список (Unitpay + IP мерчанта); закешируйте его после `refreshAllowedIps()` и верните через `setAllowedIps()`, чтобы не обращаться к сети на каждый вебхук -* `UnitpayIpAllowlist::isValidEntry()` проверяет каждую загруженную запись IP/CIDR, поэтому битый JSON не может обнулить список -* handler: `checkHandlerRequest()` теперь принимает вебхук `preauth` (уведомление о холде при двухстадийной оплате, когда средства заблокированы, но ещё не списаны) — раньше он отклонялся как неподдерживаемый метод, из-за чего двухстадийные/подписочные обработчики не могли его проверить -* Добавлены типизированные исключения (UnitpaySignatureException, UnitpayIpException, UnitpayTransportException, UnitpayUnsupportedMethodException) с интерфейсом UnitpayExceptionInterface; каждое по-прежнему наследует прежний SPL-класс, поэтому существующие catch-блоки продолжают работать -* `api()`: опциональный транспорт cURL с таймаутами подключения/чтения и без зависимости от allow_url_fopen (откат на file_get_contents); ext-curl добавлен в composer "suggest" -* `api()`: транспорт cURL не вызывает curl_close() на PHP 8.0+ (там это устаревший no-op, вызывающий E_DEPRECATED на PHP 8.5 при каждом обращении к API); на PHP <8.0 хэндл (ресурс) закрывается явно через проверку PHP_VERSION_ID -* examples: полный набор сценариев — платёжная форма (`paymentForm`), API (`initPaymentApi`), чек 54-ФЗ через `CashItem` (`receipt`), вебхук (`webhook`), `getPayment` (`paymentInfo`), возврат (`refund`), двухстадийные (`twoStagePayment`), подписки (`subscriptions`), выплаты по СБП (`payout`), справочные вызовы кабинета (`accountInfo`), чек зачёта аванса (`offsetAdvance`); добавлен индекс `examples/README.md` -* examples: настройки подключения и данные заказа разделены — `config.php` (домен, ключи проекта/кабинета, login) и `order.php` (данные заказа); секреты читаются из окружения (`UNITPAY_SECRET_KEY`, `UNITPAY_LOGIN`, `UNITPAY_ACCOUNT_SECRET_KEY`) вместо хардкода -* examples: надёжность — `require` через `__DIR__` (не зависит от рабочего каталога), `exit` после `header('Location:')`, вызовы `api()`/`form()` обёрнуты в try/catch (`UnitpayExceptionInterface`); `webhook` отдаёт `application/json` и не оставляет пустой ответ на неизвестный метод (`default` в switch) -* examples: удалена недостижимая ветка обработчика "refund"; добавлена ветка "preauth" (уведомление о холде — подтверждаем получение, но товар не выдаём, это ждёт "pay"); обработка ответа типа "response" у initPayment (например, рекуррентные/подписочные списания без редиректа) -* Добавлен набор тестов PHPUnit и внедряемые точки (getIp/транспорт API) для тестирования -* Добавлены инструменты QA: phpstan, php-cs-fixer, phpmd и parallel-lint -* Нативные объявления типов: параметры, возвраты и типизированные свойства во всех трёх классах (`CashItem`, `UnitpayIpAllowlist`, `UnitPay`) в границах PHP 7.4 (без union-типов, `mixed` и `declare(strict_types)`) — публичный API и поведение не изменены. Денежные и количественные параметры (`form()` `$sum`, `CashItem` `$count`/`$price`, а также возврат `httpGet()` `string|false`) намеренно оставлены нетипизированными, чтобы сохранить прежнюю «мягкую» скалярную эргономику; их типы по-прежнему описаны в PHPDoc. Проверка PHPStan поднята с level 5 до level 6 -* Минимальная версия PHP поднята до 7.4 -* Усиление по итогам код-ревью: - * `api()`: сворачивает только параметры fluent-сеттеров (cashItems/customerEmail/customerPhone/backUrl), а не весь набор — переиспользуемый экземпляр больше не протаскивает ключевые параметры `form()` или устаревшую подпись в посторонний вызов `api()` - * `setCashItems()`: бросает исключение при ошибке json_encode (например, название товара не в UTF-8) вместо тихого прикрепления пустого чека 54-ФЗ - * `CashItem`: сохраняет дробный count (весовой/объёмный товар) вместо усечения до int - * `form()`: бросает исключение при пустом секрете вместо возврата неподписанного URL — как в `api()`/`checkHandlerRequest()` - * `getSignature()`/`api()`/`form()`: форматируют float-параметры независимо от локали, чтобы локаль с запятой-разделителем на PHP <8.0 не испортила подпись или сумму - * `api()`: пустой явный secretKey (например, несработавший getenv()) откатывается на ключ экземпляра вместо исключения - * `httpGet()`: подавляет предупреждение file_get_contents, чтобы URL с секретом не попал в лог ошибок - * `checkHandlerRequest()`: отдаёт проверенные method/params через `getHandlerMethod()`/`getHandlerParams()`, чтобы потребителю не перечитывать $_GET - * каждое исключение SDK реализует UnitpayExceptionInterface (добавлено UnitpayValidationException для случаев отсутствующего параметра/секрета/метода) - -### v2.0.6 от 14.05.2025 - -* Добавлен новый поддерживаемый IP-адрес Unitpay -* Обновлён README.md - -### v2.0.5 от 04.02.2022 - -* Обновлён список IP-адресов Unitpay -* Обновлены ссылки на документацию -* Улучшены качество и структура кода - -### v2.0.4 от 17.03.2021 - -* Обновлён метод `getSignature` (2Garin) - -### v2.0.3 от 20.02.2021 - -* Фильтрация входных параметров подписи (удаление полей sign/signature перед подписанием) - -### v2.0.2 от 31.08.2020 - -* Добавлены параметры nds, type и paymentMethod в `CashItem` - -### v2.0.1 от 03.03.2020 - -* Добавлен выбор домена в примерах - -### v2.0.0 от 03.03.2020 - -* Добавлен выбор домена (настраиваемый домен API) -* Обновлён URL документации - -### v1.1.2 от 15.06.2018 - -* Исправлено исключение array_merge («Argument #1 is not an array»), когда чек не задан - -### v1.1.1 от 08.02.2018 - -* Добавлен файл LICENSE -* Исправлен файл composer - -### v1.1.0 от 01.08.2017 - -* Добавлены customerEmail, customerPhone и cashItems в платёжную форму - -### v1.0.0 от 10.04.2017 - -* Первый публичный релиз Unitpay PHP SDK -* Переход на подписи SHA-256 для всех методов (поддержка MD5 удалена) -* Добавлен API-метод getPayment и пример orderInfo.php -* secretKey стал обязательным параметром для вызовов API -* billingCode переименован в paymentType -* statusUrl объявлен устаревшим в пользу receiptUrl -* Добавлена поддержка метода обработчика партнёра "error" -* Добавлен переопределяемый метод `getIp()` +* Telemetry: passive anonymous version fingerprint (`User-Agent` and `X-Unitpay-Client` headers in `api()`, `sdk` parameter in the `form()` URL) — SDK self-identification with no extra network requests and no PII; there is no dedicated telemetry endpoint. `User-Agent: unitpay-php-sdk/ api/` and a JSON `X-Unitpay-Client` header with fields `sdk_version`, `api_version` (the Unitpay API version the SDK targets), `lang`, `lang_version`, `platform` (OS family only), `publisher`. Added constants `UnitPay::VERSION` and `UnitPay::API_VERSION` +* `CashItem`: 54-FZ dictionaries synced with the backend: + * Added VAT rates: vat5, vat7, vat22 and the calculated vat105, vat107, vat110, vat120, vat122 + * Added payment objects: payment_2, deposit, expense, pension_insurance_ip, pension_insurance, medical_insurance_ip, medical_insurance, social_insurance, casino_payment, issuance_bank, commodity_without_mark, commodity_mark + * Marked as deprecated (kept for backward compatibility, removal in 3.0) the values rejected by the public API: excise, gambling_bet, gambling_prize, lottery_prize, composite +* `CashItem`: added optional backend fields — sum, currency, measure (`MEASURE_*` constants), nomenclatureCode, markCode, markQuantity, pre_text, post_text; `setCashItems()` serializes them only when they are set +* `api()`: added support for the methods refundPayment, confirmPayment, cancelPayment, listSubscriptions, getSubscription, closeSubscription, getMethodsAvailable, getCommissions, getCurrencyCourses, getPartner, offsetAdvance — each with validation of its required parameters (secretKey is supplied automatically) +* `api()`: added mass-payout methods — massPayment, massPaymentStatus, massPaymentAvailableAmount, massPaymentCommissions, getSbpBankList, getBinInfo (require the account login and secretKey) +* `api()`: added `PAYMENT_TYPE_*` constants for the current Unitpay payment methods (card, cardInvoice, sbp, sberpay, tinkoffpay, paypal, webmoney) — for convenience and typo protection only; `paymentType` is still passed without validation; in the README and the `initPaymentApi` example they replaced the deprecated qiwi/yandex/mc/alfaClick +* `api()`: an explicit secretKey in the call parameters overrides the key from the constructor — account-level and payout methods can be called with the account key instead of the project key +* `api()`: the required initPayment parameters were aligned with the backend — account, sum, projectId, paymentType (secretKey is validated separately); desc is no longer required +* `api()`: parameters are now sent flat (`method=X&account=…&secretKey=…`), as Unitpay documents and has accepted since 05.2026, instead of the deprecated `params[...]` nesting (still accepted by the backend, so this is not a breaking change); the inbound webhook handler is unaffected and keeps reading `params[...]` +* `api()`: parameters from the fluent setters (setCashItems, setCustomerEmail, setCustomerPhone, setBackUrl) now reach `api()` calls too, not just `form()`; explicit `api()` parameters take precedence over the accumulated ones +* `CashItem`: the constructor now rejects non-numeric count and price (previously only 0/negative were caught) and normalizes numeric strings to int/float +* `CashItem`: the constructor rejects a non-positive count and a negative price (behavior change) +* `CashItem`: preserves a fractional count (weight/volume goods) instead of truncating to int +* handler: the IP allowlist was trimmed to the officially published addresses (31.186.100.49, 51.250.20.9); 127.0.0.1 is not trusted by default (behind a reverse proxy on the same host it would nullify the IP check) — add it via `setAllowedIps()` for local debugging; `setAllowedIps()` itself was added to override the list +* handler: `isAllowedIp()` now matches not only exact IPs but also CIDR subnets (IPv4/IPv6) — `setAllowedIps(['77.75.153.0/25'])` works +* handler: added `refreshAllowedIps()` — pulls the current webhook IP list from the public feed `/ips/ips_webhooks.json` and replaces the built-in one (a decommissioned IP drops off automatically); fail-safe: on any transport/parse/validation error it keeps the built-in list and does not throw, so it can be called before `checkHandlerRequest()` +* handler: added `addAllowedIps()` — adds the merchant's own IPs/CIDRs (e.g. your own proxy/relay) on top of the Unitpay list; unlike `setAllowedIps()`, they are preserved across `refreshAllowedIps()`/`setAllowedIps()` +* handler: added `getAllowedIps()` — returns the effective list (Unitpay + merchant IPs); cache it after `refreshAllowedIps()` and feed it back via `setAllowedIps()` to avoid hitting the network on every webhook +* `UnitpayIpAllowlist::isValidEntry()` validates every loaded IP/CIDR entry, so malformed JSON can never empty the list +* handler: `checkHandlerRequest()` now accepts the `preauth` webhook (a hold notification in two-stage payments, when funds are blocked but not yet captured) — it used to be rejected as an unsupported method, which prevented two-stage/subscription handlers from verifying it +* Added typed exceptions (UnitpaySignatureException, UnitpayIpException, UnitpayTransportException, UnitpayUnsupportedMethodException) with the UnitpayExceptionInterface; each still extends its former SPL class, so existing catch blocks keep working +* `api()`: optional cURL transport with connect/read timeouts and no dependency on allow_url_fopen (falls back to file_get_contents); ext-curl added to composer "suggest" +* `api()`: the cURL transport does not call curl_close() on PHP 8.0+ (there it is a deprecated no-op that raises E_DEPRECATED on PHP 8.5 on every API call); on PHP <8.0 the handle (resource) is closed explicitly via a PHP_VERSION_ID check +* examples: a full set of scenarios — payment form (`paymentForm`), API (`initPaymentApi`), 54-FZ receipt via `CashItem` (`receipt`), webhook (`webhook`), `getPayment` (`paymentInfo`), refund (`refund`), two-stage (`twoStagePayment`), subscriptions (`subscriptions`), SBP payouts (`payout`), account reference calls (`accountInfo`), advance-offset receipt (`offsetAdvance`); added an `examples/README.md` index +* examples: connection settings and order data separated — `config.php` (domain, project/account keys, login) and `order.php` (order data); secrets are read from the environment (`UNITPAY_SECRET_KEY`, `UNITPAY_LOGIN`, `UNITPAY_ACCOUNT_SECRET_KEY`) instead of being hardcoded +* examples: robustness — `require` via `__DIR__` (independent of the working directory), `exit` after `header('Location:')`, `api()`/`form()` calls wrapped in try/catch (`UnitpayExceptionInterface`); `webhook` returns `application/json` and no longer leaves an empty response for an unknown method (`default` in the switch) +* examples: removed the unreachable "refund" handler branch; added a "preauth" branch (hold notification — acknowledge receipt but do not deliver goods, that waits for "pay"); handling of the "response" reply type from initPayment (e.g. recurring/subscription charges without a redirect) +* Added a PHPUnit test suite and injectable seams (getIp / API transport) for testability +* Added QA tooling: phpstan, php-cs-fixer, phpmd and parallel-lint +* Native type declarations: parameters, return types and typed properties across all three classes (`CashItem`, `UnitpayIpAllowlist`, `UnitPay`) within the PHP 7.4 limits (no union types, `mixed`, or `declare(strict_types)`) — the public API and behavior are unchanged. Money and quantity parameters (`form()` `$sum`, `CashItem` `$count`/`$price`, and the `httpGet()` `string|false` return) are deliberately left untyped to preserve the previous "soft" scalar ergonomics; their types are still documented in PHPDoc. PHPStan raised from level 5 to level 6 +* Minimum PHP version raised to 7.4 +* Hardening from code review: + * `api()`: folds in only the fluent-setter parameters (cashItems/customerEmail/customerPhone/backUrl), not the whole set — a reused instance no longer leaks the key `form()` parameters or the stale signature into an unrelated `api()` call + * `setCashItems()`: throws on a json_encode failure (e.g. a product name that is not UTF-8) instead of silently attaching an empty 54-FZ receipt + * `CashItem`: preserves a fractional count (weight/volume goods) instead of truncating to int + * `form()`: throws on an empty secret instead of returning an unsigned URL — like `api()`/`checkHandlerRequest()` + * `getSignature()`/`api()`/`form()`: format float parameters locale-independently, so a comma-separator locale on PHP <8.0 cannot corrupt the signature or the amount + * `api()`: an empty explicit secretKey (e.g. a getenv() that did not resolve) falls back to the instance key instead of throwing + * `httpGet()`: suppresses the file_get_contents warning so a URL containing the secret does not leak into the error log + * `checkHandlerRequest()`: exposes the verified method/params via `getHandlerMethod()`/`getHandlerParams()`, so the consumer does not re-read $_GET + * every SDK exception implements UnitpayExceptionInterface (UnitpayValidationException was added for the missing-parameter/secret/method cases) + +### v2.0.6 — 2025-05-14 + +* Added a new supported Unitpay IP address +* Updated README.md + +### v2.0.5 — 2022-02-04 + +* Updated the list of Unitpay IP addresses +* Updated documentation links +* Improved code quality and structure + +### v2.0.4 — 2021-03-17 + +* Updated the `getSignature` method (2Garin) + +### v2.0.3 — 2021-02-20 + +* Filtering of signature input parameters (removing the sign/signature fields before signing) + +### v2.0.2 — 2020-08-31 + +* Added the nds, type and paymentMethod parameters to `CashItem` + +### v2.0.1 — 2020-03-03 + +* Added domain selection in the examples + +### v2.0.0 — 2020-03-03 + +* Added domain selection (configurable API domain) +* Updated the documentation URL + +### v1.1.2 — 2018-06-15 + +* Fixed the array_merge exception ("Argument #1 is not an array") when no receipt is set + +### v1.1.1 — 2018-02-08 + +* Added a LICENSE file +* Fixed the composer file + +### v1.1.0 — 2017-08-01 + +* Added customerEmail, customerPhone and cashItems to the payment form + +### v1.0.0 — 2017-04-10 + +* First public release of the Unitpay PHP SDK +* Switched to SHA-256 signatures for all methods (MD5 support removed) +* Added the getPayment API method and the orderInfo.php example +* secretKey became a required parameter for API calls +* billingCode renamed to paymentType +* statusUrl deprecated in favor of receiptUrl +* Added support for the partner handler method "error" +* Added an overridable `getIp()` method From fafa1161752216460c2a5a64dc9afbc23e80e48a Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Fri, 24 Jul 2026 14:52:31 +0300 Subject: [PATCH 26/30] docs(examples): translate README to English --- examples/README.md | 64 +++++++++++++++++++++++----------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/examples/README.md b/examples/README.md index 8b9a313..3c677b6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,50 +1,50 @@ -# Примеры +# Examples -Готовые сценарии интеграции с Unitpay. Примеры читают `$_GET`/`$_SERVER` и вызывают -`header()`, поэтому их запускают по HTTP, а не из CLI: +Ready-made Unitpay integration scenarios. The examples read `$_GET`/`$_SERVER` and call +`header()`, so they must be served over HTTP, not run from the CLI: ```sh php -S localhost:8000 -t examples -# затем откройте, например, http://localhost:8000/paymentInfo.php +# then open e.g. http://localhost:8000/paymentInfo.php ``` -## Настройки +## Configuration -Общие данные вынесены в два подключаемых файла (сами по себе не запускаются): +Shared data lives in two include files (not runnable on their own): -- [config.php](config.php) — подключение и ключи: `domain`, `projectId`, `publicId`, - `secretKey`, а также `login`/`accountSecretKey` для методов уровня кабинета. -- [order.php](order.php) — данные заказа: `orderId`, `orderSum`, `orderDesc`, - `orderCurrency`. Подключается примерами с оплатой в дополнение к `config.php`. +- [config.php](config.php) — connection and keys: `domain`, `projectId`, `publicId`, + `secretKey`, plus `login`/`accountSecretKey` for account-level methods. +- [order.php](order.php) — order data: `orderId`, `orderSum`, `orderDesc`, + `orderCurrency`. Included by payment examples in addition to `config.php`. -Секреты не хранятся в коде — читаются из окружения (с заглушками по умолчанию): +Secrets are not stored in code — they are read from the environment (with default placeholders): ```sh -export UNITPAY_SECRET_KEY=... # ключ проекта -export UNITPAY_LOGIN=... # login кабинета (методы уровня кабинета) -export UNITPAY_ACCOUNT_SECRET_KEY=... # ключ кабинета +export UNITPAY_SECRET_KEY=... # project key +export UNITPAY_LOGIN=... # account login (account-level methods) +export UNITPAY_ACCOUNT_SECRET_KEY=... # account key ``` -## Сценарии +## Scenarios -| Файл | Сценарий | +| File | Scenario | | --- | --- | -| [paymentForm.php](paymentForm.php) | Платёжная форма на стороне Unitpay: `form()` строит URL на страницу оплаты; fluent-сеттеры (`setBackUrl`/`setCustomerEmail`/`setCustomerPhone`). | -| [initPaymentApi.php](initPaymentApi.php) | Server-to-server `initPayment`: обработка ответа `redirect` / `invoice` / `response`. | -| [receipt.php](receipt.php) | Фискальный чек по 54-ФЗ: позиции через `CashItem` + `setCashItems()`. | -| [webhook.php](webhook.php) | Обработчик вебхуков: проверка подписи и IP, ответы `check`/`pay`/`preauth`/`error`. | -| [paymentInfo.php](paymentInfo.php) | Информация о платеже (`getPayment`). | -| [refund.php](refund.php) | Возврат платежа, полный или частичный (`refundPayment`). | -| [twoStagePayment.php](twoStagePayment.php) | Двухстадийный платёж: `confirmPayment` (списание) / `cancelPayment` (разблокировка). | -| [subscriptions.php](subscriptions.php) | Подписки: список, информация, закрытие. | -| [payout.php](payout.php) | Выплаты (mass-payment) по СБП + статус. | -| [accountInfo.php](accountInfo.php) | Справочные вызовы (только для чтения): баланс, комиссии, курсы валют, BIN, способы оплаты. | -| [offsetAdvance.php](offsetAdvance.php) | Чек зачёта аванса (`offsetAdvance`) — создаёт фискальный чек по предоплате. | - -## Обработчик вебхуков локально - -По умолчанию `127.0.0.1` не доверенный. Только для локальной отладки повторов вебхука -с того же хоста включите его явным флагом (и **никогда** — в продакшене): +| [paymentForm.php](paymentForm.php) | Unitpay-hosted payment form: `form()` builds the URL to the payment page; fluent setters (`setBackUrl`/`setCustomerEmail`/`setCustomerPhone`). | +| [initPaymentApi.php](initPaymentApi.php) | Server-to-server `initPayment`: handling the `redirect` / `invoice` / `response` reply. | +| [receipt.php](receipt.php) | 54-FZ fiscal receipt: line items via `CashItem` + `setCashItems()`. | +| [webhook.php](webhook.php) | Webhook handler: signature and IP verification, `check`/`pay`/`preauth`/`error` responses. | +| [paymentInfo.php](paymentInfo.php) | Payment info (`getPayment`). | +| [refund.php](refund.php) | Payment refund, full or partial (`refundPayment`). | +| [twoStagePayment.php](twoStagePayment.php) | Two-stage payment: `confirmPayment` (capture) / `cancelPayment` (release). | +| [subscriptions.php](subscriptions.php) | Subscriptions: list, info, close. | +| [payout.php](payout.php) | Payouts (mass-payment) via SBP + status. | +| [accountInfo.php](accountInfo.php) | Reference calls (read-only): balance, commissions, currency rates, BIN, payment methods. | +| [offsetAdvance.php](offsetAdvance.php) | Advance-offset receipt (`offsetAdvance`) — creates a fiscal receipt for a prepayment. | + +## Webhook handler locally + +By default `127.0.0.1` is not trusted. For local debugging of webhook retries from the same +host only, enable it with an explicit flag (and **never** in production): ```sh UNITPAY_DEBUG_LOCAL=1 php -S localhost:8000 -t examples From a6f50782770a9a9a3b155fb09007620feb184b30 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Fri, 24 Jul 2026 15:38:55 +0300 Subject: [PATCH 27/30] fix: harden signing/allowlist edge cases and clear api() params on failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api(): clear the accumulated fluent-setter params in a finally (on both success and transport failure), symmetric with form(), so a stale receipt/customer no longer leaks into an unrelated later call on a reused instance; a retry after a failure must re-apply the setters - getSignature(): reject a null/empty secret up front — a public call must not silently hash with an empty secret (the appended null coerces to '' and drops out, yielding a plausible but secret-less signature) - setAllowedIps([]): document as fail-closed (an empty allowlist rejects every webhook, not a no-op) - transport seam: document the $headers argument the transport actually receives - add regression tests (empty-secret signing, fail-closed allowlist, cleared params after a failed call); record the api() behavior change in CHANGELOG --- CHANGELOG.md | 3 +++ UnitPay.php | 48 ++++++++++++++++++++++----------- tests/UnitPayAllowedIpsTest.php | 14 ++++++++++ tests/UnitPayApiTest.php | 12 ++++----- tests/UnitPaySignatureTest.php | 15 +++++++++++ 5 files changed, 71 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 701e83a..8af58ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ * Minimum PHP version raised to 7.4 * Hardening from code review: * `api()`: folds in only the fluent-setter parameters (cashItems/customerEmail/customerPhone/backUrl), not the whole set — a reused instance no longer leaks the key `form()` parameters or the stale signature into an unrelated `api()` call + * `api()`: the fluent-setter parameters are now cleared once the request has been attempted — on a transport failure too, not only on success — so a stale receipt/customer can no longer leak from a failed call into an unrelated later call on a reused instance (symmetric with `form()`); a retry after a failure must re-apply the setters * `setCashItems()`: throws on a json_encode failure (e.g. a product name that is not UTF-8) instead of silently attaching an empty 54-FZ receipt * `CashItem`: preserves a fractional count (weight/volume goods) instead of truncating to int * `form()`: throws on an empty secret instead of returning an unsigned URL — like `api()`/`checkHandlerRequest()` @@ -45,6 +46,8 @@ * `api()`: an empty explicit secretKey (e.g. a getenv() that did not resolve) falls back to the instance key instead of throwing * `httpGet()`: suppresses the file_get_contents warning so a URL containing the secret does not leak into the error log * `checkHandlerRequest()`: exposes the verified method/params via `getHandlerMethod()`/`getHandlerParams()`, so the consumer does not re-read $_GET + * `getSignature()`: rejects a null/empty secret up front — as a public method it must not silently hash with an empty secret (the appended null would coerce to '' and drop out, yielding a plausible but secret-less signature); the normal `form()`/`checkHandlerRequest()` paths already guarded this, this is defense-in-depth for direct calls + * docs: `setAllowedIps([])` is documented as fail-closed (an empty allowlist rejects every webhook rather than being a no-op); the `$transport` seam docblock now documents the `$headers` argument the transport actually receives * every SDK exception implements UnitpayExceptionInterface (UnitpayValidationException was added for the missing-parameter/secret/method cases) ### v2.0.6 — 2025-05-14 diff --git a/UnitPay.php b/UnitPay.php index 8e5f761..fda3b0e 100644 --- a/UnitPay.php +++ b/UnitPay.php @@ -639,7 +639,8 @@ class UnitPay /** * @param string $domain host only, e.g. "unitpay.ru" — without scheme or path (becomes "https://$domain/api"). - * @param callable|null $transport outbound HTTP transport for api(): fn(string $url): string|false. + * @param callable|null $transport outbound HTTP transport for api(): fn(string $url, string[] $headers): string|false. + * $headers carries the telemetry fingerprint; a transport may ignore it. * Defaults to file_get_contents(). Override to test api() without the network. * @param array|null $request inbound webhook array read by checkHandlerRequest(). * Defaults to $_GET. Override to test the handler without superglobals. @@ -663,6 +664,9 @@ public function __construct(string $domain, ?string $secretKey = null, ?callable * NOT touch the merchant IPs added via addAllowedIps() — they remain on top. * Use it to keep the SDK current when Unitpay's infrastructure changes without * waiting for a release, or to restore a list you fetched and cached yourself. + * + * Passing an empty array with no addAllowedIps() entries leaves the allowlist empty, + * so every webhook is rejected (fail-closed, not a no-op) — pass at least one IP/CIDR. * @link https://help.unitpay.ru/book-of-reference/ip-addresses * @param string[] $ips */ @@ -750,10 +754,18 @@ private function fetchUnitpayIps(): ?array * value (e.g. params[x][]=1), so non-scalars are coerced to '' — implode() emits no * warning and verification still fails, because the secret is appended regardless. * + * A null/empty secret is rejected up front: form()/checkHandlerRequest() already guard + * it before calling this, but as a public method it must not silently hash with an empty + * secret (is_scalar(null) is false, so the appended key would coerce to '' and drop out). + * * @param array $params + * @throws UnitpayValidationException when the secret key is unset/empty */ public function getSignature(array $params, ?string $method = null): string { + if (empty($this->secretKey)) { + throw new UnitpayValidationException('SecretKey is null'); + } unset($params['sign'], $params['signature'], $params[PHP_INT_MAX]); ksort($params); $params[] = $this->secretKey; @@ -952,13 +964,14 @@ public function setBackUrl(string $backUrl): self /** * Performs a server-to-server call to the Unitpay REST API. Fluent-setter params * are merged in (so setCashItems()->api('initPayment', ...) sends the receipt) and - * cleared only by a SUCCESSFUL call. A transport failure keeps them, so a retry - * goes out with the same receipt — hence the state is clean only after success: - * an unrelated call right AFTER a failure inherits the accumulated params (reset - * them explicitly or use a new instance if that is undesirable). Explicit $params - * take precedence. An explicit non-empty secretKey in $params overrides the - * instance key, so account-level methods (getPartner, getCommissions, payouts, ...) - * can use the account key. + * then cleared once the request has been attempted — on BOTH success and transport + * failure — so a reused instance never carries this call's receipt/customer into the + * next one (symmetric with form()). A retry after a failure must re-apply the setters. + * (Validation errors thrown before the request — unsupported method, missing param, + * empty secret — happen before the attempt, so they leave the accumulated params in + * place.) Explicit $params take precedence. An explicit non-empty secretKey in $params + * overrides the instance key, so account-level methods (getPartner, getCommissions, + * payouts, ...) can use the account key. * @param array $params * * @throws InvalidArgumentException @@ -994,14 +1007,19 @@ public function api(string $method, array $params = []): object PHP_QUERY_RFC3986 ); - $response = json_decode($this->httpGet($requestUrl, $this->fingerprintHeaders())); - if (!is_object($response)) { - throw new UnitpayTransportException('Temporary server error. Please try again later.'); - } - - $this->params = []; + // Clear the accumulated fluent-setter params once the request has been attempted, + // on both success and transport failure (finally), so a stale receipt/customer never + // leaks into an unrelated later call on a reused instance — symmetric with form(). + try { + $response = json_decode($this->httpGet($requestUrl, $this->fingerprintHeaders())); + if (!is_object($response)) { + throw new UnitpayTransportException('Temporary server error. Please try again later.'); + } - return $response; + return $response; + } finally { + $this->params = []; + } } /** diff --git a/tests/UnitPayAllowedIpsTest.php b/tests/UnitPayAllowedIpsTest.php index 8879ab8..330cb1f 100644 --- a/tests/UnitPayAllowedIpsTest.php +++ b/tests/UnitPayAllowedIpsTest.php @@ -183,6 +183,20 @@ public function testGetAllowedIpsDefaultsToBuiltinList(): void $this->assertSame(['31.186.100.49', '51.250.20.9'], $unitPay->getAllowedIps()); } + /** + * setAllowedIps([]) is fail-closed, not a no-op: an empty allowlist (with no + * addAllowedIps() entries) rejects every webhook rather than trusting all sources. + */ + public function testEmptyAllowlistRejectsEveryWebhook(): void + { + $unitPay = new UnitPay('unitpay.ru', self::SECRET, null, $this->validRequest(), self::DEFAULT_IP); + $unitPay->setAllowedIps([]); + + $this->assertSame([], $unitPay->getAllowedIps()); + $this->expectException(UnitpayIpException::class); + $unitPay->checkHandlerRequest(); + } + // --- matcher cache reset --------------------------------------------- public function testAddAllowedIpsInvalidatesTheMatcherCache(): void diff --git a/tests/UnitPayApiTest.php b/tests/UnitPayApiTest.php index 6158348..407e43c 100644 --- a/tests/UnitPayApiTest.php +++ b/tests/UnitPayApiTest.php @@ -172,11 +172,11 @@ public function testFluentSetterParamsDoNotBleedIntoNextApiCall(): void } /** - * Fluent-setter params are cleared only by a SUCCESSFUL api() call. After a transport - * failure they are retained, so a retry goes out with the same receipt rather than - * silently without it. + * Fluent-setter params are cleared once the request has been attempted — on a transport + * failure too, not only on success — so a stale receipt cannot leak into an unrelated + * later call on a reused instance. A retry must re-apply the setters (symmetric with form()). */ - public function testFluentSetterParamsAreRetainedAfterFailedApiCall(): void + public function testFluentSetterParamsAreClearedAfterFailedApiCall(): void { $urls = []; $calls = 0; @@ -198,9 +198,9 @@ public function testFluentSetterParamsAreRetainedAfterFailedApiCall(): void $unitPay->api('getPayment', ['paymentId' => 2]); - // The receipt survived the failure and went out with the retry. + // The receipt was consumed by the failed call and did NOT leak into the next one. $this->assertStringContainsString('cashItems=', $urls[0]); - $this->assertStringContainsString('cashItems=', $urls[1]); + $this->assertStringNotContainsString('cashItems=', $urls[1]); } public function testNonObjectResponseIsReportedAsTemporaryServerError(): void diff --git a/tests/UnitPaySignatureTest.php b/tests/UnitPaySignatureTest.php index fd6d456..8d38ea1 100644 --- a/tests/UnitPaySignatureTest.php +++ b/tests/UnitPaySignatureTest.php @@ -3,6 +3,7 @@ namespace Tests; use UnitPay; +use UnitpayValidationException; use PHPUnit\Framework\TestCase; final class UnitPaySignatureTest extends TestCase @@ -14,6 +15,20 @@ protected function setUp(): void $this->unitPay = new UnitPay('unitpay.ru', 'secret'); } + /** + * Defense-in-depth: getSignature() is public, so a direct call with no secret must + * throw rather than silently hash with an empty secret (the appended null coerces to + * '' and drops out, yielding a plausible but secret-less signature). + */ + public function testEmptySecretIsRejected(): void + { + $unitPay = new UnitPay('unitpay.ru', null); + + $this->expectException(UnitpayValidationException::class); + $this->expectExceptionMessage('SecretKey is null'); + $unitPay->getSignature(['a' => '1']); + } + public function testSignatureMatchesDocumentedFormula(): void { // sha256( {up}secretKey ) From b6823d217106c54298f49471e522c1abc401f765 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Fri, 24 Jul 2026 15:41:50 +0300 Subject: [PATCH 28/30] docs: split README into landing page and docs/ topic pages Trim README to a ~90-line landing page and move the detailed sections into docs/: getting-started, receipts, api-methods, webhooks, telemetry. Each page has prev/next navigation and a See Also footer; no content was lost. README keeps badges, quick start, key features and a documentation table. --- README.md | 411 +++++----------------------------------- docs/api-methods.md | 69 +++++++ docs/getting-started.md | 180 ++++++++++++++++++ docs/receipts.md | 34 ++++ docs/telemetry.md | 22 +++ docs/webhooks.md | 109 +++++++++++ 6 files changed, 462 insertions(+), 363 deletions(-) create mode 100644 docs/api-methods.md create mode 100644 docs/getting-started.md create mode 100644 docs/receipts.md create mode 100644 docs/telemetry.md create mode 100644 docs/webhooks.md diff --git a/README.md b/README.md index 67d5020..88a0b66 100644 --- a/README.md +++ b/README.md @@ -6,402 +6,87 @@ [![Total Downloads](https://img.shields.io/packagist/dt/unitpay/php-sdk.svg)](https://packagist.org/packages/unitpay/php-sdk) [![License](https://img.shields.io/packagist/l/unitpay/php-sdk.svg)](LICENSE.md) -PHP SDK for [Unitpay.ru](https://unitpay.ru). +> PHP SDK for the [Unitpay.ru](https://unitpay.ru) payment REST API. -Documentation: [help.unitpay.ru](https://help.unitpay.ru) +A thin, stateless SDK: build a signed redirect to Unitpay's hosted payment page or call +the API server-to-server, attach 54-FZ fiscal receipts, and verify inbound webhooks. The +whole library is a single file in the **global namespace**. + +Official Unitpay documentation: [help.unitpay.ru](https://help.unitpay.ru) ## Requirements * PHP >= 7.4 * ext-json -No runtime dependencies. The whole SDK is a single file — [`UnitPay.php`](UnitPay.php) — +No runtime dependencies. The SDK is a single file — [`UnitPay.php`](UnitPay.php) — exposing two classes in the **global namespace**: `UnitPay` and `CashItem`. -## Examples - -These are just some quick examples. The [`examples/`](examples) folder has -runnable samples for every method group: - -* [`paymentForm.php`](examples/paymentForm.php) / [`initPaymentApi.php`](examples/initPaymentApi.php) — create a payment (form / API) -* [`receipt.php`](examples/receipt.php) — 54-FZ fiscal receipt via `CashItem` -* [`paymentInfo.php`](examples/paymentInfo.php) — `getPayment` -* [`webhook.php`](examples/webhook.php) — webhook handler (`check` / `pay` / `error`) -* [`refund.php`](examples/refund.php) — `refundPayment` -* [`twoStagePayment.php`](examples/twoStagePayment.php) — `confirmPayment` / `cancelPayment` -* [`subscriptions.php`](examples/subscriptions.php) — list / info / close subscriptions -* [`payout.php`](examples/payout.php) — payouts (mass-payment) + SBP bank list -* [`accountInfo.php`](examples/accountInfo.php) — balance, commissions, rates, BIN, methods -* [`offsetAdvance.php`](examples/offsetAdvance.php) — advance-offset fiscal receipt - -### Payment integration using Unitpay form - -```php -setBackUrl('https://domain.com') - ->setCustomerEmail('customer@domain.com') - ->setCustomerPhone('79001235555') - ->setCashItems([ - new CashItem($itemName, 1, $orderSum) - ]); - -$redirectUrl = $unitpay->form( - $publicId, - $orderSum, - $orderId, - $orderDesc, - $orderCurrency -); +## Installation -header("Location: " . $redirectUrl); +```sh +composer require unitpay/php-sdk ``` -### Fiscal receipts (54-FZ) - -Attach receipt line items with `CashItem` and `setCashItems()` (works with both -`form()` and `api('initPayment', ...)`). The constructor takes the required -fields; optional fields are set via fluent setters and are serialized only when -set: +Then load the Composer autoloader — its classmap registers both `UnitPay` and `CashItem`: ```php -$item = new CashItem( - 'Iphone 6 Skin Cover', // name - 1, // count - 900, // price - CashItem::NDS_20, // VAT rate - CashItem::PAYMENT_OBJECT_COMMODITY, // payment object - CashItem::PAYMENT_METHOD_PAYMENT_FULL -); -$item->setMeasure(CashItem::MEASURE_ITEM); - -$unitpay->setCashItems([$item]); +require __DIR__ . '/vendor/autoload.php'; ``` -VAT rates (`NDS_*`), payment objects (`PAYMENT_OBJECT_*`), payment methods -(`PAYMENT_METHOD_*`) and units of measure (`MEASURE_*`) are exposed as constants -on `CashItem`. - -> Since 2026 the backend fiscalizes `NDS_20` (`vat20`) as VAT **22%** — there is -> no separate path for "real" 20%. Pick the rate that matches the actual receipt -> (see [CHANGELOG.md](CHANGELOG.md)). +See [Getting Started](docs/getting-started.md) for the `dev-master` and direct-download +options. -### Payment integration using Unitpay API +## Quick Start ```php api('initPayment', [ - 'account' => $orderId, - 'desc' => $orderDesc, - 'sum' => $orderSum, - 'paymentType' => UnitPay::PAYMENT_TYPE_CARD, - 'currency' => $orderCurrency, - 'projectId' => $projectId -]); - -// If need user redirect on Payment Gate -if (isset($response->result->type) - && $response->result->type === 'redirect') { - // Url on PaymentGate - $redirectUrl = $response->result->redirectUrl; - // Payment ID in Unitpay (you can save it) - $paymentId = $response->result->paymentId; - // User redirect - header("Location: " . $redirectUrl); - -// If without redirect (invoice) -} elseif (isset($response->result->type) - && $response->result->type === 'invoice') { - // Url on receipt page in Unitpay - $receiptUrl = $response->result->receiptUrl; - // Payment ID in Unitpay (you can save it) - $paymentId = $response->result->paymentId; - // Invoice Id in Payment Gate (you can save it) - $invoiceId = $response->result->invoiceId; - // User redirect - header("Location: " . $receiptUrl); - -// If processed without redirect (e.g. recurring/subscription charge) -} elseif (isset($response->result->type) - && $response->result->type === 'response') { - // Payment ID in Unitpay (you can save it) - $paymentId = $response->result->paymentId; - // Human-readable result message - $message = $response->result->message; - // Optional status page in Unitpay: $response->result->statusUrl - print $message; - -// If error during api request -} elseif (isset($response->error->message)) { - $error = $response->error->message; - print 'Error: '.$error; -} -``` - -### Handler sample - -```php -checkHandlerRequest(); - - // Read the verified request from the SDK (honors the overridden request, not $_GET) - $method = $unitpay->getHandlerMethod(); - $params = $unitpay->getHandlerParams(); - - // Very important! Validate request with your order data, before complete order - if ( - $params['orderSum'] != $orderSum || - $params['orderCurrency'] != $orderCurrency || - $params['account'] != $orderId || - $params['projectId'] != $projectId - ) { - // logging data and throw exception - throw new InvalidArgumentException('Order validation Error!'); - } - switch ($method) { - // Just check order (check server status, check order in DB and etc) - case 'check': - echo $unitpay->getSuccessHandlerResponse('Check Success. Ready to pay.'); - break; - // Method Pay means that the money received - case 'pay': - // Please complete order - echo $unitpay->getSuccessHandlerResponse('Pay Success'); - break; - // Method Preauth means a two-stage hold: funds are only HELD, not captured yet. - case 'preauth': - // Do NOT deliver goods/services here; wait for 'pay'. Just acknowledge receipt. - echo $unitpay->getSuccessHandlerResponse('Preauth received. Funds held, awaiting capture.'); - break; - // Method Error means that an error has occurred. - case 'error': - // Please log error text. - echo $unitpay->getSuccessHandlerResponse('Error logged'); - break; - // Unknown method: do not leave an empty response (Unitpay would treat it as a failure). - default: - throw new InvalidArgumentException('Unexpected handler method: ' . $method); - } -// Oops! Something went wrong. -} catch (Exception $e) { - echo $unitpay->getErrorHandlerResponse($e->getMessage()); -} -``` - -> The handler trusts a request only when the SHA-256 signature **and** the -> source IP both match. The built-in IP allowlist changes on Unitpay's side from -> time to time, so keep it fresh from the published feed instead of waiting for a -> release: -> -> * `$unitpay->refreshAllowedIps()` pulls the current list from -> `https:///ips/ips_webhooks.json`. It is fail-safe — on any network or -> parse error it keeps the built-in list and never throws. It makes a blocking -> HTTP request, so **don't call it on every webhook**: run it on a schedule -> (e.g. a daily cron), cache `getAllowedIps()`, and feed the cached list back -> with `setAllowedIps($cached)` in the handler. -> * `$unitpay->addAllowedIps(['1.2.3.4', ...])` adds your own IPs (e.g. a proxy or -> relay) on top of the Unitpay list; they persist across `refreshAllowedIps()`. -> * `$unitpay->setAllowedIps([...])` replaces the Unitpay list outright. -> * Override `getIp()` if you run behind a proxy. -> -> ```php -> // Cron: refresh once, cache the result on your side. -> $ips = (new UnitPay($domain, $secretKey))->refreshAllowedIps()->getAllowedIps(); -> cache_set('unitpay_ips', $ips); -> -> // Handler: feed the cached list, no network call per callback. -> (new UnitPay($domain, $secretKey)) -> ->setAllowedIps(cache_get('unitpay_ips')) -> ->checkHandlerRequest(); -> ``` - -## API methods - -All methods are called through `api('', [...])`. `secretKey` is added -automatically from the constructor, so pass only the business params below. -Full parameters and response formats are in the -[official API documentation](https://help.unitpay.ru). - -| Method | Required params | Purpose | -| --- | --- | --- | -| `initPayment` | `account`, `sum`, `projectId`, `paymentType` | Create a payment | -| `getPayment` | `paymentId` | Payment info | -| `refundPayment` | `paymentId` (+ optional `sum`) | Refund a payment (full or partial) | -| `confirmPayment` | `paymentId` | Confirm (capture) a two-stage payment | -| `cancelPayment` | `paymentId` | Cancel (release) a two-stage payment | -| `listSubscriptions` | `projectId` (+ optional `all`) | List project subscriptions | -| `getSubscription` | `subscriptionId` | Subscription info | -| `closeSubscription` | `subscriptionId` | Close a subscription | -| `getMethodsAvailable` | `projectId` | Payment methods available on the project | -| `getCommissions` | `projectId`, `login` | Acquiring commissions for a project | -| `getCurrencyCourses` | `login` | Currency conversion rates | -| `getPartner` | `login` | Account balance | -| `offsetAdvance` | `login`, `paymentId` (+ optional `cashItems`) | Advance-offset fiscal receipt | -| `massPayment` | `login`, `transactionId`, `sum`, `purse`, `paymentType` (+ `memberId` for SBP) | Create a payout | -| `massPaymentStatus` | `login`, `transactionId` | Payout status | -| `massPaymentAvailableAmount` | `login`, `sum`, `purse`, `paymentType` | Balance available for payout | -| `massPaymentCommissions` | `login` | Payout commissions | -| `getSbpBankList` | `login` | SBP participant banks | -| `getBinInfo` | `login`, `bin` | Card info by BIN | +$unitpay + ->setBackUrl('https://domain.com') + ->setCustomerEmail('customer@domain.com') + ->setCashItems([new CashItem('Iphone 6 Skin Cover', 1, 900)]); -For the account-level methods (`getCommissions`, `getCurrencyCourses`, -`getPartner`, `offsetAdvance` and all payout methods) the `secretKey` is the -**account** key (profile), not the project key, and `login` is the account email. -Pass the account key explicitly in the call — it overrides the constructor -(project) key: +$redirectUrl = $unitpay->form($publicId, 900, $orderId, 'Payment for item', 'RUB'); -```php -$response = $unitpay->api('getPartner', [ - 'login' => 'partner@example.com', - 'secretKey' => $accountKey, // overrides the project key from the constructor -]); +header('Location: ' . $redirectUrl); ``` -For SBP payouts pass `memberId` obtained from `getSbpBankList`. - -Example — refund a payment: +Prefer a server-to-server call? Use `$unitpay->api('initPayment', [...])` — see +[Getting Started](docs/getting-started.md). -```php -$response = $unitpay->api('refundPayment', [ - 'paymentId' => 123456, - // 'sum' => 100, // optional: partial refund -]); - -if (isset($response->result->message)) { - print $response->result->message; -} elseif (isset($response->error->message)) { - print 'Error: ' . $response->error->message; -} -``` +## Key Features -Note: `confirmPayment` and `cancelPayment` return a top-level `message` -(`$response->message`), not `$response->result->message`. +* **Hosted form or API** — `form()` builds a signed redirect URL; `api('initPayment', ...)` + does a server-to-server call. +* **54-FZ fiscal receipts** — attach `CashItem` line items to any payment. +* **Secure webhooks** — `checkHandlerRequest()` trusts a callback only when both the + SHA-256 signature **and** the source-IP allowlist pass. +* **Dynamic IP allowlist** — refresh Unitpay's webhook IPs from the published feed, + fail-safe. +* **Typed exceptions** — all implement `UnitpayExceptionInterface`. +* **Zero dependencies** — one file, `ext-json` only (`ext-curl` optional). -## Telemetry +## Documentation -The SDK adds a small, **anonymous** version fingerprint to the requests it -already makes, so Unitpay can see which SDK/PHP versions are in the field. This -is standard SDK self-identification, like any `User-Agent` — it makes **no extra -network calls** and never sends secrets, amounts, or customer data: +| Guide | Description | +|-------|-------------| +| [Getting Started](docs/getting-started.md) | Requirements, installation, first payment (form / API) | +| [Fiscal Receipts](docs/receipts.md) | 54-FZ receipt line items via `CashItem` | +| [API Methods](docs/api-methods.md) | Full `api()` method reference and account-level calls | +| [Webhooks](docs/webhooks.md) | Payment handler + keeping the IP allowlist fresh | +| [Telemetry](docs/telemetry.md) | Anonymous SDK version fingerprint | -* `api()` requests carry a `User-Agent: unitpay-php-sdk/ api/` header and - an `X-Unitpay-Client` JSON header with `sdk_version`, `api_version` (the Unitpay - API surface targeted), `lang`, `lang_version`, `platform` (coarse OS family - only), `publisher`. -* `form()` URLs carry an `sdk=php__` query parameter (outside - the signature — it does not affect it). +Runnable samples for every method group live in [`examples/`](examples). -That is the whole of it — there is no separate telemetry endpoint, no opt-in -beacon, and nothing to configure. - -## Installation - -### Composer (recommended) - -```sh -composer require unitpay/php-sdk -``` - -Then load the Composer autoloader — its classmap registers both `UnitPay` and -`CashItem`: - -```php -require __DIR__ . '/vendor/autoload.php'; -``` - -To follow the default branch (latest changes) instead of the newest tag: - -```sh -composer require unitpay/php-sdk:dev-master -``` - -### Direct download +## Contributing -Download the [latest version](https://github.com/unitpay/php-sdk/archive/master.zip), -unzip it and `require` the single file directly: +Please feel free to contribute to this project! Pull requests and feature requests +welcome! -```php -require '/path/to/UnitPay.php'; -``` - -## Contributing +## License -Please feel free to contribute to this project! Pull requests and feature requests welcome! +MIT — see [LICENSE.md](LICENSE.md). diff --git a/docs/api-methods.md b/docs/api-methods.md new file mode 100644 index 0000000..f00f515 --- /dev/null +++ b/docs/api-methods.md @@ -0,0 +1,69 @@ +# API Methods + +[← Fiscal Receipts](receipts.md) · [Back to README](../README.md) · [Webhooks →](webhooks.md) + +All methods are called through `api('', [...])`. `secretKey` is added +automatically from the constructor, so pass only the business params below. Full +parameters and response formats are in the +[official API documentation](https://help.unitpay.ru). + +| Method | Required params | Purpose | +| --- | --- | --- | +| `initPayment` | `account`, `sum`, `projectId`, `paymentType` | Create a payment | +| `getPayment` | `paymentId` | Payment info | +| `refundPayment` | `paymentId` (+ optional `sum`) | Refund a payment (full or partial) | +| `confirmPayment` | `paymentId` | Confirm (capture) a two-stage payment | +| `cancelPayment` | `paymentId` | Cancel (release) a two-stage payment | +| `listSubscriptions` | `projectId` (+ optional `all`) | List project subscriptions | +| `getSubscription` | `subscriptionId` | Subscription info | +| `closeSubscription` | `subscriptionId` | Close a subscription | +| `getMethodsAvailable` | `projectId` | Payment methods available on the project | +| `getCommissions` | `projectId`, `login` | Acquiring commissions for a project | +| `getCurrencyCourses` | `login` | Currency conversion rates | +| `getPartner` | `login` | Account balance | +| `offsetAdvance` | `login`, `paymentId` (+ optional `cashItems`) | Advance-offset fiscal receipt | +| `massPayment` | `login`, `transactionId`, `sum`, `purse`, `paymentType` (+ `memberId` for SBP) | Create a payout | +| `massPaymentStatus` | `login`, `transactionId` | Payout status | +| `massPaymentAvailableAmount` | `login`, `sum`, `purse`, `paymentType` | Balance available for payout | +| `massPaymentCommissions` | `login` | Payout commissions | +| `getSbpBankList` | `login` | SBP participant banks | +| `getBinInfo` | `login`, `bin` | Card info by BIN | + +## Account-level methods + +For the account-level methods (`getCommissions`, `getCurrencyCourses`, `getPartner`, +`offsetAdvance` and all payout methods) the `secretKey` is the **account** key (profile), +not the project key, and `login` is the account email. Pass the account key explicitly in +the call — it overrides the constructor (project) key: + +```php +$response = $unitpay->api('getPartner', [ + 'login' => 'partner@example.com', + 'secretKey' => $accountKey, // overrides the project key from the constructor +]); +``` + +For SBP payouts pass `memberId` obtained from `getSbpBankList`. + +## Example — refund a payment + +```php +$response = $unitpay->api('refundPayment', [ + 'paymentId' => 123456, + // 'sum' => 100, // optional: partial refund +]); + +if (isset($response->result->message)) { + print $response->result->message; +} elseif (isset($response->error->message)) { + print 'Error: ' . $response->error->message; +} +``` + +Note: `confirmPayment` and `cancelPayment` return a top-level `message` +(`$response->message`), not `$response->result->message`. + +## See Also + +* [Getting Started](getting-started.md) — the `initPayment` flow in full +* [Webhooks](webhooks.md) — handle the callbacks a payment triggers diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..a9a4b20 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,180 @@ +# Getting Started + +[Back to README](../README.md) · [Fiscal Receipts →](receipts.md) + +## Requirements + +* PHP >= 7.4 +* ext-json + +No runtime dependencies. The whole SDK is a single file — [`UnitPay.php`](../UnitPay.php) — +exposing two classes in the **global namespace**: `UnitPay` and `CashItem`. `ext-curl` is +optional: `api()` uses it when present and falls back to `file_get_contents()` otherwise. + +## Installation + +### Composer (recommended) + +```sh +composer require unitpay/php-sdk +``` + +Then load the Composer autoloader — its classmap registers both `UnitPay` and `CashItem`: + +```php +require __DIR__ . '/vendor/autoload.php'; +``` + +To follow the default branch (latest changes) instead of the newest tag: + +```sh +composer require unitpay/php-sdk:dev-master +``` + +### Direct download + +Download the [latest version](https://github.com/unitpay/php-sdk/archive/master.zip), +unzip it and `require` the single file directly: + +```php +require '/path/to/UnitPay.php'; +``` + +## Create a payment (Unitpay hosted form) + +`form()` builds a signed redirect URL to Unitpay's hosted payment page. Fluent setters +(`setBackUrl`, `setCustomerEmail`, `setCustomerPhone`, `setCashItems`) are optional and +apply to both `form()` and `api('initPayment', ...)`. + +```php +setBackUrl('https://domain.com') + ->setCustomerEmail('customer@domain.com') + ->setCustomerPhone('79001235555') + ->setCashItems([ + new CashItem($itemName, 1, $orderSum) + ]); + +$redirectUrl = $unitpay->form( + $publicId, + $orderSum, + $orderId, + $orderDesc, + $orderCurrency +); + +header("Location: " . $redirectUrl); +``` + +## Create a payment (Unitpay API) + +`api('initPayment', ...)` does a server-to-server call. `secretKey` is added +automatically from the constructor. `paymentType` is a payment-method code from the +reference (`UnitPay::PAYMENT_TYPE_*` constants): `card`, `cardInvoice`, `sbp`, `sberpay`, +`tinkoffpay`, `paypal`, `webmoney` — see the +[payment-system codes](https://help.unitpay.ru/book-of-reference/payment-system-codes). + +```php +api('initPayment', [ + 'account' => $orderId, + 'desc' => $orderDesc, + 'sum' => $orderSum, + 'paymentType' => UnitPay::PAYMENT_TYPE_CARD, + 'currency' => $orderCurrency, + 'projectId' => $projectId +]); + +// If need user redirect on Payment Gate +if (isset($response->result->type) + && $response->result->type === 'redirect') { + $redirectUrl = $response->result->redirectUrl; + $paymentId = $response->result->paymentId; // Payment ID in Unitpay (you can save it) + header("Location: " . $redirectUrl); + +// If without redirect (invoice) +} elseif (isset($response->result->type) + && $response->result->type === 'invoice') { + $receiptUrl = $response->result->receiptUrl; + $paymentId = $response->result->paymentId; + $invoiceId = $response->result->invoiceId; + header("Location: " . $receiptUrl); + +// If processed without redirect (e.g. recurring/subscription charge) +} elseif (isset($response->result->type) + && $response->result->type === 'response') { + $paymentId = $response->result->paymentId; + $message = $response->result->message; // Human-readable result message + print $message; + +// If error during api request +} elseif (isset($response->error->message)) { + $error = $response->error->message; + print 'Error: '.$error; +} +``` + +## Runnable examples + +The [`examples/`](../examples) folder has runnable samples for every method group (serve +them over HTTP, e.g. `php -S localhost:8000 -t examples`): + +* [`paymentForm.php`](../examples/paymentForm.php) / [`initPaymentApi.php`](../examples/initPaymentApi.php) — create a payment (form / API) +* [`receipt.php`](../examples/receipt.php) — 54-FZ fiscal receipt via `CashItem` +* [`paymentInfo.php`](../examples/paymentInfo.php) — `getPayment` +* [`webhook.php`](../examples/webhook.php) — webhook handler (`check` / `pay` / `error`) +* [`refund.php`](../examples/refund.php) — `refundPayment` +* [`twoStagePayment.php`](../examples/twoStagePayment.php) — `confirmPayment` / `cancelPayment` +* [`subscriptions.php`](../examples/subscriptions.php) — list / info / close subscriptions +* [`payout.php`](../examples/payout.php) — payouts (mass-payment) + SBP bank list +* [`accountInfo.php`](../examples/accountInfo.php) — balance, commissions, rates, BIN, methods +* [`offsetAdvance.php`](../examples/offsetAdvance.php) — advance-offset fiscal receipt + +## See Also + +* [Fiscal Receipts](receipts.md) — attach 54-FZ receipt line items with `CashItem` +* [API Methods](api-methods.md) — the full `api()` method reference +* [Webhooks](webhooks.md) — verify inbound payment callbacks diff --git a/docs/receipts.md b/docs/receipts.md new file mode 100644 index 0000000..0ba1e87 --- /dev/null +++ b/docs/receipts.md @@ -0,0 +1,34 @@ +# Fiscal Receipts (54-FZ) + +[← Getting Started](getting-started.md) · [Back to README](../README.md) · [API Methods →](api-methods.md) + +Attach receipt line items with `CashItem` and `setCashItems()` (works with both `form()` +and `api('initPayment', ...)`). The constructor takes the required fields; optional fields +are set via fluent setters and are serialized only when set: + +```php +$item = new CashItem( + 'Iphone 6 Skin Cover', // name + 1, // count + 900, // price + CashItem::NDS_20, // VAT rate + CashItem::PAYMENT_OBJECT_COMMODITY, // payment object + CashItem::PAYMENT_METHOD_PAYMENT_FULL +); +$item->setMeasure(CashItem::MEASURE_ITEM); + +$unitpay->setCashItems([$item]); +``` + +VAT rates (`NDS_*`), payment objects (`PAYMENT_OBJECT_*`), payment methods +(`PAYMENT_METHOD_*`) and units of measure (`MEASURE_*`) are exposed as constants on +`CashItem`. + +> Since 2026 the backend fiscalizes `NDS_20` (`vat20`) as VAT **22%** — there is no +> separate path for "real" 20%. Pick the rate that matches the actual receipt (see +> [CHANGELOG.md](../CHANGELOG.md)). + +## See Also + +* [Getting Started](getting-started.md) — create a payment with `form()` or `api()` +* [API Methods](api-methods.md) — `offsetAdvance` and other receipt-related methods diff --git a/docs/telemetry.md b/docs/telemetry.md new file mode 100644 index 0000000..d6497ed --- /dev/null +++ b/docs/telemetry.md @@ -0,0 +1,22 @@ +# Telemetry + +[← Webhooks](webhooks.md) · [Back to README](../README.md) + +The SDK adds a small, **anonymous** version fingerprint to the requests it already makes, +so Unitpay can see which SDK/PHP versions are in the field. This is standard SDK +self-identification, like any `User-Agent` — it makes **no extra network calls** and never +sends secrets, amounts, or customer data: + +* `api()` requests carry a `User-Agent: unitpay-php-sdk/ api/` header and an + `X-Unitpay-Client` JSON header with `sdk_version`, `api_version` (the Unitpay API surface + targeted), `lang`, `lang_version`, `platform` (coarse OS family only), `publisher`. +* `form()` URLs carry an `sdk=php__` query parameter (outside the + signature — it does not affect it). + +That is the whole of it — there is no separate telemetry endpoint, no opt-in beacon, and +nothing to configure. + +## See Also + +* [Getting Started](getting-started.md) — the `api()` and `form()` calls that carry the fingerprint +* [API Methods](api-methods.md) — the full `api()` method surface diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..e6616ad --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,109 @@ +# Webhooks (Payment Handler) + +[← API Methods](api-methods.md) · [Back to README](../README.md) · [Telemetry →](telemetry.md) + +The handler trusts a request only when the SHA-256 signature **and** the source IP both +match. Read the verified request from the SDK (`getHandlerMethod()` / `getHandlerParams()`) +rather than from `$_GET` directly. + +```php +checkHandlerRequest(); + + // Read the verified request from the SDK (honors the overridden request, not $_GET) + $method = $unitpay->getHandlerMethod(); + $params = $unitpay->getHandlerParams(); + + // Very important! Validate request with your order data, before complete order + if ( + $params['orderSum'] != $orderSum || + $params['orderCurrency'] != $orderCurrency || + $params['account'] != $orderId || + $params['projectId'] != $projectId + ) { + // logging data and throw exception + throw new InvalidArgumentException('Order validation Error!'); + } + switch ($method) { + // Just check order (check server status, check order in DB and etc) + case 'check': + echo $unitpay->getSuccessHandlerResponse('Check Success. Ready to pay.'); + break; + // Method Pay means that the money received + case 'pay': + // Please complete order + echo $unitpay->getSuccessHandlerResponse('Pay Success'); + break; + // Method Preauth means a two-stage hold: funds are only HELD, not captured yet. + case 'preauth': + // Do NOT deliver goods/services here; wait for 'pay'. Just acknowledge receipt. + echo $unitpay->getSuccessHandlerResponse('Preauth received. Funds held, awaiting capture.'); + break; + // Method Error means that an error has occurred. + case 'error': + // Please log error text. + echo $unitpay->getSuccessHandlerResponse('Error logged'); + break; + // Unknown method: do not leave an empty response (Unitpay would treat it as a failure). + default: + throw new InvalidArgumentException('Unexpected handler method: ' . $method); + } +// Oops! Something went wrong. +} catch (Exception $e) { + echo $unitpay->getErrorHandlerResponse($e->getMessage()); +} +``` + +## Keeping the IP allowlist fresh + +The built-in IP allowlist changes on Unitpay's side from time to time, so keep it fresh +from the published feed instead of waiting for a release: + +* `$unitpay->refreshAllowedIps()` pulls the current list from + `https:///ips/ips_webhooks.json`. It is fail-safe — on any network or parse + error it keeps the built-in list and never throws. It makes a blocking HTTP request, so + **don't call it on every webhook**: run it on a schedule (e.g. a daily cron), cache + `getAllowedIps()`, and feed the cached list back with `setAllowedIps($cached)` in the + handler. +* `$unitpay->addAllowedIps(['1.2.3.4', ...])` adds your own IPs (e.g. a proxy or relay) on + top of the Unitpay list; they persist across `refreshAllowedIps()`. +* `$unitpay->setAllowedIps([...])` replaces the Unitpay list outright. +* Override `getIp()` if you run behind a proxy (the check uses `REMOTE_ADDR`, not the + spoofable `X-Forwarded-For`). + +```php +// Cron: refresh once, cache the result on your side. +$ips = (new UnitPay($domain, $secretKey))->refreshAllowedIps()->getAllowedIps(); +cache_set('unitpay_ips', $ips); + +// Handler: feed the cached list, no network call per callback. +(new UnitPay($domain, $secretKey)) + ->setAllowedIps(cache_get('unitpay_ips')) + ->checkHandlerRequest(); +``` + +## See Also + +* [API Methods](api-methods.md) — the `api()` calls that trigger these callbacks +* [Getting Started](getting-started.md) — create the payments being confirmed here From 21e06ba7a32c02baf330440f55c8f305df10820a Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Fri, 24 Jul 2026 15:41:50 +0300 Subject: [PATCH 29/30] ci: add concurrency, permissions, dependency cache and audit Add a concurrency group with cancel-in-progress, least-privilege permissions (contents: read) and a workflow_dispatch trigger. Install and cache dependencies via ramsey/composer-install and add a non-blocking composer audit step. The PHP 7.4-8.4 matrix and job names are unchanged. --- .github/workflows/ci.yml | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b670e5..75868f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,14 @@ on: push: branches: [ master ] pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read jobs: tests: @@ -24,7 +32,10 @@ jobs: coverage: none - name: Install dependencies - run: composer update --prefer-dist --no-progress + uses: ramsey/composer-install@v3 + with: + dependency-versions: highest + composer-options: "--prefer-dist" - name: Lint run: composer lint @@ -46,7 +57,10 @@ jobs: coverage: none - name: Install dependencies - run: composer update --prefer-dist --no-progress + uses: ramsey/composer-install@v3 + with: + dependency-versions: highest + composer-options: "--prefer-dist" - name: Code style (php-cs-fixer) run: composer cs-check @@ -56,3 +70,7 @@ jobs: - name: Mess detection (PHPMD) run: composer md + + - name: Security audit (composer) + run: composer audit + continue-on-error: true From 086fbd2921189085dc1c9e5e8ee7fe72ee9b4fe3 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Fri, 24 Jul 2026 15:41:50 +0300 Subject: [PATCH 30/30] chore: add markdownlint config Pin the unordered-list style to asterisk (MD004) and disable MD060 and MD013 to match the repository's existing markdown style (padded tables, long lines). Export-ignore the config from the Composer dist. --- .gitattributes | 1 + .markdownlint.json | 6 ++++++ 2 files changed, 7 insertions(+) create mode 100644 .markdownlint.json diff --git a/.gitattributes b/.gitattributes index 650d880..5f07965 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,7 @@ /.github export-ignore /.gitattributes export-ignore /.gitignore export-ignore +/.markdownlint.json export-ignore /.vscode export-ignore /.php-cs-fixer.dist.php export-ignore /examples export-ignore diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..2ba0679 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,6 @@ +{ + "default": true, + "MD004": { "style": "asterisk" }, + "MD013": false, + "MD060": false +}