From ac53158094c090dbf8d56536e17a7cac5faf9a9e Mon Sep 17 00:00:00 2001 From: IvanKirsanov Date: Mon, 24 Aug 2026 16:52:38 +0700 Subject: [PATCH 1/2] fix(1029): changes for future refinement --- .github/workflows/tests.yml | 40 +++ README.md | 159 ++++++++---- composer.json | 2 +- examples/get_invoice_information.php | 31 +-- examples/send_payment_curl.php | 2 + examples/send_payment_jwt.php | 2 +- src/.DS_Store | Bin 6148 -> 0 bytes src/Client/HttpClient.php | 71 +++--- src/Client/HttpClientInterface.php | 8 + src/Client/Response.php | 15 ++ src/Robokassa.php | 159 +++++++++--- src/Service/PaymentService.php | 209 +++++++++++---- src/Service/ReceiptService.php | 66 ++++- src/Service/StatusService.php | 198 +++++++++++---- src/Service/WebService.php | 59 ++++- src/Signature/HashAlgorithmResolver.php | 78 ++++++ src/Signature/SignatureService.php | 125 ++++++--- tests/DummyClient.php | 33 ++- tests/ExamplesTest.php | 324 ++++++++++++++++++------ 19 files changed, 1202 insertions(+), 379 deletions(-) create mode 100644 .github/workflows/tests.yml delete mode 100644 src/.DS_Store create mode 100644 src/Client/HttpClientInterface.php create mode 100644 src/Client/Response.php create mode 100644 src/Signature/HashAlgorithmResolver.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a4b1fca --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,40 @@ +name: Tests + +on: + push: + pull_request: + +jobs: + phpunit: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php-version: + - '7.4' + - '8.0' + - '8.1' + - '8.2' + - '8.3' + - '8.4' + - '8.5' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + + - name: Validate Composer files + run: composer validate --strict + + - name: Install dependencies + run: composer install --no-interaction --no-progress --prefer-dist + + - name: Run PHPUnit + run: vendor/bin/phpunit diff --git a/README.md b/README.md index f5b5c95..28fdeb1 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,136 @@ -# Robokassa SDK (PHP) +# Robokassa SDK для PHP -SDK для интеграции с платёжной системой **Robokassa** на PHP. -Позволяет отправлять платёжные запросы (включая JWT), проверять статус платежа и получать доступные методы оплаты. +SDK для интеграции с платёжной системой Robokassa на PHP. -## 📦 Установка +Текущий основной способ создания платёжной ссылки — `payment()->sendJwt()`. Старый метод `payment()->sendCurl()` сохранён только для обратной совместимости. -Установите SDK через **Composer**: +## Установка ```sh composer require robokassa/sdk-php -```` +``` + +## Создание клиента -## 🚀 Доступные методы +```php +sendJwt(array $params): string` | ✅ Рекомендуемый способ. Создаёт ссылку на оплату через JWT-интерфейс | [docs.robokassa.ru/ru/invoice-api](https://docs.robokassa.ru/ru/invoice-api) | -| `payment()->sendCurl(array $params): string` | Создаёт ссылку на оплату через стандартный интерфейс | — | -| `webService()->getPaymentMethods(string $lang = 'ru'): array` | Получает список доступных методов оплаты | [docs.robokassa.ru/xml-interfaces/#currency](https://docs.robokassa.ru/xml-interfaces/#currency) | -| `webService()->opState(int $invoiceID): array` | Получает статус оплаты по `InvoiceID` | [docs.robokassa.ru/xml-interfaces/#account](https://docs.robokassa.ru/xml-interfaces/#account) | -| `status()->getInvoiceInformationList(array $filters): array` | Получает список выставленных счетов с возможностью фильтрации по статусу, дате, сумме и т.д.| [docs.robokassa.ru/invoiceapi/#status](https://docs.robokassa.ru/invoiceapi/#status) | -| `receipt()->sendSecondCheck(array $payload): string` | Отправляет запрос на формирование второго чека и возвращает ответ | [docs.robokassa.ru/second-check/#request](https://docs.robokassa.ru/second-check/#request) | -| `receipt()->getCheckStatus(array $payload): array` | Отправляет запрос на получение статуса фискального чека | [docs.robokassa.ru/second-check/#status](https://docs.robokassa.ru/second-check/#status) | +use Robokassa\Client\HttpClient; +use Robokassa\Robokassa; + +$robokassa = new Robokassa( + [ + 'login' => getenv('ROBOKASSA_LOGIN') ?: '', + 'password1' => getenv('ROBOKASSA_PASSWORD1') ?: '', + 'password2' => getenv('ROBOKASSA_PASSWORD2') ?: '', + 'hashType' => 'md5', + ], + new HttpClient() +); +``` -## ⚙️ Настройка окружения +Поддерживаемые алгоритмы подписи: -SDK не зависит от дополнительных библиотек для работы с конфигурацией: передавайте логин и пароли так, как это принято в вашем проекте (Laravel, Symfony, Docker, чистый PHP и т.д.). В SDK данные попадают в массив настроек при создании клиента, поэтому вы можете использовать любую существующую систему управления секретами. +```text +md5, ripemd160, sha1, sha256, sha384, sha512 +``` -### Минимальная настройка для примеров +Если передан неизвестный алгоритм, SDK выбросит `Robokassa\Exception\RobokassaException`. -1. Скопируйте файл `.env.example` в `.env`. -2. Заполните переменные `ROBOKASSA_LOGIN`, `ROBOKASSA_PASSWORD1`, `ROBOKASSA_PASSWORD2`. -3. Запустите нужный файл из папки `examples/`. Файл [`examples/bootstrap.php`](./examples/bootstrap.php) автоматически считывает `.env` и загружает значения в `$_ENV`. +## Доступные методы -### Использование в собственном приложении +| Метод | Описание | Документация | +| --- | --- | --- | +| `payment()->sendJwt(array $params): string` | Рекомендуемый способ. Создаёт ссылку на оплату через JWT-интерфейс. | [Invoice API](https://docs.robokassa.ru/ru/invoice-api) | +| `status()->getInvoiceInformationList(array $filters): array` | Получает список выставленных счетов по фильтрам. | [Invoice API](https://docs.robokassa.ru/ru/invoice-api) | +| `webService()->getPaymentMethods(string $lang = 'en'): array` | Получает список доступных способов оплаты. | [XML-интерфейсы](https://docs.robokassa.ru/ru/xml-interfaces) | +| `webService()->opState(int $invoiceID): array` | Получает статус оплаты по `InvoiceID`. | [XML-интерфейсы](https://docs.robokassa.ru/ru/xml-interfaces) | +| `receipt()->sendSecondCheck(array $payload): string` | Отправляет запрос на формирование второго чека. | [Второй чек](https://docs.robokassa.ru/ru/second-receipt.html) | +| `receipt()->getCheckStatus(array $payload): array` | Получает статус фискального чека. | [Второй чек](https://docs.robokassa.ru/ru/second-receipt.html) | -* **Фреймворки (Laravel, Symfony и др.)** — используйте штатные механизмы конфигурации и передавайте значения при создании `Robokassa`. -* **Чистый PHP или Docker** — задайте переменные окружения (например, через `export` или `docker run -e`) либо заполните `$_ENV` любым удобным способом. +## Создание ссылки на оплату через JWT ```php -$robokassa = new Robokassa( -[ -'login' => getenv('ROBOKASSA_LOGIN') ?: '', -'password1' => getenv('ROBOKASSA_PASSWORD1') ?: '', -'password2' => getenv('ROBOKASSA_PASSWORD2') ?: '', -'hashType' => 'md5', -], -new HttpClient() -); +$url = $robokassa->payment()->sendJwt([ + 'OutSum' => 100.00, + 'InvId' => 123456, + 'Description' => 'Оплата заказа #123456', + 'Culture' => 'ru', +]); ``` -## 📂 Примеры использования +Метод возвращает строку со ссылкой на оплату. -Полные примеры использования SDK находятся в папке [`examples/`](./examples): +## Получение статуса счетов -* [`send_payment_jwt.php`](./examples/send_payment_jwt.php) — создание ссылки на оплату через **JWT** (рекомендуется) -* [`send_payment_curl.php`](./examples/send_payment_curl.php) — создание ссылки на оплату через стандартный CURL-интерфейс -* [`get_payment_methods.php`](./examples/get_payment_methods.php) — получение доступных способов оплаты -* [`get_invoice_status.php`](./examples/get_invoice_status.php) — проверка статуса счёта -* [`send_second_check.php`](./examples/send_second_check.php) — отправка второго чека -* [`get_check_status.php`](./examples/get_check_status.php) — проверка статуса чека -* [`get_invoice_information.php`](./examples/get_invoice_information.php) — запрос статуса созданного счета/ссылки +```php +$result = $robokassa->status()->getInvoiceInformationList([ + 'CurrentPage' => 1, + 'PageSize' => 10, + 'InvoiceStatuses' => ['paid', 'expired', 'notpaid'], + 'DateFrom' => '2024-01-01', + 'DateTo' => '2024-01-31', + 'InvoiceTypes' => ['onetime', 'reusable'], +]); +``` -## 📌 Дополнительно +Прямое создание сервиса статусов остаётся рабочим для старого кода: -* Метод `payment()->sendJwt()` — предпочтительный способ и рекомендуется к использованию. -* Официальная документация: [docs.robokassa.ru](https://docs.robokassa.ru/) +```php +use Robokassa\Service\StatusService; + +$status = new StatusService($httpClient, $login, $password1); +``` + +## XML-интерфейсы + +```php +$methods = $robokassa->webService()->getPaymentMethods('ru'); +$state = $robokassa->webService()->opState(123456); +``` +## Второй чек + +```php +$result = $robokassa->receipt()->sendSecondCheck($payload); +$status = $robokassa->receipt()->getCheckStatus([ + 'merchantId' => 'merchant', + 'id' => '123456', +]); +``` + +## Обратная совместимость: sendCurl() + +`payment()->sendCurl(array $params): string` помечен как `@deprecated`, будет удалён в следующей major версии. Используйте `payment()->sendJwt()`. + +Метод оставлен без runtime warning, чтобы не ломать существующие интеграции. + +```php +$url = $robokassa->payment()->sendCurl([ + 'OutSum' => 100.00, + 'InvoiceID' => 123456, + 'Description' => 'Оплата заказа #123456', +]); +``` + +## Примеры + +Основные примеры находятся в папке [`examples/`](./examples): + +* [`send_payment_jwt.php`](./examples/send_payment_jwt.php) — создание ссылки на оплату через JWT. +* [`get_invoice_information.php`](./examples/get_invoice_information.php) — получение списка счетов через `$robokassa->status()`. +* [`get_payment_methods.php`](./examples/get_payment_methods.php) — получение доступных способов оплаты. +* [`get_invoice_status.php`](./examples/get_invoice_status.php) — проверка статуса оплаты через XML-интерфейс. +* [`send_second_check.php`](./examples/send_second_check.php) — отправка второго чека. +* [`get_check_status.php`](./examples/get_check_status.php) — проверка статуса чека. + +Устаревший пример для обратной совместимости: + +* [`send_payment_curl.php`](./examples/send_payment_curl.php) — старый способ создания ссылки через `sendCurl()`. + +## Проверка + +```sh +composer validate --strict +vendor/bin/phpunit +``` diff --git a/composer.json b/composer.json index 51fddb1..2a7254a 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "guzzlehttp/guzzle": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^9.6.33" + "phpunit/phpunit": "^9.6.27" }, "autoload": { "psr-4": { diff --git a/examples/get_invoice_information.php b/examples/get_invoice_information.php index a82558a..8d976cb 100644 --- a/examples/get_invoice_information.php +++ b/examples/get_invoice_information.php @@ -1,25 +1,20 @@ getInvoiceInformationList([ - 'MerchantLogin' => $_ENV['ROBOKASSA_LOGIN'] ?? '', - 'CurrentPage' => 1, - 'PageSize' => 10, - 'InvoiceStatuses' => ['paid','expired','notpaid'], - 'DateFrom' => '2023-01-01', - 'DateTo' => '2025-09-05', - 'IsAscending' => true, - 'InvoiceTypes' => ['onetime','reusable'], - 'PaymentAliases' => ['BankCard'], - 'SumFrom' => 1, - 'SumTo' => 10000, + $robokassa = createRobokassa(); + + $result = $robokassa->status()->getInvoiceInformationList([ + 'CurrentPage' => 1, + 'PageSize' => 10, + 'InvoiceStatuses' => ['paid','expired','notpaid'], + 'DateFrom' => '2023-01-01', + 'DateTo' => '2025-09-05', + 'IsAscending' => true, + 'InvoiceTypes' => ['onetime','reusable'], + 'PaymentAliases' => ['BankCard'], + 'SumFrom' => 1, + 'SumTo' => 10000, ]); print_r($result); diff --git a/examples/send_payment_curl.php b/examples/send_payment_curl.php index 77aa17e..952a0ed 100644 --- a/examples/send_payment_curl.php +++ b/examples/send_payment_curl.php @@ -4,6 +4,8 @@ /** * Пример использования метода payment()->sendCurl() + * + * @deprecated Метод будет удалён в следующей major версии. Используйте payment()->sendJwt(). * Создаёт платёжную ссылку через обычный POST-запрос (не JWT) */ diff --git a/examples/send_payment_jwt.php b/examples/send_payment_jwt.php index 5df8e82..410144c 100644 --- a/examples/send_payment_jwt.php +++ b/examples/send_payment_jwt.php @@ -12,7 +12,7 @@ $params = [ 'InvId' => 133765623, - 'OutSum' => 10, + 'OutSum' => 1, 'Description' => 'Оплата тестового заказа', 'MerchantComments' => 'Без комментариев', 'InvoiceType' => 'Reusable', diff --git a/src/.DS_Store b/src/.DS_Store deleted file mode 100644 index 5650914156a22f7e6f988029d671a7d8c8933e52..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKJ5Iwu5S@i6gpWi@BpQnbQAUW4!UTsJ$)blOC?Gj;gc3yQh6~Vh0>lY805{t1Co|HzV)#Rd@mDiMPoqcPnxZg0HDO=2}(6>f0wB6A?v_WN2 z+u;+ml^A(lM~`nG&MS{K(=?w|)N^kAilJnHw{``;rchSWwEZG}d91vy-RGQN70j8o z#mu5Ud7rFNV|g7w&*o@sTGUw`PzTh39|w4S2rw8!hnYoLIxxu=09Zp>0>`rU0|QO~ zLx-6~WDv%d0&S^rTMT2%kq;a%beLJR_I*$p2PA0UdvpS#-6dhQ# zr%mqvo%#F!Vv(My1M0xPa=_H0IBMf3x!&siIPSFp#w7*^^D>K4g2}aGIp9{jgCT)y Y0UrQEhnYoW5c)?zY|u#^_)`bI0P)d}9{>OV diff --git a/src/Client/HttpClient.php b/src/Client/HttpClient.php index 9671241..7fb9fa1 100644 --- a/src/Client/HttpClient.php +++ b/src/Client/HttpClient.php @@ -2,25 +2,9 @@ namespace Robokassa\Client; use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\RequestException; - -final class Response { - /** @var string */ - public $body; - - /** @var int */ - public $status; - - public function __construct(string $body, int $status) { - $this->body = $body; - $this->status = $status; - } -} - -interface HttpClientInterface { - public function get(string $url, array $headers = []): Response; - public function post(string $url, string $body, array $headers = []): Response; -} +use Robokassa\Exception\RobokassaException; final class HttpClient implements HttpClientInterface { /** @var Client */ @@ -31,29 +15,46 @@ public function __construct(?Client $client = null) { } public function get(string $url, array $headers = []): Response { - try { - $r = $this->client->get($url, ['headers' => $headers]); - return new Response((string)$r->getBody(), $r->getStatusCode()); - } catch (RequestException $e) { - $resp = $e->getResponse(); - $msg = $resp ? (string)$resp->getBody() : $e->getMessage(); - - throw new \Exception('Ошибка HTTP GET: ' . $msg, 0, $e); - } + return $this->request('get', $url, null, $headers); } public function post(string $url, string $body, array $headers = []): Response { + return $this->request('post', $url, $body, $headers); + } + + /** + * Выполняет HTTP-запрос и заворачивает сетевые ошибки в исключение SDK. + * + * @param string $method + * @param string $url + * @param string|null $body + * @param array $headers + * @return Response + * @throws RobokassaException + */ + private function request(string $method, string $url, ?string $body, array $headers): Response { + $options = array( + 'headers' => $headers, + ); + if ($body !== null) { + $options['body'] = $body; + } + try { - $r = $this->client->post($url, [ - 'body' => $body, - 'headers' => $headers, - ]); + $r = $this->client->{$method}($url, $options); return new Response((string)$r->getBody(), $r->getStatusCode()); } catch (RequestException $e) { - $resp = $e->getResponse(); - $msg = $resp ? (string)$resp->getBody() : $e->getMessage(); - - throw new \Exception('Ошибка HTTP POST: ' . $msg, 0, $e); + $response = $e->getResponse(); + if ($response !== null) { + throw new RobokassaException( + 'Ошибка HTTP ' . strtoupper($method) . ': HTTP Status: ' . $response->getStatusCode(), + 0, + $e + ); + } + throw new RobokassaException('Сетевая ошибка HTTP ' . strtoupper($method), 0, $e); + } catch (GuzzleException $e) { + throw new RobokassaException('Сетевая ошибка HTTP ' . strtoupper($method), 0, $e); } } } diff --git a/src/Client/HttpClientInterface.php b/src/Client/HttpClientInterface.php new file mode 100644 index 0000000..aca1bfe --- /dev/null +++ b/src/Client/HttpClientInterface.php @@ -0,0 +1,8 @@ +body = $body; + $this->status = $status; + } +} diff --git a/src/Robokassa.php b/src/Robokassa.php index 01e79b8..eaf731b 100644 --- a/src/Robokassa.php +++ b/src/Robokassa.php @@ -3,9 +3,11 @@ use Robokassa\Client\HttpClientInterface; use Robokassa\Exception\RobokassaException; +use Robokassa\Signature\HashAlgorithmResolver; use Robokassa\Signature\SignatureService; use Robokassa\Service\PaymentService; use Robokassa\Service\ReceiptService; +use Robokassa\Service\StatusService; use Robokassa\Service\WebService; class Robokassa { @@ -26,9 +28,9 @@ class Robokassa { private string $hashType = 'md5'; private string $login; - private array $hashAlgoList = ['md5','ripemd160','sha1','sha256','sha384','sha512']; private PaymentService $paymentService; private ReceiptService $receiptService; + private StatusService $statusService; private WebService $webService; /** @@ -40,56 +42,122 @@ class Robokassa { public function __construct(array $params, HttpClientInterface $httpClient, ?SignatureService $signer = null) { $this->httpClient = $httpClient; - if (empty($params['login'])) throw new RobokassaException('Param login is not defined'); - if (empty($params['password1'])) throw new RobokassaException('Param password1 is not defined'); - if (empty($params['password2'])) throw new RobokassaException('Param password2 is not defined'); + $this->validateParams($params); + $this->is_test = !empty($params['is_test']); + $this->hashType = $this->resolveHashType($params); + $this->login = (string)$params['login']; + $this->password1 = $this->is_test ? (string)$params['test_password1'] : (string)$params['password1']; + $this->password2 = $this->is_test ? (string)$params['test_password2'] : (string)$params['password2']; + $this->signer = $signer ?? new SignatureService($this->hashType); + $this->initServices(); + } + /** + * Проверяет обязательные параметры клиента. + * + * @param array $params + * @return void + * @throws RobokassaException + */ + private function validateParams(array $params): void { + if (empty($params['login'])) { + throw new RobokassaException('Param login is not defined'); + } + if (empty($params['password1'])) { + throw new RobokassaException('Param password1 is not defined'); + } + if (empty($params['password2'])) { + throw new RobokassaException('Param password2 is not defined'); + } if (!empty($params['is_test'])) { - if (empty($params['test_password1'])) throw new RobokassaException('Param test_password1 is not defined'); - if (empty($params['test_password2'])) throw new RobokassaException('Param test_password2 is not defined'); - $this->is_test = true; + $this->validateTestPasswords($params); } + } - if (!empty($params['hashType'])) { - if (!in_array($params['hashType'], $this->hashAlgoList, true)) { - $except = implode(', ', $this->hashAlgoList); - throw new RobokassaException("The hashType parameter can only the values: $except"); - } - $this->hashType = $params['hashType']; + /** + * Проверяет тестовые пароли. + * + * @param array $params + * @return void + * @throws RobokassaException + */ + private function validateTestPasswords(array $params): void { + if (empty($params['test_password1'])) { + throw new RobokassaException('Param test_password1 is not defined'); + } + if (empty($params['test_password2'])) { + throw new RobokassaException('Param test_password2 is not defined'); } + } - $this->login = (string)$params['login']; - $this->password1 = $this->is_test ? (string)$params['test_password1'] : (string)$params['password1']; - $this->password2 = $this->is_test ? (string)$params['test_password2'] : (string)$params['password2']; + /** + * Возвращает алгоритм подписи из настроек. + * + * @param array $params + * @return string + * @throws RobokassaException + */ + private function resolveHashType(array $params): string { + $resolver = new HashAlgorithmResolver(); + return $resolver->resolve($params['hashType'] ?? $this->hashType); + } - $this->signer = $signer ?? new SignatureService($this->hashType); - $this->paymentService = new PaymentService( - $this->httpClient, - $this->signer, - $this->login, - $this->password1, - $this->is_test, - $this->paymentUrl, - $this->paymentCurl, - $this->jwtApiUrl, - $this->hashType - ); - $this->receiptService = new ReceiptService( - $this->httpClient, - $this->signer, - $this->password1, - $this->hashType - ); - $this->webService = new WebService( - $this->httpClient, - $this->signer, - $this->login, - $this->password2, - $this->hashType, - $this->webServiceUrl - ); + /** + * Инициализирует сервисы SDK. + * + * @return void + */ + private function initServices(): void { + $this->paymentService = $this->createPaymentService(); + $this->receiptService = $this->createReceiptService(); + $this->statusService = $this->createStatusService(); + $this->webService = $this->createWebService(); + } + + /** + * Создаёт сервис платежей. + */ + private function createPaymentService(): PaymentService { + return new PaymentService( + $this->httpClient, + $this->signer, + $this->login, + $this->password1, + $this->is_test, + $this->paymentUrl, + $this->paymentCurl, + $this->jwtApiUrl, + $this->hashType + ); } + /** + * Создаёт сервис чеков. + */ + private function createReceiptService(): ReceiptService { + return new ReceiptService($this->httpClient, $this->signer, $this->password1, $this->hashType); + } + + /** + * Создаёт сервис статусов счетов. + */ + private function createStatusService(): StatusService { + return new StatusService($this->httpClient, $this->login, $this->password1, $this->signer); + } + + /** + * Создаёт сервис XML-интерфейсов. + */ + private function createWebService(): WebService { + return new WebService( + $this->httpClient, + $this->signer, + $this->login, + $this->password2, + $this->hashType, + $this->webServiceUrl + ); + } /** * Сервис для работы с платежами @@ -105,6 +173,13 @@ public function receipt(): ReceiptService { return $this->receiptService; } + /** + * Сервис для работы со статусами счетов + */ + public function status(): StatusService { + return $this->statusService; + } + /** * Сервис для работы с XML интерфейсами */ diff --git a/src/Service/PaymentService.php b/src/Service/PaymentService.php index 4dc808a..3a2a4ad 100644 --- a/src/Service/PaymentService.php +++ b/src/Service/PaymentService.php @@ -2,6 +2,7 @@ namespace Robokassa\Service; use Robokassa\Client\HttpClientInterface; +use Robokassa\Client\Response; use Robokassa\Exception\RobokassaException; use Robokassa\Signature\SignatureService; @@ -16,7 +17,17 @@ class PaymentService { private string $jwtApiUrl; private string $hashType; - public function __construct(HttpClientInterface $http, SignatureService $sign, string $login, string $password1, bool $isTest, string $paymentUrl, string $paymentCurl, string $jwtApiUrl, string $hashType) { + public function __construct( + HttpClientInterface $http, + SignatureService $sign, + string $login, + string $password1, + bool $isTest, + string $paymentUrl, + string $paymentCurl, + string $jwtApiUrl, + string $hashType + ) { $this->http = $http; $this->sign = $sign; $this->merchantLogin = $login; @@ -29,7 +40,9 @@ public function __construct(HttpClientInterface $http, SignatureService $sign, s } /** - * Отправка платёжного запроса через CURL (Indexjson.aspx) + * Отправка платёжного запроса через CURL (Indexjson.aspx). + * + * @deprecated будет удалён в следующей major версии. Используйте sendJwt(). * @param array $params * @return string * @throws RobokassaException @@ -46,18 +59,41 @@ public function sendCurl(array $params): string { $resp = $this->http->post($this->paymentCurl, http_build_query($params), array( 'Content-Type' => 'application/x-www-form-urlencoded', )); - if ($resp->status === 200) { - $data = json_decode($resp->body, true); - if (!empty($data['invoiceID'])) { - return $this->paymentUrl . $data['invoiceID']; - } - throw new RobokassaException('Invoice ID not found in response.'); + $this->assertSuccessStatus($resp, 'Failed to send payment request.'); + $data = $this->decodeJsonResponse($resp->body); + if (!empty($data['invoiceID'])) { + return $this->paymentUrl . $data['invoiceID']; } - throw new RobokassaException('Failed to send payment request. HTTP Status: ' . $resp->status); + throw new RobokassaException('Invoice ID not found in response.'); } /** - * Подготовка параметров для CURL-запроса + * Создание счёта через JWT интерфейс. + * + * @param array $params + * @return string + * @throws RobokassaException + */ + public function sendJwt(array $params): string { + $payload = $this->buildJwtPayload($params); + list(, , $toSign) = $this->sign->encodeJwtParts(array('alg' => 'MD5', 'typ' => 'JWT'), $payload); + $jwt = $toSign . '.' . $this->sign->jwtSignMd5($toSign, $this->merchantLogin, $this->password1); + $resp = $this->http->post( + $this->jwtApiUrl, + $this->encodeJson($jwt), + array('Content-Type' => 'application/json') + ); + $this->assertSuccessStatus($resp, 'JWT request failed.'); + $data = $this->decodeJsonResponse($resp->body); + if (!empty($data['url'])) { + return $data['url']; + } + throw new RobokassaException('JWT response does not contain payment URL.'); + } + + /** + * Подготовка параметров для CURL-запроса. + * * @param array $params * @return array * @throws RobokassaException @@ -68,9 +104,81 @@ private function prepareCurlParams(array $params): array { } $params['MerchantLogin'] = $this->merchantLogin; if (!empty($params['Receipt'])) { - $encoded = urlencode(json_encode($params['Receipt'])); + $encoded = urlencode($this->encodeJson($params['Receipt'])); $params['Receipt'] = urlencode($encoded); } + return $this->encodeShpParams($params); + } + + /** + * Формирование массива для подписи. + * + * @param array $params + * @return array + */ + private function buildCurlSignature(array $params): array { + $sig = array('OutSum' => $params['OutSum'], 'InvoiceID' => $params['InvoiceID'] ?? ''); + if (!empty($params['Receipt'])) { + $sig['Receipt'] = urldecode($params['Receipt']); + } + return $this->appendShpParams($sig, $params); + } + + /** + * Подготовка payload для JWT. + * + * @param array $params + * @return array + * @throws RobokassaException + */ + private function buildJwtPayload(array $params): array { + if (empty($params['OutSum']) || !isset($params['InvId'])) { + throw new RobokassaException('Required parameters: OutSum, InvId'); + } + $payload = $this->buildRequiredJwtPayload($params); + return $this->appendOptionalJwtPayload($payload, $params); + } + + /** + * Собирает обязательные поля JWT payload. + * + * @param array $params + * @return array + */ + private function buildRequiredJwtPayload(array $params): array { + return array( + 'MerchantLogin' => $this->merchantLogin, + 'InvoiceType' => $params['InvoiceType'] ?? 'OneTime', + 'Culture' => $params['Culture'] ?? 'ru', + 'InvId' => (int)$params['InvId'], + 'OutSum' => (float)$params['OutSum'], + ); + } + + /** + * Добавляет опциональные поля JWT payload. + * + * @param array $payload + * @param array $params + * @return array + */ + private function appendOptionalJwtPayload(array $payload, array $params): array { + $optional = array('Description','MerchantComments','InvoiceItems','UserFields','SuccessUrl2Data','FailUrl2Data'); + foreach ($optional as $key) { + if (!empty($params[$key])) { + $payload[$key] = $params[$key]; + } + } + return $payload; + } + + /** + * Кодирует параметры Shp_* и добавляет тестовый режим. + * + * @param array $params + * @return array + */ + private function encodeShpParams(array $params): array { if ($this->isTest) { $params['IsTest'] = '1'; } @@ -83,18 +191,13 @@ private function prepareCurlParams(array $params): array { } /** - * Формирование массива для подписи + * Добавляет параметры Shp_* в массив подписи. + * + * @param array $sig * @param array $params * @return array */ - private function buildCurlSignature(array $params): array { - $sig = array( - 'OutSum' => $params['OutSum'], - 'InvoiceID' => $params['InvoiceID'] ?? '', - ); - if (!empty($params['Receipt'])) { - $sig['Receipt'] = urldecode($params['Receipt']); - } + private function appendShpParams(array $sig, array $params): array { foreach ($params as $name => $value) { if (preg_match('~^Shp_~iu', $name)) { $sig[$name] = $value; @@ -104,47 +207,53 @@ private function buildCurlSignature(array $params): array { } /** - * Создание счёта через JWT интерфейс - * @param array $params + * Проверяет успешный HTTP-статус. + * + * @param Response $response + * @param string $message + * @return void + * @throws RobokassaException + */ + private function assertSuccessStatus(Response $response, string $message): void { + if ($response->status !== 200) { + throw new RobokassaException($message . ' HTTP Status: ' . $response->status); + } + } + + /** + * Кодирует данные в JSON с проверкой ошибки. + * + * @param mixed $data + * @param int $flags * @return string * @throws RobokassaException */ - public function sendJwt(array $params): string { - $payload = $this->buildJwtPayload($params); - [$eh, $ep, $toSign] = $this->sign->encodeJwtParts(array('alg' => 'MD5', 'typ' => 'JWT'), $payload); - $sig = $this->sign->jwtSignMd5($toSign, $this->merchantLogin, $this->password1); - $jwt = $toSign . '.' . $sig; - $resp = $this->http->post($this->jwtApiUrl, json_encode($jwt), array('Content-Type' => 'application/json')); - $data = json_decode($resp->body, true); - if (!empty($data['url'])) { - return $data['url']; + private function encodeJson($data, int $flags = 0): string { + $json = json_encode($data, $flags); + if ($json === false) { + throw new RobokassaException('Ошибка кодирования JSON: ' . json_last_error_msg()); } - throw new RobokassaException('JWT request failed: ' . $resp->body); + return $json; } /** - * Подготовка payload для JWT - * @param array $params + * Разбирает JSON-ответ с проверкой пустого и невалидного тела. + * + * @param string $body * @return array * @throws RobokassaException */ - private function buildJwtPayload(array $params): array { - if (empty($params['OutSum']) || !isset($params['InvId'])) { - throw new RobokassaException('Required parameters: OutSum, InvId'); + private function decodeJsonResponse(string $body): array { + if (trim($body) === '') { + throw new RobokassaException('Пустой JSON-ответ'); } - $payload = array( - 'MerchantLogin' => $this->merchantLogin, - 'InvoiceType' => $params['InvoiceType'] ?? 'OneTime', - 'Culture' => $params['Culture'] ?? 'ru', - 'InvId' => (int)$params['InvId'], - 'OutSum' => (float)$params['OutSum'], - ); - $optional = array('Description','MerchantComments','InvoiceItems','UserFields','SuccessUrl2Data','FailUrl2Data'); - foreach ($optional as $key) { - if (!empty($params[$key])) { - $payload[$key] = $params[$key]; - } + $data = json_decode($body, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RobokassaException('Некорректный JSON в ответе: ' . json_last_error_msg()); } - return $payload; + if (!is_array($data)) { + throw new RobokassaException('JSON-ответ должен быть объектом или массивом'); + } + return $data; } } diff --git a/src/Service/ReceiptService.php b/src/Service/ReceiptService.php index 6a17011..5eba1ec 100644 --- a/src/Service/ReceiptService.php +++ b/src/Service/ReceiptService.php @@ -2,6 +2,7 @@ namespace Robokassa\Service; use Robokassa\Client\HttpClientInterface; +use Robokassa\Client\Response; use Robokassa\Exception\RobokassaException; use Robokassa\Signature\SignatureService; @@ -28,10 +29,7 @@ public function __construct(HttpClientInterface $http, SignatureService $sign, s * @throws RobokassaException */ public function getSecondCheckUrl(array $payload): string { - $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - if ($json === false) { - throw new RobokassaException('Ошибка кодирования JSON'); - } + $json = $this->encodeJson($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $base64Payload = $this->sign->b64url($json); $base64Signature = $this->sign->signFiscal($base64Payload, $this->password1, $this->hashType); return $base64Payload . '.' . $base64Signature; @@ -46,6 +44,7 @@ public function getSecondCheckUrl(array $payload): string { public function sendSecondCheck(array $payload): string { $body = $this->getSecondCheckUrl($payload); $resp = $this->http->post(self::SECOND_CHECK_URL, $body, array('Content-Type' => 'application/json')); + $this->assertSuccessStatus($resp, 'Ошибка отправки второго чека.'); return $resp->body; } @@ -59,17 +58,62 @@ public function getCheckStatus(array $payload): array { if (empty($payload['merchantId']) || empty($payload['id'])) { throw new RobokassaException('Не указаны обязательные параметры: merchantId и id (InvId).'); } - $json = json_encode($payload, JSON_UNESCAPED_UNICODE); - if ($json === false) { - throw new RobokassaException('Ошибка кодирования JSON'); - } + $json = $this->encodeJson($payload, JSON_UNESCAPED_UNICODE); $base64Payload = $this->sign->b64url($json); $base64Signature = $this->sign->signFiscal($base64Payload, $this->password1, $this->hashType); $body = $base64Payload . '.' . $base64Signature; $resp = $this->http->post(self::CHECK_STATUS_URL, $body, array('Content-Type' => 'application/json; charset=utf-8')); - $data = json_decode($resp->body, true); - if ($data === null) { - throw new RobokassaException('Некорректный JSON в ответе: ' . $resp->body); + $this->assertSuccessStatus($resp, 'Ошибка получения статуса чека.'); + return $this->decodeJsonResponse($resp->body); + } + + /** + * Проверяет успешный HTTP-статус. + * + * @param Response $response + * @param string $message + * @return void + * @throws RobokassaException + */ + private function assertSuccessStatus(Response $response, string $message): void { + if ($response->status !== 200) { + throw new RobokassaException($message . ' HTTP Status: ' . $response->status); + } + } + + /** + * Кодирует данные в JSON с проверкой ошибки. + * + * @param array $data + * @param int $flags + * @return string + * @throws RobokassaException + */ + private function encodeJson(array $data, int $flags): string { + $json = json_encode($data, $flags); + if ($json === false) { + throw new RobokassaException('Ошибка кодирования JSON: ' . json_last_error_msg()); + } + return $json; + } + + /** + * Разбирает JSON-ответ с проверкой пустого и невалидного тела. + * + * @param string $body + * @return array + * @throws RobokassaException + */ + private function decodeJsonResponse(string $body): array { + if (trim($body) === '') { + throw new RobokassaException('Пустой JSON-ответ'); + } + $data = json_decode($body, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RobokassaException('Некорректный JSON в ответе: ' . json_last_error_msg()); + } + if (!is_array($data)) { + throw new RobokassaException('JSON-ответ должен быть объектом или массивом'); } return $data; } diff --git a/src/Service/StatusService.php b/src/Service/StatusService.php index 450d13f..d8e16d4 100644 --- a/src/Service/StatusService.php +++ b/src/Service/StatusService.php @@ -2,92 +2,186 @@ namespace Robokassa\Service; use Robokassa\Client\HttpClientInterface; +use Robokassa\Client\Response; +use Robokassa\Exception\RobokassaException; use Robokassa\Signature\SignatureService; -class StatusService -{ +class StatusService { /** @var string */ private $endpoint = 'https://services.robokassa.ru/InvoiceServiceWebApi/api/GetInvoiceInformationList'; - /** @var HttpClientInterface */ - private $http; - - /** @var SignatureService */ - private $sign; - - /** @var string */ - private $merchantLogin; - - /** @var string */ - private $password1; + private HttpClientInterface $http; + private SignatureService $sign; + private string $merchantLogin; + private string $password1; /** + * Создаёт сервис статусов счетов. + * * @param HttpClientInterface $http * @param string $merchantLogin * @param string $password1 + * @param SignatureService|null $sign */ - public function __construct($http, $merchantLogin, $password1) { - $this->http = $http; - $this->merchantLogin = (string)$merchantLogin; - $this->password1 = (string)$password1; - $this->sign = new SignatureService('md5'); // Robokassa JWT использует MD5 + public function __construct( + HttpClientInterface $http, + string $merchantLogin, + string $password1, + ?SignatureService $sign = null + ) { + $this->http = $http; + $this->merchantLogin = $merchantLogin; + $this->password1 = $password1; + $this->sign = $sign ?: new SignatureService('md5'); } /** - * Получить список счетов/ссылок по фильтрам (дока: GetInvoiceInformationList). - * Обязательные поля: CurrentPage, PageSize, InvoiceStatuses, DateFrom, DateTo, InvoiceTypes + * Получить список счетов/ссылок по фильтрам. + * + * Обязательные поля: CurrentPage, PageSize, InvoiceStatuses, DateFrom, DateTo, InvoiceTypes. * * @param array $filters * @return array - * @throws \Exception + * @throws RobokassaException + */ + public function getInvoiceInformationList(array $filters): array { + $this->assertRequiredFilters($filters); + $filters = $this->normalizeFilters($filters); + $jwt = $this->buildJwt($filters); + $resp = $this->http->post($this->endpoint, $this->encodeJson($jwt), array( + 'Content-Type' => 'application/json', + )); + $this->assertSuccessStatus($resp, 'Ошибка получения списка счетов.'); + return $this->decodeJsonResponse($resp->body); + } + + /** + * Проверяет обязательные фильтры запроса. + * + * @param array $filters + * @return void + * @throws RobokassaException */ - public function getInvoiceInformationList(array $filters) { - // обязательные поля + private function assertRequiredFilters(array $filters): void { $required = array('CurrentPage','PageSize','InvoiceStatuses','DateFrom','DateTo','InvoiceTypes'); foreach ($required as $req) { if (!array_key_exists($req, $filters)) { - throw new \Exception('Missing required field: ' . $req); + throw new RobokassaException('Missing required field: ' . $req); } } + } - // нормализация статусов/типов: принимаем нижний регистр, отправляем как в доке - $normalizeList = function ($list) { - if (!is_array($list)) return $list; - $out = array(); - foreach ($list as $v) { - $s = strtolower((string)$v); - if ($s === 'paid') { $out[] = 'Paid'; } - elseif ($s === 'expired') { $out[] = 'Expired'; } - elseif ($s === 'notpaid') { $out[] = 'Notpaid'; } - elseif ($s === 'onetime') { $out[] = 'OneTime'; } - elseif ($s === 'reusable') { $out[] = 'Reusable'; } - else { $out[] = $v; } - } - return $out; - }; - + /** + * Нормализует фильтры статусов и типов счетов. + * + * @param array $filters + * @return array + */ + private function normalizeFilters(array $filters): array { if (isset($filters['InvoiceStatuses'])) { - $filters['InvoiceStatuses'] = $normalizeList($filters['InvoiceStatuses']); + $filters['InvoiceStatuses'] = $this->normalizeList($filters['InvoiceStatuses']); } if (isset($filters['InvoiceTypes'])) { - $filters['InvoiceTypes'] = $normalizeList($filters['InvoiceTypes']); + $filters['InvoiceTypes'] = $this->normalizeList($filters['InvoiceTypes']); } + return $filters; + } - // JWT parts - $header = array('alg' => 'MD5', 'typ' => 'JWT'); - $payload = array_merge(array('MerchantLogin' => $this->merchantLogin), $filters); + /** + * Нормализует список значений, если он передан массивом. + * + * @param mixed $list + * @return mixed + */ + private function normalizeList($list) { + if (!is_array($list)) { + return $list; + } + $normalized = array(); + foreach ($list as $value) { + $normalized[] = $this->normalizeListValue($value); + } + return $normalized; + } - list($encHeader, $encPayload, $toSign) = $this->sign->encodeJwtParts($header, $payload); + /** + * Нормализует одно значение фильтра. + * + * @param mixed $value + * @return mixed + */ + private function normalizeListValue($value) { + $map = array( + 'paid' => 'Paid', + 'expired' => 'Expired', + 'notpaid' => 'Notpaid', + 'onetime' => 'OneTime', + 'reusable' => 'Reusable', + ); + $key = strtolower((string)$value); + return $map[$key] ?? $value; + } + + /** + * Формирует JWT для запроса списка счетов. + * + * @param array $filters + * @return string + * @throws RobokassaException + */ + private function buildJwt(array $filters): string { + $payload = array_merge(array('MerchantLogin' => $this->merchantLogin), $filters); + list(, , $toSign) = $this->sign->encodeJwtParts(array('alg' => 'MD5', 'typ' => 'JWT'), $payload); $signature = $this->sign->jwtSignMd5($toSign, $this->merchantLogin, $this->password1); - $jwt = $toSign . '.' . $signature; + return $toSign . '.' . $signature; + } - $resp = $this->http->post($this->endpoint, json_encode($jwt), array( - 'Content-Type' => 'application/json', - )); + /** + * Проверяет успешный HTTP-статус. + * + * @param Response $response + * @param string $message + * @return void + * @throws RobokassaException + */ + private function assertSuccessStatus(Response $response, string $message): void { + if ($response->status !== 200) { + throw new RobokassaException($message . ' HTTP Status: ' . $response->status); + } + } - $data = json_decode($resp->body, true); + /** + * Кодирует данные в JSON с проверкой ошибки. + * + * @param mixed $data + * @return string + * @throws RobokassaException + */ + private function encodeJson($data): string { + $json = json_encode($data); + if ($json === false) { + throw new RobokassaException('Ошибка кодирования JSON: ' . json_last_error_msg()); + } + return $json; + } + + /** + * Разбирает JSON-ответ с проверкой пустого и невалидного тела. + * + * @param string $body + * @return array + * @throws RobokassaException + */ + private function decodeJsonResponse(string $body): array { + if (trim($body) === '') { + throw new RobokassaException('Пустой JSON-ответ'); + } + $data = json_decode($body, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RobokassaException('Некорректный JSON в ответе: ' . json_last_error_msg()); + } if (!is_array($data)) { - throw new \Exception('Bad JSON in response: ' . $resp->body); + throw new RobokassaException('JSON-ответ должен быть объектом или массивом'); } return $data; } diff --git a/src/Service/WebService.php b/src/Service/WebService.php index ef11264..f4117f8 100644 --- a/src/Service/WebService.php +++ b/src/Service/WebService.php @@ -2,6 +2,7 @@ namespace Robokassa\Service; use Robokassa\Client\HttpClientInterface; +use Robokassa\Client\Response; use Robokassa\Exception\RobokassaException; use Robokassa\Signature\SignatureService; @@ -49,9 +50,7 @@ public function getPaymentMethods(string $lang = 'en'): array { 'Language' => $lang, ]); $resp = $this->http->get($this->buildUrl('GetPaymentMethods', $query)); - if ($resp->status !== 200) { - throw new RobokassaException('Ошибка запроса: HTTP ' . $resp->status); - } + $this->assertSuccessStatus($resp); return $this->xmlToArray($resp->body); } @@ -74,9 +73,7 @@ public function opState(int $invoiceID): array { ), ]); $resp = $this->http->get($this->buildUrl('OpStateExt', $query)); - if ($resp->status !== 200) { - throw new RobokassaException('Ошибка запроса: HTTP ' . $resp->status); - } + $this->assertSuccessStatus($resp); return $this->xmlToArray($resp->body); } @@ -84,8 +81,56 @@ private function buildUrl(string $segment, string $query): string { return $this->url . '/' . $segment . '?' . $query; } + /** + * Проверяет успешный HTTP-статус XML-запроса. + * + * @param Response $response + * @return void + * @throws RobokassaException + */ + private function assertSuccessStatus(Response $response): void { + if ($response->status !== 200) { + throw new RobokassaException('Ошибка запроса: HTTP ' . $response->status); + } + } + + /** + * Преобразует XML-ответ в массив без PHP warning. + * + * @param string $xml + * @return array + * @throws RobokassaException + */ private function xmlToArray(string $xml): array { + if (trim($xml) === '') { + throw new RobokassaException('Пустой XML-ответ'); + } + $previous = libxml_use_internal_errors(true); $res = simplexml_load_string($xml); - return json_decode(json_encode((array)$res, JSON_NUMERIC_CHECK), true); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + if ($res === false) { + throw new RobokassaException('Некорректный XML в ответе'); + } + return $this->simpleXmlToArray($res); + } + + /** + * Преобразует SimpleXML в массив без числовой конвертации строк. + * + * @param \SimpleXMLElement $xml + * @return array + * @throws RobokassaException + */ + private function simpleXmlToArray(\SimpleXMLElement $xml): array { + $json = json_encode((array)$xml); + if ($json === false) { + throw new RobokassaException('Ошибка кодирования XML в JSON: ' . json_last_error_msg()); + } + $data = json_decode($json, true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { + throw new RobokassaException('Некорректный JSON после преобразования XML'); + } + return $data; } } diff --git a/src/Signature/HashAlgorithmResolver.php b/src/Signature/HashAlgorithmResolver.php new file mode 100644 index 0000000..fd92dda --- /dev/null +++ b/src/Signature/HashAlgorithmResolver.php @@ -0,0 +1,78 @@ +allowedAlgorithms = $allowedAlgorithms ?: array( + 'md5', + 'ripemd160', + 'sha1', + 'sha256', + 'sha384', + 'sha512', + ); + $this->defaultAlgorithm = $this->normalize($defaultAlgorithm); + } + + /** + * Возвращает поддерживаемый алгоритм или выбрасывает исключение. + * + * @param string|null $algorithm + * @return string + * @throws RobokassaException + */ + public function resolve($algorithm = null): string { + if ($algorithm === null) { + return $this->defaultAlgorithm; + } + return $this->normalize($algorithm); + } + + /** + * Возвращает список поддерживаемых алгоритмов. + * + * @return string[] + */ + public function getSupportedAlgorithms(): array { + return $this->allowedAlgorithms; + } + + /** + * Нормализует имя алгоритма. + * + * @param string $algorithm + * @return string + * @throws RobokassaException + */ + private function normalize($algorithm): string { + $algorithm = strtolower((string)$algorithm); + if (!in_array($algorithm, $this->allowedAlgorithms, true)) { + throw new RobokassaException($this->buildUnknownAlgorithmMessage()); + } + return $algorithm; + } + + /** + * Формирует сообщение для неизвестного алгоритма. + * + * @return string + */ + private function buildUnknownAlgorithmMessage(): string { + return 'Неизвестный алгоритм подписи. Поддерживаются: ' . implode(', ', $this->allowedAlgorithms); + } +} diff --git a/src/Signature/SignatureService.php b/src/Signature/SignatureService.php index 521cd0d..a1738f6 100644 --- a/src/Signature/SignatureService.php +++ b/src/Signature/SignatureService.php @@ -1,15 +1,23 @@ defaultAlgo = $defaultAlgo; + /** @var HashAlgorithmResolver */ + private $algorithmResolver; + + /** + * @param string $defaultAlgo + * @param HashAlgorithmResolver|null $algorithmResolver + * @throws RobokassaException + */ + public function __construct($defaultAlgo = 'md5', ?HashAlgorithmResolver $algorithmResolver = null) { + $this->algorithmResolver = $algorithmResolver ?: new HashAlgorithmResolver($defaultAlgo); + $this->defaultAlgo = $this->algorithmResolver->resolve($defaultAlgo); } /** Base64URL без паддинга */ @@ -25,12 +33,10 @@ public function b64url($data) { * @param string $secret * @param string|null $algo * @return string + * @throws RobokassaException */ public function signFiscal($base64Payload, $secret, $algo = null) { - $algo = strtolower($algo ? $algo : $this->defaultAlgo); - if (!in_array($algo, self::$ALLOWED, true)) { - $algo = 'md5'; - } + $algo = $this->resolveAlgorithm($algo); $hashHex = hash($algo, $base64Payload . $secret, false); return $this->b64url($hashHex); } @@ -55,10 +61,11 @@ public function jwtSignMd5($dataToSign, $merchantLogin, $password1) { * @param array $header * @param array $payload * @return array{0:string,1:string,2:string} + * @throws RobokassaException */ public function encodeJwtParts(array $header, array $payload) { - $encHeader = $this->b64url(json_encode($header, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); - $encPayload = $this->b64url(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $encHeader = $this->b64url($this->encodeJson($header)); + $encPayload = $this->b64url($this->encodeJson($payload)); return array($encHeader, $encPayload, $encHeader . '.' . $encPayload); } @@ -73,19 +80,57 @@ public function encodeJwtParts(array $header, array $payload) { * @param array $params Должны содержать OutSum, InvoiceID, опционально Receipt и Shp_* поля * @param string $login * @param string $password1 - * @param string|null $algo md5|sha256|sha512 (иначе md5) + * @param string|null $algo md5|ripemd160|sha1|sha256|sha384|sha512 * @return string HEX-хеш + * @throws RobokassaException */ public function createPaymentSignature(array $params, $login, $password1, $algo = null) { - $required = array($login, $params['OutSum'], $params['InvoiceID']); + $hashString = $this->buildPaymentHashString($params, $login, $password1); + return hash($this->resolveAlgorithm($algo), $hashString); + } + /** + * Собирает строку для подписи платёжного запроса. + * + * @param array $params + * @param string $login + * @param string $password1 + * @return string + */ + private function buildPaymentHashString(array $params, $login, $password1): string { + $required = $this->buildPaymentRequiredParts($params, $login, $password1); + $pairs = $this->collectShpPairs($params); + $hashString = implode(':', $required); + if (!empty($pairs)) { + $hashString .= ':' . implode(':', $pairs); + } + return $hashString; + } + + /** + * Собирает обязательные части строки подписи. + * + * @param array $params + * @param string $login + * @param string $password1 + * @return array + */ + private function buildPaymentRequiredParts(array $params, $login, $password1): array { + $required = array($login, $params['OutSum'], $params['InvoiceID']); if (!empty($params['Receipt'])) { $required[] = $params['Receipt']; } - $required[] = $password1; + return $required; + } - // собрать пары Shp_* в виде key=value и отсортировать + /** + * Собирает отсортированные пользовательские параметры Shp_*. + * + * @param array $params + * @return array + */ + private function collectShpPairs(array $params): array { $pairs = array(); foreach ($params as $k => $v) { if (preg_match('~^Shp_~iu', $k)) { @@ -93,18 +138,7 @@ public function createPaymentSignature(array $params, $login, $password1, $algo } } sort($pairs); - - $hashString = implode(':', $required); - if (!empty($pairs)) { - $hashString .= ':' . implode(':', $pairs); - } - - $algo = strtolower($algo ? $algo : $this->defaultAlgo); - if (!in_array($algo, self::$ALLOWED, true)) { - $algo = 'md5'; - } - - return hash($algo, $hashString); + return $pairs; } /** @@ -116,15 +150,38 @@ public function createPaymentSignature(array $params, $login, $password1, $algo * @param string $login * @param string $invoiceID * @param string $password2 - * @param string|null $algo md5|sha256|sha512 (иначе md5) + * @param string|null $algo md5|ripemd160|sha1|sha256|sha384|sha512 * @return string HEX-хеш + * @throws RobokassaException */ public function signOpState($login, $invoiceID, $password2, $algo = null) { - $algo = strtolower($algo ? $algo : $this->defaultAlgo); - if (!in_array($algo, self::$ALLOWED, true)) { - $algo = 'md5'; - } - + $algo = $this->resolveAlgorithm($algo); return hash($algo, $login . ':' . $invoiceID . ':' . $password2); } -} \ No newline at end of file + + /** + * Выбирает алгоритм подписи. + * + * @param string|null $algo + * @return string + * @throws RobokassaException + */ + private function resolveAlgorithm($algo = null): string { + return $this->algorithmResolver->resolve($algo === null ? $this->defaultAlgo : $algo); + } + + /** + * Кодирует данные в JSON для JWT. + * + * @param array $data + * @return string + * @throws RobokassaException + */ + private function encodeJson(array $data): string { + $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RobokassaException('Ошибка кодирования JSON: ' . json_last_error_msg()); + } + return $json; + } +} diff --git a/tests/DummyClient.php b/tests/DummyClient.php index 3756465..2e0f07d 100644 --- a/tests/DummyClient.php +++ b/tests/DummyClient.php @@ -1,19 +1,16 @@ */ private array $responses = []; + public string $lastUrl = ''; public string $lastBody = ''; public array $lastHeaders = []; @@ -25,13 +22,20 @@ public function queueResponse(Response $response): void { $this->responses[] = $response; } + /** + * Добавить исключение в очередь. + */ + public function queueException(\Throwable $exception): void { + $this->responses[] = $exception; + } + /** * Имитация запроса GET */ public function get(string $url, array $headers = []): Response { $this->lastUrl = $url; $this->lastHeaders = $headers; - return array_shift($this->responses); + return $this->nextResponse(); } /** @@ -41,6 +45,17 @@ public function post(string $url, string $body, array $headers = []): Response { $this->lastUrl = $url; $this->lastBody = $body; $this->lastHeaders = $headers; - return array_shift($this->responses); + return $this->nextResponse(); + } + + private function nextResponse(): Response { + $response = array_shift($this->responses); + if ($response instanceof \Throwable) { + throw $response; + } + if (!$response instanceof Response) { + throw new \RuntimeException('Не задан ответ HTTP-заглушки'); + } + return $response; } } diff --git a/tests/ExamplesTest.php b/tests/ExamplesTest.php index 4b4634f..3ce38ee 100644 --- a/tests/ExamplesTest.php +++ b/tests/ExamplesTest.php @@ -1,117 +1,295 @@ http = new DummyClient(); } -/** - * Тест метода webService()->getPaymentMethods() - */ - public function testGetPaymentMethods(): void { - $xml = 'Card'; - $this->http->queueResponse(new Response($xml, 200)); - $robo = $this->createRobo(); - $res = $robo->webService()->getPaymentMethods('ru'); - $this->assertSame('Card', $res['Method']); + public function testCreateMainClient(): void { + $this->assertInstanceOf(Robokassa::class, $this->createRobo()); } -/** - * Тест метода webService()->opState() - */ - public function testOpState(): void { - $xml = 'ok'; - $this->http->queueResponse(new Response($xml, 200)); + public function testFacadeReturnsAllServices(): void { $robo = $this->createRobo(); - $res = $robo->webService()->opState(1); - $this->assertSame('ok', $res['OpState']); + + $this->assertInstanceOf(PaymentService::class, $robo->payment()); + $this->assertInstanceOf(ReceiptService::class, $robo->receipt()); + $this->assertInstanceOf(StatusService::class, $robo->status()); + $this->assertInstanceOf(WebService::class, $robo->webService()); } - /** - * Тест метода payment()->sendCurl() - */ - public function testSendCurl(): void { - $body = json_encode(['invoiceID' => 10]); - $this->http->queueResponse(new Response($body, 200)); - $robo = $this->createRobo(); - $url = $robo->payment()->sendCurl(['OutSum' => 5, 'Description' => 'test']); - $this->assertStringContainsString('10', $url); + public function testAutoloadsPublicClassesSeparately(): void { + $this->assertTrue(class_exists('Robokassa\Client\HttpClient')); + $this->assertTrue(interface_exists('Robokassa\Client\HttpClientInterface')); + $this->assertTrue(class_exists('Robokassa\Client\Response')); + $this->assertTrue(class_exists('Robokassa\Exception\RobokassaException')); + $this->assertTrue(class_exists('Robokassa\Robokassa')); + $this->assertTrue(class_exists('Robokassa\Service\PaymentService')); + $this->assertTrue(class_exists('Robokassa\Service\ReceiptService')); + $this->assertTrue(class_exists('Robokassa\Service\StatusService')); + $this->assertTrue(class_exists('Robokassa\Service\WebService')); + $this->assertTrue(class_exists('Robokassa\Signature\HashAlgorithmResolver')); + $this->assertTrue(class_exists('Robokassa\Signature\SignatureService')); } /** - * Тест метода payment()->sendJwt() + * @dataProvider signatureAlgorithmProvider */ - public function testSendJwt(): void { - $body = json_encode(['url' => 'https://pay']); - $this->http->queueResponse(new Response($body, 200)); - $robo = $this->createRobo(); - $url = $robo->payment()->sendJwt(['InvId' => 1, 'OutSum' => 1]); + public function testSignatureAlgorithms(string $algorithm): void { + $sign = new SignatureService($algorithm); + $params = array('OutSum' => '10', 'InvoiceID' => '20'); + + $this->assertSame( + hash($algorithm, 'login:10:20:p1'), + $sign->createPaymentSignature($params, 'login', 'p1') + ); + } + + public function signatureAlgorithmProvider(): array { + return array( + array('md5'), + array('ripemd160'), + array('sha1'), + array('sha256'), + array('sha384'), + array('sha512'), + ); + } + + public function testUnknownSignatureAlgorithmThrows(): void { + $sign = new SignatureService('md5'); + + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage('Неизвестный алгоритм подписи'); + + $sign->createPaymentSignature(array('OutSum' => 1, 'InvoiceID' => 1), 'login', 'p1', 'crc32'); + } + + public function testUnknownFacadeHashTypeThrows(): void { + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage('Неизвестный алгоритм подписи'); + + $this->createRobo(null, array('hashType' => 'crc32')); + } + + public function testGetPaymentMethods(): void { + $this->http->queueResponse(new Response('Card', 200)); + + $res = $this->createRobo()->webService()->getPaymentMethods('ru'); + + $this->assertSame('Card', $res['Method']); + } + + public function testOpStateKeepsStringIdentifiersFromXml(): void { + $this->http->queueResponse(new Response('00123', 200)); + + $res = $this->createRobo()->webService()->opState(123); + + $this->assertSame('00123', $res['InvoiceID']); + } + + public function testSendCurlKeepsCompatibilityAndExactRequest(): void { + $this->http->queueResponse(new Response('{"invoiceID":10}', 200)); + + $url = $this->createRobo()->payment()->sendCurl(array( + 'OutSum' => 5, + 'InvoiceID' => 9, + 'Description' => 'test', + 'Shp_order' => 'abc 1', + )); + + $this->assertSame('https://auth.robokassa.ru/Merchant/Index/10', $url); + $this->assertSame('https://auth.robokassa.ru/Merchant/Indexjson.aspx', $this->http->lastUrl); + $this->assertSame(array('Content-Type' => 'application/x-www-form-urlencoded'), $this->http->lastHeaders); + $this->assertSame($this->expectedCurlBody(), $this->http->lastBody); + } + + public function testSendCurlIsDeprecated(): void { + $method = new \ReflectionMethod(PaymentService::class, 'sendCurl'); + + $this->assertStringContainsString('@deprecated', (string)$method->getDocComment()); + $this->assertStringContainsString('sendJwt()', (string)$method->getDocComment()); + } + + public function testSendJwtBuildsCurrentJwtAndHeaders(): void { + $this->http->queueResponse(new Response('{"url":"https://pay"}', 200)); + + $url = $this->createRobo()->payment()->sendJwt(array('InvId' => 1, 'OutSum' => 1, 'Description' => 'test')); + $this->assertSame('https://pay', $url); + $this->assertSame('https://services.robokassa.ru/InvoiceServiceWebApi/api/CreateInvoice', $this->http->lastUrl); + $this->assertSame(array('Content-Type' => 'application/json'), $this->http->lastHeaders); + $this->assertSame($this->expectedJwtBody(), $this->http->lastBody); } - /** - * Тест метода receipt()->getCheckStatus() - */ public function testGetCheckStatus(): void { - $body = json_encode(['state' => 1]); - $this->http->queueResponse(new Response($body, 200)); - $robo = $this->createRobo(); - $res = $robo->receipt()->getCheckStatus(['merchantId' => 'm', 'id' => '1']); + $this->http->queueResponse(new Response('{"state":1}', 200)); + + $res = $this->createRobo()->receipt()->getCheckStatus(array('merchantId' => 'm', 'id' => '1')); + $this->assertSame(1, $res['state']); + $this->assertSame(array('Content-Type' => 'application/json; charset=utf-8'), $this->http->lastHeaders); } - /** - * Тест метода receipt()->sendSecondCheck() - */ public function testSendSecondCheck(): void { $this->http->queueResponse(new Response('ok', 200)); - $robo = $this->createRobo(); - $res = $robo->receipt()->sendSecondCheck(['a' => 'b']); + + $res = $this->createRobo()->receipt()->sendSecondCheck(array('a' => 'b')); + $this->assertSame('ok', $res); + $this->assertSame(array('Content-Type' => 'application/json'), $this->http->lastHeaders); } - /** - * Тест метода StatusService::getInvoiceInformationList() - */ - public function testGetInvoiceInformationList(): void { - $body = json_encode(['items' => []]); - $this->http->queueResponse(new Response($body, 200)); + public function testFacadeStatusUsesPassedSignatureService(): void { + $sign = new FixedSignatureService('md5'); + $this->http->queueResponse(new Response('{"items":[]}', 200)); + + $res = $this->createRobo($sign)->status()->getInvoiceInformationList($this->statusFilters()); + + $this->assertSame(array('items' => array()), $res); + $this->assertSame(1, $sign->jwtSignCalls); + $this->assertStringContainsString('fixed-signature', $this->http->lastBody); + } + + public function testDirectStatusServiceCreationStillWorks(): void { + $this->http->queueResponse(new Response('{"items":[]}', 200)); $status = new StatusService($this->http, 'login', 'p1'); - $res = $status->getInvoiceInformationList([ - 'CurrentPage' => 1, - 'PageSize' => 1, - 'InvoiceStatuses' => ['paid'], - 'DateFrom' => '2024-01-01', - 'DateTo' => '2024-01-02', - 'InvoiceTypes' => ['onetime'], - ]); - $this->assertIsArray($res); + + $res = $status->getInvoiceInformationList($this->statusFilters()); + + $this->assertSame(array('items' => array()), $res); } - /** - * Создание экземпляра Robokassa для тестов - */ - private function createRobo(): Robokassa { - return new Robokassa([ + public function testBadHttpStatusThrowsBeforeJsonParsing(): void { + $this->http->queueResponse(new Response('secret-signature-body', 500)); + + try { + $this->createRobo()->payment()->sendJwt(array('InvId' => 1, 'OutSum' => 1)); + $this->fail('Ожидалось исключение RobokassaException'); + } catch (RobokassaException $e) { + $this->assertStringContainsString('HTTP Status: 500', $e->getMessage()); + $this->assertStringNotContainsString('secret-signature-body', $e->getMessage()); + } + } + + public function testHttpClientNetworkErrorUsesRobokassaException(): void { + $mock = new MockHandler(array( + new ConnectException('secret-signature-network-error', new Request('GET', 'https://example.test')), + )); + $client = new HttpClient(new GuzzleClient(array('handler' => HandlerStack::create($mock)))); + + try { + $client->get('https://example.test'); + $this->fail('Ожидалось исключение RobokassaException'); + } catch (RobokassaException $e) { + $this->assertSame('Сетевая ошибка HTTP GET', $e->getMessage()); + $this->assertStringNotContainsString('secret-signature-network-error', $e->getMessage()); + $this->assertInstanceOf(ConnectException::class, $e->getPrevious()); + } + } + + public function testHttpClientBadStatusUsesRobokassaException(): void { + $mock = new MockHandler(array( + new \GuzzleHttp\Psr7\Response(500, array(), 'secret-signature-body'), + )); + $client = new HttpClient(new GuzzleClient(array('handler' => HandlerStack::create($mock)))); + + try { + $client->post('https://example.test', 'body'); + $this->fail('Ожидалось исключение RobokassaException'); + } catch (RobokassaException $e) { + $this->assertStringContainsString('HTTP Status: 500', $e->getMessage()); + $this->assertStringNotContainsString('secret-signature-body', $e->getMessage()); + } + } + + public function testInvalidJsonThrowsRobokassaException(): void { + $this->http->queueResponse(new Response('{bad', 200)); + + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage('Некорректный JSON'); + + $this->createRobo()->payment()->sendJwt(array('InvId' => 1, 'OutSum' => 1)); + } + + public function testInvalidXmlThrowsRobokassaException(): void { + $this->http->queueResponse(new Response('', 200)); + + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage('Некорректный XML'); + + $this->createRobo()->webService()->getPaymentMethods('ru'); + } + + public function testEmptyResponseThrowsRobokassaException(): void { + $this->http->queueResponse(new Response('', 200)); + + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage('Пустой JSON-ответ'); + + $this->createRobo()->payment()->sendJwt(array('InvId' => 1, 'OutSum' => 1)); + } + + private function createRobo(?SignatureService $signer = null, array $overrides = array()): Robokassa { + return new Robokassa(array_merge(array( 'login' => 'login', 'password1' => 'p1', 'password2' => 'p2', 'hashType' => 'md5', - ], $this->http); + ), $overrides), $this->http, $signer); + } + + private function expectedCurlBody(): string { + return 'OutSum=5&InvoiceID=9&Description=test&Shp_order=abc%2B1' + . '&MerchantLogin=login&SignatureValue=46e153d00db13ba3848be7d08f1cf62f'; + } + + private function expectedJwtBody(): string { + return '"eyJhbGciOiJNRDUiLCJ0eXAiOiJKV1QifQ.' + . 'eyJNZXJjaGFudExvZ2luIjoibG9naW4iLCJJbnZvaWNlVHlwZSI6Ik9uZVRpbWUiLCJDdWx0dXJlIjoicnUiLCJJbnZJZCI6MSwiT3V0U3VtIjoxLCJEZXNjcmlwdGlvbiI6InRlc3QifQ.' + . 'FKHP-6TuMui4tsnqUvjumw"'; + } + + private function statusFilters(): array { + return array( + 'CurrentPage' => 1, + 'PageSize' => 1, + 'InvoiceStatuses' => array('paid'), + 'DateFrom' => '2024-01-01', + 'DateTo' => '2024-01-02', + 'InvoiceTypes' => array('onetime'), + ); + } +} + +class FixedSignatureService extends SignatureService { + /** @var int */ + public $jwtSignCalls = 0; + + public function jwtSignMd5($dataToSign, $merchantLogin, $password1) { + $this->jwtSignCalls++; + return 'fixed-signature'; } } From 629b3a08bcf14d7ebcccdd4ff6520e1409feede9 Mon Sep 17 00:00:00 2001 From: IvanKirsanov Date: Tue, 25 Aug 2026 15:44:13 +0700 Subject: [PATCH 2/2] some fixes --- composer.json | 2 +- examples/send_payment_jwt.php | 2 +- src/Client/HttpClient.php | 8 +++----- src/Service/StatusService.php | 20 +++++++++++--------- tests/ExamplesTest.php | 35 ++++++++++++++++++++++++++++++++--- 5 files changed, 48 insertions(+), 19 deletions(-) diff --git a/composer.json b/composer.json index 2a7254a..51fddb1 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "guzzlehttp/guzzle": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^9.6.27" + "phpunit/phpunit": "^9.6.33" }, "autoload": { "psr-4": { diff --git a/examples/send_payment_jwt.php b/examples/send_payment_jwt.php index 410144c..5df8e82 100644 --- a/examples/send_payment_jwt.php +++ b/examples/send_payment_jwt.php @@ -12,7 +12,7 @@ $params = [ 'InvId' => 133765623, - 'OutSum' => 1, + 'OutSum' => 10, 'Description' => 'Оплата тестового заказа', 'MerchantComments' => 'Без комментариев', 'InvoiceType' => 'Reusable', diff --git a/src/Client/HttpClient.php b/src/Client/HttpClient.php index 7fb9fa1..a9e1a4f 100644 --- a/src/Client/HttpClient.php +++ b/src/Client/HttpClient.php @@ -47,14 +47,12 @@ private function request(string $method, string $url, ?string $body, array $head $response = $e->getResponse(); if ($response !== null) { throw new RobokassaException( - 'Ошибка HTTP ' . strtoupper($method) . ': HTTP Status: ' . $response->getStatusCode(), - 0, - $e + 'Ошибка HTTP ' . strtoupper($method) . ': HTTP Status: ' . $response->getStatusCode() ); } - throw new RobokassaException('Сетевая ошибка HTTP ' . strtoupper($method), 0, $e); + throw new RobokassaException('Сетевая ошибка HTTP ' . strtoupper($method)); } catch (GuzzleException $e) { - throw new RobokassaException('Сетевая ошибка HTTP ' . strtoupper($method), 0, $e); + throw new RobokassaException('Сетевая ошибка HTTP ' . strtoupper($method)); } } } diff --git a/src/Service/StatusService.php b/src/Service/StatusService.php index d8e16d4..0cde945 100644 --- a/src/Service/StatusService.php +++ b/src/Service/StatusService.php @@ -22,16 +22,18 @@ class StatusService { * @param string $merchantLogin * @param string $password1 * @param SignatureService|null $sign + * @throws RobokassaException */ - public function __construct( - HttpClientInterface $http, - string $merchantLogin, - string $password1, - ?SignatureService $sign = null - ) { + public function __construct($http, $merchantLogin, $password1, $sign = null) { + if (!$http instanceof HttpClientInterface) { + throw new RobokassaException('Param http must implement HttpClientInterface'); + } + if ($sign !== null && !$sign instanceof SignatureService) { + throw new RobokassaException('Param sign must be instance of SignatureService'); + } $this->http = $http; - $this->merchantLogin = $merchantLogin; - $this->password1 = $password1; + $this->merchantLogin = (string)$merchantLogin; + $this->password1 = (string)$password1; $this->sign = $sign ?: new SignatureService('md5'); } @@ -44,7 +46,7 @@ public function __construct( * @return array * @throws RobokassaException */ - public function getInvoiceInformationList(array $filters): array { + public function getInvoiceInformationList(array $filters) { $this->assertRequiredFilters($filters); $filters = $this->normalizeFilters($filters); $jwt = $this->buildJwt($filters); diff --git a/tests/ExamplesTest.php b/tests/ExamplesTest.php index 3ce38ee..e1ddc56 100644 --- a/tests/ExamplesTest.php +++ b/tests/ExamplesTest.php @@ -182,6 +182,17 @@ public function testDirectStatusServiceCreationStillWorks(): void { $this->assertSame(array('items' => array()), $res); } + public function testStatusServicePublicContractKeepsOldNativeTypes(): void { + $constructor = new \ReflectionMethod(StatusService::class, '__construct'); + $params = $constructor->getParameters(); + $method = new \ReflectionMethod(StatusService::class, 'getInvoiceInformationList'); + + $this->assertFalse($params[0]->hasType()); + $this->assertFalse($params[1]->hasType()); + $this->assertFalse($params[2]->hasType()); + $this->assertNull($method->getReturnType()); + } + public function testBadHttpStatusThrowsBeforeJsonParsing(): void { $this->http->queueResponse(new Response('secret-signature-body', 500)); @@ -205,8 +216,12 @@ public function testHttpClientNetworkErrorUsesRobokassaException(): void { $this->fail('Ожидалось исключение RobokassaException'); } catch (RobokassaException $e) { $this->assertSame('Сетевая ошибка HTTP GET', $e->getMessage()); - $this->assertStringNotContainsString('secret-signature-network-error', $e->getMessage()); - $this->assertInstanceOf(ConnectException::class, $e->getPrevious()); + $this->assertNull($e->getPrevious()); + $this->assertExceptionChainDoesNotContain($e, array( + 'secret-signature-network-error', + 'signature', + 'https://example.test', + )); } } @@ -221,7 +236,12 @@ public function testHttpClientBadStatusUsesRobokassaException(): void { $this->fail('Ожидалось исключение RobokassaException'); } catch (RobokassaException $e) { $this->assertStringContainsString('HTTP Status: 500', $e->getMessage()); - $this->assertStringNotContainsString('secret-signature-body', $e->getMessage()); + $this->assertNull($e->getPrevious()); + $this->assertExceptionChainDoesNotContain($e, array( + 'secret-signature-body', + 'signature', + 'https://example.test', + )); } } @@ -282,6 +302,15 @@ private function statusFilters(): array { 'InvoiceTypes' => array('onetime'), ); } + + private function assertExceptionChainDoesNotContain(\Throwable $exception, array $needles): void { + do { + foreach ($needles as $needle) { + $this->assertStringNotContainsString($needle, $exception->getMessage()); + } + $exception = $exception->getPrevious(); + } while ($exception !== null); + } } class FixedSignatureService extends SignatureService {