Skip to content
Open
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
103 changes: 103 additions & 0 deletions Tests/Unit/Integration/DKB/SendSEPARealtimeTransferTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

namespace Fhp\Tests\Unit\Integration\DKB;

use Fhp\Action\GetSEPARealtimeTransferStatus;
use Fhp\Action\SendSEPARealtimeTransfer;
use Fhp\Segment\IPS\HIIPSSv1;
use Fhp\Syntax\Parser;

class SendSEPARealtimeTransferTest extends DKBIntegrationTestBase
{
// The XML payload. Its content does not matter here, only the namespace has to be supported by the bank.
public const PAIN_MESSAGE = '<?xml version="1.0" encoding="UTF-8"?><Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03"><CstmrCdtTrfInitn/></Document>';

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());
}
}
179 changes: 179 additions & 0 deletions src/Action/GetSEPARealtimeTransferStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<?php

declare(strict_types=1);

namespace Fhp\Action;

use Fhp\BaseAction;
use Fhp\Model\SEPAAccount;
use Fhp\Protocol\BPD;
use Fhp\Protocol\Message;
use Fhp\Protocol\UPD;
use Fhp\Segment\BaseSegment;
use Fhp\Segment\Common\Kti;
use Fhp\Segment\IPS\HIIPSSv1;
use Fhp\Segment\IPS\HIIPSv1;
use Fhp\Segment\IPS\HKIPSv1;
use Fhp\Segment\IPS\UnterstuetzteSEPAPainMessages;
use Fhp\Segment\SPA\HISPAS;

/**
* Requests the status of a previously submitted SEPA instant payment (HKIPS).
*
* When the bank answers an instant payment ({@link SendSEPARealtimeTransfer}) with code 3045, the payee's bank has not
* confirmed the payment yet. This action asks for the final status. The bank may answer with 3045 again, in which case
* the status is still pending and the action can be executed again later. The bank announces how long a client should
* wait before asking, see {@link getMinimumWaitingTime()}.
*/
class GetSEPARealtimeTransferStatus extends BaseAction
{
/** Status SEPA-Auftrag: Auftrag ausgeführt; Geld für den Zahlungsempfänger verfügbar. */
public const STATUS_EXECUTED = 7;
/** Status SEPA-Auftrag codes that mean the payment was rejected or failed. */
public const STATUS_FAILED = [2, 6, 8, 9];

// Request (if you add a field here, update __serialize() and __unserialize() as well).
private SEPAAccount $account;
private string $orderId;
private ?string $xmlSchema;

// Response
private bool $statusPending = false;
private ?int $status = null;
private ?int $sepaCCode = null;
private ?string $painMessage = null;

/**
* @param SEPAAccount $account The account from which the instant payment was sent.
* @param string $orderId The Auftragsidentifikation that the bank returned for the instant payment, see
* {@link SendSEPARealtimeTransfer::getOrderId()}.
* @param string|null $xmlSchema The PAIN schema (XML namespace) of the original payment. If omitted, all formats
* announced by the bank are listed as supported.
*/
public static function create(SEPAAccount $account, string $orderId, ?string $xmlSchema = null): GetSEPARealtimeTransferStatus
{
$result = new GetSEPARealtimeTransferStatus();
$result->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);
}
}
47 changes: 46 additions & 1 deletion src/Action/SendSEPARealtimeTransfer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
}
22 changes: 22 additions & 0 deletions src/Segment/IPS/HIIPSSv1.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace Fhp\Segment\IPS;

use Fhp\Segment\BaseGeschaeftsvorfallparameter;
use Fhp\Segment\BaseSegment;

/**
* Segment: SEPA-Instant Payment Status Parameter (Version 1)
*
* @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Messages_Geschaeftsvorfaelle_2022-04-15_final_version.pdf
* Section: C.10.2.9.2 c)
*/
class HIIPSSv1 extends BaseGeschaeftsvorfallparameter
{
public ParameterSEPAInstantPaymentStatusV1 $parameter;

public function createRequestSegment(): BaseSegment
{
return HKIPSv1::createEmpty();
}
}
Loading
Loading