Skip to content

Commit b27e17e

Browse files
committed
TEMP VOP
1 parent 5804118 commit b27e17e

File tree

7 files changed

+278
-15
lines changed

7 files changed

+278
-15
lines changed

lib/Fhp/BaseAction.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Fhp;
66

7+
use Fhp\Model\PollingToken;
78
use Fhp\Model\TanRequest;
89
use Fhp\Protocol\ActionIncompleteException;
910
use Fhp\Protocol\BPD;
@@ -48,6 +49,9 @@ abstract class BaseAction implements \Serializable
4849
/** If set, the last response from the server regarding this action asked for a TAN from the user. */
4950
protected ?TanRequest $tanRequest = null;
5051

52+
/** If set, this action is currently waiting for a long-running operation on the server to complete. */
53+
protected ?PollingToken $pollingToken = null;
54+
5155
protected bool $isDone = false;
5256

5357
/**
@@ -139,6 +143,16 @@ public function getTanRequest(): ?TanRequest
139143
return $this->tanRequest;
140144
}
141145

146+
public function needsPollingWait(): bool
147+
{
148+
return !$this->isDone() && $this->pollingToken !== null;
149+
}
150+
151+
public function getPollingToken(): ?PollingToken
152+
{
153+
return $this->pollingToken;
154+
}
155+
142156
/**
143157
* Throws an exception unless this action has been successfully executed, i.e. in the following cases:
144158
* - the action has not been {@link FinTs::execute()}-d at all or the {@link FinTs::execute()} call for it threw an
@@ -248,4 +262,10 @@ final public function setTanRequest(?TanRequest $tanRequest): void
248262
{
249263
$this->tanRequest = $tanRequest;
250264
}
265+
266+
/** To be called only by the FinTs instance that executes this action. */
267+
final public function setPollingToken(?PollingToken $pollingToken): void
268+
{
269+
$this->pollingToken = $pollingToken;
270+
}
251271
}

lib/Fhp/FinTs.php

Lines changed: 94 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
use Fhp\Segment\TAN\HKTAN;
2727
use Fhp\Segment\TAN\HKTANFactory;
2828
use Fhp\Segment\TAN\HKTANv6;
29+
use Fhp\Segment\VPP\HKVPPv1;
30+
use Fhp\Segment\VPP\VopHelper;
31+
use Fhp\Segment\VPP\VopPollingToken;
2932
use Fhp\Syntax\InvalidResponseException;
3033
use Psr\Log\LoggerInterface;
3134
use Psr\Log\NullLogger;
@@ -285,17 +288,32 @@ public function login(): DialogInitialization
285288

286289
/**
287290
* Executes an action. Be sure to {@link login()} first. See the `\Fhp\Action` package for actions that can be
288-
* executed with this function. Note that, after this function returns, the action can be in two possible states:
291+
* executed with this function. Note that, after this function returns, the action can be in the following states:
289292
* 1. If {@link BaseAction::needsTan()} returns true, the action isn't completed yet because needs a TAN or other
290293
* kind of two-factor authentication (2FA). In this case, use {@link BaseAction::getTanRequest()} to get more
291294
* information about the TAN/2FA that is needed. Your application then needs to interact with the user to obtain
292295
* the TAN (which should be passed into {@link submitTan()}) or to have them complete the 2FA check (which can
293296
* be verified with {@link checkDecoupledSubmission()}). Both of those functions require passing the same
294297
* {@link BaseAction} argument as an argument, and once they succeed, the action will be in the same completed
295298
* state as if it had been completed right away.
296-
* 2. If {@link BaseAction::needsTan()} returns false, the action was completed right away. Use the respective
297-
* getters on the action instance to retrieve the result. In case the action fails, the corresponding exception
298-
* will be thrown from this function.
299+
* 2. If {@link BaseAction::needsPollingWait()} returns true, the action isn't completed yet because the server is
300+
* still running some slow operation. Importantly, the server has not necessarily accepted the action yet, so it
301+
* is absolutely required that the client keeps polling if they don't want the action to be abandoned.
302+
* In this case, use {@link BaseAction::getPollingToken()} to get more information on how frequently to poll, and
303+
* do the polling through {@link pollAction()}.
304+
* 3. If {@link BaseAction::needsVopConfirmation()} returns true, the action isn't completed yet because the payee
305+
* information couldn't be matched automatically, so an explicit confirmation from the user is required. In this
306+
* case, use TODO.
307+
* 4. If none of the above return true, the action was completed right away.
308+
* Use the respective getters on the action instance to retrieve the result. In case the action fails, the
309+
* corresponding exception will be thrown from this function.
310+
*
311+
* Note that all conditions above that leave the action in an incomplete state require some action from the client
312+
* application. These actions then change the state of the action again, but they don't necessarily complete it.
313+
* In practice, the typical sequence is: Maybe polling, maybe VoP confirmation, maybe TAN, done. That said, you
314+
* should ideally implement your application to deal with any sequence of states. Just execute the action, check
315+
* what's state it's in, resolve that state as appropriate, and then check again (using the same code as before). Do
316+
* this repeatedly until none of the special conditions above happen anymore, at which point the action is done.
299317
*
300318
* @param BaseAction $action The action to be executed. Its {@link BaseAction::isDone()} status will be updated when
301319
* this function returns successfully.
@@ -324,7 +342,13 @@ public function execute(BaseAction $action): void
324342
$this->requireTanMode(), $this->selectedTanMedium, $needTanForSegment));
325343
}
326344
}
327-
$request = $this->buildMessage($message, $this->getSelectedTanMode());
345+
346+
// Add HKVPP for VoP verification if necessary.
347+
$hkvpp = null;
348+
if ($this->bpd?->vopRequiredForRequest($requestSegments) !== null) {
349+
$hkvpp = VopHelper::createHKVPPForInitialRequest($this->bpd);
350+
$message->add($hkvpp);
351+
}
328352

329353
// Construct the request and tell the action about the segment numbers that were assigned.
330354
$request = $this->buildMessage($message, $this->getSelectedTanMode()); // This fills in the segment numbers.
@@ -372,7 +396,15 @@ private function processServerResponse(BaseAction $action, Message $response, ?H
372396
return;
373397
}
374398

375-
// If no TAN is needed, process the response normally, and maybe keep going for more pages.
399+
// Detect if the bank needs us to do something for Verification of Payee.
400+
if ($hkvpp != null) {
401+
if ($pollingToken = VopHelper::checkPollingRequired($response, $hkvpp->getSegmentNumber())) {
402+
$action->setPollingToken($pollingToken);
403+
return;
404+
}
405+
}
406+
407+
// If no TAN or VOP is needed, process the response normally, and maybe keep going for more pages.
376408
$this->processActionResponse($action, $response->filterByReferenceSegments($action->getRequestSegmentNumbers()));
377409
if ($action instanceof PaginateableAction && $action->hasMorePages()) {
378410
$this->execute($action);
@@ -384,9 +416,9 @@ private function processServerResponse(BaseAction $action, Message $response, ?H
384416
* `false`, this function sends the given $tan to the server to complete the action. By using {@link persist()},
385417
* this can be done asynchronously, i.e., not in the same PHP process as the original {@link execute()} call.
386418
*
387-
* After this function returns, the `$action` is completed. That is, its result is available through its getters
388-
* just as if it had been completed by the original call to {@link execute()} right away. In case the action fails,
389-
* the corresponding exception will be thrown from this function.
419+
* After this function returns, the `$action` is in any of the same states as after {@link execute()}, see there.
420+
* In practice, the action is fully completed after completing the decoupled submission.
421+
* In case the action fails, the corresponding exception will be thrown from this function.
390422
*
391423
* @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_PINTAN_2020-07-10_final_version.pdf
392424
* Section B.4.2.1.1
@@ -452,7 +484,9 @@ public function submitTan(BaseAction $action, string $tan): void
452484
* For an action where {@link BaseAction::needsTan()} returns `true` and {@link TanMode::isDecoupled()} returns
453485
* `true`, this function checks with the server whether the second factor authentication has been completed yet on
454486
* the secondary device of the user.
455-
* - If so, this completes the given action and returns `true`.
487+
* - If so, this function returns `true` and the `$action` is then in any of the same states as after
488+
* {@link execute()} (except {@link BaseAction::needsTan()} won't happen again). See there for documentation.
489+
* In practice, the action is fully completed after completing the decoupled submission.
456490
* - In case the action fails, the corresponding exception will be thrown from this function.
457491
* - If the authentication has not been completed yet, this returns `false` and the action remains in its
458492
* previous, uncompleted state.
@@ -468,9 +502,10 @@ public function submitTan(BaseAction $action, string $tan): void
468502
* Section B.4.2.2
469503
*
470504
* @param BaseAction $action The action to be completed.
471-
* @return bool True if the decoupled authentication is done and the $action was completed. If false, the
472-
* {@link TanRequest} inside the action has been updated, which *may* provide new/more instructions to the user,
473-
* though probably it rarely does in practice.
505+
* @return bool True if the decoupled authentication is done and the $action was completed or entered one of the
506+
* other states documented on {@link execute()}.
507+
* If false, the {@link TanRequest} inside the action has been updated, which *may* provide new/more
508+
* instructions to the user, though probably it rarely does in practice.
474509
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
475510
* @throws UnexpectedResponseException When the server responds with a valid but unexpected message.
476511
* @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things
@@ -549,6 +584,52 @@ public function checkDecoupledSubmission(BaseAction $action): bool
549584
return true;
550585
}
551586

587+
/**
588+
* For an action where {@link BaseAction::needsPollingWait()} returns `true`, this function polls the server.
589+
* By using {@link persist()}, this can be done asynchronously, i.e., not in the same PHP process as the original
590+
* {@link execute()} call or the previous {@link pollAction()} call.
591+
*
592+
* After this function returns, the `$action` is in any of the same states as after {@link execute()}, see there. In
593+
* particular, it's possible that the long-running operation on the server has not completed yet and thus
594+
* {@link BaseAction::needsPollingWait()} still returns `true`. In practice, actions often require VoP confirmation
595+
* or a TAN after the polling is over, though they can also complete right away.
596+
* In case the action fails, the corresponding exception will be thrown from this function.
597+
*
598+
* @param BaseAction $action The action to be completed.
599+
* @throws CurlException When the connection fails in a layer below the FinTS protocol.
600+
* @throws UnexpectedResponseException When the server responds with a valid but unexpected message.
601+
* @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things
602+
* that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc.
603+
* @link FinTS_3.0_Messages_Geschaeftsvorfaelle_VOP_1.01_2025_06_27_FV.pdf
604+
* Section C.10.7.1.1 a)
605+
*
606+
*/
607+
public function pollAction(BaseAction $action): void
608+
{
609+
$pollingToken = $action->getPollingToken();
610+
if ($pollingToken === null) {
611+
throw new \InvalidArgumentException('This action is not awaiting polling for a long-running operation');
612+
} elseif ($pollingToken instanceof VopPollingToken) {
613+
// Only send a new HKVPP.
614+
$hkvpp = VopHelper::createHKVPPForPollingRequest($this->bpd, $pollingToken);
615+
$message = MessageBuilder::create()->add($hkvpp);
616+
617+
// Add HKTAN for authentication if necessary.
618+
if (!($this->getSelectedTanMode() instanceof NoPsd2TanMode)) {
619+
if (($needTanForSegment = $action->getNeedTanForSegment()) !== null) {
620+
$message->add(HKTANFactory::createProzessvariante2Step1(
621+
$this->requireTanMode(), $this->selectedTanMedium, $needTanForSegment));
622+
}
623+
}
624+
625+
// Execute the request and process the response.
626+
$response = $this->sendMessage($this->buildMessage($message, $this->getSelectedTanMode()));
627+
$this->processServerResponse($action, $response, $hkvpp);
628+
} else {
629+
throw new \InvalidArgumentException('Unexpected polling token type: ' . gettype($pollingToken));
630+
}
631+
}
632+
552633
/**
553634
* Closes the session/dialog/connection, if open. This is equivalent to logging out. You should call this function
554635
* when you're done with all the actions, but NOT when you're persisting the instance to fulfill the TAN request of

lib/Fhp/Model/PollingToken.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace Fhp\Model;
4+
5+
/**
6+
* Provides information that the client application should use to poll for the completion of a long-running operation on
7+
* the server.
8+
*/
9+
interface PollingToken
10+
{
11+
/**
12+
* @return ?int The number of seconds (measured from the time when the client received this {@link PollingToken})
13+
* after which the client is allowed to contact the server again regarding this action. If this returns null,
14+
* there is no restriction.
15+
*/
16+
public function getNextAttemptInSeconds(): ?int;
17+
18+
/**
19+
* @return ?string A user-readable text (either in the bank's language or in English!) that the application may
20+
* display to the user to inform them (on a very high level) about why they have to wait.
21+
*/
22+
public function getInformationForUser(): ?string;
23+
}

lib/Fhp/Protocol/BPD.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Fhp\Segment\HIPINS\HIPINSv1;
1111
use Fhp\Segment\SegmentInterface;
1212
use Fhp\Segment\TAN\HITANS;
13+
use Fhp\Segment\VPP\HIVPPSv1;
1314

1415
/**
1516
* Segmentfolge: Bankparameterdaten (Version 3)
@@ -152,6 +153,28 @@ public function tanRequiredForRequest(array $requestSegments): ?string
152153
return null;
153154
}
154155

156+
/**
157+
* @param SegmentInterface[] $requestSegments The segments that shall be sent to the bank.
158+
* @return string|null Identifier of the (first) segment that requires Verification of Payee according to HIPINS, or
159+
* null if none of the segments require verification.
160+
*/
161+
public function vopRequiredForRequest(array $requestSegments): ?string
162+
{
163+
/** @var HIVPPSv1 $hivpps */
164+
$hivpps = $this->getLatestSupportedParameters('HIVPPS');
165+
$vopRequiredTypes = $hivpps?->parameter?->vopPflichtigerZahlungsverkehrsauftrag;
166+
if ($vopRequiredTypes === null) {
167+
return null;
168+
}
169+
170+
foreach ($requestSegments as $segment) {
171+
if (in_array($segment->getName(), $vopRequiredTypes)) {
172+
return $segment->getName();
173+
}
174+
}
175+
return null;
176+
}
177+
155178
/**
156179
* @return bool Whether the BPD indicates that the bank supports PSD2.
157180
*/

lib/Fhp/Protocol/Message.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,12 +190,17 @@ public function filterByReferenceSegments(array $referenceNumbers): Message
190190

191191
/**
192192
* @param int $code The response code to search for.
193+
* @param ?int $requestSegmentNumber If set, only consider Rueckmeldungen that pertain to this request segment.
193194
* @return Rueckmeldung|null The corresponding Rueckmeldung instance, or null if not found.
194195
*/
195-
public function findRueckmeldung(int $code): ?Rueckmeldung
196+
public function findRueckmeldung(int $code, ?int $requestSegmentNumber = null): ?Rueckmeldung
196197
{
197198
foreach ($this->plainSegments as $segment) {
198-
if ($segment instanceof RueckmeldungContainer) {
199+
if (
200+
$segment instanceof RueckmeldungContainer && (
201+
$requestSegmentNumber === null || $segment->segmentkopf->bezugselement === $requestSegmentNumber
202+
)
203+
) {
199204
$rueckmeldung = $segment->findRueckmeldung($code);
200205
if ($rueckmeldung !== null) {
201206
return $rueckmeldung;

lib/Fhp/Segment/VPP/VopHelper.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
namespace Fhp\Segment\VPP;
4+
5+
use Fhp\Protocol\BPD;
6+
use Fhp\Protocol\Message;
7+
use Fhp\Segment\HIRMS\Rueckmeldungscode;
8+
9+
/** Creates request segments and interprets response segments and Ruckmeldungscodes for anything related to VoP. */
10+
class VopHelper
11+
{
12+
/**
13+
* @param BPD $bpd The BPD.
14+
* @return HKVPPv1 A segment to prompt the server to do Verification of Payee.
15+
*/
16+
public static function createHKVPPForInitialRequest(BPD $bpd): HKVPPv1
17+
{
18+
// For now just pretend we support all formats.
19+
/** @var HIVPPSv1 $hivpps */
20+
$hivpps = $bpd->getLatestSupportedParameters('HIVPPS');
21+
$supportedFormats = explode(';', $hivpps->parameter->unterstuetztePaymentStatusReportDatenformate);
22+
23+
$hkvpp = HKVPPv1::createEmpty();
24+
$hkvpp->unterstuetztePaymentStatusReports->paymentStatusReportDescriptor = $supportedFormats;
25+
return $hkvpp;
26+
}
27+
28+
/**
29+
* @param BPD $bpd The BPD.
30+
* @param VopPollingToken $pollingToken The polling token we got on the immediately preceding request.
31+
* @return HKVPPv1 A segment to poll the server for the completion of Verification of Payee.
32+
*/
33+
public static function createHKVPPForPollingRequest(BPD $bpd, VopPollingToken $pollingToken): HKVPPv1
34+
{
35+
$hkvpp = static::createHKVPPForInitialRequest($bpd);
36+
$hkvpp->aufsetzpunkt = $pollingToken->getAufsetzpunkt();
37+
$hkvpp->pollingId = $pollingToken->getPollingId();
38+
return $hkvpp;
39+
}
40+
41+
/**
42+
* @param Message $response The response we just received from the server.
43+
* @param int $hkvppSegmentNumber The number of the HKVPP segment in the request we had sent.
44+
* @return ?VopPollingToken If the response indicates that the Verification of Payee is still ongoing, such that the
45+
* client should keep polling the server to (actively) wait until the result is available, this function returns
46+
* a corresponding polling token. If no polling is required, it returns null.
47+
*/
48+
public static function checkPollingRequired(Message $response, int $hkvppSegmentNumber): ?VopPollingToken
49+
{
50+
$aufsetzpunkt = $response->findRueckmeldung(Rueckmeldungscode::AUFSETZPUNKT, $hkvppSegmentNumber);
51+
if ($aufsetzpunkt === null) {
52+
return null;
53+
}
54+
/** @var HIVPPv1 $hivpp */
55+
$hivpp = $response->findSegment('HIVPP');
56+
return new VopPollingToken(
57+
$aufsetzpunkt->rueckmeldungsparameter[0],
58+
$hivpp?->pollingId,
59+
$hivpp?->wartezeitVorNaechsterAbfrage,
60+
);
61+
}
62+
}

0 commit comments

Comments
 (0)