From 66899701ad32e5bf1fa427279cb14559b3cfabb6 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 6 Oct 2026 16:54:07 +0200 Subject: [PATCH] Support SEPA instant payment status request (HKIPS/HIIPS/HIIPSS) When the payee's bank does not answer in time, the bank confirms the TAN of an instant payment (HKIPZ) with code 3045 instead of 0010/0020. SendSEPARealtimeTransfer threw 'Bank did not confirm SEPATransfer execution' although the order was accepted. - SendSEPARealtimeTransfer: read the Auftragsidentifikation and status from HIIPZ, treat 3045 as 'status pending' instead of failing. - New action GetSEPARealtimeTransferStatus (HKIPS v1) to request the final status, plus HIIPS/HIIPSS segments. getMinimumWaitingTime() exposes the Mindestwartezeit from HIIPSS. Fixes #600 --- .../DKB/SendSEPARealtimeTransferTest.php | 103 ++++++++++ src/Action/GetSEPARealtimeTransferStatus.php | 179 ++++++++++++++++++ src/Action/SendSEPARealtimeTransfer.php | 47 ++++- src/Segment/IPS/HIIPSSv1.php | 22 +++ src/Segment/IPS/HIIPSv1.php | 36 ++++ src/Segment/IPS/HKIPSv1.php | 20 ++ .../ParameterSEPAInstantPaymentStatusV1.php | 24 +++ .../IPS/UnterstuetzteSEPAPainMessages.php | 25 +++ 8 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 Tests/Unit/Integration/DKB/SendSEPARealtimeTransferTest.php create mode 100644 src/Action/GetSEPARealtimeTransferStatus.php create mode 100644 src/Segment/IPS/HIIPSSv1.php create mode 100644 src/Segment/IPS/HIIPSv1.php create mode 100644 src/Segment/IPS/HKIPSv1.php create mode 100644 src/Segment/IPS/ParameterSEPAInstantPaymentStatusV1.php create mode 100644 src/Segment/IPS/UnterstuetzteSEPAPainMessages.php diff --git a/Tests/Unit/Integration/DKB/SendSEPARealtimeTransferTest.php b/Tests/Unit/Integration/DKB/SendSEPARealtimeTransferTest.php new file mode 100644 index 00000000..36ab7b46 --- /dev/null +++ b/Tests/Unit/Integration/DKB/SendSEPARealtimeTransferTest.php @@ -0,0 +1,103 @@ +'; + + public const SEND_TRANSFER_RESPONSE = "HIRMG:3:2+0010::Nachricht entgegengenommen.'HIRMS:4:2:4+0030::Auftrag empfangen - Bitte die empfangene TAN eingeben.'HITAN:5:6:4+4++2472-12-07-21.27.57.456789+Bitte geben Sie die pushTAN ein.+++SomePhone1'"; + public const SEND_TAN_REQUEST = "HNHBK:1:3+000000000425+300+FAKEDIALOGIDabcdefghijklmnopqr+3'HNVSK:998:3+PIN:2+998+1+1::FAKEKUNDENSYSTEMIDabcdefghij+1:20190102:030405+2:2:13:@8@00000000:5:1+280:12030000:test?@user:V:0:0+0'HNVSD:999:1+@206@HNSHK:2:4+PIN:2+921+9999999+1+1+1::FAKEKUNDENSYSTEMIDabcdefghij+1+1:20190102:030405+1:999:1+6:10:19+280:12030000:test?@user:S:0:0'HKTAN:3:6+2++++2472-12-07-21.27.57.456789+N'HNSHA:4:2+9999999++12345:666555''HNHBS:5:1+3'"; + public const SEND_TAN_RESPONSE_EXECUTED = "HIRMG:3:2+0010::Nachricht entgegengenommen.'HIRMS:4:2:3+0020::Auftrag ausgeführt.'HITAN:5:6:3+2++2472-12-07-21.27.57.456789'HIIPZ:6:1:3+C6592DB8C24247C493F1B27377EC63DA++7'"; + // Real-world response (comdirect, 2026-10-01) when the payee's bank did not answer in time. + public const SEND_TAN_RESPONSE_PENDING = "HIRMG:3:2+3060::Bitte beachten Sie die enthaltenen Warnungen/Hinweise.'HIRMS:4:2:3+3045::Wir haben leider noch keine Rückmeldung zum Status ihrer Überweisung.'HITAN:5:6:3+2++2472-12-07-21.27.57.456789'HIIPZ:6:1:3+C6592DB8C24247C493F1B27377EC63DA'"; + + public const STATUS_REQUEST = "HKIPS:3:1+DExxABCDEFGH1234567890:BYLADEM1001+urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.03+C6592DB8C24247C493F1B27377EC63DA'"; + public const STATUS_RESPONSE_PENDING = "HIRMG:3:2+3060::Bitte beachten Sie die enthaltenen Warnungen/Hinweise.'HIRMS:4:2:3+3045::SEPA-Instant Payment Statusabfrage HKIPS erneut durchführen'"; + public const STATUS_RESPONSE_EXECUTED = "HIRMG:3:2+0010::Nachricht entgegengenommen.'HIRMS:4:2:3+0020::Geld für den Empfänger verfügbar'HIIPS:5:1:3+DExxABCDEFGH1234567890:BYLADEM1001+++C6592DB8C24247C493F1B27377EC63DA++7'"; + + private static function getSendTransferRequest(): string + { + return 'HKIPZ:3:1+DExxABCDEFGH1234567890:BYLADEM1001+urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.03+@' + . strlen(self::PAIN_MESSAGE) . '@' . self::PAIN_MESSAGE . "'HKTAN:4:6+4+HKIPZ+++++++++SomePhone1'"; + } + + private function sendTransfer(string $tanResponse): SendSEPARealtimeTransfer + { + $this->expectMessage(self::getSendTransferRequest(), static::SEND_TRANSFER_RESPONSE); + $transfer = SendSEPARealtimeTransfer::create($this->getTestAccount(), static::PAIN_MESSAGE, false); + $this->fints->execute($transfer); + $this->assertTrue($transfer->needsTan()); + + $this->expectMessage(static::SEND_TAN_REQUEST, $tanResponse); + $this->fints->submitTan($transfer, '666555'); + $this->assertTrue($transfer->isDone()); + return $transfer; + } + + /** + * @throws \Throwable + */ + public function testExecuted(): void + { + $this->initDialog(); + $transfer = $this->sendTransfer(static::SEND_TAN_RESPONSE_EXECUTED); + + $this->assertFalse($transfer->isStatusPending()); + $this->assertSame('C6592DB8C24247C493F1B27377EC63DA', $transfer->getOrderId()); + $this->assertSame(7, $transfer->getStatus()); + } + + /** + * @throws \Throwable + */ + public function testStatusPendingThenExecuted(): void + { + $this->initDialog(); + $transfer = $this->sendTransfer(static::SEND_TAN_RESPONSE_PENDING); + + $this->assertTrue($transfer->isStatusPending()); + $this->assertSame('C6592DB8C24247C493F1B27377EC63DA', $transfer->getOrderId()); + $this->assertNull($transfer->getStatus()); + $this->assertSame(10, GetSEPARealtimeTransferStatus::getMinimumWaitingTime($this->fints->getBpd())); + + // First status request: still no answer from the payee's bank. + $this->expectMessage(static::STATUS_REQUEST, static::STATUS_RESPONSE_PENDING); + $status = GetSEPARealtimeTransferStatus::create($this->getTestAccount(), $transfer->getOrderId(), 'urn:iso:std:iso:20022:tech:xsd:pain.001.001.03'); + $this->fints->execute($status); + $this->assertFalse($status->needsTan()); + $this->assertTrue($status->isStatusPending()); + $this->assertNull($status->getStatus()); + $this->assertFalse($status->isExecuted()); + + // Second status request: executed. The supported formats now come from the BPD. + $this->expectMessage(static::STATUS_REQUEST, static::STATUS_RESPONSE_EXECUTED); + $status = GetSEPARealtimeTransferStatus::create($this->getTestAccount(), $transfer->getOrderId()); + $this->fints->execute($status); + $this->assertFalse($status->isStatusPending()); + $this->assertSame(7, $status->getStatus()); + $this->assertTrue($status->isExecuted()); + $this->assertFalse($status->isFailed()); + } + + public function testParseBankParameters(): void + { + // Real-world BPD segments (comdirect and DKB, 2026-10). + /** @var HIIPSSv1 $comdirect */ + $comdirect = Parser::detectAndParseSegment("HIIPSS:21:1:3+999+1+0+99:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.09:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.09.xsd'"); + $this->assertInstanceOf(HIIPSSv1::class, $comdirect); + $this->assertSame(99, $comdirect->parameter->mindestwartezeit); + $this->assertSame(['urn:iso:std:iso:20022:tech:xsd:pain.001.001.09', 'urn:iso:std:iso:20022:tech:xsd:pain.001.001.09'], $comdirect->parameter->getUnterstuetzteSEPADatenformate()); + + /** @var HIIPSSv1 $dkb */ + $dkb = Parser::detectAndParseSegment("HIIPSS:43:1:3+1+1+0+5'"); + $this->assertSame(5, $dkb->parameter->mindestwartezeit); + $this->assertSame([], $dkb->parameter->getUnterstuetzteSEPADatenformate()); + } +} diff --git a/src/Action/GetSEPARealtimeTransferStatus.php b/src/Action/GetSEPARealtimeTransferStatus.php new file mode 100644 index 00000000..78dd2d40 --- /dev/null +++ b/src/Action/GetSEPARealtimeTransferStatus.php @@ -0,0 +1,179 @@ +account = $account; + $result->orderId = $orderId; + $result->xmlSchema = $xmlSchema; + return $result; + } + + /** + * @return int|null The minimum number of seconds the bank wants a client to wait before requesting the status, + * or null if the bank does not support the status request at all. + */ + public static function getMinimumWaitingTime(BPD $bpd): ?int + { + $hiipss = $bpd->getLatestSupportedParameters('HIIPSS'); + return $hiipss instanceof HIIPSSv1 ? $hiipss->parameter->mindestwartezeit : null; + } + + /** @return mixed[] */ + public function __serialize(): array + { + return [ + parent::__serialize(), + $this->account, $this->orderId, $this->xmlSchema, + ]; + } + + /** @param mixed[] $serialized */ + public function __unserialize(array $serialized): void + { + list( + $parentSerialized, + $this->account, $this->orderId, $this->xmlSchema, + ) = $serialized; + + is_array($parentSerialized) ? + parent::__unserialize($parentSerialized) : + parent::unserialize($parentSerialized); + } + + protected function createRequest(BPD $bpd, ?UPD $upd) + { + /** @var BaseSegment&HISPAS $hispas */ + $hispas = $bpd->requireLatestSupportedParameters('HISPAS'); + /** @var HIIPSSv1 $hiipss */ + $hiipss = $bpd->requireLatestSupportedParameters('HIIPSS'); + + if ($this->xmlSchema !== null) { + $schemas = [$this->xmlSchema]; + } else { + $schemas = $hiipss->parameter->getUnterstuetzteSEPADatenformate(); + if (count($schemas) === 0) { + $schemas = array_filter($hispas->getParameter()->getUnterstuetzteSEPADatenformate(), function ($schema) { + return str_contains($schema, 'pain.001.'); + }); + } + $schemas = array_unique($schemas); + } + + /** @var HKIPSv1 $hkips */ + $hkips = $hiipss->createRequestSegment(); + $hkips->kontoverbindungInternational = Kti::fromAccount( + $this->account, + $hispas->getParameter()->getNationaleKontoverbindungErlaubt() + ); + $hkips->unterstuetzteSEPAPainMessages = UnterstuetzteSEPAPainMessages::create($schemas); + $hkips->auftragsidentifikation = $this->orderId; + return $hkips; + } + + public function processResponse(Message $response): void + { + parent::processResponse($response); + + // 3045: The bank has no final status yet, the request can be repeated later. + $this->statusPending = count($response->findRueckmeldungen(SendSEPARealtimeTransfer::CODE_STATUS_PENDING)) > 0; + + /** @var HIIPSv1|null $hiips */ + $hiips = $response->findSegment(HIIPSv1::class); + if ($hiips !== null) { + $this->status = $hiips->statusSepaAuftrag; + $this->sepaCCode = $hiips->sepaCCode; + $this->painMessage = $hiips->sepaPainMessage?->getData(); + } + } + + /** @return bool True if the bank still has no final status (code 3045); the request may be repeated later. */ + public function isStatusPending(): bool + { + $this->ensureDone(); + return $this->statusPending; + } + + /** @return int|null The Status SEPA-Auftrag (1-9) as reported by the bank, see {@link \Fhp\Segment\IPZ\HIIPZv1}. */ + public function getStatus(): ?int + { + $this->ensureDone(); + return $this->status; + } + + /** @return int|null The SEPA C-Code, if the payment was deleted (3) or recalled (4). */ + public function getSepaCCode(): ?int + { + $this->ensureDone(); + return $this->sepaCCode; + } + + /** @return string|null The PAIN message of the payment, if the bank returned it. */ + public function getPainMessage(): ?string + { + $this->ensureDone(); + return $this->painMessage; + } + + /** @return bool True if the bank reports the money as available to the payee. */ + public function isExecuted(): bool + { + return $this->getStatus() === self::STATUS_EXECUTED; + } + + /** @return bool True if the bank reports the payment as rejected or failed. */ + public function isFailed(): bool + { + return in_array($this->getStatus(), self::STATUS_FAILED, true); + } +} diff --git a/src/Action/SendSEPARealtimeTransfer.php b/src/Action/SendSEPARealtimeTransfer.php index 7ef68958..e5ddd3ca 100644 --- a/src/Action/SendSEPARealtimeTransfer.php +++ b/src/Action/SendSEPARealtimeTransfer.php @@ -15,6 +15,7 @@ use Fhp\Segment\HIRMS\Rueckmeldungscode; use Fhp\Segment\IPZ\HIIPZSv1; use Fhp\Segment\IPZ\HIIPZSv2; +use Fhp\Segment\IPZ\HIIPZv1; use Fhp\Segment\IPZ\HKIPZv1; use Fhp\Segment\IPZ\HKIPZv2; use Fhp\Segment\SPA\HISPAS; @@ -26,13 +27,19 @@ */ class SendSEPARealtimeTransfer extends BaseAction { + /** The payee's bank has not confirmed the payment yet; its status can be requested with HKIPS. */ + public const CODE_STATUS_PENDING = 3045; + // Request (if you add a field here, update __serialize() and __unserialize() as well). private SEPAAccount $account; private string $painMessage; private string $xmlSchema; private bool $allowConversionToSEPATransfer = true; - // There are no result fields. This action is simply marked as done to indicate that the transfer was executed. + // Response + private ?string $orderId = null; + private ?int $status = null; + private bool $statusPending = false; /** * @param SEPAAccount $account The account from which the transfer will be sent. @@ -139,6 +146,20 @@ public function processResponse(Message $response) { parent::processResponse($response); + /** @var HIIPZv1|null $hiipz */ + $hiipz = $response->findSegment(HIIPZv1::class); + if ($hiipz !== null) { + $this->orderId = $hiipz->auftragsidentifikation; + $this->status = $hiipz->statusSepaAuftrag; + } + + // The bank accepted the order, but the payee's bank has not answered in time. The final status can be + // requested with GetSEPARealtimeTransferStatus. + if (count($response->findRueckmeldungen(self::CODE_STATUS_PENDING)) > 0) { + $this->statusPending = true; + return; + } + // Was the instant payment converted to a regular transfer? $info = $response->findRueckmeldungen(3270); if (count($info) > 0) { @@ -153,4 +174,28 @@ public function processResponse(Message $response) throw new UnexpectedResponseException('Bank did not confirm SEPATransfer execution'); } } + + /** @return string|null The Auftragsidentifikation assigned by the bank, needed to request the status later. */ + public function getOrderId(): ?string + { + $this->ensureDone(); + return $this->orderId; + } + + /** @return int|null The Status SEPA-Auftrag (1-9) if the bank reported one, see {@link HIIPZv1}. */ + public function getStatus(): ?int + { + $this->ensureDone(); + return $this->status; + } + + /** + * @return bool True if the bank accepted the order but could not report its final status yet (code 3045). Use + * {@link GetSEPARealtimeTransferStatus} with {@link getOrderId()} to request it. + */ + public function isStatusPending(): bool + { + $this->ensureDone(); + return $this->statusPending; + } } diff --git a/src/Segment/IPS/HIIPSSv1.php b/src/Segment/IPS/HIIPSSv1.php new file mode 100644 index 00000000..270ba252 --- /dev/null +++ b/src/Segment/IPS/HIIPSSv1.php @@ -0,0 +1,22 @@ +sepaDescriptor = array_values($sepaDescriptor); + return $result; + } +}