Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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
Expand Down Expand Up @@ -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-интерфейс.
Expand Down
43 changes: 43 additions & 0 deletions examples/send_recurring_payment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

require_once __DIR__ . '/bootstrap.php';

/**
* Пример использования метода payment()->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";
}
4 changes: 3 additions & 1 deletion src/Robokassa.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -127,7 +128,8 @@ private function createPaymentService(): PaymentService {
$this->paymentUrl,
$this->paymentCurl,
$this->jwtApiUrl,
$this->hashType
$this->hashType,
$this->recurringUrl
);
}

Expand Down
137 changes: 135 additions & 2 deletions src/Service/PaymentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class PaymentService {
private string $paymentCurl;
private string $jwtApiUrl;
private string $hashType;
private string $recurringUrl;

public function __construct(
HttpClientInterface $http,
Expand All @@ -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;
Expand All @@ -37,6 +39,7 @@ public function __construct(
$this->paymentCurl = $paymentCurl;
$this->jwtApiUrl = $jwtApiUrl;
$this->hashType = $hashType;
$this->recurringUrl = $recurringUrl;
}

/**
Expand Down Expand Up @@ -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-запроса.
*
Expand All @@ -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;
}

/**
* Формирование массива для подписи.
*
Expand All @@ -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.
*
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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;
}
}
Loading
Loading