diff --git a/lib/Fhp/Action/SendSEPATransfer.php b/lib/Fhp/Action/SendSEPATransfer.php index cc2370d7..e9ed0b38 100644 --- a/lib/Fhp/Action/SendSEPATransfer.php +++ b/lib/Fhp/Action/SendSEPATransfer.php @@ -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]; diff --git a/lib/Fhp/Action/SendSEPATransferVoP.php b/lib/Fhp/Action/SendSEPATransferVoP.php new file mode 100644 index 00000000..4624efe9 --- /dev/null +++ b/lib/Fhp/Action/SendSEPATransferVoP.php @@ -0,0 +1,151 @@ +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) { + + $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) { + $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 + // 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() + { + return $this->vopNeedsConfirmation; + } + + public function setConfirmed() + { + $this->vopConfirmed = true; + } +} \ No newline at end of file diff --git a/lib/Fhp/BaseAction.php b/lib/Fhp/BaseAction.php index 76ab7fc3..c7d3d55f 100644 --- a/lib/Fhp/BaseAction.php +++ b/lib/Fhp/BaseAction.php @@ -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 diff --git a/lib/Fhp/FinTs.php b/lib/Fhp/FinTs.php index a124c27a..defaf6d7 100644 --- a/lib/Fhp/FinTs.php +++ b/lib/Fhp/FinTs.php @@ -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); + $message->add($hktan); } } $request = $this->buildMessage($message, $this->getSelectedTanMode()); @@ -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); } diff --git a/lib/Fhp/Segment/VPP/HIVPPv1.php b/lib/Fhp/Segment/VPP/HIVPPv1.php index c70a32f2..01e90f03 100644 --- a/lib/Fhp/Segment/VPP/HIVPPv1.php +++ b/lib/Fhp/Segment/VPP/HIVPPv1.php @@ -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; @@ -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; + } + } } \ No newline at end of file diff --git a/lib/Tests/Fhp/Integration/Atruvia/AtruviaIntegrationTestBase.php b/lib/Tests/Fhp/Integration/Atruvia/AtruviaIntegrationTestBase.php new file mode 100644 index 00000000..a466893a --- /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..06fdceff --- /dev/null +++ b/lib/Tests/Fhp/Integration/Atruvia/SendTransferVoPTest.php @@ -0,0 +1,97 @@ +' . "\n" . 'M12345678902025-10-10T12:52:56+02:00110.00PRIVATE__________________P12345678TRF110.00SEPA
1999-01-01
PRIVATE__________________DE00ABCDEFGH1234567890ABCDEFGHIJKSLEVNOTPROVIDED10.00EmpfängerDE00ABCDEFGH1234567890Testüberweisung
'; + + public const SEND_TRANSFER_REQUEST = + "HKVPP:3:1+urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.002.001.10'HKCCS:4:1+DE00ABCDEFGH1234567890:ABCDEFGHIJK:1234567890::280:11223344+urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.09+@1161@" . + self::XML_PAYLOAD . + "'HKTAN:5:7+4+HKCCS'" + ; + public const SEND_TRANSFER_RESPONSE = "HIRMG:3:2+3060::Bitte beachten Sie die enthaltenen Warnungen/Hinweise.+3905::Es wurde keine Challenge erzeugt.'HIRMS:4:2:3+3040::Es liegen weitere Informationen vor.:staticscrollref'HIRMS:5:2:5+3945::Freigabe ohne VOP-Bestätigung nicht möglich.'HIVPP:6:1:3+++@36@c0f5c2a4-ebb7-4e72-be44-c68742177a2b+++++2'"; + + public const POLL_VOP_REPORT_REQUEST = "HKVPP:3:1+urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.002.001.10+@36@c0f5c2a4-ebb7-4e72-be44-c68742177a2b++staticscrollref'"; + + public const POLL_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+@3616@UTF8XMLPAYLOAD'"; + + public const POLL_VOP_REPORT_MATCH_RESPONSE_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 POLL_VOP_REPORT_NO_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+@3600@UTF8XMLPAYLOAD++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 POLL_VOP_REPORT_NO_MATCH_RESPONSE_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 = + "HKVPA:3:1+@36@5e3b5c99-df27-4d42-835b-18b35d0c66ff'HKCCS:4:1+DE00ABCDEFGH1234567890:ABCDEFGHIJK:1234567890::280:11223344+urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.09+@1161@" . + self::XML_PAYLOAD . + "'HKTAN:5:7+4+HKCCS'" + ; + public const CONFIRM_VOP_RESPONSE = "HIRMG:3:2+3060::Bitte beachten Sie die enthaltenen Warnungen/Hinweise.'HIRMS:4:2:3+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 + */ + protected function testVop(string $requestVopReportResponse) + { + $this->initDialog(); + + $transferAction = $this->getTransferAction(); + + $this->expectMessage(static::SEND_TRANSFER_REQUEST, mb_convert_encoding(static::SEND_TRANSFER_RESPONSE, 'ISO-8859-1', 'UTF-8')); + $this->expectMessage(static::POLL_VOP_REPORT_REQUEST, $requestVopReportResponse); + $this->expectMessage(static::CONFIRM_VOP_REQUEST, mb_convert_encoding(static::CONFIRM_VOP_RESPONSE, 'ISO-8859-1', 'UTF-8')); + $this->expectMessage(static::CHECK_DECOUPLED_SUBMISSION_REQUEST, mb_convert_encoding(static::CHECK_DECOUPLED_SUBMISSION_RESPONSE, 'ISO-8859-1', 'UTF-8')); + + $this->fints->execute($transferAction); + + if ($transferAction->needsTime()) { + # As this is a test, we don't need to actually wait. + #$wait = $transferAction->hivpp->wartezeitVorNaechsterAbfrage; + #sleep($wait); + $this->fints->execute($transferAction); + } + + if ($transferAction->needsConfirmation()) { + # Do something with the result. + $transferAction->hivpp->getVopResultCode(); + $transferAction->setConfirmed(); + $this->fints->execute($transferAction); + } + + $tanMode = $this->fints->getSelectedTanMode(); + if ($transferAction->needsTan()) { + if ($tanMode->isDecoupled()) { + $this->fints->checkDecoupledSubmission($transferAction); + } + } + + $transferAction->ensureDone(); + } + + public function testVopNoMatch() + { + $requestVopReportResponse = str_replace('UTF8XMLPAYLOAD', self::POLL_VOP_REPORT_NO_MATCH_RESPONSE_XML_PAYLOAD, mb_convert_encoding(static::POLL_VOP_REPORT_NO_MATCH_RESPONSE, 'ISO-8859-1', 'UTF-8')); + + $this->testVop($requestVopReportResponse); + } + + public function testVopMatch() + { + $requestVopReportResponse = str_replace('UTF8XMLPAYLOAD', self::POLL_VOP_REPORT_MATCH_RESPONSE_XML_PAYLOAD, mb_convert_encoding(static::POLL_VOP_REPORT_MATCH_RESPONSE, 'ISO-8859-1', 'UTF-8')); + + $this->testVop($requestVopReportResponse); + } + + protected function getTransferAction(): SendSEPATransferVoP + { + $account = $this->getTestAccount(); + return SendSEPATransferVoP::create($account, self::XML_PAYLOAD); + } +}