diff --git a/.gitattributes b/.gitattributes index 62feb38..5f07965 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,13 +1,11 @@ -# https://git-scm.com/book/en/v2/Customizing-Git-Git-Attributes - -# A list of files and folders those will be excluded from archives and the -# Composer package (for purposes of making it smaller). -/.coveralls.yml export-ignore -/.github export-ignore -/.gitattributes export-ignore -/.travis.yml export-ignore -/.vscode export-ignore -/examples export-ignore -/phpunit.xml export-ignore -/phpunit.no_autoload.xml export-ignore -/tests export-ignore \ No newline at end of file +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.markdownlint.json export-ignore +/.vscode export-ignore +/.php-cs-fixer.dist.php export-ignore +/examples export-ignore +/phpmd.xml export-ignore +/phpstan.neon export-ignore +/phpunit.xml export-ignore +/tests export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..75868f4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + tests: + name: Tests (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: json, curl + coverage: none + + - name: Install dependencies + uses: ramsey/composer-install@v3 + with: + dependency-versions: highest + composer-options: "--prefer-dist" + + - name: Lint + run: composer lint + + - name: Tests + run: composer test + + quality: + name: Static analysis & code style + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: json, curl + coverage: none + + - name: Install dependencies + uses: ramsey/composer-install@v3 + with: + dependency-versions: highest + composer-options: "--prefer-dist" + + - name: Code style (php-cs-fixer) + run: composer cs-check + + - name: Static analysis (PHPStan) + run: composer stan + + - name: Mess detection (PHPMD) + run: composer md + + - name: Security audit (composer) + run: composer audit + continue-on-error: true diff --git a/.gitignore b/.gitignore index 5c13bcc..18c0b19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,6 @@ -.idea +.idea/ .DS_Store /vendor/ composer.lock +.php-cs-fixer.cache clover.xml -.php_cs -.php_cs.cache -.phpstan.neon -.phpdoc/* -phpdoc.xml \ No newline at end of file diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..2ba0679 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,6 @@ +{ + "default": true, + "MD004": { "style": "asterisk" }, + "MD013": false, + "MD060": false +} diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..dcb7fdc --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,17 @@ +in([__DIR__ . '/tests', __DIR__ . '/examples']) + ->append([__DIR__ . '/UnitPay.php']); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(false) + // Dev machine runs PHP 8.5 while the project targets PHP >=7.4; allow the + // newer runtime instead of exporting the deprecated PHP_CS_FIXER_IGNORE_ENV. + ->setUnsupportedPhpVersionAllowed(true) + ->setRules([ + '@PSR12' => true, + 'array_syntax' => ['syntax' => 'short'], + 'no_unused_imports' => true, + ]) + ->setFinder($finder); diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f8e16b..8af58ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,107 @@ # Changelog -## 2.0.4 - 2021-03-17 -* Updated getSignature method (2Garin) -* Changelog started \ No newline at end of file +### 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` +* `CashItem`: 54-FZ dictionaries synced with the backend: + * Added VAT rates: vat5, vat7, vat22 and the calculated vat105, vat107, vat110, vat120, vat122 + * Added payment objects: payment_2, deposit, expense, pension_insurance_ip, pension_insurance, medical_insurance_ip, medical_insurance, social_insurance, casino_payment, issuance_bank, commodity_without_mark, commodity_mark + * Marked as deprecated (kept for backward compatibility, removal in 3.0) the values rejected by the public API: excise, gambling_bet, gambling_prize, lottery_prize, composite +* `CashItem`: added optional backend fields — sum, currency, measure (`MEASURE_*` constants), nomenclatureCode, markCode, markQuantity, pre_text, post_text; `setCashItems()` serializes them only when they are set +* `api()`: added support for the methods refundPayment, confirmPayment, cancelPayment, listSubscriptions, getSubscription, closeSubscription, getMethodsAvailable, getCommissions, getCurrencyCourses, getPartner, offsetAdvance — each with validation of its required parameters (secretKey is supplied automatically) +* `api()`: added mass-payout methods — massPayment, massPaymentStatus, massPaymentAvailableAmount, massPaymentCommissions, getSbpBankList, getBinInfo (require the account login and secretKey) +* `api()`: added `PAYMENT_TYPE_*` constants for the current Unitpay payment methods (card, cardInvoice, sbp, sberpay, tinkoffpay, paypal, webmoney) — for convenience and typo protection only; `paymentType` is still passed without validation; in the README and the `initPaymentApi` example they replaced the deprecated qiwi/yandex/mc/alfaClick +* `api()`: an explicit secretKey in the call parameters overrides the key from the constructor — account-level and payout methods can be called with the account key instead of the project key +* `api()`: the required initPayment parameters were aligned with the backend — account, sum, projectId, paymentType (secretKey is validated separately); desc is no longer required +* `api()`: parameters are now sent flat (`method=X&account=…&secretKey=…`), as Unitpay documents and has accepted since 05.2026, instead of the deprecated `params[...]` nesting (still accepted by the backend, so this is not a breaking change); the inbound webhook handler is unaffected and keeps reading `params[...]` +* `api()`: parameters from the fluent setters (setCashItems, setCustomerEmail, setCustomerPhone, setBackUrl) now reach `api()` calls too, not just `form()`; explicit `api()` parameters take precedence over the accumulated ones +* `CashItem`: the constructor now rejects non-numeric count and price (previously only 0/negative were caught) and normalizes numeric strings to int/float +* `CashItem`: the constructor rejects a non-positive count and a negative price (behavior change) +* `CashItem`: preserves a fractional count (weight/volume goods) instead of truncating to int +* handler: the IP allowlist was trimmed to the officially published addresses (31.186.100.49, 51.250.20.9); 127.0.0.1 is not trusted by default (behind a reverse proxy on the same host it would nullify the IP check) — add it via `setAllowedIps()` for local debugging; `setAllowedIps()` itself was added to override the list +* handler: `isAllowedIp()` now matches not only exact IPs but also CIDR subnets (IPv4/IPv6) — `setAllowedIps(['77.75.153.0/25'])` works +* handler: added `refreshAllowedIps()` — pulls the current webhook IP list from the public feed `/ips/ips_webhooks.json` and replaces the built-in one (a decommissioned IP drops off automatically); fail-safe: on any transport/parse/validation error it keeps the built-in list and does not throw, so it can be called before `checkHandlerRequest()` +* handler: added `addAllowedIps()` — adds the merchant's own IPs/CIDRs (e.g. your own proxy/relay) on top of the Unitpay list; unlike `setAllowedIps()`, they are preserved across `refreshAllowedIps()`/`setAllowedIps()` +* handler: added `getAllowedIps()` — returns the effective list (Unitpay + merchant IPs); cache it after `refreshAllowedIps()` and feed it back via `setAllowedIps()` to avoid hitting the network on every webhook +* `UnitpayIpAllowlist::isValidEntry()` validates every loaded IP/CIDR entry, so malformed JSON can never empty the list +* handler: `checkHandlerRequest()` now accepts the `preauth` webhook (a hold notification in two-stage payments, when funds are blocked but not yet captured) — it used to be rejected as an unsupported method, which prevented two-stage/subscription handlers from verifying it +* Added typed exceptions (UnitpaySignatureException, UnitpayIpException, UnitpayTransportException, UnitpayUnsupportedMethodException) with the UnitpayExceptionInterface; each still extends its former SPL class, so existing catch blocks keep working +* `api()`: optional cURL transport with connect/read timeouts and no dependency on allow_url_fopen (falls back to file_get_contents); ext-curl added to composer "suggest" +* `api()`: the cURL transport does not call curl_close() on PHP 8.0+ (there it is a deprecated no-op that raises E_DEPRECATED on PHP 8.5 on every API call); on PHP <8.0 the handle (resource) is closed explicitly via a PHP_VERSION_ID check +* examples: a full set of scenarios — payment form (`paymentForm`), API (`initPaymentApi`), 54-FZ receipt via `CashItem` (`receipt`), webhook (`webhook`), `getPayment` (`paymentInfo`), refund (`refund`), two-stage (`twoStagePayment`), subscriptions (`subscriptions`), SBP payouts (`payout`), account reference calls (`accountInfo`), advance-offset receipt (`offsetAdvance`); added an `examples/README.md` index +* examples: connection settings and order data separated — `config.php` (domain, project/account keys, login) and `order.php` (order data); secrets are read from the environment (`UNITPAY_SECRET_KEY`, `UNITPAY_LOGIN`, `UNITPAY_ACCOUNT_SECRET_KEY`) instead of being hardcoded +* examples: robustness — `require` via `__DIR__` (independent of the working directory), `exit` after `header('Location:')`, `api()`/`form()` calls wrapped in try/catch (`UnitpayExceptionInterface`); `webhook` returns `application/json` and no longer leaves an empty response for an unknown method (`default` in the switch) +* examples: removed the unreachable "refund" handler branch; added a "preauth" branch (hold notification — acknowledge receipt but do not deliver goods, that waits for "pay"); handling of the "response" reply type from initPayment (e.g. recurring/subscription charges without a redirect) +* Added a PHPUnit test suite and injectable seams (getIp / API transport) for testability +* Added QA tooling: phpstan, php-cs-fixer, phpmd and parallel-lint +* Native type declarations: parameters, return types and typed properties across all three classes (`CashItem`, `UnitpayIpAllowlist`, `UnitPay`) within the PHP 7.4 limits (no union types, `mixed`, or `declare(strict_types)`) — the public API and behavior are unchanged. Money and quantity parameters (`form()` `$sum`, `CashItem` `$count`/`$price`, and the `httpGet()` `string|false` return) are deliberately left untyped to preserve the previous "soft" scalar ergonomics; their types are still documented in PHPDoc. PHPStan raised from level 5 to level 6 +* Minimum PHP version raised to 7.4 +* Hardening from code review: + * `api()`: folds in only the fluent-setter parameters (cashItems/customerEmail/customerPhone/backUrl), not the whole set — a reused instance no longer leaks the key `form()` parameters or the stale signature into an unrelated `api()` call + * `api()`: the fluent-setter parameters are now cleared once the request has been attempted — on a transport failure too, not only on success — so a stale receipt/customer can no longer leak from a failed call into an unrelated later call on a reused instance (symmetric with `form()`); a retry after a failure must re-apply the setters + * `setCashItems()`: throws on a json_encode failure (e.g. a product name that is not UTF-8) instead of silently attaching an empty 54-FZ receipt + * `CashItem`: preserves a fractional count (weight/volume goods) instead of truncating to int + * `form()`: throws on an empty secret instead of returning an unsigned URL — like `api()`/`checkHandlerRequest()` + * `getSignature()`/`api()`/`form()`: format float parameters locale-independently, so a comma-separator locale on PHP <8.0 cannot corrupt the signature or the amount + * `api()`: an empty explicit secretKey (e.g. a getenv() that did not resolve) falls back to the instance key instead of throwing + * `httpGet()`: suppresses the file_get_contents warning so a URL containing the secret does not leak into the error log + * `checkHandlerRequest()`: exposes the verified method/params via `getHandlerMethod()`/`getHandlerParams()`, so the consumer does not re-read $_GET + * `getSignature()`: rejects a null/empty secret up front — as a public method it must not silently hash with an empty secret (the appended null would coerce to '' and drop out, yielding a plausible but secret-less signature); the normal `form()`/`checkHandlerRequest()` paths already guarded this, this is defense-in-depth for direct calls + * docs: `setAllowedIps([])` is documented as fail-closed (an empty allowlist rejects every webhook rather than being a no-op); the `$transport` seam docblock now documents the `$headers` argument the transport actually receives + * every SDK exception implements UnitpayExceptionInterface (UnitpayValidationException was added for the missing-parameter/secret/method cases) + +### v2.0.6 — 2025-05-14 + +* Added a new supported Unitpay IP address +* Updated README.md + +### v2.0.5 — 2022-02-04 + +* Updated the list of Unitpay IP addresses +* Updated documentation links +* Improved code quality and structure + +### v2.0.4 — 2021-03-17 + +* Updated the `getSignature` method (2Garin) + +### v2.0.3 — 2021-02-20 + +* Filtering of signature input parameters (removing the sign/signature fields before signing) + +### v2.0.2 — 2020-08-31 + +* Added the nds, type and paymentMethod parameters to `CashItem` + +### v2.0.1 — 2020-03-03 + +* Added domain selection in the examples + +### v2.0.0 — 2020-03-03 + +* Added domain selection (configurable API domain) +* Updated the documentation URL + +### v1.1.2 — 2018-06-15 + +* Fixed the array_merge exception ("Argument #1 is not an array") when no receipt is set + +### v1.1.1 — 2018-02-08 + +* Added a LICENSE file +* Fixed the composer file + +### v1.1.0 — 2017-08-01 + +* Added customerEmail, customerPhone and cashItems to the payment form + +### v1.0.0 — 2017-04-10 + +* First public release of the Unitpay PHP SDK +* Switched to SHA-256 signatures for all methods (MD5 support removed) +* Added the getPayment API method and the orderInfo.php example +* secretKey became a required parameter for API calls +* billingCode renamed to paymentType +* statusUrl deprecated in favor of receiptUrl +* Added support for the partner handler method "error" +* Added an overridable `getIp()` method diff --git a/LICENSE.md b/LICENSE.md index a8dcb64..1c6b06f 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ The MIT License -Copyright (c) 2013-2021 Unitpay (https://unitpay.ru) +Copyright (c) 2013-2026 Unitpay (https://unitpay.ru) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 20f98d6..88a0b66 100644 --- a/README.md +++ b/README.md @@ -1,218 +1,92 @@ # Unitpay PHP SDK -PHP SDK for [Unitpay.ru](https://unitpay.ru). +[![CI](https://github.com/unitpay/php-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/unitpay/php-sdk/actions/workflows/ci.yml) +[![Latest Stable Version](https://img.shields.io/packagist/v/unitpay/php-sdk.svg)](https://packagist.org/packages/unitpay/php-sdk) +[![PHP Version](https://img.shields.io/packagist/php-v/unitpay/php-sdk.svg)](https://packagist.org/packages/unitpay/php-sdk) +[![Total Downloads](https://img.shields.io/packagist/dt/unitpay/php-sdk.svg)](https://packagist.org/packages/unitpay/php-sdk) +[![License](https://img.shields.io/packagist/l/unitpay/php-sdk.svg)](LICENSE.md) -Documentation https://help.unitpay.ru +> PHP SDK for the [Unitpay.ru](https://unitpay.ru) payment REST API. -## Examples ## +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**. -These are just some quick examples. Check out the samples -in [`/examples`](https://github.com/unitpay/php-sdk/blob/master/examples). +Official Unitpay documentation: [help.unitpay.ru](https://help.unitpay.ru) -### Payment integration using Unitpay form +## Requirements -```php -= 7.4 +* ext-json -// Project Data -$domain = 'unitpay.ru';// Your working domain: unitpay.ru or address provided by unitpay support service -$secretKey = '9e977d0c0e1bc8f5cc9775a8cc8744f1';// Project secret key -$publicId = '15155-ae12d'; +No runtime dependencies. The SDK is a single file — [`UnitPay.php`](UnitPay.php) — +exposing two classes in the **global namespace**: `UnitPay` and `CashItem`. -// My item Info -$itemName = 'Iphone 6 Skin Cover'; +## Installation -// My Order Data -$orderId = 'a183f94-1434-1e44'; -$orderSum = 900; -$orderDesc = 'Payment for item "' . $itemName . '"'; -$orderCurrency = 'RUB'; +```sh +composer require unitpay/php-sdk +``` -$unitpay = new UnitPay($domain, $secretKey); +Then load the Composer autoloader — its classmap registers both `UnitPay` and `CashItem`: -$unitpay - ->setBackUrl('https://domain.com') - ->setCustomerEmail('customer@domain.com') - ->setCustomerPhone('79001235555') - ->setCashItems([ - new CashItem($itemName, 1, $orderSum) - ]); - -$redirectUrl = $unitpay->form( - $publicId, - $orderSum, - $orderId, - $orderDesc, - $orderCurrency -); - -header("Location: " . $redirectUrl); +```php +require __DIR__ . '/vendor/autoload.php'; ``` -### Payment integration using Unitpay API +See [Getting Started](docs/getting-started.md) for the `dev-master` and direct-download +options. + +## Quick Start ```php api('initPayment', [ - 'account' => $orderId, - 'desc' => $orderDesc, - 'sum' => $orderSum, - 'paymentType' => 'yandex', - 'currency' => $orderCurrency, - 'projectId' => $projectId -]); - -// If need user redirect on Payment Gate -if (isset($response->result->type) - && $response->result->type == 'redirect') { - // Url on PaymentGate - $redirectUrl = $response->result->redirectUrl; - // Payment ID in Unitpay (you can save it) - $paymentId = $response->result->paymentId; - // User redirect - header("Location: " . $redirectUrl); - -// If without redirect (invoice) -} elseif (isset($response->result->type) - && $response->result->type == 'invoice') { - // Url on receipt page in Unitpay - $receiptUrl = $response->result->receiptUrl; - // Payment ID in Unitpay (you can save it) - $paymentId = $response->result->paymentId; - // Invoice Id in Payment Gate (you can save it) - $invoiceId = $response->result->invoiceId; - // User redirect - header("Location: " . $receiptUrl); - -// If error during api request -} elseif (isset($response->error->message)) { - $error = $response->error->message; - print 'Error: '.$error; -} -``` +$unitpay = new UnitPay('unitpay.ru', $secretKey); -### Handler sample +$unitpay + ->setBackUrl('https://domain.com') + ->setCustomerEmail('customer@domain.com') + ->setCashItems([new CashItem('Iphone 6 Skin Cover', 1, 900)]); -```php -form($publicId, 900, $orderId, 'Payment for item', 'RUB'); -/** - * Demo handler for your projects - * - */ -include ('../UnitPay.php'); - -// Project Data -$domain = 'unitpay.ru';// Your working domain: unitpay.ru or address provided by unitpay support service -$projectId = 1; -$secretKey = '9e977d0c0e1bc8f5cc9775a8cc8744f1';// Project secret key - -// My item Info -$itemName = 'Iphone 6 Skin Cover'; - -// My Order Data -$orderId = 'a183f94-1434-1e44'; -$orderSum = 900; -$orderDesc = 'Payment for item "' . $itemName . '"'; -$orderCurrency = 'RUB'; - -$unitpay = new UnitPay($domain, $secretKey); - -try { - // Validate request (check ip address, signature and etc) - $unitpay->checkHandlerRequest(); - - list($method, $params) = [$_GET['method'], $_GET['params']]; - - // Very important! Validate request with your order data, before complete order - if ( - $params['orderSum'] != $orderSum || - $params['orderCurrency'] != $orderCurrency || - $params['account'] != $orderId || - $params['projectId'] != $projectId - ) { - // logging data and throw exception - throw new InvalidArgumentException('Order validation Error!'); - } - switch ($method) { - // Just check order (check server status, check order in DB and etc) - case 'check': - echo $unitpay->getSuccessHandlerResponse('Check Success. Ready to pay.'); - break; - // Method Pay means that the money received - case 'pay': - // Please complete order - echo $unitpay->getSuccessHandlerResponse('Pay Success'); - break; - // Method Error means that an error has occurred. - case 'error': - // Please log error text. - echo $unitpay->getSuccessHandlerResponse('Error logged'); - break; - } -// Oops! Something went wrong. -} catch (Exception $e) { - echo $unitpay->getErrorHandlerResponse($e->getMessage()); -} +header('Location: ' . $redirectUrl); ``` -## Installation +Prefer a server-to-server call? Use `$unitpay->api('initPayment', [...])` — see +[Getting Started](docs/getting-started.md). -### Install composer package -Set up `composer.json` in your project directory: -``` -{ - "require":{"unitpay/php-sdk":"dev-master"} -} -``` +## Key Features -Run [composer](https://getcomposer.org/doc/00-intro.md#installation): -```sh -$ php composer.phar install -``` +* **Hosted form or API** — `form()` builds a signed redirect URL; `api('initPayment', ...)` + does a server-to-server call. +* **54-FZ fiscal receipts** — attach `CashItem` line items to any payment. +* **Secure webhooks** — `checkHandlerRequest()` trusts a callback only when both the + SHA-256 signature **and** the source-IP allowlist pass. +* **Dynamic IP allowlist** — refresh Unitpay's webhook IPs from the published feed, + fail-safe. +* **Typed exceptions** — all implement `UnitpayExceptionInterface`. +* **Zero dependencies** — one file, `ext-json` only (`ext-curl` optional). + +## Documentation + +| Guide | Description | +|-------|-------------| +| [Getting Started](docs/getting-started.md) | Requirements, installation, first payment (form / API) | +| [Fiscal Receipts](docs/receipts.md) | 54-FZ receipt line items via `CashItem` | +| [API Methods](docs/api-methods.md) | Full `api()` method reference and account-level calls | +| [Webhooks](docs/webhooks.md) | Payment handler + keeping the IP allowlist fresh | +| [Telemetry](docs/telemetry.md) | Anonymous SDK version fingerprint | + +Runnable samples for every method group live in [`examples/`](examples). -### Direct download +## Contributing -Download [latest version](https://github.com/unitpay/php-sdk/archive/master.zip), unzip and copy to your project folder. +Please feel free to contribute to this project! Pull requests and feature requests +welcome! -## Contributing ## +## License -Please feel free to contribute to this project! Pull requests and feature requests welcome! +MIT — see [LICENSE.md](LICENSE.md). diff --git a/UnitPay.php b/UnitPay.php index 1f56bfe..fda3b0e 100644 --- a/UnitPay.php +++ b/UnitPay.php @@ -1,182 +1,772 @@ name = $name; - $this->count = $count; - $this->price = $price; + $this->count = $count + 0; + $this->price = (float) $price; $this->nds = $nds; $this->type = $type; $this->paymentMethod = $paymentMethod; } - /** - * @return string - */ - public function getName() + public function getName(): string { return $this->name; } /** - * @return int + * @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; + } + /** - * @return float + * Total sum of the line item. If not set, the backend computes it as price * count. + * Cannot exceed round(price * count, 2). */ - public function getPrice() + public function setSum(float $sum): self { - return $this->price; + $this->sum = $sum; + return $this; + } + + public function getSum(): ?float + { + return $this->sum; } /** - * @return string + * Line-item currency (ISO 4217). Defaults to RUB on the backend. */ - public function getNds() + public function setCurrency(string $currency): self { - return $this->nds; + $this->currency = $currency; + return $this; + } + + public function getCurrency(): ?string + { + return $this->currency; } /** - * @return string + * Unit of measure, one of the MEASURE_* constants. */ - public function getType() + public function setMeasure(int $measure): self { - return $this->type; + $this->measure = $measure; + return $this; + } + + public function getMeasure(): ?int + { + return $this->measure; } /** - * @return string + * Product nomenclature code (marking). */ - public function getPaymentMethod() + public function setNomenclatureCode(string $nomenclatureCode): self { - return $this->paymentMethod; + $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; } } /** - * Payment method Unitpay process + * 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 { - private $supportedUnitpayMethods = ['initPayment', 'getPayment']; - private $requiredUnitpayMethodsParams = [ - 'initPayment' => ['desc', 'account', 'sum'], - 'getPayment' => ['paymentId'], + /** 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'], ]; - private $supportedPartnerMethods = ['check', 'pay', 'error']; - private $supportedUnitpayIp = [ + /** + * 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', - '52.29.152.23', - '52.19.56.234', - '127.0.0.1' // for debug ]; - private $secretKey; - private $params = []; - private $apiUrl; - private $formUrl; + private ?string $secretKey; + /** @var array */ + private array $params = []; + private string $apiUrl; + private string $formUrl; + /** @var callable|null */ + private $transport; + /** @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; - public function __construct($domain, $secretKey = null) + /** + * @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; } /** - * Create SHA-256 digital signature - * @param array $params - * @param string|null $method - * @return string + * 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 getSignature(array $params, $method = null) + public function addAllowedIps(array $ips): self { - unset($params['signature']); + $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; @@ -184,197 +774,397 @@ public function getSignature(array $params, $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)); } /** - * Return IP address - * @return string + * Sender IP of the inbound request (the overridden clientIp or $_SERVER['REMOTE_ADDR']). */ - protected function getIp() + protected function getIp(): string { - return $_SERVER['REMOTE_ADDR']; + return $this->clientIp !== null ? $this->clientIp : ($_SERVER['REMOTE_ADDR'] ?? ''); } /** - * Get URL for pay through the form - * @param string $publicKey + * 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 - * @param string $account - * @param string $desc - * @param string $currency - * @param string $locale - * @return string */ - public function form($publicKey, $sum, $account, $desc, $currency = 'RUB', $locale = 'ru') + public function form(string $publicKey, $sum, string $account, string $desc, string $currency = 'RUB', string $locale = 'ru'): string { - $vitalParams = [ + if (empty($this->secretKey)) { + throw new UnitpayValidationException('SecretKey is null'); + } + $vitalParams = self::stringifyFloats([ 'account' => $account, 'currency' => $currency, 'desc' => $desc, - 'sum' => $sum - ]; - $this->params = array_merge($this->params, $vitalParams); - if ($this->secretKey) { - $this->params['signature'] = $this->getSignature($vitalParams); - } - $this->params['locale'] = $locale; - return $this->formUrl . $publicKey . '?' . http_build_query($this->params); + 'sum' => $sum, + ]); + $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); } /** - * Set customer email - * @param string $email - * @return $this + * Sets the customer's email. */ - public function setCustomerEmail($email) + public function setCustomerEmail(string $email): self { $this->params['customerEmail'] = $email; return $this; } /** - * Set customer phone number - * @param string $phone - * @return $this + * Sets the customer's phone. */ - public function setCustomerPhone($phone) + public function setCustomerPhone(string $phone): self { $this->params['customerPhone'] = $phone; return $this; } /** - * Set list of paid goods + * 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 - * @return $this - */ - public function setCashItems(array $items) - { - $this->params['cashItems'] = base64_encode( - json_encode( - /** @var CashItem $item */ - array_map(static function ($item) { - return [ - 'name' => $item->getName(), - 'count' => $item->getCount(), - 'price' => $item->getPrice(), - 'nds' => $item->getNds(), - 'type' => $item->getType(), - 'paymentMethod' => $item->getPaymentMethod(), - ]; - }, $items))); + */ + 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; } /** - * Set callback URL - * @param string $backUrl - * @return $this + * Sets the URL Unitpay will return the payer to after payment. */ - public function setBackUrl($backUrl) + public function setBackUrl(string $backUrl): self { $this->params['backUrl'] = $backUrl; return $this; } /** - * Call API - * @param string $method - * @param array $params - * @return object + * 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($method, array $params = []) + public function api(string $method, array $params = []): object { - if (!in_array($method, $this->supportedUnitpayMethods, true)) { - throw new UnexpectedValueException('Method is not supported'); + if (!isset(self::REQUIRED_UNITPAY_METHODS_PARAMS[$method])) { + throw new UnitpayUnsupportedMethodException('Method is not supported'); } - if (isset($this->requiredUnitpayMethodsParams[$method])) { - foreach ($this->requiredUnitpayMethodsParams[$method] as $rParam) { - if (!isset($params[$rParam])) { - throw new InvalidArgumentException('Param ' . $rParam . ' is null'); - } + $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'); } } - $params['secretKey'] = $this->secretKey; if (empty($params['secretKey'])) { - throw new InvalidArgumentException('SecretKey is null'); + $params['secretKey'] = $this->secretKey; + } + if (empty($params['secretKey'])) { + throw new UnitpayValidationException('SecretKey is null'); } - $requestUrl = $this->apiUrl . '?' . http_build_query([ - 'method' => $method, - 'params' => $params, - ], null, '&', PHP_QUERY_RFC3986); + $params = self::stringifyFloats($params); - $response = json_decode(file_get_contents($requestUrl)); - if (!is_object($response)) { - throw new InvalidArgumentException('Temporary server error. Please try again later.'); - } + $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; + return $response; + } finally { + $this->params = []; + } } /** - * Check request on handler from Unitpay - * @return bool + * 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() + public function checkHandlerRequest(): bool { $ip = $this->getIp(); - if (!isset($_GET['method'])) { - throw new InvalidArgumentException('Method is null'); + 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($_GET['params'])) { - throw new InvalidArgumentException('Params is null'); + if (!isset($request['params'])) { + throw new UnitpayValidationException('Params is null'); } - list($method, $params) = [$_GET['method'], $_GET['params']]; + list($method, $params) = [$request['method'], $request['params']]; - if (!in_array($method, $this->supportedPartnerMethods, true)) { - throw new UnexpectedValueException('Method is not supported'); + if (!in_array($method, self::SUPPORTED_PARTNER_METHODS, true)) { + throw new UnitpayUnsupportedMethodException('Method is not supported'); } - if (!isset($params['signature']) || $params['signature'] !== $this->getSignature($params, $method)) { - throw new InvalidArgumentException('Wrong signature'); + if (!isset($params['signature']) || !is_string($params['signature']) + || !hash_equals($this->getSignature($params, $method), $params['signature'])) { + throw new UnitpaySignatureException('Wrong signature'); } - /** - * IP address check - * @link https://help.unitpay.ru/book-of-reference/ip-addresses - */ - if (!in_array($ip, $this->supportedUnitpayIp, true)) { - throw new InvalidArgumentException('IP address Error'); + if (!$this->isAllowedIp($ip)) { + throw new UnitpayIpException('IP address Error'); } + + $this->handlerMethod = $method; + $this->handlerParams = $params; + return true; } /** - * Response for Unitpay if handle success - * @param string $message - * @return string + * 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($message) + public function getSuccessHandlerResponse(string $message): string { - return json_encode(['result' => ['message' => $message]]); + return (string) json_encode(['result' => ['message' => $message]]); } /** - * Response for Unitpay if handle error - * @param string $message - * @return string + * Builds the JSON error response that Unitpay expects from the handler. */ - public function getErrorHandlerResponse($message) + public function getErrorHandlerResponse(string $message): string { - return json_encode(['error' => ['message' => $message]]); + return (string) json_encode(['error' => ['message' => $message]]); } } diff --git a/composer.json b/composer.json index 44acce8..0c5fc6a 100644 --- a/composer.json +++ b/composer.json @@ -12,12 +12,51 @@ } ], "require":{ - "php": ">=5.6.0", + "php": ">=7.4", "ext-json": "*" }, + "require-dev": { + "phpunit/phpunit": "^9.6", + "phpstan/phpstan": "^2.2", + "php-parallel-lint/php-parallel-lint": "^1.4", + "friendsofphp/php-cs-fixer": "^3.95", + "phpmd/phpmd": "^2.15" + }, + "suggest": { + "ext-curl": "Enables the default cURL transport for api(): connect/read timeouts and no dependency on allow_url_fopen. Falls back to file_get_contents when absent." + }, "autoload":{ "classmap":[ "./UnitPay.php" ] + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "lint": "parallel-lint UnitPay.php examples tests", + "stan": "phpstan analyse --no-progress", + "cs-check": "php-cs-fixer fix --dry-run --diff", + "cs-fix": "php-cs-fixer fix", + "md": "@php -d error_reporting=\"E_ALL & ~E_DEPRECATED\" vendor/bin/phpmd UnitPay.php text phpmd.xml", + "check": [ + "@lint", + "@cs-check", + "@stan", + "@md", + "@test" + ] + }, + "scripts-descriptions": { + "test": "Run the PHPUnit test suite", + "lint": "Syntax-lint every PHP file in parallel", + "stan": "Run PHPStan static analysis", + "cs-check": "Report code-style violations without changing files", + "cs-fix": "Apply code-style fixes in place", + "md": "Run PHPMD mess detection on UnitPay.php", + "check": "Run lint, cs-check, stan, md and test in sequence" } -} \ No newline at end of file +} diff --git a/docs/api-methods.md b/docs/api-methods.md new file mode 100644 index 0000000..f00f515 --- /dev/null +++ b/docs/api-methods.md @@ -0,0 +1,69 @@ +# API Methods + +[← Fiscal Receipts](receipts.md) · [Back to README](../README.md) · [Webhooks →](webhooks.md) + +All methods are called through `api('', [...])`. `secretKey` is added +automatically from the constructor, so pass only the business params below. Full +parameters and response formats are in the +[official API documentation](https://help.unitpay.ru). + +| Method | Required params | Purpose | +| --- | --- | --- | +| `initPayment` | `account`, `sum`, `projectId`, `paymentType` | Create a payment | +| `getPayment` | `paymentId` | Payment info | +| `refundPayment` | `paymentId` (+ optional `sum`) | Refund a payment (full or partial) | +| `confirmPayment` | `paymentId` | Confirm (capture) a two-stage payment | +| `cancelPayment` | `paymentId` | Cancel (release) a two-stage payment | +| `listSubscriptions` | `projectId` (+ optional `all`) | List project subscriptions | +| `getSubscription` | `subscriptionId` | Subscription info | +| `closeSubscription` | `subscriptionId` | Close a subscription | +| `getMethodsAvailable` | `projectId` | Payment methods available on the project | +| `getCommissions` | `projectId`, `login` | Acquiring commissions for a project | +| `getCurrencyCourses` | `login` | Currency conversion rates | +| `getPartner` | `login` | Account balance | +| `offsetAdvance` | `login`, `paymentId` (+ optional `cashItems`) | Advance-offset fiscal receipt | +| `massPayment` | `login`, `transactionId`, `sum`, `purse`, `paymentType` (+ `memberId` for SBP) | Create a payout | +| `massPaymentStatus` | `login`, `transactionId` | Payout status | +| `massPaymentAvailableAmount` | `login`, `sum`, `purse`, `paymentType` | Balance available for payout | +| `massPaymentCommissions` | `login` | Payout commissions | +| `getSbpBankList` | `login` | SBP participant banks | +| `getBinInfo` | `login`, `bin` | Card info by BIN | + +## Account-level methods + +For the account-level methods (`getCommissions`, `getCurrencyCourses`, `getPartner`, +`offsetAdvance` and all payout methods) the `secretKey` is the **account** key (profile), +not the project key, and `login` is the account email. Pass the account key explicitly in +the call — it overrides the constructor (project) key: + +```php +$response = $unitpay->api('getPartner', [ + 'login' => 'partner@example.com', + 'secretKey' => $accountKey, // overrides the project key from the constructor +]); +``` + +For SBP payouts pass `memberId` obtained from `getSbpBankList`. + +## Example — refund a payment + +```php +$response = $unitpay->api('refundPayment', [ + 'paymentId' => 123456, + // 'sum' => 100, // optional: partial refund +]); + +if (isset($response->result->message)) { + print $response->result->message; +} elseif (isset($response->error->message)) { + print 'Error: ' . $response->error->message; +} +``` + +Note: `confirmPayment` and `cancelPayment` return a top-level `message` +(`$response->message`), not `$response->result->message`. + +## See Also + +* [Getting Started](getting-started.md) — the `initPayment` flow in full +* [Webhooks](webhooks.md) — handle the callbacks a payment triggers diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..a9a4b20 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,180 @@ +# Getting Started + +[Back to README](../README.md) · [Fiscal Receipts →](receipts.md) + +## Requirements + +* PHP >= 7.4 +* ext-json + +No runtime dependencies. The whole SDK is a single file — [`UnitPay.php`](../UnitPay.php) — +exposing two classes in the **global namespace**: `UnitPay` and `CashItem`. `ext-curl` is +optional: `api()` uses it when present and falls back to `file_get_contents()` otherwise. + +## Installation + +### Composer (recommended) + +```sh +composer require unitpay/php-sdk +``` + +Then load the Composer autoloader — its classmap registers both `UnitPay` and `CashItem`: + +```php +require __DIR__ . '/vendor/autoload.php'; +``` + +To follow the default branch (latest changes) instead of the newest tag: + +```sh +composer require unitpay/php-sdk:dev-master +``` + +### Direct download + +Download the [latest version](https://github.com/unitpay/php-sdk/archive/master.zip), +unzip it and `require` the single file directly: + +```php +require '/path/to/UnitPay.php'; +``` + +## Create a payment (Unitpay hosted form) + +`form()` builds a signed redirect URL to Unitpay's hosted payment page. Fluent setters +(`setBackUrl`, `setCustomerEmail`, `setCustomerPhone`, `setCashItems`) are optional and +apply to both `form()` and `api('initPayment', ...)`. + +```php +setBackUrl('https://domain.com') + ->setCustomerEmail('customer@domain.com') + ->setCustomerPhone('79001235555') + ->setCashItems([ + new CashItem($itemName, 1, $orderSum) + ]); + +$redirectUrl = $unitpay->form( + $publicId, + $orderSum, + $orderId, + $orderDesc, + $orderCurrency +); + +header("Location: " . $redirectUrl); +``` + +## Create a payment (Unitpay API) + +`api('initPayment', ...)` does a server-to-server call. `secretKey` is added +automatically from the constructor. `paymentType` is a payment-method code from the +reference (`UnitPay::PAYMENT_TYPE_*` constants): `card`, `cardInvoice`, `sbp`, `sberpay`, +`tinkoffpay`, `paypal`, `webmoney` — see the +[payment-system codes](https://help.unitpay.ru/book-of-reference/payment-system-codes). + +```php +api('initPayment', [ + 'account' => $orderId, + 'desc' => $orderDesc, + 'sum' => $orderSum, + 'paymentType' => UnitPay::PAYMENT_TYPE_CARD, + 'currency' => $orderCurrency, + 'projectId' => $projectId +]); + +// If need user redirect on Payment Gate +if (isset($response->result->type) + && $response->result->type === 'redirect') { + $redirectUrl = $response->result->redirectUrl; + $paymentId = $response->result->paymentId; // Payment ID in Unitpay (you can save it) + header("Location: " . $redirectUrl); + +// If without redirect (invoice) +} elseif (isset($response->result->type) + && $response->result->type === 'invoice') { + $receiptUrl = $response->result->receiptUrl; + $paymentId = $response->result->paymentId; + $invoiceId = $response->result->invoiceId; + header("Location: " . $receiptUrl); + +// If processed without redirect (e.g. recurring/subscription charge) +} elseif (isset($response->result->type) + && $response->result->type === 'response') { + $paymentId = $response->result->paymentId; + $message = $response->result->message; // Human-readable result message + print $message; + +// If error during api request +} elseif (isset($response->error->message)) { + $error = $response->error->message; + print 'Error: '.$error; +} +``` + +## Runnable examples + +The [`examples/`](../examples) folder has runnable samples for every method group (serve +them over HTTP, e.g. `php -S localhost:8000 -t examples`): + +* [`paymentForm.php`](../examples/paymentForm.php) / [`initPaymentApi.php`](../examples/initPaymentApi.php) — create a payment (form / API) +* [`receipt.php`](../examples/receipt.php) — 54-FZ fiscal receipt via `CashItem` +* [`paymentInfo.php`](../examples/paymentInfo.php) — `getPayment` +* [`webhook.php`](../examples/webhook.php) — webhook handler (`check` / `pay` / `error`) +* [`refund.php`](../examples/refund.php) — `refundPayment` +* [`twoStagePayment.php`](../examples/twoStagePayment.php) — `confirmPayment` / `cancelPayment` +* [`subscriptions.php`](../examples/subscriptions.php) — list / info / close subscriptions +* [`payout.php`](../examples/payout.php) — payouts (mass-payment) + SBP bank list +* [`accountInfo.php`](../examples/accountInfo.php) — balance, commissions, rates, BIN, methods +* [`offsetAdvance.php`](../examples/offsetAdvance.php) — advance-offset fiscal receipt + +## See Also + +* [Fiscal Receipts](receipts.md) — attach 54-FZ receipt line items with `CashItem` +* [API Methods](api-methods.md) — the full `api()` method reference +* [Webhooks](webhooks.md) — verify inbound payment callbacks diff --git a/docs/receipts.md b/docs/receipts.md new file mode 100644 index 0000000..0ba1e87 --- /dev/null +++ b/docs/receipts.md @@ -0,0 +1,34 @@ +# Fiscal Receipts (54-FZ) + +[← Getting Started](getting-started.md) · [Back to README](../README.md) · [API Methods →](api-methods.md) + +Attach receipt line items with `CashItem` and `setCashItems()` (works with both `form()` +and `api('initPayment', ...)`). The constructor takes the required fields; optional fields +are set via fluent setters and are serialized only when set: + +```php +$item = new CashItem( + 'Iphone 6 Skin Cover', // name + 1, // count + 900, // price + CashItem::NDS_20, // VAT rate + CashItem::PAYMENT_OBJECT_COMMODITY, // payment object + CashItem::PAYMENT_METHOD_PAYMENT_FULL +); +$item->setMeasure(CashItem::MEASURE_ITEM); + +$unitpay->setCashItems([$item]); +``` + +VAT rates (`NDS_*`), payment objects (`PAYMENT_OBJECT_*`), payment methods +(`PAYMENT_METHOD_*`) and units of measure (`MEASURE_*`) are exposed as constants on +`CashItem`. + +> Since 2026 the backend fiscalizes `NDS_20` (`vat20`) as VAT **22%** — there is no +> separate path for "real" 20%. Pick the rate that matches the actual receipt (see +> [CHANGELOG.md](../CHANGELOG.md)). + +## See Also + +* [Getting Started](getting-started.md) — create a payment with `form()` or `api()` +* [API Methods](api-methods.md) — `offsetAdvance` and other receipt-related methods diff --git a/docs/telemetry.md b/docs/telemetry.md new file mode 100644 index 0000000..d6497ed --- /dev/null +++ b/docs/telemetry.md @@ -0,0 +1,22 @@ +# Telemetry + +[← Webhooks](webhooks.md) · [Back to README](../README.md) + +The SDK adds a small, **anonymous** version fingerprint to the requests it already makes, +so Unitpay can see which SDK/PHP versions are in the field. This is standard SDK +self-identification, like any `User-Agent` — it makes **no extra network calls** and never +sends secrets, amounts, or customer data: + +* `api()` requests carry a `User-Agent: unitpay-php-sdk/ api/` header and an + `X-Unitpay-Client` JSON header with `sdk_version`, `api_version` (the Unitpay API surface + targeted), `lang`, `lang_version`, `platform` (coarse OS family only), `publisher`. +* `form()` URLs carry an `sdk=php__` query parameter (outside the + signature — it does not affect it). + +That is the whole of it — there is no separate telemetry endpoint, no opt-in beacon, and +nothing to configure. + +## See Also + +* [Getting Started](getting-started.md) — the `api()` and `form()` calls that carry the fingerprint +* [API Methods](api-methods.md) — the full `api()` method surface diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..e6616ad --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,109 @@ +# Webhooks (Payment Handler) + +[← API Methods](api-methods.md) · [Back to README](../README.md) · [Telemetry →](telemetry.md) + +The handler trusts a request only when the SHA-256 signature **and** the source IP both +match. Read the verified request from the SDK (`getHandlerMethod()` / `getHandlerParams()`) +rather than from `$_GET` directly. + +```php +checkHandlerRequest(); + + // Read the verified request from the SDK (honors the overridden request, not $_GET) + $method = $unitpay->getHandlerMethod(); + $params = $unitpay->getHandlerParams(); + + // Very important! Validate request with your order data, before complete order + if ( + $params['orderSum'] != $orderSum || + $params['orderCurrency'] != $orderCurrency || + $params['account'] != $orderId || + $params['projectId'] != $projectId + ) { + // logging data and throw exception + throw new InvalidArgumentException('Order validation Error!'); + } + switch ($method) { + // Just check order (check server status, check order in DB and etc) + case 'check': + echo $unitpay->getSuccessHandlerResponse('Check Success. Ready to pay.'); + break; + // Method Pay means that the money received + case 'pay': + // Please complete order + echo $unitpay->getSuccessHandlerResponse('Pay Success'); + break; + // Method Preauth means a two-stage hold: funds are only HELD, not captured yet. + case 'preauth': + // Do NOT deliver goods/services here; wait for 'pay'. Just acknowledge receipt. + echo $unitpay->getSuccessHandlerResponse('Preauth received. Funds held, awaiting capture.'); + break; + // Method Error means that an error has occurred. + case 'error': + // Please log error text. + echo $unitpay->getSuccessHandlerResponse('Error logged'); + break; + // Unknown method: do not leave an empty response (Unitpay would treat it as a failure). + default: + throw new InvalidArgumentException('Unexpected handler method: ' . $method); + } +// Oops! Something went wrong. +} catch (Exception $e) { + echo $unitpay->getErrorHandlerResponse($e->getMessage()); +} +``` + +## Keeping the IP allowlist fresh + +The built-in IP allowlist changes on Unitpay's side from time to time, so keep it fresh +from the published feed instead of waiting for a release: + +* `$unitpay->refreshAllowedIps()` pulls the current list from + `https:///ips/ips_webhooks.json`. It is fail-safe — on any network or parse + error it keeps the built-in list and never throws. It makes a blocking HTTP request, so + **don't call it on every webhook**: run it on a schedule (e.g. a daily cron), cache + `getAllowedIps()`, and feed the cached list back with `setAllowedIps($cached)` in the + handler. +* `$unitpay->addAllowedIps(['1.2.3.4', ...])` adds your own IPs (e.g. a proxy or relay) on + top of the Unitpay list; they persist across `refreshAllowedIps()`. +* `$unitpay->setAllowedIps([...])` replaces the Unitpay list outright. +* Override `getIp()` if you run behind a proxy (the check uses `REMOTE_ADDR`, not the + spoofable `X-Forwarded-For`). + +```php +// Cron: refresh once, cache the result on your side. +$ips = (new UnitPay($domain, $secretKey))->refreshAllowedIps()->getAllowedIps(); +cache_set('unitpay_ips', $ips); + +// Handler: feed the cached list, no network call per callback. +(new UnitPay($domain, $secretKey)) + ->setAllowedIps(cache_get('unitpay_ips')) + ->checkHandlerRequest(); +``` + +## See Also + +* [API Methods](api-methods.md) — the `api()` calls that trigger these callbacks +* [Getting Started](getting-started.md) — create the payments being confirmed here diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..3c677b6 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,51 @@ +# Examples + +Ready-made Unitpay integration scenarios. The examples read `$_GET`/`$_SERVER` and call +`header()`, so they must be served over HTTP, not run from the CLI: + +```sh +php -S localhost:8000 -t examples +# then open e.g. http://localhost:8000/paymentInfo.php +``` + +## Configuration + +Shared data lives in two include files (not runnable on their own): + +- [config.php](config.php) — connection and keys: `domain`, `projectId`, `publicId`, + `secretKey`, plus `login`/`accountSecretKey` for account-level methods. +- [order.php](order.php) — order data: `orderId`, `orderSum`, `orderDesc`, + `orderCurrency`. Included by payment examples in addition to `config.php`. + +Secrets are not stored in code — they are read from the environment (with default placeholders): + +```sh +export UNITPAY_SECRET_KEY=... # project key +export UNITPAY_LOGIN=... # account login (account-level methods) +export UNITPAY_ACCOUNT_SECRET_KEY=... # account key +``` + +## Scenarios + +| File | Scenario | +| --- | --- | +| [paymentForm.php](paymentForm.php) | Unitpay-hosted payment form: `form()` builds the URL to the payment page; fluent setters (`setBackUrl`/`setCustomerEmail`/`setCustomerPhone`). | +| [initPaymentApi.php](initPaymentApi.php) | Server-to-server `initPayment`: handling the `redirect` / `invoice` / `response` reply. | +| [receipt.php](receipt.php) | 54-FZ fiscal receipt: line items via `CashItem` + `setCashItems()`. | +| [webhook.php](webhook.php) | Webhook handler: signature and IP verification, `check`/`pay`/`preauth`/`error` responses. | +| [paymentInfo.php](paymentInfo.php) | Payment info (`getPayment`). | +| [refund.php](refund.php) | Payment refund, full or partial (`refundPayment`). | +| [twoStagePayment.php](twoStagePayment.php) | Two-stage payment: `confirmPayment` (capture) / `cancelPayment` (release). | +| [subscriptions.php](subscriptions.php) | Subscriptions: list, info, close. | +| [payout.php](payout.php) | Payouts (mass-payment) via SBP + status. | +| [accountInfo.php](accountInfo.php) | Reference calls (read-only): balance, commissions, currency rates, BIN, payment methods. | +| [offsetAdvance.php](offsetAdvance.php) | Advance-offset receipt (`offsetAdvance`) — creates a fiscal receipt for a prepayment. | + +## Webhook handler locally + +By default `127.0.0.1` is not trusted. For local debugging of webhook retries from the same +host only, enable it with an explicit flag (and **never** in production): + +```sh +UNITPAY_DEBUG_LOCAL=1 php -S localhost:8000 -t examples +``` diff --git a/examples/accountInfo.php b/examples/accountInfo.php new file mode 100644 index 0000000..0017af4 --- /dev/null +++ b/examples/accountInfo.php @@ -0,0 +1,40 @@ + $login, 'secretKey' => $accountSecretKey]; + +try { + // Account balance and the amount available for withdrawal. + var_dump($unitpay->api('getPartner', $account)->result ?? null); + + // Acquiring commissions for the project. + var_dump($unitpay->api('getCommissions', $account + ['projectId' => $projectId])->result ?? null); + + var_dump($unitpay->api('getCurrencyCourses', $account)->result ?? null); + + // BIN — the first 6 digits of the card number. + var_dump($unitpay->api('getBinInfo', $account + ['bin' => 424242])->result ?? null); + + // Payment methods available on the project: project key, no login. + var_dump($unitpay->api('getMethodsAvailable', ['projectId' => $projectId])->result ?? null); +} catch (UnitpayExceptionInterface $exception) { + print 'SDK error: ' . $exception->getMessage(); +} diff --git a/examples/config.php b/examples/config.php new file mode 100644 index 0000000..4964e62 --- /dev/null +++ b/examples/config.php @@ -0,0 +1,18 @@ +checkHandlerRequest(); - - list($method, $params) = [$_GET['method'], $_GET['params']]; - - // Very important! Validate request with your order data, before complete order - if ( - $params['orderSum'] != $orderSum || - $params['orderCurrency'] != $orderCurrency || - $params['account'] != $orderId || - $params['projectId'] != $projectId - ) { - // logging data and throw exception - throw new InvalidArgumentException('Order validation Error!'); - } - - switch ($method) { - // Just check order (check server status, check order in DB and etc) - case 'check': - print $unitpay->getSuccessHandlerResponse('Check Success. Ready to pay.'); - break; - // Method Pay means that the money received - case 'pay': - // Please complete order - print $unitpay->getSuccessHandlerResponse('Pay Success'); - break; - // Method Error means that an error has occurred. - case 'error': - // Please log error text. - print $unitpay->getSuccessHandlerResponse('Error logged'); - break; - // Method Refund means that the money returned to the client - case 'refund': - // Please cancel the order - print $unitpay->getSuccessHandlerResponse('Order canceled'); - break; - } -// Oops! Something went wrong. -} catch (Exception $e) { - print $unitpay->getErrorHandlerResponse($e->getMessage()); -} diff --git a/examples/initPaymentApi.php b/examples/initPaymentApi.php index a17a186..709b0e8 100644 --- a/examples/initPaymentApi.php +++ b/examples/initPaymentApi.php @@ -8,55 +8,64 @@ * @link https://help.unitpay.ru/payments/create-payment */ -require_once('./orderInfo.php'); -require_once('../UnitPay.php'); +require_once __DIR__ . '/config.php'; +require_once __DIR__ . '/order.php'; +require_once __DIR__ . '/../UnitPay.php'; $unitpay = new UnitPay($domain, $secretKey); /** * Base params: account, desc, sum, currency, projectId, paymentType - * Additional params: - * Qiwi, Mc: - * phone - * alfaClick: - * clientId + * paymentType is a payment method code from the reference (UnitPay::PAYMENT_TYPE_* constants): + * card, cardInvoice, sbp, sberpay, tinkoffpay, paypal, webmoney. * * @link https://help.unitpay.ru/payments/create-payment * @link https://help.unitpay.ru/book-of-reference/payment-system-codes */ -$response = $unitpay->api('initPayment', [ - 'account' => $orderId, - 'desc' => $orderDesc, - 'sum' => $orderSum, - 'paymentType' => 'yandex', - 'currency' => $orderCurrency, - 'projectId' => $projectId, -]); - -// If need user redirect on Payment Gate -if (isset($response->result->type) - && $response->result->type === 'redirect') { - // Url on PaymentGate - $redirectUrl = $response->result->redirectUrl; - // Payment ID in Unitpay (you can save it) - $paymentId = $response->result->paymentId; - // User redirect - header("Location: " . $redirectUrl); - -// If without redirect (invoice) -} elseif (isset($response->result->type) - && $response->result->type === 'invoice') { - // Url on receipt page in Unitpay - $receiptUrl = $response->result->receiptUrl; - // Payment ID in Unitpay (you can save it) - $paymentId = $response->result->paymentId; - // Invoice Id in Payment Gate (you can save it) - $invoiceId = $response->result->invoiceId; - // User redirect - header("Location: " . $receiptUrl); - -// If error during api request -} elseif (isset($response->error->message)) { - $error = $response->error->message; - print 'Error: '.$error; +try { + $response = $unitpay->api('initPayment', [ + 'account' => $orderId, + 'desc' => $orderDesc, + 'sum' => $orderSum, + 'paymentType' => UnitPay::PAYMENT_TYPE_CARD, + 'currency' => $orderCurrency, + 'projectId' => $projectId, + ]); + + // The initPayment response comes in three types: redirect, invoice, response. + switch ($response->result->type ?? null) { + case 'redirect': + // paymentId is in $response->result->paymentId; save it on your side. + if (isset($response->result->redirectUrl)) { + header('Location: ' . $response->result->redirectUrl); + exit; + } + var_dump($response); + break; + + case 'invoice': + // Besides receiptUrl, $response->result->paymentId and ->invoiceId are available. + if (isset($response->result->receiptUrl)) { + header('Location: ' . $response->result->receiptUrl); + exit; + } + var_dump($response); + break; + + case 'response': + // No redirect (e.g. a recurring charge); the status is in ->statusUrl. + print $response->result->message ?? ''; + break; + + default: + // No type — usually an API-level error. + if (isset($response->error->message)) { + print 'Error: ' . $response->error->message; + } else { + var_dump($response); + } + } +} catch (UnitpayExceptionInterface $exception) { + // SDK-side failure: network, disabled allow_url_fopen, malformed JSON, etc. + print 'SDK error: ' . $exception->getMessage(); } diff --git a/examples/initPaymentForm.php b/examples/initPaymentForm.php deleted file mode 100644 index 2c743b0..0000000 --- a/examples/initPaymentForm.php +++ /dev/null @@ -1,22 +0,0 @@ -form( - $publicId, - $orderSum, - $orderId, - $orderDesc, - $orderCurrency -); - -header("Location: " . $redirectUrl); diff --git a/examples/offsetAdvance.php b/examples/offsetAdvance.php new file mode 100644 index 0000000..ec584eb --- /dev/null +++ b/examples/offsetAdvance.php @@ -0,0 +1,23 @@ + $login, 'secretKey' => $accountSecretKey]; + +try { + $response = $unitpay->api('offsetAdvance', $account + ['paymentId' => 3403575]); + var_dump($response->result ?? $response->error ?? $response); +} catch (UnitpayExceptionInterface $exception) { + print 'SDK error: ' . $exception->getMessage(); +} diff --git a/examples/order.php b/examples/order.php new file mode 100644 index 0000000..84143cc --- /dev/null +++ b/examples/order.php @@ -0,0 +1,13 @@ +setBackUrl('https://example.com/order/' . $orderId) + ->setCustomerEmail('customer@example.com') + ->setCustomerPhone('79000000000') + ->form( + $publicId, + $orderSum, + $orderId, + $orderDesc, + $orderCurrency + ); + + header("Location: " . $redirectUrl); + exit; +} catch (UnitpayExceptionInterface $exception) { + print 'SDK error: ' . $exception->getMessage(); +} diff --git a/examples/paymentInfo.php b/examples/paymentInfo.php index c58eb02..960f311 100644 --- a/examples/paymentInfo.php +++ b/examples/paymentInfo.php @@ -8,22 +8,21 @@ * @link https://help.unitpay.ru/payments/payment-info */ -require_once('./orderInfo.php'); -require_once('../UnitPay.php'); +require_once __DIR__ . '/config.php'; +require_once __DIR__ . '/../UnitPay.php'; $unitpay = new UnitPay($domain, $secretKey); -$response = $unitpay->api('getPayment', [ - 'paymentId' => 3403575 -]); +try { + $response = $unitpay->api('getPayment', [ + 'paymentId' => 3403575 + ]); -// If need user redirect on Payment Gate -if (isset($response->result)) { - // Payment Info - $paymentInfo = $response->result; - var_dump($paymentInfo); -// If error during api request -} elseif (isset($response->error->message)) { - $error = $response->error->message; - print 'Error: '.$error; + if (isset($response->result)) { + var_dump($response->result); + } elseif (isset($response->error->message)) { + print 'Error: ' . $response->error->message; + } +} catch (UnitpayExceptionInterface $exception) { + print 'SDK error: ' . $exception->getMessage(); } diff --git a/examples/payout.php b/examples/payout.php new file mode 100644 index 0000000..c6a1cd4 --- /dev/null +++ b/examples/payout.php @@ -0,0 +1,49 @@ + $login, 'secretKey' => $accountSecretKey]; +$transactionId = 'payout-1782'; // unique on your side + +try { + // SBP member banks: memberId is required for SBP payouts. + $banks = $unitpay->api('getSbpBankList', $account); + var_dump($banks->result ?? $banks->error ?? $banks); + + // Create a payout to the recipient via SBP. + $response = $unitpay->api('massPayment', $account + [ + 'transactionId' => $transactionId, + 'sum' => 100, + 'purse' => '79510000071', + 'paymentType' => 'sbp', + 'memberId' => '100000000004', // from getSbpBankList; SBP only + ]); + + if (isset($response->result)) { + $payoutId = $response->result->payoutId; + $status = $response->result->status; // success | not_completed + + // Later — check the payout status by your transactionId. + $info = $unitpay->api('massPaymentStatus', $account + ['transactionId' => $transactionId]); + var_dump($info->result ?? $info->error ?? $info); + } elseif (isset($response->error->message)) { + print 'Error: ' . $response->error->message; + } +} catch (UnitpayExceptionInterface $exception) { + print 'SDK error: ' . $exception->getMessage(); +} diff --git a/examples/receipt.php b/examples/receipt.php new file mode 100644 index 0000000..75a7cdf --- /dev/null +++ b/examples/receipt.php @@ -0,0 +1,75 @@ +setMeasure(CashItem::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 +); + +try { + // The payment sum must match the sum of the receipt line items: 900 + 150 = 1050. + $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, + ]); + + // The same receipt can also be attached to the payment form: + // $url = $unitpay->setCashItems([$item, $delivery]) + // ->form($publicId, 1050, $orderId, $orderDesc, $orderCurrency); + + if (isset($response->result->redirectUrl)) { + header('Location: ' . $response->result->redirectUrl); + exit; + } elseif (isset($response->error->message)) { + print 'Error: ' . $response->error->message; + } else { + var_dump($response); + } +} catch (UnitpayExceptionInterface $exception) { + // UnitpayValidationException if a line-item name is not UTF-8 (json_encode returns false). + print 'SDK error: ' . $exception->getMessage(); +} diff --git a/examples/refund.php b/examples/refund.php new file mode 100644 index 0000000..a9803f9 --- /dev/null +++ b/examples/refund.php @@ -0,0 +1,29 @@ +api('refundPayment', [ + 'paymentId' => 3403575, + // 'sum' => 100, // optional: partial refund; omit for a full refund + ]); + + if (isset($response->result->message)) { + print $response->result->message; + } elseif (isset($response->error->message)) { + print 'Error: ' . $response->error->message; + } +} catch (UnitpayExceptionInterface $exception) { + print 'SDK error: ' . $exception->getMessage(); +} diff --git a/examples/subscriptions.php b/examples/subscriptions.php new file mode 100644 index 0000000..0f578df --- /dev/null +++ b/examples/subscriptions.php @@ -0,0 +1,37 @@ + 1 to include all statuses). + $list = $unitpay->api('listSubscriptions', ['projectId' => $projectId]); + var_dump($list->result ?? $list->error ?? $list); + + $info = $unitpay->api('getSubscription', ['subscriptionId' => $subscriptionId]); + var_dump($info->result ?? $info->error ?? $info); + + // Close it (stops charges, detaches the card — irreversible). + $closed = $unitpay->api('closeSubscription', ['subscriptionId' => $subscriptionId]); + if (isset($closed->result->message)) { + print $closed->result->message; + } elseif (isset($closed->error->message)) { + print 'Error: ' . $closed->error->message; + } +} catch (UnitpayExceptionInterface $exception) { + print 'SDK error: ' . $exception->getMessage(); +} diff --git a/examples/twoStagePayment.php b/examples/twoStagePayment.php new file mode 100644 index 0000000..db211c9 --- /dev/null +++ b/examples/twoStagePayment.php @@ -0,0 +1,35 @@ +message`. + * + * @link https://help.unitpay.ru/api/confirm-payment + * @link https://help.unitpay.ru/api/cancel-payment + */ + +require_once __DIR__ . '/config.php'; +require_once __DIR__ . '/../UnitPay.php'; + +$unitpay = new UnitPay($domain, $secretKey); + +$paymentId = 3403575; + +try { + // Capture the held funds. + $response = $unitpay->api('confirmPayment', ['paymentId' => $paymentId]); + + // ...or release without capturing. + // $response = $unitpay->api('cancelPayment', ['paymentId' => $paymentId]); + + if (isset($response->message)) { + print $response->message; + } elseif (isset($response->error->message)) { + print 'Error: ' . $response->error->message; + } +} catch (UnitpayExceptionInterface $exception) { + print 'SDK error: ' . $exception->getMessage(); +} diff --git a/examples/webhook.php b/examples/webhook.php new file mode 100644 index 0000000..549698f --- /dev/null +++ b/examples/webhook.php @@ -0,0 +1,78 @@ +refreshAllowedIps()->getAllowedIps(); +// then pass the cached list here, plus your own IPs (proxy/relay): +// $unitpay->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). +// 127.0.0.1 is untrusted by default — behind a proxy on the same host REMOTE_ADDR equals +// 127.0.0.1 and would nullify the IP check — so enable this with an explicit flag and NEVER +// enable it in production. +if (getenv('UNITPAY_DEBUG_LOCAL') === '1') { + $unitpay->addAllowedIps(['127.0.0.1']); +} + +try { + // Verify the request (sender IP, signature, supported method). + $unitpay->checkHandlerRequest(); + + // Read the verified request from the SDK (honors the overridden request, not $_GET). + $method = $unitpay->getHandlerMethod(); + $params = $unitpay->getHandlerParams(); + + // Very important: reconcile the webhook against your order data before completing the order. + if ( + ($params['orderSum'] ?? null) != $orderSum || + ($params['orderCurrency'] ?? null) != $orderCurrency || + ($params['account'] ?? null) != $orderId || + ($params['projectId'] ?? null) != $projectId + ) { + throw new InvalidArgumentException('Order validation Error!'); + } + + 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.'); + break; + case 'pay': + // 'pay' — money received; complete the order here. + print $unitpay->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.'); + break; + case 'error': + // 'error' — an error occurred; log it. + print $unitpay->getSuccessHandlerResponse('Error logged'); + break; + default: + // Unknown method: do not leave an empty response (Unitpay would treat it as a + // failure with no diagnostics) — return an error via the shared catch below. + throw new InvalidArgumentException('Unexpected handler method: ' . $method); + } +} catch (Exception $exception) { + // Any error (wrong signature, disallowed IP, order mismatch) returns an error to Unitpay. + print $unitpay->getErrorHandlerResponse($exception->getMessage()); +} diff --git a/phpmd.xml b/phpmd.xml new file mode 100644 index 0000000..d6f0ad7 --- /dev/null +++ b/phpmd.xml @@ -0,0 +1,82 @@ + + + + + Curated PHPMD ruleset for the single-file, global-namespace Unitpay SDK. + A few default rules are excluded or retuned because they conflict with the + SDK's deliberate design rather than pointing at real problems. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..3e84653 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 6 + paths: + - UnitPay.php + - tests diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..ab754e3 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,12 @@ + + + + + tests + + + diff --git a/tests/CashItemTest.php b/tests/CashItemTest.php new file mode 100644 index 0000000..f264228 --- /dev/null +++ b/tests/CashItemTest.php @@ -0,0 +1,143 @@ +assertSame('Coffee', $item->getName()); + $this->assertSame(2, $item->getCount()); + $this->assertSame(150.5, $item->getPrice()); + $this->assertSame(CashItem::NDS_NONE, $item->getNds()); + $this->assertSame(CashItem::PAYMENT_OBJECT_COMMODITY, $item->getType()); + $this->assertSame(CashItem::PAYMENT_METHOD_PREPAYMENT_FULL, $item->getPaymentMethod()); + } + + public function testConstructorAcceptsExplicitFiscalFields(): void + { + $item = new CashItem( + 'Service', + 1, + 999.99, + CashItem::NDS_20, + CashItem::PAYMENT_OBJECT_SERVICE, + CashItem::PAYMENT_METHOD_PAYMENT_FULL + ); + + $this->assertSame(CashItem::NDS_20, $item->getNds()); + $this->assertSame(CashItem::PAYMENT_OBJECT_SERVICE, $item->getType()); + $this->assertSame(CashItem::PAYMENT_METHOD_PAYMENT_FULL, $item->getPaymentMethod()); + } + + public function testOptionalGettersDefaultToNull(): void + { + $item = new CashItem('X', 1, 1.0); + + $this->assertNull($item->getSum()); + $this->assertNull($item->getCurrency()); + $this->assertNull($item->getMeasure()); + $this->assertNull($item->getNomenclatureCode()); + $this->assertNull($item->getMarkCode()); + $this->assertNull($item->getMarkQuantity()); + $this->assertNull($item->getPreText()); + $this->assertNull($item->getPostText()); + } + + public function testFluentSettersReturnSelfAndStoreValues(): void + { + $item = new CashItem('X', 1, 1.0); + + $this->assertSame($item, $item->setSum(100.5)); + $this->assertSame($item, $item->setCurrency('USD')); + $this->assertSame($item, $item->setMeasure(CashItem::MEASURE_KG)); + $this->assertSame($item, $item->setNomenclatureCode('04620034587217')); + $this->assertSame($item, $item->setMarkCode('mark-1')); + $this->assertSame($item, $item->setPreText('before')); + $this->assertSame($item, $item->setPostText('after')); + + $this->assertSame(100.5, $item->getSum()); + $this->assertSame('USD', $item->getCurrency()); + $this->assertSame(CashItem::MEASURE_KG, $item->getMeasure()); + $this->assertSame('04620034587217', $item->getNomenclatureCode()); + $this->assertSame('mark-1', $item->getMarkCode()); + $this->assertSame('before', $item->getPreText()); + $this->assertSame('after', $item->getPostText()); + } + + public function testSetMarkQuantityStoresIntegerFraction(): void + { + $item = new CashItem('X', 1, 1.0); + + $this->assertSame($item, $item->setMarkQuantity(1, 3)); + $this->assertSame(['numerator' => 1, 'denominator' => 3], $item->getMarkQuantity()); + } + + /** A zero denominator (or a non-positive fraction) is rejected rather than silently stored. */ + public function testSetMarkQuantityRejectsNonPositiveValues(): void + { + $item = new CashItem('X', 1, 1.0); + + $this->expectException(\InvalidArgumentException::class); + $item->setMarkQuantity(1, 0); + } + + /** A non-positive numerator is rejected by a separate check (not just the denominator). */ + public function testSetMarkQuantityRejectsNonPositiveNumerator(): void + { + $item = new CashItem('X', 1, 1.0); + + $this->expectException(\InvalidArgumentException::class); + $item->setMarkQuantity(0, 3); + } + + /** count must be a positive number. */ + public function testConstructorRejectsNonPositiveCount(): void + { + $this->expectException(\InvalidArgumentException::class); + new CashItem('X', 0, 10.0); + } + + /** price must be non-negative. */ + public function testConstructorRejectsNegativePrice(): void + { + $this->expectException(\InvalidArgumentException::class); + new CashItem('X', 1, -5.0); + } + + /** A non-numeric count must be rejected rather than slip past the range check. */ + public function testConstructorRejectsNonNumericCount(): void + { + $this->expectException(\InvalidArgumentException::class); + new CashItem('X', 'abc', 10.0); + } + + /** A non-numeric price must be rejected rather than slip past the range check. */ + public function testConstructorRejectsNonNumericPrice(): void + { + $this->expectException(\InvalidArgumentException::class); + new CashItem('X', 1, 'xyz'); + } + + /** Numeric strings are accepted and normalized to int/float. */ + public function testConstructorNormalizesNumericStrings(): void + { + $item = new CashItem('X', '3', '9.5'); + + $this->assertSame(3, $item->getCount()); + $this->assertSame(9.5, $item->getPrice()); + } + + /** Fractional quantities (weight/volume goods) are preserved rather than truncated to int. */ + public function testConstructorPreservesFractionalCount(): void + { + $item = new CashItem('Cheese', 1.5, 500.0); + + $this->assertSame(1.5, $item->getCount()); + } +} diff --git a/tests/UnitPayAllowedIpsTest.php b/tests/UnitPayAllowedIpsTest.php new file mode 100644 index 0000000..330cb1f --- /dev/null +++ b/tests/UnitPayAllowedIpsTest.php @@ -0,0 +1,265 @@ +/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 new file mode 100644 index 0000000..407e43c --- /dev/null +++ b/tests/UnitPayApiTest.php @@ -0,0 +1,328 @@ +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/UnitPayCashItemsTest.php b/tests/UnitPayCashItemsTest.php new file mode 100644 index 0000000..1bd3abe --- /dev/null +++ b/tests/UnitPayCashItemsTest.php @@ -0,0 +1,116 @@ +> + */ + private function serializedItems(UnitPay $unitPay): array + { + $url = $unitPay->form('pk', 1, 'acc', 'desc'); + parse_str((string) parse_url($url, PHP_URL_QUERY), $q); + + return json_decode(base64_decode($q['cashItems']), true); + } + + public function testRequiredFieldsAreAlwaysSerialized(): void + { + $unitPay = new UnitPay('unitpay.ru', 'secret'); + $unitPay->setCashItems([ + new CashItem( + 'Coffee', + 2, + 150.5, + CashItem::NDS_20, + CashItem::PAYMENT_OBJECT_COMMODITY, + CashItem::PAYMENT_METHOD_PAYMENT_FULL + ), + ]); + + $items = $this->serializedItems($unitPay); + + $this->assertCount(1, $items); + $this->assertSame('Coffee', $items[0]['name']); + $this->assertSame(2, $items[0]['count']); + $this->assertSame(150.5, $items[0]['price']); + $this->assertSame('vat20', $items[0]['nds']); + $this->assertSame('commodity', $items[0]['type']); + $this->assertSame('full_payment', $items[0]['paymentMethod']); + } + + public function testOptionalFieldsAreOmittedWhenNotSet(): void + { + $unitPay = new UnitPay('unitpay.ru', 'secret'); + $unitPay->setCashItems([new CashItem('X', 1, 10.0)]); + + $items = $this->serializedItems($unitPay); + + foreach (['sum', 'currency', 'measure', 'nomenclatureCode', 'markCode', 'markQuantity', 'pre_text', 'post_text'] as $optional) { + $this->assertArrayNotHasKey($optional, $items[0], "Optional key '$optional' must be absent when unset"); + } + } + + public function testOptionalFieldsAreSerializedWhenSet(): void + { + $item = new CashItem('Y', 1, 10.5); + $item->setSum(10.5) + ->setCurrency('USD') + ->setMeasure(CashItem::MEASURE_KG) + ->setNomenclatureCode('NC-1') + ->setMarkCode('MC-1') + ->setPreText('pre') + ->setPostText('post') + ->setMarkQuantity(1, 2); + + $unitPay = new UnitPay('unitpay.ru', 'secret'); + $unitPay->setCashItems([$item]); + + $items = $this->serializedItems($unitPay); + + $this->assertSame(10.5, $items[0]['sum']); + $this->assertSame('USD', $items[0]['currency']); + $this->assertSame(11, $items[0]['measure']); + $this->assertSame('NC-1', $items[0]['nomenclatureCode']); + $this->assertSame('MC-1', $items[0]['markCode']); + $this->assertSame(['numerator' => 1, 'denominator' => 2], $items[0]['markQuantity']); + $this->assertSame('pre', $items[0]['pre_text']); + $this->assertSame('post', $items[0]['post_text']); + } + + public function testMultipleItemsKeepTheirOrder(): void + { + $unitPay = new UnitPay('unitpay.ru', 'secret'); + $unitPay->setCashItems([ + new CashItem('A', 1, 1.5), + new CashItem('B', 2, 2.5), + ]); + + $items = $this->serializedItems($unitPay); + + $this->assertCount(2, $items); + $this->assertSame('A', $items[0]['name']); + $this->assertSame('B', $items[1]['name']); + } + + /** + * A non-UTF-8 name (e.g. from Windows-1251) breaks json_encode; setCashItems() + * throws instead of silently sending an empty receipt. + */ + public function testSetCashItemsThrowsOnNonUtf8Name(): void + { + $unitPay = new UnitPay('unitpay.ru', 'secret'); + + $this->expectException(\UnitpayValidationException::class); + $this->expectExceptionMessage('Failed to encode cashItems'); + $unitPay->setCashItems([new CashItem("\xB0Coffee", 1, 100.0)]); + } +} diff --git a/tests/UnitPayFloatTest.php b/tests/UnitPayFloatTest.php new file mode 100644 index 0000000..bb72be2 --- /dev/null +++ b/tests/UnitPayFloatTest.php @@ -0,0 +1,101 @@ +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 new file mode 100644 index 0000000..f4e2f63 --- /dev/null +++ b/tests/UnitPayFormTest.php @@ -0,0 +1,149 @@ + + */ + 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/UnitPayHandlerTest.php b/tests/UnitPayHandlerTest.php new file mode 100644 index 0000000..e479643 --- /dev/null +++ b/tests/UnitPayHandlerTest.php @@ -0,0 +1,231 @@ + $overrides params to add/override before signing + * @return array{method: string, params: array} + */ + private function validRequest(string $method = 'pay', array $overrides = []): array + { + $params = array_merge([ + 'account' => '42', + 'orderSum' => '100.00', + 'orderCurrency' => 'RUB', + 'date' => '2026-07-20 12:00:00', + 'payerSum' => '100.00', + 'unitpayId' => '999', + ], $overrides); + + $params['signature'] = $this->sign($params, $method); + + return ['method' => $method, 'params' => $params]; + } + + /** + * @param array $params + */ + private function sign(array $params, string $method): string + { + return (new UnitPay('unitpay.ru', self::SECRET))->getSignature($params, $method); + } + + /** + * @param array $request + */ + private function handler(array $request, string $ip = self::ALLOWED_IP, ?string $secret = self::SECRET): UnitPay + { + return new UnitPay('unitpay.ru', $secret, null, $request, $ip); + } + + public function testValidSignatureAndAllowedIpPass(): void + { + $this->assertTrue($this->handler($this->validRequest('pay'))->checkHandlerRequest()); + } + + public function testTamperedParamsAreRejected(): void + { + $request = $this->validRequest('pay'); + $request['params']['orderSum'] = '0.01'; // changed after signing + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Wrong signature'); + $this->handler($request)->checkHandlerRequest(); + } + + public function testDisallowedIpIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('IP address Error'); + $this->handler($this->validRequest('pay'), '8.8.8.8')->checkHandlerRequest(); + } + + public function testEmptySecretIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('SecretKey is null'); + $this->handler($this->validRequest('pay'), self::ALLOWED_IP, null)->checkHandlerRequest(); + } + + public function testMissingMethodIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Method is null'); + $this->handler(['params' => ['x' => '1']])->checkHandlerRequest(); + } + + public function testMissingParamsIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Params is null'); + $this->handler(['method' => 'pay'])->checkHandlerRequest(); + } + + public function testUnsupportedPartnerMethodIsRejected(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Method is not supported'); + $this->handler($this->validRequest('refund'))->checkHandlerRequest(); + } + + /** + * A two-stage hold notification (preauth) is a valid webhook method that Unitpay + * sends (method = check | pay | preauth | error). It must pass verification rather + * than be rejected as unsupported. + */ + public function testPreauthPartnerMethodIsSupported(): void + { + $request = $this->validRequest('preauth', ['isPreauth' => '1']); + + $unitPay = $this->handler($request); + + $this->assertTrue($unitPay->checkHandlerRequest()); + $this->assertSame('preauth', $unitPay->getHandlerMethod()); + } + + /** + * A non-string signature (e.g. an array injected via $_GET) must be cleanly rejected + * as "Wrong signature" rather than cause a TypeError. + */ + public function testArraySignatureIsRejectedCleanly(): void + { + $request = $this->validRequest('pay'); + $request['params']['signature'] = ['not', 'a', 'string']; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Wrong signature'); + $this->handler($request)->checkHandlerRequest(); + } + + /** + * A forged params[PHP_INT_MAX] must not break verification and must pass it correctly + * (the key is stripped both when signing and when verifying). + */ + public function testPhpIntMaxKeyInParamsDoesNotBreakVerification(): void + { + $params = [ + 'account' => '42', + 'orderSum' => '100.00', + 'orderCurrency' => 'RUB', + ]; + $params[PHP_INT_MAX] = 'injected'; + $params['signature'] = $this->sign($params, 'pay'); + + $request = ['method' => 'pay', 'params' => $params]; + + $this->assertTrue($this->handler($request)->checkHandlerRequest()); + } + + public function testSetAllowedIpsOverridesTheDefaultAllowlist(): void + { + $customIp = '203.0.113.7'; // TEST-NET-3, not in the default list + $unitPay = $this->handler($this->validRequest('pay'), $customIp); + $unitPay->setAllowedIps([$customIp]); + + $this->assertTrue($unitPay->checkHandlerRequest()); + } + + /** 127.0.0.1 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'); + + $this->expectException(\UnitpayIpException::class); + $unitPay->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']); + + $this->assertTrue($unitPay->checkHandlerRequest()); + } + + public function testCidrAllowlistRejectsAddressOutOfRange(): void + { + $unitPay = $this->handler($this->validRequest('pay'), '203.0.114.1'); + $unitPay->setAllowedIps(['203.0.113.0/24']); + + $this->expectException(\UnitpayIpException::class); + $unitPay->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']); + + $this->assertTrue($unitPay->checkHandlerRequest()); + } + + /** Before the first successful verification, the verified-data getters return null. */ + public function testHandlerGettersAreNullBeforeVerification(): void + { + $unitPay = $this->handler($this->validRequest('pay')); + + $this->assertNull($unitPay->getHandlerMethod()); + $this->assertNull($unitPay->getHandlerParams()); + } + + /** After a successful verification, getHandlerParams() returns exactly the verified webhook params. */ + public function testGetHandlerParamsReturnsVerifiedParams(): void + { + $request = $this->validRequest('pay'); + $unitPay = $this->handler($request); + + $this->assertTrue($unitPay->checkHandlerRequest()); + $this->assertSame($request['params'], $unitPay->getHandlerParams()); + $this->assertSame('42', $unitPay->getHandlerParams()['account']); + } + + /** A typed exception that still extends the historical SPL type + the marker interface. */ + public function testSignatureFailureThrowsTypedExceptionStillCatchableAsInvalidArgument(): void + { + $request = $this->validRequest('pay'); + $request['params']['orderSum'] = '0.01'; // changed after signing + + try { + $this->handler($request)->checkHandlerRequest(); + $this->fail('expected a signature exception'); + } catch (\UnitpaySignatureException $e) { + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertInstanceOf(\UnitpayExceptionInterface::class, $e); + } + } +} diff --git a/tests/UnitPayPaymentTypeTest.php b/tests/UnitPayPaymentTypeTest.php new file mode 100644 index 0000000..a28812b --- /dev/null +++ b/tests/UnitPayPaymentTypeTest.php @@ -0,0 +1,45 @@ +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/UnitPayResponseTest.php b/tests/UnitPayResponseTest.php new file mode 100644 index 0000000..bf28a0e --- /dev/null +++ b/tests/UnitPayResponseTest.php @@ -0,0 +1,32 @@ +unitPay = new UnitPay('unitpay.ru', 'secret'); + } + + public function testSuccessHandlerResponseShape(): void + { + $this->assertSame( + '{"result":{"message":"ok"}}', + $this->unitPay->getSuccessHandlerResponse('ok') + ); + } + + public function testErrorHandlerResponseShape(): void + { + $this->assertSame( + '{"error":{"message":"bad"}}', + $this->unitPay->getErrorHandlerResponse('bad') + ); + } +} diff --git a/tests/UnitPaySignatureTest.php b/tests/UnitPaySignatureTest.php new file mode 100644 index 0000000..8d38ea1 --- /dev/null +++ b/tests/UnitPaySignatureTest.php @@ -0,0 +1,118 @@ +unitPay = new UnitPay('unitpay.ru', 'secret'); + } + + /** + * Defense-in-depth: getSignature() is public, so a direct call with no secret must + * throw rather than silently hash with an empty secret (the appended null coerces to + * '' and drops out, yielding a plausible but secret-less signature). + */ + public function testEmptySecretIsRejected(): void + { + $unitPay = new UnitPay('unitpay.ru', null); + + $this->expectException(UnitpayValidationException::class); + $this->expectExceptionMessage('SecretKey is null'); + $unitPay->getSignature(['a' => '1']); + } + + public function testSignatureMatchesDocumentedFormula(): void + { + // sha256( {up}secretKey ) + $this->assertSame( + hash('sha256', '1{up}secret'), + $this->unitPay->getSignature(['a' => '1']) + ); + } + + public function testSignatureIsIndependentOfKeyOrder(): void + { + $this->assertSame( + $this->unitPay->getSignature(['a' => '1', 'b' => '2']), + $this->unitPay->getSignature(['b' => '2', 'a' => '1']) + ); + } + + /** + * Pins the sort DIRECTION with a concrete value: ksort sorts by key ascending, so + * keys c,a,b yield values 1,2,3. Refactoring to krsort/asort would change this hash + * and break every production signature with multiple params — this test catches that. + */ + public function testSignaturePinsAscendingKeyOrder(): void + { + $this->assertSame( + hash('sha256', 'pay{up}1{up}2{up}3{up}secret'), + $this->unitPay->getSignature(['c' => '3', 'a' => '1', 'b' => '2'], 'pay') + ); + } + + public function testMethodIsPrependedToPayload(): void + { + $this->assertSame( + hash('sha256', 'pay{up}1{up}secret'), + $this->unitPay->getSignature(['a' => '1'], 'pay') + ); + $this->assertNotSame( + $this->unitPay->getSignature(['a' => '1']), + $this->unitPay->getSignature(['a' => '1'], 'pay') + ); + } + + public function testCallerSuppliedSignatureKeysAreStripped(): void + { + $this->assertSame( + $this->unitPay->getSignature(['a' => '1']), + $this->unitPay->getSignature(['a' => '1', 'sign' => 'x', 'signature' => 'y']) + ); + } + + /** + * Regression test: a forged params[PHP_INT_MAX] must be stripped so it cannot push the + * automatically appended secretKey out of the hash (forgeable signature on PHP <8, a + * fatal Error on PHP >=8). It must not throw, and the resulting signature must match + * the signature without the malicious key. + */ + public function testPhpIntMaxKeyIsStrippedAndSecretRetained(): void + { + $this->assertSame( + $this->unitPay->getSignature(['a' => '1']), + $this->unitPay->getSignature([PHP_INT_MAX => 'evil', 'a' => '1']) + ); + } + + /** + * An injected array value (e.g. a webhook params[x][]=1) must not raise an + * "Array to string conversion" warning; the array is coerced to '', and verification + * simply does not match a legitimate signature. + */ + public function testArrayValuedParamDoesNotEmitWarning(): void + { + set_error_handler(static function ($errno, $errstr) { + throw new \RuntimeException($errstr, $errno); + }); + try { + $signature = $this->unitPay->getSignature(['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'), + $signature + ); + } +} diff --git a/tests/UnitPayTelemetryTest.php b/tests/UnitPayTelemetryTest.php new file mode 100644 index 0000000..d1e6404 --- /dev/null +++ b/tests/UnitPayTelemetryTest.php @@ -0,0 +1,54 @@ +}> $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/UnitpayIpAllowlistTest.php b/tests/UnitpayIpAllowlistTest.php new file mode 100644 index 0000000..f1707ab --- /dev/null +++ b/tests/UnitpayIpAllowlistTest.php @@ -0,0 +1,130 @@ +assertTrue($this->matcher()->contains('31.186.100.49')); + } + + public function testUnlistedIpv4AddressDoesNotMatch(): void + { + $this->assertFalse($this->matcher()->contains('8.8.8.8')); + } + + public function testAddressInsideIpv4CidrMatches(): void + { + $this->assertTrue($this->matcher()->contains('203.0.113.55')); + } + + public function testAddressOutsideIpv4CidrDoesNotMatch(): void + { + $this->assertFalse($this->matcher()->contains('203.0.114.1')); + } + + public function testAddressInsideIpv6CidrMatches(): void + { + $this->assertTrue($this->matcher()->contains('2001:db8::1')); + } + + /** + * An exact IPv6 entry matches regardless of textual form (case, compression): + * the comparison is over the packed in_addr, not the string. + */ + public function testExactIpv6MatchesRegardlessOfTextualForm(): void + { + $upper = new UnitpayIpAllowlist(['2001:DB8::1']); + $this->assertTrue($upper->contains('2001:db8::1')); + + $expanded = new UnitpayIpAllowlist(['2001:db8:0:0:0:0:0:1']); + $this->assertTrue($expanded->contains('2001:db8::1')); + } + + /** A malformed client IP must not produce a false match. */ + public function testInvalidClientIpDoesNotMatch(): void + { + $this->assertFalse($this->matcher()->contains('not-an-ip')); + } + + /** + * An IPv4 client against an IPv6-only subnet: inet_pton yields in_addr of different + * lengths, so the comparison must fail cleanly rather than match by mistake. + */ + public function testIpv4ClientAgainstIpv6OnlySubnetDoesNotMatch(): void + { + $matcher = new UnitpayIpAllowlist(['2001:db8::/32']); + + $this->assertFalse($matcher->contains('203.0.113.55')); + } + + /** 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']); + + $this->assertFalse($matcher->contains('203.0.113.5')); + } + + /** /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']); + + $this->assertTrue($matcher->contains('77.75.153.127')); + $this->assertFalse($matcher->contains('77.75.153.128')); + } + + // --- parseWebhooksFeed() --------------------------------------------------- + + public function testParseWebhooksFeedReturnsDedupedList(): void + { + $body = json_encode(['webhooks' => ['1.1.1.1', '1.1.1.1', '2.2.2.2']]); + + $this->assertSame(['1.1.1.1', '2.2.2.2'], UnitpayIpAllowlist::parseWebhooksFeed($body)); + } + + public function testParseWebhooksFeedKeepsOnlyValidEntries(): void + { + $body = 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)); + } + + /** + * @dataProvider unusableFeeds + */ + public function testParseWebhooksFeedReturnsNullForUnusableInput(string $body): void + { + $this->assertNull(UnitpayIpAllowlist::parseWebhooksFeed($body)); + } + + /** + * @return array + */ + public function unusableFeeds(): array + { + return [ + '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"]}'], + ]; + } +}