From 3d5466b881dc1254b7164492aacc910179e8dce9 Mon Sep 17 00:00:00 2001 From: IvanKirsanov Date: Wed, 9 Sep 2026 11:00:46 +0700 Subject: [PATCH] feat(679): added holding payment --- .env.example | 8 +- README.md | 73 +++++++-- examples/hold_payment.php | 67 +++++++++ src/Robokassa.php | 6 +- src/Service/PaymentService.php | 223 ++++++++++++++++++++++++++- src/Signature/SignatureService.php | 50 ++++++ tests/ExamplesTest.php | 234 +++++++++++++++++++++++++++++ 7 files changed, 647 insertions(+), 14 deletions(-) create mode 100644 examples/hold_payment.php diff --git a/.env.example b/.env.example index 366807f..48def08 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,10 @@ # Переименуйте в ".env" и заполните своими данными ROBOKASSA_LOGIN= ROBOKASSA_PASSWORD1= -ROBOKASSA_PASSWORD2= \ No newline at end of file +ROBOKASSA_PASSWORD2= + +# Параметры примера hold_payment.php +ROBOKASSA_HOLD_ACTION=create +ROBOKASSA_HOLD_INVOICE_ID= +ROBOKASSA_HOLD_OUT_SUM= +ROBOKASSA_HOLD_RESULT_URL2= diff --git a/README.md b/README.md index 0725d3f..150672d 100644 --- a/README.md +++ b/README.md @@ -39,16 +39,19 @@ md5, ripemd160, sha1, sha256, sha384, sha512 ## Доступные методы -| Метод | Описание | Документация | -| --- | --- | --- | -| `payment()->sendJwt(array $params): string` | Рекомендуемый способ. Создаёт ссылку на оплату через JWT-интерфейс. | [Invoice API](https://docs.robokassa.ru/ru/invoice-api) | +| Метод | Описание | Документация | +| --- |------------------------------------------------------------------------------| --- | +| `payment()->sendJwt(array $params): string` | Рекомендуемый способ. Создаёт ссылку на оплату через JWT-интерфейс. | [Invoice API](https://docs.robokassa.ru/ru/invoice-api) | | `payment()->sendSavedCard(array $params): string` | Создаёт счёт для оплаты по сохранённой банковской карте через JWT-интерфейс. | [Оплата по сохраненной карте](https://docs.robokassa.ru/ru/saving) | -| `payment()->sendRecurring(array $params): string` | Создаёт дочерний рекуррентный платёж по оплаченной материнской операции. | [Периодические платежи](https://docs.robokassa.ru/ru/recurring-payments) | -| `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) | +| `payment()->sendHold(array $params): string` | Создаёт счёт для двухстадийной оплаты. | [Холдирование](https://docs.robokassa.ru/ru/holding.html) | +| `payment()->confirmHold(int $invoiceID, string $outSum, ?array $receipt = null): bool` | Подтверждает списание удержанных средств. | [Холдирование](https://docs.robokassa.ru/ru/holding.html#request) | +| `payment()->cancelHold(int $invoiceID, string $outSum): bool` | Отменяет холдирование. | [Холдирование](https://docs.robokassa.ru/ru/holding.html#cancel) | +| `payment()->sendRecurring(array $params): string` | Создаёт дочерний рекуррентный платёж по оплаченной материнской операции. | [Периодические платежи](https://docs.robokassa.ru/ru/recurring-payments) | +| `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) | ## Создание ссылки на оплату через JWT @@ -89,6 +92,57 @@ SDK передаст токен в поле `Token` внутри массива Если `AdditionalParameters` уже содержит другие значения, они сохранятся. `Token` нельзя совмещать с `Recurring` и `StepByStep` в одном счёте. +## Холдирование + +Опция должна быть предварительно подключена для магазина и работает только с платежами банковскими картами. Для создания двухстадийного платежа используйте `sendHold()`: + +```php +$url = $robokassa->payment()->sendHold([ + 'InvId' => 400001, + 'OutSum' => '100.00', + 'Description' => 'Оплата заказа #400001', + 'AdditionalParameters' => [ + 'ResultURL2' => 'https://example.com/robokassa/result2', + ], +]); +``` + +SDK создаст одноразовый счёт и самостоятельно добавит строковый параметр: + +```php +'AdditionalParameters' => [ + 'StepByStep' => 'true', +] +``` + +`StepByStep` нельзя совмещать с `Recurring` и `Token`. Уведомление о переходе операции в `HOLD` поступает на `ResultURL2`; подпись входящего JWS необходимо проверить до изменения состояния заказа. + +После получения состояния `HOLD` подтвердите списание: + +```php +$accepted = $robokassa->payment()->confirmHold(400001, '100.00'); +``` + +При необходимости в третьем аргументе можно передать обновлённый чек. Сумму и состав корзины разрешено изменять только в меньшую сторону: + +```php +$accepted = $robokassa->payment()->confirmHold(400001, '90.00', $updatedReceipt); +``` + +Для отмены холда: + +```php +$accepted = $robokassa->payment()->cancelHold(400001, '100.00'); +``` + +Возвращаемое значение показывает только, принят ли запрос Robokassa. Оно не является конечным статусом операции. После Confirm или Cancel проверьте состояние через `webService()->opState()`: + +```php +$state = $robokassa->webService()->opState(400001); +``` + +Основные коды состояния холда: `20` — средства удержаны, `50` — операция обрабатывается, `60` — холд отменён, `100` — списание подтверждено. При `false` или сетевой ошибке не повторяйте Confirm/Cancel автоматически: сначала запросите состояние операции. Confirm и Cancel не поддерживают тестовый режим. + ## Рекуррентные платежи Для материнского платежа создайте обычный счёт через `sendJwt()` и передайте `Recurring=true` в `AdditionalParameters`: @@ -177,6 +231,7 @@ $url = $robokassa->payment()->sendCurl([ * [`send_payment_jwt.php`](./examples/send_payment_jwt.php) — создание ссылки на оплату через JWT. * [`send_saved_card_payment.php`](./examples/send_saved_card_payment.php) — создание счёта для оплаты по сохранённой карте. +* [`hold_payment.php`](./examples/hold_payment.php) — создание, подтверждение, отмена и проверка двухстадийного платежа. * [`send_recurring_payment.php`](./examples/send_recurring_payment.php) — создание дочернего рекуррентного платежа по оплаченной материнской операции. * [`get_invoice_information.php`](./examples/get_invoice_information.php) — получение списка счетов через `$robokassa->status()`. * [`get_payment_methods.php`](./examples/get_payment_methods.php) — получение доступных способов оплаты. diff --git a/examples/hold_payment.php b/examples/hold_payment.php new file mode 100644 index 0000000..20ed500 --- /dev/null +++ b/examples/hold_payment.php @@ -0,0 +1,67 @@ +payment()->sendHold([ + 'InvId' => $invoiceID, + 'OutSum' => $outSum, + 'Description' => 'Двухстадийная оплата заказа #' . $invoiceID, + 'AdditionalParameters' => [ + 'ResultURL2' => $resultUrl2, + ], + ]); + echo "Ссылка на оплату с холдированием: $url\n"; + break; + + case 'confirm': + $accepted = $robokassa->payment()->confirmHold($invoiceID, $outSum); + echo $accepted + ? "Запрос подтверждения принят. Проверьте итоговый статус операции.\n" + : "Запрос подтверждения отклонён. Не повторяйте его автоматически; проверьте статус.\n"; + break; + + case 'cancel': + $accepted = $robokassa->payment()->cancelHold($invoiceID, $outSum); + echo $accepted + ? "Запрос отмены принят. Проверьте итоговый статус операции.\n" + : "Запрос отмены отклонён. Не повторяйте его автоматически; проверьте статус.\n"; + break; + + case 'status': + print_r($robokassa->webService()->opState($invoiceID)); + break; + + default: + throw new InvalidArgumentException('ROBOKASSA_HOLD_ACTION должен быть create, confirm, cancel или status.'); + } +} catch (Throwable $e) { + echo 'Ошибка: ' . $e->getMessage() . "\n"; +} diff --git a/src/Robokassa.php b/src/Robokassa.php index f06f881..6f8119a 100644 --- a/src/Robokassa.php +++ b/src/Robokassa.php @@ -20,6 +20,8 @@ class Robokassa { private string $paymentCurl = 'https://auth.robokassa.ru/Merchant/Indexjson.aspx'; private string $jwtApiUrl = 'https://services.robokassa.ru/InvoiceServiceWebApi/api/CreateInvoice'; private string $recurringUrl = 'https://auth.robokassa.ru/Merchant/Recurring'; + private string $holdConfirmUrl = 'https://auth.robokassa.ru/Merchant/Payment/Confirm'; + private string $holdCancelUrl = 'https://auth.robokassa.ru/Merchant/Payment/Cancel'; private string $webServiceUrl = 'https://auth.robokassa.ru/Merchant/WebService/Service.asmx'; private bool $is_test = false; @@ -129,7 +131,9 @@ private function createPaymentService(): PaymentService { $this->paymentCurl, $this->jwtApiUrl, $this->hashType, - $this->recurringUrl + $this->recurringUrl, + $this->holdConfirmUrl, + $this->holdCancelUrl ); } diff --git a/src/Service/PaymentService.php b/src/Service/PaymentService.php index 72a720e..61bb2ac 100644 --- a/src/Service/PaymentService.php +++ b/src/Service/PaymentService.php @@ -17,6 +17,8 @@ class PaymentService { private string $jwtApiUrl; private string $hashType; private string $recurringUrl; + private string $holdConfirmUrl; + private string $holdCancelUrl; public function __construct( HttpClientInterface $http, @@ -28,7 +30,9 @@ public function __construct( string $paymentCurl, string $jwtApiUrl, string $hashType, - string $recurringUrl = 'https://auth.robokassa.ru/Merchant/Recurring' + string $recurringUrl = 'https://auth.robokassa.ru/Merchant/Recurring', + string $holdConfirmUrl = 'https://auth.robokassa.ru/Merchant/Payment/Confirm', + string $holdCancelUrl = 'https://auth.robokassa.ru/Merchant/Payment/Cancel' ) { $this->http = $http; $this->sign = $sign; @@ -40,6 +44,8 @@ public function __construct( $this->jwtApiUrl = $jwtApiUrl; $this->hashType = $hashType; $this->recurringUrl = $recurringUrl; + $this->holdConfirmUrl = $holdConfirmUrl; + $this->holdCancelUrl = $holdCancelUrl; } /** @@ -78,6 +84,7 @@ public function sendCurl(array $params): string { * @throws RobokassaException */ public function sendJwt(array $params): string { + $params = $this->prepareJwtEnvironmentParams($params); $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); @@ -88,6 +95,12 @@ public function sendJwt(array $params): string { ); $this->assertSuccessStatus($resp, 'JWT request failed.'); $data = $this->decodeJsonResponse($resp->body); + if (($data['isSuccess'] ?? null) === false) { + $message = isset($data['message']) && is_string($data['message']) + ? ': ' . $data['message'] + : '.'; + throw new RobokassaException('Invoice API request failed' . $message); + } if (!empty($data['url'])) { return $data['url']; } @@ -105,6 +118,91 @@ public function sendSavedCard(array $params): string { return $this->sendJwt($this->prepareSavedCardParams($params)); } + /** + * Создание счёта с двухстадийной оплатой через Invoice API. + * + * @param array $params + * @return string + * @throws RobokassaException + */ + public function sendHold(array $params): string { + return $this->sendJwt($this->prepareHoldParams($params)); + } + + /** + * Подтверждение списания удержанных средств. + * + * Возвращаемое значение означает, что запрос принят или отклонён. Итоговое + * состояние операции необходимо проверять через OpStateExt. + * + * @param int $invoiceID + * @param string $outSum + * @param array|null $receipt + * @return bool + * @throws RobokassaException + */ + public function confirmHold(int $invoiceID, string $outSum, ?array $receipt = null): bool { + $this->assertHoldActionParams($invoiceID, $outSum); + $params = array( + 'MerchantLogin' => $this->merchantLogin, + 'InvoiceID' => $invoiceID, + 'OutSum' => $outSum, + ); + $encodedReceipt = null; + if ($receipt !== null) { + $encodedReceipt = urlencode($this->encodeJson($receipt)); + $params['Receipt'] = $encodedReceipt; + } + $params['SignatureValue'] = $this->sign->signHoldConfirm( + $this->merchantLogin, + $outSum, + (string)$invoiceID, + $this->password1, + $encodedReceipt, + $this->hashType + ); + + $resp = $this->http->post($this->holdConfirmUrl, http_build_query($params), array( + 'Content-Type' => 'application/x-www-form-urlencoded', + )); + $this->assertSuccessStatus($resp, 'Hold confirmation request failed.'); + + return $this->decodeHoldActionResponse($resp->body); + } + + /** + * Отмена холдирования. + * + * Возвращаемое значение означает, что запрос принят или отклонён. Итоговое + * состояние операции необходимо проверять через OpStateExt. + * + * @param int $invoiceID + * @param string $outSum + * @return bool + * @throws RobokassaException + */ + public function cancelHold(int $invoiceID, string $outSum): bool { + $this->assertHoldActionParams($invoiceID, $outSum); + $params = array( + 'MerchantLogin' => $this->merchantLogin, + 'InvoiceID' => $invoiceID, + 'OutSum' => $outSum, + 'SignatureValue' => $this->sign->signHoldCancel( + $this->merchantLogin, + (string)$invoiceID, + $this->password1, + $this->hashType + ), + ); + + $resp = $this->http->post($this->holdCancelUrl, http_build_query($params), array( + 'Content-Type' => 'application/x-www-form-urlencoded', + )); + $this->assertSuccessStatus($resp, 'Hold cancellation request failed.'); + + return $this->decodeHoldActionResponse($resp->body); + } + /** * Создание дочернего рекуррентного платежа. * @@ -147,6 +245,80 @@ private function prepareCurlParams(array $params): array { return $this->encodeShpParams($params); } + /** + * Согласует тестовый режим клиента с параметрами Invoice API. + * + * @param array $params + * @return array + * @throws RobokassaException + */ + private function prepareJwtEnvironmentParams(array $params): array { + if (!$this->isTest) { + return $params; + } + $additional = $this->getAdditionalParameters($params); + if (array_key_exists('IsTest', $additional) && (string)$additional['IsTest'] !== '1') { + throw new RobokassaException('Conflicting Invoice API test mode parameter: AdditionalParameters.IsTest'); + } + $additional['IsTest'] = '1'; + $params['AdditionalParameters'] = $additional; + + return $params; + } + + /** + * Подготовка параметров создания холда через Invoice API. + * + * @param array $params + * @return array + * @throws RobokassaException + */ + private function prepareHoldParams(array $params): array { + foreach (array('InvId', 'OutSum') as $required) { + if (!array_key_exists($required, $params)) { + throw new RobokassaException('Required hold parameters: InvId, OutSum'); + } + } + if (!$this->isPositiveInteger($params['InvId'])) { + throw new RobokassaException('Invalid hold parameter InvId: positive integer expected.'); + } + if (!$this->isPositiveAmount($params['OutSum'])) { + throw new RobokassaException('Invalid hold parameter OutSum: positive decimal expected.'); + } + if (isset($params['InvoiceType']) && $params['InvoiceType'] !== 'OneTime') { + throw new RobokassaException('Hold payments support only InvoiceType OneTime.'); + } + if (array_key_exists('StepByStep', $params)) { + throw new RobokassaException('Hold parameter StepByStep must be passed inside AdditionalParameters.'); + } + + $additional = $this->getAdditionalParameters($params); + foreach ($additional as $name => $value) { + if (!is_string($value)) { + throw new RobokassaException( + 'Invalid hold parameter AdditionalParameters.' . $name . ': string expected.' + ); + } + } + foreach (array('Recurring', 'Token') as $name) { + if (array_key_exists($name, $params)) { + throw new RobokassaException('Forbidden hold parameter: ' . $name); + } + if (array_key_exists($name, $additional)) { + throw new RobokassaException('Forbidden hold parameter: AdditionalParameters.' . $name); + } + } + if (array_key_exists('StepByStep', $additional) && $additional['StepByStep'] !== 'true') { + throw new RobokassaException('Conflicting hold StepByStep value.'); + } + + $additional['StepByStep'] = 'true'; + $params['InvoiceType'] = 'OneTime'; + $params['AdditionalParameters'] = $additional; + + return $params; + } + /** * Подготовка параметров дочернего рекуррентного платежа. * @@ -198,7 +370,7 @@ private function prepareRecurringParams(array $params): array { * @throws RobokassaException */ private function prepareSavedCardParams(array $params): array { - $additional = $this->getSavedCardAdditionalParameters($params); + $additional = $this->getAdditionalParameters($params); $rootTokenExists = array_key_exists('Token', $params); $additionalTokenExists = array_key_exists('Token', $additional); @@ -245,7 +417,7 @@ private function normalizeSavedCardToken($token): string { * @return array * @throws RobokassaException */ - private function getSavedCardAdditionalParameters(array $params): array { + private function getAdditionalParameters(array $params): array { if (!array_key_exists('AdditionalParameters', $params)) { return array(); } @@ -255,6 +427,26 @@ private function getSavedCardAdditionalParameters(array $params): array { return $params['AdditionalParameters']; } + /** + * Проверяет параметры подтверждения и отмены холда. + * + * @param int $invoiceID + * @param string $outSum + * @return void + * @throws RobokassaException + */ + private function assertHoldActionParams(int $invoiceID, string $outSum): void { + if ($this->isTest) { + throw new RobokassaException('Hold confirmation and cancellation are not supported in test mode.'); + } + if ($invoiceID <= 0) { + throw new RobokassaException('Invalid hold parameter InvoiceID: positive integer expected.'); + } + if (!$this->isPositiveAmount($outSum)) { + throw new RobokassaException('Invalid hold parameter OutSum: positive decimal expected.'); + } + } + /** * Проверяет взаимоисключающие параметры Invoice API для оплаты по сохранённой карте. * @@ -484,4 +676,29 @@ private function decodeRecurringResponse(string $body): string { } return $body; } + + /** + * Разбирает JSON-строку ответа Confirm/Cancel. + * + * @param string $body + * @return bool + * @throws RobokassaException + */ + private function decodeHoldActionResponse(string $body): bool { + if (trim($body) === '') { + throw new RobokassaException('Empty hold action response.'); + } + $data = json_decode($body, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RobokassaException('Invalid JSON in hold action response: ' . json_last_error_msg()); + } + if ($data === 'success: true') { + return true; + } + if ($data === 'success: false') { + return false; + } + + throw new RobokassaException('Unexpected hold action response.'); + } } diff --git a/src/Signature/SignatureService.php b/src/Signature/SignatureService.php index a1738f6..92251d3 100644 --- a/src/Signature/SignatureService.php +++ b/src/Signature/SignatureService.php @@ -89,6 +89,56 @@ public function createPaymentSignature(array $params, $login, $password1, $algo return hash($this->resolveAlgorithm($algo), $hashString); } + /** + * Подпись запроса подтверждения холда. + * + * Формат без чека: + * hash(algo, "{login}:{outSum}:{invoiceID}:{password1}") + * + * Формат с чеком: + * hash(algo, "{login}:{outSum}:{invoiceID}:{receipt}:{password1}") + * + * @param string $login + * @param string $outSum + * @param string $invoiceID + * @param string $password1 + * @param string|null $receipt URL-кодированный JSON чека + * @param string|null $algo + * @return string + * @throws RobokassaException + */ + public function signHoldConfirm($login, $outSum, $invoiceID, $password1, $receipt = null, $algo = null) { + $parts = array($login, $outSum, $invoiceID); + if ($receipt !== null) { + $parts[] = $receipt; + } + $parts[] = $password1; + + return hash($this->resolveAlgorithm($algo), implode(':', $parts)); + } + + /** + * Подпись запроса отмены холда. + * + * Формат: + * hash(algo, "{login}::{invoiceID}:{password1}") + * + * Пустой сегмент суммы обязателен. + * + * @param string $login + * @param string $invoiceID + * @param string $password1 + * @param string|null $algo + * @return string + * @throws RobokassaException + */ + public function signHoldCancel($login, $invoiceID, $password1, $algo = null) { + return hash( + $this->resolveAlgorithm($algo), + $login . '::' . $invoiceID . ':' . $password1 + ); + } + /** * Собирает строку для подписи платёжного запроса. * diff --git a/tests/ExamplesTest.php b/tests/ExamplesTest.php index 5474a84..cd0e234 100644 --- a/tests/ExamplesTest.php +++ b/tests/ExamplesTest.php @@ -65,6 +65,24 @@ public function testSignatureAlgorithms(string $algorithm): void { hash($algorithm, 'login:10:20:p1'), $sign->createPaymentSignature($params, 'login', 'p1') ); + $this->assertSame( + hash($algorithm, 'login:10:20:p1'), + $sign->signHoldConfirm('login', '10', '20', 'p1') + ); + $this->assertSame( + hash($algorithm, 'login::20:p1'), + $sign->signHoldCancel('login', '20', 'p1') + ); + } + + public function testHoldConfirmSignatureIncludesEncodedReceipt(): void { + $sign = new SignatureService('md5'); + $receipt = '%7B%22items%22%3A%5B%5D%7D'; + + $this->assertSame( + hash('md5', 'login:10.00:20:' . $receipt . ':p1'), + $sign->signHoldConfirm('login', '10.00', '20', 'p1', $receipt) + ); } public function signatureAlgorithmProvider(): array { @@ -311,6 +329,222 @@ public function savedCardForbiddenParameterProvider(): array { ); } + public function testSendHoldCreatesOneTimeInvoiceWithStepByStep(): void { + $this->http->queueResponse(new Response('{"url":"https://pay","isSuccess":true}', 200)); + + $url = $this->createRobo()->payment()->sendHold(array( + 'InvId' => 400001, + 'OutSum' => '100.00', + 'Description' => 'Hold payment', + 'AdditionalParameters' => array( + 'Email' => 'customer@example.com', + 'ResultURL2' => 'https://example.test/result2', + ), + )); + + $payload = $this->decodeJwtPayloadFromLastBody(); + + $this->assertSame('https://pay', $url); + $this->assertSame('https://services.robokassa.ru/InvoiceServiceWebApi/api/CreateInvoice', $this->http->lastUrl); + $this->assertSame('OneTime', $payload['InvoiceType']); + $this->assertArrayNotHasKey('StepByStep', $payload); + $this->assertSame(array( + 'Email' => 'customer@example.com', + 'ResultURL2' => 'https://example.test/result2', + 'StepByStep' => 'true', + ), $payload['AdditionalParameters']); + } + + public function testSendHoldUsesTestInvoiceParametersAndPassword(): void { + $this->http->queueResponse(new Response('{"url":"https://pay","isSuccess":true}', 200)); + + $this->createRobo(null, array( + 'is_test' => true, + 'test_password1' => 'tp1', + 'test_password2' => 'tp2', + ))->payment()->sendHold(array( + 'InvId' => 400001, + 'OutSum' => '100.00', + )); + + $payload = $this->decodeJwtPayloadFromLastBody(); + $jwt = json_decode($this->http->lastBody, true); + $parts = explode('.', $jwt); + $toSign = $parts[0] . '.' . $parts[1]; + $sign = new SignatureService('md5'); + + $this->assertSame('1', $payload['AdditionalParameters']['IsTest']); + $this->assertSame('true', $payload['AdditionalParameters']['StepByStep']); + $this->assertSame($sign->jwtSignMd5($toSign, 'login', 'tp1'), $parts[2]); + } + + /** + * @dataProvider invalidHoldCreationProvider + */ + public function testSendHoldRejectsInvalidParameters(array $params, string $message): void { + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage($message); + + $this->createRobo()->payment()->sendHold($params); + } + + public function invalidHoldCreationProvider(): array { + $base = array('InvId' => 400001, 'OutSum' => '100.00'); + + return array( + array(array('OutSum' => '100.00'), 'Required hold parameters: InvId, OutSum'), + array(array('InvId' => 400001), 'Required hold parameters: InvId, OutSum'), + array(array('InvId' => 0, 'OutSum' => '100.00'), 'Invalid hold parameter InvId'), + array(array('InvId' => 400001, 'OutSum' => '1e2'), 'Invalid hold parameter OutSum'), + array($base + array('InvoiceType' => 'Reusable'), 'Hold payments support only InvoiceType OneTime.'), + array($base + array('StepByStep' => 'true'), 'StepByStep must be passed inside AdditionalParameters'), + array($base + array('Recurring' => 'true'), 'Forbidden hold parameter: Recurring'), + array($base + array('Token' => 'saved-card-token'), 'Forbidden hold parameter: Token'), + array( + $base + array('AdditionalParameters' => array('Recurring' => 'true')), + 'Forbidden hold parameter: AdditionalParameters.Recurring' + ), + array( + $base + array('AdditionalParameters' => array('Token' => 'saved-card-token')), + 'Forbidden hold parameter: AdditionalParameters.Token' + ), + array( + $base + array('AdditionalParameters' => array('StepByStep' => 'false')), + 'Conflicting hold StepByStep value.' + ), + array( + $base + array('AdditionalParameters' => array('Email' => 123)), + 'Invalid hold parameter AdditionalParameters.Email: string expected.' + ), + ); + } + + public function testSendHoldReportsInvoiceApiBusinessError(): void { + $this->http->queueResponse(new Response('{"isSuccess":false,"message":"Hold is unavailable"}', 200)); + + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage('Invoice API request failed: Hold is unavailable'); + + $this->createRobo()->payment()->sendHold(array( + 'InvId' => 400001, + 'OutSum' => '100.00', + )); + } + + public function testConfirmHoldBuildsRequestAndReturnsAcceptedState(): void { + $this->http->queueResponse(new Response('"success: true"', 200)); + + $accepted = $this->createRobo()->payment()->confirmHold(400001, '100.00'); + + $this->assertTrue($accepted); + $this->assertSame('https://auth.robokassa.ru/Merchant/Payment/Confirm', $this->http->lastUrl); + $this->assertSame(array('Content-Type' => 'application/x-www-form-urlencoded'), $this->http->lastHeaders); + $this->assertSame( + 'MerchantLogin=login&InvoiceID=400001&OutSum=100.00&SignatureValue=' + . hash('md5', 'login:100.00:400001:p1'), + $this->http->lastBody + ); + } + + public function testConfirmHoldEncodesReceiptAndSignsItsRequestValue(): void { + $this->http->queueResponse(new Response('"success: true"', 200)); + $receipt = array( + 'items' => array(array( + 'name' => 'Updated item', + 'quantity' => 1, + 'sum' => 90, + 'payment_method' => 'full_payment', + 'payment_object' => 'commodity', + 'tax' => 'none', + )), + ); + + $this->createRobo()->payment()->confirmHold(400001, '90.00', $receipt); + + parse_str($this->http->lastBody, $body); + $this->assertSame(urlencode(json_encode($receipt)), $body['Receipt']); + $this->assertSame( + hash('md5', 'login:90.00:400001:' . $body['Receipt'] . ':p1'), + $body['SignatureValue'] + ); + } + + public function testCancelHoldKeepsOutSumOutOfSignatureAndReturnsRejectedState(): void { + $this->http->queueResponse(new Response('"success: false"', 200)); + + $accepted = $this->createRobo()->payment()->cancelHold(400001, '100.00'); + + $this->assertFalse($accepted); + $this->assertSame('https://auth.robokassa.ru/Merchant/Payment/Cancel', $this->http->lastUrl); + $this->assertSame( + 'MerchantLogin=login&InvoiceID=400001&OutSum=100.00&SignatureValue=' + . hash('md5', 'login::400001:p1'), + $this->http->lastBody + ); + } + + public function testHoldActionsUseConfiguredSignatureAlgorithm(): void { + $this->http->queueResponse(new Response('"success: true"', 200)); + + $this->createRobo(null, array('hashType' => 'sha256')) + ->payment() + ->confirmHold(400001, '100.00'); + + $this->assertStringContainsString( + 'SignatureValue=' . hash('sha256', 'login:100.00:400001:p1'), + $this->http->lastBody + ); + } + + /** + * @dataProvider invalidHoldActionResponseProvider + */ + public function testConfirmHoldRejectsInvalidResponse(string $body, string $message): void { + $this->http->queueResponse(new Response($body, 200)); + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage($message); + + $this->createRobo()->payment()->confirmHold(400001, '100.00'); + } + + public function invalidHoldActionResponseProvider(): array { + return array( + array('', 'Empty hold action response.'), + array('success: true', 'Invalid JSON in hold action response'), + array('{"success":true}', 'Unexpected hold action response.'), + array('"unknown"', 'Unexpected hold action response.'), + ); + } + + public function testHoldActionsRejectTestMode(): void { + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage('Hold confirmation and cancellation are not supported in test mode.'); + + $this->createRobo(null, array( + 'is_test' => true, + 'test_password1' => 'tp1', + 'test_password2' => 'tp2', + ))->payment()->confirmHold(400001, '100.00'); + } + + /** + * @dataProvider invalidHoldActionParamsProvider + */ + public function testHoldActionsRejectInvalidParams(int $invoiceID, string $outSum, string $message): void { + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage($message); + + $this->createRobo()->payment()->cancelHold($invoiceID, $outSum); + } + + public function invalidHoldActionParamsProvider(): array { + return array( + array(0, '100.00', 'Invalid hold parameter InvoiceID'), + array(400001, '0', 'Invalid hold parameter OutSum'), + array(400001, '1,00', 'Invalid hold parameter OutSum'), + ); + } + public function testSendRecurringBuildsCurrentRequestAndReturnsOk(): void { $this->http->queueResponse(new Response('OK200002', 200)); $receipt = array(