From 2adbbb698855d2efdaa8ef2da20d38c3954af488 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 1 Sep 2026 18:51:41 +0200 Subject: [PATCH] Only reject stepwise VOP report delivery when it actually happens The HIVPPS parameter "Art der Lieferung Payment Status Report" (V/S) describes how the bank splits the pain.002 message *if* it uses the Aufsetzpunkt mechanism, which it typically only does for large batches: "Der BPD-Parameter gibt an, ob die pain.002-Nachricht bei einer Aufsetzpunktbehandlung vollstaendig oder schrittweise uebertragen wird." -- FinTS_3.0_Messages_Geschaeftsvorfaelle_VOP_1.01_2025_06_27_FV.pdf, chapter D Rejecting "S" while building the very first HKVPP is therefore too early: it makes every transfer fail for banks that announce stepwise delivery (e.g. comdirect announces "20:J:S:N:N:sepade.pain.002.001.10.xsd"), even for a single transfer where the bank returns the result right away and no Aufsetzpunkt is involved at all. The check now happens where it matters, in checkPollingRequired(): if the bank sends an intermediate delivery of the report, "vollstaendige Lieferung" (V) means each delivery contains all data accumulated so far, so the intermediate one can be discarded and we keep polling for the final report. Only for "schrittweise Lieferung" (S) would the client have to stitch the deltas together, which is still not implemented and still raises UnsupportedException. Splitting a HIVPP with an Aufsetzpunkt and a vopId remains an unexpected response, since the specification says the VOP ID is only set in the final HIVPP of such a sequence. Tests: SendTransferVoPStepwiseTest runs all existing VOP scenarios against a bank that announces stepwise delivery, and both variants cover an intermediate report delivery. --- .../Atruvia/AtruviaIntegrationTestBase.php | 13 ++++- .../Atruvia/SendTransferVoPStepwiseTest.php | 50 +++++++++++++++++++ .../Atruvia/SendTransferVoPTest.php | 23 +++++++++ src/FinTs.php | 2 +- src/Segment/VPP/VopHelper.php | 33 +++++++++--- 5 files changed, 110 insertions(+), 11 deletions(-) create mode 100644 Tests/Unit/Integration/Atruvia/SendTransferVoPStepwiseTest.php diff --git a/Tests/Unit/Integration/Atruvia/AtruviaIntegrationTestBase.php b/Tests/Unit/Integration/Atruvia/AtruviaIntegrationTestBase.php index d03e7479..522985a6 100644 --- a/Tests/Unit/Integration/Atruvia/AtruviaIntegrationTestBase.php +++ b/Tests/Unit/Integration/Atruvia/AtruviaIntegrationTestBase.php @@ -49,7 +49,7 @@ class AtruviaIntegrationTestBase extends FinTsTestCase */ protected function InitAnonymous() { - $this->expectMessage(static::ANONYMOUS_INIT_REQUEST, mb_convert_encoding(static::ANONYMOUS_INIT_RESPONSE, 'ISO-8859-1', 'UTF-8')); + $this->expectMessage(static::ANONYMOUS_INIT_REQUEST, mb_convert_encoding(static::anonymousInitResponse(), '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(); @@ -62,7 +62,7 @@ protected function InitAnonymous() 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_INIT_REQUEST, mb_convert_encoding(static::anonymousInitResponse(), '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. @@ -78,6 +78,15 @@ protected function initDialog() $this->assertAllMessagesSeen(); } + /** + * The response that carries the BPD. Subclasses can override this to test with different bank parameters, without + * having to duplicate the (very long) message. + */ + protected static function anonymousInitResponse(): string + { + return static::ANONYMOUS_INIT_RESPONSE; + } + protected function getTestAccount(): SEPAAccount { $sepaAccount = new SEPAAccount(); diff --git a/Tests/Unit/Integration/Atruvia/SendTransferVoPStepwiseTest.php b/Tests/Unit/Integration/Atruvia/SendTransferVoPStepwiseTest.php new file mode 100644 index 00000000..12ac17b9 --- /dev/null +++ b/Tests/Unit/Integration/Atruvia/SendTransferVoPStepwiseTest.php @@ -0,0 +1,50 @@ +initDialog(); + $action = $this->createAction(); + + $response = static::buildVopReportResponse( + static::SEND_TRANSFER_RESPONSE_POLLING_NEEDED, + static::VOP_REPORT_PARTIAL_MATCH_XML_PAYLOAD + ); + $this->expectMessage(static::SEND_TRANSFER_REQUEST, $response); + + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('The stepwise transfer of VOP reports is not yet supported'); + $this->fints->execute($action); + } +} diff --git a/Tests/Unit/Integration/Atruvia/SendTransferVoPTest.php b/Tests/Unit/Integration/Atruvia/SendTransferVoPTest.php index ef23f77a..cc15b791 100644 --- a/Tests/Unit/Integration/Atruvia/SendTransferVoPTest.php +++ b/Tests/Unit/Integration/Atruvia/SendTransferVoPTest.php @@ -282,6 +282,29 @@ public function testVopWithSingleTransactionResultNoMatch(): void $this->assertNull($action->getVopConfirmationRequest()->getDifferingPayeeName()); } + /** + * The bank tells us that it needs more time (Aufsetzpunkt) and already sends a first part of the report. With + * "vollstaendige Lieferung" (V) every delivery contains all data accumulated so far, so we can simply discard the + * intermediate one and keep polling for the final report. + * @throws \Throwable + */ + public function testIntermediateReportDelivery(): void + { + $this->initDialog(); + $action = $this->createAction(); + + $response = static::buildVopReportResponse( + static::SEND_TRANSFER_RESPONSE_POLLING_NEEDED, + static::VOP_REPORT_PARTIAL_MATCH_XML_PAYLOAD + ); + $this->expectMessage(static::SEND_TRANSFER_REQUEST, $response); + $this->fints->execute($action); + + $this->assertTrue($action->needsPollingWait()); + $this->assertFalse($action->needsVopConfirmation()); + $this->assertFalse($action->isDone()); + } + protected function createAction(): SendSEPATransfer { return SendSEPATransfer::create($this->getTestAccount(), self::XML_PAYLOAD); diff --git a/src/FinTs.php b/src/FinTs.php index 18c5a61a..aac493e6 100644 --- a/src/FinTs.php +++ b/src/FinTs.php @@ -408,7 +408,7 @@ private function processServerResponse(BaseAction $action, Message $response, ?H // Detect if the bank needs us to do something for Verification of Payee. if ($hkvpp != null) { - if ($pollingInfo = VopHelper::checkPollingRequired($response, $hkvpp->getSegmentNumber())) { + if ($pollingInfo = VopHelper::checkPollingRequired($response, $hkvpp->getSegmentNumber(), $this->bpd)) { $action->setPollingInfo($pollingInfo); if ($action->needsTan()) { throw new UnexpectedResponseException('Unexpected polling and TAN request in the same response.'); diff --git a/src/Segment/VPP/VopHelper.php b/src/Segment/VPP/VopHelper.php index f7ad86fe..6e6348d3 100644 --- a/src/Segment/VPP/VopHelper.php +++ b/src/Segment/VPP/VopHelper.php @@ -28,9 +28,11 @@ public static function createHKVPPForInitialRequest(BPD $bpd): HKVPPv1 /** @var HIVPPSv1 $hivpps */ $hivpps = $bpd->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'); - } + + // Note: The "Art der Lieferung Payment Status Report" (V/S) parameter only describes how the bank splits the + // pain.002 message *if* it uses the Aufsetzpunkt mechanism, which it typically only does for large batches. + // So we can always send the initial request and only need to look at the parameter once the bank actually + // sends us a partial report, see checkPollingRequired(). $hkvpp = HKVPPv1::createEmpty(); $hkvpp->unterstuetztePaymentStatusReports->paymentStatusReportDescriptor = $supportedFormats; @@ -53,12 +55,16 @@ public static function createHKVPPForPollingRequest(BPD $bpd, VopPollingInfo $po /** * @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. + * @param BPD $bpd The BPD, which tells us how the bank splits the report across multiple deliveries. * @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 - { + public static function checkPollingRequired( + Message $response, + int $hkvppSegmentNumber, + BPD $bpd, + ): ?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 @@ -70,9 +76,20 @@ public static function checkPollingRequired(Message $response, int $hkvppSegment } /** @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.'); + if ($hivpp->vopId !== null) { + // The specification says that the VOP ID is only present in the final HIVPP of an Aufsetzpunkt sequence. + throw new UnexpectedResponseException('Got response with Aufsetzpunkt AND vopId.'); + } + if ($hivpp->paymentStatusReport !== null) { + // This is an intermediate delivery of the report. With "vollstaendige Lieferung" (V) each delivery + // contains all data accumulated so far, so the final one is enough and we can discard this one. With + // "schrittweise Lieferung" (S) the bank only sends the delta, so the client would have to stitch the + // deliveries together, which is not implemented. + /** @var HIVPPSv1 $hivpps */ + $hivpps = $bpd->getLatestSupportedParameters('HIVPPS'); + if ($hivpps->parameter->artDerLieferungPaymentStatusReport !== 'V') { + throw new UnsupportedException('The stepwise transfer of VOP reports is not yet supported'); + } } return new VopPollingInfo( $aufsetzpunkt->rueckmeldungsparameter[0],