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`) и документов
внутри него: запрос выписки, выписка, извещение о состоянии обработки контейнера и др.
+[](https://github.com/ttbooking/DirectBank/actions/workflows/tests.yml)
[](https://packagist.org/packages/ttbooking/direct-bank)
[](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'));
+ }
}