diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8631f48 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,83 @@ +# Changelog + +This file documents notable changes to the library. + +## [3.3.0] - 2026-07-23 + +### Added + +- Support for the CloudPayments `test` endpoint. +- Creation of payment links and retrieval of QR codes for SBP payments: + `payments/qr/sbp/link` and `payments/qr/sbp/image`. +- Retrieval of the list of banks participating in SBP via `sbp/v2/banks/info`. +- Lookup of the latest operation by invoice ID via `v2/payments/find`. +- Retrieval of payment operations for an arbitrary date range via + `v2/payments/list`. +- Retrieval of chargebacks via `chargebacks/list`. +- Typed request and response DTOs and enums for the new endpoints. +- The `CloudpaymentsData` DTO for building structured `JsonData`. +- Additional fiscal receipt requisites and VAT rates `5`, `7`, `22`, `5/105`, + `7/107`, and `22/122`. +- The `errorCode` field in `CloudResponse`. + +### Changed + +- Stricter type validation for the main CloudPayments response fields. +- `TransactionArrayResponse::model` now defaults to an empty array. + +### Backward compatibility + +- Existing API methods and their signatures remain unchanged. +- Support for the new endpoints does not change the behavior of existing + integrations. + +## [3.2.4] - 2026-06-09 + +### Added + +- PHPUnit, PHPStan, PHP CS Fixer, and Rector checks in CI. +- Tests for the API client, request and response DTOs, models, and webhook + handlers. +- Expanded documentation covering installation, configuration, and usage. + +### Changed + +- Improved static typing without changing the public API. +- Updated the code to comply with PHPStan and Rector rules. + +## [3.2.3] - 2026-06-08 + +### Added + +- A CI test matrix for PHP `8.1-8.5`. +- Missing transaction model fields. +- `BaseModel::getAdditionalProperties()` for accessing unknown fields returned + by CloudPayments. + +### Fixed + +- Unknown response fields no longer create dynamic model properties. + +## [3.2.2] - 2025-06-10 + +### Added + +- Composer Normalizer and Composer Bin Plugin. +- Isolated environments for PHPStan, PHP CS Fixer, and Rector. +- Makefile commands for dependency installation, testing, and code quality + checks. + +### Changed + +- Development tool caches moved to the `tmp` directory. + +## [3.2.1] - 2025-06-10 + +### Added + +- Initial release of the CloudPayments PHP client. +- Support for card and token payments, refunds, confirmations, and voids. +- Support for subscriptions, orders, notifications, fiscal receipts, and + Apple Pay. +- Request and response DTOs, response models, enums, and webhook handlers. +- Basic PHPUnit tests and code quality tools. diff --git a/README.md b/README.md index 2bb795e..4469e3a 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,13 @@ if ($response->is3dsError()) { | `payments/token/topup` | `paymentsTokenTopup` | `TokenTopUp` | `TransactionResponse` | | `payments/get` | `getPaymentData` | `PaymentsGet` | `TransactionResponse` | | `payments/find` | `getPaymentDataByInvoice` | `PaymentsFind` | `TransactionResponse` | +| `v2/payments/find` | `getPaymentDataByInvoiceV2` | `PaymentsFind` | `TransactionResponse` | | `payments/list` | `getListPayment` | `PaymentsList` | `TransactionArrayResponse` | +| `v2/payments/list` | `getListPaymentV2` | `PaymentsListV2` | `TransactionArrayResponse` | +| `chargebacks/list` | `chargebacksList` | `ChargebacksList` | `ChargebackArrayResponse` | +| `payments/qr/sbp/link` | `paymentsQrSbpLink` | `SbpLink` | `QrLinkResponse` | +| `payments/qr/sbp/image` | `paymentsQrSbpImage` | `SbpLink` | `QrLinkResponse` | +| `sbp/v2/banks/info` | `sbpV2BanksInfo` | `SbpBanksInfo` или `null` | `SbpBanksInfoResponse` | | `payments/tokens/list` | `paymentsTokensList` | `TokenList` или `null` | `TokenArrayResponse` | | `subscriptions/create` | `subscriptionsCreate` | `SubscriptionCreate` | `SubscriptionResponse` | | `subscriptions/get` | `subscriptionsGet` | `SubscriptionGet` | `SubscriptionResponse` | @@ -125,6 +131,13 @@ if ($response->is3dsError()) { | `site/notifications/{Type}/update` | `siteNotificationsUpdate` | `NotificationsUpdate` | `CloudResponse` | | `applepay/startsession` | `startSession` | `ApplepayStartSession` | `AppleSessionResponse` | | `kkt/receipt` | `createReceipt` | `KktReceipt` | `KktReceiptResponse` | +| `test` | `test` | — | `CloudResponse` | + +`payments/find` сохраняется для обратной совместимости. `v2/payments/find` ищет последнюю операцию среди платежей, возвратов и выплат на карту. + +`payments/list` выгружает операции за один день. `v2/payments/list` выгружает операции за произвольный период, использует пагинацию и необязательный фильтр статусов. `pageNumber` начинается с 1, одна страница содержит не более 100 операций. Порядок ответа сохраняется библиотекой. + +`chargebacks/list` выгружает претензии за период не больше одного календарного года. `pageNumber` начинается с 1, одна страница содержит не более 100 претензий. Библиотека сохраняет порядок ответа CloudPayments. Поле `ErrorCode` доступно через `$response->errorCode`. ## Запросы @@ -144,9 +157,38 @@ DTO наследуются от `BaseRequest` и преобразуются в - `amount` превращается в `Amount`; - значения `null` не попадают в запрос; -- `true` и `false` передаются как строковые значения, ожидаемые API; +- `true` и `false` в полях Request DTO передаются как строковые значения, ожидаемые API; - вложенные DTO и массивы DTO преобразуются рекурсивно. +Для структурированных данных `JsonData` можно использовать общий DTO `CloudpaymentsData`. Он добавляет обязательную для CloudPayments обёртку `cloudpayments` и переиспользует `CustomerReceipt` во всех платежных методах, поддерживающих `JsonData`: + +```php +use Excent\Cloudpayments\Enum\Currency; +use Excent\Cloudpayments\Enum\SbpScheme; +use Excent\Cloudpayments\Request\SbpLink; +use Excent\Cloudpayments\Request\CloudpaymentsData; +use Excent\Cloudpayments\Request\Receipt\CustomerReceipt; +use Excent\Cloudpayments\Request\Receipt\ReceiptItem; + +$jsonData = new CloudpaymentsData( + customerReceipt: new CustomerReceipt([ + new ReceiptItem('Товар', '100.00', '1.00', '100.00'), + ]), + additionalData: ['name' => 'Покупатель'], +); + +$request = new SbpLink( + '1000.00', + Currency::RUB, + SbpScheme::CHARGE, + jsonData: $jsonData, +); +``` + +Для `SbpLink` используются типизированные enum-поля `Currency::RUB`, `SbpScheme::CHARGE` и `Device`. Поля `Os` и `Browser` остаются строковыми, поскольку API допускает новые значения. Поле `jsonData` принимает только `CloudpaymentsData`; для существующих DTO со строковым `JsonData` используется `$jsonData->asJson()`. + +`CustomerReceipt` является общим DTO для чеков и не привязан к СБП. Вложенные реквизиты представлены DTO `UserRequisiteData`, `OperationReceiptRequisite`, `IndustryRequisiteCollection[]` и `NonCashPayments[]`; `RussiaTimeZone` принимает RTZ enum-коды `1–11`. + Некоторые DTO для совместимости с существующим публичным контрактом заполняются через публичные свойства: ```php @@ -169,6 +211,7 @@ $client->siteNotificationsUpdate($request); | `success` | Результат операции из поля `Success`. | | `message` | Сообщение из поля `Message`. | | `warning` | Предупреждение из поля `Warning`. | +| `errorCode` | Код ошибки из поля `ErrorCode`. | | `model` | Модель ответа, тип зависит от вызванного метода. | Поддерживаемые модели: @@ -176,9 +219,12 @@ $client->siteNotificationsUpdate($request); | Response DTO | Model | |--------------|-------| | `AppleSessionResponse` | `AppleSessionModel` | +| `ChargebackArrayResponse` | `ChargebackModel[]` | | `KktReceiptResponse` | `KktReceiptModel` | | `NotificationResponse` | `NotificationModel` | | `OrderResponse` | `OrderModel` | +| `QrLinkResponse` | `QrLinkModel` | +| `SbpBanksInfoResponse` | `SbpBanksInfoModel[]` | | `SubscriptionResponse` | `SubscriptionModel` | | `SubscriptionArrayResponse` | `SubscriptionModel[]` | | `TokenArrayResponse` | `TokenModel[]` | @@ -186,6 +232,10 @@ $client->siteNotificationsUpdate($request); | `TransactionArrayResponse` | `TransactionModel[]` | | `TransactionWith3dsResponse` | `TransactionWith3dsModel` | +`SbpBanksInfoResponse` содержит массив источников (`SbpBanksInfoModel`), а `members` каждого источника — типизированный массив банков (`SbpBankMemberModel`). Библиотека сохраняет исходный порядок источников и банков и не изменяет значения `name`, `logo` и `url`. + +Для `paymentsQrSbpImage` значение `model->qrImage` содержит PNG-код, закодированный в Base64, и возвращается библиотекой без декодирования. Значение `model->qrUrl` для этого метода равно `null`. Один QR-код СБП можно использовать для многократной оплаты. + Если CloudPayments вернет поля, которых нет в модели, они будут доступны через `getAdditionalProperties()`. ```php diff --git a/src/Enum/CloudMethodsEnum.php b/src/Enum/CloudMethodsEnum.php index 66512fb..f0d9fb5 100644 --- a/src/Enum/CloudMethodsEnum.php +++ b/src/Enum/CloudMethodsEnum.php @@ -14,6 +14,9 @@ enum CloudMethodsEnum: string /** Проверка платежа по номеру заказа */ case PAYMENTS_FIND = 'payments/find'; + /** Проверка последней операции по номеру заказа, включая возвраты и выплаты на карту */ + case V2_PAYMENTS_FIND = 'v2/payments/find'; + /** Проверка платежа */ case PAYMENTS_GET = 'payments/get'; @@ -35,6 +38,21 @@ enum CloudMethodsEnum: string /** Список транзакций за определенное время */ case PAYMENTS_LIST = 'payments/list'; + /** Список транзакций за произвольный период */ + case V2_PAYMENTS_LIST = 'v2/payments/list'; + + /** Список претензий за произвольный период */ + case CHARGEBACKS_LIST = 'chargebacks/list'; + + /** Создание ссылки на оплату через СБП */ + case PAYMENTS_QR_SBP_LINK = 'payments/qr/sbp/link'; + + /** Получение QR-кода для оплаты через СБП */ + case PAYMENTS_QR_SBP_IMAGE = 'payments/qr/sbp/image'; + + /** Список участников СБП */ + case SBP_V2_BANKS_INFO = 'sbp/v2/banks/info'; + /** Отмена оплаты */ case PAYMENTS_VOID = 'payments/void'; @@ -79,4 +97,7 @@ enum CloudMethodsEnum: string /** Создание чека */ case KKT_RECEIPT = 'kkt/receipt'; + + /** Тестовый метод */ + case TEST = 'test'; } diff --git a/src/Enum/Currency.php b/src/Enum/Currency.php new file mode 100644 index 0000000..397044e --- /dev/null +++ b/src/Enum/Currency.php @@ -0,0 +1,40 @@ +request($method, $data->asArray(), new TransactionResponse()); } + /** + * Проверка через v2 последней операции по номеру заказа, включая возвраты и выплаты на карту. + */ + public function getPaymentDataByInvoiceV2(PaymentsFind $data): TransactionResponse + { + $method = CloudMethodsEnum::V2_PAYMENTS_FIND; + + return $this->request($method, $data->asArray(), new TransactionResponse()); + } + /** * Метод получения детализации по транзакции. */ @@ -181,6 +198,62 @@ public function getListPayment(PaymentsList $data): TransactionArrayResponse return $this->request($method, $data->asArray(), new TransactionArrayResponse()); } + /** + * Список транзакций за произвольный период. + */ + public function getListPaymentV2(PaymentsListV2 $data): TransactionArrayResponse + { + $method = CloudMethodsEnum::V2_PAYMENTS_LIST; + + return $this->request($method, $data->asArray(), new TransactionArrayResponse()); + } + + /** + * Список претензий за произвольный период. + */ + public function chargebacksList(ChargebacksList $data): ChargebackArrayResponse + { + $method = CloudMethodsEnum::CHARGEBACKS_LIST; + + return $this->request($method, $data->asArray(), new ChargebackArrayResponse()); + } + + /** + * Создание ссылки на оплату через СБП. + */ + public function paymentsQrSbpLink(SbpLink $data): QrLinkResponse + { + $method = CloudMethodsEnum::PAYMENTS_QR_SBP_LINK; + $postData = $data->asArray(); + $postData['PublicId'] = $this->publicId; + + return $this->request($method, $postData, new QrLinkResponse()); + } + + /** + * Получение QR-кода для оплаты через СБП. + */ + public function paymentsQrSbpImage(SbpLink $data): QrLinkResponse + { + $method = CloudMethodsEnum::PAYMENTS_QR_SBP_IMAGE; + $postData = $data->asArray(); + $postData['PublicId'] = $this->publicId; + + return $this->request($method, $postData, new QrLinkResponse()); + } + + /** + * Получение списка банков — участников СБП. + */ + public function sbpV2BanksInfo(?SbpBanksInfo $data = null): SbpBanksInfoResponse + { + $method = CloudMethodsEnum::SBP_V2_BANKS_INFO; + $postData = $data === null ? [] : $data->asArray(); + $postData['PublicTerminalId'] = $this->publicId; + + return $this->request($method, $postData, new SbpBanksInfoResponse()); + } + /** * Отмена оплаты. */ @@ -352,6 +425,16 @@ public function siteNotificationsUpdate(NotificationsUpdate $data): CloudRespons return $this->request($method, $data->asArray(), new CloudResponse()); } + /** + * Тестовый метод CloudPayments. + */ + public function test(): CloudResponse + { + $method = CloudMethodsEnum::TEST; + + return $this->request($method, [], new CloudResponse()); + } + /** * Базовый запрос * diff --git a/src/Request/ChargebacksList.php b/src/Request/ChargebacksList.php new file mode 100644 index 0000000..60a1bcd --- /dev/null +++ b/src/Request/ChargebacksList.php @@ -0,0 +1,49 @@ + $createdDateGte->modify('+1 year')) { + throw new BadTypeException('Chargebacks list period must not exceed one calendar year'); + } + } + + /** + * @return array{ + * CreatedDateGte: string, + * CreatedDateLte: string, + * PageNumber: int + * } + */ + public function asArray(): array + { + return [ + 'CreatedDateGte' => $this->createdDateGte->format(DateTimeInterface::ATOM), + 'CreatedDateLte' => $this->createdDateLte->format(DateTimeInterface::ATOM), + 'PageNumber' => $this->pageNumber, + ]; + } +} diff --git a/src/Request/CloudpaymentsData.php b/src/Request/CloudpaymentsData.php new file mode 100644 index 0000000..47e82fd --- /dev/null +++ b/src/Request/CloudpaymentsData.php @@ -0,0 +1,102 @@ + $additionalData + */ + public function __construct( + public ?CustomerReceipt $customerReceipt = null, + public array $additionalData = [], + ) { + } + + /** + * @return array + */ + public function asArray(): array + { + $data = $this->serializeValue($this->additionalData); + + if ($this->customerReceipt !== null) { + $data['CustomerReceipt'] = $this->serializeValue($this->customerReceipt); + } + + return ['cloudpayments' => $data]; + } + + /** + * @throws JsonException + */ + public function asJson(): string + { + return json_encode($this->asArray(), JSON_THROW_ON_ERROR); + } + + private function serializeValue(mixed $value): mixed + { + if ($value instanceof ReceiptItem) { + $data = $this->serializeRequest($value); + + if ($value->vat !== null && preg_match('/^(0|[1-9]\d*)$/D', $value->vat) === 1) { + $vat = Vat::tryFrom((int) $value->vat); + + if ($vat !== null) { + $data['Vat'] = $vat->value; + } + } + + return $data; + } + + if ($value instanceof BackedEnum) { + return $value->value; + } + + if ($value instanceof BaseRequest) { + return $this->serializeRequest($value); + } + + if (is_array($value)) { + foreach ($value as $key => $item) { + $value[$key] = $this->serializeValue($item); + } + } + + return $value; + } + + /** + * @return array + */ + private function serializeRequest(BaseRequest $request): array + { + $data = []; + + foreach (get_object_vars($request) as $field => $fieldValue) { + if ($fieldValue === null) { + continue; + } + + $data[ucfirst((string) $field)] = $this->serializeValue($fieldValue); + } + + return $data; + } +} diff --git a/src/Request/PaymentsListV2.php b/src/Request/PaymentsListV2.php new file mode 100644 index 0000000..77b4640 --- /dev/null +++ b/src/Request/PaymentsListV2.php @@ -0,0 +1,62 @@ + $statuses + * + * @throws BadTypeException + */ + public function __construct( + public readonly DateTimeImmutable $createdDateGte, + public readonly DateTimeImmutable $createdDateLte, + public readonly int $pageNumber, + public readonly ?TimezoneCodes $timeZone = null, + public readonly array $statuses = [], + ) { + if ($pageNumber < 1) { + throw new BadTypeException('Page number must be greater than or equal to 1'); + } + } + + /** + * @return array{ + * CreatedDateGte: string, + * CreatedDateLte: string, + * PageNumber: int, + * TimeZone?: string, + * Statuses?: list + * } + */ + public function asArray(): array + { + $data = [ + 'CreatedDateGte' => $this->createdDateGte->format(DateTimeInterface::ATOM), + 'CreatedDateLte' => $this->createdDateLte->format(DateTimeInterface::ATOM), + 'PageNumber' => $this->pageNumber, + ]; + + if ($this->timeZone !== null) { + $data['TimeZone'] = $this->timeZone->value; + } + + if ($this->statuses !== []) { + $data['Statuses'] = array_map( + static fn (TransactionStatus $status): string => $status->value, + $this->statuses, + ); + } + + return $data; + } +} diff --git a/src/Request/Receipt/CustomerReceipt.php b/src/Request/Receipt/CustomerReceipt.php index e3ccc1d..93e7802 100644 --- a/src/Request/Receipt/CustomerReceipt.php +++ b/src/Request/Receipt/CustomerReceipt.php @@ -2,6 +2,7 @@ namespace Excent\Cloudpayments\Request\Receipt; +use Excent\Cloudpayments\Enum\RussiaTimeZone; use Excent\Cloudpayments\Request\BaseRequest; /** @@ -22,6 +23,18 @@ class CustomerReceipt extends BaseRequest * @param string|null $agentSign * @param string|null $cashierName * @param array|null $additionalReceiptInfos + * @param string|null $additionalReceiptRequisite + * @param string|null $customerBirthday + * @param string|null $customerStateCode + * @param string|null $customerDocType + * @param string|null $customerDoc + * @param string|null $customerPlace + * @param UserRequisiteData|null $userRequisiteData + * @param OperationReceiptRequisite|null $operationReceiptRequisite + * @param array|null $industryRequisiteCollection + * @param bool|null $isInternetPayment + * @param RussiaTimeZone|null $russiaTimeZone + * @param array|null $nonCashPayments */ public function __construct( public array $items, @@ -36,6 +49,42 @@ public function __construct( public ?string $agentSign = null, public ?string $cashierName = null, public ?array $additionalReceiptInfos = null, + public ?string $additionalReceiptRequisite = null, + public ?string $customerBirthday = null, + public ?string $customerStateCode = null, + public ?string $customerDocType = null, + public ?string $customerDoc = null, + public ?string $customerPlace = null, + public ?UserRequisiteData $userRequisiteData = null, + public ?OperationReceiptRequisite $operationReceiptRequisite = null, + /** @var array|null */ + public ?array $industryRequisiteCollection = null, + public ?bool $isInternetPayment = null, + public ?RussiaTimeZone $russiaTimeZone = null, + /** @var array|null */ + public ?array $nonCashPayments = null, ) { } + + /** + * @return array + */ + public function asArray(): array + { + $data = parent::asArray(); + + if ($this->userRequisiteData !== null) { + $data['UserRequisiteData'] = $this->userRequisiteData->asArray(); + } + + if ($this->operationReceiptRequisite !== null) { + $data['OperationReceiptRequisite'] = $this->operationReceiptRequisite->asArray(); + } + + if ($this->russiaTimeZone !== null) { + $data['RussiaTimeZone'] = $this->russiaTimeZone->value; + } + + return $data; + } } diff --git a/src/Request/Receipt/IndustryRequisiteCollection.php b/src/Request/Receipt/IndustryRequisiteCollection.php new file mode 100644 index 0000000..e3a4707 --- /dev/null +++ b/src/Request/Receipt/IndustryRequisiteCollection.php @@ -0,0 +1,23 @@ +vat = $vat instanceof Vat ? (string) $vat->value : $vat; } } diff --git a/src/Request/Receipt/UserRequisiteData.php b/src/Request/Receipt/UserRequisiteData.php new file mode 100644 index 0000000..c4fe681 --- /dev/null +++ b/src/Request/Receipt/UserRequisiteData.php @@ -0,0 +1,21 @@ + + */ + public function asArray(): array + { + $data = parent::asArray(); + + if ($this->platform !== null) { + $data['Platform'] = $this->platform->value; + } + + return $data; + } +} diff --git a/src/Request/SbpLink.php b/src/Request/SbpLink.php new file mode 100644 index 0000000..774e11e --- /dev/null +++ b/src/Request/SbpLink.php @@ -0,0 +1,84 @@ + 1024 + || preg_match('/[^\x00-\x7F]/', $successRedirectUrl) === 1 + || filter_var($successRedirectUrl, FILTER_VALIDATE_URL) === false + )) { + throw new BadTypeException( + 'SuccessRedirectUrl must be an ASCII RFC 3986 URL no longer than 1024 characters' + ); + } + + if ($ttlMinutes !== null && ($ttlMinutes < 1 || $ttlMinutes > 129600)) { + throw new BadTypeException('TtlMinutes must be between 1 and 129600'); + } + } + + /** + * @return array + * @throws JsonException + */ + public function asArray(): array + { + $data = parent::asArray(); + + if ($this->jsonData instanceof CloudpaymentsData) { + $data['JsonData'] = $this->jsonData->asJson(); + } + + if ($this->device !== null) { + $data['Device'] = $this->device->value; + } + + $data['Currency'] = $this->currency->value; + $data['Scheme'] = $this->scheme->value; + + return $data; + } +} diff --git a/src/Response/ChargebackArrayResponse.php b/src/Response/ChargebackArrayResponse.php new file mode 100644 index 0000000..dbcafe5 --- /dev/null +++ b/src/Response/ChargebackArrayResponse.php @@ -0,0 +1,40 @@ +model = []; + + return; + } + + $models = []; + + foreach ($modelDate as $value) { + if (! $value instanceof stdClass) { + continue; + } + + $model = new ChargebackModel(); + $model->fill($value); + $models[] = $model; + } + + $this->model = $models; + } +} diff --git a/src/Response/CloudResponse.php b/src/Response/CloudResponse.php index 22a69ac..355f851 100644 --- a/src/Response/CloudResponse.php +++ b/src/Response/CloudResponse.php @@ -14,6 +14,7 @@ class CloudResponse public bool $success; public ?string $message = null; public ?string $warning = null; + public ?int $errorCode = null; /** @var mixed */ public $model; @@ -29,9 +30,15 @@ public function fillByResponse(ResponseInterface $response): self $responseContent = new stdClass(); } - $this->success = $responseContent->Success ?? false; - $this->message = $responseContent->Message ?? 'Message is not set'; - $this->warning = $responseContent->Warning ?? 'Warning is not set'; + $success = $responseContent->Success ?? false; + $message = $responseContent->Message ?? 'Message is not set'; + $warning = $responseContent->Warning ?? 'Warning is not set'; + $errorCode = $responseContent->ErrorCode ?? null; + + $this->success = is_bool($success) ? $success : false; + $this->message = is_string($message) ? $message : 'Message is not set'; + $this->warning = is_string($warning) ? $warning : 'Warning is not set'; + $this->errorCode = is_int($errorCode) ? $errorCode : null; if (! empty($responseContent->Model)) { $this->fillModel($responseContent->Model); diff --git a/src/Response/Models/ChargebackModel.php b/src/Response/Models/ChargebackModel.php new file mode 100644 index 0000000..781d0dc --- /dev/null +++ b/src/Response/Models/ChargebackModel.php @@ -0,0 +1,19 @@ +{$key}; + unset($data->{$key}); + + break; + } + } + + parent::fill($data); + + $this->members = []; + + if (! is_array($membersData)) { + return; + } + + foreach ($membersData as $memberData) { + if (! $memberData instanceof stdClass) { + continue; + } + + $member = new SbpBankMemberModel(); + $member->fill($memberData); + $this->members[] = $member; + } + } +} diff --git a/src/Response/QrLinkResponse.php b/src/Response/QrLinkResponse.php new file mode 100644 index 0000000..7b989d1 --- /dev/null +++ b/src/Response/QrLinkResponse.php @@ -0,0 +1,28 @@ +fill($modelDate); + + $this->model = $model; + } +} diff --git a/src/Response/SbpBanksInfoResponse.php b/src/Response/SbpBanksInfoResponse.php new file mode 100644 index 0000000..cdc6730 --- /dev/null +++ b/src/Response/SbpBanksInfoResponse.php @@ -0,0 +1,39 @@ +fill($value); + $models[] = $model; + } + } + + $this->model = $models; + } +} diff --git a/src/Response/TransactionArrayResponse.php b/src/Response/TransactionArrayResponse.php index 3769a23..627fc38 100644 --- a/src/Response/TransactionArrayResponse.php +++ b/src/Response/TransactionArrayResponse.php @@ -11,7 +11,7 @@ class TransactionArrayResponse extends CloudResponse { /** @var TransactionModel[] */ - public $model; + public $model = []; /** * @param mixed $modelDate diff --git a/tests/LibraryChargebacksListTest.php b/tests/LibraryChargebacksListTest.php new file mode 100644 index 0000000..3f8c969 --- /dev/null +++ b/tests/LibraryChargebacksListTest.php @@ -0,0 +1,59 @@ +setNextResponse(new Response(200, [], <<<'JSON' + { + "Success": true, + "Model": [{ + "ChargeBackId": "6811d4a17bab1c4b44c19371", + "TerminalUrl": "https://cloudpayments.ru/", + "TransactionId": 2200203594, + "Amount": 100, + "Currency": "RUB", + "Type": "Chargeback", + "Operation": "Presentment", + "Card": "520500*******3055", + "Gateway": "SberbankRu", + "Date": "/Date(1745960400000)/" + }] + } + JSON)); + + $request = new ChargebacksList( + new DateTimeImmutable('2021-03-09T00:00:00+03:00'), + new DateTimeImmutable('2021-03-10T00:00:00+03:00'), + 1, + ); + $requestBeforeCall = $request->asArray(); + + $result = $library->chargebacksList($request); + + $this->assertInstanceOf(ChargebackArrayResponse::class, $result); + $this->assertSame('chargebacks/list', $library->lastMethod); + $this->assertSame([ + 'CreatedDateGte' => '2021-03-09T00:00:00+03:00', + 'CreatedDateLte' => '2021-03-10T00:00:00+03:00', + 'PageNumber' => 1, + ], $library->lastPostData); + $this->assertSame($requestBeforeCall, $request->asArray()); + $this->assertCount(1, $result->model); + $this->assertInstanceOf(ChargebackModel::class, $result->model[0]); + $this->assertSame('6811d4a17bab1c4b44c19371', $result->model[0]->chargeBackId); + } +} diff --git a/tests/LibraryPaymentsFindV2Test.php b/tests/LibraryPaymentsFindV2Test.php new file mode 100644 index 0000000..b422d5e --- /dev/null +++ b/tests/LibraryPaymentsFindV2Test.php @@ -0,0 +1,54 @@ +setNextResponse(new Response( + 200, + [], + '{"Success":true,"Message":null,"Model":{"TransactionId":504,"InvoiceId":"invoice-1","Amount":100.0,"Status":"Completed"}}' + )); + + $result = $library->getPaymentDataByInvoiceV2(new PaymentsFind('invoice-1')); + + $this->assertInstanceOf(TransactionResponse::class, $result); + $this->assertSame('v2/payments/find', $library->lastMethod); + $this->assertSame(['InvoiceId' => 'invoice-1'], $library->lastPostData); + $this->assertTrue($result->success); + $this->assertInstanceOf(TransactionModel::class, $result->model); + $this->assertSame(504, $result->model->transactionId); + $this->assertSame('invoice-1', $result->model->invoiceId); + $this->assertSame(100.0, $result->model->amount); + $this->assertSame('Completed', $result->model->status); + } + + public function testReturnsFailureWhenOperationIsNotFound(): void + { + $library = new RecordingLibrary('public_id', 'password'); + $library->setNextResponse(new Response(200, [], '{"Success":false,"Message":"Not found"}')); + + $result = $library->getPaymentDataByInvoiceV2(new PaymentsFind('invoice-1')); + + $this->assertInstanceOf(TransactionResponse::class, $result); + $this->assertFalse($result->success); + $this->assertSame('Not found', $result->message); + $this->assertSame('v2/payments/find', $library->lastMethod); + $this->assertSame(['InvoiceId' => 'invoice-1'], $library->lastPostData); + } +} diff --git a/tests/LibraryPaymentsListV2Test.php b/tests/LibraryPaymentsListV2Test.php new file mode 100644 index 0000000..9e12dd9 --- /dev/null +++ b/tests/LibraryPaymentsListV2Test.php @@ -0,0 +1,120 @@ +setNextResponse($this->jsonResponse(json_encode([ + 'Success' => true, + 'Message' => null, + 'Model' => [ + [ + 'TransactionId' => 2, + 'CreatedDateIso' => '2021-03-09T01:00:00+03:00', + 'Status' => 'Completed', + ], + [ + 'TransactionId' => 1, + 'CreatedDateIso' => '2021-03-09T00:00:00+03:00', + 'Status' => 'Authorized', + ], + ], + ], JSON_THROW_ON_ERROR))); + + $request = new PaymentsListV2( + new DateTimeImmutable('2021-03-09T00:00:00+03:00'), + new DateTimeImmutable('2021-03-10T00:00:00+03:00'), + 1, + TimezoneCodes::MSK, + [TransactionStatus::AUTHORIZED, TransactionStatus::COMPLETED], + ); + + $result = $library->getListPaymentV2($request); + + $this->assertInstanceOf(TransactionArrayResponse::class, $result); + $this->assertTrue($result->success); + $this->assertSame('v2/payments/list', $library->lastMethod); + $this->assertSame([ + 'CreatedDateGte' => '2021-03-09T00:00:00+03:00', + 'CreatedDateLte' => '2021-03-10T00:00:00+03:00', + 'PageNumber' => 1, + 'TimeZone' => 'MSK', + 'Statuses' => ['Authorized', 'Completed'], + ], $library->lastPostData); + + $this->assertCount(2, $result->model); + $this->assertInstanceOf(TransactionModel::class, $result->model[0]); + $this->assertInstanceOf(TransactionModel::class, $result->model[1]); + $this->assertSame(2, $result->model[0]->transactionId); + $this->assertSame('2021-03-09T01:00:00+03:00', $result->model[0]->createdDateIso); + $this->assertSame('Completed', $result->model[0]->status); + $this->assertSame(1, $result->model[1]->transactionId); + $this->assertSame('2021-03-09T00:00:00+03:00', $result->model[1]->createdDateIso); + $this->assertSame('Authorized', $result->model[1]->status); + } + + /** + * @throws JsonException + */ + public function testEmptyModelIsInitializedAsEmptyArray(): void + { + $library = new RecordingLibrary('public_id', 'password'); + $library->setNextResponse($this->jsonResponse(json_encode([ + 'Success' => true, + 'Message' => null, + 'Model' => [], + ], JSON_THROW_ON_ERROR))); + + $result = $library->getListPaymentV2($this->request()); + + $this->assertTrue($result->success); + $this->assertSame([], $result->model); + } + + /** + * @throws JsonException + */ + public function testMissingModelLeavesModelAsEmptyArray(): void + { + $library = new RecordingLibrary('public_id', 'password'); + $library->setNextResponse($this->jsonResponse(json_encode(['Success' => true], JSON_THROW_ON_ERROR))); + + $result = $library->getListPaymentV2($this->request()); + + $this->assertTrue($result->success); + $this->assertSame([], $result->model); + } + + private function request(): PaymentsListV2 + { + return new PaymentsListV2( + new DateTimeImmutable('2021-03-09T00:00:00+03:00'), + new DateTimeImmutable('2021-03-10T00:00:00+03:00'), + 1, + ); + } + + private function jsonResponse(string $payload): Response + { + return new Response(200, ['Content-type' => 'application/json'], $payload); + } +} diff --git a/tests/LibrarySbpBanksInfoTest.php b/tests/LibrarySbpBanksInfoTest.php new file mode 100644 index 0000000..1de60bd --- /dev/null +++ b/tests/LibrarySbpBanksInfoTest.php @@ -0,0 +1,60 @@ +createLibrary(); + + $this->assertInstanceOf(SbpBanksInfoResponse::class, $library->sbpV2BanksInfo()); + $this->assertSame('sbp/v2/banks/info', $library->lastMethod); + $this->assertSame([ + 'PublicTerminalId' => 'public_id', + ], $library->lastPostData); + } + + public function testSbpV2BanksInfoAddsPublicTerminalIdWithoutMutatingRequest(): void + { + $library = $this->createLibrary(); + $request = new SbpBanksInfo( + accountId: 'account-id', + platform: SbpPlatform::ANDROID, + saveCard: true, + ); + $requestDataBeforeCall = $request->asArray(); + + $this->assertInstanceOf(SbpBanksInfoResponse::class, $library->sbpV2BanksInfo($request)); + $this->assertSame('sbp/v2/banks/info', $library->lastMethod); + $this->assertSame([ + 'AccountId' => 'account-id', + 'Platform' => 'android', + 'SaveCard' => 'true', + 'PublicTerminalId' => 'public_id', + ], $library->lastPostData); + $this->assertSame($requestDataBeforeCall, $request->asArray()); + $this->assertArrayNotHasKey('PublicTerminalId', $request->asArray()); + } + + private function createLibrary(): RecordingLibrary + { + $library = new RecordingLibrary('public_id', 'password'); + $library->setNextResponse(new Response(200, ['Content-type' => 'application/json'], json_encode([ + 'Success' => true, + 'Message' => null, + 'Model' => [], + ], JSON_THROW_ON_ERROR))); + + return $library; + } +} diff --git a/tests/LibrarySbpLinkTest.php b/tests/LibrarySbpLinkTest.php new file mode 100644 index 0000000..389eb57 --- /dev/null +++ b/tests/LibrarySbpLinkTest.php @@ -0,0 +1,69 @@ +setNextResponse(new Response(200, ['Content-type' => 'application/json'], json_encode([ + 'Success' => true, + 'Message' => null, + 'Model' => ['QrUrl' => 'https://qr.nspk.ru/example'], + ], JSON_THROW_ON_ERROR))); + + $request = new SbpLink('1000.00', Currency::RUB, SbpScheme::CHARGE); + $result = $library->paymentsQrSbpLink($request); + + $this->assertInstanceOf(QrLinkResponse::class, $result); + $this->assertTrue($result->success); + $this->assertSame('payments/qr/sbp/link', $library->lastMethod); + $this->assertSame([ + 'Amount' => '1000.00', + 'Currency' => 'RUB', + 'Scheme' => 'charge', + 'PublicId' => 'public_id', + ], $library->lastPostData); + $this->assertArrayNotHasKey('PublicId', $request->asArray()); + } + + public function testPaymentsQrSbpImageSendsPublicIdAndReturnsQrImage(): void + { + $library = new RecordingLibrary('public_id', 'password'); + $library->setNextResponse(new Response(200, ['Content-type' => 'application/json'], json_encode([ + 'Success' => true, + 'Message' => null, + 'Model' => [ + 'QrUrl' => null, + 'QrImage' => 'iVBORw0KGgoAAAANSUhEUg==', + ], + ], JSON_THROW_ON_ERROR))); + + $request = new SbpLink('1000.00', Currency::RUB, SbpScheme::CHARGE); + $result = $library->paymentsQrSbpImage($request); + + $this->assertInstanceOf(QrLinkResponse::class, $result); + $this->assertTrue($result->success); + $this->assertSame('payments/qr/sbp/image', $library->lastMethod); + $this->assertSame([ + 'Amount' => '1000.00', + 'Currency' => 'RUB', + 'Scheme' => 'charge', + 'PublicId' => 'public_id', + ], $library->lastPostData); + $this->assertNull($result->model->qrUrl); + $this->assertSame('iVBORw0KGgoAAAANSUhEUg==', $result->model->qrImage); + $this->assertArrayNotHasKey('PublicId', $request->asArray()); + } +} diff --git a/tests/LibraryTest.php b/tests/LibraryTest.php index ac86538..9ddbc0a 100644 --- a/tests/LibraryTest.php +++ b/tests/LibraryTest.php @@ -85,6 +85,20 @@ public function testApiMethodsSendExpectedRequests( $this->assertEquals($expectedData, $library->lastPostData); } + public function testTestMethodSendsEmptyRequestAndFillsCloudResponse(): void + { + $library = new RecordingLibrary('public_id', 'password'); + $library->setNextResponse($this->jsonResponse(['Success' => true, 'Message' => 'ok'])); + + $result = $library->test(); + + $this->assertInstanceOf(CloudResponse::class, $result); + $this->assertTrue($result->success); + $this->assertSame('ok', $result->message); + $this->assertSame('test', $library->lastMethod); + $this->assertSame([], $library->lastPostData); + } + public function testGettersReturnConstructorValues(): void { $library = new Library('public_id', 'password', 'https://example.com/'); diff --git a/tests/Request/ChargebacksListTest.php b/tests/Request/ChargebacksListTest.php new file mode 100644 index 0000000..adb83d5 --- /dev/null +++ b/tests/Request/ChargebacksListTest.php @@ -0,0 +1,84 @@ +assertSame([ + 'CreatedDateGte' => '2021-03-09T00:00:00+03:00', + 'CreatedDateLte' => '2021-03-10T00:00:00+03:00', + 'PageNumber' => 1, + ], $request->asArray()); + } + + public function testAsArrayPreservesDateTimeOffsets(): void + { + $request = new ChargebacksList( + new DateTimeImmutable('2021-03-09T00:00:00+03:00'), + new DateTimeImmutable('2021-03-10T01:30:00+04:00'), + 1, + ); + + $this->assertSame('2021-03-09T00:00:00+03:00', $request->asArray()['CreatedDateGte']); + $this->assertSame('2021-03-10T01:30:00+04:00', $request->asArray()['CreatedDateLte']); + } + + public function testRejectsPageNumberLessThanOne(): void + { + $this->expectException(BadTypeException::class); + + new ChargebacksList( + new DateTimeImmutable('2021-03-09T00:00:00+03:00'), + new DateTimeImmutable('2021-03-10T00:00:00+03:00'), + 0, + ); + } + + public function testRejectsEndBeforeStart(): void + { + $this->expectException(BadTypeException::class); + + new ChargebacksList( + new DateTimeImmutable('2021-03-10T00:00:00+03:00'), + new DateTimeImmutable('2021-03-09T00:00:00+03:00'), + 1, + ); + } + + public function testRejectsPeriodLongerThanOneCalendarYear(): void + { + $this->expectException(BadTypeException::class); + + new ChargebacksList( + new DateTimeImmutable('2021-03-09T00:00:00+03:00'), + new DateTimeImmutable('2022-03-09T00:00:01+03:00'), + 1, + ); + } + + public function testAcceptsPeriodOfExactlyOneCalendarYear(): void + { + $request = new ChargebacksList( + new DateTimeImmutable('2021-03-09T00:00:00+03:00'), + new DateTimeImmutable('2022-03-09T00:00:00+03:00'), + 1, + ); + + $this->assertSame('2022-03-09T00:00:00+03:00', $request->asArray()['CreatedDateLte']); + } +} diff --git a/tests/Request/CloudpaymentsDataTest.php b/tests/Request/CloudpaymentsDataTest.php new file mode 100644 index 0000000..5a423ef --- /dev/null +++ b/tests/Request/CloudpaymentsDataTest.php @@ -0,0 +1,130 @@ +assertSame('20', $item->asArray()['Vat']); + } + + public function testReceiptItemKeepsLegacyStringVatPropertyInChildClass(): void + { + $item = new LegacyReceiptItem('Item', '100.00', '1.00', '100.00', Vat::PERCENT_22); + + $this->assertSame('22', $item->vat); + } + + public function testJsonDataConvertsOnlyValidVatIntegerStrings(): void + { + $data = new CloudpaymentsData(new CustomerReceipt([ + new ReceiptItem('Valid', '100.00', '1.00', '100.00', '22'), + new ReceiptItem('Decimal', '100.00', '1.00', '100.00', '5.5'), + new ReceiptItem('Exponent', '100.00', '1.00', '100.00', '2e1'), + new ReceiptItem('Unknown', '100.00', '1.00', '100.00', '999'), + ])); + + $items = $data->asArray()['cloudpayments']['CustomerReceipt']['Items']; + + $this->assertSame(22, $items[0]['Vat']); + $this->assertSame('5.5', $items[1]['Vat']); + $this->assertSame('2e1', $items[2]['Vat']); + $this->assertSame('999', $items[3]['Vat']); + } + + public function testWrapsCustomerReceiptAndAdditionalData(): void + { + $receipt = new CustomerReceipt( + [new ReceiptItem('Item', '100.00', '1.00', '100.00', Vat::PERCENT_22)], + '2', + new ReceiptAmounts(electronic: '100.00'), + isBso: false, + customerBirthday: '1990-01-01', + userRequisiteData: new UserRequisiteData('key', 'value'), + operationReceiptRequisite: new OperationReceiptRequisite( + 123, + '2026-07-15T12:00:00+03:00', + 'operation-data', + ), + industryRequisiteCollection: [ + new IndustryRequisiteCollection('code', '2026-07-15', 'document-1', 'value'), + ], + isInternetPayment: true, + russiaTimeZone: RussiaTimeZone::RTZ_2, + nonCashPayments: [new NonCashPayments(100.00, 1, 'payment-1', 'Additional info')], + ); + $data = new CloudpaymentsData($receipt, ['name' => 'Customer']); + + $this->assertSame([ + 'cloudpayments' => [ + 'name' => 'Customer', + 'CustomerReceipt' => [ + 'Items' => [ + [ + 'Vat' => 22, + 'Label' => 'Item', + 'Price' => '100.00', + 'Quantity' => '1.00', + 'Amount' => '100.00', + ], + ], + 'TaxationSystem' => '2', + 'Amounts' => ['Electronic' => '100.00'], + 'IsBso' => false, + 'CustomerBirthday' => '1990-01-01', + 'UserRequisiteData' => [ + 'RequisiteKey' => 'key', + 'RequisiteValue' => 'value', + ], + 'OperationReceiptRequisite' => [ + 'OperationIdentifier' => 123, + 'OperationDate' => '2026-07-15T12:00:00+03:00', + 'OperationData' => 'operation-data', + ], + 'IndustryRequisiteCollection' => [ + [ + 'Code' => 'code', + 'DocumentDate' => '2026-07-15', + 'DocumentNumber' => 'document-1', + 'RequisiteValue' => 'value', + ], + ], + 'IsInternetPayment' => true, + 'RussiaTimeZone' => 2, + 'NonCashPayments' => [ + [ + 'Amount' => 100.00, + 'PaymentMethod' => 1, + 'PaymentId' => 'payment-1', + 'AdditionalInfo' => 'Additional info', + ], + ], + ], + ], + ], $data->asArray()); + + $this->assertJson($data->asJson()); + } +} + +final class LegacyReceiptItem extends ReceiptItem +{ + public ?string $vat = null; +} diff --git a/tests/Request/PaymentsListV2Test.php b/tests/Request/PaymentsListV2Test.php new file mode 100644 index 0000000..df363bc --- /dev/null +++ b/tests/Request/PaymentsListV2Test.php @@ -0,0 +1,77 @@ +assertSame([ + 'CreatedDateGte' => '2021-03-09T00:00:00+03:00', + 'CreatedDateLte' => '2021-03-10T00:00:00+03:00', + 'PageNumber' => 1, + ], $request->asArray()); + } + + public function testAsArraySerializesAllParameters(): void + { + $request = new PaymentsListV2( + new DateTimeImmutable('2021-03-09T00:00:00+03:00'), + new DateTimeImmutable('2021-03-10T00:00:00+03:00'), + 1, + TimezoneCodes::MSK, + [ + TransactionStatus::AUTHORIZED, + TransactionStatus::COMPLETED, + TransactionStatus::CANCELLED, + TransactionStatus::DECLINED, + ], + ); + + $this->assertSame([ + 'CreatedDateGte' => '2021-03-09T00:00:00+03:00', + 'CreatedDateLte' => '2021-03-10T00:00:00+03:00', + 'PageNumber' => 1, + 'TimeZone' => 'MSK', + 'Statuses' => ['Authorized', 'Completed', 'Cancelled', 'Declined'], + ], $request->asArray()); + } + + public function testAsArrayOmitsEmptyStatuses(): void + { + $request = new PaymentsListV2( + new DateTimeImmutable('2021-03-09T00:00:00+03:00'), + new DateTimeImmutable('2021-03-10T00:00:00+03:00'), + 1, + statuses: [], + ); + + $this->assertArrayNotHasKey('Statuses', $request->asArray()); + } + + public function testRejectsPageNumberLessThanOne(): void + { + $this->expectException(BadTypeException::class); + + new PaymentsListV2( + new DateTimeImmutable('2021-03-09T00:00:00+03:00'), + new DateTimeImmutable('2021-03-10T00:00:00+03:00'), + 0, + ); + } +} diff --git a/tests/Request/SbpBanksInfoTest.php b/tests/Request/SbpBanksInfoTest.php new file mode 100644 index 0000000..db5eb9c --- /dev/null +++ b/tests/Request/SbpBanksInfoTest.php @@ -0,0 +1,54 @@ +assertSame([], $request->asArray()); + } + + public function testAsArraySerializesAllFieldsWithApiNames(): void + { + $request = new SbpBanksInfo( + accountId: 'account-id', + isRenewSubscription: true, + jsonData: '{"customer":"John"}', + platform: SbpPlatform::IOS, + saveCard: false, + token: 'token-value', + ); + + $this->assertSame([ + 'AccountId' => 'account-id', + 'IsRenewSubscription' => 'true', + 'JsonData' => '{"customer":"John"}', + 'Platform' => 'ios', + 'SaveCard' => 'false', + 'Token' => 'token-value', + ], $request->asArray()); + } + + public function testAsArrayExcludesNullFields(): void + { + $request = new SbpBanksInfo(accountId: 'account-id', platform: SbpPlatform::DESKTOP); + + $this->assertSame([ + 'AccountId' => 'account-id', + 'Platform' => 'desktop', + ], $request->asArray()); + $this->assertArrayNotHasKey('IsRenewSubscription', $request->asArray()); + $this->assertArrayNotHasKey('JsonData', $request->asArray()); + $this->assertArrayNotHasKey('SaveCard', $request->asArray()); + $this->assertArrayNotHasKey('Token', $request->asArray()); + } +} diff --git a/tests/Request/SbpLinkTest.php b/tests/Request/SbpLinkTest.php new file mode 100644 index 0000000..7d56bbb --- /dev/null +++ b/tests/Request/SbpLinkTest.php @@ -0,0 +1,157 @@ +assertSame([ + 'Amount' => '1000.00', + 'Currency' => 'RUB', + 'Scheme' => 'charge', + ], $request->asArray()); + } + + public function testAsArraySerializesOptionalFieldsAndBooleanValues(): void + { + $request = new SbpLink( + '1000.50', + Currency::RUB, + SbpScheme::CHARGE, + 'Оплата по СБП', + 'account-id', + 'email@example.com', + new CloudpaymentsData(additionalData: ['receipt' => []]), + 'order-1', + 'https://example.com/success', + '127.0.0.1', + 'Android', + false, + Device::MOBILE_APP, + 'Chrome', + 129600, + false, + false, + ); + + $this->assertSame([ + 'Amount' => '1000.50', + 'Currency' => 'RUB', + 'Scheme' => 'charge', + 'Description' => 'Оплата по СБП', + 'AccountId' => 'account-id', + 'Email' => 'email@example.com', + 'JsonData' => '{"cloudpayments":{"receipt":[]}}', + 'InvoiceId' => 'order-1', + 'SuccessRedirectUrl' => 'https://example.com/success', + 'IpAddress' => '127.0.0.1', + 'Os' => 'Android', + 'Webview' => 'false', + 'Device' => 'MobileApp', + 'Browser' => 'Chrome', + 'TtlMinutes' => 129600, + 'SaveCard' => 'false', + 'IsTest' => 'false', + ], $request->asArray()); + } + + public function testAsArrayExcludesNullOptionalFields(): void + { + $request = new SbpLink('1000.00', Currency::RUB, SbpScheme::CHARGE); + + $this->assertArrayNotHasKey('Description', $request->asArray()); + $this->assertArrayNotHasKey('PublicId', $request->asArray()); + $this->assertCount(3, $request->asArray()); + } + + public function testAsArraySerializesCloudpaymentsDataObject(): void + { + $request = new SbpLink( + '1000.00', + Currency::RUB, + SbpScheme::CHARGE, + jsonData: new CloudpaymentsData(additionalData: ['name' => 'Customer']), + ); + + $this->assertSame( + '{"cloudpayments":{"name":"Customer"}}', + $request->asArray()['JsonData'] + ); + } + + /** + * @dataProvider validTtlMinutesProvider + */ + public function testAcceptsValidTtlMinutes(int $ttlMinutes): void + { + $request = new SbpLink('1000.00', Currency::RUB, SbpScheme::CHARGE, ttlMinutes: $ttlMinutes); + + $this->assertSame($ttlMinutes, $request->ttlMinutes); + } + + /** + * @dataProvider invalidTtlMinutesProvider + */ + public function testRejectsInvalidTtlMinutes(int $ttlMinutes): void + { + $this->expectException(BadTypeException::class); + + new SbpLink('1000.00', Currency::RUB, SbpScheme::CHARGE, ttlMinutes: $ttlMinutes); + } + + public function testRejectsAmountWithMoreThanTwoDecimalPlaces(): void + { + $this->expectException(BadTypeException::class); + + new SbpLink('1000.123', Currency::RUB, SbpScheme::CHARGE); + } + + public function testRejectsUnsupportedCurrency(): void + { + $this->expectException(BadTypeException::class); + + new SbpLink('1000.00', Currency::USD, SbpScheme::CHARGE); + } + + public function testRejectsInvalidSuccessRedirectUrl(): void + { + $this->expectException(BadTypeException::class); + + new SbpLink('1000.00', Currency::RUB, SbpScheme::CHARGE, successRedirectUrl: 'https://example.com/тест'); + } + + /** + * @return array + */ + public static function validTtlMinutesProvider(): array + { + return [ + 'minimum' => [1], + 'maximum' => [129600], + ]; + } + + /** + * @return array + */ + public static function invalidTtlMinutesProvider(): array + { + return [ + 'zero' => [0], + 'above maximum' => [129601], + ]; + } +} diff --git a/tests/Response/ChargebackArrayResponseTest.php b/tests/Response/ChargebackArrayResponseTest.php new file mode 100644 index 0000000..05a4b3e --- /dev/null +++ b/tests/Response/ChargebackArrayResponseTest.php @@ -0,0 +1,124 @@ +fillByResponse(new Response(200, [], <<<'JSON' + { + "Success": true, + "Message": "Loaded", + "ErrorCode": 17, + "Model": [ + { + "ChargeBackId": "first", + "TerminalUrl": "https://first.example/", + "TransactionId": 2200203594, + "Amount": 100, + "Currency": "RUB", + "Type": "Chargeback", + "Operation": "Presentment", + "Card": "520500*******3055", + "Gateway": "SberbankRu", + "Date": "/Date(1745960400000)/", + "FutureField": "available" + }, + { + "ChargeBackId": "second", + "TerminalUrl": "https://second.example/", + "TransactionId": 2200203595, + "Amount": 12.5, + "Currency": "USD", + "Type": "Fee", + "Operation": "Representment", + "Card": "424242*******4242", + "Gateway": "AnotherBank", + "Date": "/Date(1745960500000)/" + } + ] + } + JSON)); + + $this->assertTrue($response->success); + $this->assertSame('Loaded', $response->message); + $this->assertSame(17, $response->errorCode); + $this->assertCount(2, $response->model); + $this->assertContainsOnlyInstancesOf(ChargebackModel::class, $response->model); + + $first = $response->model[0]; + $this->assertSame('first', $first->chargeBackId); + $this->assertSame('https://first.example/', $first->terminalUrl); + $this->assertSame(2200203594, $first->transactionId); + $this->assertSame(100, $first->amount); + $this->assertSame('RUB', $first->currency); + $this->assertSame('Chargeback', $first->type); + $this->assertSame('Presentment', $first->operation); + $this->assertSame('520500*******3055', $first->card); + $this->assertSame('SberbankRu', $first->gateway); + $this->assertSame('/Date(1745960400000)/', $first->date); + $this->assertSame('available', $first->getAdditionalProperties()['futureField']); + + $second = $response->model[1]; + $this->assertSame('second', $second->chargeBackId); + $this->assertSame(12.5, $second->amount); + $this->assertSame('Fee', $second->type); + $this->assertSame('Representment', $second->operation); + $this->assertSame('/Date(1745960500000)/', $second->date); + } + + public function testNullErrorCodeRemainsNull(): void + { + $response = new ChargebackArrayResponse(); + $response->fillByResponse(new Response(200, [], '{"Success":true,"ErrorCode":null,"Model":[]}')); + + $this->assertNull($response->errorCode); + $this->assertSame([], $response->model); + } + + public function testMissingModelLeavesEmptyList(): void + { + $response = new ChargebackArrayResponse(); + $response->fillByResponse(new Response(200, [], '{"Success":true}')); + + $this->assertSame([], $response->model); + } + + public function testFillModelSkipsInvalidElementsAndNonArrayInput(): void + { + $response = new ChargebackArrayResponse(); + $response->fillModel([ + 'invalid', + (object) [ + 'ChargeBackId' => 'valid', + 'TerminalUrl' => 'https://example.com/', + 'TransactionId' => 1, + 'Amount' => 1, + 'Currency' => 'RUB', + 'Type' => 'Chargeback', + 'Operation' => 'Presentment', + 'Card' => '4242********4242', + 'Gateway' => 'Bank', + 'Date' => '/Date(1)/', + ], + [], + ]); + + $this->assertCount(1, $response->model); + $this->assertInstanceOf(ChargebackModel::class, $response->model[0]); + + $response->fillModel(new stdClass()); + + $this->assertSame([], $response->model); + } +} diff --git a/tests/Response/CloudResponseTest.php b/tests/Response/CloudResponseTest.php index 31a0c1b..340027f 100644 --- a/tests/Response/CloudResponseTest.php +++ b/tests/Response/CloudResponseTest.php @@ -22,6 +22,41 @@ public function testFillByResponseUsesDefaultsForNonObjectJson(): void $this->assertFalse($response->success); $this->assertSame('Message is not set', $response->message); $this->assertSame('Warning is not set', $response->warning); + $this->assertNull($response->errorCode); + } + + public function testFillByResponseMapsIntegerErrorCode(): void + { + $response = new CloudResponse(); + $response->fillByResponse(new Response(200, [], '{"Success":true,"ErrorCode":42}')); + + $this->assertTrue($response->success); + $this->assertSame(42, $response->errorCode); + } + + public function testFillByResponseKeepsNullForMissingAndNullErrorCode(): void + { + $missingErrorCode = new CloudResponse(); + $missingErrorCode->fillByResponse(new Response(200, [], '{"Success":true}')); + + $nullErrorCode = new CloudResponse(); + $nullErrorCode->fillByResponse(new Response(200, [], '{"Success":true,"ErrorCode":null}')); + + $this->assertNull($missingErrorCode->errorCode); + $this->assertNull($nullErrorCode->errorCode); + } + + public function testFillByResponseUsesDefaultsForInvalidScalarTypes(): void + { + $response = new CloudResponse(); + $response->fillByResponse(new Response(200, [], <<<'JSON' + {"Success":1,"Message":false,"Warning":[],"ErrorCode":"17"} + JSON)); + + $this->assertFalse($response->success); + $this->assertSame('Message is not set', $response->message); + $this->assertSame('Warning is not set', $response->warning); + $this->assertNull($response->errorCode); } public function testFillModelKeepsScalar(): void diff --git a/tests/Response/QrLinkResponseTest.php b/tests/Response/QrLinkResponseTest.php new file mode 100644 index 0000000..eacbc70 --- /dev/null +++ b/tests/Response/QrLinkResponseTest.php @@ -0,0 +1,46 @@ +fillByResponse(new Response(200, ['Content-type' => 'application/json'], json_encode([ + 'Success' => true, + 'Message' => 'ok', + 'Model' => [ + 'QrUrl' => 'https://qr.nspk.ru/example', + 'QrImage' => null, + 'TransactionId' => 11122233344, + 'MerchantOrderId' => 'order-1', + 'ProviderQrId' => 'provider-qr-id', + 'Amount' => 1000.50, + 'Message' => 'Created', + 'IsTest' => false, + 'FutureField' => 'preserved', + ], + ], JSON_THROW_ON_ERROR))); + + $this->assertTrue($response->success); + $this->assertSame('ok', $response->message); + $this->assertInstanceOf(QrLinkModel::class, $response->model); + $this->assertSame('https://qr.nspk.ru/example', $response->model->qrUrl); + $this->assertNull($response->model->qrImage); + $this->assertSame(11122233344, $response->model->transactionId); + $this->assertSame('order-1', $response->model->merchantOrderId); + $this->assertSame('provider-qr-id', $response->model->providerQrId); + $this->assertSame(1000.50, $response->model->amount); + $this->assertSame('Created', $response->model->message); + $this->assertFalse($response->model->isTest); + $this->assertSame('preserved', $response->model->getAdditionalProperties()['futureField']); + } +} diff --git a/tests/Response/SbpBanksInfoResponseTest.php b/tests/Response/SbpBanksInfoResponseTest.php new file mode 100644 index 0000000..1e2c62a --- /dev/null +++ b/tests/Response/SbpBanksInfoResponseTest.php @@ -0,0 +1,98 @@ +fillByResponse(new Response(200, ['Content-type' => 'application/json'], json_encode([ + 'Success' => true, + 'Message' => 'ok', + 'Model' => [ + [ + 'Source' => 'widget', + 'Version' => '2.0', + 'FutureField' => 'source-extra', + 'members' => [ + [ + 'id' => '100000000111', + 'logo' => 'https://qr.nspk.ru/proxyapp/logo/bank100000000111.png', + 'name' => 'Сбербанк', + 'url' => 'bank100000000111://qr.nspk.ru/{QRC_ID}{QUERY_STRING}', + 'FutureMemberField' => 'member-extra', + ], + [ + 'id' => '100000000222', + 'logo' => 'https://example.test/logo-2.png', + 'name' => 'Второй банк', + 'url' => 'bank100000000222://qr.nspk.ru/{QRC_ID}{QUERY_STRING}', + ], + ], + ], + [ + 'source' => 'mobile', + 'version' => '1.0', + 'Members' => [], + ], + ], + ], JSON_THROW_ON_ERROR))); + + $this->assertTrue($response->success); + $this->assertSame('ok', $response->message); + $this->assertCount(2, $response->model); + $this->assertInstanceOf(SbpBanksInfoModel::class, $response->model[0]); + $this->assertSame('widget', $response->model[0]->source); + $this->assertSame('2.0', $response->model[0]->version); + $this->assertSame('source-extra', $response->model[0]->getAdditionalProperties()['futureField']); + + $this->assertCount(2, $response->model[0]->members); + $this->assertInstanceOf(SbpBankMemberModel::class, $response->model[0]->members[0]); + $this->assertSame('100000000111', $response->model[0]->members[0]->id); + $this->assertSame('Сбербанк', $response->model[0]->members[0]->name); + $this->assertSame( + 'https://qr.nspk.ru/proxyapp/logo/bank100000000111.png', + $response->model[0]->members[0]->logo + ); + $this->assertSame( + 'bank100000000111://qr.nspk.ru/{QRC_ID}{QUERY_STRING}', + $response->model[0]->members[0]->url + ); + $this->assertSame( + 'member-extra', + $response->model[0]->members[0]->getAdditionalProperties()['futureMemberField'] + ); + + $this->assertSame('mobile', $response->model[1]->source); + $this->assertSame('1.0', $response->model[1]->version); + $this->assertSame([], $response->model[1]->members); + } + + public function testMissingOrEmptyModelLeavesEmptyArray(): void + { + $emptyModelResponse = new SbpBanksInfoResponse(); + $emptyModelResponse->fillByResponse(new Response(200, [], json_encode([ + 'Success' => true, + 'Message' => null, + 'Model' => [], + ], JSON_THROW_ON_ERROR))); + + $missingModelResponse = new SbpBanksInfoResponse(); + $missingModelResponse->fillByResponse(new Response(200, [], json_encode([ + 'Success' => true, + 'Message' => null, + ], JSON_THROW_ON_ERROR))); + + $this->assertSame([], $emptyModelResponse->model); + $this->assertSame([], $missingModelResponse->model); + } +}