diff --git a/README.md b/README.md index 28fdeb1..d981918 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ md5, ripemd160, sha1, sha256, sha384, sha512 | Метод | Описание | Документация | | --- | --- | --- | | `payment()->sendJwt(array $params): string` | Рекомендуемый способ. Создаёт ссылку на оплату через JWT-интерфейс. | [Invoice API](https://docs.robokassa.ru/ru/invoice-api) | +| `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) | @@ -61,6 +62,36 @@ $url = $robokassa->payment()->sendJwt([ Метод возвращает строку со ссылкой на оплату. +## Рекуррентные платежи + +Для материнского платежа создайте обычный счёт через `sendJwt()` и передайте `Recurring=true` в `AdditionalParameters`: + +```php +$url = $robokassa->payment()->sendJwt([ + 'OutSum' => 100.00, + 'InvId' => 200001, + 'Description' => 'Оплата подписки', + 'AdditionalParameters' => [ + 'Recurring' => 'true', + ], +]); +``` + +После успешной оплаты материнского платежа можно создать дочерний платёж: + +```php +$result = $robokassa->payment()->sendRecurring([ + 'OutSum' => '100.00', + 'InvoiceID' => 200002, + 'PreviousInvoiceID' => 200001, + 'Description' => 'Повторная оплата подписки', +]); +``` + +Метод возвращает текстовый ответ Robokassa, например `OK200002`. Такой ответ означает создание дочерней операции, а не гарантированное успешное списание. Итоговый статус проверяйте через `ResultURL`/`ResultUrl2` или XML-интерфейс в боевом режиме. + +У `Merchant/Recurring` нет тестового режима. Если клиент SDK создан с `is_test => true`, `sendRecurring()` выбросит исключение. + ## Получение статуса счетов ```php @@ -118,6 +149,7 @@ $url = $robokassa->payment()->sendCurl([ Основные примеры находятся в папке [`examples/`](./examples): * [`send_payment_jwt.php`](./examples/send_payment_jwt.php) — создание ссылки на оплату через JWT. +* [`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) — получение доступных способов оплаты. * [`get_invoice_status.php`](./examples/get_invoice_status.php) — проверка статуса оплаты через XML-интерфейс. diff --git a/examples/send_recurring_payment.php b/examples/send_recurring_payment.php new file mode 100644 index 0000000..5543fe2 --- /dev/null +++ b/examples/send_recurring_payment.php @@ -0,0 +1,43 @@ +sendRecurring() + * + * Метод создаёт дочерний рекуррентный платёж по уже оплаченной материнской + * операции. У Merchant/Recurring нет тестового режима, поэтому запуск этого + * примера может создать боевое списание. + * + * Перед запуском задайте: + * ROBOKASSA_PREVIOUS_INVOICE_ID — InvoiceID оплаченного материнского платежа + * ROBOKASSA_RECURRING_INVOICE_ID — новый InvoiceID дочернего платежа + * ROBOKASSA_RECURRING_OUT_SUM — сумма дочернего платежа + */ + +try { + $previousInvoiceID = (int)($_ENV['ROBOKASSA_PREVIOUS_INVOICE_ID'] ?? 0); + if ($previousInvoiceID <= 0) { + throw new InvalidArgumentException('Укажите ROBOKASSA_PREVIOUS_INVOICE_ID с InvoiceID материнского платежа.'); + } + + $invoiceID = (int)($_ENV['ROBOKASSA_RECURRING_INVOICE_ID'] ?? 0); + if ($invoiceID <= 0) { + throw new InvalidArgumentException('Укажите ROBOKASSA_RECURRING_INVOICE_ID с новым InvoiceID дочернего платежа.'); + } + $outSum = $_ENV['ROBOKASSA_RECURRING_OUT_SUM'] ?? '10.00'; + + $robokassa = createRobokassa(); + + $result = $robokassa->payment()->sendRecurring([ + 'OutSum' => $outSum, + 'InvoiceID' => $invoiceID, + 'PreviousInvoiceID' => $previousInvoiceID, + 'Description' => 'Повторная оплата подписки', + ]); + + echo "Ответ Robokassa: $result\n"; + +} catch (Exception $e) { + echo "Ошибка: " . $e->getMessage() . "\n"; +} diff --git a/src/Robokassa.php b/src/Robokassa.php index eaf731b..f06f881 100644 --- a/src/Robokassa.php +++ b/src/Robokassa.php @@ -19,6 +19,7 @@ class Robokassa { private string $paymentUrl = 'https://auth.robokassa.ru/Merchant/Index/'; 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 $webServiceUrl = 'https://auth.robokassa.ru/Merchant/WebService/Service.asmx'; private bool $is_test = false; @@ -127,7 +128,8 @@ private function createPaymentService(): PaymentService { $this->paymentUrl, $this->paymentCurl, $this->jwtApiUrl, - $this->hashType + $this->hashType, + $this->recurringUrl ); } diff --git a/src/Service/PaymentService.php b/src/Service/PaymentService.php index 3a2a4ad..0ce71b1 100644 --- a/src/Service/PaymentService.php +++ b/src/Service/PaymentService.php @@ -16,6 +16,7 @@ class PaymentService { private string $paymentCurl; private string $jwtApiUrl; private string $hashType; + private string $recurringUrl; public function __construct( HttpClientInterface $http, @@ -26,7 +27,8 @@ public function __construct( string $paymentUrl, string $paymentCurl, string $jwtApiUrl, - string $hashType + string $hashType, + string $recurringUrl = 'https://auth.robokassa.ru/Merchant/Recurring' ) { $this->http = $http; $this->sign = $sign; @@ -37,6 +39,7 @@ public function __construct( $this->paymentCurl = $paymentCurl; $this->jwtApiUrl = $jwtApiUrl; $this->hashType = $hashType; + $this->recurringUrl = $recurringUrl; } /** @@ -91,6 +94,29 @@ public function sendJwt(array $params): string { throw new RobokassaException('JWT response does not contain payment URL.'); } + /** + * Создание дочернего рекуррентного платежа. + * + * @param array $params + * @return string + * @throws RobokassaException + */ + public function sendRecurring(array $params): string { + $params = $this->prepareRecurringParams($params); + $sigParams = $this->buildRecurringSignature($params); + $params['SignatureValue'] = $this->sign->createPaymentSignature( + $sigParams, + $this->merchantLogin, + $this->password1, + $this->hashType + ); + $resp = $this->http->post($this->recurringUrl, http_build_query($params), array( + 'Content-Type' => 'application/x-www-form-urlencoded', + )); + $this->assertSuccessStatus($resp, 'Recurring payment request failed.'); + return $this->decodeRecurringResponse($resp->body); + } + /** * Подготовка параметров для CURL-запроса. * @@ -110,6 +136,73 @@ private function prepareCurlParams(array $params): array { return $this->encodeShpParams($params); } + /** + * Подготовка параметров дочернего рекуррентного платежа. + * + * @param array $params + * @return array + * @throws RobokassaException + */ + private function prepareRecurringParams(array $params): array { + if ($this->isTest) { + throw new RobokassaException('Recurring payments are not supported in test mode.'); + } + foreach (array('OutSum', 'InvoiceID', 'PreviousInvoiceID') as $required) { + if (!array_key_exists($required, $params)) { + throw new RobokassaException('Required parameters: OutSum, InvoiceID, PreviousInvoiceID'); + } + } + foreach (array('Recurring', 'IncCurrLabel', 'ExpirationDate', 'IsTest') as $forbidden) { + if (array_key_exists($forbidden, $params)) { + throw new RobokassaException('Forbidden recurring parameter: ' . $forbidden); + } + } + foreach ($params as $name => $value) { + if (!in_array($name, array('OutSum', 'InvoiceID', 'PreviousInvoiceID', 'Description', 'Receipt'), true) + && !preg_match('~^Shp_~iu', $name)) { + throw new RobokassaException('Unsupported recurring parameter: ' . $name); + } + } + if (!$this->isPositiveInteger($params['InvoiceID'])) { + throw new RobokassaException('Invalid recurring parameter InvoiceID: positive integer expected.'); + } + if (!$this->isPositiveInteger($params['PreviousInvoiceID'])) { + throw new RobokassaException('Invalid recurring parameter PreviousInvoiceID: positive integer expected.'); + } + if (!$this->isPositiveAmount($params['OutSum'])) { + throw new RobokassaException('Invalid recurring parameter OutSum: positive decimal expected.'); + } + $params['MerchantLogin'] = $this->merchantLogin; + if (!empty($params['Receipt'])) { + $params['Receipt'] = urlencode($this->encodeJson($params['Receipt'])); + } + return $this->encodeShpParams($params); + } + + /** + * @param mixed $value + * @return bool + */ + private function isPositiveInteger($value): bool { + if (!is_int($value) && !is_string($value)) { + return false; + } + $value = (string)$value; + return preg_match('~^\d+$~D', $value) === 1 && preg_match('~[1-9]~', $value) === 1; + } + + /** + * @param mixed $value + * @return bool + */ + private function isPositiveAmount($value): bool { + if (!is_int($value) && !is_float($value) && !is_string($value)) { + return false; + } + $value = (string)$value; + return preg_match('~^\d+(?:\.\d+)?$~D', $value) === 1 && preg_match('~[1-9]~', $value) === 1; + } + /** * Формирование массива для подписи. * @@ -124,6 +217,20 @@ private function buildCurlSignature(array $params): array { return $this->appendShpParams($sig, $params); } + /** + * Формирование массива для подписи рекуррентного платежа. + * + * @param array $params + * @return array + */ + private function buildRecurringSignature(array $params): array { + $sig = array('OutSum' => $params['OutSum'], 'InvoiceID' => $params['InvoiceID']); + if (!empty($params['Receipt'])) { + $sig['Receipt'] = $params['Receipt']; + } + return $this->appendShpParams($sig, $params); + } + /** * Подготовка payload для JWT. * @@ -163,7 +270,15 @@ private function buildRequiredJwtPayload(array $params): array { * @return array */ private function appendOptionalJwtPayload(array $payload, array $params): array { - $optional = array('Description','MerchantComments','InvoiceItems','UserFields','SuccessUrl2Data','FailUrl2Data'); + $optional = array( + 'Description', + 'MerchantComments', + 'InvoiceItems', + 'UserFields', + 'SuccessUrl2Data', + 'FailUrl2Data', + 'AdditionalParameters', + ); foreach ($optional as $key) { if (!empty($params[$key])) { $payload[$key] = $params[$key]; @@ -256,4 +371,22 @@ private function decodeJsonResponse(string $body): array { } return $data; } + + /** + * Проверяет текстовый ответ рекуррентного платежа. + * + * @param string $body + * @return string + * @throws RobokassaException + */ + private function decodeRecurringResponse(string $body): string { + $body = trim($body); + if ($body === '') { + throw new RobokassaException('Empty recurring payment response.'); + } + if (!preg_match('~^OK\+?\d+$~i', $body)) { + throw new RobokassaException('Recurring payment response is not successful.'); + } + return $body; + } } diff --git a/tests/ExamplesTest.php b/tests/ExamplesTest.php index e1ddc56..1c1fe2d 100644 --- a/tests/ExamplesTest.php +++ b/tests/ExamplesTest.php @@ -126,6 +126,23 @@ public function testSendCurlKeepsCompatibilityAndExactRequest(): void { $this->assertSame($this->expectedCurlBody(), $this->http->lastBody); } + public function testSendCurlPassesRecurringAsPaymentParameter(): void { + $this->http->queueResponse(new Response('{"invoiceID":10}', 200)); + + $this->createRobo()->payment()->sendCurl(array( + 'OutSum' => 5, + 'InvoiceID' => 154, + 'Description' => 'Subscription parent payment', + 'Recurring' => 'true', + )); + + $this->assertSame( + 'OutSum=5&InvoiceID=154&Description=Subscription+parent+payment&Recurring=true' + . '&MerchantLogin=login&SignatureValue=ea9bd729a4456cfbfb91294b8e2781d6', + $this->http->lastBody + ); + } + public function testSendCurlIsDeprecated(): void { $method = new \ReflectionMethod(PaymentService::class, 'sendCurl'); @@ -144,6 +161,173 @@ public function testSendJwtBuildsCurrentJwtAndHeaders(): void { $this->assertSame($this->expectedJwtBody(), $this->http->lastBody); } + public function testSendJwtPassesRecurringInAdditionalParameters(): void { + $this->http->queueResponse(new Response('{"url":"https://pay"}', 200)); + + $this->createRobo()->payment()->sendJwt(array( + 'InvId' => 200001, + 'OutSum' => 100, + 'Description' => 'Subscription parent payment', + 'AdditionalParameters' => array( + 'Recurring' => 'true', + ), + )); + + $payload = $this->decodeJwtPayloadFromLastBody(); + + $this->assertSame(array('Recurring' => 'true'), $payload['AdditionalParameters']); + $this->assertArrayNotHasKey('Recurring', $payload); + } + + public function testSendRecurringBuildsCurrentRequestAndReturnsOk(): void { + $this->http->queueResponse(new Response('OK200002', 200)); + $receipt = array( + 'items' => array(array( + 'name' => 'Subscription', + 'quantity' => 1, + 'sum' => 100, + 'payment_method' => 'full_payment', + 'payment_object' => 'service', + 'tax' => 'none', + )), + ); + + $result = $this->createRobo()->payment()->sendRecurring(array( + 'OutSum' => '100.00', + 'InvoiceID' => 200002, + 'PreviousInvoiceID' => 200001, + 'Description' => 'Recurring payment', + 'Receipt' => $receipt, + 'Shp_order' => 'abc 1', + )); + + $this->assertSame('OK200002', $result); + $this->assertSame('https://auth.robokassa.ru/Merchant/Recurring', $this->http->lastUrl); + $this->assertSame(array('Content-Type' => 'application/x-www-form-urlencoded'), $this->http->lastHeaders); + $this->assertSame($this->expectedRecurringBody(), $this->http->lastBody); + $this->assertStringNotContainsString('Receipt=%25257B', $this->http->lastBody); + $this->assertStringNotContainsString( + hash('md5', 'login:100.00:200002:200001:p1:Shp_order=abc+1'), + $this->http->lastBody + ); + } + + public function testSendRecurringRejectsTestMode(): void { + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage('Recurring payments are not supported in test mode.'); + + $this->createRobo(null, array( + 'is_test' => true, + 'test_password1' => 'tp1', + 'test_password2' => 'tp2', + ))->payment()->sendRecurring(array( + 'OutSum' => '100.00', + 'InvoiceID' => 200002, + 'PreviousInvoiceID' => 200001, + )); + } + + public function testSendRecurringRequiresContractParameters(): void { + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage('Required parameters: OutSum, InvoiceID, PreviousInvoiceID'); + + $this->createRobo()->payment()->sendRecurring(array( + 'OutSum' => '100.00', + 'InvoiceID' => 200002, + )); + } + + public function testSendRecurringRejectsForbiddenParameters(): void { + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage('Forbidden recurring parameter: Recurring'); + + $this->createRobo()->payment()->sendRecurring(array( + 'OutSum' => '100.00', + 'InvoiceID' => 200002, + 'PreviousInvoiceID' => 200001, + 'Recurring' => 'true', + )); + } + + /** + * @dataProvider invalidRecurringIdentifierProvider + * @param mixed $value + */ + public function testSendRecurringRejectsInvalidIdentifiers(string $name, $value): void { + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage('Invalid recurring parameter ' . $name . ': positive integer expected.'); + + $params = array( + 'OutSum' => '100.00', + 'InvoiceID' => 200002, + 'PreviousInvoiceID' => 200001, + ); + $params[$name] = $value; + + $this->createRobo()->payment()->sendRecurring($params); + } + + public function invalidRecurringIdentifierProvider(): array { + return array( + array('InvoiceID', -1), + array('InvoiceID', 1.5), + array('InvoiceID', '1.5'), + array('PreviousInvoiceID', -1), + array('PreviousInvoiceID', 1.5), + array('PreviousInvoiceID', '1.5'), + ); + } + + /** + * @dataProvider invalidRecurringOutSumProvider + * @param mixed $value + */ + public function testSendRecurringRejectsInvalidOutSum($value): void { + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage('Invalid recurring parameter OutSum: positive decimal expected.'); + + $this->createRobo()->payment()->sendRecurring(array( + 'OutSum' => $value, + 'InvoiceID' => 200002, + 'PreviousInvoiceID' => 200001, + )); + } + + public function invalidRecurringOutSumProvider(): array { + return array( + array(-1), + array(0), + array('1,00'), + array('1e2'), + array('invalid'), + ); + } + + public function testSendRecurringRejectsUnsupportedParameter(): void { + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage('Unsupported recurring parameter: Email'); + + $this->createRobo()->payment()->sendRecurring(array( + 'OutSum' => '100.00', + 'InvoiceID' => 200002, + 'PreviousInvoiceID' => 200001, + 'Email' => 'customer@example.com', + )); + } + + public function testSendRecurringRejectsNonOkResponse(): void { + $this->http->queueResponse(new Response('Recurring error', 200)); + + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage('Recurring payment response is not successful.'); + + $this->createRobo()->payment()->sendRecurring(array( + 'OutSum' => '100.00', + 'InvoiceID' => 200002, + 'PreviousInvoiceID' => 200001, + )); + } + public function testGetCheckStatus(): void { $this->http->queueResponse(new Response('{"state":1}', 200)); @@ -292,6 +476,24 @@ private function expectedJwtBody(): string { . 'FKHP-6TuMui4tsnqUvjumw"'; } + private function expectedRecurringBody(): string { + return 'OutSum=100.00&InvoiceID=200002&PreviousInvoiceID=200001&Description=Recurring+payment' + . '&Receipt=%257B%2522items%2522%253A%255B%257B%2522name%2522%253A%2522Subscription%2522%252C' + . '%2522quantity%2522%253A1%252C%2522sum%2522%253A100%252C%2522payment_method%2522%253A' + . '%2522full_payment%2522%252C%2522payment_object%2522%253A%2522service%2522%252C%2522tax%2522%253A' + . '%2522none%2522%257D%255D%257D&Shp_order=abc%2B1&MerchantLogin=login' + . '&SignatureValue=1be6746b70e8f702171f85e427399a9e'; + } + + private function decodeJwtPayloadFromLastBody(): array { + $jwt = json_decode($this->http->lastBody, true); + $parts = explode('.', $jwt); + $payload = strtr($parts[1], '-_', '+/'); + $payload .= str_repeat('=', (4 - strlen($payload) % 4) % 4); + + return json_decode(base64_decode($payload), true); + } + private function statusFilters(): array { return array( 'CurrentPage' => 1,