diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bae428a..1d996cf 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,19 +5,46 @@ on: pull_request: jobs: - phpunit: - name: PHPUnit (PHP ${{ matrix.php-version }}) + checks: + name: ${{ matrix.name }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: - php-version: - - '8.1' - - '8.2' - - '8.3' - - '8.4' - - '8.5' + include: + - name: PHPUnit (PHP 8.1) + php-version: '8.1' + command: composer test + tool: '' + - name: PHPUnit (PHP 8.2) + php-version: '8.2' + command: composer test + tool: '' + - name: PHPUnit (PHP 8.3) + php-version: '8.3' + command: composer test + tool: '' + - name: PHPUnit (PHP 8.4) + php-version: '8.4' + command: composer test + tool: '' + - name: PHPUnit (PHP 8.5) + php-version: '8.5' + command: composer test + tool: '' + - name: PHPStan + php-version: '8.3' + command: make phpstan + tool: phpstan + - name: Rector + php-version: '8.3' + command: make rector + tool: rector + - name: PHP CS Fixer + php-version: '8.3' + command: make lint + tool: php-cs-fixer steps: - name: Checkout @@ -36,5 +63,9 @@ jobs: - name: Install dependencies run: composer update --prefer-dist --no-progress --no-interaction - - name: Run tests - run: composer test + - name: Install tool dependencies + if: ${{ matrix.tool != '' }} + run: composer bin ${{ matrix.tool }} install --no-progress --no-interaction + + - name: Run ${{ matrix.name }} + run: ${{ matrix.command }} diff --git a/Makefile b/Makefile index aff09ac..d5364bd 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,10 @@ lint: vendor ## Проверить PHP code style при помощи PHP CS Fix $(EXEC_PHP) vendor-bin/php-cs-fixer/vendor/bin/php-cs-fixer fix --dry-run --diff --verbose .PHONY: lint +rector: vendor ## Проверить PHP код при помощи Rector (https://getrector.com) + $(EXEC_PHP) vendor-bin/rector/vendor/bin/rector process --dry-run --no-progress-bar +.PHONY: rector + fixcs: vendor ## Исправить ошибки PHP code style при помощи PHP CS Fixer (https://github.com/FriendsOfPHP/PHP-CS-Fixer) $(EXEC_PHP) vendor-bin/php-cs-fixer/vendor/bin/php-cs-fixer fix --diff --verbose .PHONY: fixcs diff --git a/README.md b/README.md index 8dba834..2bb795e 100644 --- a/README.md +++ b/README.md @@ -1,158 +1,298 @@ -# Cloudpayments API Library +# CloudPayments PHP Client + +[![Tests](https://github.com/axcherednikov/cloudpayments-php-client/actions/workflows/tests.yml/badge.svg)](https://github.com/axcherednikov/cloudpayments-php-client/actions/workflows/tests.yml) +[![Latest Stable Version](https://img.shields.io/packagist/v/axcherednikov/cloudpayments-php-client.svg)](https://packagist.org/packages/axcherednikov/cloudpayments-php-client) +[![PHP Version](https://img.shields.io/packagist/dependency-v/axcherednikov/cloudpayments-php-client/php.svg)](https://packagist.org/packages/axcherednikov/cloudpayments-php-client) +[![License](https://img.shields.io/packagist/l/axcherednikov/cloudpayments-php-client.svg)](LICENSE) + +PHP-клиент для [CloudPayments API](https://developers.cloudpayments.ru/#api). +Пакет предоставляет DTO для запросов, DTO для ответов, обработку HTTP-запросов через Guzzle и классы для данных webhook-уведомлений. + +Проект основан на кодовой базе `flowwow/cloudpayments-php-client`, развивается независимо и использует namespace `Excent\Cloudpayments`. ## Оглавление -- [Предисловие](#предисловие) - [Требования](#требования) - [Установка](#установка) -- [Начало работы](#начало-работы) +- [Быстрый старт](#быстрый-старт) +- [3-D Secure](#3-d-secure) - [Поддерживаемые методы](#поддерживаемые-методы) -- [Параметры запроса](#параметры-запроса) -- [Параметры ответа](#параметры-ответа) +- [Запросы](#запросы) +- [Ответы](#ответы) - [Уведомления](#уведомления) - [Идемпотентность](#идемпотентность) - -## Предисловие - -Пакет flowwow/cloudpayments-php-client потерял своевременную поддержку, -и было принято решение создать форк данного пакета для поддержки более современных версий php и пакетов, -поддерживающие новые версии php +- [Обработка ошибок](#обработка-ошибок) +- [Разработка](#разработка) +- [Версионирование](#версионирование) +- [License](#license) ## Требования -- php 8.1 +- PHP `^8.1` +- Guzzle `^7.4` +- Composer ## Установка -Установить библиотеку можно с помощью composer: - ```bash -$ composer require axcherednikov/cloudpayments-php-client +composer require axcherednikov/cloudpayments-php-client ``` -## Начало работы +## Быстрый старт ```php -$publicId = /*...*/; -$pass = /*...*/; -$apiClient = new \Excent\Cloudpayments\Library($publicId, $pass); +paymentsCardsCharge(new \Excent\Cloudpayments\Request\CardsPayment( - 100, +require __DIR__ . '/vendor/autoload.php'; + +$client = new Library( + $_ENV['CLOUDPAYMENTS_PUBLIC_ID'], + $_ENV['CLOUDPAYMENTS_API_PASSWORD'] +); + +$request = new CardsPayment( + 100.00, 'RUB', - '123.123.123.123', - '01492500008719030128SMfLeYdKp5dSQVIiO5l6ZCJiPdel4uDjdFTTz1UnXY' -)); + '127.0.0.1', + 'CARD_CRYPTOGRAM_PACKET' +); -echo $response->success; +$response = $client->paymentsCardsCharge($request); + +if ($response->success) { + echo $response->model->transactionId; +} ``` -## Поддерживаемые методы +Если нужно переопределить endpoint CloudPayments, передайте URL третьим аргументом конструктора: + +```php +$client = new Library($publicId, $apiPassword, $customApiUrl); +``` -Библиотека поддерживает большое количество методов api(https://developers.cloudpayments.ru/#api). Для параметров запроса и ответа поддерживается объект-обертка. +## 3-D Secure + +Методы `paymentsCardsCharge()` и `createPaymentByCard2Step()` возвращают `TransactionWith3dsResponse`. Если требуется 3-D Secure, `is3dsError()` вернет `true`, а данные для перенаправления будут доступны в `model`. ```php -$apiClient = new \Excent\Cloudpayments\Library(\*...*\); -$apiClient->paymentsCardsCharge(\*...*\); +use Excent\Cloudpayments\Request\Post3DS; + +$response = $client->paymentsCardsCharge($request); + +if ($response->is3dsError()) { + $acsUrl = $response->model->acsUrl; + $paReq = $response->model->paReq; + $transactionId = $response->model->transactionId; + + // Передайте пользователя на страницу ACS банка, затем обработайте PaRes. + $client->post3Ds(new Post3DS($transactionId, 'PARES_FROM_ACS')); +} ``` -| Метод api | Метод library | Объект Request | Объект Response | -|----------------------------------|--------------------------|----------------------|----------------------------| -| payments/cards/charge | paymentsCardsCharge | CardsPayment | TransactionWith3dsResponse | -| payments/cards/auth | createPaymentByCard2Step | CardsPayment | TransactionWith3dsResponse | -| payments/tokens/charge | executePaymentByToken | TokenPayment | TransactionResponse | -| payments/tokens/auth | executePaymentByToken | TokenPayment | TransactionResponse | -| payments/confirm | confirmPayment | PaymentsConfirm | CloudResponse | -| payments/void | cancelPayment | PaymentsVoid | CloudResponse | -| payments/refund | paymentsRefund | PaymentsRefund | TransactionResponse | -| payments/cards/topup | paymentsCardsTopup | CardsTopUp | TransactionResponse | -| payments/token/topup | paymentsTokenTopup | TokenTopUp | TransactionResponse | -| payments/get | getPaymentData | PaymentsGet | TransactionResponse | -| payments/find | getPaymentDataByInvoice | PaymentsFind | TransactionResponse | -| payments/list | getListPayment | PaymentsList | TransactionArrayResponse | -| payments/tokens/list | paymentsTokensList | TokenList | TokenArrayResponse | -| subscriptions/create | subscriptionsCreate | SubscriptionCreate | SubscriptionResponse | -| subscriptions/get | subscriptionsGet | SubscriptionGet | SubscriptionResponse | -| subscriptions/find | subscriptionsFind | SubscriptionFind | SubscriptionArrayResponse | -| subscriptions/update | subscriptionsUpdate | SubscriptionUpdate | SubscriptionResponse | -| subscriptions/cancel | subscriptionsCancel | SubscriptionCancel | CloudResponse | -| orders/create | ordersCreate | OrderCreate | OrderResponse | -| orders/cancel | ordersCancel | OrderCancel | CloudResponse | -| site/notifications/{Type}/get | siteNotificationsGet | NotificationsGet | NotificationResponse | -| site/notifications/{Type}/update | siteNotificationsUpdate | NotificationsUpdate | CloudResponse | -| applepay/startsession | startSession | ApplepayStartSession | AppleSessionResponse | - -## Параметры запроса - -Параметры запроса обернуты в dto-объект +## Поддерживаемые методы + +Библиотека работает с методами из [CloudPayments API](https://developers.cloudpayments.ru/#api) через request/response DTO. + +| Метод API | Метод Library | Request DTO | Response DTO | +|----------------------------------|---------------------------|----------------------|----------------------------| +| `payments/cards/charge` | `paymentsCardsCharge` | `CardsPayment` | `TransactionWith3dsResponse` | +| `payments/cards/auth` | `createPaymentByCard2Step` | `CardsPayment` | `TransactionWith3dsResponse` | +| `payments/cards/post3ds` | `post3Ds` | `Post3DS` | `TransactionResponse` | +| `payments/tokens/charge` | `executePaymentByToken` | `TokenPayment` | `TransactionResponse` | +| `payments/tokens/auth` | `createPaymentByToken2Step` | `TokenPayment` | `TransactionResponse` | +| `payments/confirm` | `confirmPayment` | `PaymentsConfirm` | `CloudResponse` | +| `payments/void` | `cancelPayment` | `PaymentsVoid` | `CloudResponse` | +| `payments/refund` | `paymentsRefund` | `PaymentsRefund` | `TransactionResponse` | +| `payments/cards/topup` | `paymentsCardsTopup` | `CardsTopUp` | `TransactionResponse` | +| `payments/token/topup` | `paymentsTokenTopup` | `TokenTopUp` | `TransactionResponse` | +| `payments/get` | `getPaymentData` | `PaymentsGet` | `TransactionResponse` | +| `payments/find` | `getPaymentDataByInvoice` | `PaymentsFind` | `TransactionResponse` | +| `payments/list` | `getListPayment` | `PaymentsList` | `TransactionArrayResponse` | +| `payments/tokens/list` | `paymentsTokensList` | `TokenList` или `null` | `TokenArrayResponse` | +| `subscriptions/create` | `subscriptionsCreate` | `SubscriptionCreate` | `SubscriptionResponse` | +| `subscriptions/get` | `subscriptionsGet` | `SubscriptionGet` | `SubscriptionResponse` | +| `subscriptions/find` | `subscriptionsFind` | `SubscriptionFind` | `SubscriptionArrayResponse` | +| `subscriptions/update` | `subscriptionsUpdate` | `SubscriptionUpdate` | `SubscriptionResponse` | +| `subscriptions/cancel` | `subscriptionsCancel` | `SubscriptionCancel` | `CloudResponse` | +| `orders/create` | `ordersCreate` | `OrderCreate` | `OrderResponse` | +| `orders/cancel` | `ordersCancel` | `OrderCancel` | `CloudResponse` | +| `site/notifications/{Type}/get` | `siteNotificationsGet` | `NotificationsGet` | `NotificationResponse` | +| `site/notifications/{Type}/update` | `siteNotificationsUpdate` | `NotificationsUpdate` | `CloudResponse` | +| `applepay/startsession` | `startSession` | `ApplepayStartSession` | `AppleSessionResponse` | +| `kkt/receipt` | `createReceipt` | `KktReceipt` | `KktReceiptResponse` | + +## Запросы + +Параметры API передаются через DTO из namespace `Excent\Cloudpayments\Request`. ```php -... -$validationUrl = 'https://apple-pay-gateway.apple.com/paymentservices/startSession'; -$request = new \Excent\Cloudpayments\Request\ApplepayStartSession($validationUrl); -$apiClient->startSession($request); +use Excent\Cloudpayments\Request\ApplepayStartSession; + +$request = new ApplepayStartSession( + 'https://apple-pay-gateway.apple.com/paymentservices/startSession' +); + +$response = $client->startSession($request); ``` -Библиотека может выбрасывать ошибку ```BadTypeException``` при формировании request-объекта +DTO наследуются от `BaseRequest` и преобразуются в формат CloudPayments через `asArray()`: + +- `amount` превращается в `Amount`; +- значения `null` не попадают в запрос; +- `true` и `false` передаются как строковые значения, ожидаемые API; +- вложенные DTO и массивы DTO преобразуются рекурсивно. + +Некоторые DTO для совместимости с существующим публичным контрактом заполняются через публичные свойства: ```php -try { - ... - $validationUrl = 'https://apple-pay-gateway.apple.com/paymentservices/startSession'; - $request = new \Excent\Cloudpayments\Request\ApplepayStartSession($validationUrl); - ... -} catch (\Excent\Cloudpayments\Exceptions\BadTypeException $e) { - var_dump($e->getMessage()); -} +use Excent\Cloudpayments\Request\NotificationsUpdate; + +$request = new NotificationsUpdate(); +$request->type = 'pay'; +$request->isEnabled = true; +$request->address = 'https://example.com/cloudpayments/pay'; + +$client->siteNotificationsUpdate($request); ``` -## Параметры ответа +## Ответы + +Все response DTO наследуются от `CloudResponse`. -Параметры ответа так же обернуты в dto-объект. ```CloudResponse``` имеет 3 свойства: ```success```, ```message```, ```model``` +| Свойство | Описание | +|-----------|----------| +| `success` | Результат операции из поля `Success`. | +| `message` | Сообщение из поля `Message`. | +| `warning` | Предупреждение из поля `Warning`. | +| `model` | Модель ответа, тип зависит от вызванного метода. | -В свойство ```model``` записывается нужная сущность, в зависимости от запроса. +Поддерживаемые модели: -Список поддерживаемых сущностей: -- ```AppleSessionModel``` -- ```NotificationModel``` -- ```SubscriptionModel``` -- ```TokenModel``` -- ```TransactionModel``` -- ```TransactionWith3dsModel``` +| Response DTO | Model | +|--------------|-------| +| `AppleSessionResponse` | `AppleSessionModel` | +| `KktReceiptResponse` | `KktReceiptModel` | +| `NotificationResponse` | `NotificationModel` | +| `OrderResponse` | `OrderModel` | +| `SubscriptionResponse` | `SubscriptionModel` | +| `SubscriptionArrayResponse` | `SubscriptionModel[]` | +| `TokenArrayResponse` | `TokenModel[]` | +| `TransactionResponse` | `TransactionModel` | +| `TransactionArrayResponse` | `TransactionModel[]` | +| `TransactionWith3dsResponse` | `TransactionWith3dsModel` | + +Если CloudPayments вернет поля, которых нет в модели, они будут доступны через `getAdditionalProperties()`. + +```php +$extra = $response->model->getAdditionalProperties(); +``` ## Уведомления -Библиотека включает в себя dto-объекты для параметров веб хуков +Библиотека включает DTO для данных webhook-уведомлений. Классы мапят поля CloudPayments вида `TransactionId` в свойства вида `transactionId`. ```php -$hookData = new \Excent\Cloudpayments\Hook\HookPay($_POST); -echo $hookData->transactionId; +use Excent\Cloudpayments\Hook\HookPay; + +$hook = new HookPay($_POST); + +echo $hook->transactionId; ``` -Список всех уведомлений - https://developers.cloudpayments.ru/#check +Классы уведомлений: -| Webhook | Объект | -|-----------|---------------| -| Check | HookCheck | -| Pay | HookPay | -| Fail | HookFail | -| Confirm | HookConfirm | -| Refund | HookRefund | -| Recurrent | HookRecurrent | -| Cancel | HookCancel | +| Webhook | DTO | +|---------|-----| +| `Check` | `HookCheck` | +| `Pay` | `HookPay` | +| `Fail` | `HookFail` | +| `Confirm` | `HookConfirm` | +| `Refund` | `HookRefund` | +| `Recurrent` | `HookRecurrent` | +| `Cancel` | `HookCancel` | +| `Receipt` | `HookReceipt` | + +Webhook DTO только преобразуют входные данные в объект. Проверку подписи, бизнес-валидацию и формирование ответа для CloudPayments нужно реализовать на стороне приложения. ## Идемпотентность -Библиотека поддерживает идемпотентные запросы +Для идемпотентных запросов библиотека отправляет заголовок `X-Request-ID`. + +Автоматический ключ строится из метода и данных запроса: + +```php +$client->setIdempotency(true); + +$response = $client->createPaymentByCard2Step($request); +``` + +Можно задать ключ явно: + +```php +$client->setIdempotencyKey('order-100500-auth'); + +$response = $client->createPaymentByCard2Step($request); +``` + +## Обработка ошибок + +Request DTO могут выбрасывать `BadTypeException`, если переданы некорректные значения. HTTP-слой может выбросить исключения Guzzle, а разбор JSON - `JsonException`. ```php -... -$apiClient = new \Excent\Cloudpayments\Library(\*...*\); -$apiClient->setIdempotency(true); -$apiClient->createPaymentByCard2Step(\*...*\); -... +use Excent\Cloudpayments\Exceptions\BadTypeException; +use GuzzleHttp\Exception\GuzzleException; + +try { + $response = $client->paymentsRefund($request); +} catch (BadTypeException $exception) { + // Некорректные параметры request DTO. +} catch (GuzzleException $exception) { + // Ошибка HTTP-запроса. +} catch (JsonException $exception) { + // Ответ API не удалось разобрать как JSON. +} +``` + +## Разработка + +```bash +composer install +composer bin phpstan install +composer bin rector install +composer bin php-cs-fixer install + +composer test +make phpstan +make rector +make lint ``` +Полезные команды: + +| Команда | Назначение | +|---------|------------| +| `composer test` | Запустить PHPUnit. | +| `make phpstan` | Запустить PHPStan. | +| `make rector` | Проверить код Rector в dry-run режиме. | +| `make lint` | Проверить стиль PHP CS Fixer. | +| `make fixcs` | Исправить стиль PHP CS Fixer. | +| `make rector-fix` | Применить исправления Rector. | + +CI запускает тесты на поддерживаемых версиях PHP и отдельные quality checks для PHPStan, Rector и PHP CS Fixer. + +## Версионирование + +Проект следует SemVer: + +- patch-релизы исправляют ошибки, документацию, тесты и статический анализ без изменения публичного контракта; +- minor-релизы могут добавлять новые методы и DTO обратно совместимым образом; +- major-релизы могут содержать несовместимые изменения. + ## License -MIT +MIT. See [LICENSE](LICENSE). diff --git a/src/Hook/BaseHook.php b/src/Hook/BaseHook.php index 9a59165..45d70fc 100644 --- a/src/Hook/BaseHook.php +++ b/src/Hook/BaseHook.php @@ -10,11 +10,17 @@ */ class BaseHook { + /** + * @param array $request + */ public function __construct(protected array $request) { $this->fill(); } + /** + * @return array + */ public function getRequest(): array { return $this->request; @@ -25,7 +31,7 @@ private function fill(): void $modelFields = get_object_vars($this); foreach ($modelFields as $key => $field) { - $requestKey = ucfirst($key); + $requestKey = ucfirst((string) $key); if (isset($this->request[$requestKey])) { $this->$key = $this->request[$requestKey]; diff --git a/src/Hook/HookReceipt.php b/src/Hook/HookReceipt.php index ad601dc..1945af4 100644 --- a/src/Hook/HookReceipt.php +++ b/src/Hook/HookReceipt.php @@ -7,28 +7,88 @@ */ class HookReceipt extends BaseHook { + /** + * @var int|string|null + */ public $id; + /** + * @var int|string|null + */ public $documentNumber; + /** + * @var int|string|null + */ public $sessionNumber; + /** + * @var int|string|null + */ public $number; + /** + * @var int|string|null + */ public $fiscalSign; + /** + * @var string|null + */ public $deviceNumber; + /** + * @var string|null + */ public $regNumber; + /** + * @var string|null + */ public $fiscalNumber; + /** + * @var string|null + */ public $inn; + /** + * @var string|null + */ public $type; + /** + * @var string|null + */ public $ofd; + /** + * @var string|null + */ public $url; + /** + * @var string|null + */ public $qrCodeUrl; /** * @var int|null */ public $transactionId; + /** + * @var float|int|string|null + */ public $amount; + /** + * @var string|null + */ public $dateTime; + /** + * @var string|null + */ public $invoiceId; + /** + * @var string|null + */ public $accountId; + /** + * @var array|string|null + */ public $receipt; + /** + * @var string|null + */ public $calculationPlace; + /** + * @var string|null + */ public $settlePlace; } diff --git a/src/Library.php b/src/Library.php index 1281c71..6f75f8d 100644 --- a/src/Library.php +++ b/src/Library.php @@ -39,6 +39,7 @@ use Excent\Cloudpayments\Response\TransactionWith3dsResponse; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; +use JsonException; use Psr\Http\Message\ResponseInterface; /** @@ -353,6 +354,16 @@ public function siteNotificationsUpdate(NotificationsUpdate $data): CloudRespons /** * Базовый запрос + * + * @template T of CloudResponse + * + * @param string|CloudMethodsEnum $method + * @param array $postData + * @param T $cloudResponse + * + * @return T + * @throws GuzzleException + * @throws JsonException */ private function request(string|CloudMethodsEnum $method, array $postData, CloudResponse $cloudResponse): CloudResponse { @@ -361,13 +372,16 @@ private function request(string|CloudMethodsEnum $method, array $postData, Cloud } $response = $this->sendRequest($method, $postData); + $cloudResponse->fillByResponse($response); - return $cloudResponse->fillByResponse($response); + return $cloudResponse; } /** * Запрос по api. * + * @param array $postData + * * @throws GuzzleException */ public function sendRequest(string $method, array $postData = []): ResponseInterface @@ -383,6 +397,8 @@ public function sendRequest(string $method, array $postData = []): ResponseInter /** * Генерирует request id для идемпотентных запросов. + * + * @param array $postData */ public function getRequestId(string $method, array $postData): string { diff --git a/src/Request/BaseRequest.php b/src/Request/BaseRequest.php index 2f93694..26f6dc1 100644 --- a/src/Request/BaseRequest.php +++ b/src/Request/BaseRequest.php @@ -22,7 +22,7 @@ public function asArray(): array $fields = get_object_vars($this); foreach ($fields as $field => $value) { - $key = ucfirst($field); + $key = ucfirst((string) $field); if ($value === true) { $value = BoolField::TRUE->value; diff --git a/src/Request/CardsPayment.php b/src/Request/CardsPayment.php index ec138ae..316c5a3 100644 --- a/src/Request/CardsPayment.php +++ b/src/Request/CardsPayment.php @@ -9,6 +9,9 @@ */ class CardsPayment extends BaseRequest { + /** + * @param array|null $payer + */ public function __construct( public float|int $amount, public string $currency, diff --git a/src/Request/CardsTopUp.php b/src/Request/CardsTopUp.php index 14822dc..5abcea5 100644 --- a/src/Request/CardsTopUp.php +++ b/src/Request/CardsTopUp.php @@ -18,6 +18,12 @@ class CardsTopUp extends BaseRequest public ?string $jsonData = null; public ?string $invoiceId = null; public ?string $description = null; + /** + * @var array|null + */ public ?array $payer = null; + /** + * @var array|null + */ public ?array $receiver = null; } diff --git a/src/Request/OrderCreate.php b/src/Request/OrderCreate.php index 5af4378..dd4254b 100644 --- a/src/Request/OrderCreate.php +++ b/src/Request/OrderCreate.php @@ -12,7 +12,7 @@ class OrderCreate extends BaseRequest { /** - * @var int|float + * @var int|float|string */ public $amount; public ?string $email = null; @@ -33,7 +33,8 @@ class OrderCreate extends BaseRequest /** * OrderCreate constructor. * - * @param $amount + * @param int|float|string $amount + * * @throws BadTypeException */ public function __construct($amount, public string $currency, public string $description) diff --git a/src/Request/PaymentsConfirm.php b/src/Request/PaymentsConfirm.php index 95820b7..a5f6d25 100644 --- a/src/Request/PaymentsConfirm.php +++ b/src/Request/PaymentsConfirm.php @@ -12,7 +12,7 @@ class PaymentsConfirm extends BaseRequest { /** - * @var int|float + * @var int|float|string */ public $amount; public ?string $jsonData = null; @@ -20,8 +20,9 @@ class PaymentsConfirm extends BaseRequest /** * PaymentsConfirm constructor. * - * @param int|float $amount - * @param string|null $jsonData + * @param int|float|string $amount + * @param string|null $jsonData + * * @throws BadTypeException */ public function __construct($amount, public int $transactionId, ?string $jsonData = null) diff --git a/src/Request/Receipt/CustomerReceipt.php b/src/Request/Receipt/CustomerReceipt.php index 944120c..e3ccc1d 100644 --- a/src/Request/Receipt/CustomerReceipt.php +++ b/src/Request/Receipt/CustomerReceipt.php @@ -21,7 +21,7 @@ class CustomerReceipt extends BaseRequest * @param bool|null $isBso * @param string|null $agentSign * @param string|null $cashierName - * @param array|null $additionalReceiptInfos + * @param array|null $additionalReceiptInfos */ public function __construct( public array $items, diff --git a/src/Request/TokenTopUp.php b/src/Request/TokenTopUp.php index 736e1cf..aae6e29 100644 --- a/src/Request/TokenTopUp.php +++ b/src/Request/TokenTopUp.php @@ -17,6 +17,12 @@ class TokenTopUp extends BaseRequest public string $accountId; public string $currency; public ?string $invoiceId = null; + /** + * @var array|null + */ public ?array $payer = null; + /** + * @var array|null + */ public ?array $receiver = null; } diff --git a/src/Response/AppleSessionResponse.php b/src/Response/AppleSessionResponse.php index f0165bb..bda8f7a 100644 --- a/src/Response/AppleSessionResponse.php +++ b/src/Response/AppleSessionResponse.php @@ -3,6 +3,7 @@ namespace Excent\Cloudpayments\Response; use Excent\Cloudpayments\Response\Models\AppleSessionModel; +use stdClass; /** * Class NotificationResponse. @@ -12,6 +13,9 @@ class AppleSessionResponse extends CloudResponse /** @var AppleSessionModel */ public $model; + /** + * @param stdClass $modelDate + */ public function fillModel($modelDate): void { $model = new AppleSessionModel(); diff --git a/src/Response/CloudResponse.php b/src/Response/CloudResponse.php index 3b1e82e..22a69ac 100644 --- a/src/Response/CloudResponse.php +++ b/src/Response/CloudResponse.php @@ -4,6 +4,7 @@ use Excent\Cloudpayments\Response\Models\BaseModel; use Psr\Http\Message\ResponseInterface; +use stdClass; /** * Class CloudResponse. @@ -14,6 +15,7 @@ class CloudResponse public ?string $message = null; public ?string $warning = null; + /** @var mixed */ public $model; /** @@ -21,7 +23,11 @@ class CloudResponse */ public function fillByResponse(ResponseInterface $response): self { - $responseContent = (object) json_decode($response->getBody()->getContents(), null, 512, JSON_THROW_ON_ERROR); + $responseContent = json_decode($response->getBody()->getContents(), null, 512, JSON_THROW_ON_ERROR); + + if (! $responseContent instanceof stdClass) { + $responseContent = new stdClass(); + } $this->success = $responseContent->Success ?? false; $this->message = $responseContent->Message ?? 'Message is not set'; @@ -36,12 +42,14 @@ public function fillByResponse(ResponseInterface $response): self /** * Заполняет model свойство. + * + * @param mixed $modelDate */ public function fillModel($modelDate): void { $model = $modelDate; - if (is_object($modelDate)) { + if ($modelDate instanceof stdClass) { $model = new BaseModel(); $model->fill($modelDate); } diff --git a/src/Response/KktReceiptResponse.php b/src/Response/KktReceiptResponse.php index 8c63ef4..91cde13 100644 --- a/src/Response/KktReceiptResponse.php +++ b/src/Response/KktReceiptResponse.php @@ -3,6 +3,7 @@ namespace Excent\Cloudpayments\Response; use Excent\Cloudpayments\Response\Models\KktReceiptModel; +use stdClass; /** * Class KktReceiptResponse. @@ -12,6 +13,9 @@ class KktReceiptResponse extends CloudResponse /** @var KktReceiptModel */ public $model; + /** + * @param stdClass $modelDate + */ public function fillModel($modelDate): void { $model = new KktReceiptModel(); diff --git a/src/Response/Models/BaseModel.php b/src/Response/Models/BaseModel.php index 1333874..e041d73 100644 --- a/src/Response/Models/BaseModel.php +++ b/src/Response/Models/BaseModel.php @@ -18,7 +18,7 @@ public function fill(stdClass $fillData): void $knownProperties = $this->getKnownProperties(); foreach ($props as $key => $value) { - $lowerKey = lcfirst($key); + $lowerKey = lcfirst((string) $key); if (isset($knownProperties[$lowerKey])) { $this->{$lowerKey} = $value; diff --git a/src/Response/NotificationResponse.php b/src/Response/NotificationResponse.php index e9bb744..1312ebc 100644 --- a/src/Response/NotificationResponse.php +++ b/src/Response/NotificationResponse.php @@ -3,6 +3,7 @@ namespace Excent\Cloudpayments\Response; use Excent\Cloudpayments\Response\Models\NotificationModel; +use stdClass; /** * Class NotificationResponse. @@ -12,6 +13,9 @@ class NotificationResponse extends CloudResponse /** @var NotificationModel */ public $model; + /** + * @param stdClass $modelDate + */ public function fillModel($modelDate): void { $model = new NotificationModel(); diff --git a/src/Response/OrderResponse.php b/src/Response/OrderResponse.php index e8fe745..0916cb6 100644 --- a/src/Response/OrderResponse.php +++ b/src/Response/OrderResponse.php @@ -3,6 +3,7 @@ namespace Excent\Cloudpayments\Response; use Excent\Cloudpayments\Response\Models\OrderModel; +use stdClass; /** * Class SubscriptionResponse. @@ -12,6 +13,9 @@ class OrderResponse extends CloudResponse /** @var OrderModel */ public $model; + /** + * @param stdClass $modelDate + */ public function fillModel($modelDate): void { $model = new OrderModel(); diff --git a/src/Response/SubscriptionArrayResponse.php b/src/Response/SubscriptionArrayResponse.php index 1734325..8891a88 100644 --- a/src/Response/SubscriptionArrayResponse.php +++ b/src/Response/SubscriptionArrayResponse.php @@ -3,6 +3,7 @@ namespace Excent\Cloudpayments\Response; use Excent\Cloudpayments\Response\Models\SubscriptionModel; +use stdClass; /** * Class SubscriptionArrayResponse. @@ -12,12 +13,16 @@ class SubscriptionArrayResponse extends CloudResponse /** @var SubscriptionModel[] */ public $model; + /** + * @param mixed $modelDate + */ public function fillModel($modelDate): void { $models = []; if (is_array($modelDate)) { foreach ($modelDate as $value) { + /** @var stdClass $value */ $model = new SubscriptionModel(); $model->fill($value); diff --git a/src/Response/SubscriptionResponse.php b/src/Response/SubscriptionResponse.php index e62d7b6..a242d50 100644 --- a/src/Response/SubscriptionResponse.php +++ b/src/Response/SubscriptionResponse.php @@ -3,6 +3,7 @@ namespace Excent\Cloudpayments\Response; use Excent\Cloudpayments\Response\Models\SubscriptionModel; +use stdClass; /** * Class SubscriptionResponse. @@ -12,6 +13,9 @@ class SubscriptionResponse extends CloudResponse /** @var SubscriptionModel */ public $model; + /** + * @param stdClass $modelDate + */ public function fillModel($modelDate): void { $model = new SubscriptionModel(); diff --git a/src/Response/TokenArrayResponse.php b/src/Response/TokenArrayResponse.php index d6c65ba..58dde82 100644 --- a/src/Response/TokenArrayResponse.php +++ b/src/Response/TokenArrayResponse.php @@ -3,6 +3,7 @@ namespace Excent\Cloudpayments\Response; use Excent\Cloudpayments\Response\Models\TokenModel; +use stdClass; /** * Class TokenArrayResponse. @@ -12,12 +13,16 @@ class TokenArrayResponse extends CloudResponse /** @var TokenModel[] */ public $model; + /** + * @param mixed $modelDate + */ public function fillModel($modelDate): void { $models = []; if (is_array($modelDate)) { foreach ($modelDate as $value) { + /** @var stdClass $value */ $model = new TokenModel(); $model->fill($value); diff --git a/src/Response/TransactionArrayResponse.php b/src/Response/TransactionArrayResponse.php index ab264b7..3769a23 100644 --- a/src/Response/TransactionArrayResponse.php +++ b/src/Response/TransactionArrayResponse.php @@ -3,6 +3,7 @@ namespace Excent\Cloudpayments\Response; use Excent\Cloudpayments\Response\Models\TransactionModel; +use stdClass; /** * Class TransactionArrayResponse. @@ -12,12 +13,16 @@ class TransactionArrayResponse extends CloudResponse /** @var TransactionModel[] */ public $model; + /** + * @param mixed $modelDate + */ public function fillModel($modelDate): void { $models = []; if (is_array($modelDate)) { foreach ($modelDate as $value) { + /** @var stdClass $value */ $model = new TransactionModel(); $model->fill($value); diff --git a/src/Response/TransactionResponse.php b/src/Response/TransactionResponse.php index b308749..a3f9ef2 100644 --- a/src/Response/TransactionResponse.php +++ b/src/Response/TransactionResponse.php @@ -3,17 +3,19 @@ namespace Excent\Cloudpayments\Response; use Excent\Cloudpayments\Response\Models\TransactionModel; +use stdClass; /** * Class TransactionResponse. - * - * @extends TransactionResponse */ class TransactionResponse extends CloudResponse { /** @var TransactionModel */ public $model; + /** + * @param stdClass $modelDate + */ public function fillModel($modelDate): void { $model = new TransactionModel(); diff --git a/src/Response/TransactionWith3dsResponse.php b/src/Response/TransactionWith3dsResponse.php index 3dceadc..11a89c3 100644 --- a/src/Response/TransactionWith3dsResponse.php +++ b/src/Response/TransactionWith3dsResponse.php @@ -3,6 +3,7 @@ namespace Excent\Cloudpayments\Response; use Excent\Cloudpayments\Response\Models\TransactionWith3dsModel; +use stdClass; /** * Class TransactionResponse. @@ -12,6 +13,9 @@ class TransactionWith3dsResponse extends CloudResponse /** @var TransactionWith3dsModel */ public $model; + /** + * @param stdClass $modelDate + */ public function fillModel($modelDate): void { $model = new TransactionWith3dsModel(); diff --git a/tests/Hook/HookPayTest.php b/tests/Hook/HookPayTest.php new file mode 100644 index 0000000..f462145 --- /dev/null +++ b/tests/Hook/HookPayTest.php @@ -0,0 +1,39 @@ + 123, + 'Amount' => 10.5, + 'Currency' => 'RUB', + 'DateTime' => '2024-01-01T00:00:00Z', + 'CardFirstSix' => '411111', + 'CardLastFour' => '1111', + 'CardType' => 'Visa', + 'CardExpDate' => '12/30', + 'TestMode' => 1, + 'Status' => 'Completed', + 'OperationType' => 'Payment', + 'GatewayName' => 'Gateway', + ]; + + $hook = new HookPay($request); + + $this->assertSame($request, $hook->getRequest()); + $this->assertSame(123, $hook->transactionId); + $this->assertSame(10.5, $hook->amount); + $this->assertSame('Gateway', $hook->gatewayName); + } +} diff --git a/tests/LibraryTest.php b/tests/LibraryTest.php index 11435a4..ac86538 100644 --- a/tests/LibraryTest.php +++ b/tests/LibraryTest.php @@ -1,234 +1,509 @@ $expectedData + * @param array $responsePayload + * @param class-string $responseClass + * + * @throws JsonException + */ + public function testApiMethodsSendExpectedRequests( + string $apiMethod, + string $cloudMethod, + callable $requestFactory, + array $expectedData, + array $responsePayload, + string $responseClass + ): void { + $library = new RecordingLibrary('public_id', 'password'); + $library->setNextResponse($this->jsonResponse($responsePayload)); + + $request = $requestFactory(); + $result = $request === null ? $library->{$apiMethod}() : $library->{$apiMethod}($request); + + $this->assertInstanceOf($responseClass, $result); + $this->assertTrue($result->success); + $this->assertSame($cloudMethod, $library->lastMethod); + $this->assertEquals($expectedData, $library->lastPostData); + } - public function setUp(): void + public function testGettersReturnConstructorValues(): void { - parent::setUp(); + $library = new Library('public_id', 'password', 'https://example.com/'); - $this->library = $this->getMockBuilder(Library::class) - ->setConstructorArgs(['public_id', 'password']) - ->onlyMethods(['sendRequest']) - ->getMock(); + $this->assertSame('public_id', $library->getPublicId()); + $this->assertSame('password', $library->getPass()); + $this->assertSame('https://example.com/', $library->getUrl()); } - /** - * Проверяем заполнение модели по запросу - * createPaymentByToken2Step. - */ - public function testCreatePaymentByToken2Step(): void + public function testSendRequestUsesFormParams(): void { - $response = new Response(200, ['Content-type' => 'application/json'], json_encode([ - 'Success' => false, - 'Message' => null, - 'Model' => ['TransactionId' => 504], - ])); + $library = new HttpClientLibrary('public_id', 'password'); + $response = new Response(200); + $requestLog = new HttpRequestLog(); + $library->replaceClient($this->recordingClient($requestLog, $response)); + + $this->assertSame($response, $library->sendRequest('payments/get', ['TransactionId' => 10])); + $this->assertSame('POST', $requestLog->method); + $this->assertSame('/payments/get', $requestLog->path); + $this->assertSame(['TransactionId' => '10'], $requestLog->formParams); + $this->assertArrayNotHasKey('X-Request-ID', $requestLog->headers); + } - $library = clone $this->library; - $library - ->expects($this->once()) - ->method('sendRequest') - ->willReturn($response); + public function testSendRequestUsesCustomIdempotencyKey(): void + { + $library = new HttpClientLibrary('public_id', 'password'); + $library->setIdempotencyKey('request-key'); - $result = $library->createPaymentByToken2Step(new TokenPayment(100, 'RUB', '100', '100')); + $response = new Response(200); + $requestLog = new HttpRequestLog(); + $library->replaceClient($this->recordingClient($requestLog, $response)); - $this->assertEquals(false, $result->success); - $this->assertEquals(504, $result->model->transactionId); + $this->assertSame($response, $library->sendRequest('payments/confirm', ['TransactionId' => 10])); + $this->assertSame('/payments/confirm', $requestLog->path); + $this->assertSame(['TransactionId' => '10'], $requestLog->formParams); + $this->assertSame(['request-key'], $requestLog->headers['X-Request-ID']); } - /** - * Проверяем заполнение модели по запросу - * getPaymentData. - */ - public function testGetPaymentData(): void + public function testSendRequestGeneratesIdempotencyKey(): void { - $response = new Response(200, ['Content-type' => 'application/json'], json_encode([ - 'Success' => true, - 'Message' => null, - 'Model' => ['TransactionId' => 504, 'Amount' => 10.00000], - ])); - - $library = clone $this->library; - $library->expects($this->once())->method('sendRequest')->willReturn($response); + $library = new HttpClientLibrary('public_id', 'password'); + $library->setIdempotency(true); + + $postData = ['TransactionId' => 10, 'Amount' => 20]; + $response = new Response(200); + $requestLog = new HttpRequestLog(); + $library->replaceClient($this->recordingClient($requestLog, $response)); + + $this->assertSame($response, $library->sendRequest('payments/confirm', $postData)); + $this->assertSame(['TransactionId' => '10', 'Amount' => '20'], $requestLog->formParams); + $this->assertSame( + [$library->getRequestId('payments/confirm', $postData)], + $requestLog->headers['X-Request-ID'] + ); + } - $result = $library->getPaymentData(new PaymentsGet(1)); + public function testGetRequestIdIsDeterministic(): void + { + $library = new Library('public_id', 'password'); + $postData = ['Amount' => 10, 'JsonData' => ['key' => 'value']]; - $this->assertEquals(true, $result->success); - $this->assertEquals(504, $result->model->transactionId); - $this->assertEquals(10.0, $result->model->amount); + $this->assertSame( + md5('method:payments/refund;Amount:' . serialize(10) . 'JsonData:' . serialize(['key' => 'value'])), + $library->getRequestId('payments/refund', $postData) + ); } /** - * Проверяем заполнение модели по запросу - * paymentsCardsCharge. + * @return array, + * 4: array, + * 5: class-string + * }> */ - public function testPaymentsCardsCharge(): void + public static function apiMethodsProvider(): array { - $response = new Response(200, ['Content-type' => 'application/json'], json_encode([ - 'Success' => true, - 'Message' => null, - 'Model' => ['TransactionId' => 521], - ])); - - $library = clone $this->library; - $library->expects($this->once())->method('sendRequest')->willReturn($response); - - $result = $library->paymentsCardsCharge(new CardsPayment(1, 'RUB', '0.0.0.0', '123')); - - $this->assertEquals(true, $result->success); - $this->assertEquals(521, $result->model->transactionId); + return [ + 'getPaymentData' => [ + 'getPaymentData', + 'payments/get', + static fn (): PaymentsGet => new PaymentsGet(1), + ['TransactionId' => 1], + ['Success' => true, 'Model' => ['TransactionId' => 504, 'Amount' => 10.0]], + TransactionResponse::class, + ], + 'getPaymentDataByInvoice' => [ + 'getPaymentDataByInvoice', + 'payments/find', + static fn (): PaymentsFind => new PaymentsFind('invoice-1'), + ['InvoiceId' => 'invoice-1'], + self::transactionPayload(), + TransactionResponse::class, + ], + 'createPaymentByCard2Step' => [ + 'createPaymentByCard2Step', + 'payments/cards/auth', + static fn (): CardsPayment => new CardsPayment(10, 'RUB', '127.0.0.1', 'cryptogram'), + ['Amount' => 10, 'Currency' => 'RUB', 'IpAddress' => '127.0.0.1', 'CardCryptogramPacket' => 'cryptogram'], + self::transactionPayload(), + TransactionWith3dsResponse::class, + ], + 'createPaymentByToken2Step' => [ + 'createPaymentByToken2Step', + 'payments/tokens/auth', + static fn (): TokenPayment => new TokenPayment(100, 'RUB', 'account', 'token'), + [ + 'Amount' => 100, + 'Currency' => 'RUB', + 'AccountId' => 'account', + 'Token' => 'token', + 'PaymentScheduled' => 0, + 'TrInitiatorCode' => 1, + ], + self::transactionPayload(), + TransactionResponse::class, + ], + 'post3Ds' => [ + 'post3Ds', + 'payments/cards/post3ds', + static fn (): Post3DS => new Post3DS(10, 'pares'), + ['TransactionId' => 10, 'PaRes' => 'pares'], + self::transactionPayload(), + TransactionResponse::class, + ], + 'executePaymentByToken' => [ + 'executePaymentByToken', + 'payments/tokens/charge', + static fn (): TokenPayment => new TokenPayment(10, 'RUB', 'account', 'token'), + [ + 'Amount' => 10, + 'Currency' => 'RUB', + 'AccountId' => 'account', + 'Token' => 'token', + 'PaymentScheduled' => 0, + 'TrInitiatorCode' => 1, + ], + self::transactionPayload(), + TransactionResponse::class, + ], + 'confirmPayment' => [ + 'confirmPayment', + 'payments/confirm', + static fn (): PaymentsConfirm => new PaymentsConfirm(10, 20, '{"key":"value"}'), + ['Amount' => 10, 'JsonData' => '{"key":"value"}', 'TransactionId' => 20], + self::successPayload(), + CloudResponse::class, + ], + 'getListPayment' => [ + 'getListPayment', + 'payments/list', + static fn (): PaymentsList => new PaymentsList('2024-01-01', 'MSK'), + ['Date' => '2024-01-01', 'TimeZone' => 'MSK'], + ['Success' => true, 'Model' => [['TransactionId' => 1]]], + TransactionArrayResponse::class, + ], + 'cancelPayment' => [ + 'cancelPayment', + 'payments/void', + static fn (): PaymentsVoid => new PaymentsVoid(10), + ['TransactionId' => 10], + self::successPayload(), + CloudResponse::class, + ], + 'startSession' => [ + 'startSession', + 'applepay/startsession', + static fn (): ApplepayStartSession => new ApplepayStartSession('https://apple-pay-gateway.apple.com/paymentservices/startSession'), + ['ValidationUrl' => 'https://apple-pay-gateway.apple.com/paymentservices/startSession'], + ['Success' => true, 'Model' => ['nonce' => 'd6358e06']], + AppleSessionResponse::class, + ], + 'createReceipt' => [ + 'createReceipt', + 'kkt/receipt', + static fn (): KktReceipt => new KktReceipt('1234567890', 'Income', new CustomerReceipt([])), + ['Inn' => '1234567890', 'Type' => 'Income', 'CustomerReceipt' => ['Items' => []]], + ['Success' => true, 'Model' => ['Id' => 'receipt-id', 'ErrorCode' => 0]], + KktReceiptResponse::class, + ], + 'paymentsRefund' => [ + 'paymentsRefund', + 'payments/refund', + static fn (): PaymentsRefund => new PaymentsRefund(10, 20), + ['TransactionId' => 10, 'Amount' => 20.0], + self::transactionPayload(), + TransactionResponse::class, + ], + 'paymentsCardsCharge' => [ + 'paymentsCardsCharge', + 'payments/cards/charge', + static fn (): CardsPayment => new CardsPayment(10, 'RUB', '127.0.0.1', 'cryptogram'), + ['Amount' => 10, 'Currency' => 'RUB', 'IpAddress' => '127.0.0.1', 'CardCryptogramPacket' => 'cryptogram'], + self::transactionPayload(), + TransactionWith3dsResponse::class, + ], + 'paymentsCardsTopup' => [ + 'paymentsCardsTopup', + 'payments/cards/topup', + self::cardsTopUp(...), + [ + 'Name' => 'Card Holder', + 'CardCryptogramPacket' => 'cryptogram', + 'Amount' => 10, + 'AccountId' => 'account', + 'Currency' => 'RUB', + ], + self::transactionPayload(), + TransactionResponse::class, + ], + 'paymentsTokenTopup' => [ + 'paymentsTokenTopup', + 'payments/token/topup', + self::tokenTopUp(...), + ['Token' => 'token', 'Amount' => 10, 'AccountId' => 'account', 'Currency' => 'RUB'], + self::transactionPayload(), + TransactionResponse::class, + ], + 'paymentsTokensList' => [ + 'paymentsTokensList', + 'payments/tokens/list', + static fn (): ?TokenList => null, + [], + ['Success' => true, 'Model' => [['Token' => 'token']]], + TokenArrayResponse::class, + ], + 'subscriptionsCreate' => [ + 'subscriptionsCreate', + 'subscriptions/create', + static fn (): SubscriptionCreate => new SubscriptionCreate(), + [], + ['Success' => true, 'Model' => ['Id' => 'sub-id']], + SubscriptionResponse::class, + ], + 'subscriptionsGet' => [ + 'subscriptionsGet', + 'subscriptions/get', + self::subscriptionGet(...), + ['Id' => 'sub-id'], + ['Success' => true, 'Model' => ['Id' => 'sub-id']], + SubscriptionResponse::class, + ], + 'subscriptionsFind' => [ + 'subscriptionsFind', + 'subscriptions/find', + static fn (): SubscriptionFind => new SubscriptionFind(), + [], + ['Success' => true, 'Model' => [['Id' => 'sub-id']]], + SubscriptionArrayResponse::class, + ], + 'subscriptionsUpdate' => [ + 'subscriptionsUpdate', + 'subscriptions/update', + self::subscriptionUpdate(...), + ['Id' => 'sub-id', 'Description' => 'description'], + ['Success' => true, 'Model' => ['Id' => 'sub-id']], + SubscriptionResponse::class, + ], + 'subscriptionsCancel' => [ + 'subscriptionsCancel', + 'subscriptions/cancel', + self::subscriptionCancel(...), + ['Id' => 'sub-id'], + self::successPayload(), + CloudResponse::class, + ], + 'ordersCreate' => [ + 'ordersCreate', + 'orders/create', + static fn (): OrderCreate => new OrderCreate(1, 'RUB', 'description'), + ['Amount' => 1, 'Currency' => 'RUB', 'Description' => 'description'], + ['Success' => true, 'Model' => ['Id' => 'order-id']], + OrderResponse::class, + ], + 'ordersCancel' => [ + 'ordersCancel', + 'orders/cancel', + self::orderCancel(...), + ['Id' => 'order-id'], + self::successPayload(), + CloudResponse::class, + ], + 'siteNotificationsGet' => [ + 'siteNotificationsGet', + 'site/notifications/pay/get', + self::notificationsGet(...), + ['Type' => 'pay'], + ['Success' => true, 'Model' => ['Address' => 'https://example.com/hook']], + NotificationResponse::class, + ], + 'siteNotificationsUpdate' => [ + 'siteNotificationsUpdate', + 'site/notifications/pay/update', + self::notificationsUpdate(...), + ['Type' => 'pay', 'IsEnabled' => 'true', 'Address' => 'https://example.com/hook'], + self::successPayload(), + CloudResponse::class, + ], + ]; } /** - * Проверяем заполнение модели по запросу - * paymentsTokensList. + * @param array $payload + * + * @throws JsonException */ - public function testPaymentsTokensList(): void + private function jsonResponse(array $payload): Response { - $response = new Response(200, ['Content-type' => 'application/json'], json_encode([ - 'Success' => true, - 'Message' => null, - 'Model' => [['Token' => '123asdf']], - ])); - - $library = clone $this->library; - $library->expects($this->once())->method('sendRequest')->willReturn($response); - - $result = $library->paymentsTokensList(); - - $model = $result->model[0]; + return new Response(200, ['Content-type' => 'application/json'], json_encode($payload, JSON_THROW_ON_ERROR)); + } - $this->assertEquals(true, $result->success); - $this->assertEquals('123asdf', $model->token); + private function recordingClient(HttpRequestLog $requestLog, Response $response): Client + { + return new Client([ + 'base_uri' => 'https://api.cloudpayments.ru/', + 'handler' => static function (RequestInterface $request) use ($requestLog, $response) { + $requestLog->method = $request->getMethod(); + $requestLog->path = $request->getUri()->getPath(); + $requestLog->headers = $request->getHeaders(); + + /** @var array $formParams */ + $formParams = []; + parse_str((string) $request->getBody(), $formParams); + $requestLog->formParams = $formParams; + + return Create::promiseFor($response); + }, + ]); } /** - * Проверяем заполнение модели по запросу - * subscriptionsCreate. + * @return array */ - public function testSubscriptionsCreate(): void + private static function transactionPayload(): array { - $response = new Response(200, ['Content-type' => 'application/json'], json_encode([ - 'Success' => true, - 'Message' => null, - 'Model' => ['Id' => 'sc_8cf8a9338fb8ebf7202b08d09c938'], - ])); - - $library = clone $this->library; - $library->expects($this->once())->method('sendRequest')->willReturn($response); - - $result = $library->subscriptionsCreate(new SubscriptionCreate()); - - $this->assertEquals(true, $result->success); - $this->assertEquals('sc_8cf8a9338fb8ebf7202b08d09c938', $result->model->id); + return ['Success' => true, 'Model' => ['TransactionId' => 1]]; } /** - * Проверяем заполнение модели по запросу - * subscriptionsFind. + * @return array */ - public function testSubscriptionsFind(): void + private static function successPayload(): array { - $response = new Response(200, ['Content-type' => 'application/json'], json_encode([ - 'Success' => true, - 'Message' => null, - 'Model' => [['Id' => 'sc_b4bdedba0e2bdf279be2e0bab9c99']], - ])); - - $library = clone $this->library; - $library->expects($this->once())->method('sendRequest')->willReturn($response); + return ['Success' => true, 'Message' => 'ok']; + } - $result = $library->subscriptionsFind(new SubscriptionFind()); + private static function cardsTopUp(): CardsTopUp + { + $request = new CardsTopUp(); + $request->name = 'Card Holder'; + $request->cardCryptogramPacket = 'cryptogram'; + $request->amount = 10; + $request->accountId = 'account'; + $request->currency = 'RUB'; + + return $request; + } - $model = $result->model[0]; + private static function tokenTopUp(): TokenTopUp + { + $request = new TokenTopUp(); + $request->token = 'token'; + $request->amount = 10; + $request->accountId = 'account'; + $request->currency = 'RUB'; - $this->assertEquals(true, $result->success); - $this->assertEquals('sc_b4bdedba0e2bdf279be2e0bab9c99', $model->id); + return $request; } - /** - * Проверяем заполнение модели по запросу - * ordersCreate. - */ - public function testOrdersCreate(): void + private static function subscriptionGet(): SubscriptionGet { - $response = new Response(200, ['Content-type' => 'application/json'], json_encode([ - 'Success' => true, - 'Message' => null, - 'Model' => ['Id' => 'f2K8LV6reGE9WBFn'], - ])); + $request = new SubscriptionGet(); + $request->id = 'sub-id'; - $library = clone $this->library; - $library->expects($this->once())->method('sendRequest')->willReturn($response); + return $request; + } - $result = $library->ordersCreate(new OrderCreate(1, 'RUB', 'asdf')); + private static function subscriptionUpdate(): SubscriptionUpdate + { + $request = new SubscriptionUpdate(); + $request->id = 'sub-id'; + $request->description = 'description'; - $this->assertEquals(true, $result->success); - $this->assertEquals('f2K8LV6reGE9WBFn', $result->model->id); + return $request; } - /** - * Проверяем заполнение модели по запросу - * siteNotificationsGet. - */ - public function testSiteNotificationsGet(): void + private static function subscriptionCancel(): SubscriptionCancel { - $response = new Response(200, ['Content-type' => 'application/json'], json_encode([ - 'Success' => true, - 'Message' => null, - 'Model' => ['Address' => 'http://example.com'], - ])); + $request = new SubscriptionCancel(); + $request->id = 'sub-id'; - $library = clone $this->library; - $library->expects($this->once())->method('sendRequest')->willReturn($response); + return $request; + } - $notificationModel = new NotificationsGet(); - $notificationModel->type = 'type'; - $result = $library->siteNotificationsGet($notificationModel); + private static function orderCancel(): OrderCancel + { + $request = new OrderCancel(); + $request->id = 'order-id'; - $this->assertEquals(true, $result->success); - $this->assertEquals('http://example.com', $result->model->address); + return $request; } - /** - * Проверяем заполнение модели по запросу - * startSession. - */ - public function testStartSession(): void + private static function notificationsGet(): NotificationsGet { - $response = new Response(200, ['Content-type' => 'application/json'], json_encode([ - 'Success' => true, - 'Message' => null, - 'Model' => ['nonce' => 'd6358e06'], - ])); + $request = new NotificationsGet(); + $request->type = 'pay'; - $library = clone $this->library; - $library->expects($this->once())->method('sendRequest')->willReturn($response); + return $request; + } - $result = $library->startSession(new ApplepayStartSession('https://apple-pay-gateway.apple.com/paymentservices/startSession')); + private static function notificationsUpdate(): NotificationsUpdate + { + $request = new NotificationsUpdate(); + $request->type = 'pay'; + $request->isEnabled = true; + $request->address = 'https://example.com/hook'; - $this->assertEquals(true, $result->success); - $this->assertEquals('d6358e06', $result->model->nonce); + return $request; } } diff --git a/tests/Request/ApplepayStartSessionTest.php b/tests/Request/ApplepayStartSessionTest.php index 63acd7b..937961c 100644 --- a/tests/Request/ApplepayStartSessionTest.php +++ b/tests/Request/ApplepayStartSessionTest.php @@ -1,22 +1,16 @@ assertEquals($validationUrl, $appleStartSessionRequest->validationUrl); } - /** - * Проверяем валидацию для validationUrl - ожидаем ошибку. - */ - public function testCheckValidationUrlFailed(): void + public function testConstructorRejectsInvalidValidationUrl(): void { $validationUrl = 'asdf'; $this->expectException(BadTypeException::class); - /* @phan-suppress-next-line PhanNoopNew */ new ApplepayStartSession($validationUrl); } - /** - * Проверяем валидацию для paymentUrl - успешный вариант - */ - public function testFillPaymentUrlSuccess(): void + public function testConstructorAcceptsValidPaymentUrl(): void { $validationUrl = 'https://apple-pay-gateway.apple.com/paymentservices/startSession'; $paymentUrl = 'https://apple-pay-gateway.apple.com/paymentservices/startSession'; @@ -50,11 +37,7 @@ public function testFillPaymentUrlSuccess(): void $this->assertEquals($paymentUrl, $appleStartSessionRequest->paymentUrl); } - /** - * Проверяем, что поле paymentUrl не заполняется, - * если ничего не передано. - */ - public function testFillPaymentUrlNotFilledSuccess(): void + public function testConstructorLeavesPaymentUrlUnsetWhenItIsNotPassed(): void { $validationUrl = 'https://apple-pay-gateway.apple.com/paymentservices/startSession'; @@ -64,16 +47,12 @@ public function testFillPaymentUrlNotFilledSuccess(): void $this->assertFalse(isset($vars['paymentUrl'])); } - /** - * Проверяем валидацию для paymentUrl - ожидаем ошибку. - */ - public function testFillPaymentUrlFailed(): void + public function testConstructorRejectsInvalidPaymentUrl(): void { $validationUrl = 'https://apple-pay-gateway.apple.com/paymentservices/startSession'; $paymentUrl = 'asdf'; $this->expectException(BadTypeException::class); - /* @phan-suppress-next-line PhanNoopNew */ new ApplepayStartSession($validationUrl, $paymentUrl); } } diff --git a/tests/Request/KktReceiptTest.php b/tests/Request/KktReceiptTest.php index e89a0b4..0dd0564 100644 --- a/tests/Request/KktReceiptTest.php +++ b/tests/Request/KktReceiptTest.php @@ -1,5 +1,7 @@ type = 'fail'; + $request->isEnabled = false; + + $this->assertSame([ + 'Type' => 'fail', + 'IsEnabled' => 'false', + ], $request->asArray()); + } +} diff --git a/tests/Request/OrderCreateTest.php b/tests/Request/OrderCreateTest.php index 34a20a5..2b6d72e 100644 --- a/tests/Request/OrderCreateTest.php +++ b/tests/Request/OrderCreateTest.php @@ -1,22 +1,16 @@ assertEquals($description, $orderCreateRequest->description); } - /** - * Проверяем валидацию для amount - успешный вариант - */ - public function testCheckValidationAmountSuccessFloat(): void + public function testConstructorAcceptsFloatAmount(): void { $amount = 1.123; $currency = 'RUB'; @@ -43,17 +34,21 @@ public function testCheckValidationAmountSuccessFloat(): void $this->assertEquals($description, $orderCreateRequest->description); } - /** - * Проверяем валидацию для amount - ожидаем ошибку. - */ - public function testCheckValidationAmountFailed(): void + public function testConstructorRejectsNonNumericAmount(): void { $amount = 'asdf'; $currency = 'RUB'; $description = 'asdf'; $this->expectException(BadTypeException::class); - /* @phan-suppress-next-line PhanNoopNew */ new OrderCreate($amount, $currency, $description); } + + public function testAsArrayCastsRequireConfirmationToCloudpaymentsBoolean(): void + { + $order = new OrderCreate(10, 'RUB', 'description'); + $order->requireConfirmation = true; + + $this->assertSame('true', $order->asArray()['RequireConfirmation']); + } } diff --git a/tests/Request/PaymentsConfirmTest.php b/tests/Request/PaymentsConfirmTest.php new file mode 100644 index 0000000..738f007 --- /dev/null +++ b/tests/Request/PaymentsConfirmTest.php @@ -0,0 +1,33 @@ +assertSame([ + 'Amount' => '10.50', + 'JsonData' => '{"key":"value"}', + 'TransactionId' => 123, + ], $request->asArray()); + } + + public function testConstructorRejectsNonNumericAmount(): void + { + $this->expectException(BadTypeException::class); + + new PaymentsConfirm('wrong', 123); + } +} diff --git a/tests/Request/PaymentsFindTest.php b/tests/Request/PaymentsFindTest.php new file mode 100644 index 0000000..aa54e82 --- /dev/null +++ b/tests/Request/PaymentsFindTest.php @@ -0,0 +1,19 @@ +assertSame(['InvoiceId' => 'invoice'], (new PaymentsFind('invoice'))->asArray()); + } +} diff --git a/tests/Request/PaymentsListTest.php b/tests/Request/PaymentsListTest.php new file mode 100644 index 0000000..130c8da --- /dev/null +++ b/tests/Request/PaymentsListTest.php @@ -0,0 +1,19 @@ +assertSame(['Date' => '2024-01-01'], (new PaymentsList('2024-01-01'))->asArray()); + } +} diff --git a/tests/Request/PaymentsRefundTest.php b/tests/Request/PaymentsRefundTest.php index 2ab811b..13d5ce9 100644 --- a/tests/Request/PaymentsRefundTest.php +++ b/tests/Request/PaymentsRefundTest.php @@ -1,22 +1,16 @@ assertEquals($transactionId, $paymentsRefundRequest->transactionId); } - /** - * Проверяем валидацию для amount - успешный вариант - */ - public function testCheckValidationAmountSuccessFloat(): void + public function testConstructorAcceptsFloatAmount(): void { $amount = 1.123; $transactionId = 1; @@ -39,16 +30,12 @@ public function testCheckValidationAmountSuccessFloat(): void $this->assertEquals($transactionId, $paymentsRefundRequest->transactionId); } - /** - * Проверяем валидацию для amount - ожидаем ошибку. - */ - public function testCheckValidationAmountFailed(): void + public function testConstructorRejectsNonNumericAmount(): void { $amount = 'asdf'; $transactionId = 1; $this->expectException(BadTypeException::class); - /* @phan-suppress-next-line PhanNoopNew */ new PaymentsRefund($transactionId, $amount); } } diff --git a/tests/Request/PaymentsVoidTest.php b/tests/Request/PaymentsVoidTest.php new file mode 100644 index 0000000..307beb8 --- /dev/null +++ b/tests/Request/PaymentsVoidTest.php @@ -0,0 +1,19 @@ +assertSame(['TransactionId' => 123], (new PaymentsVoid(123))->asArray()); + } +} diff --git a/tests/Request/Post3DSTest.php b/tests/Request/Post3DSTest.php new file mode 100644 index 0000000..f47cc4c --- /dev/null +++ b/tests/Request/Post3DSTest.php @@ -0,0 +1,19 @@ +assertSame(['TransactionId' => 123, 'PaRes' => 'pares'], (new Post3DS(123, 'pares'))->asArray()); + } +} diff --git a/tests/Response/CloudResponseTest.php b/tests/Response/CloudResponseTest.php new file mode 100644 index 0000000..31a0c1b --- /dev/null +++ b/tests/Response/CloudResponseTest.php @@ -0,0 +1,43 @@ +fillByResponse(new Response(200, [], '[]')); + + $this->assertFalse($response->success); + $this->assertSame('Message is not set', $response->message); + $this->assertSame('Warning is not set', $response->warning); + } + + public function testFillModelKeepsScalar(): void + { + $response = new CloudResponse(); + $response->fillModel('plain-model'); + + $this->assertSame('plain-model', $response->model); + } + + public function testFillModelConvertsObjectToBaseModel(): void + { + $response = new CloudResponse(); + $response->fillModel((object) ['UnknownField' => 'value']); + + $this->assertInstanceOf(BaseModel::class, $response->model); + $this->assertSame('value', $response->model->getAdditionalProperties()['unknownField']); + } +} diff --git a/tests/Response/KktReceiptResponseTest.php b/tests/Response/KktReceiptResponseTest.php new file mode 100644 index 0000000..84ce43b --- /dev/null +++ b/tests/Response/KktReceiptResponseTest.php @@ -0,0 +1,23 @@ +fillModel((object) ['Id' => 'receipt-id', 'ErrorCode' => 0]); + + $this->assertSame('receipt-id', $response->model->id); + $this->assertSame(0, $response->model->errorCode); + } +} diff --git a/tests/Response/Models/BaseModelTest.php b/tests/Response/Models/BaseModelTest.php index bdcad84..76396ae 100644 --- a/tests/Response/Models/BaseModelTest.php +++ b/tests/Response/Models/BaseModelTest.php @@ -1,20 +1,14 @@ 1, 'b' => 2, 'c' => 3]; @@ -26,9 +20,6 @@ public function testFillBaseModel(): void $this->assertEquals($testObject->c, $model->c); } - /** - * Проверяем, что неизвестные поля не становятся динамическими свойствами. - */ public function testFillKeepsUnknownFieldsWithoutDynamicProperties(): void { $model = new TestModel(); diff --git a/tests/Response/Models/TestModel.php b/tests/Response/Models/TestModel.php index b25e664..788e638 100644 --- a/tests/Response/Models/TestModel.php +++ b/tests/Response/Models/TestModel.php @@ -6,7 +6,7 @@ use Excent\Cloudpayments\Response\Models\BaseModel; -class TestModel extends BaseModel +final class TestModel extends BaseModel { public int $a; public int $b; diff --git a/tests/Response/Models/TransactionModelTest.php b/tests/Response/Models/TransactionModelTest.php index e794dde..63f507c 100644 --- a/tests/Response/Models/TransactionModelTest.php +++ b/tests/Response/Models/TransactionModelTest.php @@ -1,21 +1,15 @@ '1234567890']; @@ -46,4 +40,12 @@ public function testFillAdditionalTransactionFields(): void $this->assertSame($splits, $transaction->splits); $this->assertTrue($transaction->transactionIsInProcess); } + + public function testGetClientErrorCode(): void + { + $model = new TransactionModel(); + $model->reasonCode = 5005; + + $this->assertSame('cloudpayments_error_5005', $model->getClientErrorCode()); + } } diff --git a/tests/Response/TransactionArrayResponseTest.php b/tests/Response/TransactionArrayResponseTest.php new file mode 100644 index 0000000..271e00e --- /dev/null +++ b/tests/Response/TransactionArrayResponseTest.php @@ -0,0 +1,27 @@ +fillModel([ + (object) ['TransactionId' => 123], + (object) ['TransactionId' => 456], + ]); + + $this->assertCount(2, $response->model); + $this->assertSame(123, $response->model[0]->transactionId); + $this->assertSame(456, $response->model[1]->transactionId); + } +} diff --git a/tests/Response/TransactionWith3dsModelTest.php b/tests/Response/TransactionWith3dsModelTest.php index a121a0e..efd5af4 100644 --- a/tests/Response/TransactionWith3dsModelTest.php +++ b/tests/Response/TransactionWith3dsModelTest.php @@ -1,40 +1,31 @@ 'some', 'AcsUrl' => 'some']; - $cloudReponseModel = new TransactionWith3dsResponse(); - $cloudReponseModel->fillModel($responseModel); + $cloudResponseModel = new TransactionWith3dsResponse(); + $cloudResponseModel->fillModel($responseModel); - $this->assertTrue($cloudReponseModel->is3dsError()); + $this->assertTrue($cloudResponseModel->is3dsError()); } - /** - * Проверка на наличие 3ds - проверка не нужна. - */ - public function testIs3dsErrorFalse(): void + public function testIs3dsErrorReturnsFalseWhenPaReqAndAcsUrlAreMissing(): void { $responseModel = (object) []; - $cloudReponseModel = new TransactionWith3dsResponse(); - $cloudReponseModel->fillModel($responseModel); + $cloudResponseModel = new TransactionWith3dsResponse(); + $cloudResponseModel->fillModel($responseModel); - $this->assertFalse($cloudReponseModel->is3dsError()); + $this->assertFalse($cloudResponseModel->is3dsError()); } } diff --git a/tests/Support/HttpClientLibrary.php b/tests/Support/HttpClientLibrary.php new file mode 100644 index 0000000..e65de3b --- /dev/null +++ b/tests/Support/HttpClientLibrary.php @@ -0,0 +1,16 @@ +client = $client; + } +} diff --git a/tests/Support/HttpRequestLog.php b/tests/Support/HttpRequestLog.php new file mode 100644 index 0000000..d07026f --- /dev/null +++ b/tests/Support/HttpRequestLog.php @@ -0,0 +1,18 @@ + */ + public array $headers = []; + + /** @var array */ + public array $formParams = []; +} diff --git a/tests/Support/RecordingLibrary.php b/tests/Support/RecordingLibrary.php new file mode 100644 index 0000000..8873887 --- /dev/null +++ b/tests/Support/RecordingLibrary.php @@ -0,0 +1,34 @@ +|null */ + public ?array $lastPostData = null; + + private ResponseInterface $nextResponse; + + public function setNextResponse(ResponseInterface $nextResponse): void + { + $this->nextResponse = $nextResponse; + } + + /** + * @param array $postData + */ + public function sendRequest(string $method, array $postData = []): ResponseInterface + { + $this->lastMethod = $method; + $this->lastPostData = $postData; + + return $this->nextResponse; + } +}