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
28 changes: 28 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()->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) |
Expand All @@ -62,6 +63,32 @@ $url = $robokassa->payment()->sendJwt([

Метод возвращает строку со ссылкой на оплату.

## Оплата по сохранённой карте

Для оплаты по сохранённой карте нужен `OpKey` прошлой операции, где покупатель уже использовал банковскую карту. Получите его из уведомления `ResultUrl2` или через `webService()->opState()`, сохраните в своей системе и передайте как `Token` при создании нового счёта:

```php
$url = $robokassa->payment()->sendSavedCard([
'OutSum' => 100.00,
'InvId' => 300001,
'Description' => 'Оплата заказа #300001',
'Token' => $opKey,
'AdditionalParameters' => [
'Email' => 'buyer@example.com',
],
]);
```

SDK передаст токен в поле `Token` внутри массива `AdditionalParameters`:

```php
'AdditionalParameters' => [
'Token' => $opKey,
]
```

Если `AdditionalParameters` уже содержит другие значения, они сохранятся. `Token` нельзя совмещать с `Recurring` и `StepByStep` в одном счёте.

## Рекуррентные платежи

Для материнского платежа создайте обычный счёт через `sendJwt()` и передайте `Recurring=true` в `AdditionalParameters`:
Expand Down Expand Up @@ -149,6 +176,7 @@ $url = $robokassa->payment()->sendCurl([
Основные примеры находятся в папке [`examples/`](./examples):

* [`send_payment_jwt.php`](./examples/send_payment_jwt.php) — создание ссылки на оплату через JWT.
* [`send_saved_card_payment.php`](./examples/send_saved_card_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) — получение доступных способов оплаты.
Expand Down
34 changes: 34 additions & 0 deletions examples/send_saved_card_payment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

require_once __DIR__ . '/bootstrap.php';

/**
* Пример использования метода payment()->sendSavedCard().
*
* Token — это OpKey прошлой операции, где покупатель уже использовал банковскую карту.
* В реальной интеграции магазин хранит OpKey у себя и передаёт его в SDK при создании нового счёта.
*/

try {
$robokassa = createRobokassa();
$opKey = $_ENV['ROBOKASSA_SAVED_CARD_OP_KEY'] ?? '';

if ($opKey === '') {
throw new RuntimeException('Укажите ROBOKASSA_SAVED_CARD_OP_KEY с OpKey прошлой операции.');
}

$url = $robokassa->payment()->sendSavedCard([
'InvId' => 300001,
'OutSum' => 100.00,
'Description' => 'Оплата по сохранённой карте',
'Token' => $opKey,
'AdditionalParameters' => [
'Email' => 'buyer@example.com',
],
]);

echo "Ссылка на оплату по сохранённой карте: $url\n";

} catch (Throwable $e) {
echo "Ошибка: " . $e->getMessage() . "\n";
}
95 changes: 95 additions & 0 deletions src/Service/PaymentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,17 @@ public function sendJwt(array $params): string {
throw new RobokassaException('JWT response does not contain payment URL.');
}

/**
* Создание счёта для оплаты по сохранённой карте через JWT интерфейс.
*
* @param array $params
* @return string
* @throws RobokassaException
*/
public function sendSavedCard(array $params): string {
return $this->sendJwt($this->prepareSavedCardParams($params));
}

/**
* Создание дочернего рекуррентного платежа.
*
Expand Down Expand Up @@ -179,6 +190,90 @@ private function prepareRecurringParams(array $params): array {
return $this->encodeShpParams($params);
}

/**
* Подготовка параметров оплаты по сохранённой карте.
*
* @param array $params
* @return array
* @throws RobokassaException
*/
private function prepareSavedCardParams(array $params): array {
$additional = $this->getSavedCardAdditionalParameters($params);
$rootTokenExists = array_key_exists('Token', $params);
$additionalTokenExists = array_key_exists('Token', $additional);

if (!$rootTokenExists && !$additionalTokenExists) {
throw new RobokassaException('Required saved card parameter: Token');
}
$rootToken = $rootTokenExists ? $this->normalizeSavedCardToken($params['Token']) : null;
$additionalToken = $additionalTokenExists ? $this->normalizeSavedCardToken($additional['Token']) : null;
if ($rootToken !== null && $additionalToken !== null && $rootToken !== $additionalToken) {
throw new RobokassaException('Conflicting saved card Token values.');
}

$this->assertSavedCardExclusiveParameters($params, $additional);

$additional['Token'] = $rootToken !== null ? $rootToken : $additionalToken;
$params['AdditionalParameters'] = $additional;
unset($params['Token']);

return $params;
}

/**
* Нормализует Token сохранённой карты.
*
* @param mixed $token
* @return string
* @throws RobokassaException
*/
private function normalizeSavedCardToken($token): string {
if (is_array($token) || is_object($token)) {
throw new RobokassaException('Invalid saved card parameter Token: string expected.');
}
$token = trim((string)$token);
if ($token === '') {
throw new RobokassaException('Required saved card parameter: Token');
}
return $token;
}

/**
* Возвращает AdditionalParameters для оплаты по сохранённой карте.
*
* @param array $params
* @return array
* @throws RobokassaException
*/
private function getSavedCardAdditionalParameters(array $params): array {
if (!array_key_exists('AdditionalParameters', $params)) {
return array();
}
if (!is_array($params['AdditionalParameters'])) {
throw new RobokassaException('AdditionalParameters must be an array.');
}
return $params['AdditionalParameters'];
}

/**
* Проверяет взаимоисключающие параметры Invoice API для оплаты по сохранённой карте.
*
* @param array $params
* @param array $additional
* @return void
* @throws RobokassaException
*/
private function assertSavedCardExclusiveParameters(array $params, array $additional): void {
foreach (array('Recurring', 'StepByStep') as $name) {
if (array_key_exists($name, $params)) {
throw new RobokassaException('Forbidden saved card parameter: ' . $name);
}
if (array_key_exists($name, $additional)) {
throw new RobokassaException('Forbidden saved card parameter: AdditionalParameters.' . $name);
}
}
}

/**
* @param mixed $value
* @return bool
Expand Down
132 changes: 132 additions & 0 deletions tests/ExamplesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,138 @@ public function testSendJwtPassesRecurringInAdditionalParameters(): void {
$this->assertArrayNotHasKey('Recurring', $payload);
}

public function testSendSavedCardPassesTokenInAdditionalParameters(): void {
$this->http->queueResponse(new Response('{"url":"https://pay"}', 200));

$url = $this->createRobo()->payment()->sendSavedCard(array(
'InvId' => 300001,
'OutSum' => 100,
'Description' => 'Saved card payment',
'Token' => 'E1253728-48A9-488D-A045-9954C442AF5C-qNavrXC6Y4',
'AdditionalParameters' => array(
'Email' => 'customer@example.com',
'ResultURL2' => 'https://example.test/result',
),
));

$payload = $this->decodeJwtPayloadFromLastBody();

$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->assertArrayNotHasKey('Token', $payload);
$this->assertSame(array(
'Email' => 'customer@example.com',
'ResultURL2' => 'https://example.test/result',
'Token' => 'E1253728-48A9-488D-A045-9954C442AF5C-qNavrXC6Y4',
), $payload['AdditionalParameters']);
}

public function testSendSavedCardAcceptsTokenFromAdditionalParameters(): void {
$this->http->queueResponse(new Response('{"url":"https://pay"}', 200));

$this->createRobo()->payment()->sendSavedCard(array(
'InvId' => 300001,
'OutSum' => 100,
'AdditionalParameters' => array(
'Token' => 'saved-card-token',
),
));

$payload = $this->decodeJwtPayloadFromLastBody();

$this->assertSame(array('Token' => 'saved-card-token'), $payload['AdditionalParameters']);
$this->assertArrayNotHasKey('Token', $payload);
}

public function testSendSavedCardRequiresToken(): void {
$this->expectException(RobokassaException::class);
$this->expectExceptionMessage('Required saved card parameter: Token');

$this->createRobo()->payment()->sendSavedCard(array(
'InvId' => 300001,
'OutSum' => 100,
));
}

public function testSendSavedCardRejectsInvalidTokenType(): void {
$this->expectException(RobokassaException::class);
$this->expectExceptionMessage('Invalid saved card parameter Token: string expected.');

$this->createRobo()->payment()->sendSavedCard(array(
'InvId' => 300001,
'OutSum' => 100,
'Token' => array('saved-card-token'),
));
}

public function testSendSavedCardRejectsInvalidAdditionalParameters(): void {
$this->expectException(RobokassaException::class);
$this->expectExceptionMessage('AdditionalParameters must be an array.');

$this->createRobo()->payment()->sendSavedCard(array(
'InvId' => 300001,
'OutSum' => 100,
'Token' => 'saved-card-token',
'AdditionalParameters' => 'Email=customer@example.com',
));
}

public function testSendSavedCardRejectsConflictingTokenValues(): void {
$this->expectException(RobokassaException::class);
$this->expectExceptionMessage('Conflicting saved card Token values.');

$this->createRobo()->payment()->sendSavedCard(array(
'InvId' => 300001,
'OutSum' => 100,
'Token' => 'root-token',
'AdditionalParameters' => array(
'Token' => 'additional-token',
),
));
}

/**
* @dataProvider savedCardForbiddenParameterProvider
*/
public function testSendSavedCardRejectsForbiddenParameters(array $params, string $message): void {
$this->expectException(RobokassaException::class);
$this->expectExceptionMessage($message);

$this->createRobo()->payment()->sendSavedCard($params);
}

public function savedCardForbiddenParameterProvider(): array {
return array(
array(
array('InvId' => 300001, 'OutSum' => 100, 'Token' => 'saved-card-token', 'Recurring' => 'true'),
'Forbidden saved card parameter: Recurring',
),
array(
array('InvId' => 300001, 'OutSum' => 100, 'Token' => 'saved-card-token', 'StepByStep' => 'true'),
'Forbidden saved card parameter: StepByStep',
),
array(
array(
'InvId' => 300001,
'OutSum' => 100,
'Token' => 'saved-card-token',
'AdditionalParameters' => array('Recurring' => 'true'),
),
'Forbidden saved card parameter: AdditionalParameters.Recurring',
),
array(
array(
'InvId' => 300001,
'OutSum' => 100,
'Token' => 'saved-card-token',
'AdditionalParameters' => array('StepByStep' => 'true'),
),
'Forbidden saved card parameter: AdditionalParameters.StepByStep',
),
);
}

public function testSendRecurringBuildsCurrentRequestAndReturnsOk(): void {
$this->http->queueResponse(new Response('OK200002', 200));
$receipt = array(
Expand Down
Loading