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