Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/Fhp/Action/SendSEPATransfer.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ class SendSEPATransfer extends BaseAction
* to create this.
* @return SendSEPATransfer A new action for executing this the given PAIN message.
*/
public static function create(SEPAAccount $account, string $painMessage): SendSEPATransfer
public static function create(SEPAAccount $account, string $painMessage): static
{
if (preg_match('/xmlns="(.*?)"/', $painMessage, $match) === false) {
throw new \InvalidArgumentException('xmlns not found in the PAIN message');
}
$result = new SendSEPATransfer();
$result = new static();
$result->account = $account;
$result->painMessage = $painMessage;
$result->xmlSchema = $match[1];
Expand Down
151 changes: 151 additions & 0 deletions lib/Fhp/Action/SendSEPATransferVoP.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?php

namespace Fhp\Action;

use Fhp\Protocol\BPD;
use Fhp\Protocol\Message;
use Fhp\Protocol\UPD;
use Fhp\Segment\HIRMS\Rueckmeldungscode;
use Fhp\Segment\VPP\HIVPPSv1;
use Fhp\Segment\VPP\HIVPPv1;
use Fhp\Segment\VPP\HKVPAv1;
use Fhp\Segment\VPP\HKVPPv1;
use Fhp\UnsupportedException;

/**
* Initiates an outgoing wire transfer in SEPA format (PAIN XML) with VoP.
* @see FinTS_3.0_Messages_Geschaeftsvorfaelle_VOP_1.01_2025_06_27_FV.pdf
*/
class SendSEPATransferVoP extends SendSEPATransfer
{
protected $vopRequired = false;
protected $vopIsPending = false;
protected $vopNeedsConfirmation = false;

protected $vopConfirmed = false;

/**
* If set, the last response from the server regarding this action indicated that there are more results to be
* fetched using this pagination token. This is called "Aufsetzpunkt" in the specification.
* Pagination is used in VoP to poll for the result of the name check.
*/
protected ?string $paginationToken = null;

public ?HKVPPv1 $hkvpp = null;
public ?HIVPPv1 $hivpp = null;

protected function createRequest(BPD $bpd, ?UPD $upd)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Für mein besseres Verständnis: Diese Funktion wird ja nun vermutlich einige Male nacheinander aufgerufen. Anhand des Zustands der obigen Member-Variablen muss die Action dann entscheiden können, welche Requests jeweils gesendet werden sollen.

Könntest du mir bitte einen Überblick geben, was der Reihe nach passiert? Vielleicht in Form einer Chronologie, welche von den ifs unten nacheinander zutreffen? Oder in Form eines Logs von Requests und Responses? Oder als Beschreibung der Zustands-Änderungen (also wann geht vopNeedsConfirmation auf true, wann auf false, wann relativ dazu geht vopIsPending auf true und so weiter)? Ich weiß nicht, in welcher Form es sich am besten erklären lässt. (Im Idealfall kann man eine einfachst-mögliche Erklärung am Ende auch in einfachen Code gießen.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Der Ablauf ist in der Spezifikation bereits als Diagram dargestellt. Grob gesagt:

  • Prüfauftrag (HKVPP) + Geschäftsvorfall abschicken
  • Bank antwortet mit, keine Prüfautrag nötig oder mit HIVPP (der dann entweder das Prüfergebnis + VoP-Id enthält oder eine Polling-Id und keine VoP-Id).
  • Abhängig davon muss ggf. solange HKVPP (Polling-Id + Aufsetzpunkt) nochmal schicken abfragen bis man eine VoP-Id ()bekommen hat.
  • Wenn man endlich eine VoP-Id hat, dann kann man den Ausführungsauftrag (HKVPA) + den ursprünglichen Geschäftsvorfall abschicken.
  • Dann kommt die normale Tan-Behandlung

{
// Do we need to ask for the VoP result?
if ($this->vopIsPending) {
$this->hkvpp->pollingId = $this->hivpp->pollingId;
$this->hkvpp->aufsetzpunkt = $this->paginationToken;
return $this->hkvpp;
}

$requestSegment = parent::createRequest($bpd, $upd);
$requestSegments = [$requestSegment];

if ($this->vopNeedsConfirmation && $this->vopConfirmed) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hier fehlt mir ein erklärender Kommentar.


$hkvpa = HKVPAv1::createEmpty();
$hkvpa->vopId = $this->hivpp->vopId;
return [$hkvpa, $requestSegment];
}

// Check if VoP is supported by the bank

/** @var HIVPPSv1 $hivpps */
if ($hivpps = $bpd->getLatestSupportedParameters('HIVPPS')) {
// Check if the request segment is in the list of VoP-supported segments
if (in_array($requestSegment->getName(), $hivpps->parameter->vopPflichtigerZahlungsverkehrsauftrag)) {

$this->vopRequired = true;

// Send VoP confirmation
if ($this->needsConfirmation() && $this->hivpp?->vopId) {
$hkvpp = HKVPAv1::createEmpty();
$hkvpp->vopId = $this->hivpp->vopId;
$requestSegments = [$hkvpp, $requestSegment];
} else {
// Ask for VoP
$this->hkvpp = $hkvpp = HKVPPv1::createEmpty();

// For now just pretend we support all formats
$supportedFormats = explode(';', $hivpps->parameter->unterstuetztePaymentStatusReportDatenformate);
$hkvpp->unterstuetztePaymentStatusReports->paymentStatusReportDescriptor = $supportedFormats;

// VoP before the transfer request
$requestSegments = [$hkvpp, $requestSegment];
}
}
}

return $requestSegments;
}

public function processResponse(Message $response)
{
$this->vopIsPending = false;
$this->hivpp = $response->findSegment(HIVPPv1::class);

// The Bank does not want a separate HKVPA ("VoP Ausführungsauftrag").
if ($response->findRueckmeldung(Rueckmeldungscode::VOP_AUSFUEHRUNGSAUFTRAG_NICHT_BENOETIGT) !== null) {
$this->vopRequired = false;
$this->vopNeedsConfirmation = false;
parent::processResponse($response);
return;
}

if ($response->findRueckmeldung(Rueckmeldungscode::VOP_NAMENSABGLEICH_IST_NOCH_IN_BEARBEITUNG) !== null) {
$this->vopIsPending = true;
$this->vopNeedsConfirmation = false;
return;
}

if (($pagination = $response->findRueckmeldung(Rueckmeldungscode::PAGINATION)) !== null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Das ist code 3040. In der VoP-Spezifikation kommt der nicht so explizit vor. Ergibt sich das daraus, dass dort von "Aufsetzpunktmechanismus" die Rede ist? Oder hast du die 3040 einfach in der freien Wildbahn beobachtet?

Der Begriff "Pagination" passt dann nicht mehr so richtig. Wenn das wirklich identisch ist mit dem Aufsetzpunkt der für Pagination verwendet wird, sollten wir es in "continuation (token)" oder so umbenennen.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ich habe lange nicht verstanden wo der Aufsetzpunkt herkommen soll, der in der Spezifikation erwähnt wird. Bis ich dann draufgekommen bin das es Aufsetzpunkte ja schon öfter gab. Und ja: ohne den Aufsetzpunkt funktioniert das Polling der Ergebnisse nicht.

$this->paginationToken = $pagination->rueckmeldungsparameter[0];
}

if (
$response->findRueckmeldung(Rueckmeldungscode::VOP_KEINE_NAMENSABWEICHUNG) !== null
// The bank has discarded the request, and wants us to resend it with a HKVPA
// This can happen even if the name matches.
|| $response->findRueckmeldung(Rueckmeldungscode::FREIGABE_KANN_NICHT_ERTEILT_WERDEN) !== null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Es gibt drei alternative Bedingungen in diesem if. Wenn wir hier in dieses if reingehen, wird vopNeedsConfirmation = true gesetzt. Das überrascht mich:

  1. Zur ersten Bedingung VOP_KEINE_NAMENSABWEICHUNG finde ich nicht viel in der Spezifikation, aber ich würde vermuten es bedeutet "ist auch ohne VOP okay", also wäre vopNeedsConfirmation = false angemessen.
  2. Bei der zweiten Bedingung FREIGABE_KANN_NICHT_ERTEILT_WERDEN habe ich das Gefühl, dass es ein Nebenschauplatz ist. Wenn die Bank VOP machen will, dann dauert das erst Mal und am Ende ist eventuell eine Bestätigung nötig. Darum verwirft sie den aktuellen TAN-Request (denn wenn eine TAN-Challenge enthalten wäre, hätte die eventuell ein Timeout z.B. aus kryptographischen Gründen, das während der VOP-Prüfung ablaufen könnte). Wenn VOP dann durch ist, sollen wir laut Spezifikation einen frischen Anlauf mit der TAN starten. Wir könnten zwar den Abbruch des TAN-Requests als Signal interpretieren, dass VOP stattfindet aber es kommt mir ziemlich implizit vor. Ich hoffe, dass die erste Antwort der Bank (SEND_TRANSFER_RESPONSE im Unit Test) noch hilfreichere Signale in die Richtung enthält.
    • Und selbst wenn wir erkennen, dass VOP stattfindet, sollten wir noch nicht vopNeedsConfirmation setzen, sondern nur vopIsPending. Denn bis jetzt arbeitet die Bank ja nur an der Prüfung und es kann durchaus sein, dass sie nach der Verarbeitung noch zu dem Schluss kommt, dass keine Bestätigung durch den Nutzer mehr nötig ist.
  3. VOP_ERGEBNIS_NAMENSABGLEICH_PRUEFEN Das ist genau die Nachricht, wo ich vopNeedsConfirmation = true setzen würde. Es heißt ja schon wörtlich fast gleich.

Also ist jetzt die Frage, wie man der SEND_TRANSFER_RESPONSE ansieht, dass (1) VOP stattfindet und (2) man noch warten/pollen muss. Wir haben diese Segmente darin:

  1. HIRMS:4:2:3+3040::Es liegen weitere Informationen vor.:staticscrollref' => PAGINATION aka Aufsetzpunkt.
    • Dieses Segment wird ein paar Zeilen weiter oben erkannt und paginationToken daraus befüllt (sonst nichts).
    • Ich habe den Eindruck, dass das das Signal ist, nach dem wir suchen. Klingt das plausibel? Die Idee ist, dass der Client immer gleich auf einen Aufsetzpunkt reagiert: Das betroffene Segment nochmal einreichen und die Antworten aneinanderhängen, so lange bis der Aufsetzpunkt verschwindet -- denn dann hat man das Ende ("die letzte Seite") erreicht.
  2. HIVPP:6:1:3+++@36@c0f5c2a4-ebb7-4e72-be44-c68742177a2b+++++2' Hier ist das pollingId-Feld belegt. Wenn das so ist, müssen wir sie beim Polling zurückliefern. Aber wenn sie nicht belegt wäre, würde Polling trotzdem stattfinden, einfach nur mit dem Aufsetzpunkt allein.

Ich gehe mal davon aus, dass meine Vermutung richtig ist, und implementiere es in FinTs allein aufgrund vom Aufsetzpunkt. Hoffentlich funktioniert es dann trotzdem mit deinem Unit-Test zusammen, ohne dass dieser geändert werden muss.

Copy link
Contributor Author

@ampaze ampaze Oct 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Habe auch wenig zu VOP_KEINE_NAMENSABWEICHUNG gefunden, aber Bank (bzw. Atruvia) verlangt soweit ich es ausprobiert habe auch bei Namensgleichheit, dass man die Ausführungsauftrag sendet. Ich denke die haben versucht den Prozess einheitlich zu gestalten, sodass der Ablauf immer gleich ist, egal wie das Ergebnis der Prüfung ist.

  2. FREIGABE_KANN_NICHT_ERTEILT_WERDEN wird zumindest von Atruvia auch bei Namensgleichheit geschickt, das hab ich gerade noch mal in den Logs nachgeguckt. Es ist also ein Ausführungsauftrag erforderlich.


In der Spezifikation steht explizit dass alleine die Rückmeldungscode ausschlaggebend sind, nicht die sonstigen Segmente. So hab ich das auch implementiert. Zitat:

Der Ablauf wird grundsätzlich nur durch die Rückmeldungscodes gesteuert und nicht durch das Prüfergebnis im HIVPP.
Es kann also vorkommen, dass der Rückmeldungscode 3945 "Freigabe kann nicht erteilt werden" auch bei einem VOP-Prüfergebnis RCVC (Match) gesendet wird. Dies gilt insbesondere für das Polling oder aber bei Opt-Out mit Decoupled-Verfahren. Das Kundenprodukt hat dann den Ablauf analog zum Close-/No-Match/Not Applicable-Ablauf (d.h. Neueinreichung des ZV-Auftrags und HKTAN in Verbindung mit HKVPA) fortzusetzen (s. Punkt 4. und Ablaufdiagramme_E.8.1.1.2 bzw._E.8.1.2.2 und_E.8.1.2.4..
Implementierungsbedingt kann es vorkommen, dass auch im Match-Fall einen Rückmeldungscode 3090 gesendet wird.
Implementierungsbedingt kann es vorkommen, dass ein Institut auch bei einem Match-Ergebnis immer mit einem Rückmeldungscode 3945 „Freigabe kann nicht erteilt werden" antwortet und grundsätzlich eine erneute Einreichung des ZV-Auftrags und HKTAN in Verbindung mit HKVPA erwartet.

// The user needs to check the result of the name check.
// This can be sent by the bank even if the name matches.
|| $response->findRueckmeldung(Rueckmeldungscode::VOP_ERGEBNIS_NAMENSABGLEICH_PRUEFEN) !== null
) {
$this->vopNeedsConfirmation = true;
// Is the result already available?
if (!$this->hivpp->vopId) {
$this->vopIsPending = true;
}
return;
}

// The bank accepted the request as is.
if ($response->findRueckmeldung(Rueckmeldungscode::ENTGEGENGENOMMEN) !== null || $response->findRueckmeldung(Rueckmeldungscode::AUSGEFUEHRT) !== null) {
$this->vopRequired = false;
parent::processResponse($response);
return;
}

throw new UnsupportedException('Unexpected state in VoP process');
}

public function needsTime()
{
return $this->vopIsPending;
}

public function needsConfirmation()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needsConfirmation() ist, wenn der Endnutzer etwas tun muss (z.b. 2FA bestätigen auf dem Handy). Das scheint hier nicht der Fall zu sein, oder? (Sonst würde ich eine neue Methode FinTs::confirmVoP() erwarten analog zu submitTan().)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah jetzt sehe ich deinen Beispielcode. Also braucht es doch beides und statt FinTs::confirmVoP() haben wir im Moment Action::setConfirmed() (was für mich wie ein dummer Setter klang, aber was wohl eine Funktion mit mehr Tragweite ist).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needsConfirmation ist wenn die Bank einen HKVPA verlangt. Das ist also außer in bestimmten Sonderfällen eigentlich immer der Fall.
Dann muss der Nutzer ja irgendwie mitteilen, dass er diese Bestätigung auch erteilt. Das hatte ich setConfirmed genannt. Beides muss True sein bevor man einen Ausführungsauftrag sendet. FinTs::confirmVoP() ist aber in der Tat viel klarer.

{
return $this->vopNeedsConfirmation;
}

public function setConfirmed()
{
$this->vopConfirmed = true;
}
}
10 changes: 10 additions & 0 deletions lib/Fhp/BaseAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,16 @@ public function getTanRequest(): ?TanRequest
return $this->tanRequest;
}

public function needsConfirmation()
{
return false;
}

public function needsTime()
{
return false;
}

/**
* 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
Expand Down
10 changes: 7 additions & 3 deletions lib/Fhp/FinTs.php
Original file line number Diff line number Diff line change
Expand Up @@ -320,8 +320,8 @@ public function execute(BaseAction $action)
$message = MessageBuilder::create()->add($requestSegments); // This fills in the segment numbers.
if (!($this->getSelectedTanMode() instanceof NoPsd2TanMode)) {
if (($needTanForSegment = $action->getNeedTanForSegment()) !== null) {
$message->add(HKTANFactory::createProzessvariante2Step1(
$this->requireTanMode(), $this->selectedTanMedium, $needTanForSegment));
$hktan = HKTANFactory::createProzessvariante2Step1($this->requireTanMode(), $this->selectedTanMedium, $needTanForSegment);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diese Code-Änderung hat keine Auswirkung, oder? Könnte man also auch rückgängig machen.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doch, ich brauche die Variable um die Segmentnummer zu ermitteln, damit es nicht rausgefiltert wird. Da ist aber nur relevant wenn die Action selbst die VoP sachen macht.

$message->add($hktan);
}
}
$request = $this->buildMessage($message, $this->getSelectedTanMode());
Expand Down Expand Up @@ -354,7 +354,11 @@ public function execute(BaseAction $action)
}

// If no TAN is needed, process the response normally, and maybe keep going for more pages.
$this->processActionResponse($action, $response->filterByReferenceSegments($action->getRequestSegmentNumbers()));
$requestSegmentsNumbers = $action->getRequestSegmentNumbers();
if (isset($hktan)) {
$requestSegmentsNumbers[] = $hktan->getSegmentNumber();
}
$this->processActionResponse($action, $response->filterByReferenceSegments($requestSegmentsNumbers));
if ($action instanceof PaginateableAction && $action->hasMorePages()) {
$this->execute($action);
}
Expand Down
19 changes: 19 additions & 0 deletions lib/Fhp/Segment/VPP/HIVPPv1.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@
*/
class HIVPPv1 extends BaseSegment
{
public const VOP_RESULT_CODES = [
'RCVC' => 'ReceivedVerificationCompleted',
'RVNA' => 'ReceivedVerificationCompletedNotApplicable',
'RVNM' => 'ReceivedVerificationCompletedNoMatch',
'RVMC' => 'ReceivedVerificationCompletedMatchClosely',
'RVNC' => 'ReceivedVerificationNotCompleted',
'RVCM' => 'ReceivedVerificationCompletedWithMismatches'
];

public ?Bin $vopId = null;

public ?Tsp $vopIdGueltigBis = null;
Expand All @@ -30,4 +39,14 @@ class HIVPPv1 extends BaseSegment

// This value is in seconds
public ?int $wartezeitVorNaechsterAbfrage = null;

public function getVopResultCode(): ?string
{
if ($this->paymentStatusReport) {
$report = simplexml_load_string($this->paymentStatusReport->getData());
return $report->CstmrPmtStsRpt->OrgnlGrpInfAndSts->GrpSts;
} else {
return $this->ergebnisVopPruefungEinzeltransaktion?->vopPruefergebnis;
}
}
}
Loading