diff --git a/Samples/statementOfAccount.php b/Samples/statementOfAccount.php
index 1bbdb334..4fd4f5ac 100644
--- a/Samples/statementOfAccount.php
+++ b/Samples/statementOfAccount.php
@@ -4,6 +4,10 @@
/**
* SAMPLE - Displays the statement of account for a specific time range and account.
+ *
+ * GetStatementOfAccount asks the bank for its statements in whichever format the bank supports, preferring CAMT XML
+ * (HKCAZ) over the older MT 940 format (HKKAZ). If your application depends on one particular format, e.g. because it
+ * parses the raw data itself, use GetStatementOfAccountXML or GetStatementOfAccountMT940 directly.
*/
// See login.php, it returns a FinTs instance that is already logged in.
diff --git a/Samples/statementOfAccountXML.php b/Samples/statementOfAccountXML.php
index 454223e4..0461cff5 100644
--- a/Samples/statementOfAccountXML.php
+++ b/Samples/statementOfAccountXML.php
@@ -4,9 +4,11 @@
/**
* SAMPLE - Displays the statement of account using XML format (CAMT).
- * This sample demonstrates how to use GetStatementOfAccountXML directly when you need
- * raw XML access, or shows that GetStatementOfAccount now automatically falls back to
- * XML format when MT940 is not available.
+ * This sample demonstrates the two ways of retrieving CAMT XML statements: through GetStatementOfAccount, which picks
+ * the format that the bank supports (preferring CAMT XML over the older MT 940 format), and through
+ * GetStatementOfAccountXML directly, which additionally gives you access to the raw XML documents.
+ * If you need the MT 940 format specifically, e.g. because your bank does not offer CAMT XML, use
+ * GetStatementOfAccountMT940 (see statementOfAccount.php).
*/
// See login.php, it returns a FinTs instance that is already logged in.
@@ -25,7 +27,8 @@
$from = new \DateTime('2022-07-15');
$to = new \DateTime();
-// Option 1: Use GetStatementOfAccount - it will automatically use XML if MT940 is not available
+// Option 1: Use GetStatementOfAccount, which asks the bank for CAMT XML whenever the bank supports it. The last
+// parameter asks for the transactions that the bank received but has not booked yet (only sent for recent dates).
$getStatement = \Fhp\Action\GetStatementOfAccount::create($oneAccount, $from, $to, false, true);
$fints->execute($getStatement);
if ($getStatement->needsTan()) {
@@ -56,7 +59,8 @@
echo 'Option 2: Direct XML access if needed' . PHP_EOL;
echo '========================================' . PHP_EOL;
-// Option 2: Use GetStatementOfAccountXML directly if you need raw XML access
+// Option 2: Use GetStatementOfAccountXML directly if you need the raw XML documents. Note that the action above can
+// also hand them out via $getStatement->getRawResponse(), which works no matter which format the bank answered in.
$getStatementXML = \Fhp\Action\GetStatementOfAccountXML::create($oneAccount, $from, $to);
$fints->execute($getStatementXML);
if ($getStatementXML->needsTan()) {
diff --git a/Tests/Unit/Action/GetStatementOfAccountAutoTest.php b/Tests/Unit/Action/GetStatementOfAccountAutoTest.php
new file mode 100644
index 00000000..a707f6ab
--- /dev/null
+++ b/Tests/Unit/Action/GetStatementOfAccountAutoTest.php
@@ -0,0 +1,242 @@
+1234567890-2020-02-05DExxABCDEFGH1234567890'
+ . 'OPBD1234.56CRDT2020-02-05'
+ . '123.45CRDTBOOK2020-02-052020-02-05'
+ . 'GUTSCHRIFT TESTZAHLUNGSENDER NAME'
+ . '42.00DBITBOOK2020-02-052020-02-06'
+ . 'MIETE FEBRUAREMPFAENGER NAME'
+ . '9.99DBITPDNG2020-02-052020-02-05'
+ . 'KARTENZAHLUNG VORGEMERKTSUPERMARKT'
+ . '';
+
+ /** The CAMT version that {@link CAMT_DOCUMENT} uses, as announced by the bank in the HICAZ segment. */
+ public const CAMT_VERSION = 'camt.052.001.02';
+
+ /** How this CAMT version spells the status of a transaction that the bank has not booked yet. */
+ public const STATUS_PENDING = 'PDNG';
+
+ /** The separate document that the bank sends for transactions it has received but not booked yet. */
+ private static function unbookedCamtDocument(): string
+ {
+ return ''
+ . '1234567890-vorgemerktDExxABCDEFGH1234567890'
+ . '17.50DBIT' . static::STATUS_PENDING
+ . '2020-02-052020-02-05'
+ . 'TANKSTELLE'
+ . '';
+ }
+
+ /** Like {@link hicazWithTransactions()}, but the bank also sends the not-yet-booked transactions. */
+ private static function hicazWithUnbookedTransactions(): string
+ {
+ $unbooked = self::unbookedCamtDocument();
+ return rtrim(self::hicazWithTransactions(), "'") . '+@' . strlen($unbooked) . '@' . $unbooked . "'";
+ }
+
+ /** Like {@link GetStatementOfAccountXMLTest::GET_STATEMENT_EMPTY_HICAZ_RESPONSE}, but with actual transactions. */
+ private static function hicazWithTransactions(): string
+ {
+ return 'HICAZ:6:1:3+DExxABCDEFGH1234567890:GENODEM1GLS:1234567890::280:43060967+urn?:iso?:std?:iso?:20022?:tech?:xsd?:'
+ . static::CAMT_VERSION . '+@' . strlen(static::CAMT_DOCUMENT) . '@' . static::CAMT_DOCUMENT . "'";
+ }
+
+ /**
+ * @throws \Throwable
+ */
+ private function runInitialRequest(): GetStatementOfAccount
+ {
+ $getStatement = GetStatementOfAccount::create($this->getTestAccount(), new \DateTime('2020-02-05'));
+ $this->fints->execute($getStatement);
+ return $getStatement;
+ }
+
+ /**
+ * The bank asks for a TAN, the action is persisted while the user enters it, and the statement itself only arrives
+ * afterwards - the exact sequence from issue #553.
+ *
+ * @throws \Throwable
+ */
+ public function testCamtIsChosenAndSurvivesPersist()
+ {
+ $this->initDialog();
+
+ $this->expectMessage(GetStatementOfAccountXMLTest::GET_STATEMENT_REQUEST,
+ mb_convert_encoding(GetStatementOfAccountXMLTest::GET_STATEMENT_RESPONSE_BEFORE_TAN, 'ISO-8859-1', 'UTF-8'));
+ $getStatement = $this->runInitialRequest();
+ $this->assertTrue($getStatement->needsTan());
+
+ // Pretend that we close everything and open everything from scratch, as if it were a new PHP process.
+ $persistedInstance = $this->fints->persist(true);
+ $persistedGetStatement = serialize($getStatement);
+ $this->connection->expects($this->once())->method('disconnect');
+ $this->fints = new FinTsPeer($this->options, $this->credentials);
+ $this->fints->loadPersistedInstance($persistedInstance);
+ /** @var GetStatementOfAccount $getStatement */
+ $getStatement = unserialize($persistedGetStatement);
+
+ $this->expectMessage(GetStatementOfAccountXMLTest::SEND_TAN_REQUEST,
+ mb_convert_encoding(GetStatementOfAccountXMLTest::SEND_TAN_RESPONSE . self::hicazWithTransactions(), 'ISO-8859-1', 'UTF-8'));
+ $this->expectMessage(GetStatementOfAccountXMLTest::GET_STATEMENT_PAGE_2_REQUEST,
+ mb_convert_encoding(GetStatementOfAccountXMLTest::GET_STATEMENT_PAGE_2_RESPONSE, 'ISO-8859-1', 'UTF-8'));
+ $this->expectMessage(GetStatementOfAccountXMLTest::GET_STATEMENT_PAGE_3_REQUEST,
+ mb_convert_encoding(GetStatementOfAccountXMLTest::GET_STATEMENT_PAGE_3_RESPONSE, 'ISO-8859-1', 'UTF-8'));
+ $this->fints->submitTan($getStatement, '123456');
+ $this->assertFalse($getStatement->needsTan());
+
+ $this->assertInstanceOf(GetStatementOfAccountXML::class, $getStatement->getDelegate());
+ $this->assertSame([static::CAMT_DOCUMENT, '', ''], array_map(function (string $xml) {
+ // The two paginated responses contain an empty camt document, which is not interesting here.
+ return str_contains($xml, '') ? $xml : '';
+ }, $getStatement->getRawResponse()));
+
+ $statement = $getStatement->getStatement();
+ $this->assertCount(1, $statement->getStatements());
+ $statement1 = $statement->getStatements()[0];
+ $this->assertEquals(new \DateTime('2020-02-05'), $statement1->getDate());
+ $this->assertEqualsWithDelta(1234.56, $statement1->getStartBalance(), 0.01);
+ $this->assertCount(3, $statement1->getTransactions());
+
+ $transaction1 = $statement1->getTransactions()[0];
+ $this->assertEquals(Statement::CD_CREDIT, $transaction1->getCreditDebit());
+ $this->assertEqualsWithDelta(123.45, $transaction1->getAmount(), 0.01);
+ $this->assertEquals(new \DateTime('2020-02-05'), $transaction1->getBookingDate());
+ $this->assertEquals('SENDER NAME', $transaction1->getName());
+
+ $transaction2 = $statement1->getTransactions()[1];
+ $this->assertEquals(Statement::CD_DEBIT, $transaction2->getCreditDebit());
+ $this->assertEqualsWithDelta(42.00, $transaction2->getAmount(), 0.01);
+ $this->assertEquals(new \DateTime('2020-02-06'), $transaction2->getValutaDate());
+ $this->assertEquals('EMPFAENGER NAME', $transaction2->getName());
+
+ // The booking status has to be recognized in all CAMT versions, which spell it differently.
+ $this->assertTrue($transaction1->getBooked());
+ $this->assertTrue($transaction2->getBooked());
+ $this->assertFalse($statement1->getTransactions()[2]->getBooked());
+ $this->assertEquals('SUPERMARKT', $statement1->getTransactions()[2]->getName());
+ }
+
+ /**
+ * Accessing MT 940 specific results has to fail with a helpful message when the bank answered in CAMT XML.
+ *
+ * @throws \Throwable
+ */
+ public function testMT940ResultsAreUnavailable()
+ {
+ $this->initDialog();
+
+ $this->expectMessage(GetStatementOfAccountXMLTest::GET_STATEMENT_REQUEST,
+ mb_convert_encoding(GetStatementOfAccountXMLTest::GET_STATEMENT_RESPONSE_BEFORE_TAN, 'ISO-8859-1', 'UTF-8'));
+ $getStatement = $this->runInitialRequest();
+
+ $this->expectMessage(GetStatementOfAccountXMLTest::SEND_TAN_REQUEST,
+ mb_convert_encoding(GetStatementOfAccountXMLTest::SEND_TAN_RESPONSE . self::hicazWithTransactions(), 'ISO-8859-1', 'UTF-8'));
+ $this->expectMessage(GetStatementOfAccountXMLTest::GET_STATEMENT_PAGE_2_REQUEST,
+ mb_convert_encoding(GetStatementOfAccountXMLTest::GET_STATEMENT_PAGE_2_RESPONSE, 'ISO-8859-1', 'UTF-8'));
+ $this->expectMessage(GetStatementOfAccountXMLTest::GET_STATEMENT_PAGE_3_REQUEST,
+ mb_convert_encoding(GetStatementOfAccountXMLTest::GET_STATEMENT_PAGE_3_RESPONSE, 'ISO-8859-1', 'UTF-8'));
+ $this->fints->submitTan($getStatement, '123456');
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessageMatches('/retrieved with .*GetStatementOfAccountXML.*GetStatementOfAccountMT940/s');
+ $getStatement->getRawMT940();
+ }
+
+ /**
+ * Banks report transactions that they have received but not booked yet in a separate CAMT document. They are only
+ * part of the statement if the caller asked for them.
+ *
+ * @throws \Throwable
+ */
+ public function testUnbookedTransactionsAreIncludedWhenRequested()
+ {
+ $this->initDialog();
+
+ $this->expectMessage(GetStatementOfAccountXMLTest::GET_STATEMENT_REQUEST,
+ mb_convert_encoding(GetStatementOfAccountXMLTest::GET_STATEMENT_RESPONSE_BEFORE_TAN, 'ISO-8859-1', 'UTF-8'));
+ // The request is the same either way, the bank decides whether to send unbooked transactions at all.
+ $getStatement = GetStatementOfAccountXML::create(
+ $this->getTestAccount(), new \DateTime('2020-02-05'), null, null, false, true);
+ $this->fints->execute($getStatement);
+
+ $this->expectMessage(GetStatementOfAccountXMLTest::SEND_TAN_REQUEST,
+ mb_convert_encoding(GetStatementOfAccountXMLTest::SEND_TAN_RESPONSE . self::hicazWithUnbookedTransactions(), 'ISO-8859-1', 'UTF-8'));
+ $this->expectMessage(GetStatementOfAccountXMLTest::GET_STATEMENT_PAGE_2_REQUEST,
+ mb_convert_encoding(GetStatementOfAccountXMLTest::GET_STATEMENT_PAGE_2_RESPONSE, 'ISO-8859-1', 'UTF-8'));
+ $this->expectMessage(GetStatementOfAccountXMLTest::GET_STATEMENT_PAGE_3_REQUEST,
+ mb_convert_encoding(GetStatementOfAccountXMLTest::GET_STATEMENT_PAGE_3_RESPONSE, 'ISO-8859-1', 'UTF-8'));
+ $this->fints->submitTan($getStatement, '123456');
+
+ // The pages without unbooked transactions must not add anything, and the booked ones stay separate.
+ $this->assertSame([self::unbookedCamtDocument()], $getStatement->getUnbookedXML());
+ $this->assertCount(3, $getStatement->getBookedXML());
+ $this->assertCount(4, $getStatement->getRawResponse());
+
+ $transactions = $getStatement->getStatement()->getStatements()[0]->getTransactions();
+ $this->assertCount(4, $transactions);
+ $unbooked = $transactions[3];
+ $this->assertFalse($unbooked->getBooked());
+ $this->assertEqualsWithDelta(17.50, $unbooked->getAmount(), 0.01);
+ $this->assertEquals('TANKSTELLE', $unbooked->getMainDescription());
+ }
+
+ /**
+ * Unbooked transactions that the caller did not ask for stay out of the statement, but remain accessible.
+ *
+ * @throws \Throwable
+ */
+ public function testUnbookedTransactionsAreExcludedByDefault()
+ {
+ $this->initDialog();
+
+ $this->expectMessage(GetStatementOfAccountXMLTest::GET_STATEMENT_REQUEST,
+ mb_convert_encoding(GetStatementOfAccountXMLTest::GET_STATEMENT_RESPONSE_BEFORE_TAN, 'ISO-8859-1', 'UTF-8'));
+ $getStatement = $this->runInitialRequest(); // Does not ask for unbooked transactions.
+
+ $this->expectMessage(GetStatementOfAccountXMLTest::SEND_TAN_REQUEST,
+ mb_convert_encoding(GetStatementOfAccountXMLTest::SEND_TAN_RESPONSE . self::hicazWithUnbookedTransactions(), 'ISO-8859-1', 'UTF-8'));
+ $this->expectMessage(GetStatementOfAccountXMLTest::GET_STATEMENT_PAGE_2_REQUEST,
+ mb_convert_encoding(GetStatementOfAccountXMLTest::GET_STATEMENT_PAGE_2_RESPONSE, 'ISO-8859-1', 'UTF-8'));
+ $this->expectMessage(GetStatementOfAccountXMLTest::GET_STATEMENT_PAGE_3_REQUEST,
+ mb_convert_encoding(GetStatementOfAccountXMLTest::GET_STATEMENT_PAGE_3_RESPONSE, 'ISO-8859-1', 'UTF-8'));
+ $this->fints->submitTan($getStatement, '123456');
+
+ // The bank sent them anyway, so they are still available separately ...
+ $delegate = $getStatement->getDelegate();
+ $this->assertInstanceOf(GetStatementOfAccountXML::class, $delegate);
+ $this->assertSame([self::unbookedCamtDocument()], $delegate->getUnbookedXML());
+
+ // ... but they are not part of the statement, which only has the three transactions of the booked document.
+ $this->assertCount(3, $getStatement->getRawResponse());
+ $this->assertCount(3, $getStatement->getStatement()->getStatements()[0]->getTransactions());
+ }
+}
diff --git a/Tests/Unit/Action/GetStatementOfAccountCamt08Test.php b/Tests/Unit/Action/GetStatementOfAccountCamt08Test.php
new file mode 100644
index 00000000..9be9a3e5
--- /dev/null
+++ b/Tests/Unit/Action/GetStatementOfAccountCamt08Test.php
@@ -0,0 +1,26 @@
+ element, unlike .02, which spells it out directly.
+ */
+class GetStatementOfAccountCamt08Test extends GetStatementOfAccountAutoTest
+{
+ public const CAMT_VERSION = 'camt.052.001.08';
+
+ public const STATUS_PENDING = 'PDNG';
+
+ public const CAMT_DOCUMENT = '1234567890-2020-02-05DExxABCDEFGH1234567890'
+ . 'OPBD1234.56CRDT2020-02-05'
+ . '123.45CRDTBOOK2020-02-052020-02-05'
+ . 'GUTSCHRIFT TESTZAHLUNGSENDER NAME'
+ . '42.00DBITBOOK2020-02-052020-02-06'
+ . 'MIETE FEBRUAREMPFAENGER NAME'
+ . '9.99DBITPDNG2020-02-052020-02-05'
+ . 'KARTENZAHLUNG VORGEMERKTSUPERMARKT'
+ . '';
+}
diff --git a/Tests/Unit/Integration/DKB/GetStatementOfAccountTest.php b/Tests/Unit/Integration/DKB/GetStatementOfAccountTest.php
index 7eda54a2..056e085b 100644
--- a/Tests/Unit/Integration/DKB/GetStatementOfAccountTest.php
+++ b/Tests/Unit/Integration/DKB/GetStatementOfAccountTest.php
@@ -2,7 +2,7 @@
namespace Fhp\Tests\Unit\Integration\DKB;
-use Fhp\Action\GetStatementOfAccount;
+use Fhp\Action\GetStatementOfAccountMT940;
use Fhp\Model\StatementOfAccount\Statement;
use Fhp\Model\StatementOfAccount\StatementOfAccount;
use Fhp\Tests\FinTsPeer;
@@ -10,6 +10,8 @@
class GetStatementOfAccountTest extends DKBIntegrationTestBase
{
// Statement request (HKKAZ). Note that DKB's BPD (see InitEndDialogTest) declares only HIKAZSv4 and HIKAZSv5.
+ // NOTE: DKB's BPD declares HICAZS as well, so this test uses GetStatementOfAccountMT940 to pin the MT 940 format.
+ // GetStatementOfAccount would request CAMT XML instead, see Action\GetStatementOfAccountAutoTest.
public const GET_STATEMENT_REQUEST = "HKKAZ:3:5+1234567890::280:12030000+N+20190901+20190922'";
public const MT940_STATEMENT_1 = "\r\n:20:STARTUMSE\r\n:25:12030000/1234567890\r\n:28C:00000/001\r\n:60F:C190821EUR1234,56\r\n:61:1909030904DR12,00N033NONREF\r\n:86:177?00ONLINE-UEBERWEISUNG?109310?20KREF+HKCCS12345?21SVWZ+323\r\n01000-P111111-33333?22333?23DATUM 02.09.2019, 22.19 UHR?241.TAN 0\r\n12345?30DEUTDEBBXXX?31DExx123412341234123431?32EMPFAENGER ABCDE?3\r\n4997\r\n:62F:C190903EUR1222,56\r\n-\r\n:20:STARTUMSE\r\n:25:12030000/1234567890\r\n:28C:00000/001\r\n:60F:C190903EUR1222,56";
// NOTE: This contains an 'รค' in UTF-8, but in practice DKB sends it as ISO-8859-1. We cannot hard-code non-UTF8
@@ -51,9 +53,9 @@ private static function getHikazContentPage2(): string
/**
* @throws \Throwable
*/
- private function runInitialRequest(): GetStatementOfAccount
+ private function runInitialRequest(): GetStatementOfAccountMT940
{
- $getStatement = GetStatementOfAccount::create($this->getTestAccount(),
+ $getStatement = GetStatementOfAccountMT940::create($this->getTestAccount(),
new \DateTime('2019-09-01'), new \DateTime('2019-09-22'));
$this->fints->execute($getStatement);
return $getStatement;
@@ -74,7 +76,7 @@ public function testSimple()
/**
* @throws \Throwable
*/
- private function completeWithTan(GetStatementOfAccount $getStatement)
+ private function completeWithTan(GetStatementOfAccountMT940 $getStatement)
{
$this->expectMessage(static::SEND_TAN_REQUEST, static::SEND_TAN_RESPONSE . static::getHikazContent() . "'");
$this->fints->submitTan($getStatement, '777666');
@@ -112,7 +114,7 @@ public function testWithTanPersist()
$this->connection->expects($this->once())->method('disconnect');
$this->fints = new FinTsPeer($this->options, $this->credentials);
$this->fints->loadPersistedInstance($persistedInstance);
- /** @var GetStatementOfAccount $getStatement */
+ /** @var GetStatementOfAccountMT940 $getStatement */
$getStatement = unserialize($persistedGetStatement);
$this->completeWithTan($getStatement);
diff --git a/Tests/Unit/Integration/GLS/GetStatementOfAccountXMLTest.php b/Tests/Unit/Integration/GLS/GetStatementOfAccountXMLTest.php
index b2bfdb66..3a248438 100644
--- a/Tests/Unit/Integration/GLS/GetStatementOfAccountXMLTest.php
+++ b/Tests/Unit/Integration/GLS/GetStatementOfAccountXMLTest.php
@@ -42,6 +42,9 @@ public function testWithTanPaginated()
$this->assertTrue($getStatement->needsTan());
$this->completeWithTan($getStatement);
+
+ // The XML documents can also be converted to a StatementOfAccount. The ones in this test are empty.
+ $this->assertEmpty($getStatement->getStatement()->getStatements());
}
/**
diff --git a/src/Action/AbstractGetStatementOfAccount.php b/src/Action/AbstractGetStatementOfAccount.php
new file mode 100644
index 00000000..d2a10f6b
--- /dev/null
+++ b/src/Action/AbstractGetStatementOfAccount.php
@@ -0,0 +1,31 @@
+account, $this->from, $this->to, $this->allAccounts, $this->includeUnbooked,
$this->bankName,
+ $this->delegate,
];
}
@@ -121,6 +108,7 @@ public function __unserialize(array $serialized): void
$parentSerialized,
$this->account, $this->from, $this->to, $this->allAccounts, $this->includeUnbooked,
$this->bankName,
+ $this->delegate,
) = $serialized;
is_array($parentSerialized) ?
@@ -129,142 +117,133 @@ public function __unserialize(array $serialized): void
}
/**
+ * @return AbstractGetStatementOfAccount|null The action that this one delegates to, or null if it has not been
+ * executed yet. Useful to access format-specific results.
+ * @noinspection PhpUnused
+ */
+ public function getDelegate(): ?AbstractGetStatementOfAccount
+ {
+ return $this->delegate;
+ }
+
+ public function getStatement(): StatementOfAccount
+ {
+ return $this->requireDelegate()->getStatement();
+ }
+
+ public function getRawResponse(): array
+ {
+ return $this->requireDelegate()->getRawResponse();
+ }
+
+ /**
+ * @deprecated This action only returns MT 940 data if the bank does not support CAMT XML. Use
+ * {@link getRawResponse()} to obtain the raw data in whichever format the bank used, or use
+ * {@link GetStatementOfAccountMT940} directly if your application requires the MT 940 format.
+ *
* @return string The raw MT940 data received from the server.
+ * @throws \RuntimeException If the bank returned CAMT XML instead.
* @noinspection PhpUnused
*/
public function getRawMT940(): string
{
- $this->ensureDone();
- return $this->rawMT940;
+ return $this->requireDelegateOfType(GetStatementOfAccountMT940::class)->getRawMT940();
}
/**
+ * @deprecated This action only returns MT 940 data if the bank does not support CAMT XML. Use
+ * {@link getStatement()} for the parsed statement independent of the format, or use
+ * {@link GetStatementOfAccountMT940} directly if your application requires the MT 940 format.
+ *
* @return array The parsed MT940 data.
+ * @throws \RuntimeException If the bank returned CAMT XML instead.
*/
public function getParsedMT940(): array
{
- $this->ensureDone();
- return $this->parsedMT940;
+ return $this->requireDelegateOfType(GetStatementOfAccountMT940::class)->getParsedMT940();
}
- public function getStatement(): StatementOfAccount
+ /**
+ * @deprecated This action only returns CAMT XML if the bank supports it. Use {@link getRawResponse()} to obtain the
+ * raw data in whichever format the bank used, or use {@link GetStatementOfAccountXML} directly if your
+ * application requires the CAMT XML format.
+ *
+ * @return string[] The XML-Document(s) received from the bank, or empty array if the statement is unavailable/empty.
+ * @throws \RuntimeException If the bank returned MT 940 data instead.
+ * @noinspection PhpUnused
+ */
+ public function getBookedXML(): array
{
- $this->ensureDone();
- return $this->statement;
+ return $this->requireDelegateOfType(GetStatementOfAccountXML::class)->getBookedXML();
}
protected function createRequest(BPD $bpd, ?UPD $upd)
{
$this->bankName = $bpd->getBankName();
-
- // Try to use MT940 format (HIKAZS) if supported
- try {
- /** @var HIKAZS $hikazs */
- $hikazs = $bpd->requireLatestSupportedParameters('HIKAZS');
- if ($this->allAccounts && !$hikazs->getParameter()->getAlleKontenErlaubt()) {
- throw new \InvalidArgumentException('The bank do not permit the use of allAccounts=true');
- }
- switch ($hikazs->getVersion()) {
- case 4:
- return HKKAZv4::create(Kto::fromAccount($this->account), $this->from, $this->to);
- case 5:
- return HKKAZv5::create(KtvV3::fromAccount($this->account), $this->allAccounts, $this->from, $this->to);
- case 6:
- return HKKAZv6::create(KtvV3::fromAccount($this->account), $this->allAccounts, $this->from, $this->to);
- case 7:
- /** @var HISPAS $hispas */
- $hispas = $bpd->requireLatestSupportedParameters('HISPAS');
- $kti = Kti::fromAccount($this->account, $hispas->getParameter()->getNationaleKontoverbindungErlaubt());
- return HKKAZv7::create($kti, $this->allAccounts, $this->from, $this->to);
- default:
- throw new UnsupportedException('Unsupported HKKAZ version: ' . $hikazs->getVersion());
- }
- } catch (UnexpectedResponseException|UnsupportedException $e) {
- // MT940 format not supported, fall back to XML format (HICAZS)
- $this->xmlAction = GetStatementOfAccountXML::create($this->account, $this->from, $this->to, null, $this->allAccounts);
- return $this->xmlAction->createRequest($bpd, $upd);
- }
+ $this->delegate ??= $this->createDelegate($bpd, $upd);
+ return $this->delegate->createRequest($bpd, $upd);
}
public function processResponse(Message $response)
{
parent::processResponse($response);
- // If we're using XML fallback, delegate to the XML action
- if ($this->xmlAction !== null) {
- $this->xmlAction->processResponse($response);
-
- // Parse XML and convert to StatementOfAccount once all pages are received
- if (!$this->hasMorePages()) {
- $this->parseXml();
- }
- return;
- }
-
- // Banks send just 3010 and no HIKAZ in case there are no transactions.
- $isUnavailable = $response->findRueckmeldung(Rueckmeldungscode::NICHT_VERFUEGBAR) !== null;
- $responseHikaz = $response->findSegments(HIKAZ::class);
- $numResponseSegments = count($responseHikaz);
- if (!$isUnavailable && $numResponseSegments < count($this->getRequestSegmentNumbers())) {
- throw new UnexpectedResponseException("Only got $numResponseSegments HIKAZ response segments!");
- }
-
- /** @var HIKAZ $hikaz */
- foreach ($responseHikaz as $hikaz) {
- $this->rawMT940 .= $hikaz->getGebuchteUmsaetze()->getData();
- if ($this->includeUnbooked and $hikaz->getNichtGebuchteUmsaetze() !== null) {
- $this->rawMT940 .= $hikaz->getNichtGebuchteUmsaetze()->getData();
- }
- }
-
- // Note: Pagination boundaries may cut in the middle of the MT940 data, so it is not possible to parse a partial
- // reponse before having received all pages.
- if (!$this->hasMorePages()) {
- $this->parseMt940();
- }
+ $delegate = $this->requireDelegate(false);
+ // The delegate needs to know the segment numbers to validate the response against, and only this action (the one
+ // that FinTs executes) is told about them.
+ $delegate->setRequestSegmentNumbers($this->getRequestSegmentNumbers());
+ $delegate->processResponse($response);
}
- private function parseMt940()
+ /**
+ * Decides which format to request: CAMT XML if the bank (and the account) supports it, MT 940 otherwise.
+ */
+ private function createDelegate(BPD $bpd, ?UPD $upd): AbstractGetStatementOfAccount
{
- if (str_contains(strtolower($this->bankName), 'sparda')) {
- $parser = new SpardaMT940();
- } elseif (str_contains(strtolower($this->bankName), 'postbank')) {
- $parser = new PostbankMT940();
- } else {
- $parser = new MT940();
+ $camtSupported = $bpd->getLatestSupportedParameters('HICAZS') !== null
+ && ($upd === null || $upd->isRequestSupportedForAccount($this->account, 'HKCAZ'));
+ if ($camtSupported) {
+ return GetStatementOfAccountXML::create(
+ $this->account, $this->from, $this->to, null, $this->allAccounts, $this->includeUnbooked);
}
-
- try {
- // Note: Some banks encode their MT 940 data as SWIFT/ISO-8859 like it should be according to the
- // specification (e.g. DKB), others just send UTF-8 (e.g. Consorsbank), so we try to detect it here.
- $rawMT940 = mb_detect_encoding($this->rawMT940, 'UTF-8', true) === false
- ? mb_convert_encoding($this->rawMT940, 'UTF-8', 'ISO-8859-1') : $this->rawMT940;
- $this->parsedMT940 = $parser->parse($rawMT940);
- $this->statement = StatementOfAccount::fromMT940Array($this->parsedMT940);
- } catch (MT940Exception $e) {
- throw new \InvalidArgumentException('Invalid MT940 data', 0, $e);
+ if ($bpd->getLatestSupportedParameters('HIKAZS') !== null) {
+ return GetStatementOfAccountMT940::create($this->account, $this->from, $this->to, $this->allAccounts, $this->includeUnbooked);
}
+ throw new UnsupportedException(
+ 'The bank does not support retrieving statements in any format implemented in this library (neither HKCAZ '
+ . 'nor HKKAZ).');
}
- private function parseXml()
+ /**
+ * @param bool $ensureDone Whether to also verify that the action has completed, i.e. that results are available.
+ * @return AbstractGetStatementOfAccount The delegate, guaranteed to be present.
+ */
+ private function requireDelegate(bool $ensureDone = true): AbstractGetStatementOfAccount
{
- if ($this->xmlAction === null) {
- throw new \RuntimeException('XML action not initialized');
+ if ($ensureDone) {
+ $this->ensureDone();
}
-
- $xmlStrings = $this->xmlAction->getBookedXML();
- if (empty($xmlStrings)) {
- // No transactions available
- $this->statement = new StatementOfAccount();
- return;
+ if ($this->delegate === null) {
+ throw new \RuntimeException(
+ 'This action does not know which statement format it requested. It was probably restored from a '
+ . 'serialized form that a different version of this library had created, in which case the request '
+ . 'has to be started over.');
}
+ return $this->delegate;
+ }
- try {
- $parser = new CAMT();
- $parsedCAMT = $parser->parse($xmlStrings);
- $this->statement = StatementOfAccount::fromCAMTArray($parsedCAMT);
- } catch (\Exception $e) {
- throw new \InvalidArgumentException('Invalid CAMT XML data', 0, $e);
+ /**
+ * @param class-string $type
+ * @return AbstractGetStatementOfAccount The delegate, guaranteed to be an instance of $type.
+ */
+ private function requireDelegateOfType(string $type): AbstractGetStatementOfAccount
+ {
+ $delegate = $this->requireDelegate();
+ if (!$delegate instanceof $type) {
+ throw new \RuntimeException(
+ 'This statement was retrieved with ' . get_class($delegate) . ', so the requested data is not '
+ . 'available. Use ' . $type . ' directly if your application needs a particular format.');
}
+ return $delegate;
}
}
diff --git a/src/Action/GetStatementOfAccountMT940.php b/src/Action/GetStatementOfAccountMT940.php
new file mode 100644
index 00000000..73e47d4b
--- /dev/null
+++ b/src/Action/GetStatementOfAccountMT940.php
@@ -0,0 +1,231 @@
+ $to) {
+ throw new \InvalidArgumentException('From-date must be before to-date');
+ }
+
+ $result = new GetStatementOfAccountMT940();
+ $result->account = $account;
+ $result->from = $from;
+ $result->to = $to;
+ $result->allAccounts = $allAccounts;
+ $result->includeUnbooked = $includeUnbooked;
+ return $result;
+ }
+
+ /**
+ * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
+ */
+ public function serialize(): string
+ {
+ return serialize($this->__serialize());
+ }
+
+ public function __serialize(): array
+ {
+ return [
+ parent::__serialize(),
+ $this->account, $this->from, $this->to, $this->allAccounts, $this->includeUnbooked,
+ $this->bankName,
+ ];
+ }
+
+ /**
+ * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
+ *
+ * @param string $serialized
+ * @return void
+ */
+ public function unserialize($serialized)
+ {
+ self::__unserialize(unserialize($serialized));
+ }
+
+ public function __unserialize(array $serialized): void
+ {
+ list(
+ $parentSerialized,
+ $this->account, $this->from, $this->to, $this->allAccounts, $this->includeUnbooked,
+ $this->bankName,
+ ) = $serialized;
+
+ is_array($parentSerialized) ?
+ parent::__unserialize($parentSerialized) :
+ parent::unserialize($parentSerialized);
+ }
+
+ public function getRawResponse(): array
+ {
+ return $this->getRawMT940() === '' ? [] : [$this->getRawMT940()];
+ }
+
+ /**
+ * @return string The raw MT940 data received from the server. Note that this is the concatenation of all pages, and
+ * it also contains the unbooked transactions if they were requested.
+ * @noinspection PhpUnused
+ */
+ public function getRawMT940(): string
+ {
+ $this->ensureDone();
+ return $this->rawMT940;
+ }
+
+ /**
+ * @return array The parsed MT940 data.
+ */
+ public function getParsedMT940(): array
+ {
+ $this->ensureDone();
+ return $this->parsedMT940;
+ }
+
+ public function getStatement(): StatementOfAccount
+ {
+ $this->ensureDone();
+ return $this->statement;
+ }
+
+ protected function createRequest(BPD $bpd, ?UPD $upd)
+ {
+ $this->bankName = $bpd->getBankName();
+
+ /** @var HIKAZS $hikazs */
+ $hikazs = $bpd->requireLatestSupportedParameters('HIKAZS');
+ if ($this->allAccounts && !$hikazs->getParameter()->getAlleKontenErlaubt()) {
+ throw new \InvalidArgumentException('The bank do not permit the use of allAccounts=true');
+ }
+ switch ($hikazs->getVersion()) {
+ case 4:
+ return HKKAZv4::create(Kto::fromAccount($this->account), $this->from, $this->to);
+ case 5:
+ return HKKAZv5::create(KtvV3::fromAccount($this->account), $this->allAccounts, $this->from, $this->to);
+ case 6:
+ return HKKAZv6::create(KtvV3::fromAccount($this->account), $this->allAccounts, $this->from, $this->to);
+ case 7:
+ /** @var HISPAS $hispas */
+ $hispas = $bpd->requireLatestSupportedParameters('HISPAS');
+ $kti = Kti::fromAccount($this->account, $hispas->getParameter()->getNationaleKontoverbindungErlaubt());
+ return HKKAZv7::create($kti, $this->allAccounts, $this->from, $this->to);
+ default:
+ throw new UnsupportedException('Unsupported HKKAZ version: ' . $hikazs->getVersion());
+ }
+ }
+
+ public function processResponse(Message $response)
+ {
+ parent::processResponse($response);
+
+ // Banks send just 3010 and no HIKAZ in case there are no transactions.
+ $isUnavailable = $response->findRueckmeldung(Rueckmeldungscode::NICHT_VERFUEGBAR) !== null;
+ $responseHikaz = $response->findSegments(HIKAZ::class);
+ $numResponseSegments = count($responseHikaz);
+ if (!$isUnavailable && $numResponseSegments < count($this->getRequestSegmentNumbers())) {
+ throw new UnexpectedResponseException("Only got $numResponseSegments HIKAZ response segments!");
+ }
+
+ /** @var HIKAZ $hikaz */
+ foreach ($responseHikaz as $hikaz) {
+ $this->rawMT940 .= $hikaz->getGebuchteUmsaetze()->getData();
+ if ($this->includeUnbooked and $hikaz->getNichtGebuchteUmsaetze() !== null) {
+ $this->rawMT940 .= $hikaz->getNichtGebuchteUmsaetze()->getData();
+ }
+ }
+
+ // Note: Pagination boundaries may cut in the middle of the MT940 data, so it is not possible to parse a partial
+ // reponse before having received all pages.
+ if (!$this->hasMorePages()) {
+ $this->parseMt940();
+ }
+ }
+
+ private function parseMt940()
+ {
+ if (str_contains(strtolower($this->bankName), 'sparda')) {
+ $parser = new SpardaMT940();
+ } elseif (str_contains(strtolower($this->bankName), 'postbank')) {
+ $parser = new PostbankMT940();
+ } else {
+ $parser = new MT940();
+ }
+
+ try {
+ // Note: Some banks encode their MT 940 data as SWIFT/ISO-8859 like it should be according to the
+ // specification (e.g. DKB), others just send UTF-8 (e.g. Consorsbank), so we try to detect it here.
+ $rawMT940 = mb_detect_encoding($this->rawMT940, 'UTF-8', true) === false
+ ? mb_convert_encoding($this->rawMT940, 'UTF-8', 'ISO-8859-1') : $this->rawMT940;
+ $this->parsedMT940 = $parser->parse($rawMT940);
+ $this->statement = StatementOfAccount::fromMT940Array($this->parsedMT940);
+ } catch (MT940Exception $e) {
+ throw new \InvalidArgumentException('Invalid MT940 data', 0, $e);
+ }
+ }
+}
diff --git a/src/Action/GetStatementOfAccountXML.php b/src/Action/GetStatementOfAccountXML.php
index 47b73dd1..14917284 100644
--- a/src/Action/GetStatementOfAccountXML.php
+++ b/src/Action/GetStatementOfAccountXML.php
@@ -4,8 +4,9 @@
namespace Fhp\Action;
+use Fhp\CAMT\CAMT;
use Fhp\Model\SEPAAccount;
-use Fhp\PaginateableAction;
+use Fhp\Model\StatementOfAccount\StatementOfAccount;
use Fhp\Protocol\BPD;
use Fhp\Protocol\Message;
use Fhp\Protocol\UnexpectedResponseException;
@@ -20,10 +21,11 @@
use Fhp\UnsupportedException;
/**
- * Retrieves statements for one specific account or for all accounts that the user has access to. A statement is a
- * series of financial transactions that pertain to the account, grouped by day.
+ * Retrieves statements in the CAMT XML format (HKCAZ), which supersedes the MT 940 format (see
+ * {@link GetStatementOfAccountMT940}). Use this action directly if your application needs the raw XML documents,
+ * otherwise you probably want {@link GetStatementOfAccount}, which picks whichever format the bank supports.
*/
-class GetStatementOfAccountXML extends PaginateableAction
+class GetStatementOfAccountXML extends AbstractGetStatementOfAccount
{
// Request (if you add a field here, update __serialize() and __unserialize() as well).
/** @var SEPAAccount */
@@ -36,11 +38,16 @@ class GetStatementOfAccountXML extends PaginateableAction
private $camtURN;
/** @var bool */
private $allAccounts;
+ /** @var bool */
+ private $includeUnbooked;
// Response
/** @var string[] */
protected $xml = [];
+ /** @var string[] */
+ protected $unbookedXml = [];
+
/**
* @param SEPAAccount $account The account to get the statement for. This can be constructed based on information
* that the user entered, or it can be {@link SEPAAccount} instance retrieved from {@link getAccounts()}.
@@ -51,9 +58,13 @@ class GetStatementOfAccountXML extends PaginateableAction
* For example urn:iso:std:iso:20022:tech:xsd:camt.052.001.02
* @param bool $allAccounts If set to true, will return statements for all accounts of the user. You still need to
* pass one of the accounts into $account, though.
+ * @param bool $includeUnbooked If set to true, transactions that the bank has received but not booked yet are
+ * included in {@link getStatement()} and {@link getRawResponse()}. Note that the bank decides whether to send
+ * them at all: they are always absent for a time range that lies in the past, and {@link getUnbookedXML()}
+ * exposes them regardless of this flag if the bank did send them.
* @return GetStatementOfAccountXML A new action instance.
*/
- public static function create(SEPAAccount $account, ?\DateTime $from = null, ?\DateTime $to = null, ?string $camtURN = null, bool $allAccounts = false): GetStatementOfAccountXML
+ public static function create(SEPAAccount $account, ?\DateTime $from = null, ?\DateTime $to = null, ?string $camtURN = null, bool $allAccounts = false, bool $includeUnbooked = false): GetStatementOfAccountXML
{
if ($from !== null && $to !== null && $from > $to) {
throw new \InvalidArgumentException('From-date must be before to-date');
@@ -65,6 +76,7 @@ public static function create(SEPAAccount $account, ?\DateTime $from = null, ?\D
$result->from = $from;
$result->to = $to;
$result->allAccounts = $allAccounts;
+ $result->includeUnbooked = $includeUnbooked;
return $result;
}
@@ -81,6 +93,7 @@ public function __serialize(): array
return [
parent::__serialize(),
$this->account, $this->camtURN, $this->from, $this->to, $this->allAccounts,
+ $this->includeUnbooked,
];
}
@@ -100,6 +113,7 @@ public function __unserialize(array $serialized): void
list(
$parentSerialized,
$this->account, $this->camtURN, $this->from, $this->to, $this->allAccounts,
+ $this->includeUnbooked,
) = $serialized;
is_array($parentSerialized) ?
@@ -107,6 +121,13 @@ public function __unserialize(array $serialized): void
parent::unserialize($parentSerialized);
}
+ public function getRawResponse(): array
+ {
+ return $this->includeUnbooked
+ ? array_merge($this->getBookedXML(), $this->getUnbookedXML())
+ : $this->getBookedXML();
+ }
+
/**
* @return string[] The XML-Document(s) received from the bank, or empty array if the statement is unavailable/empty.
*/
@@ -116,6 +137,40 @@ public function getBookedXML(): array
return $this->xml;
}
+ /**
+ * @return string[] The XML-Document that contains the transactions which the bank has received but not booked yet,
+ * or an empty array if the bank did not send any. This is independent of the $includeUnbooked flag, which only
+ * determines whether these transactions are part of {@link getStatement()} and {@link getRawResponse()}.
+ * @noinspection PhpUnused
+ */
+ public function getUnbookedXML(): array
+ {
+ $this->ensureDone();
+ return $this->unbookedXml;
+ }
+
+ /**
+ * @return StatementOfAccount The transactions from the CAMT XML document(s), for applications that don't want to
+ * parse the XML themselves. Use {@link getBookedXML()} to access the raw documents. Note that this conversion
+ * is lossy, see {@link CAMT}.
+ */
+ public function getStatement(): StatementOfAccount
+ {
+ $xmlStrings = $this->getRawResponse();
+ if (empty($xmlStrings)) {
+ // No transactions available
+ return new StatementOfAccount();
+ }
+
+ try {
+ $parser = new CAMT();
+ $parsedCAMT = $parser->parse($xmlStrings);
+ return StatementOfAccount::fromCAMTArray($parsedCAMT);
+ } catch (\Exception $e) {
+ throw new \InvalidArgumentException('Invalid CAMT XML data', 0, $e);
+ }
+ }
+
protected function createRequest(BPD $bpd, ?UPD $upd)
{
if ($upd === null) {
@@ -176,5 +231,10 @@ public function processResponse(Message $response)
foreach ($responseHicaz[0]->getGebuchteUmsaetze() as $xml_string) {
$this->xml[] = $xml_string;
}
+ // Banks only send this in case the requested time range reaches into the present.
+ $nichtGebuchteUmsaetze = $responseHicaz[0]->getNichtGebuchteUmsaetze();
+ if ($nichtGebuchteUmsaetze !== null) {
+ $this->unbookedXml[] = $nichtGebuchteUmsaetze;
+ }
}
}
diff --git a/src/CAMT/CAMT.php b/src/CAMT/CAMT.php
index ed82b2ce..3d8fc5e3 100644
--- a/src/CAMT/CAMT.php
+++ b/src/CAMT/CAMT.php
@@ -202,9 +202,11 @@ private function parseEntry(\SimpleXMLElement $entry, string $ns): ?array
$reversalIndicator = (string) ($entry->xpath('.//c:RvslInd')[0] ?? 'false');
$isStorno = strtolower($reversalIndicator) === 'true';
- // Get status - check if booked or pending
- $status = (string) ($entry->xpath('.//c:Sts')[0] ?? 'BOOK');
- $booked = strtoupper($status) === 'BOOK';
+ // Get status - check if booked or pending. Up to camt.052.001.06 this is a plain code (e.g. BOOK),
+ // from camt.052.001.08 on it is a choice element (e.g. BOOK, or for a proprietary
+ // status, which is not a documented code and thus treated as not booked here).
+ $status = (string) ($entry->xpath('.//c:Sts/c:Cd')[0] ?? $entry->xpath('.//c:Sts')[0] ?? 'BOOK');
+ $booked = strtoupper(trim($status)) === 'BOOK';
// Parse transaction details
$details = $this->parseEntryDetails($entry, $ns);
diff --git a/src/Segment/CAZ/HICAZv1.php b/src/Segment/CAZ/HICAZv1.php
index c800c39c..596c1877 100644
--- a/src/Segment/CAZ/HICAZv1.php
+++ b/src/Segment/CAZ/HICAZv1.php
@@ -54,8 +54,8 @@ public function getGebuchteUmsaetze(): array
return $this->gebuchteUmsaetze->getData();
}
- public function getNichtGebuchteUmsaetze(): string
+ public function getNichtGebuchteUmsaetze(): ?string
{
- return $this->nichtGebuchteUmsaetze->getData();
+ return $this->nichtGebuchteUmsaetze?->getData();
}
}