From 2b9f1926d34db9dfbbbdfe467120cc7d47d67b59 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Fri, 24 Jul 2026 17:05:58 +0300 Subject: [PATCH 1/6] refactor: introduce PSR-4 src/ skeleton, Exception and Model layers Add PSR-4 autoload for the Unitpay\ namespace alongside the existing classmap (strangler bridge) and wire src/ into phpstan, php-cs-fixer and the lint glob. Extract the exception hierarchy into src/Exception/ and CashItem plus the 54-FZ dictionaries (Nds, PaymentObject, PaymentMethod, Measure, PaymentType) into src/Model/ and src/Model/Enum/ as PHP 7.4-safe const-classes. The single-file UnitPay.php still powers the SDK; the new layers only coexist. --- .php-cs-fixer.dist.php | 2 +- composer.json | 5 +- phpstan.neon | 1 + src/Exception/UnitpayExceptionInterface.php | 14 ++ src/Exception/UnitpayIpException.php | 8 + src/Exception/UnitpaySignatureException.php | 8 + src/Exception/UnitpayTransportException.php | 8 + .../UnitpayUnsupportedMethodException.php | 8 + src/Exception/UnitpayValidationException.php | 8 + src/Model/CashItem.php | 226 ++++++++++++++++++ src/Model/Enum/Measure.php | 59 +++++ src/Model/Enum/Nds.php | 40 ++++ src/Model/Enum/PaymentMethod.php | 19 ++ src/Model/Enum/PaymentObject.php | 72 ++++++ src/Model/Enum/PaymentType.php | 29 +++ 15 files changed, 505 insertions(+), 2 deletions(-) create mode 100644 src/Exception/UnitpayExceptionInterface.php create mode 100644 src/Exception/UnitpayIpException.php create mode 100644 src/Exception/UnitpaySignatureException.php create mode 100644 src/Exception/UnitpayTransportException.php create mode 100644 src/Exception/UnitpayUnsupportedMethodException.php create mode 100644 src/Exception/UnitpayValidationException.php create mode 100644 src/Model/CashItem.php create mode 100644 src/Model/Enum/Measure.php create mode 100644 src/Model/Enum/Nds.php create mode 100644 src/Model/Enum/PaymentMethod.php create mode 100644 src/Model/Enum/PaymentObject.php create mode 100644 src/Model/Enum/PaymentType.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index dcb7fdc..92ef07d 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,7 +1,7 @@ in([__DIR__ . '/tests', __DIR__ . '/examples']) + ->in([__DIR__ . '/src', __DIR__ . '/tests', __DIR__ . '/examples']) ->append([__DIR__ . '/UnitPay.php']); return (new PhpCsFixer\Config()) diff --git a/composer.json b/composer.json index 0c5fc6a..ccb9b17 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,9 @@ "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":{ + "psr-4": { + "Unitpay\\": "src/" + }, "classmap":[ "./UnitPay.php" ] @@ -37,7 +40,7 @@ }, "scripts": { "test": "phpunit", - "lint": "parallel-lint UnitPay.php examples tests", + "lint": "parallel-lint UnitPay.php src examples tests", "stan": "phpstan analyse --no-progress", "cs-check": "php-cs-fixer fix --dry-run --diff", "cs-fix": "php-cs-fixer fix", diff --git a/phpstan.neon b/phpstan.neon index 3e84653..20ab4c2 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,6 @@ parameters: level: 6 paths: + - src - UnitPay.php - tests diff --git a/src/Exception/UnitpayExceptionInterface.php b/src/Exception/UnitpayExceptionInterface.php new file mode 100644 index 0000000..af9a024 --- /dev/null +++ b/src/Exception/UnitpayExceptionInterface.php @@ -0,0 +1,14 @@ +name = $name; + $this->count = $count + 0; + $this->price = (float) $price; + $this->nds = $nds; + $this->type = $type; + $this->paymentMethod = $paymentMethod; + } + + public function getName(): string + { + return $this->name; + } + + /** + * @return int|float + */ + public function getCount() + { + return $this->count; + } + + public function getPrice(): float + { + return $this->price; + } + + public function getNds(): string + { + return $this->nds; + } + + public function getType(): string + { + return $this->type; + } + + public function getPaymentMethod(): string + { + return $this->paymentMethod; + } + + /** + * Total sum of the line item. If not set, the backend computes it as price * count. + * Cannot exceed round(price * count, 2). + */ + public function setSum(float $sum): self + { + $this->sum = $sum; + return $this; + } + + public function getSum(): ?float + { + return $this->sum; + } + + /** + * Line-item currency (ISO 4217). Defaults to RUB on the backend. + */ + public function setCurrency(string $currency): self + { + $this->currency = $currency; + return $this; + } + + public function getCurrency(): ?string + { + return $this->currency; + } + + /** + * Unit of measure, one of the Unitpay\Model\Enum\Measure constants. + */ + public function setMeasure(int $measure): self + { + $this->measure = $measure; + return $this; + } + + public function getMeasure(): ?int + { + return $this->measure; + } + + /** + * Product nomenclature code (marking). + */ + public function setNomenclatureCode(string $nomenclatureCode): self + { + $this->nomenclatureCode = $nomenclatureCode; + return $this; + } + + public function getNomenclatureCode(): ?string + { + return $this->nomenclatureCode; + } + + /** + * Product mark code. + */ + public function setMarkCode(string $markCode): self + { + $this->markCode = $markCode; + return $this; + } + + public function getMarkCode(): ?string + { + return $this->markCode; + } + + /** + * Fractional quantity of a marked product. + * Allowed only when measure = Measure::ITEM and count = 1. + */ + public function setMarkQuantity(int $numerator, int $denominator): self + { + 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{numerator: int, denominator: int}|null + */ + public function getMarkQuantity(): ?array + { + return $this->markQuantity; + } + + /** + * Text shown before the line item on the receipt. + */ + public function setPreText(string $preText): self + { + $this->preText = $preText; + return $this; + } + + public function getPreText(): ?string + { + return $this->preText; + } + + /** + * Text shown after the line item on the receipt. + */ + public function setPostText(string $postText): self + { + $this->postText = $postText; + return $this; + } + + public function getPostText(): ?string + { + return $this->postText; + } +} diff --git a/src/Model/Enum/Measure.php b/src/Model/Enum/Measure.php new file mode 100644 index 0000000..dc92f9e --- /dev/null +++ b/src/Model/Enum/Measure.php @@ -0,0 +1,59 @@ + Date: Fri, 24 Jul 2026 17:10:38 +0300 Subject: [PATCH 2/6] refactor: extract Http transport, Signature and Webhook layers Add Unitpay\Http (TransportInterface + CurlTransport with the cURL/file_get_contents fallback and TLS on), Unitpay\Signature\SignatureBuilder (with the PHP_INT_MAX forgery guard and locale-safe float serialization), and Unitpay\Webhook (WebhookVerifier + IpAllowlist) covering signature + IP-allowlist verification, fail-safe refresh, and handler responses. Standalone testable units; the facade wiring and single-file removal come next. --- src/Http/CurlTransport.php | 54 +++++++ src/Http/TransportInterface.php | 18 +++ src/Signature/SignatureBuilder.php | 85 ++++++++++ src/Webhook/IpAllowlist.php | 138 ++++++++++++++++ src/Webhook/WebhookVerifier.php | 249 +++++++++++++++++++++++++++++ 5 files changed, 544 insertions(+) create mode 100644 src/Http/CurlTransport.php create mode 100644 src/Http/TransportInterface.php create mode 100644 src/Signature/SignatureBuilder.php create mode 100644 src/Webhook/IpAllowlist.php create mode 100644 src/Webhook/WebhookVerifier.php diff --git a/src/Http/CurlTransport.php b/src/Http/CurlTransport.php new file mode 100644 index 0000000..7e8a84e --- /dev/null +++ b/src/Http/CurlTransport.php @@ -0,0 +1,54 @@ + true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 10, + ]; + if ($headers !== []) { + $opts[CURLOPT_HTTPHEADER] = $headers; + } + curl_setopt_array($ch, $opts); + $body = curl_exec($ch); + if (\PHP_VERSION_ID < 80000) { + curl_close($ch); + } + return $body; + } + + $http = ['timeout' => 10]; + if ($headers !== []) { + $http['header'] = implode("\r\n", $headers); + } + $context = stream_context_create(['http' => $http]); + set_error_handler(static function () { + return true; + }); + try { + return file_get_contents($url, false, $context); + } finally { + restore_error_handler(); + } + } +} diff --git a/src/Http/TransportInterface.php b/src/Http/TransportInterface.php new file mode 100644 index 0000000..4824466 --- /dev/null +++ b/src/Http/TransportInterface.php @@ -0,0 +1,18 @@ +=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. + * + * A null/empty secret is rejected up front: as a public entry point 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 build(array $params, ?string $secretKey, ?string $method = null): string + { + if (empty($secretKey)) { + throw new UnitpayValidationException('SecretKey is null'); + } + unset($params['sign'], $params['signature'], $params[PHP_INT_MAX]); + ksort($params); + $params[] = $secretKey; + + if ($method !== null) { + array_unshift($params, $method); + } + + $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)); + } + + /** + * 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 + */ + public static function stringifyFloats(array $params): array + { + foreach ($params as $key => $value) { + if (is_float($value)) { + $params[$key] = self::floatToString($value); + } + } + + return $params; + } + + /** + * 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 build() and + * stringifyFloats() so the signature and the transmitted value look identical. + */ + public static function floatToString(float $value): string + { + return rtrim(rtrim(sprintf('%.8F', $value), '0'), '.'); + } +} diff --git a/src/Webhook/IpAllowlist.php b/src/Webhook/IpAllowlist.php new file mode 100644 index 0000000..48e2472 --- /dev/null +++ b/src/Webhook/IpAllowlist.php @@ -0,0 +1,138 @@ +entries = $entries; + } + + public function contains(string $ip): bool + { + $ipBin = $this->toBinary($ip); + foreach ($this->entries as $entry) { + if (strpos($entry, '/') === false) { + if ($entry === $ip) { + return true; + } + // Normalized comparison: the same address written differently + // (case/IPv6 compression) yields the same packed in_addr. + if ($ipBin !== null) { + $entryBin = $this->toBinary($entry); + if ($entryBin !== null && $entryBin === $ipBin) { + return true; + } + } + continue; + } + if ($ipBin !== null && $this->cidrContains($entry, $ipBin)) { + return true; + } + } + return false; + } + + /** + * @param string $ipBin packed in_addr of the client IP (from toBinary()) + */ + private function cidrContains(string $cidr, string $ipBin): bool + { + 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); + } + + /** + * 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 + { + if (strpos($entry, '/') === false) { + return filter_var($entry, FILTER_VALIDATE_IP) !== false; + } + list($subnet, $bits) = explode('/', $entry, 2); + if (!ctype_digit($bits) || filter_var($subnet, FILTER_VALIDATE_IP) === false) { + return 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; + } + + /** + * 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 + { + 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)); + } + + /** + * @return string|null packed in_addr, or null if $ip is not a valid address + */ + private function toBinary(string $ip): ?string + { + if (filter_var($ip, FILTER_VALIDATE_IP) === false) { + return null; + } + $binary = inet_pton($ip); + return $binary === false ? null : $binary; + } + + private function prefixMatches(string $ipBin, string $subnetBin, int $bits): bool + { + 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); + } +} diff --git a/src/Webhook/WebhookVerifier.php b/src/Webhook/WebhookVerifier.php new file mode 100644 index 0000000..36bc191 --- /dev/null +++ b/src/Webhook/WebhookVerifier.php @@ -0,0 +1,249 @@ +|null */ + private ?array $request; + private ?string $clientIp; + /** + * 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 = [ + '31.186.100.49', + '51.250.20.9', + ]; + /** + * 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 = []; + private ?IpAllowlist $ipAllowlist = null; + private ?string $handlerMethod = null; + /** @var array|null */ + private ?array $handlerParams = null; + + /** + * @param array|null $request inbound webhook array read by + * checkHandlerRequest(). Defaults to $_GET. + * @param string|null $clientIp sender IP used by getIp(). Defaults to + * $_SERVER['REMOTE_ADDR']. Override getIp() behind a proxy. + */ + public function __construct( + ?string $secretKey, + SignatureBuilder $signature, + TransportInterface $transport, + string $ipsUrl, + ?array $request = null, + ?string $clientIp = null + ) { + $this->secretKey = $secretKey; + $this->signature = $signature; + $this->transport = $transport; + $this->ipsUrl = $ipsUrl; + $this->request = $request; + $this->clientIp = $clientIp; + } + + /** + * Verifies the inbound webhook: supported method, SHA-256 signature (constant-time) + * and the sender IP allowlist. On success it stores the verified method and params, + * available via getHandlerMethod()/getHandlerParams(). + * + * @throws \InvalidArgumentException + * @throws \UnexpectedValueException + */ + public function checkHandlerRequest(): bool + { + $ip = $this->getIp(); + if (empty($this->secretKey)) { + throw new UnitpayValidationException('SecretKey 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) = [$request['method'], $request['params']]; + + if (!in_array($method, self::SUPPORTED_PARTNER_METHODS, true)) { + throw new UnitpayUnsupportedMethodException('Method is not supported'); + } + + if (!isset($params['signature']) || !is_string($params['signature']) + || !hash_equals($this->signature->build($params, $this->secretKey, $method), $params['signature'])) { + throw new UnitpaySignatureException('Wrong signature'); + } + + if (!$this->isAllowedIp($ip)) { + throw new UnitpayIpException('IP address Error'); + } + + $this->handlerMethod = $method; + $this->handlerParams = $params; + + return true; + } + + /** + * The webhook method verified by the last successful checkHandlerRequest() + * ('check' | 'pay' | 'preauth' | 'error'). null until a successful verification. + */ + public function getHandlerMethod(): ?string + { + return $this->handlerMethod; + } + + /** + * The webhook params verified by the last successful checkHandlerRequest(). + * null until a successful verification. + * @return array|null + */ + public function getHandlerParams(): ?array + { + return $this->handlerParams; + } + + /** + * 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. + * + * 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. + * @param string[] $ips + */ + public function setAllowedIps(array $ips): self + { + $this->supportedUnitpayIp = $ips; + $this->ipAllowlist = null; + return $this; + } + + /** + * Adds the merchant's own IP/CIDR ranges (e.g. your proxy/relay) on top of the + * Unitpay list. Preserved across refreshAllowedIps()/setAllowedIps(). Duplicates + * are removed. + * @param string[] $ips exact IPs and/or CIDR ranges + */ + public function addAllowedIps(array $ips): self + { + $this->customIps = array_values(array_unique(array_merge($this->customIps, $ips))); + $this->ipAllowlist = null; + return $this; + } + + /** + * Fetches Unitpay's current published webhook IPs and makes them the allowlist. + * + * Best-effort and fail-safe: on any transport/parse/validation error the previously + * configured Unitpay list 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; merchant IPs added via addAllowedIps() + * are preserved and always applied on top. + * + * Makes a blocking network request — call it periodically (e.g. a daily cron) and + * cache getAllowedIps() yourself; do NOT call it on every webhook. + */ + public function refreshAllowedIps(): self + { + $ips = $this->fetchUnitpayIps(); + if ($ips !== null) { + $this->supportedUnitpayIp = $ips; + $this->ipAllowlist = null; + } + return $this; + } + + /** + * 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 + { + return array_values(array_unique(array_merge($this->supportedUnitpayIp, $this->customIps))); + } + + /** Builds the JSON success response that Unitpay expects from the handler. */ + public function getSuccessHandlerResponse(string $message): string + { + return (string) json_encode(['result' => ['message' => $message]]); + } + + /** Builds the JSON error response that Unitpay expects from the handler. */ + public function getErrorHandlerResponse(string $message): string + { + return (string) json_encode(['error' => ['message' => $message]]); + } + + /** + * Sender IP of the inbound request (the overridden clientIp or $_SERVER['REMOTE_ADDR']). + * Override for proxy-aware logic. + */ + protected function getIp(): string + { + return $this->clientIp !== null ? $this->clientIp : ($_SERVER['REMOTE_ADDR'] ?? ''); + } + + /** + * Whether $ip is allowed to call the handler. Matches exact addresses and CIDR + * subnets (IPv4/IPv6) via IpAllowlist. Override for proxy-aware logic. + */ + protected function isAllowedIp(string $ip): bool + { + if ($this->ipAllowlist === null) { + $this->ipAllowlist = new IpAllowlist( + array_merge($this->supportedUnitpayIp, $this->customIps) + ); + } + return $this->ipAllowlist->contains($ip); + } + + /** + * Fetches and validates the published webhook IP feed. + * @return string[]|null validated non-empty list, or null on any error + */ + private function fetchUnitpayIps(): ?array + { + $body = $this->transport->send($this->ipsUrl); + return is_string($body) ? IpAllowlist::parseWebhooksFeed($body) : null; + } +} From d280ad52b312f3a87b9ccdd6fb2b4f29fb5167e7 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Fri, 24 Jul 2026 17:30:07 +0300 Subject: [PATCH 3/6] refactor: add Api service layer and thin Unitpay facade Add Unitpay\Api (AbstractService request pipeline with telemetry fingerprint, PendingParams fluent-param holder, and typed PaymentService/SubscriptionService/PayoutService/ReferenceService whose method names mirror the backend) and the Unitpay\Unitpay facade (composition root: lazy service getters, fluent setters, form(), webhook() accessor). The global UnitPay.php still powers the SDK and stays until the test port so the single-file removal is one green cutover. --- src/Api/AbstractService.php | 100 ++++++++++++++ src/Api/PaymentService.php | 78 +++++++++++ src/Api/PayoutService.php | 82 ++++++++++++ src/Api/PendingParams.php | 34 +++++ src/Api/ReferenceService.php | 49 +++++++ src/Api/SubscriptionService.php | 37 ++++++ src/Unitpay.php | 229 ++++++++++++++++++++++++++++++++ 7 files changed, 609 insertions(+) create mode 100644 src/Api/AbstractService.php create mode 100644 src/Api/PaymentService.php create mode 100644 src/Api/PayoutService.php create mode 100644 src/Api/PendingParams.php create mode 100644 src/Api/ReferenceService.php create mode 100644 src/Api/SubscriptionService.php create mode 100644 src/Unitpay.php diff --git a/src/Api/AbstractService.php b/src/Api/AbstractService.php new file mode 100644 index 0000000..ea4cd8a --- /dev/null +++ b/src/Api/AbstractService.php @@ -0,0 +1,100 @@ +transport = $transport; + $this->apiUrl = $apiUrl; + $this->secretKey = $secretKey; + $this->pending = $pending; + $this->sdkVersion = $sdkVersion; + $this->apiVersion = $apiVersion; + } + + /** + * Runs a server-to-server call. Accumulated fluent params (cashItems, backUrl, ...) + * are drained and merged, with the explicit call params taking precedence. An + * explicit non-empty secretKey overrides the instance key so account-level methods + * (getPartner, payouts, ...) can use the account key. The params are cleared as part + * of drain(), symmetric with form(). + * + * @param array $params + * @throws UnitpayValidationException when the secret key is unset/empty + * @throws UnitpayTransportException when no usable response comes back + */ + protected function request(string $method, array $params): object + { + $params = array_merge($this->pending->drain(), $params); + + if (empty($params['secretKey'])) { + $params['secretKey'] = $this->secretKey; + } + if (empty($params['secretKey'])) { + throw new UnitpayValidationException('SecretKey is null'); + } + + $params = SignatureBuilder::stringifyFloats($params); + + $requestUrl = $this->apiUrl . '?' . http_build_query( + ['method' => $method] + $params, + '', + '&', + PHP_QUERY_RFC3986 + ); + + $response = json_decode($this->transport->send($requestUrl, $this->fingerprintHeaders())); + if (!is_object($response)) { + throw new UnitpayTransportException('Temporary server error. Please try again later.'); + } + + return $response; + } + + /** + * SDK self-identification headers (anonymous, no PII): a short User-Agent plus an + * X-Unitpay-Client JSON object. api_version is the Unitpay API surface targeted; + * platform is the coarse OS family only. + * @return string[] + */ + private function fingerprintHeaders(): array + { + $client = (string) json_encode([ + 'sdk_version' => $this->sdkVersion, + 'api_version' => $this->apiVersion, + 'lang' => 'php', + 'lang_version' => PHP_VERSION, + 'platform' => PHP_OS_FAMILY, + 'publisher' => 'unitpay', + ]); + return [ + 'User-Agent: unitpay-php-sdk/' . $this->sdkVersion . ' api/' . $this->apiVersion, + 'X-Unitpay-Client: ' . $client, + ]; + } +} diff --git a/src/Api/PaymentService.php b/src/Api/PaymentService.php new file mode 100644 index 0000000..5554fd6 --- /dev/null +++ b/src/Api/PaymentService.php @@ -0,0 +1,78 @@ + $options extra params (e.g. desc, currency, account) + */ + public function initPayment(string $account, $sum, $projectId, string $paymentType, array $options = []): object + { + return $this->request('initPayment', array_merge([ + 'account' => $account, + 'sum' => $sum, + 'projectId' => $projectId, + 'paymentType' => $paymentType, + ], $options)); + } + + /** + * @param int|string $paymentId + * @param array $options + */ + public function getPayment($paymentId, array $options = []): object + { + return $this->request('getPayment', array_merge(['paymentId' => $paymentId], $options)); + } + + /** + * Refund a payment (full, or partial via $options['sum']). + * @param int|string $paymentId + * @param array $options + */ + public function refundPayment($paymentId, array $options = []): object + { + return $this->request('refundPayment', array_merge(['paymentId' => $paymentId], $options)); + } + + /** + * Confirm (capture) a two-stage payment. + * @param int|string $paymentId + * @param array $options + */ + public function confirmPayment($paymentId, array $options = []): object + { + return $this->request('confirmPayment', array_merge(['paymentId' => $paymentId], $options)); + } + + /** + * Cancel (release) a two-stage payment. + * @param int|string $paymentId + * @param array $options + */ + public function cancelPayment($paymentId, array $options = []): object + { + return $this->request('cancelPayment', array_merge(['paymentId' => $paymentId], $options)); + } + + /** + * Advance-offset fiscal receipt. Account-level: pass the account key in + * $options['secretKey'] and optionally cashItems. + * @param int|string $paymentId + * @param array $options + */ + public function offsetAdvance(string $login, $paymentId, array $options = []): object + { + return $this->request('offsetAdvance', array_merge([ + 'login' => $login, + 'paymentId' => $paymentId, + ], $options)); + } +} diff --git a/src/Api/PayoutService.php b/src/Api/PayoutService.php new file mode 100644 index 0000000..0bd9dee --- /dev/null +++ b/src/Api/PayoutService.php @@ -0,0 +1,82 @@ + $options + */ + public function massPayment(string $login, $transactionId, $sum, string $purse, string $paymentType, array $options = []): object + { + return $this->request('massPayment', array_merge([ + 'login' => $login, + 'transactionId' => $transactionId, + 'sum' => $sum, + 'purse' => $purse, + 'paymentType' => $paymentType, + ], $options)); + } + + /** + * @param int|string $transactionId + * @param array $options + */ + public function massPaymentStatus(string $login, $transactionId, array $options = []): object + { + return $this->request('massPaymentStatus', array_merge([ + 'login' => $login, + 'transactionId' => $transactionId, + ], $options)); + } + + /** + * @param int|float|string $sum + * @param array $options + */ + public function massPaymentAvailableAmount(string $login, $sum, string $purse, string $paymentType, array $options = []): object + { + return $this->request('massPaymentAvailableAmount', array_merge([ + 'login' => $login, + 'sum' => $sum, + 'purse' => $purse, + 'paymentType' => $paymentType, + ], $options)); + } + + /** + * @param array $options + */ + public function massPaymentCommissions(string $login, array $options = []): object + { + return $this->request('massPaymentCommissions', array_merge(['login' => $login], $options)); + } + + /** + * @param array $options + */ + public function getSbpBankList(string $login, array $options = []): object + { + return $this->request('getSbpBankList', array_merge(['login' => $login], $options)); + } + + /** + * @param int|string $bin + * @param array $options + */ + public function getBinInfo(string $login, $bin, array $options = []): object + { + return $this->request('getBinInfo', array_merge([ + 'login' => $login, + 'bin' => $bin, + ], $options)); + } +} diff --git a/src/Api/PendingParams.php b/src/Api/PendingParams.php new file mode 100644 index 0000000..24fc18e --- /dev/null +++ b/src/Api/PendingParams.php @@ -0,0 +1,34 @@ + */ + private array $params = []; + + /** + * @param mixed $value + */ + public function set(string $key, $value): void + { + $this->params[$key] = $value; + } + + /** + * Returns the accumulated params and clears them. + * @return array + */ + public function drain(): array + { + $params = $this->params; + $this->params = []; + return $params; + } +} diff --git a/src/Api/ReferenceService.php b/src/Api/ReferenceService.php new file mode 100644 index 0000000..af7a0be --- /dev/null +++ b/src/Api/ReferenceService.php @@ -0,0 +1,49 @@ + $options + */ + public function getMethodsAvailable($projectId, array $options = []): object + { + return $this->request('getMethodsAvailable', array_merge(['projectId' => $projectId], $options)); + } + + /** + * @param int|string $projectId + * @param array $options + */ + public function getCommissions($projectId, string $login, array $options = []): object + { + return $this->request('getCommissions', array_merge([ + 'projectId' => $projectId, + 'login' => $login, + ], $options)); + } + + /** + * @param array $options + */ + public function getCurrencyCourses(string $login, array $options = []): object + { + return $this->request('getCurrencyCourses', array_merge(['login' => $login], $options)); + } + + /** + * @param array $options + */ + public function getPartner(string $login, array $options = []): object + { + return $this->request('getPartner', array_merge(['login' => $login], $options)); + } +} diff --git a/src/Api/SubscriptionService.php b/src/Api/SubscriptionService.php new file mode 100644 index 0000000..f7c7540 --- /dev/null +++ b/src/Api/SubscriptionService.php @@ -0,0 +1,37 @@ + $options (e.g. 'all' => true) + */ + public function listSubscriptions($projectId, array $options = []): object + { + return $this->request('listSubscriptions', array_merge(['projectId' => $projectId], $options)); + } + + /** + * @param int|string $subscriptionId + * @param array $options + */ + public function getSubscription($subscriptionId, array $options = []): object + { + return $this->request('getSubscription', array_merge(['subscriptionId' => $subscriptionId], $options)); + } + + /** + * @param int|string $subscriptionId + * @param array $options + */ + public function closeSubscription($subscriptionId, array $options = []): object + { + return $this->request('closeSubscription', array_merge(['subscriptionId' => $subscriptionId], $options)); + } +} diff --git a/src/Unitpay.php b/src/Unitpay.php new file mode 100644 index 0000000..71db1a6 --- /dev/null +++ b/src/Unitpay.php @@ -0,0 +1,229 @@ +payments()->initPayment(...), ->subscriptions(), + * ->payouts(), ->reference(). Inbound webhooks: $unitpay->webhook()->checkHandlerRequest(). + */ +final class Unitpay +{ + /** SDK version; sent in the telemetry fingerprint. Keep in sync with the release git tag. */ + public const VERSION = '3.0.0'; + + /** Unitpay API surface this SDK targets; sent in the telemetry fingerprint. */ + public const API_VERSION = 'v1'; + + private ?string $secretKey; + private string $apiUrl; + private string $formUrl; + private TransportInterface $transport; + private SignatureBuilder $signature; + private PendingParams $pending; + private WebhookVerifier $webhookVerifier; + private ?PaymentService $payments = null; + private ?SubscriptionService $subscriptions = null; + private ?PayoutService $payouts = null; + private ?ReferenceService $reference = null; + + /** + * @param string $domain host only, e.g. "unitpay.ru" — without scheme or path. + * @param TransportInterface|null $transport outbound HTTP transport for api()/feed fetch. + * Defaults to CurlTransport. Inject a fake to test without the network. + * @param array|null $request inbound webhook array read by the webhook + * verifier. Defaults to $_GET. + * @param string|null $clientIp sender IP used by the webhook verifier. Defaults to + * $_SERVER['REMOTE_ADDR']. + */ + public function __construct( + string $domain, + ?string $secretKey = null, + ?TransportInterface $transport = null, + ?array $request = null, + ?string $clientIp = null + ) { + $this->secretKey = $secretKey; + $this->apiUrl = "https://$domain/api"; + $this->formUrl = "https://$domain/pay/"; + $ipsUrl = "https://$domain/ips/ips_webhooks.json"; + $this->transport = $transport ?? new CurlTransport(); + $this->signature = new SignatureBuilder(); + $this->pending = new PendingParams(); + $this->webhookVerifier = new WebhookVerifier( + $secretKey, + $this->signature, + $this->transport, + $ipsUrl, + $request, + $clientIp + ); + } + + /** Payment operations (initPayment, getPayment, refund, confirm/cancel, offsetAdvance). */ + public function payments(): PaymentService + { + return $this->payments = $this->payments ?? $this->makeService(PaymentService::class); + } + + /** Subscription operations (list, get, close). */ + public function subscriptions(): SubscriptionService + { + return $this->subscriptions = $this->subscriptions ?? $this->makeService(SubscriptionService::class); + } + + /** Mass-payout operations plus SBP bank list and BIN lookup (account-level). */ + public function payouts(): PayoutService + { + return $this->payouts = $this->payouts ?? $this->makeService(PayoutService::class); + } + + /** Reference/account lookups (methods, commissions, currency rates, balance). */ + public function reference(): ReferenceService + { + return $this->reference = $this->reference ?? $this->makeService(ReferenceService::class); + } + + /** The inbound webhook verifier (signature + IP allowlist, allowlist management). */ + public function webhook(): WebhookVerifier + { + return $this->webhookVerifier; + } + + /** + * Sets the URL Unitpay will return the payer to after payment. + */ + public function setBackUrl(string $backUrl): self + { + $this->pending->set('backUrl', $backUrl); + return $this; + } + + /** + * Sets the customer's email. + */ + public function setCustomerEmail(string $email): self + { + $this->pending->set('customerEmail', $email); + return $this; + } + + /** + * Sets the customer's phone. + */ + public function setCustomerPhone(string $phone): self + { + $this->pending->set('customerPhone', $phone); + return $this; + } + + /** + * Attaches a fiscal receipt (54-FZ line items) to the next form()/service 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 + { + $cashItems = array_map(static function (CashItem $item) { + $cashItem = [ + 'name' => $item->getName(), + 'count' => $item->getCount(), + 'price' => $item->getPrice(), + 'nds' => $item->getNds(), + 'type' => $item->getType(), + 'paymentMethod' => $item->getPaymentMethod(), + ]; + + $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) { + throw new UnitpayValidationException('Failed to encode cashItems: ' . json_last_error_msg()); + } + $this->pending->set('cashItems', base64_encode($json)); + + return $this; + } + + /** + * Builds the redirect URL to Unitpay's hosted payment form. Accumulated fluent-setter + * params are merged in and then cleared, so a reused instance does not carry this + * call's parameters into the next form()/service call. + * @param string|float|int $sum + */ + 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'); + } + $vitalParams = SignatureBuilder::stringifyFloats([ + 'account' => $account, + 'currency' => $currency, + 'desc' => $desc, + 'sum' => $sum, + ]); + $params = array_merge($this->pending->drain(), $vitalParams); + $params['signature'] = $this->signature->build($vitalParams, $this->secretKey); + $params['locale'] = $locale; + $params['sdk'] = $this->getSdkToken(); // outside the signature — does not affect it + return $this->formUrl . $publicKey . '?' . http_build_query($params); + } + + /** + * @param class-string $class + * @return PaymentService|SubscriptionService|PayoutService|ReferenceService + */ + private function makeService(string $class) + { + return new $class( + $this->transport, + $this->apiUrl, + $this->secretKey, + $this->pending, + self::VERSION, + self::API_VERSION + ); + } + + /** + * Machine-readable fingerprint token for the form() URL: php__. + * major.minor so the exact PHP patch is not exposed in the buyer-visible payment form URL. + */ + private function getSdkToken(): string + { + return 'php_' . self::VERSION . '_' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION; + } +} From c7d526d8e9e1b2ef0de854c52415948eef977c67 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Sat, 25 Jul 2026 01:02:02 +0300 Subject: [PATCH 4/6] test: port suite to Unitpay\ namespaces and drop the single-file SDK Rework tests/ to mirror src/. The callable transport double becomes Tests\Support\FakeTransport, a TransportInterface implementation that records the URL and headers of each call and replays a queue of canned bodies. Adds focused coverage for the new seams: facade service getters, their memoization, and the fact that one injected transport serves both the API services and the webhook IP-feed fetch. 121 -> 150 tests, 318 assertions. Remove the legacy UnitPay.php along with its classmap autoload entry, PHPStan path, php-cs-fixer append and the lint/md script globs. Two tests are deliberately gone: api('doesNotExist') is no longer a code path, so the unsupported-method case is covered only where it still applies, in WebhookVerifier. Per-service method-name coverage replaces the old allowlist test, and required-param validation - now enforced by the service method signatures rather than a runtime dictionary - is pinned via ArgumentCountError. examples/ still require the removed file and are updated separately. --- .php-cs-fixer.dist.php | 3 +- UnitPay.php | 1170 ----------------- composer.json | 11 +- phpstan.neon | 1 - tests/Api/AbstractServiceTest.php | 189 +++ tests/Api/PaymentServiceTest.php | 108 ++ tests/Api/PayoutServiceTest.php | 133 ++ tests/Api/ReferenceServiceTest.php | 60 + tests/Api/SubscriptionServiceTest.php | 58 + tests/Api/TelemetryTest.php | 47 + tests/FloatHandlingTest.php | 108 ++ tests/{ => Model}/CashItemTest.php | 30 +- tests/Model/Enum/PaymentTypeTest.php | 40 + .../SignatureBuilderTest.php} | 79 +- tests/Support/FakeTransport.php | 83 ++ tests/UnitPayAllowedIpsTest.php | 265 ---- tests/UnitPayApiTest.php | 328 ----- tests/UnitPayFloatTest.php | 101 -- tests/UnitPayFormTest.php | 149 --- tests/UnitPayPaymentTypeTest.php | 45 - tests/UnitPayTelemetryTest.php | 54 - ...ItemsTest.php => UnitpayCashItemsTest.php} | 63 +- tests/UnitpayFacadeTest.php | 110 ++ tests/UnitpayFormTest.php | 166 +++ tests/Webhook/AllowedIpsTest.php | 214 +++ .../HandlerResponseTest.php} | 15 +- .../IpAllowlistTest.php} | 88 +- .../WebhookVerifierTest.php} | 80 +- 28 files changed, 1547 insertions(+), 2251 deletions(-) delete mode 100644 UnitPay.php create mode 100644 tests/Api/AbstractServiceTest.php create mode 100644 tests/Api/PaymentServiceTest.php create mode 100644 tests/Api/PayoutServiceTest.php create mode 100644 tests/Api/ReferenceServiceTest.php create mode 100644 tests/Api/SubscriptionServiceTest.php create mode 100644 tests/Api/TelemetryTest.php create mode 100644 tests/FloatHandlingTest.php rename tests/{ => Model}/CashItemTest.php (84%) create mode 100644 tests/Model/Enum/PaymentTypeTest.php rename tests/{UnitPaySignatureTest.php => Signature/SignatureBuilderTest.php} (55%) create mode 100644 tests/Support/FakeTransport.php delete mode 100644 tests/UnitPayAllowedIpsTest.php delete mode 100644 tests/UnitPayApiTest.php delete mode 100644 tests/UnitPayFloatTest.php delete mode 100644 tests/UnitPayFormTest.php delete mode 100644 tests/UnitPayPaymentTypeTest.php delete mode 100644 tests/UnitPayTelemetryTest.php rename tests/{UnitPayCashItemsTest.php => UnitpayCashItemsTest.php} (60%) create mode 100644 tests/UnitpayFacadeTest.php create mode 100644 tests/UnitpayFormTest.php create mode 100644 tests/Webhook/AllowedIpsTest.php rename tests/{UnitPayResponseTest.php => Webhook/HandlerResponseTest.php} (52%) rename tests/{UnitpayIpAllowlistTest.php => Webhook/IpAllowlistTest.php} (54%) rename tests/{UnitPayHandlerTest.php => Webhook/WebhookVerifierTest.php} (73%) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 92ef07d..7149de9 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,8 +1,7 @@ in([__DIR__ . '/src', __DIR__ . '/tests', __DIR__ . '/examples']) - ->append([__DIR__ . '/UnitPay.php']); + ->in([__DIR__ . '/src', __DIR__ . '/tests', __DIR__ . '/examples']); return (new PhpCsFixer\Config()) ->setRiskyAllowed(false) diff --git a/UnitPay.php b/UnitPay.php deleted file mode 100644 index fda3b0e..0000000 --- a/UnitPay.php +++ /dev/null @@ -1,1170 +0,0 @@ -name = $name; - $this->count = $count + 0; - $this->price = (float) $price; - $this->nds = $nds; - $this->type = $type; - $this->paymentMethod = $paymentMethod; - } - - public function getName(): string - { - return $this->name; - } - - /** - * @return int|float - */ - public function getCount() - { - return $this->count; - } - - public function getPrice(): float - { - return $this->price; - } - - public function getNds(): string - { - return $this->nds; - } - - public function getType(): string - { - return $this->type; - } - - public function getPaymentMethod(): string - { - return $this->paymentMethod; - } - - /** - * Total sum of the line item. If not set, the backend computes it as price * count. - * Cannot exceed round(price * count, 2). - */ - public function setSum(float $sum): self - { - $this->sum = $sum; - return $this; - } - - public function getSum(): ?float - { - return $this->sum; - } - - /** - * Line-item currency (ISO 4217). Defaults to RUB on the backend. - */ - public function setCurrency(string $currency): self - { - $this->currency = $currency; - return $this; - } - - public function getCurrency(): ?string - { - return $this->currency; - } - - /** - * Unit of measure, one of the MEASURE_* constants. - */ - public function setMeasure(int $measure): self - { - $this->measure = $measure; - return $this; - } - - public function getMeasure(): ?int - { - return $this->measure; - } - - /** - * Product nomenclature code (marking). - */ - public function setNomenclatureCode(string $nomenclatureCode): self - { - $this->nomenclatureCode = $nomenclatureCode; - return $this; - } - - public function getNomenclatureCode(): ?string - { - return $this->nomenclatureCode; - } - - /** - * Product mark code. - */ - public function setMarkCode(string $markCode): self - { - $this->markCode = $markCode; - return $this; - } - - public function getMarkCode(): ?string - { - return $this->markCode; - } - - /** - * Fractional quantity of a marked product. - * Allowed only when measure = MEASURE_ITEM and count = 1. - */ - public function setMarkQuantity(int $numerator, int $denominator): self - { - 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{numerator: int, denominator: int}|null - */ - public function getMarkQuantity(): ?array - { - return $this->markQuantity; - } - - /** - * Text shown before the line item on the receipt. - */ - public function setPreText(string $preText): self - { - $this->preText = $preText; - return $this; - } - - public function getPreText(): ?string - { - return $this->preText; - } - - /** - * Text shown after the line item on the receipt. - */ - public function setPostText(string $postText): self - { - $this->postText = $postText; - return $this; - } - - public function getPostText(): ?string - { - return $this->postText; - } -} - -/** - * Checks whether an IP is in the allowlist: exact addresses and CIDR subnets - * (IPv4 and IPv6). Extracted from UnitPay into a separate class so the - * range-matching logic stays cohesive and testable. - */ -final class UnitpayIpAllowlist -{ - /** @var string[] */ - private array $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; - } - - public function contains(string $ip): bool - { - $ipBin = $this->toBinary($ip); - foreach ($this->entries as $entry) { - if (strpos($entry, '/') === false) { - if ($entry === $ip) { - return true; - } - // Normalized comparison: the same address written differently - // (case/IPv6 compression) yields the same packed in_addr. - if ($ipBin !== null) { - $entryBin = $this->toBinary($entry); - if ($entryBin !== null && $entryBin === $ipBin) { - return true; - } - } - continue; - } - if ($ipBin !== null && $this->cidrContains($entry, $ipBin)) { - return true; - } - } - return false; - } - - /** - * @param string $ipBin packed in_addr of the client IP (from toBinary()) - */ - private function cidrContains(string $cidr, string $ipBin): bool - { - 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); - } - - /** - * 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 - { - if (strpos($entry, '/') === false) { - return filter_var($entry, FILTER_VALIDATE_IP) !== false; - } - list($subnet, $bits) = explode('/', $entry, 2); - if (!ctype_digit($bits) || filter_var($subnet, FILTER_VALIDATE_IP) === false) { - return 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; - } - - /** - * 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 - { - 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)); - } - - /** - * @return string|null packed in_addr, or null if $ip is not a valid address - */ - private function toBinary(string $ip): ?string - { - if (filter_var($ip, FILTER_VALIDATE_IP) === false) { - return null; - } - $binary = inet_pton($ip); - return $binary === false ? null : $binary; - } - - private function prefixMatches(string $ipBin, string $subnetBin, int $bits): bool - { - 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); - } -} - -/** - * Client for the Unitpay payment REST API: signing and form/URL building, - * server-to-server API calls, and inbound webhook verification. - */ -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: - * https://help.unitpay.ru/book-of-reference/payment-system-codes - * 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'; - /** Tinkoff Pay */ - public const PAYMENT_TYPE_TINKOFFPAY = 'tinkoffpay'; - /** PayPal */ - public const PAYMENT_TYPE_PAYPAL = 'paypal'; - /** WebMoney (WMZ wallets) */ - public const PAYMENT_TYPE_WEBMONEY = 'webmoney'; - - /** - * Supported api() methods and their required parameters. secretKey is - * injected and validated in api(), so it is not listed here. - * @var array - */ - private const REQUIRED_UNITPAY_METHODS_PARAMS = [ - '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'], - ]; - /** - * 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 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 - * IP check into a sham. Add it explicitly via setAllowedIps() for local debugging only. - * @var string[] - */ - private array $supportedUnitpayIp = [ - '31.186.100.49', - '51.250.20.9', - ]; - - private ?string $secretKey; - /** @var array */ - private array $params = []; - private string $apiUrl; - private string $formUrl; - /** @var callable|null */ - private $transport; - /** @var array|null */ - private ?array $request; - private ?string $clientIp; - private ?string $handlerMethod = null; - /** @var array|null */ - private ?array $handlerParams = null; - private ?UnitpayIpAllowlist $ipAllowlist = null; - /** - * 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 = []; - private string $ipsUrl; - - /** - * @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[] $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. - * @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) - { - $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; - } - - /** - * 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. - * - * 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 - */ - public function setAllowedIps(array $ips): self - { - $this->supportedUnitpayIp = $ips; - $this->ipAllowlist = null; - return $this; - } - - /** - * 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 - { - $this->customIps = array_values(array_unique(array_merge($this->customIps, $ips))); - $this->ipAllowlist = null; - return $this; - } - - /** - * Fetches Unitpay's current published webhook IPs from - * https:///ips/ips_webhooks.json and makes them the allowlist. - * - * 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 verification matters here (httpGet keeps CURLOPT_SSL_VERIFYPEER / verify_peer - * enabled): an unverified or spoofed list would defeat the IP check. - * - * 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 - { - $ips = $this->fetchUnitpayIps(); - if ($ips !== null) { - $this->supportedUnitpayIp = $ips; - $this->ipAllowlist = null; - } - return $this; - } - - /** - * 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 - { - return array_values(array_unique(array_merge($this->supportedUnitpayIp, $this->customIps))); - } - - /** - * Fetches and validates the published webhook IP feed. - * @return string[]|null validated non-empty list, or null on any error - */ - private function fetchUnitpayIps(): ?array - { - $body = $this->httpGet($this->ipsUrl); - return is_string($body) ? UnitpayIpAllowlist::parseWebhooksFeed($body) : null; - } - - /** - * Builds the SHA-256 signature: parameter values sorted with ksort and joined - * by the literal "{up}" delimiter, with $method prepended and secretKey - * appended. - * - * 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. - * - * 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; - - if ($method !== null) { - array_unshift($params, $method); - } - - $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)); - } - - /** - * Sender IP of the inbound request (the overridden clientIp or $_SERVER['REMOTE_ADDR']). - */ - protected function getIp(): string - { - return $this->clientIp !== null ? $this->clientIp : ($_SERVER['REMOTE_ADDR'] ?? ''); - } - - /** - * 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 - { - if ($this->ipAllowlist === null) { - $this->ipAllowlist = new UnitpayIpAllowlist( - array_merge($this->supportedUnitpayIp, $this->customIps) - ); - } - return $this->ipAllowlist->contains($ip); - } - - /** - * 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"). - * - * 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" (the SDK telemetry fingerprint on api()). - * @return string|false - */ - protected function httpGet(string $url, array $headers = []) - { - if ($this->transport !== null) { - return call_user_func($this->transport, $url, $headers); - } - - if (function_exists('curl_init')) { - $ch = curl_init($url); - $opts = [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_CONNECTTIMEOUT => 5, - CURLOPT_TIMEOUT => 10, - ]; - if ($headers !== []) { - $opts[CURLOPT_HTTPHEADER] = $headers; - } - curl_setopt_array($ch, $opts); - $body = curl_exec($ch); - if (\PHP_VERSION_ID < 80000) { - curl_close($ch); - } - return $body; - } - - $http = ['timeout' => 10]; - if ($headers !== []) { - $http['header'] = implode("\r\n", $headers); - } - $context = stream_context_create(['http' => $http]); - set_error_handler(static function () { - return true; - }); - try { - return file_get_contents($url, false, $context); - } finally { - restore_error_handler(); - } - } - - /** - * 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 - { - if (empty($this->secretKey)) { - throw new UnitpayValidationException('SecretKey is null'); - } - $vitalParams = self::stringifyFloats([ - 'account' => $account, - 'currency' => $currency, - 'desc' => $desc, - 'sum' => $sum, - ]); - $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 - $this->params = []; - return $this->formUrl . $publicKey . '?' . http_build_query($params); - } - - /** - * Sets the customer's email. - */ - public function setCustomerEmail(string $email): self - { - $this->params['customerEmail'] = $email; - return $this; - } - - /** - * Sets the customer's phone. - */ - public function setCustomerPhone(string $phone): self - { - $this->params['customerPhone'] = $phone; - return $this; - } - - /** - * 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 - { - $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 = [ - '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) { - throw new UnitpayValidationException('Failed to encode cashItems: ' . json_last_error_msg()); - } - $this->params['cashItems'] = base64_encode($json); - - return $this; - } - - /** - * Sets the URL Unitpay will return the payer to after payment. - */ - public function setBackUrl(string $backUrl): self - { - $this->params['backUrl'] = $backUrl; - return $this; - } - - /** - * 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 - * 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 - * @throws UnexpectedValueException - */ - public function api(string $method, array $params = []): object - { - if (!isset(self::REQUIRED_UNITPAY_METHODS_PARAMS[$method])) { - throw new UnitpayUnsupportedMethodException('Method is not supported'); - } - - $params = array_merge($this->params, $params); - - foreach (self::REQUIRED_UNITPAY_METHODS_PARAMS[$method] as $rParam) { - if (!isset($params[$rParam])) { - throw new UnitpayValidationException('Param ' . $rParam . ' is null'); - } - } - - if (empty($params['secretKey'])) { - $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, - '', - '&', - PHP_QUERY_RFC3986 - ); - - // 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; - } finally { - $this->params = []; - } - } - - /** - * 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 - */ - public function checkHandlerRequest(): bool - { - $ip = $this->getIp(); - if (empty($this->secretKey)) { - throw new UnitpayValidationException('SecretKey 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) = [$request['method'], $request['params']]; - - if (!in_array($method, self::SUPPORTED_PARTNER_METHODS, true)) { - throw new UnitpayUnsupportedMethodException('Method is not supported'); - } - - if (!isset($params['signature']) || !is_string($params['signature']) - || !hash_equals($this->getSignature($params, $method), $params['signature'])) { - throw new UnitpaySignatureException('Wrong signature'); - } - - if (!$this->isAllowedIp($ip)) { - throw new UnitpayIpException('IP address Error'); - } - - $this->handlerMethod = $method; - $this->handlerParams = $params; - - return true; - } - - /** - * 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 - { - return $this->handlerMethod; - } - - /** - * The webhook params verified by the last successful checkHandlerRequest(). - * null until a successful verification. - * @return array|null - */ - public function getHandlerParams(): ?array - { - return $this->handlerParams; - } - - /** - * 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 - { - return 'php_' . self::VERSION . '_' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION; - } - - /** - * 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([ - '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 . ' api/' . self::API_VERSION, - 'X-Unitpay-Client: ' . $client, - ]; - } - - /** - * 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 - */ - private static function stringifyFloats(array $params): array - { - foreach ($params as $key => $value) { - if (is_float($value)) { - $params[$key] = self::floatToString($value); - } - } - - return $params; - } - - /** - * 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 - { - return rtrim(rtrim(sprintf('%.8F', $value), '0'), '.'); - } - - /** - * Builds the JSON success response that Unitpay expects from the handler. - */ - public function getSuccessHandlerResponse(string $message): string - { - return (string) json_encode(['result' => ['message' => $message]]); - } - - /** - * Builds the JSON error response that Unitpay expects from the handler. - */ - public function getErrorHandlerResponse(string $message): string - { - return (string) json_encode(['error' => ['message' => $message]]); - } -} diff --git a/composer.json b/composer.json index ccb9b17..17b8a89 100644 --- a/composer.json +++ b/composer.json @@ -28,10 +28,7 @@ "autoload":{ "psr-4": { "Unitpay\\": "src/" - }, - "classmap":[ - "./UnitPay.php" - ] + } }, "autoload-dev": { "psr-4": { @@ -40,11 +37,11 @@ }, "scripts": { "test": "phpunit", - "lint": "parallel-lint UnitPay.php src examples tests", + "lint": "parallel-lint src 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", + "md": "@php -d error_reporting=\"E_ALL & ~E_DEPRECATED\" vendor/bin/phpmd src text phpmd.xml", "check": [ "@lint", "@cs-check", @@ -59,7 +56,7 @@ "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", + "md": "Run PHPMD mess detection on src/", "check": "Run lint, cs-check, stan, md and test in sequence" } } diff --git a/phpstan.neon b/phpstan.neon index 20ab4c2..63aadba 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,5 +2,4 @@ parameters: level: 6 paths: - src - - UnitPay.php - tests diff --git a/tests/Api/AbstractServiceTest.php b/tests/Api/AbstractServiceTest.php new file mode 100644 index 0000000..fde5a34 --- /dev/null +++ b/tests/Api/AbstractServiceTest.php @@ -0,0 +1,189 @@ +payments()->getPayment(555); + + $url = $transport->lastUrl(); + $this->assertStringStartsWith('https://unitpay.test/api?', $url); + $this->assertStringContainsString('method=getPayment', $url); + $this->assertStringContainsString('paymentId', $url); + $this->assertStringContainsString('555', $url); + $this->assertStringContainsString('my-secret', $url); + } + + public function testRequestUrlUsesFlatParamsNotNested(): void + { + $transport = new FakeTransport(); + $unitpay = new Unitpay('unitpay.test', 'my-secret', $transport); + + $unitpay->payments()->getPayment(555); + + // Unitpay accepts flat query-string params since 05/2026 — no legacy params[...] nesting. + $url = $transport->lastUrl(); + $this->assertStringContainsString('paymentId=555', $url); + $this->assertStringContainsString('secretKey=my-secret', $url); + $this->assertStringNotContainsString('params%5B', $url); + $this->assertStringNotContainsString('params[', $url); + } + + /** + * Params accumulated by the fluent setters (setCashItems/setCustomerEmail/…) must + * reach the service request, not just form(). Regression guard: the pre-3.0 api() used + * to build the URL only from its own argument and silently drop them. + */ + public function testCashItemsFromSetterAreSentByService(): void + { + $transport = new FakeTransport(); + $unitpay = new Unitpay('unitpay.test', 'my-secret', $transport); + $unitpay->setCashItems([new CashItem('Coffee', 1, 100.0)]) + ->setCustomerEmail('buyer@example.com'); + + $unitpay->payments()->initPayment('1', 100, 7, 'card'); + + $url = $transport->lastUrl(); + $this->assertStringContainsString('cashItems=', $url); + $this->assertStringContainsString('customerEmail=', $url); + + $query = $transport->query(); + $items = json_decode(base64_decode((string) $query['cashItems']), true); + $this->assertSame('Coffee', $items[0]['name']); + } + + /** Explicit call options take precedence over anything set by the fluent setters. */ + public function testExplicitOptionOverridesAccumulatedParam(): void + { + $transport = new FakeTransport(); + $unitpay = new Unitpay('unitpay.test', 'my-secret', $transport); + $unitpay->setBackUrl('https://old.example/back'); + + $unitpay->payments()->initPayment('1', 100, 7, 'card', [ + 'backUrl' => 'https://new.example/back', + ]); + + $this->assertSame('https://new.example/back', $transport->query()['backUrl']); + } + + /** + * Fluent-setter params are cleared by a successful call and must not leak into the + * next one on a reused instance (regression: a stale cashItems receipt or + * customerEmail would otherwise go out with an unrelated later order). + */ + public function testFluentSetterParamsDoNotBleedIntoNextCall(): void + { + $transport = new FakeTransport(); + $unitpay = new Unitpay('unitpay.test', 'my-secret', $transport); + + $unitpay->setCashItems([new CashItem('Coffee', 1, 100.0)]) + ->setCustomerEmail('buyer@example.com'); + $unitpay->payments()->initPayment('1', 100, 7, 'card'); + + // The second call, without re-setting the receipt/customer, must be clean. + $unitpay->payments()->getPayment(555); + + $this->assertStringContainsString('cashItems=', $transport->url(0)); + $this->assertStringNotContainsString('cashItems=', $transport->url(1)); + $this->assertStringNotContainsString('customerEmail=', $transport->url(1)); + } + + /** + * 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 testFluentSetterParamsAreClearedAfterFailedCall(): void + { + // The first call simulates a transport failure (false), later ones succeed. + $transport = new FakeTransport(false, '{"result":{}}'); + $unitpay = new Unitpay('unitpay.test', 'my-secret', $transport); + $unitpay->setCashItems([new CashItem('Coffee', 1, 100.0)]); + + try { + $unitpay->payments()->getPayment(1); + $this->fail('expected a transport exception on the first call'); + } catch (UnitpayTransportException $e) { + // expected: the transport returned false + } + + $unitpay->payments()->getPayment(2); + + // The receipt was consumed by the failed call and did NOT leak into the next one. + $this->assertStringContainsString('cashItems=', $transport->url(0)); + $this->assertStringNotContainsString('cashItems=', $transport->url(1)); + } + + public function testNonObjectResponseIsReportedAsTemporaryServerError(): void + { + $unitpay = new Unitpay('unitpay.test', 'secret', new FakeTransport('this is not json')); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Temporary server error'); + $unitpay->payments()->getPayment(1); + } + + public function testMissingSecretThrows(): void + { + $unitpay = new Unitpay('unitpay.test', null, new FakeTransport()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('SecretKey is null'); + $unitpay->payments()->getPayment(1); + } + + /** A transport failure is a typed exception, still catchable as InvalidArgumentException. */ + public function testTransportFailureThrowsTypedTransportException(): void + { + $unitpay = new Unitpay('unitpay.test', 'secret', new FakeTransport(false)); + + try { + $unitpay->payments()->getPayment(1); + $this->fail('expected a transport exception'); + } catch (UnitpayTransportException $e) { + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertStringContainsString('Temporary server error', $e->getMessage()); + } + } + + /** Account-level methods can override the project key with the account key (secretKey). */ + public function testExplicitSecretKeyOverridesInstanceKey(): void + { + $transport = new FakeTransport(); + $unitpay = new Unitpay('unitpay.test', 'project-key', $transport); + + $unitpay->reference()->getPartner('partner@example.com', ['secretKey' => 'account-key']); + + $this->assertSame('account-key', $transport->query()['secretKey']); + } + + /** + * Required params are now enforced by the service method signatures instead of the + * pre-3.0 runtime REQUIRED_UNITPAY_METHODS_PARAMS dictionary. Guards against someone + * "simplifying" the services by giving the required arguments defaults. + */ + public function testRequiredParamsAreEnforcedByTheMethodSignature(): void + { + $unitpay = new Unitpay('unitpay.test', 'secret', new FakeTransport()); + + $this->expectException(\ArgumentCountError::class); + /** @phpstan-ignore-next-line deliberately called with too few arguments */ + $unitpay->payments()->initPayment('order-1'); + } +} diff --git a/tests/Api/PaymentServiceTest.php b/tests/Api/PaymentServiceTest.php new file mode 100644 index 0000000..bd44725 --- /dev/null +++ b/tests/Api/PaymentServiceTest.php @@ -0,0 +1,108 @@ +unitpay(new FakeTransport('{"result":{"receiptId":42}}')); + + $response = $unitpay->payments()->initPayment('1', 100, 7, 'card'); + + $this->assertSame(42, $response->result->receiptId); + } + + public function testInitPaymentSendsItsRequiredParams(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->payments()->initPayment('order-1', 100, 7, 'card'); + + $query = $transport->query(); + $this->assertSame('initPayment', $query['method']); + $this->assertSame('order-1', $query['account']); + $this->assertSame('100', $query['sum']); + $this->assertSame('7', $query['projectId']); + $this->assertSame('card', $query['paymentType']); + } + + /** desc is optional since the required params were aligned with the backend. */ + public function testInitPaymentPassesOptionsThrough(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->payments()->initPayment('order-1', 100, 7, 'card', [ + 'desc' => 'Order #1', + 'currency' => 'USD', + ]); + + $query = $transport->query(); + $this->assertSame('Order #1', $query['desc']); + $this->assertSame('USD', $query['currency']); + } + + public function testGetPaymentSendsPaymentId(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->payments()->getPayment(555); + + $this->assertSame('getPayment', $transport->query()['method']); + $this->assertSame('555', $transport->query()['paymentId']); + } + + /** A refund is full by default and partial when a sum is passed. */ + public function testRefundPaymentSupportsPartialSum(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->payments()->refundPayment(555, ['sum' => 50]); + + $query = $transport->query(); + $this->assertSame('refundPayment', $query['method']); + $this->assertSame('555', $query['paymentId']); + $this->assertSame('50', $query['sum']); + } + + public function testConfirmPaymentSendsPaymentId(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->payments()->confirmPayment(555); + + $this->assertSame('confirmPayment', $transport->query()['method']); + $this->assertSame('555', $transport->query()['paymentId']); + } + + public function testCancelPaymentSendsPaymentId(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->payments()->cancelPayment(555); + + $this->assertSame('cancelPayment', $transport->query()['method']); + $this->assertSame('555', $transport->query()['paymentId']); + } + + public function testOffsetAdvanceSendsLoginAndPaymentId(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->payments()->offsetAdvance('partner@example.com', 555); + + $query = $transport->query(); + $this->assertSame('offsetAdvance', $query['method']); + $this->assertSame('partner@example.com', $query['login']); + $this->assertSame('555', $query['paymentId']); + } +} diff --git a/tests/Api/PayoutServiceTest.php b/tests/Api/PayoutServiceTest.php new file mode 100644 index 0000000..8298c52 --- /dev/null +++ b/tests/Api/PayoutServiceTest.php @@ -0,0 +1,133 @@ +unitpay($transport)->payouts()->massPayment( + 'partner@example.com', + 1782, + 10, + '79510000071', + 'sbp', + ['memberId' => '100000000111'] + ); + + $query = $transport->query(); + $this->assertSame('massPayment', $query['method']); + $this->assertSame('partner@example.com', $query['login']); + $this->assertSame('1782', $query['transactionId']); + $this->assertSame('10', $query['sum']); + $this->assertSame('79510000071', $query['purse']); + $this->assertSame('sbp', $query['paymentType']); + $this->assertSame('100000000111', $query['memberId']); + } + + public function testPayoutRequestUrlUsesFlatParams(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->payouts()->massPayment('partner@example.com', 1782, 10, '79510000071', 'sbp'); + + $url = $transport->lastUrl(); + $this->assertStringContainsString('method=massPayment', $url); + $this->assertStringContainsString('transactionId=1782', $url); + $this->assertStringContainsString('purse=79510000071', $url); + $this->assertStringNotContainsString('params%5B', $url); + } + + public function testMassPaymentStatusSendsLoginAndTransactionId(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->payouts()->massPaymentStatus('partner@example.com', 1782); + + $query = $transport->query(); + $this->assertSame('massPaymentStatus', $query['method']); + $this->assertSame('partner@example.com', $query['login']); + $this->assertSame('1782', $query['transactionId']); + } + + public function testMassPaymentAvailableAmountSendsItsRequiredParams(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->payouts()->massPaymentAvailableAmount( + 'partner@example.com', + 10, + '79510000071', + 'sbp' + ); + + $query = $transport->query(); + $this->assertSame('massPaymentAvailableAmount', $query['method']); + $this->assertSame('partner@example.com', $query['login']); + $this->assertSame('10', $query['sum']); + $this->assertSame('79510000071', $query['purse']); + $this->assertSame('sbp', $query['paymentType']); + } + + public function testMassPaymentCommissionsSendsLogin(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->payouts()->massPaymentCommissions('partner@example.com'); + + $query = $transport->query(); + $this->assertSame('massPaymentCommissions', $query['method']); + $this->assertSame('partner@example.com', $query['login']); + } + + public function testGetSbpBankListSendsLogin(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->payouts()->getSbpBankList('partner@example.com'); + + $query = $transport->query(); + $this->assertSame('getSbpBankList', $query['method']); + $this->assertSame('partner@example.com', $query['login']); + } + + public function testGetBinInfoSendsLoginAndBin(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->payouts()->getBinInfo('partner@example.com', '220220'); + + $query = $transport->query(); + $this->assertSame('getBinInfo', $query['method']); + $this->assertSame('partner@example.com', $query['login']); + $this->assertSame('220220', $query['bin']); + } + + /** Payouts run on the account key, which overrides the project key from the constructor. */ + public function testAccountSecretKeyOverridesProjectKey(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->payouts()->massPaymentCommissions('partner@example.com', [ + 'secretKey' => 'account-key', + ]); + + $this->assertSame('account-key', $transport->query()['secretKey']); + } +} diff --git a/tests/Api/ReferenceServiceTest.php b/tests/Api/ReferenceServiceTest.php new file mode 100644 index 0000000..1b8afa0 --- /dev/null +++ b/tests/Api/ReferenceServiceTest.php @@ -0,0 +1,60 @@ +unitpay($transport)->reference()->getMethodsAvailable(7); + + $query = $transport->query(); + $this->assertSame('getMethodsAvailable', $query['method']); + $this->assertSame('7', $query['projectId']); + } + + public function testGetCommissionsSendsProjectIdAndLogin(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->reference()->getCommissions(7, 'partner@example.com'); + + $query = $transport->query(); + $this->assertSame('getCommissions', $query['method']); + $this->assertSame('7', $query['projectId']); + $this->assertSame('partner@example.com', $query['login']); + } + + public function testGetCurrencyCoursesSendsLogin(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->reference()->getCurrencyCourses('partner@example.com'); + + $query = $transport->query(); + $this->assertSame('getCurrencyCourses', $query['method']); + $this->assertSame('partner@example.com', $query['login']); + } + + public function testGetPartnerSendsLogin(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->reference()->getPartner('partner@example.com'); + + $query = $transport->query(); + $this->assertSame('getPartner', $query['method']); + $this->assertSame('partner@example.com', $query['login']); + } +} diff --git a/tests/Api/SubscriptionServiceTest.php b/tests/Api/SubscriptionServiceTest.php new file mode 100644 index 0000000..3bf38e8 --- /dev/null +++ b/tests/Api/SubscriptionServiceTest.php @@ -0,0 +1,58 @@ +unitpay($transport)->subscriptions()->listSubscriptions(7); + + $query = $transport->query(); + $this->assertSame('listSubscriptions', $query['method']); + $this->assertSame('7', $query['projectId']); + } + + /** 'all' widens the listing to closed subscriptions too. */ + public function testListSubscriptionsPassesOptionsThrough(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->subscriptions()->listSubscriptions(7, ['all' => 1]); + + $this->assertSame('1', $transport->query()['all']); + } + + public function testGetSubscriptionSendsSubscriptionId(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->subscriptions()->getSubscription(123); + + $query = $transport->query(); + $this->assertSame('getSubscription', $query['method']); + $this->assertSame('123', $query['subscriptionId']); + } + + public function testCloseSubscriptionSendsSubscriptionId(): void + { + $transport = new FakeTransport(); + + $this->unitpay($transport)->subscriptions()->closeSubscription(123); + + $query = $transport->query(); + $this->assertSame('closeSubscription', $query['method']); + $this->assertSame('123', $query['subscriptionId']); + } +} diff --git a/tests/Api/TelemetryTest.php b/tests/Api/TelemetryTest.php new file mode 100644 index 0000000..232288d --- /dev/null +++ b/tests/Api/TelemetryTest.php @@ -0,0 +1,47 @@ +payments()->getPayment(1); + + $ua = $transport->header('User-Agent'); + $client = $transport->header('X-Unitpay-Client'); + + $this->assertSame('unitpay-php-sdk/' . Unitpay::VERSION . ' api/' . Unitpay::API_VERSION, $ua); + + $decoded = json_decode((string) $client, true); + $this->assertSame(Unitpay::VERSION, $decoded['sdk_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']); + } + + /** The IP-feed fetch is a plain GET: no fingerprint headers ride along with it. */ + public function testIpFeedFetchDoesNotCarryFingerprintHeaders(): void + { + $transport = new FakeTransport('{"webhooks":["203.0.113.7"]}'); + $unitpay = new Unitpay('unitpay.test', 'secret', $transport); + + $unitpay->webhook()->refreshAllowedIps(); + + $this->assertNull($transport->header('User-Agent')); + $this->assertNull($transport->header('X-Unitpay-Client')); + } +} diff --git a/tests/FloatHandlingTest.php b/tests/FloatHandlingTest.php new file mode 100644 index 0000000..f57d9fd --- /dev/null +++ b/tests/FloatHandlingTest.php @@ -0,0 +1,108 @@ +unitpay = new Unitpay('unitpay.ru', self::SECRET); + $this->signature = new SignatureBuilder(); + } + + /** + * @param array $params + */ + private function sign(array $params): string + { + return $this->signature->build($params, self::SECRET); + } + + /** + * @return array + */ + private function queryOf(string $url): array + { + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + + return $query; + } + + public function testSignatureRendersFloatAsCanonicalDecimalString(): void + { + $this->assertSame( + hash('sha256', '100.5{up}secret'), + $this->sign(['sum' => 100.5]) + ); + } + + /** 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( + $this->sign(['sum' => '100']), + $this->sign(['sum' => 100.0]) + ); + } + + public function testFormRendersFloatSumAsCanonicalDecimalString(): void + { + $query = $this->queryOf($this->unitpay->form('pk', 100.5, 'acc', 'desc')); + + $this->assertSame('100.5', $query['sum']); + } + + /** The trailing zero is stripped: 100.0 becomes "100" in the query string, not "100.00000000". */ + public function testFormStripsTrailingZeroFromWholeFloatSum(): void + { + $query = $this->queryOf($this->unitpay->form('pk', 100.0, 'acc', 'desc')); + + $this->assertSame('100', $query['sum']); + } + + /** + * 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 + { + $query = $this->queryOf($this->unitpay->form('pk', 100.5, 'acc', 'desc')); + + $this->assertSame( + $this->sign([ + 'account' => 'acc', + 'currency' => 'RUB', + 'desc' => 'desc', + 'sum' => $query['sum'], + ]), + $query['signature'] + ); + } + + public function testServiceCallRendersFloatSumAsCanonicalDecimalString(): void + { + $transport = new FakeTransport(); + $unitpay = new Unitpay('unitpay.test', self::SECRET, $transport); + + $unitpay->payments()->initPayment('order-1', 100.5, 1, 'card'); + + $this->assertSame('100.5', $transport->query()['sum']); + } +} diff --git a/tests/CashItemTest.php b/tests/Model/CashItemTest.php similarity index 84% rename from tests/CashItemTest.php rename to tests/Model/CashItemTest.php index f264228..96fa5ed 100644 --- a/tests/CashItemTest.php +++ b/tests/Model/CashItemTest.php @@ -1,9 +1,13 @@ 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()); + $this->assertSame(Nds::NONE, $item->getNds()); + $this->assertSame(PaymentObject::COMMODITY, $item->getType()); + $this->assertSame(PaymentMethod::PREPAYMENT_FULL, $item->getPaymentMethod()); } public function testConstructorAcceptsExplicitFiscalFields(): void @@ -25,14 +29,14 @@ public function testConstructorAcceptsExplicitFiscalFields(): void 'Service', 1, 999.99, - CashItem::NDS_20, - CashItem::PAYMENT_OBJECT_SERVICE, - CashItem::PAYMENT_METHOD_PAYMENT_FULL + Nds::VAT20, + PaymentObject::SERVICE, + PaymentMethod::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()); + $this->assertSame(Nds::VAT20, $item->getNds()); + $this->assertSame(PaymentObject::SERVICE, $item->getType()); + $this->assertSame(PaymentMethod::PAYMENT_FULL, $item->getPaymentMethod()); } public function testOptionalGettersDefaultToNull(): void @@ -55,7 +59,7 @@ public function testFluentSettersReturnSelfAndStoreValues(): void $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->setMeasure(Measure::KG)); $this->assertSame($item, $item->setNomenclatureCode('04620034587217')); $this->assertSame($item, $item->setMarkCode('mark-1')); $this->assertSame($item, $item->setPreText('before')); @@ -63,7 +67,7 @@ public function testFluentSettersReturnSelfAndStoreValues(): void $this->assertSame(100.5, $item->getSum()); $this->assertSame('USD', $item->getCurrency()); - $this->assertSame(CashItem::MEASURE_KG, $item->getMeasure()); + $this->assertSame(Measure::KG, $item->getMeasure()); $this->assertSame('04620034587217', $item->getNomenclatureCode()); $this->assertSame('mark-1', $item->getMarkCode()); $this->assertSame('before', $item->getPreText()); diff --git a/tests/Model/Enum/PaymentTypeTest.php b/tests/Model/Enum/PaymentTypeTest.php new file mode 100644 index 0000000..cd295dc --- /dev/null +++ b/tests/Model/Enum/PaymentTypeTest.php @@ -0,0 +1,40 @@ +assertSame('card', PaymentType::CARD); + $this->assertSame('cardInvoice', PaymentType::CARD_INVOICE); + $this->assertSame('sbp', PaymentType::SBP); + $this->assertSame('sberpay', PaymentType::SBERPAY); + $this->assertSame('tinkoffpay', PaymentType::TINKOFFPAY); + $this->assertSame('paypal', PaymentType::PAYPAL); + $this->assertSame('webmoney', PaymentType::WEBMONEY); + } + + /** A payment method constant is accepted as-is as the paymentType for initPayment. */ + public function testConstantIsUsableAsInitPaymentType(): void + { + $transport = new FakeTransport( + (string) json_encode(['result' => ['type' => 'redirect', 'redirectUrl' => 'https://unitpay.ru/pay']]) + ); + $unitpay = new Unitpay('unitpay.ru', 'secret', $transport); + + $unitpay->payments()->initPayment('order-1', 100, 1, PaymentType::CARD); + + $this->assertStringContainsString('paymentType=card', $transport->lastUrl()); + } +} diff --git a/tests/UnitPaySignatureTest.php b/tests/Signature/SignatureBuilderTest.php similarity index 55% rename from tests/UnitPaySignatureTest.php rename to tests/Signature/SignatureBuilderTest.php index 8d38ea1..4ff7de2 100644 --- a/tests/UnitPaySignatureTest.php +++ b/tests/Signature/SignatureBuilderTest.php @@ -1,32 +1,41 @@ unitPay = new UnitPay('unitpay.ru', 'secret'); + $this->signature = new SignatureBuilder(); + } + + /** + * Convenience wrapper pinning the secret, so each case reads as the payload it signs. + * @param array $params + */ + private function sign(array $params, ?string $method = null): string + { + return $this->signature->build($params, self::SECRET, $method); } /** - * Defense-in-depth: getSignature() is public, so a direct call with no secret must + * Defense-in-depth: build() is a public entry point, so a 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']); + $this->signature->build(['a' => '1'], null); } public function testSignatureMatchesDocumentedFormula(): void @@ -34,15 +43,15 @@ public function testSignatureMatchesDocumentedFormula(): void // sha256( {up}secretKey ) $this->assertSame( hash('sha256', '1{up}secret'), - $this->unitPay->getSignature(['a' => '1']) + $this->sign(['a' => '1']) ); } public function testSignatureIsIndependentOfKeyOrder(): void { $this->assertSame( - $this->unitPay->getSignature(['a' => '1', 'b' => '2']), - $this->unitPay->getSignature(['b' => '2', 'a' => '1']) + $this->sign(['a' => '1', 'b' => '2']), + $this->sign(['b' => '2', 'a' => '1']) ); } @@ -55,7 +64,7 @@ public function testSignaturePinsAscendingKeyOrder(): void { $this->assertSame( hash('sha256', 'pay{up}1{up}2{up}3{up}secret'), - $this->unitPay->getSignature(['c' => '3', 'a' => '1', 'b' => '2'], 'pay') + $this->sign(['c' => '3', 'a' => '1', 'b' => '2'], 'pay') ); } @@ -63,19 +72,19 @@ public function testMethodIsPrependedToPayload(): void { $this->assertSame( hash('sha256', 'pay{up}1{up}secret'), - $this->unitPay->getSignature(['a' => '1'], 'pay') + $this->sign(['a' => '1'], 'pay') ); $this->assertNotSame( - $this->unitPay->getSignature(['a' => '1']), - $this->unitPay->getSignature(['a' => '1'], 'pay') + $this->sign(['a' => '1']), + $this->sign(['a' => '1'], 'pay') ); } public function testCallerSuppliedSignatureKeysAreStripped(): void { $this->assertSame( - $this->unitPay->getSignature(['a' => '1']), - $this->unitPay->getSignature(['a' => '1', 'sign' => 'x', 'signature' => 'y']) + $this->sign(['a' => '1']), + $this->sign(['a' => '1', 'sign' => 'x', 'signature' => 'y']) ); } @@ -88,8 +97,8 @@ public function testCallerSuppliedSignatureKeysAreStripped(): void public function testPhpIntMaxKeyIsStrippedAndSecretRetained(): void { $this->assertSame( - $this->unitPay->getSignature(['a' => '1']), - $this->unitPay->getSignature([PHP_INT_MAX => 'evil', 'a' => '1']) + $this->sign(['a' => '1']), + $this->sign([PHP_INT_MAX => 'evil', 'a' => '1']) ); } @@ -104,15 +113,39 @@ public function testArrayValuedParamDoesNotEmitWarning(): void throw new \RuntimeException($errstr, $errno); }); try { - $signature = $this->unitPay->getSignature(['a' => ['nested']], 'pay'); + $signature = $this->sign(['a' => ['nested']], 'pay'); } finally { restore_error_handler(); } // '' is substituted for the array, so it matches a param with an empty value. $this->assertSame( - $this->unitPay->getSignature(['a' => ''], 'pay'), + $this->sign(['a' => ''], 'pay'), $signature ); } + + /** + * floatToString()/stringifyFloats() are locale-independent: (string) $float honors + * LC_NUMERIC on PHP <8.0 and would yield "100,5" in comma locales, breaking the + * signature/URL match. + */ + public function testFloatToStringDropsTrailingZeros(): void + { + $this->assertSame('100.5', SignatureBuilder::floatToString(100.5)); + $this->assertSame('100', SignatureBuilder::floatToString(100.0)); + } + + public function testStringifyFloatsConvertsOnlyFloats(): void + { + $params = SignatureBuilder::stringifyFloats([ + 'sum' => 100.5, + 'count' => 2, + 'account' => 'acc', + ]); + + $this->assertSame('100.5', $params['sum']); + $this->assertSame(2, $params['count']); + $this->assertSame('acc', $params['account']); + } } diff --git a/tests/Support/FakeTransport.php b/tests/Support/FakeTransport.php new file mode 100644 index 0000000..63baea1 --- /dev/null +++ b/tests/Support/FakeTransport.php @@ -0,0 +1,83 @@ + */ + private array $calls = []; + + /** @var array */ + private array $responses; + + /** + * @param string|false ...$responses bodies returned by successive send() calls; the + * last one is reused once the queue runs out. Pass + * false to simulate a transport failure. + */ + public function __construct(...$responses) + { + /** @var array $responses */ + $this->responses = $responses === [] ? ['{"result":{}}'] : $responses; + } + + /** + * @param string[] $headers + * @return string|false + */ + public function send(string $url, array $headers = []) + { + $this->calls[] = ['url' => $url, 'headers' => $headers]; + + $index = count($this->calls) - 1; + $last = count($this->responses) - 1; + + return $this->responses[$index < $last ? $index : $last]; + } + + public function callCount(): int + { + return count($this->calls); + } + + /** URL of the n-th call (0-based). */ + public function url(int $index = 0): string + { + return $this->calls[$index]['url']; + } + + public function lastUrl(): string + { + return $this->url(count($this->calls) - 1); + } + + /** + * Query string of the n-th call, parsed into an array. + * @return array + */ + public function query(int $index = 0): array + { + parse_str((string) parse_url($this->url($index), PHP_URL_QUERY), $query); + + return $query; + } + + /** Value of a request header sent on the n-th call, or null when absent. */ + public function header(string $name, int $index = 0): ?string + { + foreach ($this->calls[$index]['headers'] as $header) { + if (stripos($header, $name . ':') === 0) { + return trim(substr($header, strlen($name) + 1)); + } + } + + return null; + } +} diff --git a/tests/UnitPayAllowedIpsTest.php b/tests/UnitPayAllowedIpsTest.php deleted file mode 100644 index 330cb1f..0000000 --- a/tests/UnitPayAllowedIpsTest.php +++ /dev/null @@ -1,265 +0,0 @@ -/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'; - - /** - * Builds a valid signed 'pay' webhook. - * - * @return array{method: string, params: array} - */ - private function validRequest(): array - { - $params = [ - 'account' => '42', - 'orderSum' => '100.00', - 'unitpayId' => '999', - ]; - $params['signature'] = (new UnitPay('unitpay.ru', self::SECRET))->getSignature($params, 'pay'); - - return ['method' => 'pay', 'params' => $params]; - } - - /** 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) { - return $feedBody; - }, $ip); - } - - /** 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); - } - - /** - * @param string[] $ips - */ - 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, not in the default list - $unitPay = $this->handler($this->feed([$ip]), $ip); - - $this->assertTrue($unitPay->refreshAllowedIps()->checkHandlerRequest()); - } - - 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(); - - $this->expectException(UnitpayIpException::class); - $unitPay->checkHandlerRequest(); - } - - // --- fail-safety (fall back to the built-in list) -------------------- - - public function testTransportFailureKeepsBuiltinList(): void - { - $unitPay = $this->handlerWithTransport(static function () { - return false; // transport failure - }, self::DEFAULT_IP); - - $this->assertTrue($unitPay->refreshAllowedIps()->checkHandlerRequest()); - } - - public function testMalformedJsonKeepsBuiltinList(): void - { - $unitPay = $this->handler('this is not json', self::DEFAULT_IP); - - $this->assertTrue($unitPay->refreshAllowedIps()->checkHandlerRequest()); - } - - public function testMissingWebhooksKeyKeepsBuiltinList(): void - { - $unitPay = $this->handler('{"foo":123}', self::DEFAULT_IP); - - $this->assertTrue($unitPay->refreshAllowedIps()->checkHandlerRequest()); - } - - public function testEmptyFeedKeepsBuiltinList(): void - { - $unitPay = $this->handler($this->feed([]), self::DEFAULT_IP); - - $this->assertTrue($unitPay->refreshAllowedIps()->checkHandlerRequest()); - } - - 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, the merchant's own relay - $unitPay = $this->handler($this->feed(['203.0.113.7']), $customIp); - - $unitPay->addAllowedIps([$customIp])->refreshAllowedIps(); - - $this->assertTrue($unitPay->checkHandlerRequest()); - } - - // --- feed URL -------------------------------------------------------- - - public function testRefreshFetchesTheCanonicalFeedUrl(): void - { - $captured = null; - $unitPay = $this->handlerWithTransport(static function ($url) use (&$captured) { - $captured = $url; - return '{"webhooks":["203.0.113.7"]}'; - }, self::DEFAULT_IP); - - $unitPay->refreshAllowedIps(); - - $this->assertSame('https://unitpay.ru/ips/ips_webhooks.json', $captured); - } - - // --- CIDR from the feed ---------------------------------------------- - - public function testCidrRangeFromFeedIsHonoured(): void - { - $unitPay = $this->handler($this->feed(['203.0.113.0/24']), '203.0.113.55'); - - $this->assertTrue($unitPay->refreshAllowedIps()->checkHandlerRequest()); - } - - // --- junk filtering -------------------------------------------------- - - public function testValidEntriesAppliedAndJunkDropped(): void - { - $unitPay = $this->handler($this->feed(['203.0.113.7', 'garbage']), '203.0.113.7'); - $unitPay->refreshAllowedIps(); - - $this->assertSame(['203.0.113.7'], $unitPay->getAllowedIps()); - $this->assertTrue($unitPay->checkHandlerRequest()); - } - - // --- getAllowedIps() ----------------------------------------------------- - - public function testGetAllowedIpsReturnsDedupedUnion(): void - { - $unitPay = new UnitPay('unitpay.ru', self::SECRET); - $unitPay->setAllowedIps(['1.1.1.1'])->addAllowedIps(['1.1.1.1', '2.2.2.2']); - - $this->assertSame(['1.1.1.1', '2.2.2.2'], $unitPay->getAllowedIps()); - } - - public function testGetAllowedIpsDefaultsToBuiltinList(): void - { - $unitPay = new UnitPay('unitpay.ru', self::SECRET); - - $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 - { - $customIp = '198.51.100.5'; - $unitPay = $this->handler($this->feed([self::DEFAULT_IP]), $customIp); - - // 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 - } - - // Adding the IP must reset the matcher cache so the next check sees it. - $unitPay->addAllowedIps([$customIp]); - $this->assertTrue($unitPay->checkHandlerRequest()); - } - - // --- UnitpayIpAllowlist::isValidEntry() ---------------------------------- - - /** - * @dataProvider validEntries - */ - public function testIsValidEntryAcceptsWellFormedEntries(string $entry): void - { - $this->assertTrue(UnitpayIpAllowlist::isValidEntry($entry)); - } - - /** - * @return array - */ - public function validEntries(): array - { - return [ - 'ipv4' => ['31.186.100.49'], - 'ipv6' => ['2001:db8::1'], - 'ipv4 cidr' => ['203.0.113.0/24'], - 'ipv6 cidr' => ['2001:db8::/32'], - ]; - } - - /** - * @dataProvider invalidEntries - */ - public function testIsValidEntryRejectsMalformedEntries(string $entry): void - { - $this->assertFalse(UnitpayIpAllowlist::isValidEntry($entry)); - } - - /** - * @return array - */ - public function invalidEntries(): array - { - return [ - 'garbage' => ['garbage'], - 'out of range' => ['999.999.999.999'], - 'empty bits' => ['203.0.113.0/'], - 'non-digit bits' => ['203.0.113.0/abc'], - 'ipv4 bits too big' => ['203.0.113.0/33'], - 'ipv6 bits too big' => ['2001:db8::/129'], - ]; - } -} diff --git a/tests/UnitPayApiTest.php b/tests/UnitPayApiTest.php deleted file mode 100644 index 407e43c..0000000 --- a/tests/UnitPayApiTest.php +++ /dev/null @@ -1,328 +0,0 @@ -api('initPayment', [ - 'account' => 1, - 'sum' => 100, - 'projectId' => 7, - 'paymentType' => 'card', - ]); - - $this->assertSame(42, $response->result->receiptId); - } - - public function testRequestUrlCarriesMethodParamsAndSecret(): void - { - $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(): void - { - $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-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); - $this->assertStringNotContainsString('params[', $captured); - } - - public function testPayoutRequestUrlUsesFlatParams(): void - { - $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 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 - { - $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 take precedence over anything set by the fluent setters. */ - public function testExplicitApiParamOverridesAccumulatedParam(): void - { - $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 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 - { - $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', - ]); - - // The 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]); - } - - /** - * 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 testFluentSetterParamsAreClearedAfterFailedApiCall(): void - { - $urls = []; - $calls = 0; - // The first call simulates a transport failure (false), later ones succeed. - $transport = static function ($url) use (&$urls, &$calls) { - $urls[] = $url; - $calls++; - return $calls === 1 ? false : '{"result":{}}'; - }; - $unitPay = new UnitPay('unitpay.test', 'my-secret', $transport); - $unitPay->setCashItems([new CashItem('Coffee', 1, 100.0)]); - - try { - $unitPay->api('getPayment', ['paymentId' => 1]); - $this->fail('expected a transport exception on the first call'); - } catch (\UnitpayTransportException $e) { - // expected: the transport returned false - } - - $unitPay->api('getPayment', ['paymentId' => 2]); - - // The receipt was consumed by the failed call and did NOT leak into the next one. - $this->assertStringContainsString('cashItems=', $urls[0]); - $this->assertStringNotContainsString('cashItems=', $urls[1]); - } - - public function testNonObjectResponseIsReportedAsTemporaryServerError(): void - { - $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(): void - { - $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(): void - { - $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(): void - { - $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(): void - { - $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) { - // each payout method requires login first - $this->assertStringContainsString('login', $e->getMessage()); - } - } - } - - /** 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; // 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()); - } - } - - /** An unsupported method throws a typed exception, still catchable as UnexpectedValueException. */ - public function testUnsupportedMethodThrowsTypedException(): void - { - $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 key (secretKey). */ - public function testExplicitSecretKeyOverridesInstanceKey(): void - { - $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/UnitPayFloatTest.php b/tests/UnitPayFloatTest.php deleted file mode 100644 index bb72be2..0000000 --- a/tests/UnitPayFloatTest.php +++ /dev/null @@ -1,101 +0,0 @@ -unitPay = new UnitPay('unitpay.ru', 'secret'); - } - - /** - * @return array - */ - private function queryOf(string $url): array - { - parse_str((string) parse_url($url, PHP_URL_QUERY), $q); - return $q; - } - - public function testSignatureRendersFloatAsCanonicalDecimalString(): void - { - $this->assertSame( - hash('sha256', '100.5{up}secret'), - $this->unitPay->getSignature(['sum' => 100.5]) - ); - } - - /** 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( - $this->unitPay->getSignature(['sum' => '100']), - $this->unitPay->getSignature(['sum' => 100.0]) - ); - } - - public function testFormRendersFloatSumAsCanonicalDecimalString(): void - { - $q = $this->queryOf($this->unitPay->form('pk', 100.5, 'acc', 'desc')); - - $this->assertSame('100.5', $q['sum']); - } - - /** 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')); - - $this->assertSame('100', $q['sum']); - } - - /** - * 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 - { - $q = $this->queryOf($this->unitPay->form('pk', 100.5, 'acc', 'desc')); - - $expected = $this->unitPay->getSignature([ - 'account' => 'acc', - 'currency' => 'RUB', - 'desc' => 'desc', - 'sum' => $q['sum'], - ]); - $this->assertSame($expected, $q['signature']); - } - - public function testApiRendersFloatSumAsCanonicalDecimalString(): void - { - $captured = null; - $transport = static function ($url) use (&$captured) { - $captured = $url; - return '{"result":{}}'; - }; - $unitPay = new UnitPay('unitpay.test', 'secret', $transport); - - $unitPay->api('initPayment', [ - 'account' => 'order-1', - 'sum' => 100.5, - 'projectId' => 1, - 'paymentType' => 'card', - ]); - - parse_str((string) parse_url($captured, PHP_URL_QUERY), $q); - $this->assertSame('100.5', $q['sum']); - } -} diff --git a/tests/UnitPayFormTest.php b/tests/UnitPayFormTest.php deleted file mode 100644 index f4e2f63..0000000 --- a/tests/UnitPayFormTest.php +++ /dev/null @@ -1,149 +0,0 @@ - - */ - private function queryOf(string $url): array - { - parse_str((string) parse_url($url, PHP_URL_QUERY), $q); - return $q; - } - - public function testFormBuildsHostedPaymentUrl(): void - { - $unitPay = new UnitPay('unitpay.ru', 'secret'); - - $url = $unitPay->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(): void - { - $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(): void - { - $unitPay = new UnitPay('unitpay.ru'); - - $this->expectException(\UnitpayValidationException::class); - $unitPay->form('pk', 100, 'acc', 'desc'); - } - - public function testFormHonoursCurrencyAndLocaleOverrides(): void - { - $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(): void - { - $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); - } - - /** - * 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 - { - $unitPay = new UnitPay('unitpay.ru', 'secret'); - $unitPay->setBackUrl('https://shop.example/back') - ->setCustomerEmail('customer@example.com'); - - $first = $this->queryOf($unitPay->form('pk', 100, 'acc', 'desc')); - $second = $this->queryOf($unitPay->form('pk', 200, 'acc2', 'desc2')); - - $this->assertArrayHasKey('backUrl', $first); - $this->assertArrayNotHasKey('backUrl', $second); - $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'); - $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']); - } - - /** - * 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 - { - $unitPay = new UnitPay('unitpay.test', 'secret'); - $url = $unitPay->form('pub', 100, 'order-1', 'Desc'); - $q = $this->queryOf($url); - - $this->assertSame( - 'php_' . UnitPay::VERSION . '_' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION, - $q['sdk'] - ); - // URL-safe: the token appears in the final URL verbatim, without %-encoding. - $this->assertStringContainsString('sdk=php_', $url); - - $expected = (new UnitPay('unitpay.test', 'secret'))->getSignature([ - 'account' => 'order-1', - 'currency' => 'RUB', - 'desc' => 'Desc', - 'sum' => 100, - ]); - $this->assertSame($expected, $q['signature']); - } -} diff --git a/tests/UnitPayPaymentTypeTest.php b/tests/UnitPayPaymentTypeTest.php deleted file mode 100644 index a28812b..0000000 --- a/tests/UnitPayPaymentTypeTest.php +++ /dev/null @@ -1,45 +0,0 @@ -assertSame('card', UnitPay::PAYMENT_TYPE_CARD); - $this->assertSame('cardInvoice', UnitPay::PAYMENT_TYPE_CARD_INVOICE); - $this->assertSame('sbp', UnitPay::PAYMENT_TYPE_SBP); - $this->assertSame('sberpay', UnitPay::PAYMENT_TYPE_SBERPAY); - $this->assertSame('tinkoffpay', UnitPay::PAYMENT_TYPE_TINKOFFPAY); - $this->assertSame('paypal', UnitPay::PAYMENT_TYPE_PAYPAL); - $this->assertSame('webmoney', UnitPay::PAYMENT_TYPE_WEBMONEY); - } - - /** A payment method constant is accepted as-is as the paymentType for initPayment. */ - public function testConstantIsUsableAsInitPaymentType(): void - { - $captured = null; - $transport = static function ($url) use (&$captured) { - $captured = $url; - return json_encode(['result' => ['type' => 'redirect', 'redirectUrl' => 'https://unitpay.ru/pay']]); - }; - $unitpay = new UnitPay('unitpay.ru', 'secret', $transport); - - $unitpay->api('initPayment', [ - 'account' => 'order-1', - 'sum' => 100, - 'projectId' => 1, - 'paymentType' => UnitPay::PAYMENT_TYPE_CARD, - ]); - - $this->assertStringContainsString('paymentType=card', $captured); - } -} diff --git a/tests/UnitPayTelemetryTest.php b/tests/UnitPayTelemetryTest.php deleted file mode 100644 index d1e6404..0000000 --- a/tests/UnitPayTelemetryTest.php +++ /dev/null @@ -1,54 +0,0 @@ -}> $calls - */ - private function spy(array &$calls): callable - { - return static function (string $url, array $headers = []) use (&$calls): string { - $calls[] = ['url' => $url, 'headers' => $headers]; - return '{"result":{}}'; - }; - } - - /** - * @param array $headers - */ - private function headerValue(array $headers, string $name): ?string - { - foreach ($headers as $h) { - if (stripos($h, $name . ':') === 0) { - return trim(substr($h, strlen($name) + 1)); - } - } - return null; - } - - public function testApiSendsFingerprintHeaders(): void - { - $calls = []; - $unitPay = new UnitPay('unitpay.test', 'secret', $this->spy($calls)); - $unitPay->api('getPayment', ['paymentId' => 1]); - - $headers = $calls[0]['headers']; - $ua = $this->headerValue($headers, 'User-Agent'); - $client = $this->headerValue($headers, 'X-Unitpay-Client'); - - $this->assertSame('unitpay-php-sdk/' . UnitPay::VERSION . ' api/' . UnitPay::API_VERSION, $ua); - $decoded = json_decode((string) $client, true); - $this->assertSame(UnitPay::VERSION, $decoded['sdk_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']); - } -} diff --git a/tests/UnitPayCashItemsTest.php b/tests/UnitpayCashItemsTest.php similarity index 60% rename from tests/UnitPayCashItemsTest.php rename to tests/UnitpayCashItemsTest.php index 1bd3abe..95a41f6 100644 --- a/tests/UnitPayCashItemsTest.php +++ b/tests/UnitpayCashItemsTest.php @@ -2,41 +2,46 @@ namespace Tests; -use CashItem; -use UnitPay; use PHPUnit\Framework\TestCase; - -final class UnitPayCashItemsTest extends TestCase +use Unitpay\Exception\UnitpayValidationException; +use Unitpay\Model\CashItem; +use Unitpay\Model\Enum\Measure; +use Unitpay\Model\Enum\Nds; +use Unitpay\Model\Enum\PaymentMethod; +use Unitpay\Model\Enum\PaymentObject; +use Unitpay\Unitpay; + +final class UnitpayCashItemsTest extends TestCase { /** - * 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. + * setCashItems() stores base64(json(...)) in the pending params; the only public way + * to read it back is via the form's query string, so we decode it from there. * * @return array> */ - private function serializedItems(UnitPay $unitPay): array + private function serializedItems(Unitpay $unitpay): array { - $url = $unitPay->form('pk', 1, 'acc', 'desc'); - parse_str((string) parse_url($url, PHP_URL_QUERY), $q); + $url = $unitpay->form('pk', 1, 'acc', 'desc'); + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); - return json_decode(base64_decode($q['cashItems']), true); + return json_decode(base64_decode((string) $query['cashItems']), true); } public function testRequiredFieldsAreAlwaysSerialized(): void { - $unitPay = new UnitPay('unitpay.ru', 'secret'); - $unitPay->setCashItems([ + $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 + Nds::VAT20, + PaymentObject::COMMODITY, + PaymentMethod::PAYMENT_FULL ), ]); - $items = $this->serializedItems($unitPay); + $items = $this->serializedItems($unitpay); $this->assertCount(1, $items); $this->assertSame('Coffee', $items[0]['name']); @@ -49,10 +54,10 @@ public function testRequiredFieldsAreAlwaysSerialized(): void public function testOptionalFieldsAreOmittedWhenNotSet(): void { - $unitPay = new UnitPay('unitpay.ru', 'secret'); - $unitPay->setCashItems([new CashItem('X', 1, 10.0)]); + $unitpay = new Unitpay('unitpay.ru', 'secret'); + $unitpay->setCashItems([new CashItem('X', 1, 10.0)]); - $items = $this->serializedItems($unitPay); + $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"); @@ -64,17 +69,17 @@ public function testOptionalFieldsAreSerializedWhenSet(): void $item = new CashItem('Y', 1, 10.5); $item->setSum(10.5) ->setCurrency('USD') - ->setMeasure(CashItem::MEASURE_KG) + ->setMeasure(Measure::KG) ->setNomenclatureCode('NC-1') ->setMarkCode('MC-1') ->setPreText('pre') ->setPostText('post') ->setMarkQuantity(1, 2); - $unitPay = new UnitPay('unitpay.ru', 'secret'); - $unitPay->setCashItems([$item]); + $unitpay = new Unitpay('unitpay.ru', 'secret'); + $unitpay->setCashItems([$item]); - $items = $this->serializedItems($unitPay); + $items = $this->serializedItems($unitpay); $this->assertSame(10.5, $items[0]['sum']); $this->assertSame('USD', $items[0]['currency']); @@ -88,13 +93,13 @@ public function testOptionalFieldsAreSerializedWhenSet(): void public function testMultipleItemsKeepTheirOrder(): void { - $unitPay = new UnitPay('unitpay.ru', 'secret'); - $unitPay->setCashItems([ + $unitpay = new Unitpay('unitpay.ru', 'secret'); + $unitpay->setCashItems([ new CashItem('A', 1, 1.5), new CashItem('B', 2, 2.5), ]); - $items = $this->serializedItems($unitPay); + $items = $this->serializedItems($unitpay); $this->assertCount(2, $items); $this->assertSame('A', $items[0]['name']); @@ -107,10 +112,10 @@ public function testMultipleItemsKeepTheirOrder(): void */ public function testSetCashItemsThrowsOnNonUtf8Name(): void { - $unitPay = new UnitPay('unitpay.ru', 'secret'); + $unitpay = new Unitpay('unitpay.ru', 'secret'); - $this->expectException(\UnitpayValidationException::class); + $this->expectException(UnitpayValidationException::class); $this->expectExceptionMessage('Failed to encode cashItems'); - $unitPay->setCashItems([new CashItem("\xB0Coffee", 1, 100.0)]); + $unitpay->setCashItems([new CashItem("\xB0Coffee", 1, 100.0)]); } } diff --git a/tests/UnitpayFacadeTest.php b/tests/UnitpayFacadeTest.php new file mode 100644 index 0000000..c35e923 --- /dev/null +++ b/tests/UnitpayFacadeTest.php @@ -0,0 +1,110 @@ +assertInstanceOf(PaymentService::class, $unitpay->payments()); + $this->assertInstanceOf(SubscriptionService::class, $unitpay->subscriptions()); + $this->assertInstanceOf(PayoutService::class, $unitpay->payouts()); + $this->assertInstanceOf(ReferenceService::class, $unitpay->reference()); + $this->assertInstanceOf(WebhookVerifier::class, $unitpay->webhook()); + } + + /** Services are built lazily and then reused — a getter is not a factory. */ + public function testServiceGettersAreMemoized(): void + { + $unitpay = new Unitpay('unitpay.test', 'secret', new FakeTransport()); + + $this->assertSame($unitpay->payments(), $unitpay->payments()); + $this->assertSame($unitpay->subscriptions(), $unitpay->subscriptions()); + $this->assertSame($unitpay->payouts(), $unitpay->payouts()); + $this->assertSame($unitpay->reference(), $unitpay->reference()); + $this->assertSame($unitpay->webhook(), $unitpay->webhook()); + } + + /** + * One injected transport serves both the API service layer and the webhook IP-feed + * fetch, so a consumer can swap the HTTP stack (or stub it in tests) in one place. + */ + public function testInjectedTransportIsSharedByServicesAndWebhook(): void + { + $transport = new FakeTransport('{"result":{}}', '{"webhooks":["203.0.113.7"]}'); + $unitpay = new Unitpay('unitpay.test', 'secret', $transport); + + $unitpay->payments()->getPayment(1); + $unitpay->webhook()->refreshAllowedIps(); + + $this->assertSame(2, $transport->callCount()); + $this->assertStringStartsWith('https://unitpay.test/api?', $transport->url(0)); + $this->assertSame('https://unitpay.test/ips/ips_webhooks.json', $transport->url(1)); + } + + public function testAllServicesShareTheInjectedTransport(): void + { + $transport = new FakeTransport(); + $unitpay = new Unitpay('unitpay.test', 'secret', $transport); + + $unitpay->payments()->getPayment(1); + $unitpay->subscriptions()->getSubscription(2); + $unitpay->payouts()->massPaymentCommissions('partner@example.com'); + $unitpay->reference()->getPartner('partner@example.com'); + + $this->assertSame(4, $transport->callCount()); + } + + /** + * The fluent setters live on the facade but their params belong to whichever service + * is called next — the pending-params holder is shared, not per-service. + */ + public function testAccumulatedParamsReachAnyService(): void + { + $transport = new FakeTransport(); + $unitpay = new Unitpay('unitpay.test', 'secret', $transport); + + $unitpay->setCashItems([new CashItem('Coffee', 1, 100.0)]); + $unitpay->payouts()->massPaymentCommissions('partner@example.com'); + + $this->assertArrayHasKey('cashItems', $transport->query()); + } + + /** The domain given to the constructor drives every endpoint the facade builds. */ + public function testDomainDrivesFormAndApiEndpoints(): void + { + $transport = new FakeTransport(); + $unitpay = new Unitpay('unitpay.test', 'secret', $transport); + + $formUrl = $unitpay->form('pk', 100, 'acc', 'desc'); + $unitpay->payments()->getPayment(1); + + $this->assertStringStartsWith('https://unitpay.test/pay/pk?', $formUrl); + $this->assertStringStartsWith('https://unitpay.test/api?', $transport->lastUrl()); + } + + /** Without an injected transport the facade still builds — it falls back to CurlTransport. */ + public function testFacadeIsUsableWithoutAnInjectedTransport(): void + { + $unitpay = new Unitpay('unitpay.test', 'secret'); + + $this->assertInstanceOf(PaymentService::class, $unitpay->payments()); + $this->assertStringStartsWith('https://unitpay.test/pay/pk?', $unitpay->form('pk', 100, 'acc', 'desc')); + } +} diff --git a/tests/UnitpayFormTest.php b/tests/UnitpayFormTest.php new file mode 100644 index 0000000..08da885 --- /dev/null +++ b/tests/UnitpayFormTest.php @@ -0,0 +1,166 @@ + + */ + private function queryOf(string $url): array + { + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + + return $query; + } + + /** + * @param array $params + */ + private function sign(array $params): string + { + return (new SignatureBuilder())->build($params, self::SECRET); + } + + public function testFormBuildsHostedPaymentUrl(): void + { + $unitpay = new Unitpay('unitpay.ru', self::SECRET); + + $url = $unitpay->form('public-key', 100, 'user@example.com', 'Order #1'); + + $this->assertStringStartsWith('https://unitpay.ru/pay/public-key?', $url); + + $query = $this->queryOf($url); + $this->assertSame('user@example.com', $query['account']); + $this->assertSame('RUB', $query['currency']); + $this->assertSame('Order #1', $query['desc']); + $this->assertSame('100', $query['sum']); + $this->assertSame('ru', $query['locale']); + } + + public function testFormIncludesSignatureOverVitalParamsWhenSecretIsSet(): void + { + $unitpay = new Unitpay('unitpay.ru', self::SECRET); + + $query = $this->queryOf($unitpay->form('pk', 100, 'acc', 'desc')); + + $this->assertArrayHasKey('signature', $query); + $this->assertSame( + $this->sign([ + 'account' => 'acc', + 'currency' => 'RUB', + 'desc' => 'desc', + 'sum' => 100, + ]), + $query['signature'] + ); + } + + public function testFormThrowsWithoutSecret(): void + { + $unitpay = new Unitpay('unitpay.ru'); + + $this->expectException(UnitpayValidationException::class); + $unitpay->form('pk', 100, 'acc', 'desc'); + } + + public function testFormHonoursCurrencyAndLocaleOverrides(): void + { + $unitpay = new Unitpay('unitpay.ru', self::SECRET); + + $query = $this->queryOf($unitpay->form('pk', 100, 'acc', 'desc', 'USD', 'en')); + + $this->assertSame('USD', $query['currency']); + $this->assertSame('en', $query['locale']); + } + + public function testChainedSettersLandInTheFormUrl(): void + { + $unitpay = new Unitpay('unitpay.ru', self::SECRET); + $unitpay->setBackUrl('https://shop.example/back') + ->setCustomerEmail('customer@example.com') + ->setCustomerPhone('+79990000000') + ->setCashItems([new CashItem('X', 1, 100.0)]); + + $query = $this->queryOf($unitpay->form('pk', 100, 'acc', 'desc')); + + $this->assertSame('https://shop.example/back', $query['backUrl']); + $this->assertSame('customer@example.com', $query['customerEmail']); + $this->assertSame('+79990000000', $query['customerPhone']); + $this->assertArrayHasKey('cashItems', $query); + } + + /** + * 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 + { + $unitpay = new Unitpay('unitpay.ru', self::SECRET); + $unitpay->setBackUrl('https://shop.example/back') + ->setCustomerEmail('customer@example.com'); + + $first = $this->queryOf($unitpay->form('pk', 100, 'acc', 'desc')); + $second = $this->queryOf($unitpay->form('pk', 200, 'acc2', 'desc2')); + + $this->assertArrayHasKey('backUrl', $first); + $this->assertArrayNotHasKey('backUrl', $second); + $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', self::SECRET); + $unitpay->setCustomerEmail('customer@example.com') + ->setCashItems([new CashItem('X', 1, 100.0)]); + + $query = $this->queryOf($unitpay->form('pk', 100, 'acc', 'desc')); + + $this->assertSame( + $this->sign([ + 'account' => 'acc', + 'currency' => 'RUB', + 'desc' => 'desc', + 'sum' => 100, + ]), + $query['signature'] + ); + } + + /** + * 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 + { + $unitpay = new Unitpay('unitpay.test', self::SECRET); + $url = $unitpay->form('pub', 100, 'order-1', 'Desc'); + $query = $this->queryOf($url); + + $this->assertSame( + 'php_' . Unitpay::VERSION . '_' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION, + $query['sdk'] + ); + // URL-safe: the token appears in the final URL verbatim, without %-encoding. + $this->assertStringContainsString('sdk=php_', $url); + + $this->assertSame( + $this->sign([ + 'account' => 'order-1', + 'currency' => 'RUB', + 'desc' => 'Desc', + 'sum' => 100, + ]), + $query['signature'] + ); + } +} diff --git a/tests/Webhook/AllowedIpsTest.php b/tests/Webhook/AllowedIpsTest.php new file mode 100644 index 0000000..51129eb --- /dev/null +++ b/tests/Webhook/AllowedIpsTest.php @@ -0,0 +1,214 @@ +/ips/ips_webhooks.json), addAllowedIps() adds merchant IPs on top, + * and every path is fail-safe (never empties the list, never throws). + */ +final class AllowedIpsTest extends TestCase +{ + private const SECRET = 'secret'; + /** One of the built-in default addresses. */ + private const DEFAULT_IP = '31.186.100.49'; + + /** + * Builds a valid signed 'pay' webhook. + * + * @return array{method: string, params: array} + */ + private function validRequest(): array + { + $params = [ + 'account' => '42', + 'orderSum' => '100.00', + 'unitpayId' => '999', + ]; + $params['signature'] = (new SignatureBuilder())->build($params, self::SECRET, 'pay'); + + return ['method' => 'pay', 'params' => $params]; + } + + /** A verifier whose transport returns a fixed body for any URL. */ + private function handler(string $feedBody, string $ip): WebhookVerifier + { + return $this->handlerWithTransport(new FakeTransport($feedBody), $ip); + } + + /** A verifier with a given transport (to simulate failures / capture the URL). */ + private function handlerWithTransport(FakeTransport $transport, string $ip): WebhookVerifier + { + return (new Unitpay('unitpay.ru', self::SECRET, $transport, $this->validRequest(), $ip))->webhook(); + } + + /** + * @param string[] $ips + */ + private function feed(array $ips): string + { + return (string) json_encode(['webhooks' => $ips]); + } + + // --- replace semantics ----------------------------------------------- + + public function testFetchedIpNotInDefaultBecomesAllowed(): void + { + $ip = '203.0.113.7'; // TEST-NET-3, not in the default list + $webhook = $this->handler($this->feed([$ip]), $ip); + + $this->assertTrue($webhook->refreshAllowedIps()->checkHandlerRequest()); + } + + public function testDefaultIpDroppedByFetchIsRejected(): void + { + // The feed no longer contains the built-in address → it must stop being trusted. + $webhook = $this->handler($this->feed(['203.0.113.7']), self::DEFAULT_IP); + $webhook->refreshAllowedIps(); + + $this->expectException(UnitpayIpException::class); + $webhook->checkHandlerRequest(); + } + + // --- fail-safety (fall back to the built-in list) -------------------- + + public function testTransportFailureKeepsBuiltinList(): void + { + $webhook = $this->handlerWithTransport(new FakeTransport(false), self::DEFAULT_IP); + + $this->assertTrue($webhook->refreshAllowedIps()->checkHandlerRequest()); + } + + public function testMalformedJsonKeepsBuiltinList(): void + { + $webhook = $this->handler('this is not json', self::DEFAULT_IP); + + $this->assertTrue($webhook->refreshAllowedIps()->checkHandlerRequest()); + } + + public function testMissingWebhooksKeyKeepsBuiltinList(): void + { + $webhook = $this->handler('{"foo":123}', self::DEFAULT_IP); + + $this->assertTrue($webhook->refreshAllowedIps()->checkHandlerRequest()); + } + + public function testEmptyFeedKeepsBuiltinList(): void + { + $webhook = $this->handler($this->feed([]), self::DEFAULT_IP); + + $this->assertTrue($webhook->refreshAllowedIps()->checkHandlerRequest()); + } + + public function testAllInvalidEntriesKeepBuiltinList(): void + { + $webhook = $this->handler($this->feed(['garbage', '999.999.999.999']), self::DEFAULT_IP); + + $this->assertTrue($webhook->refreshAllowedIps()->checkHandlerRequest()); + // the fallback list kept the default addresses, junk was not stored + $this->assertContains(self::DEFAULT_IP, $webhook->getAllowedIps()); + $this->assertNotContains('garbage', $webhook->getAllowedIps()); + } + + // --- merchant additions on top --------------------------------------- + + public function testCustomIpSurvivesRefresh(): void + { + $customIp = '198.51.100.5'; // TEST-NET-2, the merchant's own relay + $webhook = $this->handler($this->feed(['203.0.113.7']), $customIp); + + $webhook->addAllowedIps([$customIp])->refreshAllowedIps(); + + $this->assertTrue($webhook->checkHandlerRequest()); + } + + // --- feed URL -------------------------------------------------------- + + public function testRefreshFetchesTheCanonicalFeedUrl(): void + { + $transport = new FakeTransport('{"webhooks":["203.0.113.7"]}'); + $webhook = $this->handlerWithTransport($transport, self::DEFAULT_IP); + + $webhook->refreshAllowedIps(); + + $this->assertSame('https://unitpay.ru/ips/ips_webhooks.json', $transport->lastUrl()); + } + + // --- CIDR from the feed ---------------------------------------------- + + public function testCidrRangeFromFeedIsHonoured(): void + { + $webhook = $this->handler($this->feed(['203.0.113.0/24']), '203.0.113.55'); + + $this->assertTrue($webhook->refreshAllowedIps()->checkHandlerRequest()); + } + + // --- junk filtering -------------------------------------------------- + + public function testValidEntriesAppliedAndJunkDropped(): void + { + $webhook = $this->handler($this->feed(['203.0.113.7', 'garbage']), '203.0.113.7'); + $webhook->refreshAllowedIps(); + + $this->assertSame(['203.0.113.7'], $webhook->getAllowedIps()); + $this->assertTrue($webhook->checkHandlerRequest()); + } + + // --- getAllowedIps() ----------------------------------------------------- + + public function testGetAllowedIpsReturnsDedupedUnion(): void + { + $webhook = (new Unitpay('unitpay.ru', self::SECRET))->webhook(); + $webhook->setAllowedIps(['1.1.1.1'])->addAllowedIps(['1.1.1.1', '2.2.2.2']); + + $this->assertSame(['1.1.1.1', '2.2.2.2'], $webhook->getAllowedIps()); + } + + public function testGetAllowedIpsDefaultsToBuiltinList(): void + { + $webhook = (new Unitpay('unitpay.ru', self::SECRET))->webhook(); + + $this->assertSame(['31.186.100.49', '51.250.20.9'], $webhook->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 + { + $webhook = (new Unitpay('unitpay.ru', self::SECRET, null, $this->validRequest(), self::DEFAULT_IP))->webhook(); + $webhook->setAllowedIps([]); + + $this->assertSame([], $webhook->getAllowedIps()); + $this->expectException(UnitpayIpException::class); + $webhook->checkHandlerRequest(); + } + + // --- matcher cache reset --------------------------------------------- + + public function testAddAllowedIpsInvalidatesTheMatcherCache(): void + { + $customIp = '198.51.100.5'; + $webhook = $this->handler($this->feed([self::DEFAULT_IP]), $customIp); + + // The first check builds and caches the matcher without the added IP → rejection. + try { + $webhook->checkHandlerRequest(); + $this->fail('expected the custom IP to be rejected before it is added'); + } catch (UnitpayIpException $e) { + // expected + } + + // Adding the IP must reset the matcher cache so the next check sees it. + $webhook->addAllowedIps([$customIp]); + $this->assertTrue($webhook->checkHandlerRequest()); + } +} diff --git a/tests/UnitPayResponseTest.php b/tests/Webhook/HandlerResponseTest.php similarity index 52% rename from tests/UnitPayResponseTest.php rename to tests/Webhook/HandlerResponseTest.php index bf28a0e..5a962a1 100644 --- a/tests/UnitPayResponseTest.php +++ b/tests/Webhook/HandlerResponseTest.php @@ -1,24 +1,25 @@ unitPay = new UnitPay('unitpay.ru', 'secret'); + $this->webhook = (new Unitpay('unitpay.ru', 'secret'))->webhook(); } public function testSuccessHandlerResponseShape(): void { $this->assertSame( '{"result":{"message":"ok"}}', - $this->unitPay->getSuccessHandlerResponse('ok') + $this->webhook->getSuccessHandlerResponse('ok') ); } @@ -26,7 +27,7 @@ public function testErrorHandlerResponseShape(): void { $this->assertSame( '{"error":{"message":"bad"}}', - $this->unitPay->getErrorHandlerResponse('bad') + $this->webhook->getErrorHandlerResponse('bad') ); } } diff --git a/tests/UnitpayIpAllowlistTest.php b/tests/Webhook/IpAllowlistTest.php similarity index 54% rename from tests/UnitpayIpAllowlistTest.php rename to tests/Webhook/IpAllowlistTest.php index f1707ab..0120e04 100644 --- a/tests/UnitpayIpAllowlistTest.php +++ b/tests/Webhook/IpAllowlistTest.php @@ -1,21 +1,21 @@ assertTrue($upper->contains('2001:db8::1')); - $expanded = new UnitpayIpAllowlist(['2001:db8:0:0:0:0:0:1']); + $expanded = new IpAllowlist(['2001:db8:0:0:0:0:0:1']); $this->assertTrue($expanded->contains('2001:db8::1')); } @@ -68,7 +68,7 @@ public function testInvalidClientIpDoesNotMatch(): void */ public function testIpv4ClientAgainstIpv6OnlySubnetDoesNotMatch(): void { - $matcher = new UnitpayIpAllowlist(['2001:db8::/32']); + $matcher = new IpAllowlist(['2001:db8::/32']); $this->assertFalse($matcher->contains('203.0.113.55')); } @@ -76,7 +76,7 @@ public function testIpv4ClientAgainstIpv6OnlySubnetDoesNotMatch(): void /** 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']); + $matcher = new IpAllowlist(['203.0.113.0/33']); $this->assertFalse($matcher->contains('203.0.113.5')); } @@ -84,26 +84,72 @@ public function testPrefixWiderThanAddressDoesNotMatch(): void /** /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']); + $matcher = new IpAllowlist(['77.75.153.0/25']); $this->assertTrue($matcher->contains('77.75.153.127')); $this->assertFalse($matcher->contains('77.75.153.128')); } + // --- isValidEntry() -------------------------------------------------------- + + /** + * @dataProvider validEntries + */ + public function testIsValidEntryAcceptsWellFormedEntries(string $entry): void + { + $this->assertTrue(IpAllowlist::isValidEntry($entry)); + } + + /** + * @return array + */ + public function validEntries(): array + { + return [ + 'ipv4' => ['31.186.100.49'], + 'ipv6' => ['2001:db8::1'], + 'ipv4 cidr' => ['203.0.113.0/24'], + 'ipv6 cidr' => ['2001:db8::/32'], + ]; + } + + /** + * @dataProvider invalidEntries + */ + public function testIsValidEntryRejectsMalformedEntries(string $entry): void + { + $this->assertFalse(IpAllowlist::isValidEntry($entry)); + } + + /** + * @return array + */ + public function invalidEntries(): array + { + return [ + 'garbage' => ['garbage'], + 'out of range' => ['999.999.999.999'], + 'empty bits' => ['203.0.113.0/'], + 'non-digit bits' => ['203.0.113.0/abc'], + 'ipv4 bits too big' => ['203.0.113.0/33'], + 'ipv6 bits too big' => ['2001:db8::/129'], + ]; + } + // --- parseWebhooksFeed() --------------------------------------------------- public function testParseWebhooksFeedReturnsDedupedList(): void { - $body = json_encode(['webhooks' => ['1.1.1.1', '1.1.1.1', '2.2.2.2']]); + $body = (string) 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)); + $this->assertSame(['1.1.1.1', '2.2.2.2'], IpAllowlist::parseWebhooksFeed($body)); } public function testParseWebhooksFeedKeepsOnlyValidEntries(): void { - $body = json_encode(['webhooks' => ['203.0.113.0/24', 'garbage', '2001:db8::1']]); + $body = (string) json_encode(['webhooks' => ['203.0.113.0/24', 'garbage', '2001:db8::1']]); - $this->assertSame(['203.0.113.0/24', '2001:db8::1'], UnitpayIpAllowlist::parseWebhooksFeed($body)); + $this->assertSame(['203.0.113.0/24', '2001:db8::1'], IpAllowlist::parseWebhooksFeed($body)); } /** @@ -111,7 +157,7 @@ public function testParseWebhooksFeedKeepsOnlyValidEntries(): void */ public function testParseWebhooksFeedReturnsNullForUnusableInput(string $body): void { - $this->assertNull(UnitpayIpAllowlist::parseWebhooksFeed($body)); + $this->assertNull(IpAllowlist::parseWebhooksFeed($body)); } /** @@ -120,10 +166,10 @@ public function testParseWebhooksFeedReturnsNullForUnusableInput(string $body): public function unusableFeeds(): array { return [ - 'empty string' => [''], - 'malformed json' => ['this is not json'], - 'missing webhooks' => ['{"foo":1}'], - 'webhooks not array' => ['{"webhooks":42}'], + 'empty string' => [''], + 'malformed json' => ['this is not json'], + 'missing webhooks' => ['{"foo":1}'], + 'webhooks not array' => ['{"webhooks":42}'], 'only invalid entries' => ['{"webhooks":["garbage","999.999.999.999"]}'], ]; } diff --git a/tests/UnitPayHandlerTest.php b/tests/Webhook/WebhookVerifierTest.php similarity index 73% rename from tests/UnitPayHandlerTest.php rename to tests/Webhook/WebhookVerifierTest.php index e479643..ba952fe 100644 --- a/tests/UnitPayHandlerTest.php +++ b/tests/Webhook/WebhookVerifierTest.php @@ -1,13 +1,18 @@ getSignature($params, $method); + return (new SignatureBuilder())->build($params, self::SECRET, $method); } /** + * The verifier as a consumer gets it — wired by the facade, with the inbound request + * and sender IP injected instead of read from the superglobals. + * * @param array $request */ - private function handler(array $request, string $ip = self::ALLOWED_IP, ?string $secret = self::SECRET): UnitPay + private function handler(array $request, string $ip = self::ALLOWED_IP, ?string $secret = self::SECRET): WebhookVerifier { - return new UnitPay('unitpay.ru', $secret, null, $request, $ip); + return (new Unitpay('unitpay.ru', $secret, null, $request, $ip))->webhook(); } public function testValidSignatureAndAllowedIpPass(): void @@ -110,10 +118,10 @@ public function testPreauthPartnerMethodIsSupported(): void { $request = $this->validRequest('preauth', ['isPreauth' => '1']); - $unitPay = $this->handler($request); + $webhook = $this->handler($request); - $this->assertTrue($unitPay->checkHandlerRequest()); - $this->assertSame('preauth', $unitPay->getHandlerMethod()); + $this->assertTrue($webhook->checkHandlerRequest()); + $this->assertSame('preauth', $webhook->getHandlerMethod()); } /** @@ -152,66 +160,66 @@ public function testPhpIntMaxKeyInParamsDoesNotBreakVerification(): void public function testSetAllowedIpsOverridesTheDefaultAllowlist(): void { $customIp = '203.0.113.7'; // TEST-NET-3, not in the default list - $unitPay = $this->handler($this->validRequest('pay'), $customIp); - $unitPay->setAllowedIps([$customIp]); + $webhook = $this->handler($this->validRequest('pay'), $customIp); + $webhook->setAllowedIps([$customIp]); - $this->assertTrue($unitPay->checkHandlerRequest()); + $this->assertTrue($webhook->checkHandlerRequest()); } /** 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'); + $webhook = $this->handler($this->validRequest('pay'), '127.0.0.1'); - $this->expectException(\UnitpayIpException::class); - $unitPay->checkHandlerRequest(); + $this->expectException(UnitpayIpException::class); + $webhook->checkHandlerRequest(); } /** setAllowedIps accepts CIDR subnets, not just exact IPs. */ public function testCidrAllowlistMatchesAddressInRange(): void { - $unitPay = $this->handler($this->validRequest('pay'), '203.0.113.55'); - $unitPay->setAllowedIps(['203.0.113.0/24']); + $webhook = $this->handler($this->validRequest('pay'), '203.0.113.55'); + $webhook->setAllowedIps(['203.0.113.0/24']); - $this->assertTrue($unitPay->checkHandlerRequest()); + $this->assertTrue($webhook->checkHandlerRequest()); } public function testCidrAllowlistRejectsAddressOutOfRange(): void { - $unitPay = $this->handler($this->validRequest('pay'), '203.0.114.1'); - $unitPay->setAllowedIps(['203.0.113.0/24']); + $webhook = $this->handler($this->validRequest('pay'), '203.0.114.1'); + $webhook->setAllowedIps(['203.0.113.0/24']); - $this->expectException(\UnitpayIpException::class); - $unitPay->checkHandlerRequest(); + $this->expectException(UnitpayIpException::class); + $webhook->checkHandlerRequest(); } /** CIDR matching works for IPv6 too (binary comparison via inet_pton). */ public function testCidrAllowlistMatchesIpv6InRange(): void { - $unitPay = $this->handler($this->validRequest('pay'), '2001:db8::1'); - $unitPay->setAllowedIps(['2001:db8::/32']); + $webhook = $this->handler($this->validRequest('pay'), '2001:db8::1'); + $webhook->setAllowedIps(['2001:db8::/32']); - $this->assertTrue($unitPay->checkHandlerRequest()); + $this->assertTrue($webhook->checkHandlerRequest()); } /** Before the first successful verification, the verified-data getters return null. */ public function testHandlerGettersAreNullBeforeVerification(): void { - $unitPay = $this->handler($this->validRequest('pay')); + $webhook = $this->handler($this->validRequest('pay')); - $this->assertNull($unitPay->getHandlerMethod()); - $this->assertNull($unitPay->getHandlerParams()); + $this->assertNull($webhook->getHandlerMethod()); + $this->assertNull($webhook->getHandlerParams()); } /** After a successful verification, getHandlerParams() returns exactly the verified webhook params. */ public function testGetHandlerParamsReturnsVerifiedParams(): void { $request = $this->validRequest('pay'); - $unitPay = $this->handler($request); + $webhook = $this->handler($request); - $this->assertTrue($unitPay->checkHandlerRequest()); - $this->assertSame($request['params'], $unitPay->getHandlerParams()); - $this->assertSame('42', $unitPay->getHandlerParams()['account']); + $this->assertTrue($webhook->checkHandlerRequest()); + $this->assertSame($request['params'], $webhook->getHandlerParams()); + $this->assertSame('42', $webhook->getHandlerParams()['account']); } /** A typed exception that still extends the historical SPL type + the marker interface. */ @@ -223,9 +231,9 @@ public function testSignatureFailureThrowsTypedExceptionStillCatchableAsInvalidA try { $this->handler($request)->checkHandlerRequest(); $this->fail('expected a signature exception'); - } catch (\UnitpaySignatureException $e) { + } catch (UnitpaySignatureException $e) { $this->assertInstanceOf(InvalidArgumentException::class, $e); - $this->assertInstanceOf(\UnitpayExceptionInterface::class, $e); + $this->assertInstanceOf(UnitpayExceptionInterface::class, $e); } } } From 70322f8a551150a34536aa76342e4ede646cb5a3 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Sat, 25 Jul 2026 01:11:37 +0300 Subject: [PATCH 5/6] refactor(examples): move to the v3 namespaced API Every example now autoloads from vendor/ and uses the Unitpay\ namespace: api('method', $params) becomes the matching service call, the webhook handler goes through webhook(), and the CashItem dictionaries come from Model\Enum (Nds, PaymentObject, PaymentMethod, Measure, PaymentType). Account-level calls read better under the new signatures: the login is the first argument and only the account secretKey stays in the options array. Note that getBinInfo lives on the payouts service, which accountInfo.php now reflects. Verified past parallel-lint, which only sees syntax: every `use Unitpay\...` import resolves through the real autoloader, and PHPStan level 0 over examples/ is clean, so method names and arity are checked too. --- examples/README.md | 7 ++++- examples/accountInfo.php | 29 ++++++++++++-------- examples/config.php | 5 ++-- examples/initPaymentApi.php | 31 ++++++++++++--------- examples/offsetAdvance.php | 12 ++++++--- examples/paymentForm.php | 7 +++-- examples/paymentInfo.php | 11 ++++---- examples/payout.php | 36 +++++++++++++++---------- examples/receipt.php | 52 ++++++++++++++++++++++-------------- examples/refund.php | 13 ++++----- examples/subscriptions.php | 17 +++++++----- examples/twoStagePayment.php | 11 +++++--- examples/webhook.php | 31 ++++++++++++--------- 13 files changed, 162 insertions(+), 100 deletions(-) diff --git a/examples/README.md b/examples/README.md index 3c677b6..b11dc1d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,10 +4,15 @@ Ready-made Unitpay integration scenarios. The examples read `$_GET`/`$_SERVER` a `header()`, so they must be served over HTTP, not run from the CLI: ```sh +composer install # the examples autoload the SDK from vendor/ php -S localhost:8000 -t examples # then open e.g. http://localhost:8000/paymentInfo.php ``` +Since 3.0 every example loads `vendor/autoload.php` and uses the `Unitpay\` namespace: +`new Unitpay\Unitpay(...)` as the entry point, then the service objects — `payments()`, +`subscriptions()`, `payouts()`, `reference()` — and `webhook()` for inbound verification. + ## Configuration Shared data lives in two include files (not runnable on their own): @@ -31,7 +36,7 @@ export UNITPAY_ACCOUNT_SECRET_KEY=... # account key | --- | --- | | [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()`. | +| [receipt.php](receipt.php) | 54-FZ fiscal receipt: line items via `CashItem` + `setCashItems()`, dictionaries from `Model\Enum`. | | [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`). | diff --git a/examples/accountInfo.php b/examples/accountInfo.php index 0017af4..b71521a 100644 --- a/examples/accountInfo.php +++ b/examples/accountInfo.php @@ -4,9 +4,11 @@ /** * 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. + * BIN info. They authenticate with the ACCOUNT key + login — the login is a method + * argument and the key goes in the options array, overriding the project key from the + * constructor. getMethodsAvailable is project-level and uses the project key (no login). + * Note that BIN lookup lives on the payouts service, next to the SBP bank list. + * The data-changing offsetAdvance is in offsetAdvance.php. * * @link https://help.unitpay.ru/api/balance * @link https://help.unitpay.ru/api/commissions @@ -14,27 +16,32 @@ * @link https://help.unitpay.ru/api/bin_info */ +use Unitpay\Exception\UnitpayExceptionInterface; +use Unitpay\Unitpay; + +require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/config.php'; -require_once __DIR__ . '/../UnitPay.php'; -$unitpay = new UnitPay($domain, $secretKey); +$unitpay = new Unitpay($domain, $secretKey); -$account = ['login' => $login, 'secretKey' => $accountSecretKey]; +$accountKey = ['secretKey' => $accountSecretKey]; try { + $reference = $unitpay->reference(); + // Account balance and the amount available for withdrawal. - var_dump($unitpay->api('getPartner', $account)->result ?? null); + var_dump($reference->getPartner($login, $accountKey)->result ?? null); // Acquiring commissions for the project. - var_dump($unitpay->api('getCommissions', $account + ['projectId' => $projectId])->result ?? null); + var_dump($reference->getCommissions($projectId, $login, $accountKey)->result ?? null); - var_dump($unitpay->api('getCurrencyCourses', $account)->result ?? null); + var_dump($reference->getCurrencyCourses($login, $accountKey)->result ?? null); // BIN — the first 6 digits of the card number. - var_dump($unitpay->api('getBinInfo', $account + ['bin' => 424242])->result ?? null); + var_dump($unitpay->payouts()->getBinInfo($login, 424242, $accountKey)->result ?? null); // Payment methods available on the project: project key, no login. - var_dump($unitpay->api('getMethodsAvailable', ['projectId' => $projectId])->result ?? null); + var_dump($reference->getMethodsAvailable($projectId)->result ?? null); } catch (UnitpayExceptionInterface $exception) { print 'SDK error: ' . $exception->getMessage(); } diff --git a/examples/config.php b/examples/config.php index 4964e62..2ca0416 100644 --- a/examples/config.php +++ b/examples/config.php @@ -12,7 +12,8 @@ $secretKey = getenv('UNITPAY_SECRET_KEY') ?: 'set-me-in-env'; // Account: account-level methods (payouts, getPartner, commissions, currency rates, BIN, -// offsetAdvance) authenticate with the ACCOUNT key + login, not the project key. -// Pass them explicitly to api() to override the project key from the constructor. +// offsetAdvance) authenticate with the ACCOUNT key + login, not the project key. The login +// is the method's first argument; the key goes in the options array as 'secretKey' and +// overrides the project key from the constructor. $login = getenv('UNITPAY_LOGIN') ?: 'partner@example.com'; $accountSecretKey = getenv('UNITPAY_ACCOUNT_SECRET_KEY') ?: 'set-account-key-in-env'; diff --git a/examples/initPaymentApi.php b/examples/initPaymentApi.php index 709b0e8..79f8645 100644 --- a/examples/initPaymentApi.php +++ b/examples/initPaymentApi.php @@ -8,29 +8,36 @@ * @link https://help.unitpay.ru/payments/create-payment */ +use Unitpay\Exception\UnitpayExceptionInterface; +use Unitpay\Model\Enum\PaymentType; +use Unitpay\Unitpay; + +require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/config.php'; require_once __DIR__ . '/order.php'; -require_once __DIR__ . '/../UnitPay.php'; -$unitpay = new UnitPay($domain, $secretKey); +$unitpay = new Unitpay($domain, $secretKey); /** - * Base params: account, desc, sum, currency, projectId, paymentType - * paymentType is a payment method code from the reference (UnitPay::PAYMENT_TYPE_* constants): + * initPayment takes its required params as arguments — account, sum, projectId, + * paymentType — and everything else (desc, currency, ...) in the options array. + * paymentType is a payment method code from the reference (the PaymentType constants): * card, cardInvoice, sbp, sberpay, tinkoffpay, paypal, webmoney. * * @link https://help.unitpay.ru/payments/create-payment * @link https://help.unitpay.ru/book-of-reference/payment-system-codes */ try { - $response = $unitpay->api('initPayment', [ - 'account' => $orderId, - 'desc' => $orderDesc, - 'sum' => $orderSum, - 'paymentType' => UnitPay::PAYMENT_TYPE_CARD, - 'currency' => $orderCurrency, - 'projectId' => $projectId, - ]); + $response = $unitpay->payments()->initPayment( + $orderId, + $orderSum, + $projectId, + PaymentType::CARD, + [ + 'desc' => $orderDesc, + 'currency' => $orderCurrency, + ] + ); // The initPayment response comes in three types: redirect, invoice, response. switch ($response->result->type ?? null) { diff --git a/examples/offsetAdvance.php b/examples/offsetAdvance.php index ec584eb..559e5b6 100644 --- a/examples/offsetAdvance.php +++ b/examples/offsetAdvance.php @@ -8,15 +8,19 @@ * authenticates with the ACCOUNT key + login, passed explicitly. */ +use Unitpay\Exception\UnitpayExceptionInterface; +use Unitpay\Unitpay; + +require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/config.php'; -require_once __DIR__ . '/../UnitPay.php'; -$unitpay = new UnitPay($domain, $secretKey); +$unitpay = new Unitpay($domain, $secretKey); -$account = ['login' => $login, 'secretKey' => $accountSecretKey]; +// The account key overrides the project key from the constructor. +$accountKey = ['secretKey' => $accountSecretKey]; try { - $response = $unitpay->api('offsetAdvance', $account + ['paymentId' => 3403575]); + $response = $unitpay->payments()->offsetAdvance($login, 3403575, $accountKey); var_dump($response->result ?? $response->error ?? $response); } catch (UnitpayExceptionInterface $exception) { print 'SDK error: ' . $exception->getMessage(); diff --git a/examples/paymentForm.php b/examples/paymentForm.php index dfda6fd..6dd14d8 100644 --- a/examples/paymentForm.php +++ b/examples/paymentForm.php @@ -9,11 +9,14 @@ * @link https://help.unitpay.ru/payments/create-payment-easy */ +use Unitpay\Exception\UnitpayExceptionInterface; +use Unitpay\Unitpay; + +require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/config.php'; require_once __DIR__ . '/order.php'; -require_once __DIR__ . '/../UnitPay.php'; -$unitpay = new UnitPay($domain, $secretKey); +$unitpay = new Unitpay($domain, $secretKey); try { $redirectUrl = $unitpay diff --git a/examples/paymentInfo.php b/examples/paymentInfo.php index 960f311..ae12fc4 100644 --- a/examples/paymentInfo.php +++ b/examples/paymentInfo.php @@ -8,15 +8,16 @@ * @link https://help.unitpay.ru/payments/payment-info */ +use Unitpay\Exception\UnitpayExceptionInterface; +use Unitpay\Unitpay; + +require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/config.php'; -require_once __DIR__ . '/../UnitPay.php'; -$unitpay = new UnitPay($domain, $secretKey); +$unitpay = new Unitpay($domain, $secretKey); try { - $response = $unitpay->api('getPayment', [ - 'paymentId' => 3403575 - ]); + $response = $unitpay->payments()->getPayment(3403575); if (isset($response->result)) { var_dump($response->result); diff --git a/examples/payout.php b/examples/payout.php index c6a1cd4..f24872f 100644 --- a/examples/payout.php +++ b/examples/payout.php @@ -4,42 +4,50 @@ /** * Payouts (mass-payment). Account-level API: authenticates with the ACCOUNT key + - * login, not the project key. The account key is passed explicitly in the api() - * parameters and overrides the project key from the constructor. + * login, not the project key. The login is the first argument of every payout method, + * and the account key goes in the options array, overriding the project key from the + * constructor. * * @link https://help.unitpay.ru/api/create_payout * @link https://help.unitpay.ru/api/payout_info * @link https://help.unitpay.ru/api/poluchenie-spravochnika-bankov-uchastnikov-sbp-api */ +use Unitpay\Exception\UnitpayExceptionInterface; +use Unitpay\Model\Enum\PaymentType; +use Unitpay\Unitpay; + +require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/config.php'; -require_once __DIR__ . '/../UnitPay.php'; -$unitpay = new UnitPay($domain, $secretKey); +$unitpay = new Unitpay($domain, $secretKey); -$account = ['login' => $login, 'secretKey' => $accountSecretKey]; +$accountKey = ['secretKey' => $accountSecretKey]; $transactionId = 'payout-1782'; // unique on your side try { + $payouts = $unitpay->payouts(); + // SBP member banks: memberId is required for SBP payouts. - $banks = $unitpay->api('getSbpBankList', $account); + $banks = $payouts->getSbpBankList($login, $accountKey); 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', // from getSbpBankList; SBP only - ]); + $response = $payouts->massPayment( + $login, + $transactionId, + 100, + '79510000071', + PaymentType::SBP, + $accountKey + ['memberId' => '100000000004'] // memberId 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]); + $info = $payouts->massPaymentStatus($login, $transactionId, $accountKey); var_dump($info->result ?? $info->error ?? $info); } elseif (isset($response->error->message)) { print 'Error: ' . $response->error->message; diff --git a/examples/receipt.php b/examples/receipt.php index 75a7cdf..2c18225 100644 --- a/examples/receipt.php +++ b/examples/receipt.php @@ -4,21 +4,30 @@ /** * 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 + * the payment via setCashItems(). The receipt goes out with the next form()/service 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(). * - * The dictionaries of VAT rates (NDS_*), payment objects (PAYMENT_OBJECT_*), payment - * methods (PAYMENT_METHOD_*) and units of measure (MEASURE_*) are CashItem constants. + * The dictionaries of VAT rates, payment objects, payment methods and units of measure + * are const-classes under Unitpay\Model\Enum: Nds, PaymentObject, PaymentMethod, Measure. * * @link https://help.unitpay.ru/payments/create-payment */ +use Unitpay\Exception\UnitpayExceptionInterface; +use Unitpay\Model\CashItem; +use Unitpay\Model\Enum\Measure; +use Unitpay\Model\Enum\Nds; +use Unitpay\Model\Enum\PaymentMethod; +use Unitpay\Model\Enum\PaymentObject; +use Unitpay\Model\Enum\PaymentType; +use Unitpay\Unitpay; + +require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/config.php'; require_once __DIR__ . '/order.php'; -require_once __DIR__ . '/../UnitPay.php'; -$unitpay = new UnitPay($domain, $secretKey); +$unitpay = new Unitpay($domain, $secretKey); // 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. @@ -26,21 +35,21 @@ $itemName, 1, 900, - CashItem::NDS_20, - CashItem::PAYMENT_OBJECT_COMMODITY, - CashItem::PAYMENT_METHOD_PAYMENT_FULL + Nds::VAT20, + PaymentObject::COMMODITY, + PaymentMethod::PAYMENT_FULL ); // Optional fields are serialized only when set (e.g. unit of measure): -$item->setMeasure(CashItem::MEASURE_ITEM); +$item->setMeasure(Measure::ITEM); // Line item 2: a service (delivery), no VAT. $delivery = new CashItem( 'Доставка', 1, 150, - CashItem::NDS_NONE, - CashItem::PAYMENT_OBJECT_SERVICE, - CashItem::PAYMENT_METHOD_PAYMENT_FULL + Nds::NONE, + PaymentObject::SERVICE, + PaymentMethod::PAYMENT_FULL ); try { @@ -48,14 +57,17 @@ $response = $unitpay ->setCustomerEmail('customer@example.com') ->setCashItems([$item, $delivery]) - ->api('initPayment', [ - 'account' => $orderId, - 'desc' => $orderDesc, - 'sum' => 1050, - 'paymentType' => UnitPay::PAYMENT_TYPE_CARD, - 'currency' => $orderCurrency, - 'projectId' => $projectId, - ]); + ->payments() + ->initPayment( + $orderId, + 1050, + $projectId, + PaymentType::CARD, + [ + 'desc' => $orderDesc, + 'currency' => $orderCurrency, + ] + ); // The same receipt can also be attached to the payment form: // $url = $unitpay->setCashItems([$item, $delivery]) diff --git a/examples/refund.php b/examples/refund.php index a9803f9..733174d 100644 --- a/examples/refund.php +++ b/examples/refund.php @@ -8,16 +8,17 @@ * @link https://help.unitpay.ru/api/payment-refund */ +use Unitpay\Exception\UnitpayExceptionInterface; +use Unitpay\Unitpay; + +require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/config.php'; -require_once __DIR__ . '/../UnitPay.php'; -$unitpay = new UnitPay($domain, $secretKey); +$unitpay = new Unitpay($domain, $secretKey); try { - $response = $unitpay->api('refundPayment', [ - 'paymentId' => 3403575, - // 'sum' => 100, // optional: partial refund; omit for a full refund - ]); + // Omit the options for a full refund; pass ['sum' => 100] to refund part of it. + $response = $unitpay->payments()->refundPayment(3403575); if (isset($response->result->message)) { print $response->result->message; diff --git a/examples/subscriptions.php b/examples/subscriptions.php index 0f578df..fed8f3d 100644 --- a/examples/subscriptions.php +++ b/examples/subscriptions.php @@ -10,23 +10,28 @@ * @link https://help.unitpay.ru/api/close-subscription */ +use Unitpay\Exception\UnitpayExceptionInterface; +use Unitpay\Unitpay; + +require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/config.php'; -require_once __DIR__ . '/../UnitPay.php'; -$unitpay = new UnitPay($domain, $secretKey); +$unitpay = new Unitpay($domain, $secretKey); $subscriptionId = 12345; try { - // The project's active subscriptions (add 'all' => 1 to include all statuses). - $list = $unitpay->api('listSubscriptions', ['projectId' => $projectId]); + $subscriptions = $unitpay->subscriptions(); + + // The project's active subscriptions (pass ['all' => 1] to include all statuses). + $list = $subscriptions->listSubscriptions($projectId); var_dump($list->result ?? $list->error ?? $list); - $info = $unitpay->api('getSubscription', ['subscriptionId' => $subscriptionId]); + $info = $subscriptions->getSubscription($subscriptionId); var_dump($info->result ?? $info->error ?? $info); // Close it (stops charges, detaches the card — irreversible). - $closed = $unitpay->api('closeSubscription', ['subscriptionId' => $subscriptionId]); + $closed = $subscriptions->closeSubscription($subscriptionId); if (isset($closed->result->message)) { print $closed->result->message; } elseif (isset($closed->error->message)) { diff --git a/examples/twoStagePayment.php b/examples/twoStagePayment.php index db211c9..cd28cc2 100644 --- a/examples/twoStagePayment.php +++ b/examples/twoStagePayment.php @@ -11,19 +11,22 @@ * @link https://help.unitpay.ru/api/cancel-payment */ +use Unitpay\Exception\UnitpayExceptionInterface; +use Unitpay\Unitpay; + +require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/config.php'; -require_once __DIR__ . '/../UnitPay.php'; -$unitpay = new UnitPay($domain, $secretKey); +$unitpay = new Unitpay($domain, $secretKey); $paymentId = 3403575; try { // Capture the held funds. - $response = $unitpay->api('confirmPayment', ['paymentId' => $paymentId]); + $response = $unitpay->payments()->confirmPayment($paymentId); // ...or release without capturing. - // $response = $unitpay->api('cancelPayment', ['paymentId' => $paymentId]); + // $response = $unitpay->payments()->cancelPayment($paymentId); if (isset($response->message)) { print $response->message; diff --git a/examples/webhook.php b/examples/webhook.php index 549698f..66918f0 100644 --- a/examples/webhook.php +++ b/examples/webhook.php @@ -6,20 +6,25 @@ * @link https://help.unitpay.ru/payments/payment-handler */ +use Unitpay\Unitpay; + +require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/config.php'; require_once __DIR__ . '/order.php'; -require_once __DIR__ . '/../UnitPay.php'; -$unitpay = new UnitPay($domain, $secretKey); +$unitpay = new Unitpay($domain, $secretKey); + +// Inbound verification and the allowlist live on the webhook verifier. +$webhook = $unitpay->webhook(); // The handler response is JSON (getSuccessHandlerResponse/getErrorHandlerResponse). header('Content-Type: application/json; charset=UTF-8'); // Keep the webhook IP allowlist current WITHOUT a network request on every call: // refresh it on a schedule (e.g. a daily cron) and cache the result — -// $ips = (new UnitPay($domain, $secretKey))->refreshAllowedIps()->getAllowedIps(); +// $ips = (new Unitpay($domain, $secretKey))->webhook()->refreshAllowedIps()->getAllowedIps(); // then pass the cached list here, plus your own IPs (proxy/relay): -// $unitpay->setAllowedIps($cachedIps)->addAllowedIps(['1.2.3.4']); +// $webhook->setAllowedIps($cachedIps)->addAllowedIps(['1.2.3.4']); // 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). @@ -27,16 +32,16 @@ // 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']); + $webhook->addAllowedIps(['127.0.0.1']); } try { // Verify the request (sender IP, signature, supported method). - $unitpay->checkHandlerRequest(); + $webhook->checkHandlerRequest(); // Read the verified request from the SDK (honors the overridden request, not $_GET). - $method = $unitpay->getHandlerMethod(); - $params = $unitpay->getHandlerParams(); + $method = $webhook->getHandlerMethod(); + $params = $webhook->getHandlerParams(); // Very important: reconcile the webhook against your order data before completing the order. if ( @@ -51,21 +56,21 @@ switch ($method) { case 'check': // 'check' — verify the order can be paid (server status, order in the DB, ...). - print $unitpay->getSuccessHandlerResponse('Check Success. Ready to pay.'); + print $webhook->getSuccessHandlerResponse('Check Success. Ready to pay.'); break; case 'pay': // 'pay' — money received; complete the order here. - print $unitpay->getSuccessHandlerResponse('Pay Success'); + print $webhook->getSuccessHandlerResponse('Pay Success'); break; case 'preauth': // '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.'); + print $webhook->getSuccessHandlerResponse('Preauth received. Funds held, awaiting capture.'); break; case 'error': // 'error' — an error occurred; log it. - print $unitpay->getSuccessHandlerResponse('Error logged'); + print $webhook->getSuccessHandlerResponse('Error logged'); break; default: // Unknown method: do not leave an empty response (Unitpay would treat it as a @@ -74,5 +79,5 @@ } } catch (Exception $exception) { // Any error (wrong signature, disallowed IP, order mismatch) returns an error to Unitpay. - print $unitpay->getErrorHandlerResponse($exception->getMessage()); + print $webhook->getErrorHandlerResponse($exception->getMessage()); } From 2f97ad6820ccb91e8d9a116cb047556135bd4db0 Mon Sep 17 00:00:00 2001 From: Artem Dragunov Date: Sat, 25 Jul 2026 01:28:45 +0300 Subject: [PATCH 6/6] docs: rewrite for the v3 namespaced API and add a migration guide README and docs/ now describe the service API: Unitpay\Unitpay as the entry point, payments()/subscriptions()/payouts()/reference() for calls, webhook() for inbound verification, and Model\Enum for the fiscal dictionaries. The API reference is grouped by service with full method signatures instead of one flat api() table. Adds docs/migration-v3.md: every class, method and constant rename from 2.x, with the old names taken from the pre-removal UnitPay.php rather than guessed. It opens with what did not change - signature algorithm, wire format, webhook semantics, exception hierarchy - since that is most of the surface. The v3.0.0 changelog entry lists the breaking changes and records two decisions explicitly: no compatibility shim ships, and the payment objects v2.1.0 announced for "removal in 3.0" (excise, gambling_bet, gambling_prize, lottery_prize, composite) are kept and deferred to 4.0 instead of lapsing silently. --- CHANGELOG.md | 17 +++ README.md | 41 +++--- docs/api-methods.md | 99 +++++++++----- docs/getting-started.md | 94 +++++++++----- docs/migration-v3.md | 279 ++++++++++++++++++++++++++++++++++++++++ docs/receipts.md | 38 ++++-- docs/telemetry.md | 13 +- docs/webhooks.md | 52 ++++---- 8 files changed, 514 insertions(+), 119 deletions(-) create mode 100644 docs/migration-v3.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8af58ec..85ddf53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +### v3.0.0 + +**Breaking release.** The SDK is no longer a single file in the global namespace: it is now a PSR-4 package (`Unitpay\` → `src/`) split into layers — Http, Api services, Signature, Webhook, Model/Enum, Exception — behind a thin `Unitpay\Unitpay` facade. Nothing changed on the wire; only the PHP surface you call. Step-by-step renames are in [docs/migration-v3.md](docs/migration-v3.md). + +* **No compatibility shim is provided.** A `class_alias` would have restored the old class names without the old methods and constants, so the code would compile and then fail at runtime; a full compat layer would have re-created the god class this release removes. Migration is a mechanical one-time edit instead +* Installation is Composer-only: `UnitPay.php` is gone, so the package needs the PSR-4 autoloader and can no longer be `require`d as a single file +* Classes moved: `UnitPay` → `Unitpay\Unitpay` (note the casing), `CashItem` → `Unitpay\Model\CashItem`, `UnitpayIpAllowlist` → `Unitpay\Webhook\IpAllowlist`, and every exception into `Unitpay\Exception\`. Exception class names and their `InvalidArgumentException` / `UnexpectedValueException` parents are unchanged, so existing catch blocks keep working once the imports are updated +* `api('method', [...])` was replaced by four service objects reached from the facade: `payments()`, `subscriptions()`, `payouts()`, `reference()`. Each method takes its required parameters as arguments and the rest in a trailing options array. `getBinInfo` sits on `payouts()`, next to `getSbpBankList`, rather than on `reference()` +* Required parameters are now enforced by the method signatures instead of the runtime `REQUIRED_UNITPAY_METHODS_PARAMS` dictionary — a missing one is an `ArgumentCountError` caught by static analysis, not a `UnitpayValidationException` at request time. `UnitpayUnsupportedMethodException` is no longer thrown for outbound calls (there is no method-name string to mistype) and now applies only to inbound webhooks +* The fiscal dictionaries left `CashItem` for const-classes in `Unitpay\Model\Enum`, dropping the redundant prefix: `CashItem::NDS_20` → `Nds::VAT20`, `CashItem::PAYMENT_OBJECT_COMMODITY` → `PaymentObject::COMMODITY`, `CashItem::PAYMENT_METHOD_PAYMENT_FULL` → `PaymentMethod::PAYMENT_FULL`, `CashItem::MEASURE_KG` → `Measure::KG`, `UnitPay::PAYMENT_TYPE_CARD` → `PaymentType::CARD`. One irregular case: `CashItem::NDS_NONE` → `Nds::NONE`, without a `VAT` prefix +* Inbound webhook verification and the IP allowlist moved to `Unitpay\Webhook\WebhookVerifier`, reached via `$unitpay->webhook()`: `checkHandlerRequest()`, `getHandlerMethod()`, `getHandlerParams()`, `getSuccessHandlerResponse()`, `getErrorHandlerResponse()`, `setAllowedIps()`, `addAllowedIps()`, `getAllowedIps()`, `refreshAllowedIps()`. `getIp()` and `isAllowedIp()` remain `protected` — subclass the verifier instead of the facade to run behind a proxy +* `getSignature()` is no longer public on the facade; direct signing lives in `Unitpay\Signature\SignatureBuilder::build($params, $secretKey, $method)` +* The transport constructor argument changed from a `callable` to `Unitpay\Http\TransportInterface`. Omitting it still yields the default `CurlTransport` with unchanged behavior (cURL with a `file_get_contents()` fallback, TLS verified). One transport instance now serves both the API calls and the webhook IP-feed fetch, so a custom HTTP stack is injected in a single place +* Deprecated payment objects rejected by the public API — `excise`, `gambling_bet`, `gambling_prize`, `lottery_prize`, `composite` — were announced in v2.1.0 for removal in 3.0, but are **kept in this release** and slated for 4.0 instead. Removing them alongside the namespace break would have added migration friction for no functional gain; they remain available as `PaymentObject::EXCISE` and friends, and should not be used in new code +* Unchanged and verified as such: the signature algorithm and the `{up}` delimiter, the mandatory `PHP_INT_MAX` guard against signature forgery, constant-time comparison via `hash_equals`, the `REMOTE_ADDR`-only IP source, TLS verification on the IP-feed fetch, the fail-safe allowlist refresh, the flat request format, response shapes, PHP >= 7.4 support and the zero-dependency policy +* Test suite ported to the new namespaces and grown from 121 to 150 tests: it now mirrors `src/`, uses a `TransportInterface` double instead of a callable, and covers the new seams (service getters and their memoization, shared transport injection) + ### v2.1.0 * 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` diff --git a/README.md b/README.md index 88a0b66..57b9261 100644 --- a/README.md +++ b/README.md @@ -9,18 +9,22 @@ > PHP SDK for the [Unitpay.ru](https://unitpay.ru) payment REST API. 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**. +the API server-to-server, attach 54-FZ fiscal receipts, and verify inbound webhooks. +Everything hangs off one entry point, `Unitpay\Unitpay`, which hands out service objects. Official Unitpay documentation: [help.unitpay.ru](https://help.unitpay.ru) +> **Upgrading from 2.x?** 3.0 moves every class into the `Unitpay\` namespace and replaces +> `api('method', [...])` with typed service methods. There is no compatibility shim — +> see the [v3 Migration Guide](docs/migration-v3.md). + ## Requirements * PHP >= 7.4 * ext-json -No runtime dependencies. The SDK is a single file — [`UnitPay.php`](UnitPay.php) — -exposing two classes in the **global namespace**: `UnitPay` and `CashItem`. +No runtime dependencies. `ext-curl` is optional: the default transport uses it when +present and falls back to `file_get_contents()` otherwise. ## Installation @@ -28,14 +32,13 @@ exposing two classes in the **global namespace**: `UnitPay` and `CashItem`. composer require unitpay/php-sdk ``` -Then load the Composer autoloader — its classmap registers both `UnitPay` and `CashItem`: +Then load the Composer autoloader — the package is PSR-4 (`Unitpay\` → `src/`): ```php require __DIR__ . '/vendor/autoload.php'; ``` -See [Getting Started](docs/getting-started.md) for the `dev-master` and direct-download -options. +See [Getting Started](docs/getting-started.md) for the `dev-master` option. ## Quick Start @@ -43,7 +46,10 @@ options. setBackUrl('https://domain.com') @@ -55,20 +61,24 @@ $redirectUrl = $unitpay->form($publicId, 900, $orderId, 'Payment for item', 'RUB header('Location: ' . $redirectUrl); ``` -Prefer a server-to-server call? Use `$unitpay->api('initPayment', [...])` — see +Prefer a server-to-server call? Use `$unitpay->payments()->initPayment(...)` — see [Getting Started](docs/getting-started.md). ## Key Features -* **Hosted form or API** — `form()` builds a signed redirect URL; `api('initPayment', ...)` - does a server-to-server call. +* **Hosted form or API** — `form()` builds a signed redirect URL; + `payments()->initPayment(...)` does a server-to-server call. +* **Service objects** — `payments()`, `subscriptions()`, `payouts()`, `reference()`, each + with typed methods, so required parameters are enforced at the call site. * **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. +* **Secure webhooks** — `webhook()->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. +* **Swappable transport** — inject any `Unitpay\Http\TransportInterface` to plug in your + own HTTP stack or to test without the network. * **Typed exceptions** — all implement `UnitpayExceptionInterface`. -* **Zero dependencies** — one file, `ext-json` only (`ext-curl` optional). +* **Zero dependencies** — `ext-json` only (`ext-curl` optional). ## Documentation @@ -76,9 +86,10 @@ Prefer a server-to-server call? Use `$unitpay->api('initPayment', [...])` — se |-------|-------------| | [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 | +| [API Methods](docs/api-methods.md) | Full service reference and account-level calls | | [Webhooks](docs/webhooks.md) | Payment handler + keeping the IP allowlist fresh | | [Telemetry](docs/telemetry.md) | Anonymous SDK version fingerprint | +| [v3 Migration Guide](docs/migration-v3.md) | Upgrading from 2.x to 3.0 | Runnable samples for every method group live in [`examples/`](examples). diff --git a/docs/api-methods.md b/docs/api-methods.md index f00f515..bf3f0be 100644 --- a/docs/api-methods.md +++ b/docs/api-methods.md @@ -2,56 +2,78 @@ [← 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). +Server-to-server calls go through the service objects the facade hands out. Each method +takes its required parameters as arguments and everything else in a trailing options +array. `secretKey` is added automatically from the constructor. Full parameters and +response formats are in the [official API documentation](https://help.unitpay.ru). -| Method | Required params | Purpose | +Every method returns the decoded JSON envelope as `object`, and throws a +`UnitpayTransportException` when no usable response comes back. + +## `payments()` + +| Method | Signature | Purpose | +| --- | --- | --- | +| `initPayment` | `(string $account, $sum, $projectId, string $paymentType, array $options = [])` | Create a payment | +| `getPayment` | `($paymentId, array $options = [])` | Payment info | +| `refundPayment` | `($paymentId, array $options = [])` — `$options['sum']` for partial | Refund a payment | +| `confirmPayment` | `($paymentId, array $options = [])` | Confirm (capture) a two-stage payment | +| `cancelPayment` | `($paymentId, array $options = [])` | Cancel (release) a two-stage payment | +| `offsetAdvance` | `(string $login, $paymentId, array $options = [])` | Advance-offset fiscal receipt (account-level) | + +## `subscriptions()` + +| Method | Signature | Purpose | +| --- | --- | --- | +| `listSubscriptions` | `($projectId, array $options = [])` — `$options['all']` widens the listing | List project subscriptions | +| `getSubscription` | `($subscriptionId, array $options = [])` | Subscription info | +| `closeSubscription` | `($subscriptionId, array $options = [])` | Close a subscription | + +## `payouts()` + +All payout methods are account-level: the account email is the `$login` argument. + +| Method | Signature | Purpose | +| --- | --- | --- | +| `massPayment` | `(string $login, $transactionId, $sum, string $purse, string $paymentType, array $options = [])` | Create a payout | +| `massPaymentStatus` | `(string $login, $transactionId, array $options = [])` | Payout status | +| `massPaymentAvailableAmount` | `(string $login, $sum, string $purse, string $paymentType, array $options = [])` | Balance available for payout | +| `massPaymentCommissions` | `(string $login, array $options = [])` | Payout commissions | +| `getSbpBankList` | `(string $login, array $options = [])` | SBP participant banks | +| `getBinInfo` | `(string $login, $bin, array $options = [])` | Card info by BIN | + +## `reference()` + +| Method | Signature | 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 | +| `getMethodsAvailable` | `($projectId, array $options = [])` | Payment methods available on the project | +| `getCommissions` | `($projectId, string $login, array $options = [])` | Acquiring commissions for a project | +| `getCurrencyCourses` | `(string $login, array $options = [])` | Currency conversion rates | +| `getPartner` | `(string $login, array $options = [])` | Account balance | ## 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: +For the account-level methods — everything on `payouts()`, plus `getCommissions`, +`getCurrencyCourses`, `getPartner` and `offsetAdvance` — the `secretKey` is the **account** +key (profile), not the project key, and `login` is the account email. Pass the account key +in the options array; it overrides the constructor (project) key: ```php -$response = $unitpay->api('getPartner', [ - 'login' => 'partner@example.com', +$response = $unitpay->reference()->getPartner('partner@example.com', [ 'secretKey' => $accountKey, // overrides the project key from the constructor ]); ``` For SBP payouts pass `memberId` obtained from `getSbpBankList`. +Note that `getBinInfo` lives on `payouts()` rather than `reference()` — it sits next to +the SBP bank list, which is the other payout-routing lookup. + ## Example — refund a payment ```php -$response = $unitpay->api('refundPayment', [ - 'paymentId' => 123456, - // 'sum' => 100, // optional: partial refund -]); +$response = $unitpay->payments()->refundPayment(123456); +// Partial refund: ->refundPayment(123456, ['sum' => 100]) if (isset($response->result->message)) { print $response->result->message; @@ -63,7 +85,16 @@ if (isset($response->result->message)) { Note: `confirmPayment` and `cancelPayment` return a top-level `message` (`$response->message`), not `$response->result->message`. +## Fluent parameters + +Parameters accumulated by `setCashItems()`, `setCustomerEmail()`, `setCustomerPhone()` and +`setBackUrl()` are merged into the next call — `form()` or any service method — and cleared +afterwards, so a reused instance never carries one order's receipt into the next. Explicit +options take precedence over accumulated ones. The clearing happens even when the request +fails, so a retry must re-apply the setters. + ## See Also * [Getting Started](getting-started.md) — the `initPayment` flow in full * [Webhooks](webhooks.md) — handle the callbacks a payment triggers +* [v3 Migration Guide](migration-v3.md) — the old `api('method', [...])` mapping diff --git a/docs/getting-started.md b/docs/getting-started.md index a9a4b20..56e0bfa 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -7,19 +7,21 @@ * 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. +No runtime dependencies. The SDK is a PSR-4 package: namespace `Unitpay\` maps to `src/`, +with `Unitpay\Unitpay` as the single entry point. `ext-curl` is optional — the default +transport uses it when present and falls back to `file_get_contents()` otherwise. + +Upgrading from 2.x? Start with the [v3 Migration Guide](migration-v3.md). ## Installation -### Composer (recommended) +### Composer ```sh composer require unitpay/php-sdk ``` -Then load the Composer autoloader — its classmap registers both `UnitPay` and `CashItem`: +Then load the Composer autoloader: ```php require __DIR__ . '/vendor/autoload.php'; @@ -31,28 +33,45 @@ To follow the default branch (latest changes) instead of the newest tag: composer require unitpay/php-sdk:dev-master ``` -### Direct download +Composer is the only supported installation path since 3.0: the SDK is no longer a single +file you can `require` directly, so it needs a PSR-4 autoloader. + +## The entry point + +`Unitpay\Unitpay` is a thin facade. It builds the payment-form URL itself and hands out +service objects for everything else: + +| Accessor | What it covers | +| --- | --- | +| `payments()` | `initPayment`, `getPayment`, `refundPayment`, `confirmPayment`, `cancelPayment`, `offsetAdvance` | +| `subscriptions()` | `listSubscriptions`, `getSubscription`, `closeSubscription` | +| `payouts()` | `massPayment*`, `getSbpBankList`, `getBinInfo` | +| `reference()` | `getMethodsAvailable`, `getCommissions`, `getCurrencyCourses`, `getPartner` | +| `webhook()` | Inbound verification and the IP allowlist | -Download the [latest version](https://github.com/unitpay/php-sdk/archive/master.zip), -unzip it and `require` the single file directly: +The constructor takes the domain, the project secret key, and three optional seams — +a `TransportInterface`, the inbound request array, and the client IP: ```php -require '/path/to/UnitPay.php'; +new Unitpay(string $domain, ?string $secretKey = null, ?TransportInterface $transport = null, ?array $request = null, ?string $clientIp = null) ``` ## 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', ...)`. +apply to both `form()` and the service calls. ```php setBackUrl('https://domain.com') @@ -87,10 +106,11 @@ 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 +`payments()->initPayment(...)` does a server-to-server call. `secretKey` is added +automatically from the constructor. The four required parameters are method arguments — +account, sum, projectId, paymentType — and anything else goes in the options array. +`paymentType` is a payment-method code from the reference (the `PaymentType` constants): +`card`, `cardInvoice`, `sbp`, `sberpay`, `tinkoffpay`, `paypal`, `webmoney` — see the [payment-system codes](https://help.unitpay.ru/book-of-reference/payment-system-codes). ```php @@ -106,10 +126,13 @@ header('Content-Type: text/html; charset=UTF-8'); require __DIR__ . '/vendor/autoload.php'; +use Unitpay\Model\Enum\PaymentType; +use Unitpay\Unitpay; + // Project Data -$domain = 'unitpay.ru';// Your working domain: unitpay.ru or address provided by unitpay support service +$domain = 'unitpay.ru'; // Your working domain: unitpay.ru or the address Unitpay support gave you $projectId = 1; -$secretKey = '9e977d0c0e1bc8f5cc9775a8cc8744f1';// Project secret key +$secretKey = '9e977d0c0e1bc8f5cc9775a8cc8744f1'; // Project secret key // My Order Data $orderId = 'a183f94-1434-1e44'; @@ -117,16 +140,18 @@ $orderSum = 900; $orderDesc = 'Payment for item "Iphone 6 Skin Cover"'; $orderCurrency = 'RUB'; -$unitpay = new UnitPay($domain, $secretKey); +$unitpay = new Unitpay($domain, $secretKey); -$response = $unitpay->api('initPayment', [ - 'account' => $orderId, - 'desc' => $orderDesc, - 'sum' => $orderSum, - 'paymentType' => UnitPay::PAYMENT_TYPE_CARD, - 'currency' => $orderCurrency, - 'projectId' => $projectId -]); +$response = $unitpay->payments()->initPayment( + $orderId, + $orderSum, + $projectId, + PaymentType::CARD, + [ + 'desc' => $orderDesc, + 'currency' => $orderCurrency, + ] +); // If need user redirect on Payment Gate if (isset($response->result->type) @@ -157,6 +182,16 @@ if (isset($response->result->type) } ``` +## Testing without the network + +The transport sits behind `Unitpay\Http\TransportInterface`, so you can inject a fake and +exercise the SDK without HTTP. The webhook verifier likewise accepts the inbound request +array and the client IP, instead of reading `$_GET` / `$_SERVER['REMOTE_ADDR']`: + +```php +$unitpay = new Unitpay('unitpay.ru', $secretKey, $fakeTransport, $requestArray, '31.186.100.49'); +``` + ## Runnable examples The [`examples/`](../examples) folder has runnable samples for every method group (serve @@ -176,5 +211,6 @@ them over HTTP, e.g. `php -S localhost:8000 -t examples`): ## See Also * [Fiscal Receipts](receipts.md) — attach 54-FZ receipt line items with `CashItem` -* [API Methods](api-methods.md) — the full `api()` method reference +* [API Methods](api-methods.md) — the full service reference * [Webhooks](webhooks.md) — verify inbound payment callbacks +* [v3 Migration Guide](migration-v3.md) — upgrading from 2.x diff --git a/docs/migration-v3.md b/docs/migration-v3.md new file mode 100644 index 0000000..5351618 --- /dev/null +++ b/docs/migration-v3.md @@ -0,0 +1,279 @@ +# v3 Migration Guide (2.x → 3.0) + +[Back to README](../README.md) · [Getting Started →](getting-started.md) + +3.0 replaces the single-file, global-namespace SDK with a PSR-4 package under `src/`, +organized into layers behind a thin `Unitpay\Unitpay` facade. Nothing about the wire +protocol changed — only the PHP surface you call. + +**There is no compatibility shim.** A `class_alias` would have given you the old class +names without the old methods or constants, which is worse than a clean break: the code +would compile and then fail at runtime. Migrating is a mechanical, one-time edit, and this +guide lists every rename. + +## What did NOT change + +Before you start, the reassuring part — none of this needs touching: + +* The signature algorithm, the `{up}` delimiter, and the `PHP_INT_MAX` guard. +* The request format on the wire (flat `method=X&...` query params). +* Webhook verification semantics: signature **and** `REMOTE_ADDR` allowlist, `hash_equals`, + the fail-safe IP-feed refresh. +* Response shapes — you still get the decoded JSON envelope as `object`. +* Exception class names and their SPL parents, so existing `catch` blocks keep working + once the `use` statements are updated. +* PHP >= 7.4 support and the zero-dependency policy. + +## 1. Installation + +The SDK is no longer a file you can `require` directly — it needs the PSR-4 autoloader: + +```diff +-require '/path/to/UnitPay.php'; ++require __DIR__ . '/vendor/autoload.php'; +``` + +If you installed by downloading the repository, switch to Composer: + +```sh +composer require unitpay/php-sdk:^3.0 +``` + +## 2. Class names + +| 2.x (global namespace) | 3.0 | +| --- | --- | +| `UnitPay` | `Unitpay\Unitpay` | +| `CashItem` | `Unitpay\Model\CashItem` | +| `UnitpayIpAllowlist` | `Unitpay\Webhook\IpAllowlist` | +| `UnitpayExceptionInterface` | `Unitpay\Exception\UnitpayExceptionInterface` | +| `UnitpaySignatureException` | `Unitpay\Exception\UnitpaySignatureException` | +| `UnitpayIpException` | `Unitpay\Exception\UnitpayIpException` | +| `UnitpayTransportException` | `Unitpay\Exception\UnitpayTransportException` | +| `UnitpayUnsupportedMethodException` | `Unitpay\Exception\UnitpayUnsupportedMethodException` | +| `UnitpayValidationException` | `Unitpay\Exception\UnitpayValidationException` | + +Watch the casing: the class is `Unitpay`, not `UnitPay`. + +```diff ++use Unitpay\Unitpay; ++ +-$unitpay = new UnitPay('unitpay.ru', $secretKey); ++$unitpay = new Unitpay('unitpay.ru', $secretKey); +``` + +`form()` and the fluent setters (`setBackUrl`, `setCustomerEmail`, `setCustomerPhone`, +`setCashItems`) stayed on the facade with identical signatures — those call sites need no +change beyond the class name. + +## 3. `api()` → service methods + +`api('', [...])` is gone. Each API method is now a typed method on one of four +service objects, with its required parameters as arguments and everything else in a +trailing options array. + +### Payments — `$unitpay->payments()` + +| 2.x | 3.0 | +| --- | --- | +| `api('initPayment', ['account' => $a, 'sum' => $s, 'projectId' => $p, 'paymentType' => $t])` | `initPayment($a, $s, $p, $t)` | +| `api('getPayment', ['paymentId' => $id])` | `getPayment($id)` | +| `api('refundPayment', ['paymentId' => $id])` | `refundPayment($id)` | +| `api('refundPayment', ['paymentId' => $id, 'sum' => $s])` | `refundPayment($id, ['sum' => $s])` | +| `api('confirmPayment', ['paymentId' => $id])` | `confirmPayment($id)` | +| `api('cancelPayment', ['paymentId' => $id])` | `cancelPayment($id)` | +| `api('offsetAdvance', ['login' => $l, 'paymentId' => $id])` | `offsetAdvance($l, $id)` | + +Extra parameters — `desc`, `currency`, `backUrl`, ... — move into the options array: + +```diff +-$response = $unitpay->api('initPayment', [ +- 'account' => $orderId, +- 'desc' => $orderDesc, +- 'sum' => $orderSum, +- 'paymentType' => UnitPay::PAYMENT_TYPE_CARD, +- 'currency' => $orderCurrency, +- 'projectId' => $projectId, +-]); ++$response = $unitpay->payments()->initPayment( ++ $orderId, ++ $orderSum, ++ $projectId, ++ PaymentType::CARD, ++ [ ++ 'desc' => $orderDesc, ++ 'currency' => $orderCurrency, ++ ] ++); +``` + +### Subscriptions — `$unitpay->subscriptions()` + +| 2.x | 3.0 | +| --- | --- | +| `api('listSubscriptions', ['projectId' => $p])` | `listSubscriptions($p)` | +| `api('listSubscriptions', ['projectId' => $p, 'all' => 1])` | `listSubscriptions($p, ['all' => 1])` | +| `api('getSubscription', ['subscriptionId' => $s])` | `getSubscription($s)` | +| `api('closeSubscription', ['subscriptionId' => $s])` | `closeSubscription($s)` | + +### Payouts — `$unitpay->payouts()` + +| 2.x | 3.0 | +| --- | --- | +| `api('massPayment', ['login' => $l, 'transactionId' => $tx, 'sum' => $s, 'purse' => $pu, 'paymentType' => $t])` | `massPayment($l, $tx, $s, $pu, $t)` | +| `api('massPaymentStatus', ['login' => $l, 'transactionId' => $tx])` | `massPaymentStatus($l, $tx)` | +| `api('massPaymentAvailableAmount', ['login' => $l, 'sum' => $s, 'purse' => $pu, 'paymentType' => $t])` | `massPaymentAvailableAmount($l, $s, $pu, $t)` | +| `api('massPaymentCommissions', ['login' => $l])` | `massPaymentCommissions($l)` | +| `api('getSbpBankList', ['login' => $l])` | `getSbpBankList($l)` | +| `api('getBinInfo', ['login' => $l, 'bin' => $b])` | `getBinInfo($l, $b)` | + +### Reference — `$unitpay->reference()` + +| 2.x | 3.0 | +| --- | --- | +| `api('getMethodsAvailable', ['projectId' => $p])` | `getMethodsAvailable($p)` | +| `api('getCommissions', ['projectId' => $p, 'login' => $l])` | `getCommissions($p, $l)` | +| `api('getCurrencyCourses', ['login' => $l])` | `getCurrencyCourses($l)` | +| `api('getPartner', ['login' => $l])` | `getPartner($l)` | + +Note that **`getBinInfo` moved to `payouts()`**, not `reference()` — it sits next to the +SBP bank list, the other payout-routing lookup. + +### Account-level calls + +The account key still overrides the project key; it just travels in the options array now, +while the login became a proper argument: + +```diff +-$response = $unitpay->api('getPartner', [ +- 'login' => 'partner@example.com', +- 'secretKey' => $accountKey, +-]); ++$response = $unitpay->reference()->getPartner('partner@example.com', [ ++ 'secretKey' => $accountKey, ++]); +``` + +## 4. Constants → `Model\Enum` const-classes + +The dictionaries left `CashItem` and became const-classes under `Unitpay\Model\Enum`. The +prefix drops out, since the class name now carries it: + +| 2.x | 3.0 | Rule | +| --- | --- | --- | +| `CashItem::NDS_NONE` | `Nds::NONE` | irregular — no `VAT` prefix | +| `CashItem::NDS_0`, `NDS_20`, `NDS_122` | `Nds::VAT0`, `Nds::VAT20`, `Nds::VAT122` | `NDS_` → `VAT` | +| `CashItem::PAYMENT_OBJECT_COMMODITY` | `PaymentObject::COMMODITY` | drop `PAYMENT_OBJECT_` | +| `CashItem::PAYMENT_METHOD_PAYMENT_FULL` | `PaymentMethod::PAYMENT_FULL` | drop `PAYMENT_METHOD_` | +| `CashItem::MEASURE_KG` | `Measure::KG` | drop `MEASURE_` | +| `UnitPay::PAYMENT_TYPE_CARD` | `PaymentType::CARD` | drop `PAYMENT_TYPE_` | +| `UnitPay::VERSION`, `UnitPay::API_VERSION` | `Unitpay::VERSION`, `Unitpay::API_VERSION` | stayed on the facade | + +`Nds::NONE` is the one case the rule does not cover — `NDS_NONE` means "no VAT", not +"VAT of NONE", so it did not gain a `VAT` prefix. + +```diff ++use Unitpay\Model\CashItem; ++use Unitpay\Model\Enum\Nds; ++use Unitpay\Model\Enum\PaymentMethod; ++use Unitpay\Model\Enum\PaymentObject; ++ + $item = new CashItem( + 'Iphone 6 Skin Cover', + 1, + 900, +- CashItem::NDS_20, +- CashItem::PAYMENT_OBJECT_COMMODITY, +- CashItem::PAYMENT_METHOD_PAYMENT_FULL ++ Nds::VAT20, ++ PaymentObject::COMMODITY, ++ PaymentMethod::PAYMENT_FULL + ); +``` + +## 5. Webhooks moved behind `webhook()` + +Everything about inbound verification and the IP allowlist now lives on +`Unitpay\Webhook\WebhookVerifier`, reached via `$unitpay->webhook()`: + +| 2.x — on `UnitPay` | 3.0 — on `$unitpay->webhook()` | +| --- | --- | +| `checkHandlerRequest()` | `checkHandlerRequest()` | +| `getHandlerMethod()`, `getHandlerParams()` | same | +| `getSuccessHandlerResponse()`, `getErrorHandlerResponse()` | same | +| `setAllowedIps()`, `addAllowedIps()`, `getAllowedIps()`, `refreshAllowedIps()` | same | + +```diff +-$unitpay = new UnitPay($domain, $secretKey); +-$unitpay->checkHandlerRequest(); +-print $unitpay->getSuccessHandlerResponse('Pay Success'); ++$webhook = (new Unitpay($domain, $secretKey))->webhook(); ++$webhook->checkHandlerRequest(); ++print $webhook->getSuccessHandlerResponse('Pay Success'); +``` + +If you subclassed `UnitPay` to override `getIp()` or `isAllowedIp()` behind a proxy, +subclass `Unitpay\Webhook\WebhookVerifier` instead — both are still `protected`. + +## 6. Signing is its own class + +`getSignature()` is no longer a public method on the facade. Direct signing — normally +only needed in tests or bespoke integrations — moved to `Unitpay\Signature\SignatureBuilder`: + +```diff +-$signature = $unitpay->getSignature($params, 'pay'); ++$signature = (new SignatureBuilder())->build($params, $secretKey, 'pay'); +``` + +`form()` and the services sign internally, so ordinary code never calls this. + +## 7. Transport: callable → `TransportInterface` + +The third constructor argument used to accept a `callable`. It now takes a +`Unitpay\Http\TransportInterface`: + +```diff +-$unitpay = new UnitPay($domain, $key, function (string $url, array $headers = []) { +- return file_get_contents($url); +-}); ++final class MyTransport implements Unitpay\Http\TransportInterface ++{ ++ public function send(string $url, array $headers = []) ++ { ++ return file_get_contents($url); ++ } ++} ++ ++$unitpay = new Unitpay($domain, $key, new MyTransport()); +``` + +Passing `null` (or omitting it) still gives you the default `CurlTransport`, unchanged in +behavior. The same instance now serves both the API calls and the webhook IP-feed fetch. + +## 8. Required parameters are checked earlier + +In 2.x a missing required parameter surfaced at runtime as a +`UnitpayValidationException`. In 3.0 the method signatures enforce it, so the same mistake +is an `ArgumentCountError` — and, more usefully, your IDE and static analyzer catch it +before the code ever runs. `UnitpayValidationException` still covers a missing secret key +and invalid `CashItem` input. + +Likewise, `UnitpayUnsupportedMethodException` is no longer thrown for outbound calls — +there is no method-name string to get wrong. It survives only for inbound webhooks whose +`method` is not one of `check` / `pay` / `preauth` / `error`. + +## Migration checklist + +1. `composer require unitpay/php-sdk:^3.0`, replace any direct `require` of `UnitPay.php`. +2. Add `use` statements; rename `UnitPay` → `Unitpay`, `CashItem` → `Unitpay\Model\CashItem`. +3. Replace every `api('method', [...])` with the service call from section 3. +4. Replace the `CashItem::*` / `UnitPay::PAYMENT_TYPE_*` constants per section 4. +5. Route webhook calls through `webhook()`. +6. Replace any callable transport with a `TransportInterface` implementation. +7. Run your test suite — most breakage surfaces as "undefined method" at analysis time. + +## See Also + +* [Getting Started](getting-started.md) — the 3.0 flows in full +* [API Methods](api-methods.md) — the complete service reference +* [CHANGELOG](../CHANGELOG.md) — the full list of 3.0 changes diff --git a/docs/receipts.md b/docs/receipts.md index 0ba1e87..b186f6f 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -3,32 +3,42 @@ [← 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: +and `payments()->initPayment(...)`). The constructor takes the required fields; optional +fields are set via fluent setters and are serialized only when set: ```php +use Unitpay\Model\CashItem; +use Unitpay\Model\Enum\Measure; +use Unitpay\Model\Enum\Nds; +use Unitpay\Model\Enum\PaymentMethod; +use Unitpay\Model\Enum\PaymentObject; + $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 + 'Iphone 6 Skin Cover', // name + 1, // count + 900, // price + Nds::VAT20, // VAT rate + PaymentObject::COMMODITY, // payment object + PaymentMethod::PAYMENT_FULL ); -$item->setMeasure(CashItem::MEASURE_ITEM); +$item->setMeasure(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`. +The dictionaries live in `Unitpay\Model\Enum` as const-classes: `Nds`, `PaymentObject`, +`PaymentMethod`, `Measure` (and `PaymentType` for payment-method codes). They are plain +classes with `public const`, not native enums, because the SDK supports PHP 7.4. -> Since 2026 the backend fiscalizes `NDS_20` (`vat20`) as VAT **22%** — there is no +> Since 2026 the backend fiscalizes `Nds::VAT20` (`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)). +Some payment-object values are kept only for backward compatibility and are rejected by +the public API: `EXCISE`, `GAMBLING_BET`, `GAMBLING_PRIZE`, `LOTTERY_PRIZE`, `COMPOSITE`. +Do not use them in new code; they are slated for removal in 4.0. + ## See Also -* [Getting Started](getting-started.md) — create a payment with `form()` or `api()` +* [Getting Started](getting-started.md) — create a payment with `form()` or the API * [API Methods](api-methods.md) — `offsetAdvance` and other receipt-related methods diff --git a/docs/telemetry.md b/docs/telemetry.md index d6497ed..b7e3fdb 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -7,16 +7,19 @@ 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`. +* Service calls (`payments()`, `subscriptions()`, `payouts()`, `reference()`) 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). +The webhook IP-feed fetch is a plain GET and carries no fingerprint headers. + 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 +* [Getting Started](getting-started.md) — the service and `form()` calls that carry the fingerprint +* [API Methods](api-methods.md) — the full service surface diff --git a/docs/webhooks.md b/docs/webhooks.md index e6616ad..a8fccb7 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -2,9 +2,10 @@ [← 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. +Inbound verification lives on `$unitpay->webhook()`, which returns a +`Unitpay\Webhook\WebhookVerifier`. 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 webhook(); try { // Validate request (check ip address, signature and etc) - $unitpay->checkHandlerRequest(); + $webhook->checkHandlerRequest(); // Read the verified request from the SDK (honors the overridden request, not $_GET) - $method = $unitpay->getHandlerMethod(); - $params = $unitpay->getHandlerParams(); + $method = $webhook->getHandlerMethod(); + $params = $webhook->getHandlerParams(); // Very important! Validate request with your order data, before complete order if ( @@ -48,22 +51,22 @@ try { switch ($method) { // Just check order (check server status, check order in DB and etc) case 'check': - echo $unitpay->getSuccessHandlerResponse('Check Success. Ready to pay.'); + echo $webhook->getSuccessHandlerResponse('Check Success. Ready to pay.'); break; // Method Pay means that the money received case 'pay': // Please complete order - echo $unitpay->getSuccessHandlerResponse('Pay Success'); + echo $webhook->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.'); + echo $webhook->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'); + echo $webhook->getSuccessHandlerResponse('Error logged'); break; // Unknown method: do not leave an empty response (Unitpay would treat it as a failure). default: @@ -71,39 +74,44 @@ try { } // Oops! Something went wrong. } catch (Exception $e) { - echo $unitpay->getErrorHandlerResponse($e->getMessage()); + echo $webhook->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: +from the published feed instead of waiting for a release. All four calls live on the +webhook verifier: -* `$unitpay->refreshAllowedIps()` pulls the current list from +* `$webhook->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 +* `$webhook->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. +* `$webhook->setAllowedIps([...])` replaces the Unitpay list outright. Passing an empty + array is fail-closed, not a no-op: with no `addAllowedIps()` entries it rejects every + webhook. * Override `getIp()` if you run behind a proxy (the check uses `REMOTE_ADDR`, not the - spoofable `X-Forwarded-For`). + spoofable `X-Forwarded-For`). `getIp()` and `isAllowedIp()` are `protected`, so extend + `WebhookVerifier` to change them. ```php // Cron: refresh once, cache the result on your side. -$ips = (new UnitPay($domain, $secretKey))->refreshAllowedIps()->getAllowedIps(); +$ips = (new Unitpay($domain, $secretKey))->webhook()->refreshAllowedIps()->getAllowedIps(); cache_set('unitpay_ips', $ips); // Handler: feed the cached list, no network call per callback. -(new UnitPay($domain, $secretKey)) +(new Unitpay($domain, $secretKey)) + ->webhook() ->setAllowedIps(cache_get('unitpay_ips')) ->checkHandlerRequest(); ``` ## See Also -* [API Methods](api-methods.md) — the `api()` calls that trigger these callbacks +* [API Methods](api-methods.md) — the service calls that trigger these callbacks * [Getting Started](getting-started.md) — create the payments being confirmed here