diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..0a9e0d0 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,35 @@ +name: Tests + +on: + push: + branches: [master] + pull_request: + +jobs: + tests: + name: PHP ${{ matrix.php }}, Guzzle ${{ matrix.guzzle }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: ['8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + guzzle: ['7', '8'] + + steps: + - uses: actions/checkout@v7 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl + coverage: none + + - name: Install dependencies + run: | + composer require "guzzlehttp/guzzle:^${{ matrix.guzzle }}.0" --no-update --no-interaction + composer update --prefer-dist --no-interaction --no-progress + + # Тесты против стенда банка (группа bank-stand) исключены в phpunit.xml + - name: Run tests + run: vendor/bin/phpunit diff --git a/README.md b/README.md index 1f20852..3f3ae07 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ PHP-клиент для обмена с банком по протоколу **1 и даёт типизированные объекты для транспортного контейнера (`Packet`) и документов внутри него: запрос выписки, выписка, извещение о состоянии обработки контейнера и др. +[![Tests](https://github.com/ttbooking/DirectBank/actions/workflows/tests.yml/badge.svg)](https://github.com/ttbooking/DirectBank/actions/workflows/tests.yml) [![Packagist](https://img.shields.io/packagist/v/ttbooking/direct-bank.svg)](https://packagist.org/packages/ttbooking/direct-bank) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) @@ -39,12 +40,13 @@ $client = new Client([ 'apiVersion' => DefaultValue::FORMAT_VERSION, // по умолчанию '2.2.2' 'sessionId' => null, // можно передать уже полученный SID 'verify' => true, // проверка SSL-сертификата + 'handler' => null, // свой Guzzle handler, например MockHandler в тестах ]); ``` Настройки проверяются в конструкторе: `url`, `customerId`, `login`, `password` и `apiVersion` должны быть непустыми строками, `sessionId` — строкой или `null`, `verify` — булевым значением -или путём к CA-бандлу. Иначе выбрасывается +или путём к CA-бандлу, `handler` — callable. Иначе выбрасывается `TTBooking\DirectBank\Exceptions\InvalidSettingsException` (наследник `\InvalidArgumentException`) с именем неверной настройки. @@ -59,6 +61,8 @@ $client = new Client($settings, $logger); // Psr\Log\LoggerInterface Явно вызывать `createSession()` не обязательно: при первом запросе, требующем авторизации, клиент сам выполнит `Logon` и подставит полученный `sid` в заголовки. +Если сессия истекла или стала недействительной (ошибки банка `1006` и `1007`), +клиент войдёт заново и повторит запрос один раз. ```php $sid = $client->createSession(); @@ -73,9 +77,18 @@ $sid = $client->createSession(); | `getPackList(?DateTimeInterface $date = null): ?array` | `GET GetPackList` | список идентификаторов контейнеров, готовых к получению | | `getPack(string $id): Packet` | `GET GetPack` | транспортный контейнер | -Если банк вернул ошибку (`ResultBank/Error`), выбрасывается -`TTBooking\DirectBank\Exceptions\ClientException` с кодом и описанием из ответа. -Транспортные ошибки пробрасываются как исключения Guzzle. +Отметка времени для `getPackList()` задаётся по часам сервера банка и передаётся +в формате `dd.MM.yyyy HH:mm:ss`. + +### Ошибки + +- `TTBooking\DirectBank\Exceptions\ClientException` — банк вернул ошибку (`ResultBank/Error`), + при любом HTTP-статусе. Код банка как есть — `getBankCode()` (строка, например `'1201'`), + вся ошибка — `getError()`, описание — `getMessage()`, `getCode()` — код числом. +- `TTBooking\DirectBank\Exceptions\UnexpectedResponseException` (наследник `ClientException`) — + ответ не удалось разобрать или в нём нет ожидаемых данных. HTTP-ответ — `getResponse()`, + `getCode()` — HTTP-статус. +- Сетевые ошибки (нет соединения, таймаут) пробрасываются как исключения Guzzle. ## Примеры @@ -187,8 +200,13 @@ composer install vendor/bin/phpunit ``` -`tests/Objects` — офлайн-тесты маппинга XML. `tests/ClientTest.php` обращается -к тестовому стенду банка и требует сетевого доступа к нему. +По умолчанию запускаются офлайн-тесты: маппинг XML на примерах из описания стандарта 1С +и работа `Client` с подменённым HTTP-обработчиком. Тесты против тестового стенда банка +(`tests/ClientTest.php`) требуют сетевого доступа к нему и запускаются отдельно: + +```bash +vendor/bin/phpunit --group bank-stand +``` ## История изменений diff --git a/phpunit.xml b/phpunit.xml index 5463efc..6b04294 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -15,6 +15,12 @@ + + + bank-stand + + + diff --git a/src/Client.php b/src/Client.php index 05b5bc0..d2b5e75 100644 --- a/src/Client.php +++ b/src/Client.php @@ -9,11 +9,14 @@ use GuzzleHttp\MessageFormatter; use GuzzleHttp\Middleware; use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; use Psr\Log\LoggerInterface; use TTBooking\DirectBank\Dictionary\DefaultValue; +use TTBooking\DirectBank\Dictionary\ErrorCode; use GuzzleHttp\Client as HttpClient; use TTBooking\DirectBank\Exceptions\ClientException; use TTBooking\DirectBank\Exceptions\InvalidSettingsException; +use TTBooking\DirectBank\Exceptions\UnexpectedResponseException; use TTBooking\DirectBank\Objects\Packet; use TTBooking\DirectBank\Objects\ResultBank; @@ -39,19 +42,28 @@ public function createSession(): string { $result = $this->invoke('POST', 'Logon'); - return $result->getSuccess()->getLogonResponse()->getSID(); + $response = $result->getSuccess()->getLogonResponse() + ?? throw new UnexpectedResponseException('Bank response to Logon has no LogonResponse.'); + + return $response->getSID(); } public function sendPack(Packet $packet): string { $result = $this->invoke('POST', 'SendPack', (string) $packet); - return $result->getSuccess()->getSendPacketResponse()->getID(); + $response = $result->getSuccess()->getSendPacketResponse() + ?? throw new UnexpectedResponseException('Bank response to SendPack has no SendPacketResponse.'); + + return $response->getID(); } - public function getPackList(\DateTimeInterface $dateTime = null): ?array + /** + * @param \DateTimeInterface|null $dateTime отметка времени по часам сервера банка + */ + public function getPackList(?\DateTimeInterface $dateTime = null): ?array { - $result = $this->invoke('GET', 'GetPackList', query: ['date' => $dateTime ? $dateTime->format('Y-m-d H:i:s') : null]); + $result = $this->invoke('GET', 'GetPackList', query: ['date' => $dateTime?->format(DefaultValue::TIMESTAMP_FORMAT)]); return $result->getSuccess()->getGetPacketListResponse()?->getPacketID(); } @@ -60,7 +72,8 @@ public function getPack(string $uid): Packet { $result = $this->invoke('GET', 'GetPack', query: ['id' => $uid]); - return $result->getSuccess()->getGetPacketResponse(); + return $result->getSuccess()->getGetPacketResponse() + ?? throw new UnexpectedResponseException('Bank response to GetPack has no GetPacketResponse.'); } /** @@ -81,23 +94,74 @@ protected function validateSettings(array $settings): void if (isset($settings['verify']) && ! is_bool($settings['verify']) && ! is_string($settings['verify'])) { throw new InvalidSettingsException('Setting "verify" must be a boolean or a path to a CA bundle.'); } + + if (isset($settings['handler']) && ! is_callable($settings['handler'])) { + throw new InvalidSettingsException('Setting "handler" must be a Guzzle handler (callable).'); + } } /** + * @return ResultBank с заполненным Success + * * @throws \GuzzleHttp\Exception\GuzzleException * @throws \TTBooking\DirectBank\Exceptions\ClientException */ - protected function invoke(string $method, string $path, string $body = null, array $query = []): ResultBank + protected function invoke(string $method, string $path, ?string $body = null, array $query = [], bool $reauthenticate = true): ResultBank { $client = $this->getHttpClient($this->settings, $path !== 'Logon'); $response = $client->request($method, $path, ['body' => $body, 'query' => $query]); - $result = new ResultBank(); - $result->mapFromXml((string) $response->getBody()); + $result = $this->parseResult($response); + + if ($error = $result?->getError()) { + // Сессия истекла или недействительна: входим заново и повторяем запрос один раз + if ($reauthenticate && $path !== 'Logon' && in_array($error->getCode(), ErrorCode::REAUTHENTICATE, true)) { + $this->settings['sessionId'] = null; + + return $this->invoke($method, $path, $body, $query, false); + } + + throw ClientException::fromError($error); + } + + if ($response->getStatusCode() >= 400) { + throw UnexpectedResponseException::fromResponse( + sprintf('Bank responded with HTTP %d %s and no error description.', $response->getStatusCode(), $response->getReasonPhrase()), + $response + ); + } + + if (! $result?->getSuccess()) { + throw UnexpectedResponseException::fromResponse(sprintf('Bank response to %s has neither Success nor Error.', $path), $response); + } + + return $result; + } + + /** + * Ошибка банка приходит в теле ResultBank при любом HTTP-статусе, + * поэтому тело разбирается до проверки статуса. + * + * @throws \TTBooking\DirectBank\Exceptions\UnexpectedResponseException + */ + protected function parseResult(ResponseInterface $response): ?ResultBank + { + $body = (string) $response->getBody(); + + if (trim($body) === '') { + return null; + } + + try { + $result = new ResultBank(); + $result->mapFromXml($body); + } catch (\Throwable $e) { + if ($response->getStatusCode() >= 400) { + return null; + } - if($error = $result->getError()) { - throw new ClientException($error->getDescription(), $error->getCode()); + throw UnexpectedResponseException::fromResponse('Unable to parse bank response: ' . $e->getMessage(), $response, $e); } return $result; @@ -105,7 +169,7 @@ protected function invoke(string $method, string $path, string $body = null, arr protected function getHttpClient(array $settings, $withAuth = false): HttpClient { - $handler = new CurlHandler(); + $handler = $this->settings['handler'] ?? new CurlHandler(); $stack = HandlerStack::create($handler); if ($this->logger) { @@ -143,6 +207,7 @@ protected function getHttpClient(array $settings, $withAuth = false): HttpClient $settings['password'], ], 'verify' => $this->settings['verify'] ?? true, + 'http_errors' => false, 'handler' => $stack, ]); } diff --git a/src/ClientInterface.php b/src/ClientInterface.php index 302f1e3..347c66f 100644 --- a/src/ClientInterface.php +++ b/src/ClientInterface.php @@ -11,7 +11,7 @@ public function createSession(): string; public function sendPack(Packet $packet): string; - public function getPackList(\DateTimeInterface $dateTime = null): ?array; + public function getPackList(?\DateTimeInterface $dateTime = null): ?array; public function getPack(string $uid) : Packet; } \ No newline at end of file diff --git a/src/Dictionary/DefaultValue.php b/src/Dictionary/DefaultValue.php index b742fbe..aeeea31 100644 --- a/src/Dictionary/DefaultValue.php +++ b/src/Dictionary/DefaultValue.php @@ -7,4 +7,7 @@ class DefaultValue { const FORMAT_VERSION = '2.2.2'; + + //Формат отметки времени в GetPackList: dd.MM.yyyy HH:mm:ss + const TIMESTAMP_FORMAT = 'd.m.Y H:i:s'; } \ No newline at end of file diff --git a/src/Dictionary/ErrorCode.php b/src/Dictionary/ErrorCode.php new file mode 100644 index 0000000..b928a7c --- /dev/null +++ b/src/Dictionary/ErrorCode.php @@ -0,0 +1,19 @@ +getDescription(), (int) $error->getCode()); + $exception->error = $error; + + return $exception; + } + + /** + * Ошибка из ответа банка (ResultBank/Error), если она была + */ + public function getError(): ?ErrorType + { + return $this->error; + } -} \ No newline at end of file + /** + * Код ошибки банка как есть, строкой из 4 символов + */ + public function getBankCode(): ?string + { + return $this->error?->getCode(); + } +} diff --git a/src/Exceptions/UnexpectedResponseException.php b/src/Exceptions/UnexpectedResponseException.php new file mode 100644 index 0000000..843f020 --- /dev/null +++ b/src/Exceptions/UnexpectedResponseException.php @@ -0,0 +1,28 @@ +getStatusCode(), $previous); + $exception->response = $response; + + return $exception; + } + + public function getResponse(): ?ResponseInterface + { + return $this->response; + } +} diff --git a/src/Objects/CheckType.php b/src/Objects/CheckType.php index 39ff0ce..bb007c9 100644 --- a/src/Objects/CheckType.php +++ b/src/Objects/CheckType.php @@ -10,7 +10,10 @@ class CheckType extends OtherPaymentDataType protected ?DataPrintingType $DataPrinting = null; - protected ?DetailsType $Details = null; + /** + * @var DetailsType[] + */ + protected array $Details = []; public function getPerson(): ?PersonType { @@ -22,8 +25,11 @@ public function getDataPrinting(): ?DataPrintingType return $this->DataPrinting; } - public function getDetails(): ?DetailsType + /** + * @return DetailsType[] + */ + public function getDetails(): array { return $this->Details; } -} \ No newline at end of file +} diff --git a/src/Objects/DataType.php b/src/Objects/DataType.php new file mode 100644 index 0000000..25cb320 --- /dev/null +++ b/src/Objects/DataType.php @@ -0,0 +1,48 @@ +value = $value; + $this->fileName = $fileName; + $this->contentType = $contentType; + } + + public function getValue(): string + { + return $this->value; + } + + public function getFileName(): ?string + { + return $this->fileName; + } + + public function getContentType(): ?string + { + return $this->contentType; + } +} diff --git a/src/Objects/DetailsType.php b/src/Objects/DetailsType.php index 3c4a895..73998a6 100644 --- a/src/Objects/DetailsType.php +++ b/src/Objects/DetailsType.php @@ -13,7 +13,7 @@ class DetailsType protected string $Symbol; //Указываются направления (цели) выдачи наличных денег в соответствии //с содержанием символов отчетности по форме 0409202 и содержанием операции - protected ?string $Purpose; + protected ?string $Purpose = null; //Сумма расходов protected float $Sum; diff --git a/src/Objects/DocumentType.php b/src/Objects/DocumentType.php index 7075248..cea696f 100644 --- a/src/Objects/DocumentType.php +++ b/src/Objects/DocumentType.php @@ -53,9 +53,18 @@ class DocumentType protected ?string $extID = null; /** + * Строка base64 или DataType, если у Data есть атрибуты fileName или contentType + * * @name Data + * @var DataType */ - protected string $data; + protected $data; + + /** + * @name Signature + * @var SignatureType[] + */ + protected array $signatures = []; /** * @return string @@ -116,7 +125,7 @@ public function setFormatVersion(string $formatVersion): DocumentType */ public function isTestOnly(): bool { - return $this->testOnly; + return $this->testOnly ?? false; } /** @@ -134,7 +143,7 @@ public function setTestOnly(bool $testOnly): DocumentType */ public function isCompressed(): bool { - return $this->compressed; + return $this->compressed ?? false; } /** @@ -152,7 +161,7 @@ public function setCompressed(bool $compressed): DocumentType */ public function isEncrypted(): bool { - return $this->encrypted; + return $this->encrypted ?? false; } /** @@ -170,7 +179,7 @@ public function setEncrypted(bool $encrypted): DocumentType */ public function isSignResponse(): bool { - return $this->signResponse; + return $this->signResponse ?? false; } /** @@ -220,20 +229,46 @@ public function setNotifyRequired($notifyRequired) } /** + * Содержимое документа в base64 + * * @return string */ public function getData(): string { - return $this->data; + return $this->data instanceof DataType ? $this->data->getValue() : (string) $this->data; } /** - * @param string $data + * @param string $data содержимое документа в base64 * @return DocumentType */ - public function setData(string $data): DocumentType + public function setData(string $data, ?string $fileName = null, ?string $contentType = null): DocumentType + { + $this->data = $fileName === null && $contentType === null ? $data : new DataType($data, $fileName, $contentType); + return $this; + } + + public function getFileName(): ?string + { + return $this->data instanceof DataType ? $this->data->getFileName() : null; + } + + public function getContentType(): ?string + { + return $this->data instanceof DataType ? $this->data->getContentType() : null; + } + + /** + * @return SignatureType[] + */ + public function getSignatures(): array + { + return $this->signatures; + } + + public function addSignature(SignatureType $signature): DocumentType { - $this->data = $data; + $this->signatures[] = $signature; return $this; } } \ No newline at end of file diff --git a/src/Objects/ErrorType.php b/src/Objects/ErrorType.php index fc5db88..8e37b83 100644 --- a/src/Objects/ErrorType.php +++ b/src/Objects/ErrorType.php @@ -6,7 +6,10 @@ class ErrorType { - protected int $Code; + /** + * @var string + */ + protected string $Code; protected string $Description; @@ -15,7 +18,7 @@ class ErrorType /** * @return string */ - public function getCode(): int + public function getCode(): string { return $this->Code; } diff --git a/src/Objects/GetPacketListResponseType.php b/src/Objects/GetPacketListResponseType.php index 5d2d679..dbb3f79 100644 --- a/src/Objects/GetPacketListResponseType.php +++ b/src/Objects/GetPacketListResponseType.php @@ -9,13 +9,18 @@ class GetPacketListResponseType /** * @xmlAttribute */ - protected string $TimeStampLastPacket; + protected ?string $TimeStampLastPacket = null; /** * @var string[] */ protected array $PacketID = []; + public function getTimeStampLastPacket(): ?string + { + return $this->TimeStampLastPacket; + } + /** * @return string[] */ diff --git a/src/Objects/InnerDocType.php b/src/Objects/InnerDocType.php new file mode 100644 index 0000000..0771189 --- /dev/null +++ b/src/Objects/InnerDocType.php @@ -0,0 +1,19 @@ +InnerDocKind; + } +} diff --git a/src/Objects/Packet.php b/src/Objects/Packet.php index aa63ce1..a0a64c6 100644 --- a/src/Objects/Packet.php +++ b/src/Objects/Packet.php @@ -55,8 +55,9 @@ class Packet implements \Stringable /** * @name Document + * @var DocumentType[] */ - protected DocumentType $document; + protected array $documents = []; /** * @return string @@ -113,9 +114,9 @@ public function setCreationDate(string $creationDate): Packet } /** - * @return string + * @return string|null */ - public function getUserAgent(): string + public function getUserAgent(): ?string { return $this->userAgent; } @@ -167,20 +168,48 @@ public function setRecipient(ParticipantType $recipient): Packet } /** + * Первый документ контейнера + * * @return \TTBooking\DirectBank\Objects\DocumentType */ public function getDocument(): DocumentType { - return $this->document; + return $this->documents[0] ?? throw new \LogicException('Packet has no Document.'); } /** + * Заменяет документы контейнера одним документом + * * @param \TTBooking\DirectBank\Objects\DocumentType $document * @return Packet */ public function setDocument(DocumentType $document): Packet { - $this->document = $document; + $this->documents = [$document]; + return $this; + } + + /** + * @return \TTBooking\DirectBank\Objects\DocumentType[] + */ + public function getDocuments(): array + { + return $this->documents; + } + + /** + * @param \TTBooking\DirectBank\Objects\DocumentType[] $documents + * @return Packet + */ + public function setDocuments(array $documents): Packet + { + $this->documents = array_values($documents); + return $this; + } + + public function addDocument(DocumentType $document): Packet + { + $this->documents[] = $document; return $this; } diff --git a/src/Objects/PayDoc.php b/src/Objects/PayDoc.php index 298910b..c555db9 100644 --- a/src/Objects/PayDoc.php +++ b/src/Objects/PayDoc.php @@ -38,7 +38,7 @@ class PayDoc protected ?MemOrderApp $MemOrder = null; //Данные внутр.банковского документа - protected ?OtherPaymentDataType $InnerDoc = null; + protected ?InnerDocType $InnerDoc = null; //Данные объявления на взнос наличными protected ?CashContributionType $CashContribution = null; @@ -92,7 +92,7 @@ public function getMemOrder(): ?MemOrderApp return $this->MemOrder; } - public function getInnerDoc(): ?OtherPaymentDataType + public function getInnerDoc(): ?InnerDocType { return $this->InnerDoc; } diff --git a/src/Objects/PayRequestApp.php b/src/Objects/PayRequestApp.php index 2cc308a..ef431e1 100644 --- a/src/Objects/PayRequestApp.php +++ b/src/Objects/PayRequestApp.php @@ -14,9 +14,9 @@ class PayRequestApp extends PaymentDataType //2 - требуется получение акцепта плательщика. protected string $PaymentCondition; //Срок для акцепта (поле 36): количество дней. - protected ?string $AcceptTerm; + protected ?string $AcceptTerm = null; //Дата отсылки (вручения) плательщику предусмотренных договором документов (поле 37). - protected ?string $DocDispatchDate; + protected ?string $DocDispatchDate = null; /** * @return string diff --git a/src/Objects/PaymentOrder.php b/src/Objects/PaymentOrder.php index 2c7f89a..3ac6cea 100644 --- a/src/Objects/PaymentOrder.php +++ b/src/Objects/PaymentOrder.php @@ -19,6 +19,6 @@ class PaymentOrder extends PaymentDataType //Дата платежного документа (поле 41). protected ?string $PartialDocDate = null; - protected ?BudgetPaymentInfoType $BudgetPaymentInfo; + protected ?BudgetPaymentInfoType $BudgetPaymentInfo = null; } \ No newline at end of file diff --git a/src/Objects/ResultBank.php b/src/Objects/ResultBank.php index bf79c13..2372752 100644 --- a/src/Objects/ResultBank.php +++ b/src/Objects/ResultBank.php @@ -24,7 +24,7 @@ class ResultBank protected ?SuccessResultType $Success = null; - public function getSuccess(): SuccessResultType + public function getSuccess(): ?SuccessResultType { return $this->Success; } diff --git a/src/Objects/SignatureType.php b/src/Objects/SignatureType.php new file mode 100644 index 0000000..a8682d6 --- /dev/null +++ b/src/Objects/SignatureType.php @@ -0,0 +1,59 @@ +x509IssuerName; + } + + public function setX509IssuerName(string $x509IssuerName): SignatureType + { + $this->x509IssuerName = $x509IssuerName; + return $this; + } + + public function getX509SerialNumber(): string + { + return $this->x509SerialNumber; + } + + public function setX509SerialNumber(string $x509SerialNumber): SignatureType + { + $this->x509SerialNumber = $x509SerialNumber; + return $this; + } + + /** + * Подпись в base64 + */ + public function getSignedData(): string + { + return $this->SignedData; + } + + public function setSignedData(string $signedData): SignatureType + { + $this->SignedData = $signedData; + return $this; + } +} diff --git a/src/Objects/Stamp.php b/src/Objects/Stamp.php index 13d33c8..2e80cd0 100644 --- a/src/Objects/Stamp.php +++ b/src/Objects/Stamp.php @@ -6,12 +6,17 @@ class Stamp extends BankType { - protected string $Branch; + protected ?string $Branch = null; protected ?StatusType $Status= null; - public function getBranch(): string + public function getBranch(): ?string { return $this->Branch; } + + public function getStatus(): ?StatusType + { + return $this->Status; + } } \ No newline at end of file diff --git a/src/Objects/Statement.php b/src/Objects/Statement.php index 2729917..a12765f 100644 --- a/src/Objects/Statement.php +++ b/src/Objects/Statement.php @@ -34,6 +34,11 @@ class Statement */ protected string $creationDate; + /** + * @xmlAttribute + */ + protected ?string $userAgent = null; + protected BankPartyType $Sender; protected CustomerPartyType $Recipient; @@ -57,6 +62,11 @@ public function getCreationDate(): string return $this->creationDate; } + public function getUserAgent(): ?string + { + return $this->userAgent; + } + public function getSender(): BankPartyType { return $this->Sender; diff --git a/src/Objects/StatementData.php b/src/Objects/StatementData.php index 6c48138..9ae6f6f 100644 --- a/src/Objects/StatementData.php +++ b/src/Objects/StatementData.php @@ -29,7 +29,7 @@ class StatementData */ protected array $OperationInfo = []; - protected Stamp $Stamp; + protected ?Stamp $Stamp = null; public function getStatementType(): string { @@ -84,7 +84,7 @@ public function getOperationInfo(): array return $this->OperationInfo; } - public function getStamp(): Stamp + public function getStamp(): ?Stamp { return $this->Stamp; } diff --git a/src/Objects/StatementRequest.php b/src/Objects/StatementRequest.php index c7b0f0d..d6ee3af 100644 --- a/src/Objects/StatementRequest.php +++ b/src/Objects/StatementRequest.php @@ -36,7 +36,7 @@ class StatementRequest implements \Stringable /** * @xmlAttribute */ - protected string $userAgent; + protected ?string $userAgent = null; /** * @name Sender @@ -108,9 +108,9 @@ public function setCreationDate(string $creationDate): StatementRequest } /** - * @return string + * @return string|null */ - public function getUserAgent(): string + public function getUserAgent(): ?string { return $this->userAgent; } diff --git a/src/Objects/StatusType.php b/src/Objects/StatusType.php index 49a3244..b6bd048 100644 --- a/src/Objects/StatusType.php +++ b/src/Objects/StatusType.php @@ -11,4 +11,19 @@ class StatusType protected ?string $Name = null; protected ?string $MoreInfo = null; + + public function getCode(): string + { + return $this->Code; + } + + public function getName(): ?string + { + return $this->Name; + } + + public function getMoreInfo(): ?string + { + return $this->MoreInfo; + } } \ No newline at end of file diff --git a/tests/ClientHttpTest.php b/tests/ClientHttpTest.php new file mode 100644 index 0000000..9f592e6 --- /dev/null +++ b/tests/ClientHttpTest.php @@ -0,0 +1,338 @@ + 'https://bank.example.ru/API/v1/directbank/', + 'customerId' => '40702810000000000000', + 'login' => 'user', + 'password' => 'secret', + ]; + + protected MockHandler $mock; + + /** + * @var RequestInterface[] + */ + protected array $requests = []; + + protected function setUp(): void + { + $this->mock = new MockHandler(); + $this->requests = []; + } + + protected function createClient(array $settings = []): Client + { + $handler = function (RequestInterface $request, array $options) { + $this->requests[] = $request; + + return ($this->mock)($request, $options); + }; + + return new Client(['handler' => $handler] + $settings + $this->settings); + } + + protected static function success(string $content): Response + { + return new Response(200, ['Content-Type' => 'application/xml; charset=utf-8'], self::resultBank("$content")); + } + + protected static function error(string $code, string $description, int $status = 200): Response + { + return new Response($status, ['Content-Type' => 'application/xml; charset=utf-8'], self::resultBank( + "$code$description" + )); + } + + protected static function resultBank(string $content): string + { + return '' + . '' . $content . ''; + } + + protected static function logon(string $sid = 'SID-1'): Response + { + return self::success("$sid"); + } + + public function testCreateSession() + { + $this->mock->append(self::logon()); + + $this->assertSame('SID-1', $this->createClient()->createSession()); + + $request = $this->requests[0]; + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('/API/v1/directbank/Logon', $request->getUri()->getPath()); + $this->assertSame('Basic ' . base64_encode('user:secret'), $request->getHeaderLine('Authorization')); + $this->assertSame('40702810000000000000', $request->getHeaderLine('customerid')); + $this->assertSame('2.2.2', $request->getHeaderLine('apiversion')); + $this->assertFalse($request->hasHeader('sid')); + } + + public function testLogonBeforeFirstRequest() + { + $this->mock->append( + self::logon(), + self::success('PACK-1'), + self::success('A'), + ); + + $client = $this->createClient(); + $client->sendPack(PacketFixture::createPacket()); + $client->getPackList(); + + $this->assertCount(3, $this->requests); + $this->assertStringEndsWith('/Logon', $this->requests[0]->getUri()->getPath()); + $this->assertSame('SID-1', $this->requests[1]->getHeaderLine('sid')); + $this->assertSame('SID-1', $this->requests[2]->getHeaderLine('sid')); + } + + public function testSessionIdFromSettings() + { + $this->mock->append(self::success('A')); + + $this->createClient(['sessionId' => 'SID-0'])->getPackList(); + + $this->assertCount(1, $this->requests); + $this->assertSame('SID-0', $this->requests[0]->getHeaderLine('sid')); + } + + public function testSendPack() + { + $this->mock->append(self::success('PACK-1')); + + $packet = PacketFixture::createPacket(); + + $this->assertSame('PACK-1', $this->createClient(['sessionId' => 'SID-0'])->sendPack($packet)); + + $request = $this->requests[0]; + $this->assertSame('POST', $request->getMethod()); + $this->assertStringEndsWith('/SendPack', $request->getUri()->getPath()); + $this->assertSame((string) $packet, (string) $request->getBody()); + } + + public function testGetPackList() + { + $this->mock->append( + self::success('AB'), + self::success('A'), + ); + + $client = $this->createClient(['sessionId' => 'SID-0']); + + $this->assertSame(['A', 'B'], $client->getPackList()); + $this->assertSame(['A'], $client->getPackList()); + + $request = $this->requests[0]; + $this->assertSame('GET', $request->getMethod()); + $this->assertStringEndsWith('/GetPackList', $request->getUri()->getPath()); + $this->assertSame('', $request->getUri()->getQuery()); + } + + public function testGetPackListWithoutTimeStamp() + { + $this->mock->append( + self::success('A'), + self::success(''), + ); + + $client = $this->createClient(['sessionId' => 'SID-0']); + + $this->assertSame(['A'], $client->getPackList()); + $this->assertNull($client->getPackList()); + } + + public function testGetPackListDate() + { + $this->mock->append(self::success('A')); + + $this->createClient(['sessionId' => 'SID-0'])->getPackList(new \DateTimeImmutable('2015-02-16 11:25:32')); + + $query = []; + parse_str($this->requests[0]->getUri()->getQuery(), $query); + + $this->assertSame(['date' => '16.02.2015 11:25:32'], $query); + } + + public function testGetPack() + { + $packet = PacketFixture::createPacket(); + $xml = preg_replace('/^<\?xml[^>]*\?>\s*/', '', (string) $packet); + $xml = str_replace([''], [''], $xml); + + $this->mock->append(self::success($xml)); + + $result = $this->createClient(['sessionId' => 'SID-0'])->getPack($packet->getId()); + + $this->assertSame($packet->getId(), $result->getId()); + $this->assertSame($packet->getDocument()->getData(), $result->getDocument()->getData()); + + $request = $this->requests[0]; + $this->assertStringEndsWith('/GetPack', $request->getUri()->getPath()); + $this->assertSame('id=' . $packet->getId(), $request->getUri()->getQuery()); + } + + public function testBankError() + { + $this->mock->append(self::error('1201', 'Некорректные данные для аутентификации')); + + try { + $this->createClient()->createSession(); + $this->fail('ClientException expected'); + } catch (ClientException $e) { + $this->assertSame(1201, $e->getCode()); + $this->assertSame('1201', $e->getBankCode()); + $this->assertSame('Некорректные данные для аутентификации', $e->getMessage()); + $this->assertSame('Некорректные данные для аутентификации', $e->getError()->getDescription()); + } + } + + public function testBankErrorCodeIsString() + { + $this->mock->append(self::error('0042', 'Код с ведущими нулями'), self::error('AB12', 'Буквенный код')); + + $client = $this->createClient(); + + foreach (['0042' => 42, 'AB12' => 0] as $bankCode => $code) { + try { + $client->createSession(); + $this->fail('ClientException expected'); + } catch (ClientException $e) { + $this->assertSame((string) $bankCode, $e->getBankCode()); + $this->assertSame($code, $e->getCode()); + } + } + } + + public function sessionErrorProvider(): array + { + return [ + 'session timeout' => ['1006', 200], + 'invalid session id' => ['1007', 200], + 'invalid session id, HTTP 401' => ['1007', 401], + ]; + } + + /** + * @dataProvider sessionErrorProvider + */ + public function testReauthenticateOnSessionError(string $code, int $status) + { + $this->mock->append( + self::error($code, 'Сессия недействительна', $status), + self::logon('SID-2'), + self::success('PACK-1'), + self::success('PACK-2'), + ); + + $client = $this->createClient(['sessionId' => 'SID-1']); + $packet = PacketFixture::createPacket(); + + $this->assertSame('PACK-1', $client->sendPack($packet)); + $this->assertSame('PACK-2', $client->sendPack($packet)); + + $this->assertCount(4, $this->requests); + $this->assertSame('SID-1', $this->requests[0]->getHeaderLine('sid')); + $this->assertStringEndsWith('/Logon', $this->requests[1]->getUri()->getPath()); + $this->assertSame('SID-2', $this->requests[2]->getHeaderLine('sid')); + $this->assertSame((string) $packet, (string) $this->requests[2]->getBody()); + $this->assertSame('SID-2', $this->requests[3]->getHeaderLine('sid')); + } + + public function testReauthenticateOnlyOnce() + { + $this->mock->append( + self::error('1006', 'Сессия закрыта по тайм-ауту'), + self::logon('SID-2'), + self::error('1006', 'Сессия закрыта по тайм-ауту'), + ); + + try { + $this->createClient(['sessionId' => 'SID-1'])->getPackList(); + $this->fail('ClientException expected'); + } catch (ClientException $e) { + $this->assertSame('1006', $e->getBankCode()); + } + + $this->assertCount(3, $this->requests); + } + + public function testNoReauthenticateOnOtherErrors() + { + $this->mock->append(self::error('2102', 'Транспортный контейнер не найден')); + + try { + $this->createClient(['sessionId' => 'SID-1'])->getPack('unknown'); + $this->fail('ClientException expected'); + } catch (ClientException $e) { + $this->assertSame('2102', $e->getBankCode()); + } + + $this->assertCount(1, $this->requests); + } + + public function testBankErrorWithHttpErrorStatus() + { + $this->mock->append(self::error('1009', 'Ошибка приемного сервиса', 500)); + + try { + $this->createClient()->createSession(); + $this->fail('ClientException expected'); + } catch (ClientException $e) { + $this->assertNotInstanceOf(UnexpectedResponseException::class, $e); + $this->assertSame('1009', $e->getBankCode()); + } + } + + public function unexpectedResponseProvider(): array + { + return [ + 'HTTP error without ResultBank' => [new Response(502, [], 'Bad Gateway'), 502], + 'HTTP error with empty body' => [new Response(503), 503], + 'not XML' => [new Response(200, [], 'not xml'), 200], + 'empty body' => [new Response(200), 200], + 'ResultBank without Success and Error' => [new Response(200, [], self::resultBank('')), 200], + 'Success without LogonResponse' => [self::success('PACK-1'), 0], + ]; + } + + /** + * @dataProvider unexpectedResponseProvider + */ + public function testUnexpectedResponse(Response $response, int $code) + { + $this->mock->append($response); + + try { + $this->createClient()->createSession(); + $this->fail('UnexpectedResponseException expected'); + } catch (UnexpectedResponseException $e) { + $this->assertSame($code, $e->getCode()); + $this->assertNull($e->getBankCode()); + } + } + + public function testLogger() + { + $this->mock->append(self::logon()); + + $logger = new \Monolog\Logger('test', [$handler = new \Monolog\Handler\TestHandler()]); + + (new Client(['handler' => $this->mock] + $this->settings, $logger))->createSession(); + + $this->assertTrue($handler->hasInfoThatContains('Logon')); + } +} diff --git a/tests/ClientSettingsTest.php b/tests/ClientSettingsTest.php index 5d044ae..6402496 100644 --- a/tests/ClientSettingsTest.php +++ b/tests/ClientSettingsTest.php @@ -61,4 +61,12 @@ public function testInvalidVerify() new Client(['verify' => 1] + $this->settings); } + + public function testInvalidHandler() + { + $this->expectException(InvalidSettingsException::class); + $this->expectExceptionMessage('"handler"'); + + new Client(['handler' => 'not a handler'] + $this->settings); + } } diff --git a/tests/ClientTest.php b/tests/ClientTest.php index a3e901e..e9bae46 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -11,6 +11,12 @@ use TTBooking\DirectBank\Objects\Statement; use TTBooking\DirectBank\Objects\StatusPacketNotice; +/** + * Тесты против тестового стенда банка, требуют сетевого доступа к нему. + * Запуск: vendor/bin/phpunit --group bank-stand + * + * @group bank-stand + */ final class ClientTest extends TestCase { protected array $settings = [ diff --git a/tests/Fixture/xml/1c/Packet.xml b/tests/Fixture/xml/1c/Packet.xml new file mode 100644 index 0000000..3ae796c --- /dev/null +++ b/tests/Fixture/xml/1c/Packet.xml @@ -0,0 +1,54 @@ + + + + + + + + + + +PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4NCjxQYXlEb2NSdSB4bWxucz0i +aHR0cDovL2RpcmVjdGJhbmsuMWMucnUvWE1MU2NoZW1hIiANCgl4bWxuczp4cz0iaHR0cDovL3d3 +dy53My5vcmcvMjAwMS9YTUxTY2hlbWEiIA0KICAgIHhtbG5zOnhzaT0iaHR0cDovL3d3dy53My5v +cmcvMjAwMS9YTUxTY2hlbWEtaW5zdGFuY2UiIA0KICAgIGlkPSIwNTY4ODA5Ni0wODA2LTRlZjAt +YWY0Yy01NzJmNzVkYmFmN2MiIA0KICAgIGZvcm1hdFZlcnNpb249IjIuMi4xIiANCiAgICBjcmVh +dGlvbkRhdGU9IjIwMTYtMDQtMjJUMDk6Mzg6NTEiIA0KICAgIHVzZXJBZ2VudD0iMdChIC0g0JHQ +rdCUOiAxLjQuMS4xOyDQkdC40LHQu9C40L7RgtC10LrQsNCt0LvQtdC60YLRgNC+0L3QvdGL0YXQ +lNC+0LrRg9C80LXQvdGC0L7QsjogMS40LjEuMSI+DQogICAgPFNlbmRlciBpZD0iaWQ6NDI7czo5 +OTk5IiBuYW1lPSLQotC+0YDQs9C+0LLRi9C5INC00L7QvCDQmtC+0LzQv9C70LXQutGB0L3Ri9C5 +IiBpbm49Ijc3MDUyNjA2OTkiIGtwcD0iNzcwNTAxMDAxIi8+DQogICAgPFJlY2lwaWVudCBiaWM9 +IjA0NDUyNTg4OCIgbmFtZT0i0JTQldCc0J4t0JHQkNCd0JoiLz4NCgk8RGF0YT4NCiAgICAgICAg +PERvY05vPjE0PC9Eb2NObz4NCiAgICAgICAgPERvY0RhdGU+MjAxNi0wNC0yMjwvRG9jRGF0ZT4N +CiAgICAgICAgPFN1bT4xNTwvU3VtPg0KICAgICAgICA8UGF5ZXI+DQogICAgICAgICAgICA8TmFt +ZT7QotC+0YDQs9C+0LLRi9C5INC00L7QvCAi0JrQvtC80L/Qu9C10LrRgdC90YvQuSI8L05hbWU+ +DQogICAgICAgICAgICA8SU5OPjc3MDUyNjA2OTk8L0lOTj4NCiAgICAgICAgICAgIDxLUFA+Nzcw +NTAxMDAxPC9LUFA+DQogICAgICAgICAgICA8QWNjb3VudD40MDcwMjgxMDgxMzEyMzEyMzIyMjwv +QWNjb3VudD4NCiAgICAgICAgICAgIDxCYW5rPg0KICAgICAgICAgICAgICAgIDxCSUM+MDQ0NTI1 +ODg4PC9CSUM+DQogICAgICAgICAgICAgICAgPE5hbWU+0JTQldCc0J4t0JHQkNCd0Jo8L05hbWU+ +DQogICAgICAgICAgICAgICAgPENvcnJlc3BBY2M+MzAxMDE4MTA1MDAwMDAwMDAyMTk8L0NvcnJl +c3BBY2M+DQogICAgICAgICAgICA8L0Jhbms+DQogICAgICAgIDwvUGF5ZXI+DQogICAgICAgIDxQ +YXllZT4NCiAgICAgICAgICAgIDxOYW1lPtCe0J7QniAi0JrQsNC90YbRgtC+0LLQsNGA0YsiPC9O +YW1lPg0KICAgICAgICAgICAgPElOTj43NzA0NTk2MTgxPC9JTk4+DQogICAgICAgICAgICA8S1BQ +Pjc3MDQwMTAwMTwvS1BQPg0KICAgICAgICAgICAgPEFjY291bnQ+NDA3MDI4MTA0MDEyMDAwMDAw +MzU8L0FjY291bnQ+DQogICAgICAgICAgICA8QmFuaz4NCiAgICAgICAgICAgICAgICA8QklDPjA0 +NDUyNTk5OTwvQklDPg0KICAgICAgICAgICAgICAgIDxOYW1lPtCU0JXQnNCeLdCR0JDQndCaMjwv +TmFtZT4NCiAgICAgICAgICAgICAgICA8Q29ycmVzcEFjYz4zMDEwMTgxMDIwMDAwMDAwMDU5Mzwv +Q29ycmVzcEFjYz4NCiAgICAgICAgICAgIDwvQmFuaz4NCiAgICAgICAgPC9QYXllZT4NCiAgICAg +ICAgPFRyYW5zaXRpb25LaW5kPjAxPC9UcmFuc2l0aW9uS2luZD4NCiAgICAgICAgPFByaW9yaXR5 +PjM8L1ByaW9yaXR5Pg0KICAgICAgICA8UHVycG9zZT7Qt9CwINGC0L7QstCw0YA8L1B1cnBvc2U+ +DQoJPC9EYXRhPg0KPC9QYXlEb2NSdT4= + + + +MIIGbQYJKoZIhvcNAQcCoIIGXjCCBloCAQExEDAOBgorBgEEAa1ZAQIBBQAwCwYJKoZIhvcNAQcBoIIEwDCCBLwwggRdoAMCAQICCwFsAwFbAwEPAh/mMA4GCisGAQQBrVkBAwIFADCB7jELMAkGA1UEBhMCUlUxFTATBgNVBAgeDAQcBD4EQQQ6BDIEMDEVMBMGA1UEBx4MBBwEPgRBBDoEMgQwMTUwMwYDVQQKHiwEHgQQBB4AIAQRBDAEPQQ6ACAAIgQkBBoAIAQeBEIEOgRABEsEQgQ4BDUAIjFfMF0GA1UEAx5WBCMENAQ+BEEEQgQ+BDIENQRABE8ETgRJBDgEOQAgBCYENQQ9BEIEQAAgBB4EEAQeACAEEQQwBD0EOgAgACIEJAQaACAEHgRCBDoEQARLBEIEOAQ1ACIxGTAXBgkqhkiG9w0BCQEWCnBraUBvZmMucnUwHhcNMTQxMDA2MDcxMDMyWhcNMTUxMjEwMDcxMDMyWjCB2jELMAkGA1UEBhMCUlUxFTATBgNVBAgeDAQcBB4EIQQaBBIEEDEpMCcGA1UECh4gBBgEHwAgBB8ENQRCBEAEPgQyBDgERwAgBDgAIAQaBD4xDzANBgNVBAseBgBEAEIATzFFMEMGA1UEDB48BBgEPQQ0BDgEMgQ4BDQEQwQwBDsETAQ9BEsEOQAgBD8EQAQ1BDQEPwRABDgEPQQ4BDwEMARCBDUEOwRMMTEwLwYDVQQDHigEHwQ1BEIEQAQ+BDIAIAQfBDUEQgRAACAEHwQ1BEIEQAQ+BDIEOARHMF4wFgYKKwYBBAGtWQEGAgYIKoZIzj0DAQcDRAAEQQSvMegoDmW20Br8eWAZipeFbWfUR7J7d/pdCiO8pMw2lfHX1Vjet7cTaiG0vQhwmD+TGIOh+FgRHBkMZXNVDl1Do4IB6TCCAeUwHQYDVR0OBBYEFC8QX0Ex2lQqSBZDVujpCceXCG/wMIIBJAYDVR0jBIIBGzCCAReAFEnfU+U9thXTPfDAbd2Z2TnrZfZZoYH0pIHxMIHuMQswCQYDVQQGEwJSVTEVMBMGA1UECB4MBBwEPgRBBDoEMgQwMRUwEwYDVQQHHgwEHAQ+/EhM5EcGNPvvvYXrX14rtH0Q7J7yOAV1ROmMxggFwMIIBbAIBATCB/jCB7jELMAkGA1UEBhMCUlUxRJBDgEOQAgBCYENQQ9BEIEQAAgBB4EEAQeACAEEQQwBD0EOgAgACIEJAQaACAEHgRCBDoEQARLBEIEOAQ1ACIxGTAXBgkqhkiG9w0BCQEWCnBraUBvZmMucnUCCwFsAwFbAwEPAh/mMA4GCisGAQQBrVkBAgEFADAMBgorBgEEAa1ZAQYCBEgwRgIhALJ4SDHfRVBq9egxlJiAC+tGHRNU7vg4AIUA8iS9qFmOAiEA5rdyEyuYZ5H46JjDNVJexcYmgCuDNpiU15rskCKDuVc= + + + + diff --git a/tests/Fixture/xml/1c/Statement.xml b/tests/Fixture/xml/1c/Statement.xml new file mode 100644 index 0000000..16599c0 --- /dev/null +++ b/tests/Fixture/xml/1c/Statement.xml @@ -0,0 +1,80 @@ + + + + + + 0 + 2016-05-04T00:00:00.000 + 2016-05-13T11:51:13.000 + 40702810500000000001 + + 044525888 + ДЕМО-БАНК" + МОСКВА + 30101810500000000219 + + 139280.91 + 88970.02 + + + + 768 + 2016-05-04 + 14 + + Торговый дом Комплексный + 7705260699 + 770501001 + 40702810500000000001 + + 044525888 + ДЕМО-БАНК + МОСКВА + 30101810500000000219 + + + + Индивидуальный предприниматель Иванов Иван Иванович + 0 + 0 + 40802810300020007955 + + 046577413 + ФИЛИАЛ ДЕМО-БАНК БТВ + ЕКАТЕРИНБУРГ + 30101810965770000413 + + + 5 + За транспортные услуги по счету № Ек2Тюм003221 от 18 Апреля 2016г. Сумма 2 999-00руб без НДС + + + 1 + 2016-05-04 + + 044525888 + ДЕМО-БАНК + МОСКВА + 30101810500000000219 + + 02 + Исполнен + Платежный документ исполнен банком + + + + + 044525888 + ДЕМО-БАНК + МОСКВА + 30101810500000000219 + + + 39f9553d-67b1-4314-a2b1-8bddc99e0f42 + \ No newline at end of file diff --git a/tests/Fixture/xml/1c/StatementRequest.xml b/tests/Fixture/xml/1c/StatementRequest.xml new file mode 100644 index 0000000..91ff6cf --- /dev/null +++ b/tests/Fixture/xml/1c/StatementRequest.xml @@ -0,0 +1,21 @@ + + + + + + 0 + 2016-04-21T00:00:00 + 2016-04-21T23:59:59 + 40702810500000000001 + + 044525888 + ДЕМО-БАНК + + + \ No newline at end of file diff --git a/tests/Fixture/xml/1c/readme.md b/tests/Fixture/xml/1c/readme.md new file mode 100644 index 0000000..309e891 --- /dev/null +++ b/tests/Fixture/xml/1c/readme.md @@ -0,0 +1,8 @@ +# Примеры 1С + +Официальные примеры из описания стандарта 1С:DirectBank, версия 2.2.2. +Файлы скопированы без изменений. + +- `Statement.xml`, `StatementRequest.xml` — +- `Packet.xml` — пример запроса SendPack из . + Подпись в примере обрезана и не является корректным base64, поэтому XSD файл не проходит. diff --git a/tests/Fixture/xml/statement_all_doc_kinds.xml b/tests/Fixture/xml/statement_all_doc_kinds.xml new file mode 100644 index 0000000..b5d484e --- /dev/null +++ b/tests/Fixture/xml/statement_all_doc_kinds.xml @@ -0,0 +1,69 @@ + + + + + +0 +2024-01-01T00:00:00 +2024-01-02T00:00:00 +40702810500000000001 +044525888 +100.10 +10 +20 +110.10 + +12024-01-011.50 +A044525888B0012345678044525888 +электронно01501P +011821010201001100011045000000ТПМС.01.2024000 + +12024-01-01e1 +044525888Br02Исполнен + + +22024-01-012 +A044525888B044525888P +152024-01-01 +22024-01-01 + + +32024-01-013 +A044525888B044525888P +1 +12024-01-01 + + +42024-01-014 +A044525888B044525888P +TC1011.0092024-01-01 +12024-01-01 + + +52024-01-015 +A044525888B044525888P +12024-01-01 + + +62024-01-01x044525888Br +d16xc2 +01xxtx +12024-01-01 + + +77PКомиссия +12024-01-01 + + +8AFI02S +22024-01-01 + + +9FAA0001 +
40zp4
535
+12024-01-01 +
+044525888Br +
+r1 +
diff --git a/tests/Fixture/xml/statement_minimal.xml b/tests/Fixture/xml/statement_minimal.xml new file mode 100644 index 0000000..66eca08 --- /dev/null +++ b/tests/Fixture/xml/statement_minimal.xml @@ -0,0 +1,6 @@ + + + +02024-01-02T00:00:00407028105000000000010445258880 +7K12024-01-01 + diff --git a/tests/Objects/PacketTest.php b/tests/Objects/PacketTest.php index 9b4b02c..28bc046 100644 --- a/tests/Objects/PacketTest.php +++ b/tests/Objects/PacketTest.php @@ -3,6 +3,7 @@ namespace TTBooking\DirectBank\Objects; +use Mapper\XmlModelMapper; use PHPUnit\Framework\TestCase; use Ramsey\Uuid\Uuid; use TTBooking\DirectBank\Fixture\PacketFixture; @@ -63,4 +64,111 @@ public function testStatementRequest() $this->assertTrue($dom->schemaValidate(__DIR__ . '/../Fixture/xsd/1C-Bank_StatementRequest.xsd')); } + + /** + * Официальный пример запроса выписки из описания стандарта 1С: + * разбирается и собирается обратно в XML, проходящий XSD + */ + public function testOfficialStatementRequest() + { + $request = (new XmlModelMapper())->map( + file_get_contents(__DIR__ . '/../Fixture/xml/1c/StatementRequest.xml'), + new StatementRequest() + ); + + $this->assertSame('da06dc8f-afbe-4172-89c8-0d4492c2dd25', $request->getId()); + $this->assertSame('7705260699', $request->getSender()->getInn()); + $this->assertSame('044525888', $request->getRecipient()->getBic()); + $this->assertSame('40702810500000000001', $request->getData()->getAccount()); + $this->assertSame('2016-04-21T00:00:00', $request->getData()->getDateFrom()); + + $dom = new \DOMDocument(); + $dom->loadXML($request->toXml()); + + $this->assertTrue($dom->schemaValidate(__DIR__ . '/../Fixture/xsd/1C-Bank_StatementRequest.xsd')); + } + + public function testMultipleDocuments() + { + $packet = PacketFixture::createPacket(); + $first = $packet->getDocument(); + $second = (new DocumentType()) + ->setId((string) Uuid::uuid4()) + ->setDockind($first->getDockind()) + ->setData($first->getData()); + + $packet->addDocument($second); + + $dom = new \DOMDocument(); + $dom->loadXML($packet->toXml()); + $this->assertTrue($dom->schemaValidate(__DIR__ . '/../Fixture/xsd/1C-Bank_Packet.xsd')); + $this->assertSame(2, $dom->getElementsByTagName('Document')->length); + + $mapped = (new XmlModelMapper())->map($packet->toXml(), new Packet()); + + $this->assertCount(2, $mapped->getDocuments()); + $this->assertSame($first->getId(), $mapped->getDocument()->getId()); + $this->assertSame($second->getId(), $mapped->getDocuments()[1]->getId()); + + $mapped = (new XmlModelMapper())->map(PacketFixture::createPacket()->toXml(), new Packet()); + + $this->assertCount(1, $mapped->getDocuments()); + } + + /** + * Пример контейнера с подписью из описания транспортного протокола 1С + */ + public function testOfficialPacketWithSignature() + { + $packet = (new XmlModelMapper())->map(file_get_contents(__DIR__ . '/../Fixture/xml/1c/Packet.xml'), new Packet()); + + $document = $packet->getDocument(); + $this->assertSame('a64225eb-9737-4d80-bd9d-1ffe5fdb63b1', $document->getId()); + $this->assertStringStartsWith('', base64_decode($document->getData())); + $this->assertNull($document->getFileName()); + + $signatures = $document->getSignatures(); + $this->assertCount(1, $signatures); + $this->assertSame('Удостоверяющий Центр Банка', $signatures[0]->getX509IssuerName()); + $this->assertSame('022C03015B03010F022FE2', $signatures[0]->getX509SerialNumber()); + $this->assertStringStartsWith('MIIGbQYJKoZIhvcNAQcC', trim($signatures[0]->getSignedData())); + } + + public function testDataAttributesAndSignature() + { + $packet = PacketFixture::createPacket(); + $data = $packet->getDocument()->getData(); + + $packet->getDocument() + ->setData($data, 'statement-request.xml', 'application/xml') + ->addSignature( + (new SignatureType()) + ->setX509IssuerName('CN=Test CA') + ->setX509SerialNumber('0A1B') + ->setSignedData(base64_encode('signature')) + ); + + $dom = new \DOMDocument(); + $dom->loadXML($packet->toXml()); + $this->assertTrue($dom->schemaValidate(__DIR__ . '/../Fixture/xsd/1C-Bank_Packet.xsd')); + + $document = (new XmlModelMapper())->map($packet->toXml(), new Packet())->getDocument(); + + $this->assertSame($data, $document->getData()); + $this->assertSame('statement-request.xml', $document->getFileName()); + $this->assertSame('application/xml', $document->getContentType()); + $this->assertCount(1, $document->getSignatures()); + $this->assertSame('0A1B', $document->getSignatures()[0]->getX509SerialNumber()); + $this->assertSame('signature', base64_decode($document->getSignatures()[0]->getSignedData())); + } + + public function testWithoutUserAgent() + { + $packet = PacketFixture::createPacket(); + + $this->assertNull((new Packet())->getUserAgent()); + $this->assertNull((new StatementRequest())->getUserAgent()); + $this->assertFalse($packet->getDocument()->isTestOnly()); + $this->assertFalse($packet->getDocument()->isCompressed()); + } } diff --git a/tests/Objects/StatementTest.php b/tests/Objects/StatementTest.php index d59e2e4..257e1ca 100644 --- a/tests/Objects/StatementTest.php +++ b/tests/Objects/StatementTest.php @@ -52,4 +52,139 @@ public function testFromXml() $this->assertEquals([], $statement->getData()->getOperationInfo()); } + + /** + * Официальный пример выписки из описания стандарта 1С + */ + public function testOfficialExample() + { + $statement = new Statement(); + $statement->mapFromXml(file_get_contents(__DIR__ . '/../Fixture/xml/1c/Statement.xml')); + + $this->assertSame('f7cbc6af-33dd-4c37-b67d-7400e1c327ad', $statement->getId()); + $this->assertSame('044525888', $statement->getSender()->getBic()); + $this->assertSame('7705260699', $statement->getRecipient()->getInn()); + $this->assertSame('39f9553d-67b1-4314-a2b1-8bddc99e0f42', $statement->getExtIDStatementRequest()); + + $data = $statement->getData(); + $this->assertSame('40702810500000000001', $data->getAccount()); + $this->assertSame('2016-05-04T00:00:00.000', $data->getDateFrom()); + $this->assertSame(139280.91, $data->getOpeningBalance()); + $this->assertSame(88970.02, $data->getClosingBalance()); + $this->assertNull($data->getTotalDebits()); + $this->assertSame('044525888', $data->getStamp()->getBic()); + $this->assertNull($data->getStamp()->getBranch()); + + $operations = $data->getOperationInfo(); + $this->assertCount(1, $operations); + $this->assertSame(1, $operations[0]->getDC()); + $this->assertSame('2016-05-04', $operations[0]->getDate()); + + $payDoc = $operations[0]->getPayDoc(); + $this->assertSame('768', $payDoc->getId()); + $this->assertSame('10', $payDoc->getDocKind()); + + $payDocRu = $payDoc->getPayDocRu(); + $this->assertSame('768', $payDocRu->getDocNo()); + $this->assertSame(14.0, $payDocRu->getSum()); + $this->assertSame('7705260699', $payDocRu->getPayer()->getINN()); + $this->assertSame('40802810300020007955', $payDocRu->getPayee()->getAccount()); + $this->assertSame('046577413', $payDocRu->getPayee()->getBank()->getBic()); + $this->assertSame('5', $payDocRu->getPriority()); + $this->assertStringStartsWith('За транспортные услуги', $payDocRu->getPurpose()); + $this->assertNull($payDocRu->getBudgetPaymentInfo()); + + $this->assertSame('DemoBankService', $statement->getUserAgent()); + + $status = $operations[0]->getStamp()->getStatus(); + $this->assertSame('02', $status->getCode()); + $this->assertSame('Исполнен', $status->getName()); + $this->assertSame('Платежный документ исполнен банком', $status->getMoreInfo()); + } + + /** + * Выписка только с обязательными по XSD элементами + */ + public function testMinimal() + { + $xml = file_get_contents(__DIR__ . '/../Fixture/xml/statement_minimal.xml'); + + $this->assertValid($xml); + + $statement = new Statement(); + $statement->mapFromXml($xml); + + $data = $statement->getData(); + $this->assertNull($data->getDateFrom()); + $this->assertNull($data->getOpeningBalance()); + $this->assertNull($data->getStamp()); + $this->assertSame(0.0, $data->getClosingBalance()); + + $operation = $data->getOperationInfo()[0]; + $this->assertNull($operation->getStamp()); + $this->assertNull($operation->getExtID()); + $this->assertSame(7.0, $operation->getPayDoc()->getInnerDoc()->getSum()); + } + + /** + * Выписка со всеми вариантами PayDoc и всеми необязательными элементами + */ + public function testAllDocKinds() + { + $xml = file_get_contents(__DIR__ . '/../Fixture/xml/statement_all_doc_kinds.xml'); + + $this->assertValid($xml); + + $statement = new Statement(); + $statement->mapFromXml($xml); + + $payDocs = []; + foreach ($statement->getData()->getOperationInfo() as $operation) { + $payDocs[$operation->getPayDoc()->getDocKind()] = $operation->getPayDoc(); + } + + $this->assertSame(['10', '11', '12', '17', '18', '16', '13', '24', '25'], array_map('strval', array_keys($payDocs))); + + $check = $payDocs['25']->getCheck(); + $this->assertSame(9.0, $check->getSum()); + $this->assertSame('0001', $check->getDataPrinting()->getCheckNumber()); + $this->assertCount(2, $check->getDetails()); + $this->assertSame('40', $check->getDetails()[0]->getSymbol()); + $this->assertSame('zp', $check->getDetails()[0]->getPurpose()); + $this->assertSame('53', $check->getDetails()[1]->getSymbol()); + $this->assertNull($check->getDetails()[1]->getPurpose()); + $this->assertSame(5.0, $check->getDetails()[1]->getSum()); + + $this->assertSame('Bank', $statement->getUserAgent()); + $this->assertSame('Комиссия', $payDocs['13']->getInnerDoc()->getInnerDocKind()); + $this->assertSame(7.0, $payDocs['13']->getInnerDoc()->getSum()); + $this->assertSame('Br', $statement->getData()->getStamp()->getBranch()); + } + + public function testCheckWithSingleDetails() + { + $xml = str_replace( + '
535
', + '', + file_get_contents(__DIR__ . '/../Fixture/xml/statement_all_doc_kinds.xml') + ); + + $statement = new Statement(); + $statement->mapFromXml($xml); + + foreach ($statement->getData()->getOperationInfo() as $operation) { + if ($check = $operation->getPayDoc()->getCheck()) { + $this->assertCount(1, $check->getDetails()); + $this->assertSame('40', $check->getDetails()[0]->getSymbol()); + } + } + } + + protected function assertValid(string $xml): void + { + $dom = new \DOMDocument(); + $dom->loadXML($xml); + + $this->assertTrue($dom->schemaValidate(__DIR__ . '/../Fixture/xsd/1C-Bank_Statement.xsd')); + } }