From c3b6a7f6a43aeca2094a42171853b6557c50da3d Mon Sep 17 00:00:00 2001 From: IvanKirsanov Date: Wed, 2 Sep 2026 17:47:29 +0700 Subject: [PATCH] feat(680): added saved card payment --- README.md | 28 ++++++ examples/send_saved_card_payment.php | 34 +++++++ src/Service/PaymentService.php | 95 +++++++++++++++++++ tests/ExamplesTest.php | 132 +++++++++++++++++++++++++++ 4 files changed, 289 insertions(+) create mode 100644 examples/send_saved_card_payment.php diff --git a/README.md b/README.md index d981918..0725d3f 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()->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) | @@ -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`: @@ -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) — получение доступных способов оплаты. diff --git a/examples/send_saved_card_payment.php b/examples/send_saved_card_payment.php new file mode 100644 index 0000000..3d3d69f --- /dev/null +++ b/examples/send_saved_card_payment.php @@ -0,0 +1,34 @@ +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"; +} diff --git a/src/Service/PaymentService.php b/src/Service/PaymentService.php index 0ce71b1..72a720e 100644 --- a/src/Service/PaymentService.php +++ b/src/Service/PaymentService.php @@ -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)); + } + /** * Создание дочернего рекуррентного платежа. * @@ -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 diff --git a/tests/ExamplesTest.php b/tests/ExamplesTest.php index 1c1fe2d..5474a84 100644 --- a/tests/ExamplesTest.php +++ b/tests/ExamplesTest.php @@ -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(