dolibarr.bankimport/vendor/nemiah/php-fints/lib/Fhp/FinTs.php
data 8b64fd24d3 feat: php-fints 4.0 Update + HKEKA/HKKAA Segmente (WIP)
- php-fints Bibliothek von 3.7.0 auf 4.0.0 aktualisiert
- Parser-Fix: Ignoriert zusätzliche Bank-Felder statt Exception
- HKEKA Segmente implementiert (HIEKASv5, HKEKAv5, HIEKAv5)
- HKKAA Segmente implementiert (HIKAASv1, HKKAAv1)
- GetStatementFromArchive und GetElectronicStatement Actions

HINWEIS: HKKAA/HKEKA funktionieren noch nicht mit VR Bank
(Fehler "unerwarteter Aufbau wrt DE 2" - Kontoverbindungsformat)
Normale Funktionalität (Transaktionsimport) ist nicht betroffen.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-05 15:47:27 +01:00

1206 lines
62 KiB
PHP

<?php
namespace Fhp;
use Fhp\Model\NoPsd2TanMode;
use Fhp\Model\TanMedium;
use Fhp\Model\TanMode;
use Fhp\Model\VopConfirmationRequest;
use Fhp\Model\VopConfirmationRequestImpl;
use Fhp\Model\VopPollingInfo;
use Fhp\Model\VopVerificationResult;
use Fhp\Options\Credentials;
use Fhp\Options\FinTsOptions;
use Fhp\Options\SanitizingLogger;
use Fhp\Protocol\BPD;
use Fhp\Protocol\DialogInitialization;
use Fhp\Protocol\GetTanMedia;
use Fhp\Protocol\Message;
use Fhp\Protocol\MessageBuilder;
use Fhp\Protocol\ServerException;
use Fhp\Protocol\UnexpectedResponseException;
use Fhp\Protocol\UPD;
use Fhp\Segment\BaseSegment;
use Fhp\Segment\HIBPA\HIBPAv3;
use Fhp\Segment\HIRMS\Rueckmeldungscode;
use Fhp\Segment\HKEND\HKENDv1;
use Fhp\Segment\HKIDN\HKIDNv2;
use Fhp\Segment\HKVVB\HKVVBv3;
use Fhp\Segment\TAN\HITAN;
use Fhp\Segment\TAN\HKTAN;
use Fhp\Segment\TAN\HKTANFactory;
use Fhp\Segment\TAN\HKTANv6;
use Fhp\Segment\VPP\HKVPPv1;
use Fhp\Segment\VPP\VopHelper;
use Fhp\Syntax\InvalidResponseException;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* This is the main class of this library. Please see the Samples directory for how to use it.
* This class is not thread-safe. Do not call its funtions concurrently.
*/
class FinTs
{
// Things we retrieved from the user / the calling application.
/** @var FinTsOptions */
private $options;
/** @var Credentials|null */
private $credentials;
/** @var SanitizingLogger */
private $logger;
// The TAN mode and medium to be used for business transactions that require a TAN.
/** @var TanMode|int|null */
private $selectedTanMode;
/** @var string|null This is a {@link TanMedium::getName()}, but we don't have the {@link TanMedium} instance. */
private $selectedTanMedium;
// State that persists across physical connections, dialogs and even PHP processes.
/** @var BPD|null */
private $bpd;
/** @var int[]|null The IDs of the {@link TanMode}s from the BPD which the user is allowed to use. */
private $allowedTanModes;
/** @var UPD|null */
private $upd;
// State of the current connection/dialog with the bank.
/** @var Connection|null */
private $connection;
/** @var string|null */
private $kundensystemId;
/** @var string|null */
protected $dialogId;
/** @var int */
private $messageNumber = 1;
/**
* Use this factory to create new instances.
* @param FinTsOptions $options Configuration options for the connection to the bank.
* @param Credentials $credentials Authentication information for the user. Note: This library does not support
* anonymous connections, so the credentials are mandatory.
* @param string|null $persistedInstance The return value of {@link persist()} of a previous FinTs instance,
* usually from an earlier PHP process. NOTE: Each persisted instance may be used only once and should be
* considered invalid afterwards. To continue the session, call {@link persist()} again.
*/
public static function new(FinTsOptions $options, Credentials $credentials, ?string $persistedInstance = null): FinTs
{
$options->validate();
$fints = new static($options, $credentials);
if ($persistedInstance !== null) {
$fints->loadPersistedInstance($persistedInstance);
}
return $fints;
}
/**
* This function allows fetching the BPD without knowing the user's credentials yet, by using an anonymous dialog.
* Note: If this fails with an error saying that your bank does not support the anonymous dialog, you probably need
* to use {@link NoPsd2TanMode} for regular login.
* @param FinTsOptions $options Configuration options for the connection to the bank.
* @param ?LoggerInterface $logger An optional logger to record messages exchanged with the bank.
* @return BPD Bank parameters that tell the client software what features the bank supports.
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
* @throws UnexpectedResponseException When the server does not send the BPD or close the dialog properly.
* @throws ServerException When the server resopnds with an error.
*/
public static function fetchBpd(FinTsOptions $options, ?LoggerInterface $logger = null): BPD
{
$options->validate();
$fints = new static($options, null);
if ($logger !== null) {
$fints->setLogger($logger);
}
return $fints->getBpd();
}
/** Please use the factory above. */
protected function __construct(FinTsOptions $options, ?Credentials $credentials)
{
$this->options = $options;
$this->credentials = $credentials;
$this->setLogger(new NullLogger());
}
/**
* Destructing the object only disconnects. Please use {@link close()} if you want to properly "log out", i.e., end
* the FinTs dialog.
*/
public function __destruct()
{
$this->disconnect();
}
/**
* Returns a serialized form this object. This is different from PHP's {@link \Serializable} in that it only
* serializes parts and cannot simply be restored with {@link unserialize()}, because the {@link FinTsOptions} and
* the {@link Credentials} need to be passed to {@link FinTs::new()}, in addition to the string returned here.
*
* Alternatively, you can use {@link loadPersistedInstance()} to separate constructing the instance and resuming it.
*
* There are broadly two reasons to persist the instance:
* 1. During login or certain other actions, you may encounter a TAN/2FA request ({@link BaseAction::needsTan()}
* returns true). In that case, you MUST call {@link submitTan()} or {@link checkDecoupledSubmission()} later,
* without losing the dialog state in between. Depending on your application's circumstances, one option might be
* to simply keep the {@link FinTs} instance itself alive in memory (e.g., in a CLI application, you can block
* until the user provides the TAN). In most server-based scenarios, however, the PHP process will shut down and
* a new PHP process will be started later, when the client calls again to provide the TAN. In this case, you
* need to persist the {@link FinTs} instance and restore it later in order for the action to succeed.
* 2. Even when there is no outstanding action and after logging out with {@link close()}, it's beneficial to
* persist the instance (with $minimal=false). By reusing the cached BPD, UPD and TAN mode information upon the
* next {@link login()}, a few roundtrips to the FinTS server can be avoided.
*
* IMPORTANT: Each serialized instance (each value returned from {@link persist()}) can only be used once. After
* passing it to {@link FinTs::new()} or {@link loadPersistedInstance()}, you must consider it invalidated. To keep
* the same instance/session alive, you must call {@link persist()} again.
*
* @param bool $minimal If true, the return value only contains the values necessary to complete an outstanding TAN
* request, but not the relatively large BPD/UPD, which can always be retrieved again later with a few extra
* requests to the server. So the persisting doesn't work for use case (2.) from above, but in turn, it saves
* storage space.
* @return string A serialized form of those parts of the FinTs instance that can reasonably be persisted (BPD, UPD,
* Kundensystem-ID, etc.). Note that this usually contains some user data (user's name, account names and
* sometimes a dialog ID that is equivalent to session cookie). So the returned string needs to be treated
* carefully (not written to log files, only to a database or other storage system that would normally be used
* for user data). The returned string never contains highly sensitive information (not the user's password or
* PIN), so it probably does not need to be encrypted. Treat it like a session cookie of the same bank.
* Note that this is not necessarily valid UTF-8, so you should store it as a BLOB column or raw bytes.
*/
public function persist(bool $minimal = false): string
{
// IMPORTANT: Be sure not to include highly sensitive user information here.
return serialize([ // This should match loadPersistedInstanceVersion1().
2, // Version of the serialized format.
$minimal ? null : $this->bpd,
$minimal ? null : $this->allowedTanModes,
$minimal ? null : $this->upd,
$this->selectedTanMode,
$this->selectedTanMedium,
$this->kundensystemId,
$this->dialogId,
$this->messageNumber,
]);
}
public function __serialize(): array
{
throw new \LogicException('FinTs cannot be serialize()-ed, you should call persist() instead.');
}
public function __unserialize(array $data): void
{
throw new \LogicException(
'FinTs cannot be unserialize()-ed, you should pass $persistedInstance to FinTs::new() instead.');
}
/**
* Loads data from a previous {@link FinTs} instance, to reuse cached BPD/UPD information and/or to continue using
* an ongoing session. The latter is necessary to complete a TAN request when the user provides the TAN in a fresh
* PHP process.
*
* Unless it's not available to you at that time already, you can just pass the persisted instance into
* {@link FinTs::new()} instead of calling this function.
*
* @param string $persistedInstance The return value of {@link persist()} of a previous FinTs instance, usually
* from an earlier PHP process. NOTE: Each persisted instance may be used only once and should be considered
* invalid afterwards. To continue the session, call {@link persist()} again.
*
* @throws \InvalidArgumentException
*/
public function loadPersistedInstance(string $persistedInstance): void
{
$unserialized = unserialize($persistedInstance);
if (!is_array($unserialized) || count($unserialized) === 0) {
throw new \InvalidArgumentException("Invalid persistedInstance: '$persistedInstance'");
}
$version = $unserialized[0];
$data = array_slice($unserialized, 1);
if ($version === 2) {
$this->loadPersistedInstanceVersion2($data);
} else {
throw new \InvalidArgumentException("Unknown persistedInstace version: '{$unserialized[0]}''");
}
}
private function loadPersistedInstanceVersion2(array $data): void
{
list( // This should match persist().
$this->bpd,
$this->allowedTanModes,
$this->upd,
$this->selectedTanMode,
$this->selectedTanMedium,
$this->kundensystemId,
$this->dialogId,
$this->messageNumber,
) = $data;
}
/** @noinspection PhpUnused */
public function getLogger(): SanitizingLogger
{
return $this->logger;
}
/**
* @param LoggerInterface $logger The logger to use going forward. Note that it will be wrapped in a
* {@link SanitizingLogger} to protect sensitive information like usernames and PINs.
*/
public function setLogger(LoggerInterface $logger): void
{
if ($logger instanceof SanitizingLogger) {
$this->logger = $logger;
} else {
$this->logger = new SanitizingLogger($logger, [$this->options, $this->credentials]);
}
}
/**
* @param int $connectTimeout The number of seconds to wait before aborting a connection attempt to the bank server.
* @param int $responseTimeout The number of seconds to wait before aborting a request to the bank server.
* @noinspection PhpUnused
*/
public function setTimeouts(int $connectTimeout, int $responseTimeout): void
{
$this->options->timeoutConnect = $connectTimeout;
$this->options->timeoutResponse = $responseTimeout;
}
/**
* Executes a strongly authenticated login action and returns it. With some banks, this requires a TAN.
* @return DialogInitialization A {@link BaseAction} for the outcome of the login. You should check whether a TAN is
* needed using {@link BaseAction::needsTan()} and, if so, let the user complete the TAN request from
* {@link BaseAction::getTanRequest()} and then finish the login by passing the {@link BaseAction}
* returned here to {@link submitTan()} or {@link checkDecoupledSubmission()}. See {@link execute()} for
* details.
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
* @throws UnexpectedResponseException When the server responds with a valid but unexpected message.
* @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things
* that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc.
*/
public function login(): DialogInitialization
{
$this->requireTanMode();
$this->ensureSynchronized();
$this->messageNumber = 1;
$login = new DialogInitialization($this->options, $this->requireCredentials(), $this->getSelectedTanMode(),
$this->selectedTanMedium, $this->kundensystemId);
$this->execute($login);
return $login;
}
/**
* Executes an action. Be sure to {@link login()} first. See the `\Fhp\Action` package for actions that can be
* executed with this function. Note that, after this function returns, the action can be in the following states:
* 1. If {@link BaseAction::needsTan()} returns true, the action isn't completed yet because needs a TAN or other
* kind of two-factor authentication (2FA). In this case, use {@link BaseAction::getTanRequest()} to get more
* information about the TAN/2FA that is needed. Your application then needs to interact with the user to obtain
* the TAN (which should be passed into {@link submitTan()}) or to have them complete the 2FA check (which can
* be verified with {@link checkDecoupledSubmission()}). Both of those functions require passing the same
* {@link BaseAction} argument as an argument, and once they succeed, the action will be in the same completed
* state as if it had been completed right away.
* 2. If {@link BaseAction::needsPollingWait()} returns true, the action isn't completed yet because the server is
* still running some slow operation. Importantly, the server has not necessarily accepted the action yet, so it
* is absolutely required that the client keeps polling if they don't want the action to be abandoned.
* In this case, use {@link BaseAction::getPollingInfo()} to get more information on how frequently to poll, and
* do the polling through {@link pollAction()}.
* 3. If {@link BaseAction::needsVopConfirmation()} returns true, the action isn't completed yet because the payee
* information couldn't be matched automatically, so an explicit confirmation from the user is required.
* In this case, use {@link BaseAction::getVopConfirmationRequest()} to get more information to display to the
* user, ask the user to confirm that they want to proceed with the action, and then call {@link confirmVop()}.
* 4. If none of the above return true, the action was completed right away.
* Use the respective getters on the action instance to retrieve the result. In case the action fails, the
* corresponding exception will be thrown from this function.
*
* Tip: In practice, polling (2.) and confirmation (3.) are needed only for Verification of Payee. So if your
* application only ever executes read-only actions like account statement fetching, but never executes any
* transfers, instead of handling these cases you could simply assert that {@link BaseAction::needsPollingWait()}
* and {@link BaseAction::needsVopConfirmation()} both return false.
*
* Note that all conditions above that leave the action in an incomplete state require some action from the client
* application. These actions then change the state of the action again, but they don't necessarily complete it.
* In practice, the typical sequence is: Maybe polling, maybe VOP confirmation, maybe TAN, done. That said, you
* should ideally implement your application to deal with any sequence of states. Just execute the action, check
* what's state it's in, resolve that state as appropriate, and then check again (using the same code as before). Do
* this repeatedly until none of the special conditions above happen anymore, at which point the action is done.
*
* @param BaseAction $action The action to be executed. Its {@link BaseAction::isDone()} status will be updated when
* this function returns successfully.
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
* @throws UnexpectedResponseException When the server responds with a valid but unexpected message.
* @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things
* that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc.
*/
public function execute(BaseAction $action): void
{
if ($this->dialogId === null && !($action instanceof DialogInitialization)) {
throw new \RuntimeException('Need to login (DialogInitialization) before executing other actions');
}
// Add the action's main request segments.
$requestSegments = $action->getNextRequest($this->bpd, $this->upd);
if (count($requestSegments) === 0) {
return; // No request needed.
}
$message = MessageBuilder::create()->add($requestSegments);
// Add HKTAN for authentication if necessary.
if (!($this->getSelectedTanMode() instanceof NoPsd2TanMode)) {
if (($needTanForSegment = $action->getNeedTanForSegment()) !== null) {
$message->add(HKTANFactory::createProzessvariante2Step1(
$this->requireTanMode(), $this->selectedTanMedium, $needTanForSegment));
}
}
// Add HKVPP for VOP verification if necessary.
$hkvpp = null;
if ($this->bpd?->vopRequiredForRequest($requestSegments) !== null) {
$hkvpp = VopHelper::createHKVPPForInitialRequest($this->bpd);
$message->add($hkvpp);
}
// Construct the request message and tell the action about the segment numbers that were assigned.
$request = $this->buildMessage($message, $this->getSelectedTanMode()); // This fills in the segment numbers.
$action->setRequestSegmentNumbers(array_map(function ($segment) {
/* @var BaseSegment $segment */
return $segment->getSegmentNumber();
}, $requestSegments));
// Execute the request.
$response = $this->sendMessage($request);
$this->processServerResponse($action, $response, $hkvpp);
}
/**
* Updates the state of this FinTs instance and of the `$action` based on the server's response.
* See {@link execute()} for more documentation on the possible outcomes.
* @param BaseAction $action The action for which the request was sent.
* @param Message $response The response we just got from the server.
* @param HKVPPv1|null $hkvpp The HKVPP segment, if any was present in the request.
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
* @throws UnexpectedResponseException When the server responds with a valid but unexpected message.
* @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things
* that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc.
*/
private function processServerResponse(BaseAction $action, Message $response, ?HKVPPv1 $hkvpp = null): void
{
$this->readBPD($response);
// Detect if the bank wants a TAN.
/** @var HITAN $hitan */
$hitan = $response->findSegment(HITAN::class);
// Note: Instead of DUMMY_REFERENCE, it's officially the 3076 Rueckmeldungscode that tells we don't need a TAN.
if ($hitan !== null && $hitan->getAuftragsreferenz() !== HITAN::DUMMY_REFERENCE) {
if ($hitan->tanProzess !== HKTAN::TAN_PROZESS_4) {
throw new UnexpectedResponseException("Unsupported TAN request type $hitan->tanProzess");
}
if ($this->bpd === null || $this->kundensystemId === null) {
throw new UnexpectedResponseException('Unexpected TAN request');
}
// NOTE: In case of a decoupled TAN mode, the response code 3955 must be present, but it seems useless to us.
$action->setTanRequest($hitan);
if ($action instanceof DialogInitialization) {
$action->setDialogId($response->header->dialogId);
$action->setMessageNumber($this->messageNumber);
}
}
// Detect if the bank needs us to do something for Verification of Payee.
if ($hkvpp != null) {
if ($pollingInfo = VopHelper::checkPollingRequired($response, $hkvpp->getSegmentNumber())) {
$action->setPollingInfo($pollingInfo);
if ($action->needsTan()) {
throw new UnexpectedResponseException('Unexpected polling and TAN request in the same response.');
}
return;
}
if ($confirmationRequest = VopHelper::checkVopConfirmationRequired($response, $hkvpp->getSegmentNumber())) {
$action->setVopConfirmationRequest($confirmationRequest);
if ($action->needsTan()) {
if ($confirmationRequest->getVerificationResult() === VopVerificationResult::CompletedFullMatch) {
// If someone hits this branch in practice, we can implement it.
throw new UnsupportedException('Combined VOP match confirmation and TAN request');
} else {
throw new UnexpectedResponseException(
'Unexpected TAN request on VOP result: ' . $confirmationRequest->getVerificationResult()
);
}
}
}
}
if ($action->needsVopConfirmation() || $action->needsTan()) {
return; // The action isn't complete yet.
}
// If no TAN or VOP is needed, process the response normally, and maybe keep going for more pages.
$this->processActionResponse($action, $response->filterByReferenceSegments($action->getRequestSegmentNumbers()));
if ($action instanceof PaginateableAction && $action->hasMorePages()) {
$this->execute($action);
}
// Check whether the server requested a Kundensystem-ID refresh.
if ($response->findRueckmeldung(Rueckmeldungscode::NEUE_KUNDENSYSTEM_ID_HOLEN) !== null) {
// TODO Properly implement the refresh here, see https://github.com/nemiah/phpFinTS/issues/458.
$this->logger->warning(
'The server asked us to refresh the Kundensystem-ID in response to a ' . gettype($action) .
' action, but that is not implemented yet. This could result in authentication errors or extraneous ' .
' re-authentication prompts from the bank.'
);
}
}
/**
* For an action where {@link BaseAction::needsTan()} returns `true` and {@link TanMode::isDecoupled()} returns
* `false`, this function sends the given $tan to the server to complete the action. By using {@link persist()},
* this can be done asynchronously, i.e., not in the same PHP process as the original {@link execute()} call.
*
* After this function returns, the `$action` is in any of the same states as after {@link execute()}, see there.
* In practice, the action is fully completed after completing the decoupled submission.
* In case the action fails, the corresponding exception will be thrown from this function.
*
* @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_PINTAN_2020-07-10_final_version.pdf
* Section B.4.2.1.1
*
* @param BaseAction $action The action to be completed.
* @param string $tan The TAN entered by the user.
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
* @throws UnexpectedResponseException When the server responds with a valid but unexpected message.
* @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things
* that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc.
*/
public function submitTan(BaseAction $action, string $tan): void
{
// Check the action's state.
$tanRequest = $action->getTanRequest();
if ($tanRequest === null) {
throw new \InvalidArgumentException('This action does not need a TAN');
}
if ($action instanceof DialogInitialization) {
if ($this->dialogId !== null) {
throw new \RuntimeException('Cannot init another dialog.');
}
$this->dialogId = $action->getDialogId();
$this->messageNumber = $action->getMessageNumber();
}
// Construct the request.
$tanMode = $this->requireTanMode();
if ($tanMode instanceof NoPsd2TanMode) {
throw new \InvalidArgumentException('Cannot submit TAN when the bank does not support PSD2');
}
if ($tanMode->isDecoupled()) {
throw new \InvalidArgumentException('Cannot submit TAN for a decoupled TAN mode');
}
$message = MessageBuilder::create()
->add(HKTANFactory::createProzessvariante2Step2($tanMode, $tanRequest->getProcessId()));
$request = $this->buildMessage($message, $tanMode, $tan);
// Execute the request.
$response = $this->sendMessage($request);
$this->readBPD($response);
// Ensure that the TAN was accepted.
/** @var HITAN $hitan */
$hitan = $response->findSegment(HITAN::class);
if ($hitan === null) {
throw new UnexpectedResponseException('HITAN missing after submitting TAN');
}
if ($hitan->getTanProzess() !== HKTAN::TAN_PROZESS_2 // We only support the case "(B)" in the specification.
|| $hitan->getAuftragsreferenz() !== $tanRequest->getProcessId()) {
throw new UnexpectedResponseException("Bank has not accepted TAN: $hitan");
}
$action->setTanRequest(null);
// Process the response normally, and maybe keep going for more pages.
$this->processActionResponse($action, $response->filterByReferenceSegments($action->getRequestSegmentNumbers()));
if ($action instanceof PaginateableAction && $action->hasMorePages()) {
$this->execute($action);
}
}
/**
* For an action where {@link BaseAction::needsTan()} returns `true` and {@link TanMode::isDecoupled()} returns
* `true`, this function checks with the server whether the second factor authentication has been completed yet on
* the secondary device of the user.
* - If so, this function returns `true` and the `$action` is then in any of the same states as after
* {@link execute()} (except {@link BaseAction::needsTan()} won't happen again). See there for documentation.
* In practice, the action is fully completed after completing the decoupled submission.
* - In case the action fails, the corresponding exception will be thrown from this function.
* - If the authentication has not been completed yet, this returns `false` and the action remains in its
* previous, uncompleted state.
*
* By using {@link persist()}, this function can be called asynchronously, i.e., not in the same PHP process as the
* original {@link execute()} call.
*
* This function can be called repeatedly, subject to the delays specified in the {@link TanMode}.
* IMPORTANT: Remember to re-{@link persist()} the {@link FinTs} instance after each
* {@link checkDecoupledSubmission()} call.
*
* @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_PINTAN_2020-07-10_final_version.pdf
* Section B.4.2.2
*
* @param BaseAction $action The action to be completed.
* @return bool True if the decoupled authentication is done and the $action was completed or entered one of the
* other states documented on {@link execute()}.
* If false, the {@link TanRequest} inside the action has been updated, which *may* provide new/more
* instructions to the user, though probably it rarely does in practice.
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
* @throws UnexpectedResponseException When the server responds with a valid but unexpected message.
* @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things
* that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc.
*/
public function checkDecoupledSubmission(BaseAction $action): bool
{
// Check the action's state.
$tanRequest = $action->getTanRequest();
if ($tanRequest === null) {
throw new \InvalidArgumentException('This action is not awaiting decoupled confirmation');
}
if ($action instanceof DialogInitialization) {
if ($this->dialogId !== null) {
throw new \RuntimeException('Cannot init another dialog.');
}
$this->dialogId = $action->getDialogId();
$this->messageNumber = $action->getMessageNumber();
}
// (2a) Construct the request.
$tanMode = $this->requireTanMode();
if ($tanMode instanceof NoPsd2TanMode) {
throw new \InvalidArgumentException('Cannot check decoupled status when the bank does not support PSD2');
}
if (!$tanMode->isDecoupled()) {
throw new \InvalidArgumentException('Cannot check decoupled status for a non-decoupled TAN mode');
}
$message = MessageBuilder::create()
->add(HKTANFactory::createProzessvariante2StepS($tanMode, $tanRequest->getProcessId()));
$request = $this->buildMessage($message, $tanMode);
// Execute the request.
$response = $this->sendMessage($request);
$this->readBPD($response);
// Determine if the decoupled authentication has completed. See section B.4.2.2.1.
// There is always at least one HITAN segment with TAN-Prozess=S and the reference ID.
// (2b) The response code 3956 indicates that the authentication is still outstanding. There could also be more
// information for the user in the HITAN challenge field.
// (2c) Note that we only support the (B) variant here. There is additionally supposed to be a HITAN segment
// with TAN-Prozess=2 and the reference ID to indicate that the authentication has completed, though not
// all banks actually send this, as they seem to consider the absence of 3956 as sufficient for signaling
// success. In this case, the response also contains the response segments for the executed action, if any.
$hitanProcessS = null;
/** @var HITAN $hitan */
foreach ($response->findSegments(HITAN::class) as $hitan) {
if ($hitan->getAuftragsreferenz() !== $tanRequest->getProcessId()) {
throw new UnexpectedResponseException('Unexpected Auftragsreferenz: ' . $hitan->getAuftragsreferenz());
}
if ($hitan->getTanProzess() === HKTAN::TAN_PROZESS_S) {
$hitanProcessS = $hitan;
}
}
if ($hitanProcessS === null) {
throw new UnexpectedResponseException('Missing HITAN with tanProzess=S in the response');
}
if ($response->findRueckmeldungen(Rueckmeldungscode::STARKE_KUNDENAUTHENTIFIZIERUNG_NOCH_AUSSTEHEND)) {
// The decoupled submission isn't complete yet. Update the TAN request, as the bank may have sent additional
// instructions.
$action->setTanRequest($hitanProcessS);
if ($action instanceof DialogInitialization) {
$this->dialogId = null;
$action->setMessageNumber($this->messageNumber);
}
return false;
}
// The decoupled submission is complete and the action's result is included in the response.
$action->setTanRequest(null);
// Process the response normally, and maybe keep going for more pages.
$this->processActionResponse($action, $response->filterByReferenceSegments($action->getRequestSegmentNumbers()));
if ($action instanceof PaginateableAction && $action->hasMorePages()) {
$this->execute($action);
}
return true;
}
/**
* For an action where {@link BaseAction::needsPollingWait()} returns `true`, this function polls the server.
* By using {@link persist()}, this can be done asynchronously, i.e., not in the same PHP process as the original
* {@link execute()} call or the previous {@link pollAction()} call.
*
* After this function returns, the `$action` is in any of the same states as after {@link execute()}, see there. In
* particular, it's possible that the long-running operation on the server has not completed yet and thus
* {@link BaseAction::needsPollingWait()} still returns `true`. In practice, actions often require VOP confirmation
* or a TAN after the polling is over, though they can also complete right away.
* In case the action fails, the corresponding exception will be thrown from this function.
*
* @param BaseAction $action The action to be completed.
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
* @throws UnexpectedResponseException When the server responds with a valid but unexpected message.
* @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things
* that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc.
* @link FinTS_3.0_Messages_Geschaeftsvorfaelle_VOP_1.01_2025_06_27_FV.pdf
* Section C.10.7.1.1 a)
*/
public function pollAction(BaseAction $action): void
{
$pollingInfo = $action->getPollingInfo();
if ($pollingInfo === null) {
throw new \InvalidArgumentException('This action is not awaiting polling for a long-running operation');
} elseif ($pollingInfo instanceof VopPollingInfo) {
// Only send a new HKVPP.
$hkvpp = VopHelper::createHKVPPForPollingRequest($this->bpd, $pollingInfo);
$message = MessageBuilder::create()->add($hkvpp);
// Execute the request and process the response.
$response = $this->sendMessage($this->buildMessage($message, $this->getSelectedTanMode()));
$action->setPollingInfo(null);
$this->processServerResponse($action, $response, $hkvpp);
} else {
throw new \InvalidArgumentException('Unexpected PollingInfo type: ' . gettype($pollingInfo));
}
}
/**
* For an action where {@link BaseAction::needsVopConfirmation()} returns `true`, this function re-submits the
* action with the additional confirmation from the user that they want to execute the transfer(s) after having
* reviewed the information from the {@link VopConfirmationRequest}.
* By using {@link persist()}, this can be done asynchronously, i.e., not in the same PHP process as the original
* {@link execute()} call.
*
* After this function returns, the `$action` is in any of the same states as after {@link execute()}, see there. In
* practice, actions often require a TAN after VOP is confirmed, though they can also complete right away.
* In case the action fails, the corresponding exception will be thrown from this function.
*
* @param BaseAction $action The action to be completed.
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
* @throws UnexpectedResponseException When the server responds with a valid but unexpected message.
* @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things
* that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc.
* @link FinTS_3.0_Messages_Geschaeftsvorfaelle_VOP_1.01_2025_06_27_FV.pdf
* Section C.10.7.1.2 a)
*/
public function confirmVop(BaseAction $action): void
{
$vopConfirmationRequest = $action->getVopConfirmationRequest();
if (!($vopConfirmationRequest instanceof VopConfirmationRequestImpl)) {
throw new \InvalidArgumentException('Unexpected type: ' . gettype($vopConfirmationRequest));
}
// We need to send the original request again, plus HKVPA as the confirmation.
$requestSegments = $action->getNextRequest($this->bpd, $this->upd);
if (count($requestSegments) === 0) {
throw new \AssertionError('Request unexpectedly became empty upon VOP confirmation');
}
$message = MessageBuilder::create()
->add($requestSegments)
->add(VopHelper::createHKVPAForConfirmation($vopConfirmationRequest));
// Add HKTAN for authentication if necessary.
if (!($this->getSelectedTanMode() instanceof NoPsd2TanMode)) {
if (($needTanForSegment = $action->getNeedTanForSegment()) !== null) {
$message->add(HKTANFactory::createProzessvariante2Step1(
$this->requireTanMode(), $this->selectedTanMedium, $needTanForSegment));
}
}
// Construct the request message and tell the action about the segment numbers that were assigned.
$request = $this->buildMessage($message, $this->getSelectedTanMode()); // This fills in the segment numbers.
$action->setRequestSegmentNumbers(array_map(function ($segment) {
/* @var BaseSegment $segment */
return $segment->getSegmentNumber();
}, $requestSegments));
// Execute the request and process the response.
$response = $this->sendMessage($this->buildMessage($message, $this->getSelectedTanMode()));
$action->setVopConfirmationRequest(null);
$this->processServerResponse($action, $response);
}
/**
* Closes the session/dialog/connection, if open. This is equivalent to logging out. You should call this function
* when you're done with all the actions, but NOT when you're persisting the instance to fulfill the TAN request of
* an outstanding action.
* This FinTs object remains usable even after closing the session. You can still {@link persist()} it to benefit
* from cached BPD/UPD upon the next {@link login()}, for instance.
* @throws ServerException When closing the dialog fails.
*/
public function close(): void
{
if ($this->dialogId !== null) {
$this->endDialog();
}
$this->disconnect();
}
/**
* Assumes that the session/dialog (if any is open) is gone, but keeps any cached BPD/UPD for reuse (to allow for
* faster re-login).
* This can be called by the application using this library when it just restored this FinTs instance from the
* persisted format after a long time, during which the session/dialog has most likely expired on the server side.
*/
public function forgetDialog(): void
{
$this->dialogId = null;
}
/**
* Before executing any actions that might require two-step authentication (like fetching a statement or initiating
* a wire transfer), the user needs to pick a {@link TanMode}. Note that this does not always imply that the user
* actually needs to enter a TAN every time, but they need to have picked the mode so that the system knows how to
* deliver a TAN, if necesssary.
* @return TanMode[] The TAN modes that are available to the user, indexed by their IDs.
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
* @throws UnexpectedResponseException When the server does not send the BPD, the Kundensystem-ID or the TAN modes
* like it should according to the protocol, or when the dialog is not closed properly.
* @throws ServerException When the server responds with an error.
*/
public function getTanModes(): array
{
$this->ensureTanModesAvailable();
$result = [];
foreach ($this->allowedTanModes as $tanModeId) {
if (!array_key_exists($tanModeId, $this->bpd->allTanModes)) {
continue;
}
$result[$tanModeId] = $this->bpd->allTanModes[$tanModeId];
}
return $result;
}
/**
* For TAN modes where {@link TanMode::needsTanMedium()} returns true, the user additionally needs to pick a TAN
* medium. This function returns a list of possible TAN media. Note that, depending on the bank, this list may
* contain all the user's TAN media, or just the ones that are compatible with the given $tanMode.
* @param TanMode|int $tanMode Either a {@link TanMode} instance obtained from {@link getTanModes()} or its ID.
* @return TanMedium[] A list of possible TAN media.
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
* @throws UnexpectedResponseException When the server does not send the BPD, the Kundensystem-ID or the TAN media
* (which includes the case where the server does not support enumerating TAN media, which is indicated by
* {@link TanMode::needsTanMedium()} returning false), or when the dialog is not closed properly.
* @throws ServerException When the server responds with an error.
*/
public function getTanMedia($tanMode): array
{
if ($this->dialogId !== null) {
$this->endDialog();
}
$this->ensureBpdAvailable();
$this->ensureSynchronized();
$getTanMedia = new GetTanMedia();
// Execute the GetTanMedia request with the $tanMode swapped in temporarily.
$oldTanMode = $this->selectedTanMode;
$oldTanMedium = $this->selectedTanMedium;
$this->selectedTanMode = $tanMode instanceof TanMode ? $tanMode->getId() : $tanMode;
$this->selectedTanMedium = '';
try {
$this->executeWeakDialogInitialization('HKTAB');
$this->execute($getTanMedia);
$this->endDialog();
return $getTanMedia->getTanMedia();
} catch (UnexpectedResponseException|CurlException|ServerException $e) {
throw $e;
} finally {
$this->selectedTanMode = $oldTanMode;
$this->selectedTanMedium = $oldTanMedium;
}
}
/**
* @param TanMode|int $tanMode Either a {@link TanMode} instance obtained from {@link getTanModes()} or its ID.
* @param TanMedium|string|null $tanMedium If the $tanMode has {@link TanMode::needsTanMedium()} set to true, this
* must be the value returned from {@link TanMedium::getName()} for one of the TAN media supported with that TAN
* mode. Use {@link getTanMedia()} to obtain a list of possible TAN media options.
*/
public function selectTanMode($tanMode, $tanMedium = null): void
{
if (!is_int($tanMode) && !($tanMode instanceof TanMode)) {
throw new \InvalidArgumentException('tanMode must be an int or a TanMode');
}
if ($tanMedium !== null && !is_string($tanMedium) && !($tanMedium instanceof TanMedium)) {
throw new \InvalidArgumentException('tanMedium must be a string or a TanMedium');
}
$this->selectedTanMode = $tanMode instanceof TanMode ? $tanMode->getId() : $tanMode;
$this->selectedTanMedium = $tanMedium instanceof TanMedium ? $tanMedium->getName() : $tanMedium;
}
/**
* Fetches the BPD from the server, if they are not already present at the client, and then returns them. Note that
* this does not require user login.
* @return BPD The BPD from the bank.
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
* @throws UnexpectedResponseException When the server does not send the BPD or close the dialog properly.
* @throws ServerException When the server resopnds with an error.
*/
public function getBpd(): BPD
{
$this->ensureBpdAvailable();
return $this->bpd;
}
// ------------------------------------------------- IMPLEMENTATION ------------------------------------------------
/**
* Ensures that the latest BPD data is present by executing an anonymous dialog (including initialization and
* termination of the dialog) if necessary. Executing this does not require (strong or any) authentication, and it
* makes the {@link $bpd} available.
*
* @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Formals_2017-10-06_final_version.pdf
* Section: C.5.1 (and also C.3.1.1)
*
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
* @throws UnexpectedResponseException When the server does not send the BPD or close the dialog properly.
* @throws ServerException When the server resopnds with an error.
*/
private function ensureBpdAvailable(): void
{
if ($this->bpd !== null) {
return; // Nothing to do.
}
if ($this->dialogId !== null) {
throw new \RuntimeException('Cannot init another dialog.');
}
if ($this->selectedTanMode === NoPsd2TanMode::ID || $this->selectedTanMode instanceof NoPsd2TanMode) {
// For banks that don't support PSD2, we also don't use an anonymous dialog to obtain the BPD. The more
// common procedure before PSD2 was to just get the BPD upon first login. Thus execute(DialogInitialization)
// tolerates not having a BPD yet.
return;
}
// We must always include HKTAN in order to signal that strong authentication (PSD2) is supported (section
// B.4.3.1). As this is the first contact with the server, we don't know which HKTAN versions it supports, so we
// just sent HKTANv6 as it's currently most supported by banks.
$initRequest = Message::createPlainMessage(MessageBuilder::create()
->add(HKIDNv2::createAnonymous($this->options->bankCode))
->add(HKVVBv3::create($this->options, null, null)) // Pretend we have no BPD/UPD.
->add(HKTANv6::createDummy()));
$initResponse = $this->sendMessage($initRequest);
if (!$this->readBPD($initResponse)) {
throw new UnexpectedResponseException('Did not receive BPD');
}
$this->dialogId = $initResponse->header->dialogId;
$this->endDialog(true);
}
private function requireCredentials(): Credentials
{
if ($this->credentials === null) {
throw new \LogicException('This action is not allowed on a FinTs instance without Credentials');
}
return $this->credentials;
}
/**
* Ensures that the {@link $allowedTanModes} are available by executing a personalized, TAN-less dialog
* initialization (and closing the dialog again), if necessary. Executing this only requires the {@link Credentials}
* but no strong authentication.
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
* @throws UnexpectedResponseException When the server does not send the BPD, the Kundensystem-ID or the TAN modes
* like it should according to the protocol, or when the dialog is not closed properly.
* @throws ServerException When the server responds with an error.
*/
private function ensureTanModesAvailable(): void
{
if ($this->allowedTanModes === null) {
$this->ensureBpdAvailable();
$this->ensureSynchronized(); // The response here will contain 3920, which is written to $allowedTanModes.
if ($this->allowedTanModes === null) {
throw new UnexpectedResponseException('No TAN modes received');
}
}
}
/**
* Ensures that we have a {@link $kundensystemId} by executing a synchronization dialog (and closing it again) if
* if necessary. Executing this does not require strong authentication.
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
* @throws UnexpectedResponseException When the server does not send the BPD or the Kundensystem-ID, or when the
* dialog is not closed properly.
* @throws ServerException When the server responds with an error.
*/
private function ensureSynchronized(): void
{
if ($this->kundensystemId === null) {
$this->ensureBpdAvailable();
// Execute dialog initialization without a TAN mode/medium, so using the fake mode 999. While most banks
// accept the real TAN mode for synchronization (as defined in the specification), some get confused by the
// presence of anything other than 999 into thinking that strong authentication is required. And for those
// banks that don't support PSD2, we just keep the dummy TAN mode, as they wouldn't even understand 999.
$oldTanMode = $this->selectedTanMode;
$oldTanMedium = $this->selectedTanMedium;
if (!($this->selectedTanMode instanceof NoPsd2TanMode)) {
$this->selectedTanMode = null;
}
$this->selectedTanMedium = null;
try {
$this->executeWeakDialogInitialization(null);
if ($this->kundensystemId === null) {
throw new UnexpectedResponseException('No Kundensystem-ID retrieved from sync.');
}
$this->endDialog();
} finally {
$this->selectedTanMode = $oldTanMode;
$this->selectedTanMedium = $oldTanMedium;
}
}
}
/**
* If the selected TAN mode was provided as an int, resolves it to a full {@link TanMode} instance, which may
* involve a request to the server to retrieve the BPD. Then returns it.
* @return TanMode|null The current TAN mode, null if none was selected, never an int.
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
* @throws ServerException When the server resopnds with an error during the BPD fetch.
*/
public function getSelectedTanMode(): ?TanMode
{
if ($this->selectedTanMode === NoPsd2TanMode::ID) {
$this->selectedTanMode = new NoPsd2TanMode();
} elseif (is_int($this->selectedTanMode)) {
$this->ensureBpdAvailable();
if (!array_key_exists($this->selectedTanMode, $this->bpd->allTanModes)) {
throw new \InvalidArgumentException("Unknown TAN mode: $this->selectedTanMode");
}
$this->selectedTanMode = $this->bpd->allTanModes[$this->selectedTanMode];
if (!$this->selectedTanMode->isProzessvariante2()) {
throw new UnsupportedException('Only supports Prozessvariante 2');
}
if ($this->selectedTanMode->needsTanMedium()) {
if ($this->selectedTanMedium === null) {
throw new \InvalidArgumentException('tanMedium is mandatory for this tanMode');
}
} else {
if ($this->selectedTanMedium !== null) {
throw new \InvalidArgumentException('tanMedium not allowed for this tanMode');
}
}
}
return $this->selectedTanMode;
}
/**
* Like {@link getSelectedTanMode()}, but throws an exception if none was selected.
* @return TanMode The current TAN mode.
* @throws \RuntimeException If no TAN mode has been selected.
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
* @throws ServerException When the server resopnds with an error during the BPD fetch.
*/
private function requireTanMode(): TanMode
{
$tanMode = $this->getSelectedTanMode();
if ($tanMode === null) {
throw new \RuntimeException('selectTanMode() must be called before login() or execute()');
}
return $tanMode;
}
/**
* Creates a new connection based on the {@link $options}. This can be overridden for unit testing purposes.
* @return Connection A newly instantiated connection.
*/
protected function newConnection(): Connection
{
return new Connection($this->options->url, $this->options->timeoutConnect, $this->options->timeoutResponse);
}
/**
* Closes the physical connection, if necessary.
*/
private function disconnect(): void
{
if ($this->connection !== null) {
$this->connection->disconnect();
$this->connection = null;
}
}
/**
* Passes the response segments to the action for post-processing of the response.
* @param BaseAction $action The action to which the response belongs.
* @param Message $fakeResponseMessage A messsage that contains the response segments for this action.
* @throws UnexpectedResponseException When the server responded with a valid but unexpected message.
*/
private function processActionResponse(BaseAction $action, Message $fakeResponseMessage): void
{
$action->processResponse($fakeResponseMessage);
if ($action instanceof DialogInitialization) {
$this->dialogId = $action->getDialogId();
if ($this->kundensystemId === null && $action->getKundensystemId()) {
$this->kundensystemId = $action->getKundensystemId();
}
if ($action->getUpd() !== null) {
$this->upd = $action->getUpd();
} elseif ($this->upd === null && $action->isStronglyAuthenticated()) {
throw new UnexpectedResponseException('No UPD received');
}
}
}
/**
* Initialize a personalized dialog with weak authentication (no two-step authentication, no TAN, using the fake
* mode with ID 999 instead), which can be used for certain less sensitive business transactions, including HKTAB to
* retrieve the TAN media list. This is for Authentifizierungsklasse 1 and 4 (conditionally).
* @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_PINTAN_2018-02-23_final_version.pdf
* Section: B.3
* @param string|null $hktanRef The identifier of the main PIN/TAN management segment to be executed in this dialog,
* or null for a general weakly authenticated dialog. See {@link DialogInitialization} for documentation.
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
* @throws UnexpectedResponseException When the server does not send the BPD or the Kundensystem-ID as it should
* according to the protocol, when it asks for a TAN even though it shouldn't, or when the dialog is not closed
* properly.
* @throws ServerException When the server responds with an error.
*/
private function executeWeakDialogInitialization(?string $hktanRef): void
{
if ($this->dialogId !== null) {
throw new \RuntimeException('Cannot init another dialog.');
}
$this->messageNumber = 1;
$dialogInitialization = new DialogInitialization($this->options, $this->requireCredentials(),
$this->getSelectedTanMode(), $this->selectedTanMedium, $this->kundensystemId, $hktanRef);
$this->execute($dialogInitialization);
if ($dialogInitialization->needsTan()) {
throw new UnexpectedResponseException('Server asked for TAN on a dialog meant for weak authentication');
}
}
/**
* @param Message $response A response retrieved from the server that may or may not contain the BPD.
* @return bool Whether the BPD was found in the response.
*/
private function readBPD(Message $response): bool
{
if ($allowed = $response->findRueckmeldung(Rueckmeldungscode::ZUGELASSENE_VERFAHREN)) {
$this->allowedTanModes = array_map('intval', $allowed->rueckmeldungsparameter);
}
if (!$response->hasSegment(HIBPAv3::class)) {
return false;
}
$this->bpd = BPD::extractFromResponse($response);
if (!$this->bpd->supportsPsd2() && !($this->selectedTanMode instanceof NoPsd2TanMode)) {
throw new UnsupportedException('The bank does not support PSD2.');
}
return true;
}
/**
* Closes the currently active dialog, if any. Note that this does *not* close the connection, it is possible to
* open a new dialog on the same connection.
* @param bool $isAnonymous If set to true, the HKEND message will not be wrapped into an encryption envelope.
* @throws ServerException When the server responds with an error instead of closing the dialog. This means that
* the connection is tainted and can probably not be used for another dialog.
*/
protected function endDialog(bool $isAnonymous = false): void
{
if ($this->connection === null) {
$this->dialogId = null;
return;
}
try {
if ($this->dialogId !== null) {
$message = MessageBuilder::create()->add(HKENDv1::create($this->dialogId));
$request = $isAnonymous
? Message::createPlainMessage($message)
: $this->buildMessage($message, $this->getSelectedTanMode());
$response = $this->sendMessage($request);
if ($response->findRueckmeldung(Rueckmeldungscode::BEENDET) === null) {
throw new UnexpectedResponseException(
'Server did not confirm dialog end, but did not send error either');
}
}
} catch (CurlException $e) {
// Ignore, we want to disconnect anyway.
} catch (ServerException $e) {
if ($e->hasError(Rueckmeldungscode::ABGEBROCHEN)) {
// We wanted to end the dialog, but the server already canceled it before.
$this->logger->warning("Dialog already ended: $e");
} else {
// Something else went wrong.
throw $e;
}
} finally {
$this->dialogId = null;
}
}
/**
* Injects FinTsOptions/BPD/UPD/Credentials information into the message.
* @param MessageBuilder $message The message to be built.
* @param TanMode|null $tanMode Optionally a TAN mode that will be used when sending this message, defaults to 999
* (single step).
* @param string|null $tan Optionally a TAN to sign this message with.
* @return Message The built message.
*/
private function buildMessage(MessageBuilder $message, ?TanMode $tanMode = null, ?string $tan = null): Message
{
return Message::createWrappedMessage(
$message,
$this->options,
$this->kundensystemId === null ? '0' : $this->kundensystemId,
$this->requireCredentials(),
$tanMode,
$tan
);
}
/**
* Finalizes a message (conversion to wire format, filling in message number and size), sends it to the bank and
* parses the response, plus logging.
* @param MessageBuilder|Message $request The message to be sent.
* @return Message The response from the server.
* @throws CurlException When the request failed on the physical or TCP/HTTPS protocol level.
* @throws ServerException When the response contains an error.
*/
private function sendMessage($request): Message
{
if ($request instanceof MessageBuilder) {
$request = $this->buildMessage($request, $this->getSelectedTanMode());
}
$request->header->dialogId = $this->dialogId === null ? '0' : $this->dialogId;
$request->header->nachrichtennummer = $this->messageNumber;
$request->footer->nachrichtennummer = $this->messageNumber;
++$this->messageNumber;
$request->header->setNachrichtengroesse(strlen($request->serialize()));
$request->validate();
if ($this->connection === null) {
$this->connection = $this->newConnection();
}
$rawRequest = $request->serialize();
$this->logger->debug('> ' . $rawRequest);
try {
$rawResponse = $this->connection->send($rawRequest);
$this->logger->debug('< ' . $rawResponse);
} catch (CurlException $e) {
$this->logger->critical($e->getMessage());
$this->logger->debug(print_r($e->getCurlInfo(), true));
$this->disconnect();
throw $e;
}
try {
$response = Message::parse($rawResponse);
} catch (\InvalidArgumentException $e) {
$this->disconnect();
throw new InvalidResponseException('Invalid response from server', 0, $e);
}
try {
ServerException::detectAndThrowErrors($response, $request);
} catch (ServerException $e) {
$this->disconnect();
if ($e->hasError(Rueckmeldungscode::ABGEBROCHEN)) {
$this->forgetDialog();
}
throw $e;
}
return $response;
}
}