From 8737eeec8702a64d53be91516f6da2fcc9de7d8c Mon Sep 17 00:00:00 2001 From: Philipp Keck Date: Wed, 12 Nov 2025 20:33:34 +0100 Subject: [PATCH 1/6] Allow serializing actions before they were first executed That way, they can be stashed away while the user is still logging in (which requires a separate `DialogInitialization` action). --- lib/Fhp/BaseAction.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/Fhp/BaseAction.php b/lib/Fhp/BaseAction.php index 06cfd9a0..c1f20b9b 100644 --- a/lib/Fhp/BaseAction.php +++ b/lib/Fhp/BaseAction.php @@ -61,8 +61,7 @@ abstract class BaseAction implements \Serializable * * NOTE: A common mistake is to call this function directly. Instead, you probably want `serialize($instance)`. * - * 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 before it was completed. * If a sub-class overrides this, it should call the parent function and include it in its result. * @return string The serialized action, e.g. for storage in a database. This will not contain sensitive user data. */ @@ -72,8 +71,7 @@ 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 before it was completed. * 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. @@ -81,8 +79,8 @@ public function serialize(): string */ public function __serialize(): array { - if (!$this->needsTan()) { - throw new \RuntimeException('Cannot serialize this action, because it is not waiting for a TAN.'); + if ($this->isDone()) { + throw new \RuntimeException('Completed actions cannot be serialized.'); } return [ $this->requestSegmentNumbers, From 63d6e4af6ccc7dc98b2961b2ce3e85974d343673 Mon Sep 17 00:00:00 2001 From: Philipp Keck Date: Fri, 17 Oct 2025 22:41:50 +0200 Subject: [PATCH 2/6] #477 Add support for awaiting and then confirming VOP This initial version only supports confirming VOP requests (not canceling them). And for now we only extract the top-level status code from the VOP response, ignoring all the per-transfer details and additional information (like actual payee name) that the bank delivers. This is based on the draft implementation of @ampaze in https://github.com/nemiah/phpFinTS/pull/499. --- lib/Fhp/BaseAction.php | 40 ++++ lib/Fhp/FinTs.php | 191 +++++++++++++++++-- lib/Fhp/Model/PollingInfo.php | 23 +++ lib/Fhp/Model/VopConfirmationRequest.php | 25 +++ lib/Fhp/Model/VopConfirmationRequestImpl.php | 54 ++++++ lib/Fhp/Model/VopPollingInfo.php | 50 +++++ lib/Fhp/Model/VopVerificationResult.php | 51 +++++ lib/Fhp/Protocol/BPD.php | 23 +++ lib/Fhp/Protocol/Message.php | 9 +- lib/Fhp/Segment/VPP/VopHelper.php | 138 ++++++++++++++ 10 files changed, 586 insertions(+), 18 deletions(-) create mode 100644 lib/Fhp/Model/PollingInfo.php create mode 100644 lib/Fhp/Model/VopConfirmationRequest.php create mode 100644 lib/Fhp/Model/VopConfirmationRequestImpl.php create mode 100644 lib/Fhp/Model/VopPollingInfo.php create mode 100644 lib/Fhp/Model/VopVerificationResult.php create mode 100644 lib/Fhp/Segment/VPP/VopHelper.php diff --git a/lib/Fhp/BaseAction.php b/lib/Fhp/BaseAction.php index c1f20b9b..9555f03b 100644 --- a/lib/Fhp/BaseAction.php +++ b/lib/Fhp/BaseAction.php @@ -4,7 +4,9 @@ namespace Fhp; +use Fhp\Model\PollingInfo; use Fhp\Model\TanRequest; +use Fhp\Model\VopConfirmationRequest; use Fhp\Protocol\ActionIncompleteException; use Fhp\Protocol\BPD; use Fhp\Protocol\Message; @@ -48,6 +50,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; /** @@ -138,6 +146,26 @@ 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 @@ -247,4 +275,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 c9fa8cab..24f1b6bf 100644 --- a/lib/Fhp/FinTs.php +++ b/lib/Fhp/FinTs.php @@ -5,6 +5,10 @@ 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; @@ -26,6 +30,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; @@ -286,7 +292,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 @@ -294,9 +300,30 @@ 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 {@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. @@ -326,7 +353,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 */ @@ -335,7 +369,7 @@ public function execute(BaseAction $action): void // Execute the request. $response = $this->sendMessage($request); - $this->processServerResponse($action, $response); + $this->processServerResponse($action, $response, $hkvpp); } /** @@ -343,18 +377,20 @@ 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); // 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"); @@ -368,10 +404,37 @@ private function processServerResponse(BaseAction $action, Message $response): v $action->setDialogId($response->header->dialogId); $action->setMessageNumber($this->messageNumber); } - 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); + 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); @@ -393,9 +456,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 @@ -461,7 +524,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. @@ -477,9 +542,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 @@ -558,6 +624,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..f29f569f --- /dev/null +++ b/lib/Fhp/Model/VopVerificationResult.php @@ -0,0 +1,51 @@ + null, + 'RCVC' => 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/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 d09cff36..b4babcfc 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/Segment/VPP/VopHelper.php b/lib/Fhp/Segment/VPP/VopHelper.php new file mode 100644 index 00000000..5bdd521d --- /dev/null +++ b/lib/Fhp/Segment/VPP/VopHelper.php @@ -0,0 +1,138 @@ +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 { + $codes = $response->findRueckmeldungscodesForReferenceSegment($hkvppSegmentNumber); + if (in_array(Rueckmeldungscode::VOP_AUSFUEHRUNGSAUFTRAG_NICHT_BENOETIGT, $codes)) { + return null; + } + /** @var HIVPPv1 $hivpp */ + $hivpp = $response->findSegment(HIVPPv1::class); + if ($hivpp === null) { + throw new UnexpectedResponseException('Missing HIVPP in response to a request with HKVPP'); + } + if ($hivpp->vopId === null) { + throw new UnexpectedResponseException('Missing HIVPP.vopId even though VOP should be completed.'); + } + + $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; + } +} From 6732fdc6b2c00d715d8f2ebb606fa6e35c7f729b Mon Sep 17 00:00:00 2001 From: ampaze Date: Mon, 13 Oct 2025 11:27:54 +0200 Subject: [PATCH 3/6] #477 Integration test for VoP on SEPA transfer with Atruvia bank Co-authored-by: ampaze Co-authored-by: Philipp Keck --- .../Atruvia/AtruviaIntegrationTestBase.php | 90 +++++++ .../Atruvia/SendTransferVoPTest.php | 247 ++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 lib/Tests/Fhp/Integration/Atruvia/AtruviaIntegrationTestBase.php create mode 100644 lib/Tests/Fhp/Integration/Atruvia/SendTransferVoPTest.php 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..770fd9ab --- /dev/null +++ b/lib/Tests/Fhp/Integration/Atruvia/SendTransferVoPTest.php @@ -0,0 +1,247 @@ +' . "\n" + . 'M12345678902025-10-10T12:52:56+02:00110.00PRIVATE__________________P12345678TRF110.00SEPA
1999-01-01
PRIVATE__________________DE00ABCDEFGH1234567890ABCDEFGHIJKSLEVNOTPROVIDED10.00EmpfängerDE00ABCDEFGH1234567890Testüberweisung
' + ); + + public const SEND_TRANSFER_REQUEST = ( + 'HKCCS:3:1+DE00ABCDEFGH1234567890:ABCDEFGHIJK:1234567890::280:11223344+urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.09+@1161@' . + self::XML_PAYLOAD . + "'HKTAN:4:7+4+HKCCS'HKVPP:5:1+urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.002.001.10'" + ); + public const SEND_TRANSFER_RESPONSE_POLLING_NEEDED = "HIRMG:3:2+3060::Bitte beachten Sie die enthaltenen Warnungen/Hinweise.+3905::Es wurde keine Challenge erzeugt.'HIRMS:4:2:5+3040::Es liegen weitere Informationen vor.:staticscrollref'HIRMS:5:2:4+3945::Freigabe ohne VOP-Bestätigung nicht möglich.'HIVPP:6:1:5+++@36@c0f5c2a4-ebb7-4e72-be44-c68742177a2b+++++2'"; + public const SEND_TRANSFER_RESPONSE_IMMEDIATE_SUCCESS = "HIRMG:3:2+3060::Bitte beachten Sie die enthaltenen Warnungen/Hinweise.+3905::Es wurde keine Challenge erzeugt.'HIRMS:4:2:5+3091::VOP-Ausführungsauftrag nicht benötigt.+0025::Keine Namensabweichung.'HIRMS:5:2:4+3076::Keine starke Authentifizierung erforderlich.'HIVPP:6:1:5+@36@5e3b5c99-df27-4d42-835b-18b35d0c66ff+++urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.002.001.10'HITAN:6:7:4+4++noref+nochallenge'HIRMS:7:2:3+0020::*SEPA-Einzelüberweisung erfolgreich+0900::Freigabe erfolgreich'"; + + public const POLL_VOP_REQUEST = "HKVPP:3:1+urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.002.001.10+@36@c0f5c2a4-ebb7-4e72-be44-c68742177a2b++staticscrollref'"; + + public const VOP_REPORT_MATCH_RESPONSE = "HIRMG:3:2+0010::Nachricht entgegengenommen.'HIRMS:4:2:3+0020::Auftrag ausgeführt.+0025::Keine Namensabweichung.'HIVPP:5:1:3+@36@5e3b5c99-df27-4d42-835b-18b35d0c66ff+++urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.002.001.10'"; + public const VOP_REPORT_MATCH_NO_CONFIRMATION_RESPONSE = "HIRMG:3:2+0010::Nachricht entgegengenommen.'HIRMS:4:2:3+3091::VOP-Ausführungsauftrag nicht benötigt.+0025::Keine Namensabweichung.'HIVPP:5:1:3+@36@5e3b5c99-df27-4d42-835b-18b35d0c66ff+++urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.002.001.10'HITAN:6:7:5+4++1234567890123456789012345678+Bitte bestätigen Sie den Vorgang in Ihrer SecureGo plus App'"; + public const VOP_REPORT_MATCH_XML_PAYLOAD = "ATRUVIA-20251013-125258-XXXXXXXXXXXXXXXX2025-10-13T11:36:04.201+02:00ABCDEFGHIJKM1234567890pain.001.001.091100.00RCVCRCVC Der von Ihnen eingegebene Name des Zahlungsempfängers stimmt mit dem für diese IBANRCVC hinterlegten Namen bei der Zahlungsempfängerbank überein.RVMC Der von Ihnen eingegebene Name des Zahlungsempfängers stimmt nahezu mit dem für diese IBANRVMC hinterlegten Namen bei der Zahlungsempfängerbank überein. Die Autorisierung der ZahlungRVMC kann dazu führen, dass das Geld auf ein Konto überwiesen wird, dessen Inhaber nichtRVMC der von Ihnen angegebene Empfänger ist. In diesem Fall haften die Zahlungsdienstleister nicht fürRVMC die Folgen der fehlenden Übereinstimmung, insbesondere besteht kein Anspruch auf Rückerstattung.RVNM Der von Ihnen eingegebene Name des Zahlungsempfängers stimmt nicht mit dem für diese IBAN hinter-RVNM legten Namen bei der Zahlungsempfängerbank überein. Bitte prüfen Sie den Empfängernamen. Die Autori-RVNM sierung der Zahlung kann dazu führen, dass das Geld auf ein Konto überwiesen wird, dessen InhaberRVNM nicht der von Ihnen angegebene Empfänger ist. In diesem Fall haften die Zahlungsdienstleister nichtRVNM für die Folgen der fehlenden Übereinstimmung, insbesondere besteht kein Anspruch auf Rückerstattung.RVNA Der von Ihnen eingegebene Name des Zahlungsempfängers konnte nicht mit dem für diese IBAN hinter-RVNA legten Namen bei der Zahlungsempfängerbank abgeglichen werden (z.B. technischer Fehler). Die Autori-RVNA sierung der Zahlung kann dazu führen, dass das Geld auf ein Konto überwiesen wird, dessen InhaberRVNA nicht der von Ihnen angegebene Empfänger ist. In diesem Fall haften die Zahlungsdienstleister nichtRVNA für die Folgen der fehlenden Übereinstimmung, insbesondere besteht kein Anspruch auf Rückerstattung.1RCVC0RVMC0RVNM0RVNA176034816211RCVC0RVMC0RVNM0RVNANOTPROVIDEDRCVCTestempfängerDE00ABCDEFGH1234567890"; + + public const VOP_REPORT_PARTIAL_MATCH_RESPONSE = "HIRMG:3:2+3060::Bitte beachten Sie die enthaltenen Warnungen/Hinweise.'HIRMS:4:2:3+3090::Ergebnis des Namensabgleichs prüfen.'HIVPP:5:1:3+@36@5e3b5c99-df27-4d42-835b-18b35d0c66ff+++urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.002.001.10+++Bei mindestens einem Zahlungsempfänger stimmt der Name mit dem für diese IBAN bei der Zahlungsempfängerbank hinterlegten Namen nicht oder nur nahezu überein.
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 = "ATRUVIA-20251010-125258-X2025-10-10T12:52:58.283+02:00ABCDEFGHIJKM1234567890pain.001.001.09110.00RVCMRCVC Der von Ihnen eingegebene Name des Zahlungsempfängers stimmt mit dem für diese IBANRCVC hinterlegten Namen bei der Zahlungsempfängerbank überein.RVMC Der von Ihnen eingegebene Name des Zahlungsempfängers stimmt nahezu mit dem für diese IBANRVMC hinterlegten Namen bei der Zahlungsempfängerbank überein. Die Autorisierung der ZahlungRVMC kann dazu führen, dass das Geld auf ein Konto überwiesen wird, dessen Inhaber nichtRVMC der von Ihnen angegebene Empfänger ist. In diesem Fall haften die Zahlungsdienstleister nicht fürRVMC die Folgen der fehlenden Übereinstimmung, insbesondere besteht kein Anspruch auf Rückerstattung.RVNM Der von Ihnen eingegebene Name des Zahlungsempfängers stimmt nicht mit dem für diese IBAN hinter-RVNM legten Namen bei der Zahlungsempfängerbank überein. Bitte prüfen Sie den Empfängernamen. Die Autori-RVNM sierung der Zahlung kann dazu führen, dass das Geld auf ein Konto überwiesen wird, dessen InhaberRVNM nicht der von Ihnen angegebene Empfänger ist. In diesem Fall haften die Zahlungsdienstleister nichtRVNM für die Folgen der fehlenden Übereinstimmung, insbesondere besteht kein Anspruch auf Rückerstattung.RVNA Der von Ihnen eingegebene Name des Zahlungsempfängers konnte nicht mit dem für diese IBAN hinter-RVNA legten Namen bei der Zahlungsempfängerbank abgeglichen werden (z.B. technischer Fehler). Die Autori-RVNA sierung der Zahlung kann dazu führen, dass das Geld auf ein Konto überwiesen wird, dessen InhaberRVNA nicht der von Ihnen angegebene Empfänger ist. In diesem Fall haften die Zahlungsdienstleister nichtRVNA für die Folgen der fehlenden Übereinstimmung, insbesondere besteht kein Anspruch auf Rückerstattung.0RCVC0RVMC1RVNM0RVNA176009357610RCVC0RVMC1RVNM0RVNANOTPROVIDEDRVNMTestempfängerDE00ABCDEFGH1234567890"; + + public const CONFIRM_VOP_REQUEST = ( + 'HKCCS:3:1+DE00ABCDEFGH1234567890:ABCDEFGHIJK:1234567890::280:11223344+urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.09+@1161@' + . self::XML_PAYLOAD + . "'HKVPA:4:1+@36@5e3b5c99-df27-4d42-835b-18b35d0c66ff'HKTAN:5:7+4+HKCCS'" + ); + public const CONFIRM_VOP_RESPONSE = "HIRMG:3:2+3060::Bitte beachten Sie die enthaltenen Warnungen/Hinweise.'HIRMS:4:2:4+0020::Ausführungsbestätigung nach Namensabgleich erhalten.'HIRMS:5:2:5+3955::Sicherheitsfreigabe erfolgt über anderen Kanal.'HITAN:6:7:5+4++1234567890123456789012345678+Bitte bestätigen Sie den Vorgang in Ihrer SecureGo plus App'"; + + public const CHECK_DECOUPLED_SUBMISSION_REQUEST = "HKTAN:3:7+S++++1234567890123456789012345678+N'"; + public const CHECK_DECOUPLED_SUBMISSION_RESPONSE = "HIRMG:3:2+0010::Nachricht entgegengenommen.'HIRMS:4:2:3+0020::*SEPA-Einzelüberweisung erfolgreich+0900::Freigabe erfolgreich'HITAN:5:7:3+S++1234567890123456789012345678'"; + + /** + * @throws \Throwable + */ + public function testVopWithResultMatchButConfirmationRequired(): void + { + $this->initDialog(); + $action = SendSEPATransfer::create($this->getTestAccount(), self::XML_PAYLOAD); + + // We send the transfer and the bank asks to wait while VOP is happening. + $this->expectMessage(static::SEND_TRANSFER_REQUEST, mb_convert_encoding(static::SEND_TRANSFER_RESPONSE_POLLING_NEEDED, 'ISO-8859-1', 'UTF-8')); + $this->fints->execute($action); + $this->assertTrue($action->needsPollingWait()); + $this->assertFalse($action->needsVopConfirmation()); + $this->assertFalse($action->needsTan()); + $this->assertFalse($action->isDone()); + $this->assertEquals(2, $action->getPollingInfo()->getNextAttemptInSeconds()); + + // We poll the bank for the first and only time, now the VOP process is done, the result is available, and it's + // a match (CompletedFullMatch). But the bank still asks for the VOP confirmation. + $response = static::buildVopReportResponse(static::VOP_REPORT_MATCH_RESPONSE, static::VOP_REPORT_MATCH_XML_PAYLOAD); + $this->expectMessage(static::POLL_VOP_REQUEST, $response); + $this->fints->pollAction($action); + $this->assertFalse($action->needsPollingWait()); + $this->assertTrue($action->needsVopConfirmation()); + $this->assertFalse($action->needsTan()); + $this->assertFalse($action->isDone()); + $this->assertEquals( + VopVerificationResult::CompletedFullMatch, + $action->getVopConfirmationRequest()->getVerificationResult() + ); + + // We confirm to the bank that it's okay to proceed, the bank asks for decoupled 2FA authentication. + $this->expectMessage(static::CONFIRM_VOP_REQUEST, mb_convert_encoding(static::CONFIRM_VOP_RESPONSE, 'ISO-8859-1', 'UTF-8')); + $this->fints->confirmVop($action); + $this->assertFalse($action->needsPollingWait()); + $this->assertFalse($action->needsVopConfirmation()); + $this->assertTrue($action->needsTan()); + $this->assertFalse($action->isDone()); + $this->assertEquals( + 'Bitte bestätigen Sie den Vorgang in Ihrer SecureGo plus App', + $action->getTanRequest()->getChallenge() + ); + + // After having completed the 2FA on the other device (not shown in this unit test), we ask the bank again, and + // it confirms that the transfer was executed. + $this->expectMessage(static::CHECK_DECOUPLED_SUBMISSION_REQUEST, mb_convert_encoding(static::CHECK_DECOUPLED_SUBMISSION_RESPONSE, 'ISO-8859-1', 'UTF-8')); + $this->fints->checkDecoupledSubmission($action); + $this->assertFalse($action->needsPollingWait()); + $this->assertFalse($action->needsVopConfirmation()); + $this->assertFalse($action->needsTan()); + $this->assertTrue($action->isDone()); + + $action->ensureDone(); + } + + /** + * @throws \Throwable + */ + public function testVopWithResultPartialMatch(): void + { + $this->initDialog(); + $action = SendSEPATransfer::create($this->getTestAccount(), self::XML_PAYLOAD); + + // We send the transfer and the bank asks to wait while VOP is happening. + $this->expectMessage(static::SEND_TRANSFER_REQUEST, mb_convert_encoding(static::SEND_TRANSFER_RESPONSE_POLLING_NEEDED, 'ISO-8859-1', 'UTF-8')); + $this->fints->execute($action); + $this->assertTrue($action->needsPollingWait()); + $this->assertFalse($action->needsVopConfirmation()); + $this->assertFalse($action->needsTan()); + $this->assertFalse($action->isDone()); + $this->assertEquals(2, $action->getPollingInfo()->getNextAttemptInSeconds()); + + // We poll the bank for the first and only time, now the VOP process is done and the VOP result is available, + // but the payee didn't match, and so we're being asked to confirm. + $response = static::buildVopReportResponse(static::VOP_REPORT_PARTIAL_MATCH_RESPONSE, static::VOP_REPORT_PARTIAL_MATCH_XML_PAYLOAD); + $this->expectMessage(static::POLL_VOP_REQUEST, $response); + $this->fints->pollAction($action); + $this->assertFalse($action->needsPollingWait()); + $this->assertTrue($action->needsVopConfirmation()); + $this->assertFalse($action->needsTan()); + $this->assertFalse($action->isDone()); + $this->assertEquals( + VopVerificationResult::CompletedPartialMatch, + $action->getVopConfirmationRequest()->getVerificationResult() + ); + + // We confirm to the bank that it's okay to proceed, the bank asks for decoupled 2FA authentication. + $this->expectMessage(static::CONFIRM_VOP_REQUEST, mb_convert_encoding(static::CONFIRM_VOP_RESPONSE, 'ISO-8859-1', 'UTF-8')); + $this->fints->confirmVop($action); + $this->assertFalse($action->needsPollingWait()); + $this->assertFalse($action->needsVopConfirmation()); + $this->assertTrue($action->needsTan()); + $this->assertFalse($action->isDone()); + $this->assertEquals( + 'Bitte bestätigen Sie den Vorgang in Ihrer SecureGo plus App', + $action->getTanRequest()->getChallenge() + ); + + // After having completed the 2FA on the other device (not shown in this unit test), we ask the bank again, and + // it confirms that the transfer was executed. + $this->expectMessage(static::CHECK_DECOUPLED_SUBMISSION_REQUEST, mb_convert_encoding(static::CHECK_DECOUPLED_SUBMISSION_RESPONSE, 'ISO-8859-1', 'UTF-8')); + $this->fints->checkDecoupledSubmission($action); + $this->assertFalse($action->needsPollingWait()); + $this->assertFalse($action->needsVopConfirmation()); + $this->assertFalse($action->needsTan()); + $this->assertTrue($action->isDone()); + + $action->ensureDone(); + } + + /** + * This is a hypothetical test case in the sense that it wasn't recorded based on real traffic with the bank, but + * constructed based on what the specification has to say. + * @see FinTS_3.0_Messages_Geschaeftsvorfaelle_VOP_1.01_2025_06_27_FV.pdf (E.8.1.1.1 and exclude red part). + * @throws \Throwable + */ + public function testVopWithResultMatchWithoutConfirmation(): void + { + $this->initDialog(); + $action = $this->createAction(); + + // We send the transfer and the bank asks to wait while VOP is happening. + $this->expectMessage(static::SEND_TRANSFER_REQUEST, mb_convert_encoding(static::SEND_TRANSFER_RESPONSE_POLLING_NEEDED, 'ISO-8859-1', 'UTF-8')); + $this->fints->execute($action); + $this->assertTrue($action->needsPollingWait()); + $this->assertFalse($action->needsVopConfirmation()); + $this->assertFalse($action->needsTan()); + $this->assertFalse($action->isDone()); + $this->assertEquals(2, $action->getPollingInfo()->getNextAttemptInSeconds()); + + // We poll the bank for the first and only time, now the VOP process is done, the result is available, and it's + // a match (CompletedFullMatch). The bank does not want a VOP confirmation (as indicated by code 3091), so we + // move straight on to 2FA authentication. + $response = static::buildVopReportResponse( + static::VOP_REPORT_MATCH_NO_CONFIRMATION_RESPONSE, + static::VOP_REPORT_MATCH_XML_PAYLOAD + ); + $this->expectMessage(static::POLL_VOP_REQUEST, $response); + $this->fints->pollAction($action); + $this->assertFalse($action->needsPollingWait()); + $this->assertFalse($action->needsVopConfirmation()); + $this->assertTrue($action->needsTan()); + $this->assertFalse($action->isDone()); + // Note: We currently lack an API for applications to retrieve the CompletedFullMatch result in this case, + // because the VOP check itself is no longer actionable. + $this->assertEquals( + 'Bitte bestätigen Sie den Vorgang in Ihrer SecureGo plus App', + $action->getTanRequest()->getChallenge() + ); + + // After having completed the 2FA on the other device (not shown in this unit test), we ask the bank again, and + // it confirms that the transfer was executed. + $this->expectMessage(static::CHECK_DECOUPLED_SUBMISSION_REQUEST, mb_convert_encoding(static::CHECK_DECOUPLED_SUBMISSION_RESPONSE, 'ISO-8859-1', 'UTF-8')); + $this->fints->checkDecoupledSubmission($action); + $this->assertFalse($action->needsPollingWait()); + $this->assertFalse($action->needsVopConfirmation()); + $this->assertFalse($action->needsTan()); + $this->assertTrue($action->isDone()); + + $action->ensureDone(); + } + + /** + * This is a hypothetical test case in the sense that it wasn't recorded based on real traffic with the bank, but + * constructed based on what the specification has to say. + * @see FinTS_3.0_Messages_Geschaeftsvorfaelle_VOP_1.01_2025_06_27_FV.pdf (E.8.1 bullet point 2.). + * @throws \Throwable + */ + public function testVopWithResultImmediateSuccess(): void + { + $this->initDialog(); + $action = $this->createAction(); + + // We send the transfer and the bank asks to wait while VOP is happening. + $this->expectMessage(static::SEND_TRANSFER_REQUEST, mb_convert_encoding(static::SEND_TRANSFER_RESPONSE_IMMEDIATE_SUCCESS, 'ISO-8859-1', 'UTF-8')); + $this->fints->execute($action); + $this->assertFalse($action->needsPollingWait()); + $this->assertFalse($action->needsVopConfirmation()); + $this->assertFalse($action->needsTan()); + $this->assertTrue($action->isDone()); + + $action->ensureDone(); + } + + protected function createAction(): SendSEPATransfer + { + return SendSEPATransfer::create($this->getTestAccount(), self::XML_PAYLOAD); + } + + protected static function buildVopReportResponse( + string $outerFintsMessageInUtf8, + string $innerXmlInUtf8, + ): string { + $segments = Parser::parseSegments(mb_convert_encoding($outerFintsMessageInUtf8, 'ISO-8859-1', 'UTF-8')); + foreach ($segments as $segment) { + if ($segment instanceof HIVPPv1) { + $segment->paymentStatusReport = new Bin($innerXmlInUtf8); + } + } + return Serializer::serializeSegments($segments); + } +} From a738759a23597bae3a6a4af7716ceea7cb578349 Mon Sep 17 00:00:00 2001 From: Philipp Keck Date: Fri, 17 Oct 2025 23:51:08 +0200 Subject: [PATCH 4/6] Update BaseAction::ensureDone() for VOP conditions --- lib/Fhp/BaseAction.php | 21 +++++++++++---- lib/Fhp/Protocol/ActionPendingException.php | 26 +++++++++++++++++++ .../VopConfirmationRequiredException.php | 26 +++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 lib/Fhp/Protocol/ActionPendingException.php create mode 100644 lib/Fhp/Protocol/VopConfirmationRequiredException.php diff --git a/lib/Fhp/BaseAction.php b/lib/Fhp/BaseAction.php index 9555f03b..471b1afe 100644 --- a/lib/Fhp/BaseAction.php +++ b/lib/Fhp/BaseAction.php @@ -8,11 +8,13 @@ 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; @@ -170,21 +172,30 @@ public function getVopConfirmationRequest(): ?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(); } 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/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; + } +} From cdcbebdf201143b36695dfd942c11966d4fa2538 Mon Sep 17 00:00:00 2001 From: Philipp Keck Date: Mon, 20 Oct 2025 21:27:39 +0200 Subject: [PATCH 5/6] Serialize BaseAction::pollingInfo and vopConfirmationRequest --- lib/Fhp/BaseAction.php | 6 +- .../Fhp/BaseActionVopSerializationTest.php | 58 +++++++++++++++++++ .../Atruvia/SendTransferVoPTest.php | 4 +- 3 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 lib/Tests/Fhp/BaseActionVopSerializationTest.php diff --git a/lib/Fhp/BaseAction.php b/lib/Fhp/BaseAction.php index 471b1afe..735b3906 100644 --- a/lib/Fhp/BaseAction.php +++ b/lib/Fhp/BaseAction.php @@ -96,6 +96,8 @@ public function __serialize(): array $this->requestSegmentNumbers, $this->tanRequest, $this->needTanForSegment, + $this->pollingInfo, + $this->vopConfirmationRequest, ]; } @@ -116,7 +118,9 @@ public function __unserialize(array $serialized): void $this->requestSegmentNumbers, $this->tanRequest, $this->needTanForSegment, - ) = $serialized; + $this->pollingInfo, + $this->vopConfirmationRequest, + ) = array_pad($serialized, 5, null); } /** diff --git a/lib/Tests/Fhp/BaseActionVopSerializationTest.php b/lib/Tests/Fhp/BaseActionVopSerializationTest.php new file mode 100644 index 00000000..5546c146 --- /dev/null +++ b/lib/Tests/Fhp/BaseActionVopSerializationTest.php @@ -0,0 +1,58 @@ +initDialog(); + $originalAction = $this->createAction(); + $this->expectMessage(static::SEND_TRANSFER_REQUEST, mb_convert_encoding(static::SEND_TRANSFER_RESPONSE_POLLING_NEEDED, 'ISO-8859-1', 'UTF-8')); + $this->fints->execute($originalAction); + + // Sanity-check that the polling is now expected. + $this->assertNotNull($originalAction->getPollingInfo()); + + // Do a serialization roundtrip. + $serializedAction = serialize($originalAction); + $unserializedAction = unserialize($serializedAction); + + // Verify that the polling info is still the same. + $this->assertEquals($originalAction->getPollingInfo(), $unserializedAction->getPollingInfo()); + } + + /** + * @throws \Throwable + */ + public function testSerializesVopConfirmationRequest() + { + // We piggy-back on the Atruvia integration test to provide an action that has some reasonable data inside and + // has already been executed so that polling is now required. + $this->initDialog(); + $originalAction = $this->createAction(); + $this->expectMessage(static::SEND_TRANSFER_REQUEST, mb_convert_encoding(static::SEND_TRANSFER_RESPONSE_POLLING_NEEDED, 'ISO-8859-1', 'UTF-8')); + $response = static::buildVopReportResponse(static::VOP_REPORT_PARTIAL_MATCH_RESPONSE, static::VOP_REPORT_PARTIAL_MATCH_XML_PAYLOAD); + $this->expectMessage(static::POLL_VOP_REQUEST, $response); + $this->fints->execute($originalAction); + $this->assertTrue($originalAction->needsPollingWait()); + $this->fints->pollAction($originalAction); + + // Sanity-check that the VOP confirmation is now expected. + $this->assertNotNull($originalAction->getVopConfirmationRequest()); + + // Do a serialization roundtrip. + $serializedAction = serialize($originalAction); + $unserializedAction = unserialize($serializedAction); + + // Verify that the polling info is still the same. + $this->assertEquals($originalAction->getVopConfirmationRequest(), $unserializedAction->getVopConfirmationRequest()); + } +} diff --git a/lib/Tests/Fhp/Integration/Atruvia/SendTransferVoPTest.php b/lib/Tests/Fhp/Integration/Atruvia/SendTransferVoPTest.php index 770fd9ab..80c3deae 100644 --- a/lib/Tests/Fhp/Integration/Atruvia/SendTransferVoPTest.php +++ b/lib/Tests/Fhp/Integration/Atruvia/SendTransferVoPTest.php @@ -49,7 +49,7 @@ class SendTransferVoPTest extends AtruviaIntegrationTestBase public function testVopWithResultMatchButConfirmationRequired(): void { $this->initDialog(); - $action = SendSEPATransfer::create($this->getTestAccount(), self::XML_PAYLOAD); + $action = $this->createAction(); // We send the transfer and the bank asks to wait while VOP is happening. $this->expectMessage(static::SEND_TRANSFER_REQUEST, mb_convert_encoding(static::SEND_TRANSFER_RESPONSE_POLLING_NEEDED, 'ISO-8859-1', 'UTF-8')); @@ -104,7 +104,7 @@ public function testVopWithResultMatchButConfirmationRequired(): void public function testVopWithResultPartialMatch(): void { $this->initDialog(); - $action = SendSEPATransfer::create($this->getTestAccount(), self::XML_PAYLOAD); + $action = $this->createAction(); // We send the transfer and the bank asks to wait while VOP is happening. $this->expectMessage(static::SEND_TRANSFER_REQUEST, mb_convert_encoding(static::SEND_TRANSFER_RESPONSE_POLLING_NEEDED, 'ISO-8859-1', 'UTF-8')); From a1b3f9d5ce654a75746dc6b0c71cae9f44ceb145 Mon Sep 17 00:00:00 2001 From: Philipp Keck Date: Thu, 23 Oct 2025 00:11:32 +0200 Subject: [PATCH 6/6] Improved heuristic for single-transaction VOP results Proposed by ampaze@ in https://github.com/nemiah/phpFinTS/pull/513#issuecomment-3431234981. Co-authored-by: ampaze Co-authored-by: Philipp Keck --- lib/Fhp/Segment/VPP/VopHelper.php | 19 ++++++++++++++++--- .../Atruvia/SendTransferVoPTest.php | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/Fhp/Segment/VPP/VopHelper.php b/lib/Fhp/Segment/VPP/VopHelper.php index 5bdd521d..1bc5cefe 100644 --- a/lib/Fhp/Segment/VPP/VopHelper.php +++ b/lib/Fhp/Segment/VPP/VopHelper.php @@ -109,18 +109,31 @@ public static function checkVopConfirmationRequired( if ($hivpp->ergebnisVopPruefungEinzeltransaktion === null) { throw new UnsupportedException('Missing paymentStatusReport and ergebnisVopPruefungEinzeltransaktion'); } - $verificationResultCode = $hivpp->ergebnisVopPruefungEinzeltransaktion->vopPruefergebnis; + $verificationResult = VopVerificationResult::parse( + $hivpp->ergebnisVopPruefungEinzeltransaktion->vopPruefergebnis + ); $verificationNotApplicableReason = $hivpp->ergebnisVopPruefungEinzeltransaktion->grundRVNA; } else { $report = simplexml_load_string($hivpp->paymentStatusReport->getData()); - $verificationResultCode = $report->CstmrPmtStsRpt->OrgnlGrpInfAndSts->GrpSts ?: null; + $verificationResult = VopVerificationResult::parse( + $report->CstmrPmtStsRpt->OrgnlGrpInfAndSts->GrpSts ?: null + ); + + // For a single transaction, we can do better than "CompletedPartialMatch", + // which can indicate either CompletedCloseMatch or CompletedNoMatch + if (intval($report->CstmrPmtStsRpt->OrgnlGrpInfAndSts->OrgnlNbOfTxs ?: 0) === 1 + && $verificationResult === VopVerificationResult::CompletedPartialMatch + && $verificationResultCode = $report->CstmrPmtStsRpt->OrgnlPmtInfAndSts->TxInfAndSts?->TxSts + ) { + $verificationResult = VopVerificationResult::parse($verificationResultCode); + } } return new VopConfirmationRequestImpl( $hivpp->vopId, $hivpp->vopIdGueltigBis?->asDateTime(), $hivpp->aufklaerungstextAutorisierungTrotzAbweichung, - VopVerificationResult::parse($verificationResultCode), + $verificationResult, $verificationNotApplicableReason, ); } diff --git a/lib/Tests/Fhp/Integration/Atruvia/SendTransferVoPTest.php b/lib/Tests/Fhp/Integration/Atruvia/SendTransferVoPTest.php index 80c3deae..19bb3a71 100644 --- a/lib/Tests/Fhp/Integration/Atruvia/SendTransferVoPTest.php +++ b/lib/Tests/Fhp/Integration/Atruvia/SendTransferVoPTest.php @@ -125,7 +125,7 @@ public function testVopWithResultPartialMatch(): void $this->assertFalse($action->needsTan()); $this->assertFalse($action->isDone()); $this->assertEquals( - VopVerificationResult::CompletedPartialMatch, + VopVerificationResult::CompletedNoMatch, $action->getVopConfirmationRequest()->getVerificationResult() );