diff --git a/Samples/directDebit_Sephpa.php b/Samples/directDebit_Sephpa.php
index a3c076de..f51085fc 100644
--- a/Samples/directDebit_Sephpa.php
+++ b/Samples/directDebit_Sephpa.php
@@ -46,6 +46,6 @@
$sendSEPADirectDebit = \Fhp\Action\SendSEPADirectDebit::create($oneAccount, $xml);
$fints->execute($sendSEPADirectDebit);
-if ($sendSEPADirectDebit->needsTan()) {
- handleStrongAuthentication($sendSEPADirectDebit); // See login.php for the implementation.
-}
+
+require_once 'vop.php';
+handleVopAndAuthentication($sendSEPADirectDebit);
diff --git a/Samples/directDebit_phpSepaXml.php b/Samples/directDebit_phpSepaXml.php
index 2748592d..ba9de790 100644
--- a/Samples/directDebit_phpSepaXml.php
+++ b/Samples/directDebit_phpSepaXml.php
@@ -62,6 +62,6 @@
$sendSEPADirectDebit = \Fhp\Action\SendSEPADirectDebit::create($oneAccount, $sepaDD->toXML('pain.008.001.02'));
$fints->execute($sendSEPADirectDebit);
-if ($sendSEPADirectDebit->needsTan()) {
- handleStrongAuthentication($sendSEPADirectDebit); // See login.php for the implementation.
-}
+
+require_once 'vop.php';
+handleVopAndAuthentication($sendSEPADirectDebit);
diff --git a/Samples/transfer.php b/Samples/transfer.php
index 083d8e61..40dc8a54 100644
--- a/Samples/transfer.php
+++ b/Samples/transfer.php
@@ -21,6 +21,15 @@
/** @var \Fhp\FinTs $fints */
$fints = require_once 'login.php';
+// Just pick the first account, for demonstration purposes. You could also have the user choose, or have SEPAAccount
+// hard-coded and not call getSEPAAccounts() at all.
+$getSepaAccounts = \Fhp\Action\GetSEPAAccounts::create();
+$fints->execute($getSepaAccounts);
+if ($getSepaAccounts->needsTan()) {
+ handleStrongAuthentication($getSepaAccounts); // See login.php for the implementation.
+}
+$oneAccount = $getSepaAccounts->getAccounts()[0];
+
$dt = new \DateTime();
$dt->add(new \DateInterval('P1D'));
@@ -49,6 +58,6 @@
$sendSEPATransfer = \Fhp\Action\SendSEPATransfer::create($oneAccount, $sepaDD->toXML());
$fints->execute($sendSEPATransfer);
-if ($sendSEPATransfer->needsTan()) {
- handleStrongAuthentication($sendSEPATransfer); // See login.php for the implementation.
-}
+
+require_once 'vop.php';
+handleVopAndAuthentication($sendSEPATransfer);
diff --git a/Samples/vop.php b/Samples/vop.php
new file mode 100644
index 00000000..2864e5c7
--- /dev/null
+++ b/Samples/vop.php
@@ -0,0 +1,138 @@
+isDone()) {
+ if ($action->needsTan()) {
+ handleStrongAuthentication($action); // See login.php for the implementation.
+ } elseif ($action->needsPollingWait()) {
+ handlePollingWait($action);
+ } elseif ($action->needsVopConfirmation()) {
+ handleVopConfirmation($action);
+ } else {
+ throw new \AssertionError(
+ 'Action is not done but also does not need anything to be done. Did you execute() it?'
+ );
+ }
+ }
+}
+
+/**
+ * Waits for the amount of time that the bank prescribed and then polls the server for a status update.
+ * @param \Fhp\BaseAction $action An action for which {@link \Fhp\BaseAction::needsPollingWait()} returns true.
+ * @throws CurlException|UnexpectedResponseException|ServerException See {@link FinTs::execute()} for details.
+ */
+function handlePollingWait(\Fhp\BaseAction $action): void
+{
+ global $fints, $options, $credentials; // From login.php
+
+ // Tell the user what the bank had to say (if anything).
+ $pollingInfo = $action->getPollingInfo();
+ if ($infoText = $pollingInfo->getInformationForUser()) {
+ echo $infoText . PHP_EOL;
+ }
+
+ // Optional: If the wait is too long for your PHP process to remain alive (i.e. your server would kill the process),
+ // you can persist the state as shown here and instead send a response to the client-side application indicating
+ // that the operation is still ongoing. Then after an appropriate amount of time, the client can send another
+ // request, spawning a new PHP process, where you can restore the state as shown below.
+ if ($optionallyPersistEverything = false) {
+ $persistedAction = serialize($action);
+ $persistedFints = $fints->persist();
+
+ // These are two strings (watch out, they are NOT necessarily UTF-8 encoded), which you can store anywhere.
+ // This example code stores them in a text file, but you might write them to your database (use a BLOB, not a
+ // CHAR/TEXT field to allow for arbitrary encoding) or in some other storage (possibly base64-encoded to make it
+ // ASCII).
+ file_put_contents(__DIR__ . '/state.txt', serialize([$persistedFints, $persistedAction]));
+ }
+
+ // Wait for (at least) the prescribed amount of time. --------------------------------------------------------------
+ // Note: In your real application, you may be doing this waiting on the client and then send a fresh request to your
+ // server.
+ $waitSecs = $pollingInfo->getNextAttemptInSeconds() ?: 5;
+ echo "Waiting for $waitSecs seconds before polling the bank server again..." . PHP_EOL;
+ sleep($waitSecs);
+
+ // Optional: If the state was persisted above, we can restore it now (imagine this is a new PHP process).
+ if ($optionallyPersistEverything) {
+ $restoredState = file_get_contents(__DIR__ . '/state.txt');
+ list($persistedInstance, $persistedAction) = unserialize($restoredState);
+ $fints = \Fhp\FinTs::new($options, $credentials, $persistedInstance);
+ $action = unserialize($persistedAction);
+ }
+
+ $fints->pollAction($action);
+ // Now the action is in a new state, which the caller of this function (handleVopAndAuthentication) will deal with.
+}
+
+/**
+ * Asks the user to confirm
+ * @param \Fhp\BaseAction $action An action for which {@link \Fhp\BaseAction::needsVopConfirmation()} returns true.
+ * @throws CurlException|UnexpectedResponseException|ServerException See {@link FinTs::execute()} for details.
+ */
+function handleVopConfirmation(\Fhp\BaseAction $action): void
+{
+ global $fints, $options, $credentials; // From login.php
+
+ $vopConfirmationRequest = $action->getVopConfirmationRequest();
+ if ($infoText = $vopConfirmationRequest->getInformationForUser()) {
+ echo $infoText . PHP_EOL;
+ }
+ echo match ($vopConfirmationRequest->getVerificationResult()) {
+ \Fhp\Model\VopVerificationResult::CompletedFullMatch =>
+ 'The bank says the payee information matched perfectly, but still wants you to confirm.',
+ \Fhp\Model\VopVerificationResult::CompletedCloseMatch =>
+ 'The bank says the payee information does not match exactly, so please confirm.',
+ \Fhp\Model\VopVerificationResult::CompletedPartialMatch =>
+ 'The bank says the payee information does not match for all transfers, so please confirm.',
+ \Fhp\Model\VopVerificationResult::CompletedNoMatch =>
+ 'The bank says the payee information does not match, but you can still confirm the transfer if you want.',
+ \Fhp\Model\VopVerificationResult::NotApplicable =>
+ $vopConfirmationRequest->getVerificationNotApplicableReason() == null
+ ? 'The bank did not provide any information about payee verification, but you can still confirm.'
+ : 'The bank says: ' . $vopConfirmationRequest->getVerificationNotApplicableReason(),
+ default => 'The bank failed to provide information about payee verification, but you can still confirm.',
+ } . PHP_EOL;
+
+ // Just like in handleTan(), handleDecoupledSubmission() or handlePollingWait(), we have the option to interrupt the
+ // PHP process at this point, so that we can ask the user in a client application for their confirmation.
+ if ($optionallyPersistEverything = false) {
+ $persistedAction = serialize($action);
+ $persistedFints = $fints->persist();
+ // See handlePollingWait() for how to deal with this in practice.
+ file_put_contents(__DIR__ . '/state.txt', serialize([$persistedFints, $persistedAction]));
+ }
+
+ echo "In light of the information provided above, do you want to confirm the execution of the transfer?" . PHP_EOL;
+ // Note: We currently have no way canceling the transfer; the only thing we can do is never to confirm it.
+ echo "If so, please type 'confirm' and hit Return. Otherwise, please kill this PHP process." . PHP_EOL;
+ while (trim(fgets(STDIN)) !== 'confirm') {
+ echo "Try again." . PHP_EOL;
+ }
+ echo "Confirming the transfer." . PHP_EOL;
+ $fints->confirmVop($action);
+ echo "Confirmed" . PHP_EOL;
+ // Now the action is in a new state, which the caller of this function (handleVopAndAuthentication) will deal with.
+}
diff --git a/lib/Fhp/BaseAction.php b/lib/Fhp/BaseAction.php
index 287725ce..24610063 100644
--- a/lib/Fhp/BaseAction.php
+++ b/lib/Fhp/BaseAction.php
@@ -4,13 +4,17 @@
namespace Fhp;
+use Fhp\Model\PollingInfo;
use Fhp\Model\TanRequest;
+use Fhp\Model\VopConfirmationRequest;
use Fhp\Protocol\ActionIncompleteException;
+use Fhp\Protocol\ActionPendingException;
use Fhp\Protocol\BPD;
use Fhp\Protocol\Message;
use Fhp\Protocol\TanRequiredException;
use Fhp\Protocol\UnexpectedResponseException;
use Fhp\Protocol\UPD;
+use Fhp\Protocol\VopConfirmationRequiredException;
use Fhp\Segment\BaseSegment;
use Fhp\Segment\HIRMS\Rueckmeldung;
use Fhp\Segment\HIRMS\Rueckmeldungscode;
@@ -48,6 +52,12 @@ abstract class BaseAction implements \Serializable
/** If set, the last response from the server regarding this action asked for a TAN from the user. */
protected ?TanRequest $tanRequest = null;
+ /** If set, this action is currently waiting for a long-running operation on the server to complete. */
+ protected ?PollingInfo $pollingInfo = null;
+
+ /** If set, this action needs the user's confirmation to be completed. */
+ protected ?VopConfirmationRequest $vopConfirmationRequest = null;
+
protected bool $isDone = false;
/**
@@ -72,15 +82,15 @@ public function serialize(): string
}
/**
- * An action can only be serialized *after* it has been executed in case it needs a TAN, i.e. when the result is not
- * present yet.
+ * An action can only be serialized *after* it has been executed and *if* it wasn't completed yet (e.g. because it
+ * still requires a TAN or VOP confirmation).
* If a sub-class overrides this, it should call the parent function and include it in its result.
*
* @return array The serialized action, e.g. for storage in a database. This will not contain sensitive user data.
*/
public function __serialize(): array
{
- if (!$this->needsTan()) {
+ if (!$this->needsTan() && !$this->needsPollingWait() && !$this->needsVopConfirmation()) {
throw new \RuntimeException('Cannot serialize this action, because it is not waiting for a TAN.');
}
return [
@@ -139,25 +149,54 @@ public function getTanRequest(): ?TanRequest
return $this->tanRequest;
}
+ public function needsPollingWait(): bool
+ {
+ return !$this->isDone() && $this->pollingInfo !== null;
+ }
+
+ public function getPollingInfo(): ?PollingInfo
+ {
+ return $this->pollingInfo;
+ }
+
+ public function needsVopConfirmation(): bool
+ {
+ return !$this->isDone() && $this->vopConfirmationRequest !== null;
+ }
+
+ public function getVopConfirmationRequest(): ?VopConfirmationRequest
+ {
+ return $this->vopConfirmationRequest;
+ }
+
/**
* Throws an exception unless this action has been successfully executed, i.e. in the following cases:
* - the action has not been {@link FinTs::execute()}-d at all or the {@link FinTs::execute()} call for it threw an
* exception,
- * - the action is awaiting a TAN/confirmation (as per {@link BaseAction::needsTan()}.
+ * - the action is awaiting a TAN/confirmation (as per {@link BaseAction::needsTan()},
+ * - the action is pending a long-running operation on the bank server ({@link BaseAction::needsPollingWait()}),
+ * - the action is awaiting the user's confirmation of the Verification of Payee result (as per
+ * {@link BaseAction::needsVopConfirmation()}).
*
* After executing an action, you can use this function to make sure that it succeeded. This is especially useful
* for actions that don't have any results (as each result getter would call {@link ensureDone()} internally).
* On the other hand, you do not need to call this function if you make sure that (1) you called
- * {@link FinTs::execute()} and (2) you checked {@link needsTan()} and, if it returned true, supplied a TAN by
- * calling {@ink FinTs::submitTan()}. Note that both exception types thrown from this method are sub-classes of
- * {@link \RuntimeException}, so you shouldn't need a try-catch block at the call site for this.
+ * {@link FinTs::execute()} and (2) you checked and resolved all other special outcome states documented there.
+ * Note that both exception types thrown from this method are sub-classes of {@link \RuntimeException}, so you
+ * shouldn't need a try-catch block at the call site for this.
* @throws ActionIncompleteException If the action hasn't even been executed.
+ * @throws ActionPendingException If the action is pending a long-running server operation that needs polling.
+ * @throws VopConfirmationRequiredException If the action requires the user's confirmation for VOP.
* @throws TanRequiredException If the action needs a TAN.
*/
- public function ensureDone()
+ public function ensureDone(): void
{
if ($this->tanRequest !== null) {
throw new TanRequiredException($this->tanRequest);
+ } elseif ($this->pollingInfo !== null) {
+ throw new ActionPendingException($this->pollingInfo);
+ } elseif ($this->vopConfirmationRequest !== null) {
+ throw new VopConfirmationRequiredException($this->vopConfirmationRequest);
} elseif (!$this->isDone()) {
throw new ActionIncompleteException();
}
@@ -248,4 +287,16 @@ final public function setTanRequest(?TanRequest $tanRequest): void
{
$this->tanRequest = $tanRequest;
}
+
+ /** To be called only by the FinTs instance that executes this action. */
+ final public function setPollingInfo(?PollingInfo $pollingInfo): void
+ {
+ $this->pollingInfo = $pollingInfo;
+ }
+
+ /** To be called only by the FinTs instance that executes this action. */
+ final public function setVopConfirmationRequest(?VopConfirmationRequest $vopConfirmationRequest): void
+ {
+ $this->vopConfirmationRequest = $vopConfirmationRequest;
+ }
}
diff --git a/lib/Fhp/FinTs.php b/lib/Fhp/FinTs.php
index e69ef2af..ef67e4d4 100644
--- a/lib/Fhp/FinTs.php
+++ b/lib/Fhp/FinTs.php
@@ -5,6 +5,9 @@
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\Options\Credentials;
use Fhp\Options\FinTsOptions;
use Fhp\Options\SanitizingLogger;
@@ -26,6 +29,8 @@
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;
@@ -285,7 +290,7 @@ public function login(): DialogInitialization
/**
* 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 two possible states:
+ * 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
@@ -293,9 +298,29 @@ public function login(): DialogInitialization
* 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::needsTan()} returns false, 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.
+ * 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 TODO.
+ * 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.
@@ -325,7 +350,14 @@ public function execute(BaseAction $action): void
}
}
- // Construct the request and tell the action about the segment numbers that were assigned.
+ // 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 */
@@ -334,7 +366,7 @@ public function execute(BaseAction $action): void
// Execute the request.
$response = $this->sendMessage($request);
- $this->processServerResponse($action, $response);
+ $this->processServerResponse($action, $response, $hkvpp);
}
/**
@@ -342,12 +374,13 @@ public function execute(BaseAction $action): void
* 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): void
+ private function processServerResponse(BaseAction $action, Message $response, ?HKVPPv1 $hkvpp = null): void
{
$this->readBPD($response);
@@ -370,7 +403,21 @@ private function processServerResponse(BaseAction $action, Message $response): v
return;
}
- // If no TAN is needed, process the response normally, and maybe keep going for more pages.
+ // 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);
+ return;
+ }
+ if ($confirmationRequest = VopHelper::checkVopConfirmationRequired($response, $hkvpp->getSegmentNumber())) {
+ $action->setVopConfirmationRequest($confirmationRequest);
+ return;
+ }
+ // Note: It's possible we get VOP_AUSFUEHRUNGSAUFTRAG_NICHT_BENOETIGT, but we ignore it here because it's
+ // not actionable -- the action was completed without requiring verification after all.
+ }
+
+ // 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);
@@ -382,9 +429,9 @@ private function processServerResponse(BaseAction $action, Message $response): v
* `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 completed. That is, its result is available through its getters
- * just as if it had been completed by the original call to {@link execute()} right away. In case the action fails,
- * the corresponding exception will be thrown from this function.
+ * 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
@@ -450,7 +497,9 @@ public function submitTan(BaseAction $action, string $tan): void
* 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 completes the given action and returns `true`.
+ * - 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.
@@ -466,9 +515,10 @@ public function submitTan(BaseAction $action, string $tan): void
* 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. 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.
+ * @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
@@ -547,6 +597,99 @@ public function checkDecoupledSubmission(BaseAction $action): bool
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
diff --git a/lib/Fhp/Model/PollingInfo.php b/lib/Fhp/Model/PollingInfo.php
new file mode 100644
index 00000000..68ff71f8
--- /dev/null
+++ b/lib/Fhp/Model/PollingInfo.php
@@ -0,0 +1,23 @@
+vopId = $vopId;
+ $this->expiration = $expiration;
+ $this->informationForUser = $informationForUser;
+ $this->verificationResult = $verificationResult;
+ $this->verificationNotApplicableReason = $verificationNotApplicableReason;
+ }
+
+ public function getVopId(): Bin
+ {
+ return $this->vopId;
+ }
+
+ public function getExpiration(): ?\DateTime
+ {
+ return $this->expiration;
+ }
+
+ public function getInformationForUser(): ?string
+ {
+ return $this->informationForUser;
+ }
+
+ public function getVerificationResult(): ?string
+ {
+ return $this->verificationResult;
+ }
+
+ public function getVerificationNotApplicableReason(): ?string
+ {
+ return $this->verificationNotApplicableReason;
+ }
+}
diff --git a/lib/Fhp/Model/VopPollingInfo.php b/lib/Fhp/Model/VopPollingInfo.php
new file mode 100644
index 00000000..903b345f
--- /dev/null
+++ b/lib/Fhp/Model/VopPollingInfo.php
@@ -0,0 +1,50 @@
+aufsetzpunkt = $aufsetzpunkt;
+ $this->pollingId = $pollingId;
+ $this->nextAttemptInSeconds = $nextAttemptInSeconds;
+ }
+
+ public function getAufsetzpunkt(): string
+ {
+ return $this->aufsetzpunkt;
+ }
+
+ public function getPollingId(): ?Bin
+ {
+ return $this->pollingId;
+ }
+
+ public function getNextAttemptInSeconds(): ?int
+ {
+ return $this->nextAttemptInSeconds;
+ }
+
+ public function getInformationForUser(): string
+ {
+ return 'The bank is verifying payee information...';
+ }
+}
diff --git a/lib/Fhp/Model/VopVerificationResult.php b/lib/Fhp/Model/VopVerificationResult.php
new file mode 100644
index 00000000..a5b42b14
--- /dev/null
+++ b/lib/Fhp/Model/VopVerificationResult.php
@@ -0,0 +1,50 @@
+ self::CompletedFullMatch,
+ 'RVMC' => self::CompletedCloseMatch,
+ 'RVNM' => self::CompletedNoMatch,
+ 'RVCM' => self::CompletedPartialMatch,
+ 'RVNA' => self::NotApplicable,
+ default => throw new UnexpectedResponseException("Unexpected VOP result code: $codeFromBank"),
+ };
+ }
+}
diff --git a/lib/Fhp/Protocol/ActionPendingException.php b/lib/Fhp/Protocol/ActionPendingException.php
new file mode 100644
index 00000000..9424dc1f
--- /dev/null
+++ b/lib/Fhp/Protocol/ActionPendingException.php
@@ -0,0 +1,26 @@
+pollingInfo = $pollingInfo;
+ }
+
+ public function getPollingInfo(): PollingInfo
+ {
+ return $this->pollingInfo;
+ }
+}
diff --git a/lib/Fhp/Protocol/BPD.php b/lib/Fhp/Protocol/BPD.php
index de8caf4d..5241ab46 100644
--- a/lib/Fhp/Protocol/BPD.php
+++ b/lib/Fhp/Protocol/BPD.php
@@ -10,6 +10,7 @@
use Fhp\Segment\HIPINS\HIPINSv1;
use Fhp\Segment\SegmentInterface;
use Fhp\Segment\TAN\HITANS;
+use Fhp\Segment\VPP\HIVPPSv1;
/**
* Segmentfolge: Bankparameterdaten (Version 3)
@@ -152,6 +153,28 @@ public function tanRequiredForRequest(array $requestSegments): ?string
return null;
}
+ /**
+ * @param SegmentInterface[] $requestSegments The segments that shall be sent to the bank.
+ * @return string|null Identifier of the (first) segment that requires Verification of Payee according to HIPINS, or
+ * null if none of the segments require verification.
+ */
+ public function vopRequiredForRequest(array $requestSegments): ?string
+ {
+ /** @var HIVPPSv1 $hivpps */
+ $hivpps = $this->getLatestSupportedParameters('HIVPPS');
+ $vopRequiredTypes = $hivpps?->parameter?->vopPflichtigerZahlungsverkehrsauftrag;
+ if ($vopRequiredTypes === null) {
+ return null;
+ }
+
+ foreach ($requestSegments as $segment) {
+ if (in_array($segment->getName(), $vopRequiredTypes)) {
+ return $segment->getName();
+ }
+ }
+ return null;
+ }
+
/**
* @return bool Whether the BPD indicates that the bank supports PSD2.
*/
diff --git a/lib/Fhp/Protocol/Message.php b/lib/Fhp/Protocol/Message.php
index c637a896..e5709c20 100644
--- a/lib/Fhp/Protocol/Message.php
+++ b/lib/Fhp/Protocol/Message.php
@@ -190,12 +190,17 @@ public function filterByReferenceSegments(array $referenceNumbers): Message
/**
* @param int $code The response code to search for.
+ * @param ?int $requestSegmentNumber If set, only consider Rueckmeldungen that pertain to this request segment.
* @return Rueckmeldung|null The corresponding Rueckmeldung instance, or null if not found.
*/
- public function findRueckmeldung(int $code): ?Rueckmeldung
+ public function findRueckmeldung(int $code, ?int $requestSegmentNumber = null): ?Rueckmeldung
{
foreach ($this->plainSegments as $segment) {
- if ($segment instanceof RueckmeldungContainer) {
+ if (
+ $segment instanceof RueckmeldungContainer && (
+ $requestSegmentNumber === null || $segment->segmentkopf->bezugselement === $requestSegmentNumber
+ )
+ ) {
$rueckmeldung = $segment->findRueckmeldung($code);
if ($rueckmeldung !== null) {
return $rueckmeldung;
diff --git a/lib/Fhp/Protocol/VopConfirmationRequiredException.php b/lib/Fhp/Protocol/VopConfirmationRequiredException.php
new file mode 100644
index 00000000..03ec77b0
--- /dev/null
+++ b/lib/Fhp/Protocol/VopConfirmationRequiredException.php
@@ -0,0 +1,26 @@
+vopConfirmationRequest = $vopConfirmationRequest;
+ }
+
+ public function getVopConfirmationRequest(): VopConfirmationRequest
+ {
+ return $this->vopConfirmationRequest;
+ }
+}
diff --git a/lib/Fhp/Segment/VPP/VopHelper.php b/lib/Fhp/Segment/VPP/VopHelper.php
new file mode 100644
index 00000000..fc90a7cc
--- /dev/null
+++ b/lib/Fhp/Segment/VPP/VopHelper.php
@@ -0,0 +1,135 @@
+getLatestSupportedParameters('HIVPPS');
+ $supportedFormats = explode(';', $hivpps->parameter->unterstuetztePaymentStatusReportDatenformate);
+ if ($hivpps->parameter->artDerLieferungPaymentStatusReport !== 'V') {
+ throw new UnsupportedException('The stepwise transfer of VOP reports is not yet supported');
+ }
+
+ $hkvpp = HKVPPv1::createEmpty();
+ $hkvpp->unterstuetztePaymentStatusReports->paymentStatusReportDescriptor = $supportedFormats;
+ return $hkvpp;
+ }
+
+ /**
+ * @param BPD $bpd The BPD.
+ * @param VopPollingInfo $pollingInfo The polling info we got from the immediately preceding request.
+ * @return HKVPPv1 A segment to poll the server for the completion of Verification of Payee.
+ */
+ public static function createHKVPPForPollingRequest(BPD $bpd, VopPollingInfo $pollingInfo): HKVPPv1
+ {
+ $hkvpp = static::createHKVPPForInitialRequest($bpd);
+ $hkvpp->aufsetzpunkt = $pollingInfo->getAufsetzpunkt();
+ $hkvpp->pollingId = $pollingInfo->getPollingId();
+ return $hkvpp;
+ }
+
+ /**
+ * @param Message $response The response we just received from the server.
+ * @param int $hkvppSegmentNumber The number of the HKVPP segment in the request we had sent.
+ * @return ?VopPollingInfo If the response indicates that the Verification of Payee is still ongoing, such that the
+ * client should keep polling the server to (actively) wait until the result is available, this function returns
+ * a corresponding polling info object. If no polling is required, it returns null.
+ */
+ public static function checkPollingRequired(Message $response, int $hkvppSegmentNumber): ?VopPollingInfo
+ {
+ // Note: We determine whether polling is required purely based on the presence of the primary polling token (
+ // the Aufsetzpunkt is mandatory, the polling ID is optional).
+ // The specification also contains the code "3093 Namensabgleich ist noch in Bearbeitung", which could also be
+ // used to indicate that polling is required. But the specification does not mandate its use, and we have not
+ // observed banks using it consistently, so we don't rely on it here.
+ $aufsetzpunkt = $response->findRueckmeldung(Rueckmeldungscode::AUFSETZPUNKT, $hkvppSegmentNumber);
+ if ($aufsetzpunkt === null) {
+ return null;
+ }
+ /** @var HIVPPv1 $hivpp */
+ $hivpp = $response->findSegment(HIVPPv1::class);
+ if ($hivpp->vopId !== null || $hivpp->paymentStatusReport !== null) {
+ // Implementation note: If this ever happens, it could be related to $artDerLieferungPaymentStatusReport.
+ throw new UnexpectedResponseException('Got response with Aufsetzpunkt AND vopId/paymentStatusReport.');
+ }
+ return new VopPollingInfo(
+ $aufsetzpunkt->rueckmeldungsparameter[0],
+ $hivpp?->pollingId,
+ $hivpp?->wartezeitVorNaechsterAbfrage,
+ );
+ }
+
+ /**
+ * @param Message $response The response we just received from the server.
+ * @param int $hkvppSegmentNumber The number of the HKVPP segment in the request we had sent.
+ * @return ?VopConfirmationRequestImpl If the response contains a confirmation request for the user, it is returned,
+ * otherwise null (which may imply that the action was executed without requiring confirmation).
+ */
+ public static function checkVopConfirmationRequired(
+ Message $response,
+ int $hkvppSegmentNumber,
+ ): ?VopConfirmationRequestImpl {
+ $rueckmeldung = $response->findRueckmeldung(
+ Rueckmeldungscode::VOP_ERGEBNIS_NAMENSABGLEICH_PRUEFEN,
+ $hkvppSegmentNumber
+ );
+ /** @var HIVPPv1 $hivpp */
+ $hivpp = $response->findSegment(HIVPPv1::class);
+ if ($hivpp?->vopId === null || $rueckmeldung === null) {
+ return null;
+ }
+
+ $verificationNotApplicableReason = null;
+ if ($hivpp->paymentStatusReport === null) {
+ if ($hivpp->ergebnisVopPruefungEinzeltransaktion === null) {
+ throw new UnsupportedException('Missing paymentStatusReport and ergebnisVopPruefungEinzeltransaktion');
+ }
+ $verificationResultCode = $hivpp->ergebnisVopPruefungEinzeltransaktion->vopPruefergebnis;
+ $verificationNotApplicableReason = $hivpp->ergebnisVopPruefungEinzeltransaktion->grundRVNA;
+ } else {
+ $report = simplexml_load_string($hivpp->paymentStatusReport->getData());
+ $verificationResultCode = $report->CstmrPmtStsRpt->OrgnlGrpInfAndSts->GrpSts ?: null;
+ }
+
+ return new VopConfirmationRequestImpl(
+ $hivpp->vopId,
+ $hivpp->vopIdGueltigBis?->asDateTime(),
+ $hivpp->aufklaerungstextAutorisierungTrotzAbweichung,
+ VopVerificationResult::parse($verificationResultCode),
+ $verificationNotApplicableReason,
+ );
+ }
+
+ /**
+ * @param VopConfirmationRequestImpl $vopConfirmationRequest The VOP request we're confirming.
+ * @return HKVPAv1 A HKVPA segment that tells the bank the request is good to execute.
+ */
+ public static function createHKVPAForConfirmation(VopConfirmationRequestImpl $vopConfirmationRequest): HKVPAv1
+ {
+ $hkvpa = HKVPAv1::createEmpty();
+ $hkvpa->vopId = $vopConfirmationRequest->getVopId();
+ return $hkvpa;
+ }
+}
diff --git a/lib/Tests/Fhp/Integration/Atruvia/AtruviaIntegrationTestBase.php b/lib/Tests/Fhp/Integration/Atruvia/AtruviaIntegrationTestBase.php
new file mode 100644
index 00000000..efdd4555
--- /dev/null
+++ b/lib/Tests/Fhp/Integration/Atruvia/AtruviaIntegrationTestBase.php
@@ -0,0 +1,90 @@
+expectMessage(static::ANONYMOUS_INIT_REQUEST, mb_convert_encoding(static::ANONYMOUS_INIT_RESPONSE, 'ISO-8859-1', 'UTF-8'));
+ $this->expectMessage(static::ANONYMOUS_END_REQUEST, mb_convert_encoding(static::ANONYMOUS_END_RESPONSE, 'ISO-8859-1', 'UTF-8'));
+
+ $this->fints->getBpd();
+ }
+
+ /**
+ * Executes dialog synchronization and initialization, so that BPD and UPD are filled.
+ * @throws \Throwable
+ */
+ protected function initDialog()
+ {
+ // We already know the TAN mode, so it will only fetch the BPD (anonymously) to verify it.
+ $this->expectMessage(static::ANONYMOUS_INIT_REQUEST, mb_convert_encoding(static::ANONYMOUS_INIT_RESPONSE, 'ISO-8859-1', 'UTF-8'));
+ $this->expectMessage(static::ANONYMOUS_END_REQUEST, mb_convert_encoding(static::ANONYMOUS_END_RESPONSE, 'ISO-8859-1', 'UTF-8'));
+
+ // Then when we initialize a dialog, it's going to request a Kundensystem-ID and UPD.
+ $this->expectMessage(static::SYNC_REQUEST, mb_convert_encoding(static::SYNC_RESPONSE, 'ISO-8859-1', 'UTF-8'));
+ $this->expectMessage(static::SYNC_END_REQUEST, mb_convert_encoding(static::SYNC_END_RESPONSE, 'ISO-8859-1', 'UTF-8'));
+
+ // And finally it can initialize the main dialog.
+ $this->expectMessage(static::INIT_REQUEST, mb_convert_encoding(static::INIT_RESPONSE, 'ISO-8859-1', 'UTF-8'));
+
+ $this->fints->selectTanMode(intval(static::TEST_TAN_MODE));
+ $login = $this->fints->login();
+ $login->ensureDone(); // No TAN required upon login.*/
+ $this->assertAllMessagesSeen();
+ }
+
+ protected function getTestAccount(): SEPAAccount
+ {
+ $sepaAccount = new SEPAAccount();
+ $sepaAccount->setIban('DE00ABCDEFGH1234567890');
+ $sepaAccount->setBic('ABCDEFGHIJK');
+ $sepaAccount->setAccountNumber('1234567890');
+ $sepaAccount->setBlz(self::TEST_BANK_CODE);
+ return $sepaAccount;
+ }
+}
diff --git a/lib/Tests/Fhp/Integration/Atruvia/SendTransferVoPTest.php b/lib/Tests/Fhp/Integration/Atruvia/SendTransferVoPTest.php
new file mode 100644
index 00000000..01cfa210
--- /dev/null
+++ b/lib/Tests/Fhp/Integration/Atruvia/SendTransferVoPTest.php
@@ -0,0 +1,141 @@
+' . "\n"
+ . '
Alternativ konnte der Name des Zahlungsempfängers nicht mit dem bei der Zahlungsempfängerbank hinterlegten Namen abgeglichen werden.
Eine nicht mögliche Empfängerüberprüfung kann auftreten, wenn ein technisches Problem vorliegt, die Empfängerbank diesen Service nicht anbietet oder eine Prüfung für das Empfängerkonto nicht möglich ist.
Wichtiger Hinweis?: Die Überweisung wird ohne Korrektur ausgeführt.
Dies kann dazu führen, dass das Geld auf ein Konto überwiesen wird, dessen Inhaber nicht der von Ihnen angegebene Empfänger ist.
In diesem Fall haftet die Bank nicht für die Folgen der fehlenden Übereinstimmung, insbesondere besteht kein Anspruch auf Rückerstattung.
Eine Haftung der an der Ausführung der Überweisung beteiligten Zahlungsdienstleister ist ebenfalls ausgeschlossen.'";
+ public const VOP_REPORT_PARTIAL_MATCH_XML_PAYLOAD = "