diff --git a/README.md b/README.md
index 3f3ae07..dab5436 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,8 @@ $client = new Client([
'login' => 'user',
'password' => 'secret',
'apiVersion' => DefaultValue::FORMAT_VERSION, // по умолчанию '2.2.2'
+ 'availableApiVersion' => DefaultValue::FORMAT_VERSION, // заголовок AvailableAPIVersion в Logon, null — не передавать
+ 'userAgent' => null, // заголовок User-Agent, по умолчанию стандартный Guzzle
'sessionId' => null, // можно передать уже полученный SID
'verify' => true, // проверка SSL-сертификата
'handler' => null, // свой Guzzle handler, например MockHandler в тестах
@@ -45,8 +47,8 @@ $client = new Client([
```
Настройки проверяются в конструкторе: `url`, `customerId`, `login`, `password` и `apiVersion`
-должны быть непустыми строками, `sessionId` — строкой или `null`, `verify` — булевым значением
-или путём к CA-бандлу, `handler` — callable. Иначе выбрасывается
+должны быть непустыми строками, `availableApiVersion`, `userAgent` и `sessionId` — непустой строкой
+или `null`, `verify` — булевым значением или путём к CA-бандлу, `handler` — callable. Иначе выбрасывается
`TTBooking\DirectBank\Exceptions\InvalidSettingsException` (наследник `\InvalidArgumentException`)
с именем неверной настройки.
@@ -68,17 +70,51 @@ $client = new Client($settings, $logger); // Psr\Log\LoggerInterface
$sid = $client->createSession();
```
+Логин и пароль передаются только в `Logon`, остальные запросы идут с `SID`.
+
+### Вход с одноразовым паролем
+
+Если банк требует подтвердить вход одноразовым паролем (OTP), `createSession()` — и неявный
+вход перед первым запросом — выбрасывает `TTBooking\DirectBank\Exceptions\OtpRequiredException`.
+Банк в этот момент отправляет пароль клиенту, а вход подтверждается методом `confirmOtp()`:
+
+```php
+use TTBooking\DirectBank\Exceptions\OtpRequiredException;
+
+try {
+ $client->createSession();
+} catch (OtpRequiredException $e) {
+ $e->getPhoneMask(); // маска телефона, если банк её прислал, например '7916***6465'
+ $e->getSessionCode(); // короткий код сессии для показа пользователю, если есть
+
+ $client->confirmOtp($e->getSessionId(), $otpFromUser); // дальше запросы идут с авторизованным SID
+}
+```
+
### Методы клиента
| Метод | Запрос DirectBank | Результат |
|---|---|---|
| `createSession(): string` | `POST Logon` | идентификатор сессии (SID) |
+| `confirmOtp(string $sessionId, string $otp): string` | `POST LogonOTP` | идентификатор авторизованной сессии |
| `sendPack(Packet $packet): string` | `POST SendPack` | идентификатор принятого контейнера |
| `getPackList(?DateTimeInterface $date = null): ?array` | `GET GetPackList` | список идентификаторов контейнеров, готовых к получению |
+| `getPackListResponse(DateTimeInterface\|string\|null $since = null)` | `GET GetPackList` | список контейнеров и отметка времени последнего из них |
| `getPack(string $id): Packet` | `GET GetPack` | транспортный контейнер |
-Отметка времени для `getPackList()` задаётся по часам сервера банка и передаётся
-в формате `dd.MM.yyyy HH:mm:ss`.
+Отметка времени для списка контейнеров задаётся по часам сервера банка и передаётся
+в формате `dd.MM.yyyy HH:mm:ss`. Чтобы получать только новые контейнеры, передавайте
+в следующий запрос `TimeStampLastPacket` из предыдущего ответа:
+
+```php
+$list = $client->getPackListResponse($lastTimestamp); // null — все контейнеры
+
+foreach ($list->getPacketID() as $id) {
+ // $client->getPack($id) ...
+}
+
+$lastTimestamp = $list->getTimeStampLastPacket() ?? $lastTimestamp; // сохранить до следующего запроса
+```
### Ошибки
@@ -88,6 +124,9 @@ $sid = $client->createSession();
- `TTBooking\DirectBank\Exceptions\UnexpectedResponseException` (наследник `ClientException`) —
ответ не удалось разобрать или в нём нет ожидаемых данных. HTTP-ответ — `getResponse()`,
`getCode()` — HTTP-статус.
+- `TTBooking\DirectBank\Exceptions\OtpRequiredException` (наследник `ClientException`) —
+ банк требует подтвердить вход одноразовым паролем, см. выше.
+- Коды ошибок банка — константы `TTBooking\DirectBank\Dictionary\ErrorCode`.
- Сетевые ошибки (нет соединения, таймаут) пробрасываются как исключения Guzzle.
## Примеры
@@ -148,37 +187,78 @@ $packetId = $client->sendPack($packet);
```php
use Mapper\XmlModelMapper;
use TTBooking\DirectBank\Dictionary\DocKind;
-use TTBooking\DirectBank\Objects\{Statement, StatusPacketNotice};
+use TTBooking\DirectBank\Dictionary\DocStatus;
+use TTBooking\DirectBank\Objects\{Settings, Statement, StatusDocNotice, StatusPacketNotice};
$mapper = new XmlModelMapper();
foreach ($client->getPackList() ?? [] as $id) {
$pack = $client->getPack($id);
- $xml = base64_decode($pack->getDocument()->getData());
-
- $document = match ($pack->getDocument()->getDockind()) {
- DocKind::SHIPPING_CONTAINER_HANDLING_STATUS_NOTIFICATION => $mapper->map($xml, new StatusPacketNotice()),
- DocKind::BANK_STATEMENT => $mapper->map($xml, new Statement()),
- default => null,
- };
-
- if ($document instanceof Statement) {
- $data = $document->getData();
- $data->getClosingBalance();
- foreach ($data->getOperationInfo() as $operation) {
- // ...
+
+ foreach ($pack->getDocuments() as $packDocument) {
+ $xml = base64_decode($packDocument->getData());
+
+ $document = match ($packDocument->getDockind()) {
+ DocKind::STATUS_PACKET_NOTICE => $mapper->map($xml, new StatusPacketNotice()),
+ DocKind::STATUS_DOC_NOTICE => $mapper->map($xml, new StatusDocNotice()),
+ DocKind::SETTINGS => $mapper->map($xml, new Settings()),
+ DocKind::BANK_STATEMENT => $mapper->map($xml, new Statement()),
+ default => null,
+ };
+
+ if ($document instanceof StatusDocNotice) {
+ $document->getExtID(); // документ, о котором извещение
+ $document->getResult()->getStatus()?->getCode(); // DocStatus::EXECUTED и т.д.
+ $document->getResult()->getError()?->getDescription(); // или ошибка обработки
+ }
+
+ if ($document instanceof Statement) {
+ $data = $document->getData();
+ $data->getClosingBalance();
+ foreach ($data->getOperationInfo() as $operation) {
+ // ...
+ }
}
}
}
```
+### Служебные документы
+
+Исходящие служебные документы собираются так же, как запрос выписки, и передаются
+в контейнере с соответствующим видом:
+
+| Документ | Класс | Вид ЭД |
+|---|---|---|
+| Запрос о состоянии электронного документа | `StatusRequest` (`setExtID()` — ИД документа) | `DocKind::STATUS_REQUEST` |
+| Запрос-зонд | `Probe` | `DocKind::PROBE` |
+
+Входящие: `StatusPacketNotice` (`01`), `StatusDocNotice` (`02`), `Settings` (`06`), `Statement` (`15`).
+
+## Справочники
+
+Классификаторы стандарта в пространстве имён `TTBooking\DirectBank\Dictionary`:
+
+| Класс | Содержимое |
+|---|---|
+| `DocKind` | коды видов электронных документов, `DocKind::REQUIRED` — обязательные |
+| `DocStatus` | коды статусов электронных документов |
+| `PacketStatus` | коды статусов транспортных контейнеров |
+| `StatementType` | типы выписок |
+| `ErrorCode` | коды ошибок банковского сервиса |
+
## Виды документов
Константы `TTBooking\DirectBank\Dictionary\DocKind`:
| Константа | Код | Документ |
|---|---|---|
-| `SHIPPING_CONTAINER_HANDLING_STATUS_NOTIFICATION` | `01` | Извещение о состоянии обработки транспортного контейнера |
+| `STATUS_PACKET_NOTICE` | `01` | Извещение о состоянии обработки транспортного контейнера |
+| `STATUS_DOC_NOTICE` | `02` | Извещение о состоянии электронного документа * |
+| `STATUS_REQUEST` | `03` | Запрос о состоянии электронного документа * |
+| `CANCELATION_REQUEST` | `04` | Запрос об отзыве электронного документа |
+| `PROBE` | `05` | Запрос-зонд * |
+| `SETTINGS` | `06` | Настройки обмена с банком * |
| `PAY_DOC_RU` | `10` | Платёжное поручение |
| `PAY_REQUEST` | `11` | Платёжное требование |
| `COLLECTION_ORDER` | `12` | Инкассовое поручение |
@@ -188,8 +268,13 @@ foreach ($client->getPackList() ?? [] as $id) {
| `MEM_ORDER` | `16` | Мемориальный ордер |
| `PAYMENT_ORDER` | `17` | Платёжный ордер |
| `BANK_ORDER` | `18` | Банковский ордер |
+| `WAGES_*` | `19`–`23` | Документы зарплатного проекта |
| `CASH_CONTRIBUTION` | `24` | Объявление на взнос наличными |
| `CHECK` | `25` | Денежный чек |
+| `CURRENCY_TRANSFER_ORDER` | `30` | Поручение на перевод валюты |
+| `CURRENCY_STATEMENT` | `35` | Выписка по валютному счёту |
+
+\* обязательные по стандарту. `SHIPPING_CONTAINER_HANDLING_STATUS_NOTIFICATION` — прежнее имя `STATUS_PACKET_NOTICE`.
XSD-схемы формата лежат в [`tests/Fixture/xsd`](tests/Fixture/xsd).
diff --git a/src/Client.php b/src/Client.php
index d2b5e75..8a7b3b1 100644
--- a/src/Client.php
+++ b/src/Client.php
@@ -16,15 +16,23 @@
use GuzzleHttp\Client as HttpClient;
use TTBooking\DirectBank\Exceptions\ClientException;
use TTBooking\DirectBank\Exceptions\InvalidSettingsException;
+use TTBooking\DirectBank\Exceptions\OtpRequiredException;
use TTBooking\DirectBank\Exceptions\UnexpectedResponseException;
+use TTBooking\DirectBank\Objects\GetPacketListResponseType;
use TTBooking\DirectBank\Objects\Packet;
use TTBooking\DirectBank\Objects\ResultBank;
class Client implements ClientInterface
{
+ //Методы аутентификации: выполняются вне сессии
+ const AUTHENTICATION_METHODS = ['Logon', 'LogonOTP'];
+
protected array $settings = [
'customerId' => null,
'apiVersion' => DefaultValue::FORMAT_VERSION,
+ // Максимальная версия API, которую поддерживает клиент (заголовок AvailableAPIVersion в Logon)
+ 'availableApiVersion' => DefaultValue::FORMAT_VERSION,
+ 'userAgent' => null,
'sessionId' => null,
'login' => null,
'password' => null,
@@ -38,6 +46,9 @@ public function __construct(array $settings, private ?LoggerInterface $logger =
$this->validateSettings($this->settings);
}
+ /**
+ * @throws \TTBooking\DirectBank\Exceptions\OtpRequiredException банк требует подтвердить вход одноразовым паролем
+ */
public function createSession(): string
{
$result = $this->invoke('POST', 'Logon');
@@ -45,7 +56,28 @@ public function createSession(): string
$response = $result->getSuccess()->getLogonResponse()
?? throw new UnexpectedResponseException('Bank response to Logon has no LogonResponse.');
- return $response->getSID();
+ if ($response->isOtpRequired()) {
+ throw OtpRequiredException::fromLogonResponse($response);
+ }
+
+ return $this->settings['sessionId'] = $response->getSID();
+ }
+
+ /**
+ * Подтверждение входа одноразовым паролем (LogonOTP)
+ *
+ * @param string $sessionId идентификатор неавторизованной сессии из OtpRequiredException
+ * @param string $otp одноразовый пароль, полученный клиентом
+ * @return string идентификатор авторизованной сессии
+ */
+ public function confirmOtp(string $sessionId, string $otp): string
+ {
+ $result = $this->invoke('POST', 'LogonOTP', headers: ['sid' => $sessionId, 'otp' => $otp]);
+
+ $response = $result->getSuccess()->getLogonResponse()
+ ?? throw new UnexpectedResponseException('Bank response to LogonOTP has no LogonResponse.');
+
+ return $this->settings['sessionId'] = $response->getSID();
}
public function sendPack(Packet $packet): string
@@ -63,9 +95,34 @@ public function sendPack(Packet $packet): string
*/
public function getPackList(?\DateTimeInterface $dateTime = null): ?array
{
- $result = $this->invoke('GET', 'GetPackList', query: ['date' => $dateTime?->format(DefaultValue::TIMESTAMP_FORMAT)]);
+ return $this->requestPackList($dateTime)?->getPacketID();
+ }
+
+ /**
+ * Список контейнеров вместе с отметкой времени последнего из них.
+ *
+ * @param \DateTimeInterface|string|null $since отметка времени по часам сервера банка,
+ * например TimeStampLastPacket из предыдущего ответа
+ */
+ public function getPackListResponse(\DateTimeInterface|string|null $since = null): GetPacketListResponseType
+ {
+ if (is_string($since)) {
+ try {
+ $since = new \DateTimeImmutable($since);
+ } catch (\Exception $e) {
+ throw new \InvalidArgumentException(sprintf('Invalid timestamp "%s".', $since), 0, $e);
+ }
+ }
+
+ return $this->requestPackList($since) ?? new GetPacketListResponseType();
+ }
+
+ protected function requestPackList(?\DateTimeInterface $since): ?GetPacketListResponseType
+ {
+ // Время сервера банка передаётся как есть, без перевода в другой часовой пояс
+ $result = $this->invoke('GET', 'GetPackList', query: ['date' => $since?->format(DefaultValue::TIMESTAMP_FORMAT)]);
- return $result->getSuccess()->getGetPacketListResponse()?->getPacketID();
+ return $result->getSuccess()->getGetPacketListResponse();
}
public function getPack(string $uid): Packet
@@ -87,8 +144,10 @@ protected function validateSettings(array $settings): void
}
}
- if (! is_null($settings['sessionId']) && ! is_string($settings['sessionId'])) {
- throw new InvalidSettingsException('Setting "sessionId" must be a string or null.');
+ foreach (['sessionId', 'availableApiVersion', 'userAgent'] as $key) {
+ if (! is_null($settings[$key]) && (! is_string($settings[$key]) || $settings[$key] === '')) {
+ throw new InvalidSettingsException(sprintf('Setting "%s" must be a non-empty string or null.', $key));
+ }
}
if (isset($settings['verify']) && ! is_bool($settings['verify']) && ! is_string($settings['verify'])) {
@@ -106,20 +165,22 @@ protected function validateSettings(array $settings): void
* @throws \GuzzleHttp\Exception\GuzzleException
* @throws \TTBooking\DirectBank\Exceptions\ClientException
*/
- protected function invoke(string $method, string $path, ?string $body = null, array $query = [], bool $reauthenticate = true): ResultBank
+ protected function invoke(string $method, string $path, ?string $body = null, array $query = [], bool $reauthenticate = true, array $headers = []): ResultBank
{
- $client = $this->getHttpClient($this->settings, $path !== 'Logon');
+ $withSession = ! in_array($path, self::AUTHENTICATION_METHODS, true);
- $response = $client->request($method, $path, ['body' => $body, 'query' => $query]);
+ $client = $this->getHttpClient($this->settings, $withSession, $path === 'Logon');
+
+ $response = $client->request($method, $path, ['body' => $body, 'query' => $query, 'headers' => $headers]);
$result = $this->parseResult($response);
if ($error = $result?->getError()) {
// Сессия истекла или недействительна: входим заново и повторяем запрос один раз
- if ($reauthenticate && $path !== 'Logon' && in_array($error->getCode(), ErrorCode::REAUTHENTICATE, true)) {
+ if ($reauthenticate && $withSession && in_array($error->getCode(), ErrorCode::REAUTHENTICATE, true)) {
$this->settings['sessionId'] = null;
- return $this->invoke($method, $path, $body, $query, false);
+ return $this->invoke($method, $path, $body, $query, false, $headers);
}
throw ClientException::fromError($error);
@@ -167,8 +228,14 @@ protected function parseResult(ResponseInterface $response): ?ResultBank
return $result;
}
- protected function getHttpClient(array $settings, $withAuth = false): HttpClient
+ /**
+ * @param bool $withSession запрос в рамках сессии: SID, при отсутствии — предварительный Logon
+ * @param bool|null $withCredentials логин, пароль и AvailableAPIVersion (Logon); по умолчанию — для запросов вне сессии
+ */
+ protected function getHttpClient(array $settings, $withSession = false, ?bool $withCredentials = null): HttpClient
{
+ $withCredentials ??= ! $withSession;
+
$handler = $this->settings['handler'] ?? new CurlHandler();
$stack = HandlerStack::create($handler);
@@ -176,39 +243,46 @@ protected function getHttpClient(array $settings, $withAuth = false): HttpClient
$stack->push(Middleware::log($this->logger, new MessageFormatter(MessageFormatter::DEBUG)));
}
- if ($withAuth) {
+ if ($withSession) {
$stack->push(Middleware::mapRequest(function (RequestInterface $request) {
if(! $request->hasHeader('sid')) {
- $sessionId = $this->createSession();
- $this->settings['sessionId'] = $sessionId;
-
- return $request->withHeader('sid', $sessionId);
+ return $request->withHeader('sid', $this->createSession());
}
return $request;
}));
}
$headers = [
- 'Content-Type' => 'application/xml',
+ 'Content-Type' => 'application/xml; charset=utf-8',
'Accept' => 'application/xml',
'customerid' => $settings['customerId'],
'apiversion' => $settings['apiVersion'],
];
- if ($settings['sessionId'] ?? null) {
- $headers['sid'] = $settings['sessionId'];
+ if (isset($settings['userAgent'])) {
+ $headers['User-Agent'] = $settings['userAgent'];
}
- return new HttpClient([
+ $config = [
'base_uri' => $settings['url'],
- 'headers' => $headers,
- 'auth' => [
- $settings['login'],
- $settings['password'],
- ],
'verify' => $this->settings['verify'] ?? true,
'http_errors' => false,
'handler' => $stack,
- ]);
+ ];
+
+ // Логин и пароль передаются только при аутентификации, дальше запросы идут с SID
+ if ($withSession && ($settings['sessionId'] ?? null)) {
+ $headers['sid'] = $settings['sessionId'];
+ }
+
+ if ($withCredentials) {
+ if (isset($settings['availableApiVersion'])) {
+ $headers['availableapiversion'] = $settings['availableApiVersion'];
+ }
+
+ $config['auth'] = [$settings['login'], $settings['password']];
+ }
+
+ return new HttpClient(['headers' => $headers] + $config);
}
}
\ No newline at end of file
diff --git a/src/ClientInterface.php b/src/ClientInterface.php
index 347c66f..e84cc91 100644
--- a/src/ClientInterface.php
+++ b/src/ClientInterface.php
@@ -3,15 +3,20 @@
namespace TTBooking\DirectBank;
+use TTBooking\DirectBank\Objects\GetPacketListResponseType;
use TTBooking\DirectBank\Objects\Packet;
interface ClientInterface
{
public function createSession(): string;
+ public function confirmOtp(string $sessionId, string $otp): string;
+
public function sendPack(Packet $packet): string;
public function getPackList(?\DateTimeInterface $dateTime = null): ?array;
+ public function getPackListResponse(\DateTimeInterface|string|null $since = null): GetPacketListResponseType;
+
public function getPack(string $uid) : Packet;
-}
\ No newline at end of file
+}
diff --git a/src/Dictionary/DocKind.php b/src/Dictionary/DocKind.php
index 5acc05b..f121812 100644
--- a/src/Dictionary/DocKind.php
+++ b/src/Dictionary/DocKind.php
@@ -4,31 +4,72 @@
namespace TTBooking\DirectBank\Dictionary;
+/**
+ * Коды видов электронных документов
+ *
+ * @see https://github.com/1C-Company/DirectBank/blob/2.2.2/doc/common-section/tables.md#ed
+ */
class DocKind
{
//Извещение о состоянии обработки транспортного контейнера (1С <-- Банк)
- const SHIPPING_CONTAINER_HANDLING_STATUS_NOTIFICATION = '01';
- //Платежное поручение (1С --> Банк, в выписке)
+ const STATUS_PACKET_NOTICE = '01';
+ const SHIPPING_CONTAINER_HANDLING_STATUS_NOTIFICATION = self::STATUS_PACKET_NOTICE;
+ //Извещение о состоянии электронного документа (1С <-- Банк), обязательный
+ const STATUS_DOC_NOTICE = '02';
+ //Запрос о состоянии электронного документа (1С --> Банк), обязательный
+ const STATUS_REQUEST = '03';
+ //Запрос об отзыве электронного документа (1С --> Банк)
+ const CANCELATION_REQUEST = '04';
+ //Запрос-зонд (1С --> Банк), обязательный
+ const PROBE = '05';
+ //Настройки обмена с банком (1С <-- Банк), обязательный
+ const SETTINGS = '06';
+
+ //Платежное поручение (1С --> Банк)
const PAY_DOC_RU = '10';
- //Платежное требование (1С --> Банк, в выписке)
+ //Платежное требование (1С --> Банк)
const PAY_REQUEST = '11';
//Инкассовое поручение (в выписке)
const COLLECTION_ORDER = '12';
//Внутренний банковский документ (в выписке)
const INNER_DOC = '13';
- //Запрос выписки банка (1С <-- Банк)
+ //Запрос выписки банка (1С --> Банк)
const BANK_STATEMENT_REQUEST = '14';
+ //Выписка банка (1С <-- Банк)
+ const BANK_STATEMENT = '15';
//Мемориальный ордер (в выписке)
const MEM_ORDER = '16';
//Платежный ордер (в выписке)
const PAYMENT_ORDER = '17';
//Банковский ордер (в выписке)
const BANK_ORDER = '18';
- //Выписка банка (1С <-- Банк)
- const BANK_STATEMENT = '15';
- //Объявление на взнос наличными
+
+ //Список на открытие счетов по зарплатному проекту (1С --> Банк)
+ const WAGES_ACCOUNT_OPENING_LIST = '19';
+ //Подтверждение открытия счетов по зарплатному проекту (1С <-- Банк)
+ const WAGES_ACCOUNT_OPENING_CONFIRMATION = '20';
+ //Список на зачисление денежных средств на счета сотрудников (1С --> Банк)
+ const WAGES_CREDIT_LIST = '21';
+ //Подтверждение зачисления денежных средств на счета сотрудников (1С <-- Банк)
+ const WAGES_CREDIT_CONFIRMATION = '22';
+ //Список уволенных сотрудников (1С --> Банк)
+ const WAGES_DISMISSED_LIST = '23';
+
+ //Объявление на взнос наличными (в выписке)
const CASH_CONTRIBUTION = '24';
- //Денежный чек
+ //Денежный чек (в выписке)
const CHECK = '25';
-}
\ No newline at end of file
+ //Поручение на перевод валюты (1С --> Банк)
+ const CURRENCY_TRANSFER_ORDER = '30';
+ //Выписка по валютному счету (1С <-- Банк)
+ const CURRENCY_STATEMENT = '35';
+
+ //Обязательные виды электронных документов
+ const REQUIRED = [
+ self::STATUS_DOC_NOTICE,
+ self::STATUS_REQUEST,
+ self::PROBE,
+ self::SETTINGS,
+ ];
+}
diff --git a/src/Dictionary/DocStatus.php b/src/Dictionary/DocStatus.php
new file mode 100644
index 0000000..75a9a52
--- /dev/null
+++ b/src/Dictionary/DocStatus.php
@@ -0,0 +1,26 @@
+getSessionId(), $otp).
+ */
+class OtpRequiredException extends ClientException
+{
+ protected string $sessionId;
+
+ protected ?OtpType $otp = null;
+
+ public static function fromLogonResponse(LogonResponseType $response): static
+ {
+ $exception = new static('Bank requires one-time password (OTP) to complete Logon.');
+ $exception->sessionId = $response->getSID();
+ $exception->otp = $response->getExtraAuth()?->getOTP();
+
+ return $exception;
+ }
+
+ /**
+ * Идентификатор неавторизованной сессии
+ */
+ public function getSessionId(): string
+ {
+ return $this->sessionId;
+ }
+
+ /**
+ * Маска телефона или номер клиента
+ */
+ public function getPhoneMask(): ?string
+ {
+ return $this->otp?->getPhoneMask();
+ }
+
+ /**
+ * Короткий код сессии, который показывается пользователю при вводе OTP
+ */
+ public function getSessionCode(): ?string
+ {
+ return $this->otp?->getCode();
+ }
+}
diff --git a/src/Objects/CryptoEncryptedType.php b/src/Objects/CryptoEncryptedType.php
new file mode 100644
index 0000000..a95e7ee
--- /dev/null
+++ b/src/Objects/CryptoEncryptedType.php
@@ -0,0 +1,19 @@
+EncryptAlgorithm;
+ }
+}
diff --git a/src/Objects/CryptoParametersType.php b/src/Objects/CryptoParametersType.php
new file mode 100644
index 0000000..99b7b78
--- /dev/null
+++ b/src/Objects/CryptoParametersType.php
@@ -0,0 +1,83 @@
+CSPName;
+ }
+
+ public function getCSPType(): int
+ {
+ return $this->CSPType;
+ }
+
+ public function getSignAlgorithm(): string
+ {
+ return $this->SignAlgorithm;
+ }
+
+ public function getHashAlgorithm(): string
+ {
+ return $this->HashAlgorithm;
+ }
+
+ public function getEncrypted(): ?CryptoEncryptedType
+ {
+ return $this->Encrypted;
+ }
+
+ public function getBankTrustedRootCertificate(): ?string
+ {
+ return $this->BankTrustedRootCertificate;
+ }
+
+ public function getBankCertificate(): ?string
+ {
+ return $this->BankCertificate;
+ }
+
+ public function getCustomerSignature(): CustomerSignatureType
+ {
+ return $this->CustomerSignature;
+ }
+
+ public function getURLAddinInfo(): ?string
+ {
+ return $this->URLAddinInfo;
+ }
+}
diff --git a/src/Objects/CustomerSignatureType.php b/src/Objects/CustomerSignatureType.php
new file mode 100644
index 0000000..63c63ea
--- /dev/null
+++ b/src/Objects/CustomerSignatureType.php
@@ -0,0 +1,24 @@
+GroupSignatures;
+ }
+}
diff --git a/src/Objects/DocumentSignedType.php b/src/Objects/DocumentSignedType.php
new file mode 100644
index 0000000..5609e1b
--- /dev/null
+++ b/src/Objects/DocumentSignedType.php
@@ -0,0 +1,19 @@
+RuleSignatures;
+ }
+}
diff --git a/src/Objects/ExtraAuthType.php b/src/Objects/ExtraAuthType.php
new file mode 100644
index 0000000..9a377f8
--- /dev/null
+++ b/src/Objects/ExtraAuthType.php
@@ -0,0 +1,18 @@
+OTP;
+ }
+}
diff --git a/src/Objects/GetPacketListResponseType.php b/src/Objects/GetPacketListResponseType.php
index dbb3f79..4376f84 100644
--- a/src/Objects/GetPacketListResponseType.php
+++ b/src/Objects/GetPacketListResponseType.php
@@ -16,11 +16,20 @@ class GetPacketListResponseType
*/
protected array $PacketID = [];
+ /**
+ * Отметка времени последнего контейнера в списке (xsd:dateTime, по часам сервера банка).
+ * Передаётся в следующий запрос списка, чтобы получить только новые контейнеры.
+ */
public function getTimeStampLastPacket(): ?string
{
return $this->TimeStampLastPacket;
}
+ public function getTimeStampLastPacketDateTime(): ?\DateTimeImmutable
+ {
+ return $this->TimeStampLastPacket === null ? null : new \DateTimeImmutable($this->TimeStampLastPacket);
+ }
+
/**
* @return string[]
*/
diff --git a/src/Objects/GroupSignaturesType.php b/src/Objects/GroupSignaturesType.php
new file mode 100644
index 0000000..b4b4fc0
--- /dev/null
+++ b/src/Objects/GroupSignaturesType.php
@@ -0,0 +1,36 @@
+numberGroup;
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getCertificates(): array
+ {
+ return $this->Certificate;
+ }
+}
diff --git a/src/Objects/LogonResponseType.php b/src/Objects/LogonResponseType.php
index d418bcb..9407a12 100644
--- a/src/Objects/LogonResponseType.php
+++ b/src/Objects/LogonResponseType.php
@@ -8,6 +8,8 @@ class LogonResponseType
{
protected string $SID;
+ protected ?ExtraAuthType $ExtraAuth = null;
+
/**
* @return string
*/
@@ -16,5 +18,18 @@ public function getSID(): string
return $this->SID;
}
+ public function getExtraAuth(): ?ExtraAuthType
+ {
+ return $this->ExtraAuth;
+ }
-}
\ No newline at end of file
+ /**
+ * Банк требует подтвердить вход одноразовым паролем: SID ещё не авторизован.
+ * OTP — единственный вариант ExtraAuth, а пустой маппер не разбирает,
+ * поэтому признаком служит сам ExtraAuth.
+ */
+ public function isOtpRequired(): bool
+ {
+ return $this->ExtraAuth !== null;
+ }
+}
diff --git a/src/Objects/OtpType.php b/src/Objects/OtpType.php
new file mode 100644
index 0000000..a8d6817
--- /dev/null
+++ b/src/Objects/OtpType.php
@@ -0,0 +1,37 @@
+phoneMask;
+ }
+
+ /**
+ * Короткий код сессии, который показывается пользователю при вводе OTP
+ */
+ public function getCode(): ?string
+ {
+ return $this->code;
+ }
+}
diff --git a/src/Objects/Probe.php b/src/Objects/Probe.php
new file mode 100644
index 0000000..09a7c1e
--- /dev/null
+++ b/src/Objects/Probe.php
@@ -0,0 +1,124 @@
+ Банк, вид ЭД 05)
+ *
+ * @xmlRoot Probe
+ * @xmlEncoding utf-8
+ */
+class Probe implements \Stringable
+{
+ use ConvertibleTrait;
+
+ /**
+ * @xmlAttribute
+ */
+ protected string $xmlns = 'http://directbank.1c.ru/XMLSchema';
+
+ /**
+ * @xmlAttribute
+ */
+ protected string $id;
+
+ /**
+ * @xmlAttribute
+ */
+ protected string $formatVersion = DefaultValue::FORMAT_VERSION;
+
+ /**
+ * @xmlAttribute
+ */
+ protected string $creationDate;
+
+ /**
+ * @xmlAttribute
+ */
+ protected ?string $userAgent = null;
+
+ /**
+ * @name Sender
+ */
+ protected CustomerPartyType $sender;
+
+ /**
+ * @name Recipient
+ */
+ protected BankPartyType $recipient;
+
+ public function getId(): string
+ {
+ return $this->id;
+ }
+
+ public function setId(string $id): Probe
+ {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function getFormatVersion(): string
+ {
+ return $this->formatVersion;
+ }
+
+ public function setFormatVersion(string $formatVersion): Probe
+ {
+ $this->formatVersion = $formatVersion;
+ return $this;
+ }
+
+ public function getCreationDate(): string
+ {
+ return $this->creationDate;
+ }
+
+ public function setCreationDate(string $creationDate): Probe
+ {
+ $this->creationDate = $creationDate;
+ return $this;
+ }
+
+ public function getUserAgent(): ?string
+ {
+ return $this->userAgent;
+ }
+
+ public function setUserAgent(?string $userAgent): Probe
+ {
+ $this->userAgent = $userAgent;
+ return $this;
+ }
+
+ public function getSender(): CustomerPartyType
+ {
+ return $this->sender;
+ }
+
+ public function setSender(CustomerPartyType $sender): Probe
+ {
+ $this->sender = $sender;
+ return $this;
+ }
+
+ public function getRecipient(): BankPartyType
+ {
+ return $this->recipient;
+ }
+
+ public function setRecipient(BankPartyType $recipient): Probe
+ {
+ $this->recipient = $recipient;
+ return $this;
+ }
+
+ public function __toString()
+ {
+ return $this->toXml();
+ }
+}
diff --git a/src/Objects/ReceiptStatementType.php b/src/Objects/ReceiptStatementType.php
new file mode 100644
index 0000000..f888a43
--- /dev/null
+++ b/src/Objects/ReceiptStatementType.php
@@ -0,0 +1,27 @@
+Login;
+ }
+
+ public function getInstructions(): string
+ {
+ return $this->Instructions;
+ }
+}
diff --git a/src/Objects/Settings.php b/src/Objects/Settings.php
new file mode 100644
index 0000000..18a2f21
--- /dev/null
+++ b/src/Objects/Settings.php
@@ -0,0 +1,79 @@
+id;
+ }
+
+ public function getFormatVersion(): string
+ {
+ return $this->formatVersion;
+ }
+
+ public function getCreationDate(): string
+ {
+ return $this->creationDate;
+ }
+
+ public function getUserAgent(): ?string
+ {
+ return $this->userAgent;
+ }
+
+ public function getSender(): BankPartyType
+ {
+ return $this->Sender;
+ }
+
+ public function getRecipient(): CustomerPartyType
+ {
+ return $this->Recipient;
+ }
+
+ public function getData(): SettingsDataType
+ {
+ return $this->Data;
+ }
+}
diff --git a/src/Objects/SettingsCertificateLogonType.php b/src/Objects/SettingsCertificateLogonType.php
new file mode 100644
index 0000000..648f769
--- /dev/null
+++ b/src/Objects/SettingsCertificateLogonType.php
@@ -0,0 +1,19 @@
+EncryptingAlgorithm;
+ }
+}
diff --git a/src/Objects/SettingsDataType.php b/src/Objects/SettingsDataType.php
new file mode 100644
index 0000000..4c52bb1
--- /dev/null
+++ b/src/Objects/SettingsDataType.php
@@ -0,0 +1,99 @@
+CustomerID;
+ }
+
+ public function getBankServerAddress(): string
+ {
+ return $this->BankServerAddress;
+ }
+
+ public function getFormatVersion(): string
+ {
+ return $this->FormatVersion;
+ }
+
+ public function getEncoding(): string
+ {
+ return $this->Encoding ?? 'UTF-8';
+ }
+
+ public function isCompress(): bool
+ {
+ return $this->Compress ?? false;
+ }
+
+ public function getLogon(): SettingsLogonType
+ {
+ return $this->Logon;
+ }
+
+ public function getCryptoParameters(): ?CryptoParametersType
+ {
+ return $this->CryptoParameters;
+ }
+
+ /**
+ * @return SettingsDocumentType[]
+ */
+ public function getDocuments(): array
+ {
+ return $this->Document;
+ }
+
+ /**
+ * Коды видов электронных документов, которыми возможен обмен с банком
+ *
+ * @return string[]
+ */
+ public function getDocKinds(): array
+ {
+ return array_map(fn(SettingsDocumentType $document) => $document->getDocKind(), $this->Document);
+ }
+
+ public function getReceiptStatement(): ?ReceiptStatementType
+ {
+ return $this->ReceiptStatement;
+ }
+}
diff --git a/src/Objects/SettingsDocumentType.php b/src/Objects/SettingsDocumentType.php
new file mode 100644
index 0000000..6c6eb38
--- /dev/null
+++ b/src/Objects/SettingsDocumentType.php
@@ -0,0 +1,29 @@
+docKind;
+ }
+
+ public function getSigned(): ?DocumentSignedType
+ {
+ return $this->Signed;
+ }
+}
diff --git a/src/Objects/SettingsLoginType.php b/src/Objects/SettingsLoginType.php
new file mode 100644
index 0000000..f178892
--- /dev/null
+++ b/src/Objects/SettingsLoginType.php
@@ -0,0 +1,19 @@
+User;
+ }
+}
diff --git a/src/Objects/SettingsLogonType.php b/src/Objects/SettingsLogonType.php
new file mode 100644
index 0000000..3a9293a
--- /dev/null
+++ b/src/Objects/SettingsLogonType.php
@@ -0,0 +1,31 @@
+Login;
+ }
+
+ /**
+ * Аутентификация по сертификату электронной подписи
+ */
+ public function getCertificate(): ?SettingsCertificateLogonType
+ {
+ return $this->Certificate;
+ }
+}
diff --git a/src/Objects/StatusDocNotice.php b/src/Objects/StatusDocNotice.php
new file mode 100644
index 0000000..2a63909
--- /dev/null
+++ b/src/Objects/StatusDocNotice.php
@@ -0,0 +1,95 @@
+id;
+ }
+
+ public function getFormatVersion(): string
+ {
+ return $this->formatVersion;
+ }
+
+ public function getCreationDate(): string
+ {
+ return $this->creationDate;
+ }
+
+ public function getUserAgent(): ?string
+ {
+ return $this->userAgent;
+ }
+
+ public function getSender(): ParticipantType
+ {
+ return $this->Sender;
+ }
+
+ public function getRecipient(): ParticipantType
+ {
+ return $this->Recipient;
+ }
+
+ public function getExtID(): string
+ {
+ return $this->ExtID;
+ }
+
+ public function getResult(): ResultStatusType
+ {
+ return $this->Result;
+ }
+
+ public function getExtIDStatusRequest(): ?string
+ {
+ return $this->ExtIDStatusRequest;
+ }
+}
diff --git a/src/Objects/StatusPacketNotice.php b/src/Objects/StatusPacketNotice.php
index 058287f..b3c6791 100644
--- a/src/Objects/StatusPacketNotice.php
+++ b/src/Objects/StatusPacketNotice.php
@@ -4,8 +4,18 @@
namespace TTBooking\DirectBank\Objects;
+use Traits\MappableTrait;
+
+/**
+ * Извещение о состоянии обработки транспортного контейнера (1С <-- Банк, вид ЭД 01)
+ *
+ * @xmlRoot StatusPacketNotice
+ * @xmlEncoding utf-8
+ */
class StatusPacketNotice
{
+ use MappableTrait;
+
/**
* @xmlAttribute
*/
diff --git a/src/Objects/StatusRequest.php b/src/Objects/StatusRequest.php
new file mode 100644
index 0000000..3c07ff4
--- /dev/null
+++ b/src/Objects/StatusRequest.php
@@ -0,0 +1,142 @@
+ Банк, вид ЭД 03)
+ *
+ * @xmlRoot StatusRequest
+ * @xmlEncoding utf-8
+ */
+class StatusRequest implements \Stringable
+{
+ use ConvertibleTrait;
+
+ /**
+ * @xmlAttribute
+ */
+ protected string $xmlns = 'http://directbank.1c.ru/XMLSchema';
+
+ /**
+ * @xmlAttribute
+ */
+ protected string $id;
+
+ /**
+ * @xmlAttribute
+ */
+ protected string $formatVersion = DefaultValue::FORMAT_VERSION;
+
+ /**
+ * @xmlAttribute
+ */
+ protected string $creationDate;
+
+ /**
+ * @xmlAttribute
+ */
+ protected ?string $userAgent = null;
+
+ /**
+ * @name Sender
+ */
+ protected CustomerPartyType $sender;
+
+ /**
+ * @name Recipient
+ */
+ protected BankPartyType $recipient;
+
+ /**
+ * Идентификатор электронного документа, состояние которого запрашивается
+ *
+ * @name ExtID
+ */
+ protected string $extID;
+
+ public function getId(): string
+ {
+ return $this->id;
+ }
+
+ public function setId(string $id): StatusRequest
+ {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function getFormatVersion(): string
+ {
+ return $this->formatVersion;
+ }
+
+ public function setFormatVersion(string $formatVersion): StatusRequest
+ {
+ $this->formatVersion = $formatVersion;
+ return $this;
+ }
+
+ public function getCreationDate(): string
+ {
+ return $this->creationDate;
+ }
+
+ public function setCreationDate(string $creationDate): StatusRequest
+ {
+ $this->creationDate = $creationDate;
+ return $this;
+ }
+
+ public function getUserAgent(): ?string
+ {
+ return $this->userAgent;
+ }
+
+ public function setUserAgent(?string $userAgent): StatusRequest
+ {
+ $this->userAgent = $userAgent;
+ return $this;
+ }
+
+ public function getSender(): CustomerPartyType
+ {
+ return $this->sender;
+ }
+
+ public function setSender(CustomerPartyType $sender): StatusRequest
+ {
+ $this->sender = $sender;
+ return $this;
+ }
+
+ public function getRecipient(): BankPartyType
+ {
+ return $this->recipient;
+ }
+
+ public function setRecipient(BankPartyType $recipient): StatusRequest
+ {
+ $this->recipient = $recipient;
+ return $this;
+ }
+
+ public function getExtID(): string
+ {
+ return $this->extID;
+ }
+
+ public function setExtID(string $extID): StatusRequest
+ {
+ $this->extID = $extID;
+ return $this;
+ }
+
+ public function __toString()
+ {
+ return $this->toXml();
+ }
+}
diff --git a/tests/ClientHttpTest.php b/tests/ClientHttpTest.php
index 9f592e6..844ff7a 100644
--- a/tests/ClientHttpTest.php
+++ b/tests/ClientHttpTest.php
@@ -8,6 +8,7 @@
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\RequestInterface;
use TTBooking\DirectBank\Exceptions\ClientException;
+use TTBooking\DirectBank\Exceptions\OtpRequiredException;
use TTBooking\DirectBank\Exceptions\UnexpectedResponseException;
use TTBooking\DirectBank\Fixture\PacketFixture;
@@ -82,6 +83,48 @@ public function testCreateSession()
$this->assertFalse($request->hasHeader('sid'));
}
+ public function testHeaders()
+ {
+ $this->mock->append(self::logon(), self::success('PACK-1'));
+
+ $this->createClient(['sessionId' => 'SID-0'])->createSession();
+ $this->createClient(['sessionId' => 'SID-0'])->sendPack(PacketFixture::createPacket());
+
+ [$logon, $sendPack] = $this->requests;
+
+ foreach ([$logon, $sendPack] as $request) {
+ $this->assertSame('application/xml; charset=utf-8', $request->getHeaderLine('Content-Type'));
+ $this->assertSame('40702810000000000000', $request->getHeaderLine('CustomerID'));
+ $this->assertSame('2.2.2', $request->getHeaderLine('APIVersion'));
+ $this->assertStringStartsWith('GuzzleHttp/', $request->getHeaderLine('User-Agent'));
+ }
+
+ // Logon: логин и пароль, доступная версия API, без SID
+ $this->assertSame('Basic ' . base64_encode('user:secret'), $logon->getHeaderLine('Authorization'));
+ $this->assertSame('2.2.2', $logon->getHeaderLine('AvailableAPIVersion'));
+ $this->assertFalse($logon->hasHeader('SID'));
+
+ // Запросы в рамках сессии: SID, без логина и пароля
+ $this->assertSame('SID-0', $sendPack->getHeaderLine('SID'));
+ $this->assertFalse($sendPack->hasHeader('Authorization'));
+ $this->assertFalse($sendPack->hasHeader('AvailableAPIVersion'));
+ }
+
+ public function testHeadersFromSettings()
+ {
+ $this->mock->append(self::logon());
+
+ $this->createClient([
+ 'apiVersion' => '2.1.1',
+ 'availableApiVersion' => null,
+ 'userAgent' => '1C+Enterprise/8.3',
+ ])->createSession();
+
+ $this->assertSame('2.1.1', $this->requests[0]->getHeaderLine('APIVersion'));
+ $this->assertFalse($this->requests[0]->hasHeader('AvailableAPIVersion'));
+ $this->assertSame('1C+Enterprise/8.3', $this->requests[0]->getHeaderLine('User-Agent'));
+ }
+
public function testLogonBeforeFirstRequest()
{
$this->mock->append(
@@ -155,6 +198,53 @@ public function testGetPackListWithoutTimeStamp()
$this->assertNull($client->getPackList());
}
+ public function testGetPackListResponse()
+ {
+ $this->mock->append(
+ self::success('AB'),
+ self::success(''),
+ self::success(''),
+ );
+
+ $client = $this->createClient(['sessionId' => 'SID-0']);
+
+ $list = $client->getPackListResponse();
+ $this->assertSame(['A', 'B'], $list->getPacketID());
+ $this->assertSame('2026-09-22T10:15:30+05:00', $list->getTimeStampLastPacket());
+ $this->assertSame('2026-09-22 10:15:30 +05:00', $list->getTimeStampLastPacketDateTime()->format('Y-m-d H:i:s P'));
+
+ // Отметку из ответа передаём в следующий запрос как есть: время сервера банка без перевода в другой пояс
+ $list = $client->getPackListResponse($list->getTimeStampLastPacket());
+ $this->assertSame([], $list->getPacketID());
+
+ $list = $client->getPackListResponse(new \DateTimeImmutable('2026-09-22 10:15:30'));
+ $this->assertSame([], $list->getPacketID());
+ $this->assertNull($list->getTimeStampLastPacket());
+ $this->assertNull($list->getTimeStampLastPacketDateTime());
+
+ $query = [];
+ parse_str($this->requests[0]->getUri()->getQuery(), $query);
+ $this->assertSame([], $query);
+ parse_str($this->requests[1]->getUri()->getQuery(), $query);
+ $this->assertSame(['date' => '22.09.2026 10:15:30'], $query);
+ parse_str($this->requests[2]->getUri()->getQuery(), $query);
+ $this->assertSame(['date' => '22.09.2026 10:15:30'], $query);
+ }
+
+ public function testGetPackListEmptyWithTimeStamp()
+ {
+ $this->mock->append(self::success(''));
+
+ $this->assertSame([], $this->createClient(['sessionId' => 'SID-0'])->getPackList());
+ }
+
+ public function testGetPackListResponseInvalidTimestamp()
+ {
+ $this->expectException(\InvalidArgumentException::class);
+
+ $this->createClient(['sessionId' => 'SID-0'])->getPackListResponse('not a date');
+ }
+
public function testGetPackListDate()
{
$this->mock->append(self::success('A'));
@@ -284,6 +374,87 @@ public function testNoReauthenticateOnOtherErrors()
$this->assertCount(1, $this->requests);
}
+ protected static function logonOtp(string $sid = 'SID-UNAUTHORIZED', string $otp = ''): Response
+ {
+ return self::success("$sid$otp");
+ }
+
+ public function testOtpRequired()
+ {
+ $this->mock->append(
+ self::logonOtp(),
+ self::logon('SID-2'),
+ self::success('PACK-1'),
+ );
+
+ $client = $this->createClient();
+
+ try {
+ $client->createSession();
+ $this->fail('OtpRequiredException expected');
+ } catch (OtpRequiredException $e) {
+ $this->assertSame('SID-UNAUTHORIZED', $e->getSessionId());
+ $this->assertSame('7916***6465', $e->getPhoneMask());
+ $this->assertSame('1234', $e->getSessionCode());
+ }
+
+ $this->assertSame('SID-2', $client->confirmOtp($e->getSessionId(), '034494'));
+ $this->assertSame('PACK-1', $client->sendPack(PacketFixture::createPacket()));
+
+ [, $logonOtp, $sendPack] = $this->requests;
+
+ $this->assertSame('POST', $logonOtp->getMethod());
+ $this->assertStringEndsWith('/LogonOTP', $logonOtp->getUri()->getPath());
+ $this->assertSame('SID-UNAUTHORIZED', $logonOtp->getHeaderLine('SID'));
+ $this->assertSame('034494', $logonOtp->getHeaderLine('OTP'));
+ $this->assertSame('40702810000000000000', $logonOtp->getHeaderLine('CustomerID'));
+ $this->assertFalse($logonOtp->hasHeader('Authorization'));
+ $this->assertSame('', (string) $logonOtp->getBody());
+
+ // После подтверждения запросы идут с авторизованным SID без повторного Logon
+ $this->assertCount(3, $this->requests);
+ $this->assertSame('SID-2', $sendPack->getHeaderLine('SID'));
+ }
+
+ public function testOtpRequiredOnImplicitLogon()
+ {
+ $this->mock->append(self::logonOtp('SID-UNAUTHORIZED', ''));
+
+ try {
+ $this->createClient()->sendPack(PacketFixture::createPacket());
+ $this->fail('OtpRequiredException expected');
+ } catch (OtpRequiredException $e) {
+ $this->assertSame('SID-UNAUTHORIZED', $e->getSessionId());
+ $this->assertNull($e->getPhoneMask());
+ }
+
+ $this->assertCount(1, $this->requests);
+ }
+
+ public function testInvalidOtp()
+ {
+ $this->mock->append(self::error('1204', 'Некорректный ОТР'));
+
+ try {
+ $this->createClient()->confirmOtp('SID-UNAUTHORIZED', '000000');
+ $this->fail('ClientException expected');
+ } catch (ClientException $e) {
+ $this->assertSame('1204', $e->getBankCode());
+ }
+ }
+
+ public function testCreateSessionStoresSessionId()
+ {
+ $this->mock->append(self::logon('SID-1'), self::success('PACK-1'));
+
+ $client = $this->createClient();
+ $client->createSession();
+ $client->sendPack(PacketFixture::createPacket());
+
+ $this->assertCount(2, $this->requests);
+ $this->assertSame('SID-1', $this->requests[1]->getHeaderLine('SID'));
+ }
+
public function testBankErrorWithHttpErrorStatus()
{
$this->mock->append(self::error('1009', 'Ошибка приемного сервиса', 500));
diff --git a/tests/ClientSettingsTest.php b/tests/ClientSettingsTest.php
index 6402496..e040073 100644
--- a/tests/ClientSettingsTest.php
+++ b/tests/ClientSettingsTest.php
@@ -54,6 +54,31 @@ public function testInvalidSessionId()
new Client(['sessionId' => 123] + $this->settings);
}
+ public function optionalStringSettingsProvider(): array
+ {
+ $cases = [];
+
+ foreach (['sessionId', 'availableApiVersion', 'userAgent'] as $key) {
+ $cases["$key empty"] = [$key, ''];
+ $cases["$key not string"] = [$key, 123];
+ }
+
+ return $cases;
+ }
+
+ /**
+ * @dataProvider optionalStringSettingsProvider
+ */
+ public function testOptionalStringSettings(string $key, mixed $value)
+ {
+ $this->assertInstanceOf(Client::class, new Client([$key => null] + $this->settings));
+
+ $this->expectException(InvalidSettingsException::class);
+ $this->expectExceptionMessage("\"$key\"");
+
+ new Client([$key => $value] + $this->settings);
+ }
+
public function testInvalidVerify()
{
$this->expectException(InvalidSettingsException::class);
diff --git a/tests/Dictionary/DictionaryTest.php b/tests/Dictionary/DictionaryTest.php
new file mode 100644
index 0000000..db5d94e
--- /dev/null
+++ b/tests/Dictionary/DictionaryTest.php
@@ -0,0 +1,64 @@
+getConstants(), fn($value) => ! is_array($value));
+ }
+
+ public function testDocKind()
+ {
+ $codes = array_values(array_unique(self::values(DocKind::class)));
+ sort($codes);
+
+ $this->assertSame([
+ '01', '02', '03', '04', '05', '06',
+ '10', '11', '12', '13', '14', '15', '16', '17', '18',
+ '19', '20', '21', '22', '23', '24', '25',
+ '30', '35',
+ ], $codes);
+
+ $this->assertSame(['02', '03', '05', '06'], DocKind::REQUIRED);
+ $this->assertSame(DocKind::STATUS_PACKET_NOTICE, DocKind::SHIPPING_CONTAINER_HANDLING_STATUS_NOTIFICATION);
+ }
+
+ public function testDocStatus()
+ {
+ $this->assertSame(['01', '02', '03', '04', '05', '06'], array_values(self::values(DocStatus::class)));
+ $this->assertSame(['01'], array_values(self::values(PacketStatus::class)));
+ }
+
+ public function testStatementType()
+ {
+ $this->assertSame([0, 1, 2], array_values(self::values(StatementType::class)));
+ }
+
+ public function testErrorCode()
+ {
+ $codes = array_values(self::values(ErrorCode::class));
+
+ $this->assertSame(array_unique($codes), $codes);
+ $this->assertSame(
+ array_merge(
+ array_map(fn($i) => (string) (1000 + $i), range(1, 13)),
+ array_map(fn($i) => (string) (1100 + $i), range(1, 6)),
+ array_map(fn($i) => (string) (1200 + $i), range(1, 5)),
+ array_map(fn($i) => (string) (2000 + $i), range(1, 15)),
+ array_map(fn($i) => (string) (2100 + $i), range(1, 2)),
+ array_map(fn($i) => (string) (2200 + $i), range(1, 7)),
+ ),
+ $codes
+ );
+ $this->assertSame(['1006', '1007'], ErrorCode::REAUTHENTICATE);
+ }
+}
diff --git a/tests/Fixture/xml/1c/Probe.xml b/tests/Fixture/xml/1c/Probe.xml
new file mode 100644
index 0000000..dcc12fc
--- /dev/null
+++ b/tests/Fixture/xml/1c/Probe.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Fixture/xml/1c/Settings.xml b/tests/Fixture/xml/1c/Settings.xml
new file mode 100644
index 0000000..1eabbe3
--- /dev/null
+++ b/tests/Fixture/xml/1c/Settings.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+ 2806
+ https://dbogate.demobank.ru/
+ 2.2.1
+ UTF-8
+
+
+ user_login
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Fixture/xml/1c/Settings_Logon_Certificate.xml b/tests/Fixture/xml/1c/Settings_Logon_Certificate.xml
new file mode 100644
index 0000000..9837961
--- /dev/null
+++ b/tests/Fixture/xml/1c/Settings_Logon_Certificate.xml
@@ -0,0 +1,80 @@
+
+
+
+
+
+ 2806
+ https://dbogate.demobank.ru/
+ 2.2.1
+ UTF-8
+ false
+
+
+ GOST28147
+
+
+
+ Crypto-Pro GOST R 34.10-2001 Cryptographic Service Provider
+ 75
+ GOST R 34.10-2001
+ GOST R 34.11-94
+
+
+
+ MIIDhTCCAzKgAwIBAgILAgEUAlQkAQECYQwwCgYGKoUDAgIDBQAwgeIxCzAJBgNV
+ BAYTAlJVMRUwEwYDVQQIDAzQnNC+0YHQutCy0LAxFTATBgNVBAcMDNCc0L7RgdC6
+ 0LLQsDEwMC4GA1UECgwn0J/QkNCeINCR0LDQvdC6ICLQpNCaINCe0YLQutGA0YvR
+ gtC40LUiMVgwVgYDVQQDDE/Qo9C00L7RgdGC0L7QstC10YDRj9GO0YnQuNC5INCm
+ 0LXQvdGC0YAg0J/QkNCeINCR0LDQvdC6ICLQpNCaINCe0YLQutGA0YvRgtC40LUi
+ MRkwFwYJKoZIhvcNAQkBFgpwa2lAb2ZjLnJ1MB4XDTE1MTIwMzA3MzQzNloXDTE3
+ MDIwNTA3MzQzNlowgbkxCzAJBgNVBAYTAlJVMRUwEwYDVQQHHgwEHAQ+BEEEOgQy
+ BDAxITAfBgNVBAoeGAQeBB4EHgAgACIEEgQ+BEEEQgQ+BDoAIjEPMA0GA1UECx4G
+ AEQAQgBPMUMwQQYDVQQDHjoEHgRCBDoEQARLBEIEPgQyACAEJAQ1BDQEPgRAACAE
+ GgQ+BD0EQQRCBDAEPQRCBDgEPQQ+BDIEOARHMRowGAYJKoZIhvcNAQkBFgtvZmtA
+ bWFpbC5ydTBjMBwGBiqFAwICEzASBgcqhQMCAiQABgcqhQMCAh4BA0MABEAdGL9U
+ oVW7P8jb3jSomUO5HO+yRul/F5zAL6VqOpzoHJGfCgwC+sIplqb6PkFpSZnyhYUz
+ ktux0monTcjtK3Gvo4HrMIHoMB0GA1UdDgQWBBT2v1NTaoWHLA9NJ7IAchoMFiGc
+ ZTAfBgNVHSMEGDAWgBQSfvuK/RhI/dFpGVzEiB/gG0zcujCBlQYDVR0gBIGNMIGK
+ MIGHBgorBgEEAYKcBQEBMHkwOAYIKwYBBQUHAgEWLGh0dHA6Ly93d3cub3Rrcml0
+ aWVmYy5ydS9hYm91dC9jYS1yZWdsYW1lbnQvMD0GCCsGAQUFBwICMDEaL9Ho8fLl
+ 7Psg5Ojx8uDt9uju7e3u4+4g4eDt6u7i8eru4+4g7uHx6/Pm6OLg7ej/MA4GA1Ud
+ DwEB/wQEAwIE8DAKBgYqhQMCAgMFAANBABGGfsrxsm9BIExdpEeFRvwfRvVjgoHL
+ vYWd/OAkfNjSWNX2A/H0eWZmmQxrGkLpQV2FQJES7lR2buCjxx5xVCw=
+
+
+
+
+
+
+
+ (0)
+
+
+
+
+ (0)
+
+
+
+
+ (0)
+
+
+
+
+ (0)
+
+
+
+
+ (0)
+
+
+
+
diff --git a/tests/Fixture/xml/1c/StatusDocNotice.xml b/tests/Fixture/xml/1c/StatusDocNotice.xml
new file mode 100644
index 0000000..fe26382
--- /dev/null
+++ b/tests/Fixture/xml/1c/StatusDocNotice.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+ eafa28b5-6600-424c-b0d3-3785274d570d
+
+
+ 01
+ Подписан
+
+
+
\ No newline at end of file
diff --git a/tests/Fixture/xml/1c/StatusRequest.xml b/tests/Fixture/xml/1c/StatusRequest.xml
new file mode 100644
index 0000000..023f91f
--- /dev/null
+++ b/tests/Fixture/xml/1c/StatusRequest.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ 05688096-0806-4ef0-af4c-572f75dbaf7c
+
\ No newline at end of file
diff --git a/tests/Fixture/xml/status_packet_notice.xml b/tests/Fixture/xml/status_packet_notice.xml
new file mode 100644
index 0000000..525a068
--- /dev/null
+++ b/tests/Fixture/xml/status_packet_notice.xml
@@ -0,0 +1,5 @@
+
+
+
+p101Принятmx1
+
diff --git a/tests/Objects/ServiceDocumentsTest.php b/tests/Objects/ServiceDocumentsTest.php
new file mode 100644
index 0000000..31930a4
--- /dev/null
+++ b/tests/Objects/ServiceDocumentsTest.php
@@ -0,0 +1,227 @@
+loadXML($xml);
+
+ $this->assertTrue($dom->schemaValidate(__DIR__ . '/../Fixture/xsd/1C-Bank_' . $schema . '.xsd'));
+ }
+
+ protected static function customer(): CustomerPartyType
+ {
+ return (new CustomerPartyType())->setId('id:42;s:9999')->setName('Торговый дом Комплексный')->setInn('7705260699')->setKpp('770501001');
+ }
+
+ protected static function bank(): BankPartyType
+ {
+ return (new BankPartyType())->setBic('044525888')->setName('ДЕМО-БАНК');
+ }
+
+ public function testStatusPacketNotice()
+ {
+ $notice = new StatusPacketNotice();
+ $notice->mapFromXml(self::fixture('status_packet_notice.xml'));
+
+ $this->assertSame('n1', $notice->getId());
+ $this->assertSame('044525888', $notice->getSender()->getBank()->getBic());
+ $this->assertSame('c1', $notice->getRecipient()->getCustomer()->getId());
+ $this->assertSame('p1', $notice->getIDResultSuccessResponse());
+ $this->assertSame('x1', $notice->getExtIDPacket());
+ $this->assertSame(PacketStatus::ACCEPTED, $notice->getResult()->getStatus()->getCode());
+ $this->assertNull($notice->getResult()->getError());
+ }
+
+ public function testStatusDocNotice()
+ {
+ $notice = new StatusDocNotice();
+ $notice->mapFromXml(self::fixture('1c/StatusDocNotice.xml'));
+
+ $this->assertSame('204e36e4-6910-416d-99b2-e8109e518744', $notice->getId());
+ $this->assertSame('2016-04-22T09:34:46+03:00', $notice->getCreationDate());
+ $this->assertSame('044525888', $notice->getSender()->getBank()->getBic());
+ $this->assertSame('id:42;s:9999', $notice->getRecipient()->getCustomer()->getId());
+ $this->assertSame('eafa28b5-6600-424c-b0d3-3785274d570d', $notice->getExtID());
+ $this->assertNull($notice->getExtIDStatusRequest());
+
+ $status = $notice->getResult()->getStatus();
+ $this->assertSame(DocStatus::ACCEPTED, $status->getCode());
+ $this->assertSame('Подписан', $status->getName());
+ }
+
+ public function testStatusDocNoticeWithError()
+ {
+ $xml = str_replace(
+ '
+ 01
+ Подписан
+ ',
+ '2205Ошибка в реквизитахНеверный БИК',
+ self::fixture('1c/StatusDocNotice.xml')
+ );
+ $xml = str_replace('', '0fa540c3-1aaa-4e06-8883-2c936fef05a5', $xml);
+
+ $this->assertValid($xml, 'StatusDocNotice');
+
+ $notice = new StatusDocNotice();
+ $notice->mapFromXml($xml);
+
+ $this->assertNull($notice->getResult()->getStatus());
+ $this->assertSame('2205', $notice->getResult()->getError()->getCode());
+ $this->assertSame('Неверный БИК', $notice->getResult()->getError()->getMoreInfo());
+ $this->assertSame('0fa540c3-1aaa-4e06-8883-2c936fef05a5', $notice->getExtIDStatusRequest());
+ }
+
+ public function testStatusRequest()
+ {
+ $request = (new StatusRequest())
+ ->setId('0fa540c3-1aaa-4e06-8883-2c936fef05a5')
+ ->setCreationDate('2016-04-22T10:09:11')
+ ->setUserAgent('My App')
+ ->setSender(self::customer())
+ ->setRecipient(self::bank())
+ ->setExtID('05688096-0806-4ef0-af4c-572f75dbaf7c');
+
+ $this->assertValid((string) $request, 'StatusRequest');
+
+ $official = (new XmlModelMapper())->map(self::fixture('1c/StatusRequest.xml'), new StatusRequest());
+
+ $this->assertSame('05688096-0806-4ef0-af4c-572f75dbaf7c', $official->getExtID());
+ $this->assertSame('7705260699', $official->getSender()->getInn());
+ $this->assertSame('044525888', $official->getRecipient()->getBic());
+ $this->assertValid($official->toXml(), 'StatusRequest');
+ }
+
+ public function testProbe()
+ {
+ $probe = (new Probe())
+ ->setId('eafa28b5-6600-424c-b0d3-3785274d570d')
+ ->setCreationDate('2016-04-22T09:33:57')
+ ->setSender(self::customer())
+ ->setRecipient(self::bank());
+
+ $this->assertValid((string) $probe, 'Probe');
+ $this->assertNull($probe->getUserAgent());
+
+ $official = (new XmlModelMapper())->map(self::fixture('1c/Probe.xml'), new Probe());
+
+ $this->assertSame('eafa28b5-6600-424c-b0d3-3785274d570d', $official->getId());
+ $this->assertSame('id:42;s:9999', $official->getSender()->getId());
+ $this->assertValid($official->toXml(), 'Probe');
+ }
+
+ public function testSettingsLogin()
+ {
+ $settings = new Settings();
+ $settings->mapFromXml(self::fixture('1c/Settings.xml'));
+
+ $this->assertSame('EFD857B5-7FA8-4195-8666-2CCADBC3C8DE', $settings->getId());
+ $this->assertSame('DemoBankService', $settings->getUserAgent());
+ $this->assertSame('044525888', $settings->getSender()->getBic());
+ $this->assertSame('2806', $settings->getRecipient()->getId());
+
+ $data = $settings->getData();
+ $this->assertSame('2806', $data->getCustomerID());
+ $this->assertSame('https://dbogate.demobank.ru/', $data->getBankServerAddress());
+ $this->assertSame('2.2.1', $data->getFormatVersion());
+ $this->assertSame('UTF-8', $data->getEncoding());
+ $this->assertFalse($data->isCompress());
+ $this->assertSame('user_login', $data->getLogon()->getLogin()->getUser());
+ $this->assertNull($data->getLogon()->getCertificate());
+ $this->assertNull($data->getCryptoParameters());
+ $this->assertNull($data->getReceiptStatement());
+ $this->assertSame(['03', '05', '10', '11', '14', '30'], $data->getDocKinds());
+ $this->assertNull($data->getDocuments()[0]->getSigned());
+ }
+
+ public function testSettingsCertificate()
+ {
+ $settings = new Settings();
+ $settings->mapFromXml(self::fixture('1c/Settings_Logon_Certificate.xml'));
+
+ $data = $settings->getData();
+ $this->assertNull($data->getLogon()->getLogin());
+ $this->assertSame('GOST28147', $data->getLogon()->getCertificate()->getEncryptingAlgorithm());
+
+ $crypto = $data->getCryptoParameters();
+ $this->assertSame('Crypto-Pro GOST R 34.10-2001 Cryptographic Service Provider', $crypto->getCSPName());
+ $this->assertSame(75, $crypto->getCSPType());
+ $this->assertSame('GOST R 34.10-2001', $crypto->getSignAlgorithm());
+ $this->assertSame('GOST R 34.11-94', $crypto->getHashAlgorithm());
+ $this->assertNull($crypto->getEncrypted());
+ $this->assertNull($crypto->getBankCertificate());
+
+ $groups = $crypto->getCustomerSignature()->getGroupSignatures();
+ $this->assertCount(1, $groups);
+ $this->assertSame(1, $groups[0]->getNumberGroup());
+ $this->assertCount(1, $groups[0]->getCertificates());
+ $this->assertStringStartsWith('MIIDhTCCAzKgAwIBAgIL', trim($groups[0]->getCertificates()[0]));
+
+ $this->assertSame(['03', '04', '05', '10', '11', '14'], $data->getDocKinds());
+ $this->assertNull($data->getDocuments()[0]->getSigned());
+ $this->assertSame('(0)', $data->getDocuments()[1]->getSigned()->getRuleSignatures());
+ }
+
+ /**
+ * Все необязательные элементы настроек и несколько групп подписей
+ */
+ public function testSettingsFull()
+ {
+ $xml = str_replace(
+ [
+ 'GOST R 34.11-94',
+ '',
+ '',
+ '',
+ ],
+ [
+ 'GOST R 34.11-94GOST 28147-89'
+ . 'Uk9PVA==QkFOSw==',
+ 'QQ==Qg=='
+ . 'https://bank.example.ru/addin.zip',
+ '',
+ 'statementПозвоните в банк',
+ ],
+ self::fixture('1c/Settings_Logon_Certificate.xml')
+ );
+
+ $this->assertValid($xml, 'Settings');
+
+ $settings = new Settings();
+ $settings->mapFromXml($xml);
+
+ $data = $settings->getData();
+ $crypto = $data->getCryptoParameters();
+ $this->assertSame('GOST 28147-89', $crypto->getEncrypted()->getEncryptAlgorithm());
+ $this->assertSame('Uk9PVA==', $crypto->getBankTrustedRootCertificate());
+ $this->assertSame('QkFOSw==', $crypto->getBankCertificate());
+ $this->assertSame('https://bank.example.ru/addin.zip', $crypto->getURLAddinInfo());
+
+ $groups = $crypto->getCustomerSignature()->getGroupSignatures();
+ $this->assertCount(2, $groups);
+ $this->assertSame(2, $groups[1]->getNumberGroup());
+ $this->assertSame(['QQ==', 'Qg=='], $groups[1]->getCertificates());
+
+ $this->assertSame('02', $data->getDocKinds()[0]);
+ $this->assertSame('statement', $data->getReceiptStatement()->getLogin());
+ $this->assertSame('Позвоните в банк', $data->getReceiptStatement()->getInstructions());
+ }
+}