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()); + } +}