From d9ce2a92488aa4236bbf2d34a72d647f2dea9497 Mon Sep 17 00:00:00 2001 From: IvanKirsanov Date: Mon, 14 Sep 2026 19:44:10 +0700 Subject: [PATCH] feat(678): added split-payments --- .env.example | 3 + README.md | 54 ++++++ examples/send_split_payment.php | 59 +++++++ src/Service/PaymentService.php | 280 +++++++++++++++++++++++++++++++ tests/ExamplesTest.php | 289 ++++++++++++++++++++++++++++++++ 5 files changed, 685 insertions(+) create mode 100644 examples/send_split_payment.php diff --git a/.env.example b/.env.example index 48def08..ba60310 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,6 @@ ROBOKASSA_HOLD_ACTION=create ROBOKASSA_HOLD_INVOICE_ID= ROBOKASSA_HOLD_OUT_SUM= ROBOKASSA_HOLD_RESULT_URL2= + +# Параметры примера send_split_payment.php +ROBOKASSA_SPLIT_PARTNER_LOGIN= diff --git a/README.md b/README.md index 150672d..e1980f1 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()->sendSplit(array $params): string` | Создаёт счёт со сплитованием платежа. | [Сплитование платежей](https://docs.robokassa.ru/ru/split-payments) | | `payment()->sendSavedCard(array $params): string` | Создаёт счёт для оплаты по сохранённой банковской карте через JWT-интерфейс. | [Оплата по сохраненной карте](https://docs.robokassa.ru/ru/saving) | | `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) | @@ -66,6 +67,58 @@ $url = $robokassa->payment()->sendJwt([ Метод возвращает строку со ссылкой на оплату. +## Сплитование платежа + +Сплитование передаётся через `CreateInvoice` в строковом параметре `AdditionalParameters.Split`. Для удобства передайте в `sendSplit()` нативный PHP-массив участников верхнеуровневым параметром `Split` — SDK проверит его, сериализует в JSON и добавит в `AdditionalParameters`: + +```php +$url = $robokassa->payment()->sendSplit([ + 'OutSum' => 700.00, + 'InvId' => 500001, + 'Description' => 'Оплата заказа #500001', + 'ExpirationDate' => '2026-12-31T23:59:59+03:00', + 'Aliases' => ['BankCard', 'SBP'], + 'Split' => [ + [ + 'id' => 'master-shop', + 'InvoiceId' => 500001, + 'amount' => 500, + 'receipt' => [ + 'sno' => 'osn', + 'items' => [ + [ + 'name' => 'Товар 1', + 'quantity' => 1, + 'sum' => 500, + 'tax' => 'vat20', + 'payment_method' => 'full_payment', + 'payment_object' => 'commodity', + ], + ], + ], + ], + [ + 'id' => 'partner-shop', + 'amount' => 200, + ], + ], + 'AdditionalParameters' => [ + 'Email' => 'buyer@example.com', + ], +]); +``` + +В JWT значение будет выглядеть следующим образом: + +```php +'AdditionalParameters' => [ + 'Email' => 'buyer@example.com', + 'Split' => '[{"id":"master-shop","InvoiceId":500001,"amount":500,...}]', +] +``` + +`Split` не нужно кодировать в JSON или URL-кодировать самостоятельно. Если требуется передать уже подготовленную JSON-строку вручную, используйте универсальный `sendJwt()`. Сплитование несовместимо только с тестовым режимом `IsTest`; остальные параметры `CreateInvoice` метод не изменяет. + ## Оплата по сохранённой карте Для оплаты по сохранённой карте нужен `OpKey` прошлой операции, где покупатель уже использовал банковскую карту. Получите его из уведомления `ResultUrl2` или через `webService()->opState()`, сохраните в своей системе и передайте как `Token` при создании нового счёта: @@ -230,6 +283,7 @@ $url = $robokassa->payment()->sendCurl([ Основные примеры находятся в папке [`examples/`](./examples): * [`send_payment_jwt.php`](./examples/send_payment_jwt.php) — создание ссылки на оплату через JWT. +* [`send_split_payment.php`](./examples/send_split_payment.php) — создание счёта со сплитованием. * [`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) — создание дочернего рекуррентного платежа по оплаченной материнской операции. diff --git a/examples/send_split_payment.php b/examples/send_split_payment.php new file mode 100644 index 0000000..70e9ee1 --- /dev/null +++ b/examples/send_split_payment.php @@ -0,0 +1,59 @@ +sendSplit(). + * + * Сплитование создаётся через Invoice API, но не поддерживает IsTest. Перед + * запуском используйте реквизиты основного магазина и задайте логин участника + * в ROBOKASSA_SPLIT_PARTNER_LOGIN. + */ + +try { + $masterLogin = $_ENV['ROBOKASSA_LOGIN'] ?? ''; + $partnerLogin = $_ENV['ROBOKASSA_SPLIT_PARTNER_LOGIN'] ?? ''; + if ($partnerLogin === '') { + throw new InvalidArgumentException('Укажите ROBOKASSA_SPLIT_PARTNER_LOGIN.'); + } + + $robokassa = createRobokassa(); + $url = $robokassa->payment()->sendSplit([ + 'InvId' => 500001, + 'OutSum' => 700, + 'Description' => 'Оплата заказа со сплитованием', + 'ExpirationDate' => '2026-12-31T23:59:59+03:00', + 'Aliases' => ['BankCard', 'SBP'], + 'Split' => [ + [ + 'id' => $masterLogin, + 'InvoiceId' => 500001, + 'amount' => 500, + 'receipt' => [ + 'sno' => 'osn', + 'items' => [ + [ + 'name' => 'Товар мастер-магазина', + 'quantity' => 1, + 'sum' => 500, + 'tax' => 'vat20', + 'payment_method' => 'full_payment', + 'payment_object' => 'commodity', + ], + ], + ], + ], + [ + 'id' => $partnerLogin, + 'amount' => 200, + ], + ], + '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 61bb2ac..ab4a982 100644 --- a/src/Service/PaymentService.php +++ b/src/Service/PaymentService.php @@ -118,6 +118,17 @@ public function sendSavedCard(array $params): string { return $this->sendJwt($this->prepareSavedCardParams($params)); } + /** + * Создание счёта со сплитованием платежа через Invoice API. + * + * @param array $params + * @return string + * @throws RobokassaException + */ + public function sendSplit(array $params): string { + return $this->sendJwt($this->prepareSplitParams($params)); + } + /** * Создание счёта с двухстадийной оплатой через Invoice API. * @@ -266,6 +277,268 @@ private function prepareJwtEnvironmentParams(array $params): array { return $params; } + /** + * Подготовка параметров счёта со сплитованием. + * + * @param array $params + * @return array + * @throws RobokassaException + */ + private function prepareSplitParams(array $params): array { + if ($this->isTest) { + throw new RobokassaException('Split payments are not supported in test mode.'); + } + if (!array_key_exists('Split', $params)) { + throw new RobokassaException('Required split parameter: Split'); + } + + $additional = $this->getAdditionalParameters($params); + if (array_key_exists('Split', $additional)) { + throw new RobokassaException( + 'Split must be passed as a top-level SDK parameter, not as AdditionalParameters.Split.' + ); + } + if (array_key_exists('IsTest', $params) || array_key_exists('IsTest', $additional)) { + throw new RobokassaException('Split payments are not compatible with IsTest.'); + } + foreach ($additional as $name => $value) { + if (!is_string($value)) { + throw new RobokassaException( + 'Invalid split parameter AdditionalParameters.' . $name . ': string expected.' + ); + } + } + + $this->assertSplitMerchants($params['Split']); + $additional['Split'] = $this->encodeJson( + $params['Split'], + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ); + $params['AdditionalParameters'] = $additional; + unset($params['Split']); + + return $params; + } + + /** + * Проверяет список магазинов-участников сплита. + * + * @param mixed $merchants + * @return void + * @throws RobokassaException + */ + private function assertSplitMerchants($merchants): void { + if (!is_array($merchants) || empty($merchants)) { + throw new RobokassaException('Split must be a non-empty array of merchants.'); + } + if ($merchants !== array_values($merchants)) { + throw new RobokassaException('Split must be a list of merchants.'); + } + + foreach ($merchants as $index => $merchant) { + $path = 'Split[' . $index . ']'; + if (!is_array($merchant)) { + throw new RobokassaException($path . ' must be an array.'); + } + if (!isset($merchant['id']) || !is_string($merchant['id']) || trim($merchant['id']) === '') { + throw new RobokassaException($path . '.id must be a non-empty string.'); + } + if (!array_key_exists('amount', $merchant)) { + throw new RobokassaException('Required split parameter: ' . $path . '.amount'); + } + $this->assertNonNegativeJsonNumber($merchant['amount'], $path . '.amount'); + + if (array_key_exists('InvoiceId', $merchant) + && (!is_int($merchant['InvoiceId']) || $merchant['InvoiceId'] < 0)) { + throw new RobokassaException($path . '.InvoiceId must be a non-negative integer.'); + } + if (array_key_exists('receipt', $merchant)) { + $this->assertSplitReceipt($merchant['receipt'], $path . '.receipt'); + } + } + } + + /** + * Проверяет чек отдельного участника сплита. + * + * @param mixed $receipt + * @param string $path + * @return void + * @throws RobokassaException + */ + private function assertSplitReceipt($receipt, string $path): void { + if (!is_array($receipt)) { + throw new RobokassaException($path . ' must be an array.'); + } + if (array_key_exists('sno', $receipt) + && (!is_string($receipt['sno']) + || !in_array($receipt['sno'], array( + 'osn', + 'usn_income', + 'usn_income_outcome', + 'envd', + 'esn', + 'patent', + ), true))) { + throw new RobokassaException($path . '.sno has an unsupported value.'); + } + if (!array_key_exists('items', $receipt) + || !is_array($receipt['items']) + || empty($receipt['items'])) { + throw new RobokassaException($path . '.items must be a non-empty array.'); + } + if ($receipt['items'] !== array_values($receipt['items'])) { + throw new RobokassaException($path . '.items must be a list.'); + } + + foreach ($receipt['items'] as $index => $item) { + $this->assertSplitReceiptItem($item, $path . '.items[' . $index . ']'); + } + } + + /** + * Проверяет товарную позицию чека участника сплита. + * + * @param mixed $item + * @param string $path + * @return void + * @throws RobokassaException + */ + private function assertSplitReceiptItem($item, string $path): void { + if (!is_array($item)) { + throw new RobokassaException($path . ' must be an array.'); + } + if (!isset($item['name']) || !is_string($item['name']) || trim($item['name']) === '') { + throw new RobokassaException($path . '.name must be a non-empty string.'); + } + if ($this->stringLength($item['name']) > 128) { + throw new RobokassaException($path . '.name must not exceed 128 characters.'); + } + if (!array_key_exists('quantity', $item)) { + throw new RobokassaException('Required split parameter: ' . $path . '.quantity'); + } + $this->assertSplitDecimal($item['quantity'], $path . '.quantity', 5, 3, false); + if (!array_key_exists('sum', $item)) { + throw new RobokassaException('Required split parameter: ' . $path . '.sum'); + } + $this->assertSplitDecimal($item['sum'], $path . '.sum', 8, 2, true); + if (!isset($item['tax']) || !is_string($item['tax']) + || !in_array($item['tax'], array('none', 'vat0', 'vat10', 'vat110', 'vat20', 'vat120'), true)) { + throw new RobokassaException($path . '.tax has an unsupported value.'); + } + + $this->assertOptionalSplitEnum($item, 'payment_method', array( + 'full_prepayment', + 'prepayment', + 'advance', + 'full_payment', + 'partial_payment', + 'credit', + 'credit_payment', + ), $path); + $this->assertOptionalSplitEnum($item, 'payment_object', array( + 'commodity', + 'excise', + 'job', + 'service', + 'gambling_bet', + 'gambling_prize', + 'lottery', + 'lottery_prize', + 'intellectual_activity', + 'payment', + 'agent_commission', + 'composite', + 'another', + 'property_right', + 'non-operating_gain', + 'insurance_premium', + 'sales_tax', + 'resort_fee', + ), $path); + + if (array_key_exists('nomenclature_code', $item) && !is_string($item['nomenclature_code'])) { + throw new RobokassaException($path . '.nomenclature_code must be a string.'); + } + } + + /** + * Проверяет необязательное строковое значение из фиксированного набора. + * + * @param array $data + * @param string $name + * @param array $allowed + * @param string $path + * @return void + * @throws RobokassaException + */ + private function assertOptionalSplitEnum(array $data, string $name, array $allowed, string $path): void { + if (!array_key_exists($name, $data)) { + return; + } + if (!is_string($data[$name]) || !in_array($data[$name], $allowed, true)) { + throw new RobokassaException($path . '.' . $name . ' has an unsupported value.'); + } + } + + /** + * @param mixed $value + * @param string $path + * @return void + * @throws RobokassaException + */ + private function assertNonNegativeJsonNumber($value, string $path): void { + if ((!is_int($value) && !is_float($value)) || !is_finite((float)$value) || $value < 0) { + throw new RobokassaException($path . ' must be a non-negative number.'); + } + } + + /** + * @param mixed $value + * @param string $path + * @param int $maxIntegerDigits + * @param int $maxFractionDigits + * @param bool $allowZero + * @return void + * @throws RobokassaException + */ + private function assertSplitDecimal( + $value, + string $path, + int $maxIntegerDigits, + int $maxFractionDigits, + bool $allowZero + ): void { + $isNumber = is_int($value) || is_float($value); + $isFinite = $isNumber && is_finite((float)$value); + $isAllowedValue = $isFinite && ($allowZero ? $value >= 0 : $value > 0); + $encoded = $isFinite ? json_encode($value, JSON_PRESERVE_ZERO_FRACTION) : false; + $pattern = '~^\d{1,' . $maxIntegerDigits . '}(?:\.\d{1,' . $maxFractionDigits . '})?$~D'; + + if (!$isFinite || !$isAllowedValue || !is_string($encoded) || preg_match($pattern, $encoded) !== 1) { + $constraint = $allowZero ? 'non-negative' : 'positive'; + throw new RobokassaException( + $path . ' must be a ' . $constraint . ' decimal with up to ' + . $maxIntegerDigits . ' integer and ' . $maxFractionDigits . ' fractional digits.' + ); + } + } + + /** + * Возвращает длину UTF-8 строки в символах без зависимости от mbstring. + * + * @param string $value + * @return int + * @throws RobokassaException + */ + private function stringLength(string $value): int { + $count = preg_match_all('~.~us', $value, $matches); + if ($count === false) { + throw new RobokassaException('Invalid UTF-8 in split receipt item name.'); + } + return $count; + } + /** * Подготовка параметров создания холда через Invoice API. * @@ -558,12 +831,19 @@ private function buildRequiredJwtPayload(array $params): array { */ private function appendOptionalJwtPayload(array $payload, array $params): array { $optional = array( + 'ExpirationDate', 'Description', + 'FiscalParentOpId', 'MerchantComments', 'InvoiceItems', 'UserFields', 'SuccessUrl2Data', 'FailUrl2Data', + 'Aliases', + 'Payments', + 'CustomUserProperty', + 'IsWithoutFreeSale', + 'Sno', 'AdditionalParameters', ); foreach ($optional as $key) { diff --git a/tests/ExamplesTest.php b/tests/ExamplesTest.php index cd0e234..0dd120e 100644 --- a/tests/ExamplesTest.php +++ b/tests/ExamplesTest.php @@ -197,6 +197,267 @@ public function testSendJwtPassesRecurringInAdditionalParameters(): void { $this->assertArrayNotHasKey('Recurring', $payload); } + public function testSendSplitPassesJsonStringInAdditionalParameters(): void { + $this->http->queueResponse(new Response('{"url":"https://pay/split","isSuccess":true}', 200)); + $split = $this->splitMerchants(); + + $url = $this->createRobo()->payment()->sendSplit(array( + 'InvId' => 500001, + 'OutSum' => 700, + 'InvoiceType' => 'Reusable', + 'Description' => 'Split payment', + 'Split' => $split, + 'AdditionalParameters' => array( + 'Email' => 'customer@example.com', + 'ResultURL2' => 'https://example.test/result2', + ), + )); + + $payload = $this->decodeJwtPayloadFromLastBody(); + $encodedSplit = $payload['AdditionalParameters']['Split']; + + $this->assertSame('https://pay/split', $url); + $this->assertSame('https://services.robokassa.ru/InvoiceServiceWebApi/api/CreateInvoice', $this->http->lastUrl); + $this->assertSame(array('Content-Type' => 'application/json'), $this->http->lastHeaders); + $this->assertSame('Reusable', $payload['InvoiceType']); + $this->assertArrayNotHasKey('Split', $payload); + $this->assertSame('customer@example.com', $payload['AdditionalParameters']['Email']); + $this->assertSame('https://example.test/result2', $payload['AdditionalParameters']['ResultURL2']); + $this->assertSame($split, json_decode($encodedSplit, true)); + $this->assertStringContainsString('Товар / услуга', $encodedSplit); + $this->assertStringContainsString('https://example.test/item', $encodedSplit); + $this->assertStringNotContainsString('%7B', $encodedSplit); + } + + public function testSendSplitKeepsAllCreateInvoiceParameters(): void { + $this->http->queueResponse(new Response('{"url":"https://pay/split","isSuccess":true}', 200)); + $invoiceItems = array(array( + 'Name' => 'Invoice item', + 'Quantity' => 1, + 'Cost' => 700, + 'Tax' => 'none', + )); + $expected = array( + 'ExpirationDate' => '2026-12-31T23:59:59+03:00', + 'Description' => 'Split payment', + 'FiscalParentOpId' => 123456, + 'MerchantComments' => 'Internal comment', + 'InvoiceItems' => $invoiceItems, + 'UserFields' => array('order' => '500003'), + 'SuccessUrl2Data' => array('Url' => 'https://example.test/success', 'Method' => 'GET'), + 'FailUrl2Data' => array('Url' => 'https://example.test/fail', 'Method' => 'POST'), + 'Aliases' => array('BankCard', 'SBP'), + 'Payments' => array(array('Type' => 'Cashless', 'Sum' => 700)), + 'CustomUserProperty' => array('Name' => 'source', 'Value' => 'marketplace'), + 'IsWithoutFreeSale' => true, + 'Sno' => 'osn', + ); + + $this->createRobo()->payment()->sendSplit(array_merge(array( + 'InvId' => 500003, + 'OutSum' => 700, + 'InvoiceType' => 'Reusable', + 'Culture' => 'en', + 'Split' => $this->splitMerchants(), + ), $expected)); + + $payload = $this->decodeJwtPayloadFromLastBody(); + + $this->assertSame('Reusable', $payload['InvoiceType']); + $this->assertSame('en', $payload['Culture']); + foreach ($expected as $name => $value) { + $this->assertArrayHasKey($name, $payload); + $this->assertSame($value, $payload[$name]); + } + } + + public function testSendSplitAcceptsReceiptPrecisionLimits(): void { + $this->http->queueResponse(new Response('{"url":"https://pay/split","isSuccess":true}', 200)); + $split = $this->splitMerchants(); + $split[0]['receipt']['items'][0]['quantity'] = 99999.999; + $split[0]['receipt']['items'][0]['sum'] = 99999999.99; + + $this->createRobo()->payment()->sendSplit(array( + 'InvId' => 500004, + 'OutSum' => 700, + 'Split' => $split, + )); + + $payload = $this->decodeJwtPayloadFromLastBody(); + $encodedSplit = json_decode($payload['AdditionalParameters']['Split'], true); + + $this->assertSame(99999.999, $encodedSplit[0]['receipt']['items'][0]['quantity']); + $this->assertSame(99999999.99, $encodedSplit[0]['receipt']['items'][0]['sum']); + } + + public function testSendSplitKeepsUnrelatedInvoiceParametersUntouched(): void { + $this->http->queueResponse(new Response('{"url":"https://pay/split","isSuccess":true}', 200)); + + $this->createRobo()->payment()->sendSplit(array( + 'InvId' => 500002, + 'OutSum' => 700, + 'Culture' => 'en', + 'Split' => $this->splitMerchants(), + 'AdditionalParameters' => array( + 'Recurring' => 'true', + ), + )); + + $payload = $this->decodeJwtPayloadFromLastBody(); + + $this->assertSame('en', $payload['Culture']); + $this->assertSame('true', $payload['AdditionalParameters']['Recurring']); + $this->assertArrayHasKey('Split', $payload['AdditionalParameters']); + } + + public function testSendSplitRejectsTestMode(): void { + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage('Split payments are not supported in test mode.'); + + $this->createRobo(null, array( + 'is_test' => true, + 'test_password1' => 'tp1', + 'test_password2' => 'tp2', + ))->payment()->sendSplit(array( + 'InvId' => 500001, + 'OutSum' => 700, + 'Split' => $this->splitMerchants(), + )); + } + + /** + * @dataProvider invalidSplitProvider + */ + public function testSendSplitRejectsInvalidParameters(array $params, string $message): void { + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage($message); + + $this->createRobo()->payment()->sendSplit($params); + } + + public function invalidSplitProvider(): array { + $base = array('InvId' => 500001, 'OutSum' => 700); + $merchant = array('id' => 'login', 'amount' => 700); + $item = array('name' => 'Item', 'quantity' => 1, 'sum' => 700, 'tax' => 'none'); + + return array( + array($base, 'Required split parameter: Split'), + array($base + array('Split' => array()), 'Split must be a non-empty array of merchants.'), + array( + $base + array('Split' => array('master' => $merchant)), + 'Split must be a list of merchants.' + ), + array($base + array('Split' => array('merchant')), 'Split[0] must be an array.'), + array( + $base + array('Split' => array(array('amount' => 700))), + 'Split[0].id must be a non-empty string.' + ), + array( + $base + array('Split' => array(array('id' => 'login'))), + 'Required split parameter: Split[0].amount' + ), + array( + $base + array('Split' => array(array('id' => 'login', 'amount' => '700.00'))), + 'Split[0].amount must be a non-negative number.' + ), + array( + $base + array('Split' => array(array('id' => 'login', 'amount' => -1))), + 'Split[0].amount must be a non-negative number.' + ), + array( + $base + array('Split' => array($merchant + array('InvoiceId' => '1'))), + 'Split[0].InvoiceId must be a non-negative integer.' + ), + array( + $base + array('Split' => array($merchant + array('receipt' => array()))), + 'Split[0].receipt.items must be a non-empty array.' + ), + array( + $base + array('Split' => array($merchant + array('receipt' => array( + 'items' => array(array_merge($item, array('tax' => 'vat99'))), + )))), + 'Split[0].receipt.items[0].tax has an unsupported value.' + ), + array( + $base + array('Split' => array($merchant + array('receipt' => array( + 'items' => array(array_merge($item, array('quantity' => 0))), + )))), + 'Split[0].receipt.items[0].quantity must be a positive decimal with up to 5 integer and 3 fractional digits.' + ), + array( + $base + array('Split' => array($merchant + array('receipt' => array( + 'items' => array(array_merge($item, array('quantity' => 1.2345))), + )))), + 'Split[0].receipt.items[0].quantity must be a positive decimal with up to 5 integer and 3 fractional digits.' + ), + array( + $base + array('Split' => array($merchant + array('receipt' => array( + 'items' => array(array_merge($item, array('quantity' => 123456))), + )))), + 'Split[0].receipt.items[0].quantity must be a positive decimal with up to 5 integer and 3 fractional digits.' + ), + array( + $base + array('Split' => array($merchant + array('receipt' => array( + 'items' => array(array_merge($item, array('sum' => 1.234))), + )))), + 'Split[0].receipt.items[0].sum must be a non-negative decimal with up to 8 integer and 2 fractional digits.' + ), + array( + $base + array('Split' => array($merchant + array('receipt' => array( + 'items' => array(array_merge($item, array('sum' => 123456789))), + )))), + 'Split[0].receipt.items[0].sum must be a non-negative decimal with up to 8 integer and 2 fractional digits.' + ), + array( + $base + array('Split' => array($merchant + array('receipt' => array( + 'items' => array($item + array('payment_method' => 'unknown')), + )))), + 'Split[0].receipt.items[0].payment_method has an unsupported value.' + ), + array( + $base + array( + 'Split' => array($merchant), + 'AdditionalParameters' => array('Email' => 123), + ), + 'Invalid split parameter AdditionalParameters.Email: string expected.' + ), + array( + $base + array( + 'Split' => array($merchant), + 'AdditionalParameters' => array('IsTest' => '1'), + ), + 'Split payments are not compatible with IsTest.' + ), + ); + } + + public function testSendSplitRejectsAdditionalParametersSplit(): void { + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage( + 'Split must be passed as a top-level SDK parameter, not as AdditionalParameters.Split.' + ); + + $this->createRobo()->payment()->sendSplit(array( + 'InvId' => 500001, + 'OutSum' => 700, + 'Split' => $this->splitMerchants(), + 'AdditionalParameters' => array('Split' => '[]'), + )); + } + + public function testSendSplitReportsInvoiceApiBusinessError(): void { + $this->http->queueResponse(new Response('{"isSuccess":false,"message":"Split is unavailable"}', 200)); + + $this->expectException(RobokassaException::class); + $this->expectExceptionMessage('Invoice API request failed: Split is unavailable'); + + $this->createRobo()->payment()->sendSplit(array( + 'InvId' => 500001, + 'OutSum' => 700, + 'Split' => $this->splitMerchants(), + )); + } + public function testSendSavedCardPassesTokenInAdditionalParameters(): void { $this->http->queueResponse(new Response('{"url":"https://pay"}', 200)); @@ -851,6 +1112,34 @@ private function expectedRecurringBody(): string { . '&SignatureValue=1be6746b70e8f702171f85e427399a9e'; } + private function splitMerchants(): array { + return array( + array( + 'id' => 'login', + 'InvoiceId' => 500001, + 'amount' => 500, + 'receipt' => array( + 'sno' => 'osn', + 'items' => array( + array( + 'name' => 'Товар / услуга https://example.test/item', + 'quantity' => 1, + 'sum' => 500, + 'tax' => 'vat20', + 'payment_method' => 'full_payment', + 'payment_object' => 'commodity', + 'nomenclature_code' => '1231', + ), + ), + ), + ), + array( + 'id' => 'partner-shop', + 'amount' => 200, + ), + ); + } + private function decodeJwtPayloadFromLastBody(): array { $jwt = json_decode($this->http->lastBody, true); $parts = explode('.', $jwt);