Skip to content

Commit 8334e6e

Browse files
committed
nemiah#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 nemiah#499.
1 parent 8216217 commit 8334e6e

File tree

10 files changed

+565
-19
lines changed

10 files changed

+565
-19
lines changed

lib/Fhp/BaseAction.php

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
namespace Fhp;
66

7+
use Fhp\Model\PollingInfo;
78
use Fhp\Model\TanRequest;
9+
use Fhp\Model\VopConfirmationRequest;
810
use Fhp\Protocol\ActionIncompleteException;
911
use Fhp\Protocol\BPD;
1012
use Fhp\Protocol\Message;
@@ -48,6 +50,12 @@ abstract class BaseAction implements \Serializable
4850
/** If set, the last response from the server regarding this action asked for a TAN from the user. */
4951
protected ?TanRequest $tanRequest = null;
5052

53+
/** If set, this action is currently waiting for a long-running operation on the server to complete. */
54+
protected ?PollingInfo $pollingInfo = null;
55+
56+
/** If set, this action needs the user's confirmation to be completed. */
57+
protected ?VopConfirmationRequest $vopConfirmationRequest = null;
58+
5159
protected bool $isDone = false;
5260

5361
/**
@@ -72,15 +80,15 @@ public function serialize(): string
7280
}
7381

7482
/**
75-
* An action can only be serialized *after* it has been executed in case it needs a TAN, i.e. when the result is not
76-
* present yet.
83+
* An action can only be serialized *after* it has been executed and *if* it wasn't completed yet (e.g. because it
84+
* still requires a TAN or VOP confirmation).
7785
* If a sub-class overrides this, it should call the parent function and include it in its result.
7886
*
7987
* @return array The serialized action, e.g. for storage in a database. This will not contain sensitive user data.
8088
*/
8189
public function __serialize(): array
8290
{
83-
if (!$this->needsTan()) {
91+
if (!$this->needsTan() && !$this->needsPollingWait() && !$this->needsVopConfirmation()) {
8492
throw new \RuntimeException('Cannot serialize this action, because it is not waiting for a TAN.');
8593
}
8694
return [
@@ -139,6 +147,26 @@ public function getTanRequest(): ?TanRequest
139147
return $this->tanRequest;
140148
}
141149

150+
public function needsPollingWait(): bool
151+
{
152+
return !$this->isDone() && $this->pollingInfo !== null;
153+
}
154+
155+
public function getPollingInfo(): ?PollingInfo
156+
{
157+
return $this->pollingInfo;
158+
}
159+
160+
public function needsVopConfirmation(): bool
161+
{
162+
return !$this->isDone() && $this->vopConfirmationRequest !== null;
163+
}
164+
165+
public function getVopConfirmationRequest(): ?VopConfirmationRequest
166+
{
167+
return $this->vopConfirmationRequest;
168+
}
169+
142170
/**
143171
* Throws an exception unless this action has been successfully executed, i.e. in the following cases:
144172
* - the action has not been {@link FinTs::execute()}-d at all or the {@link FinTs::execute()} call for it threw an
@@ -248,4 +276,16 @@ final public function setTanRequest(?TanRequest $tanRequest): void
248276
{
249277
$this->tanRequest = $tanRequest;
250278
}
279+
280+
/** To be called only by the FinTs instance that executes this action. */
281+
final public function setPollingInfo(?PollingInfo $pollingInfo): void
282+
{
283+
$this->pollingInfo = $pollingInfo;
284+
}
285+
286+
/** To be called only by the FinTs instance that executes this action. */
287+
final public function setVopConfirmationRequest(?VopConfirmationRequest $vopConfirmationRequest): void
288+
{
289+
$this->vopConfirmationRequest = $vopConfirmationRequest;
290+
}
251291
}

lib/Fhp/FinTs.php

Lines changed: 155 additions & 14 deletions
Large diffs are not rendered by default.

lib/Fhp/Model/PollingInfo.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 PollingInfo
10+
{
11+
/**
12+
* @return ?int The number of seconds (measured from the time when the client received this {@link PollingInfo})
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 An HTML-formatted 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+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace Fhp\Model;
4+
5+
/**
6+
* Provides information (about the payee) that the client application should present to the user and then ask for their
7+
* confirmation that the transfer (to this payee) should be executed.
8+
*/
9+
interface VopConfirmationRequest
10+
{
11+
/** An HTML-formatted text that (if present) the application must show to the user when asking for confirmation. */
12+
public function getInformationForUser(): ?string;
13+
14+
/** If this returns a non-null value, the confirmation request is only valid up to that time. */
15+
public function getExpiration(): ?\DateTime;
16+
17+
/** The main outcome of the payee verification. See {@link VopVerificationResult} for possible values. */
18+
public function getVerificationResult(): ?string;
19+
20+
/**
21+
* If {@link getVerificationResult()} returns {@link VopVerificationResult::NotApplicable}, then this function MAY
22+
* return an additional explanation (in the user's language or in English), but it may also return null.
23+
*/
24+
public function getVerificationNotApplicableReason(): ?string;
25+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
namespace Fhp\Model;
4+
5+
use Fhp\Syntax\Bin;
6+
7+
/** Application code should not interact directly with this type, see {@link VopConfirmationRequest instead}. */
8+
class VopConfirmationRequestImpl implements VopConfirmationRequest
9+
{
10+
private Bin $vopId;
11+
private ?\DateTime $expiration;
12+
private ?string $informationForUser;
13+
private ?string $verificationResult;
14+
private ?string $verificationNotApplicableReason;
15+
16+
public function __construct(
17+
Bin $vopId,
18+
?\DateTime $expiration,
19+
?string $informationForUser,
20+
?string $verificationResult,
21+
?string $verificationNotApplicableReason,
22+
) {
23+
$this->vopId = $vopId;
24+
$this->expiration = $expiration;
25+
$this->informationForUser = $informationForUser;
26+
$this->verificationResult = $verificationResult;
27+
$this->verificationNotApplicableReason = $verificationNotApplicableReason;
28+
}
29+
30+
public function getVopId(): Bin
31+
{
32+
return $this->vopId;
33+
}
34+
35+
public function getExpiration(): ?\DateTime
36+
{
37+
return $this->expiration;
38+
}
39+
40+
public function getInformationForUser(): ?string
41+
{
42+
return $this->informationForUser;
43+
}
44+
45+
public function getVerificationResult(): ?string
46+
{
47+
return $this->verificationResult;
48+
}
49+
50+
public function getVerificationNotApplicableReason(): ?string
51+
{
52+
return $this->verificationNotApplicableReason;
53+
}
54+
}

lib/Fhp/Model/VopPollingInfo.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
namespace Fhp\Model;
4+
5+
use Fhp\Syntax\Bin;
6+
7+
/**
8+
* Application code should not interact directly with this type, see {@link PollingInfo instead}.
9+
*
10+
* When we send a request to the bank that requires a Verification of Payee, this means that the bank server has to
11+
* contact another bank's server and compare payee names. Especially for larger requests (e.g. bulk transfers), this can
12+
* take some time. During this time, the server asks the client to poll regularly in order to find out when the process
13+
* is done. This class contains the state that the client needs to do this polling.
14+
*/
15+
class VopPollingInfo implements PollingInfo
16+
{
17+
// Both of these are effectively opaque tokens that only the server understands. Our job is to relay them back to
18+
// the server when polling. And for some reason there's two of them.
19+
private string $aufsetzpunkt;
20+
private ?Bin $pollingId;
21+
22+
private ?int $nextAttemptInSeconds = null;
23+
24+
public function __construct(string $aufsetzpunkt, ?Bin $pollingId, ?int $nextAttemptInSeconds)
25+
{
26+
$this->aufsetzpunkt = $aufsetzpunkt;
27+
$this->pollingId = $pollingId;
28+
$this->nextAttemptInSeconds = $nextAttemptInSeconds;
29+
}
30+
31+
public function getAufsetzpunkt(): string
32+
{
33+
return $this->aufsetzpunkt;
34+
}
35+
36+
public function getPollingId(): ?Bin
37+
{
38+
return $this->pollingId;
39+
}
40+
41+
public function getNextAttemptInSeconds(): ?int
42+
{
43+
return $this->nextAttemptInSeconds;
44+
}
45+
46+
public function getInformationForUser(): string
47+
{
48+
return 'The bank is verifying payee information...';
49+
}
50+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
namespace Fhp\Model;
4+
5+
use Fhp\Protocol\UnexpectedResponseException;
6+
7+
/**
8+
* Possible outcomes of the Verification of Payee check that the bank did on a transfer we want to execute.
9+
* TODO Once we have PHP8.1, turn this into an enum. That's why we use UpperCamelCase below (Symfony style for enums).
10+
* @see FinTS_3.0_Messages_Geschaeftsvorfaelle_VOP_1.01_2025_06_27_FV.pdf (chapter D under "VOP-Prüfergebnis")
11+
* @see https://febelfin.be/media/pages/publicaties/2023/febelfin-standaarden-voor-online-bankieren/971728b297-1746523070/febelfin-standard-payment-status-report-xml-2025-v1.0-en_final.pdf
12+
*/
13+
class VopVerificationResult
14+
{
15+
/** The verification completed and successfully matched the payee information. */
16+
public const CompletedFullMatch = 'CompletedFullMatch';
17+
/** The verification completed and only partially matched the payee information. */
18+
public const CompletedCloseMatch = 'CompletedCloseMatch';
19+
/** The verification completed but could not match the payee information. */
20+
public const CompletedNoMatch = 'CompletedNoMatch';
21+
/** The verification completed but not all included transfers were successfully matched. */
22+
public const CompletedPartialMatch = 'CompletedPartialMatch';
23+
/**
24+
* The verification was attempted but could not be completed. More information MAY be available from
25+
* {@link VopConfirmationRequest::getVerificationNotApplicableReason()}.
26+
*/
27+
public const NotApplicable = 'NotApplicable';
28+
29+
public function __construct()
30+
{
31+
// Disallow instantiation, because we'll turn this into an enum.
32+
throw new \AssertionError('There should be no instances of VopVerificationResult');
33+
}
34+
35+
/**
36+
* @param string $codeFromBank The verification status code received from the bank.
37+
* @return ?string One of the constants defined above, or null if the code could not be recognized.
38+
*/
39+
public static function parse(string $codeFromBank): ?string
40+
{
41+
return match ($codeFromBank) {
42+
'RCVC' => self::CompletedFullMatch,
43+
'RVMC' => self::CompletedCloseMatch,
44+
'RVNM' => self::CompletedNoMatch,
45+
'RVCM' => self::CompletedPartialMatch,
46+
'RVNA' => self::NotApplicable,
47+
default => throw new UnexpectedResponseException("Unexpected VOP result code: $codeFromBank"),
48+
};
49+
}
50+
}

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;

0 commit comments

Comments
 (0)