From 4dd90088545d75b3e33a41b459fd74b9a51c83b6 Mon Sep 17 00:00:00 2001 From: Lukas Staab Date: Sat, 15 Aug 2026 14:51:02 +0200 Subject: [PATCH 1/4] fix #553: pick the statement format the bank supports GetStatementOfAccount fell back to an internal GetStatementOfAccountXML when the bank did not support MT 940, but that field was not part of the serialized form. Persisting the action while the user enters a TAN - as any web application does - restored it without the fallback, so it looked for HIKAZ segments in a response that contained HICAZ and failed with "Only got 0 HIKAZ response segments!". Instead of repairing that field, the decision is now made explicitly from the BPD and each format is a first-class action: - GetStatementOfAccountMT940 (new) holds the MT 940 implementation. - GetStatementOfAccountXML gained getStatement(), so CAMT data can be used through the StatementOfAccount model as well. The conversion itself already existed, it just lived in the fallback code of GetStatementOfAccount. - GetStatementOfAccount picks between them, preferring CAMT XML because MT 940 has been retired by Die Deutsche Kreditwirtschaft, and delegates to the chosen action. The delegate is serialized like any other field, and getDelegate() tells callers which format they ended up with. - AbstractGetStatementOfAccount declares what both formats have in common: getStatement() and getRawResponse(). Pick a class to pin a format; GetStatementOfAccount's MT 940 and CAMT specific getters are deprecated in favour of getRawResponse(). Note that at banks offering both formats it now returns CAMT-derived data, which is why the DKB test pins GetStatementOfAccountMT940. Co-Authored-By: Claude Opus 5 --- .../Action/GetStatementOfAccountAutoTest.php | 144 +++++++++++ .../DKB/GetStatementOfAccountTest.php | 12 +- .../GLS/GetStatementOfAccountXMLTest.php | 3 + src/Action/AbstractGetStatementOfAccount.php | 31 +++ src/Action/GetStatementOfAccount.php | 243 ++++++++---------- src/Action/GetStatementOfAccountMT940.php | 231 +++++++++++++++++ src/Action/GetStatementOfAccountXML.php | 37 ++- 7 files changed, 559 insertions(+), 142 deletions(-) create mode 100644 Tests/Unit/Action/GetStatementOfAccountAutoTest.php create mode 100644 src/Action/AbstractGetStatementOfAccount.php create mode 100644 src/Action/GetStatementOfAccountMT940.php diff --git a/Tests/Unit/Action/GetStatementOfAccountAutoTest.php b/Tests/Unit/Action/GetStatementOfAccountAutoTest.php new file mode 100644 index 00000000..ba96b50d --- /dev/null +++ b/Tests/Unit/Action/GetStatementOfAccountAutoTest.php @@ -0,0 +1,144 @@ +1234567890-2020-02-05DExxABCDEFGH1234567890' + . 'OPBD1234.56CRDT
2020-02-05
' + . '123.45CRDTBOOK
2020-02-05
2020-02-05
' + . 'GUTSCHRIFT TESTZAHLUNGSENDER NAME
' + . '42.00DBITBOOK
2020-02-05
2020-02-06
' + . 'MIETE FEBRUAREMPFAENGER NAME
' + . '
'; + + /** 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'; + + /** 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(2, $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()); + } + + /** + * 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(); + } +} 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 +107,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 +116,132 @@ 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); } - - 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..2ed4738d 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 */ @@ -107,6 +109,11 @@ public function __unserialize(array $serialized): void parent::unserialize($parentSerialized); } + public function getRawResponse(): array + { + return $this->getBookedXML(); + } + /** * @return string[] The XML-Document(s) received from the bank, or empty array if the statement is unavailable/empty. */ @@ -116,6 +123,28 @@ public function getBookedXML(): array return $this->xml; } + /** + * @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) { From 2256ad082d723f1debcbfd31f3b9a326a72a809b Mon Sep 17 00:00:00 2001 From: Lukas Staab Date: Sat, 15 Aug 2026 14:52:08 +0200 Subject: [PATCH 2/4] Recognize the booking status in newer CAMT versions camt.052.001.02 through .06 declare the status of an entry as a plain code (EntryStatus2Code, e.g. BOOK), whereas .08 makes it a choice element (EntryStatus1Choice, e.g. BOOK). The parser only read the plain form, so with .08 - the version German banks have been offering since 2021 and are migrating to exclusively - every transaction was reported as not booked. Read the code element first and fall back to the element's own text, which covers both spellings. Note that the order matters: casting BOOK to a string yields the element's direct text, which is empty. A pending entry is added to the test fixture, so both the booked and the unbooked case are covered, and the whole test is run a second time against a camt.052.001.08 document. Co-Authored-By: Claude Opus 5 --- .../Action/GetStatementOfAccountAutoTest.php | 14 ++++++++--- .../GetStatementOfAccountCamt08Test.php | 24 +++++++++++++++++++ src/CAMT/CAMT.php | 8 ++++--- 3 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 Tests/Unit/Action/GetStatementOfAccountCamt08Test.php diff --git a/Tests/Unit/Action/GetStatementOfAccountAutoTest.php b/Tests/Unit/Action/GetStatementOfAccountAutoTest.php index ba96b50d..d0cb239b 100644 --- a/Tests/Unit/Action/GetStatementOfAccountAutoTest.php +++ b/Tests/Unit/Action/GetStatementOfAccountAutoTest.php @@ -25,8 +25,8 @@ class GetStatementOfAccountAutoTest extends GLSIntegrationTestBase { /** - * A minimal, synthetic camt.052 document, modelled after the (private) one from issue #553: two booked entries and - * an opening balance. Kept ASCII-only so that its length is the same before and after the ISO-8859-1 conversion + * A minimal, synthetic camt.052 document, modelled after the (private) one from issue #553: two booked entries, a + * pending (not yet booked) one and an opening balance. Kept ASCII-only so that its length is the same before and after the ISO-8859-1 conversion * that the responses go through. */ public const CAMT_DOCUMENT = '1234567890-2020-02-05DExxABCDEFGH1234567890' @@ -35,6 +35,8 @@ class GetStatementOfAccountAutoTest extends GLSIntegrationTestBase . 'GUTSCHRIFT TESTZAHLUNGSENDER NAME' . '42.00DBITBOOK
2020-02-05
2020-02-06
' . 'MIETE FEBRUAREMPFAENGER NAME
' + . '9.99DBITPDNG
2020-02-05
2020-02-05
' + . 'KARTENZAHLUNG VORGEMERKTSUPERMARKT
' . ''; /** The CAMT version that {@link CAMT_DOCUMENT} uses, as announced by the bank in the HICAZ segment. */ @@ -101,7 +103,7 @@ public function testCamtIsChosenAndSurvivesPersist() $statement1 = $statement->getStatements()[0]; $this->assertEquals(new \DateTime('2020-02-05'), $statement1->getDate()); $this->assertEqualsWithDelta(1234.56, $statement1->getStartBalance(), 0.01); - $this->assertCount(2, $statement1->getTransactions()); + $this->assertCount(3, $statement1->getTransactions()); $transaction1 = $statement1->getTransactions()[0]; $this->assertEquals(Statement::CD_CREDIT, $transaction1->getCreditDebit()); @@ -114,6 +116,12 @@ public function testCamtIsChosenAndSurvivesPersist() $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()); } /** diff --git a/Tests/Unit/Action/GetStatementOfAccountCamt08Test.php b/Tests/Unit/Action/GetStatementOfAccountCamt08Test.php new file mode 100644 index 00000000..db89de10 --- /dev/null +++ b/Tests/Unit/Action/GetStatementOfAccountCamt08Test.php @@ -0,0 +1,24 @@ + element, unlike .02, which spells it out directly. + */ +class GetStatementOfAccountCamt08Test extends GetStatementOfAccountAutoTest +{ + public const CAMT_VERSION = 'camt.052.001.08'; + + public const CAMT_DOCUMENT = '1234567890-2020-02-05DExxABCDEFGH1234567890' + . 'OPBD1234.56CRDT
2020-02-05
' + . '123.45CRDTBOOK
2020-02-05
2020-02-05
' + . 'GUTSCHRIFT TESTZAHLUNGSENDER NAME
' + . '42.00DBITBOOK
2020-02-05
2020-02-06
' + . 'MIETE FEBRUAREMPFAENGER NAME
' + . '9.99DBITPDNG
2020-02-05
2020-02-05
' + . 'KARTENZAHLUNG VORGEMERKTSUPERMARKT
' + . '
'; +} 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); From b8ad3800f4227a0b903526dde178d50b11825add Mon Sep 17 00:00:00 2001 From: Lukas Staab Date: Sat, 15 Aug 2026 14:52:37 +0200 Subject: [PATCH 3/4] fix #537: read the not-yet-booked transactions from HICAZ HICAZ carries the transactions that the bank has received but not booked yet in a field of its own, next to the booked ones, but GetStatementOfAccountXML never looked at it - so $includeUnbooked had no effect outside of MT 940. The action now collects that document whenever the bank sends one and exposes it through getUnbookedXML(), independent of the flag; $includeUnbooked decides whether those transactions also become part of getStatement() and getRawResponse(), which mirrors how the MT 940 action treats them. GetStatementOfAccount passes the flag on to either format now. Note that the request is the same either way: unlike MT 940 there is no parameter for it, the bank decides whether to send unbooked transactions at all, and it never does for a time range that lies in the past. While at it, HICAZv1::getNichtGebuchteUmsaetze() no longer fatals when the field is absent, which is the normal case. Co-Authored-By: Claude Opus 5 --- .../Action/GetStatementOfAccountAutoTest.php | 90 +++++++++++++++++++ .../GetStatementOfAccountCamt08Test.php | 2 + src/Action/GetStatementOfAccount.php | 8 +- src/Action/GetStatementOfAccountXML.php | 35 +++++++- src/Segment/CAZ/HICAZv1.php | 4 +- 5 files changed, 132 insertions(+), 7 deletions(-) diff --git a/Tests/Unit/Action/GetStatementOfAccountAutoTest.php b/Tests/Unit/Action/GetStatementOfAccountAutoTest.php index d0cb239b..a707f6ab 100644 --- a/Tests/Unit/Action/GetStatementOfAccountAutoTest.php +++ b/Tests/Unit/Action/GetStatementOfAccountAutoTest.php @@ -42,6 +42,27 @@ class GetStatementOfAccountAutoTest extends GLSIntegrationTestBase /** 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-05
2020-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 { @@ -149,4 +170,73 @@ public function testMT940ResultsAreUnavailable() $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 index db89de10..9be9a3e5 100644 --- a/Tests/Unit/Action/GetStatementOfAccountCamt08Test.php +++ b/Tests/Unit/Action/GetStatementOfAccountCamt08Test.php @@ -12,6 +12,8 @@ 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.56CRDT
2020-02-05
' . '123.45CRDTBOOK
2020-02-05
2020-02-05
' diff --git a/src/Action/GetStatementOfAccount.php b/src/Action/GetStatementOfAccount.php index 92ccec70..5dc4e8d5 100644 --- a/src/Action/GetStatementOfAccount.php +++ b/src/Action/GetStatementOfAccount.php @@ -53,8 +53,9 @@ class GetStatementOfAccount extends AbstractGetStatementOfAccount * @param \DateTime|null $to If set, only transactions before this date (inclusive) are returned. * @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, unbooked transactions are included. This is only supported in the - * MT 940 format, i.e. it has no effect if the bank supports CAMT XML. + * @param bool $includeUnbooked If set to true, transactions that the bank has received but not booked yet are + * included, in both formats. Note that the bank only sends them if the requested time range reaches into the + * present. * @return GetStatementOfAccount A new action instance. */ public static function create(SEPAAccount $account, ?\DateTime $from = null, ?\DateTime $to = null, bool $allAccounts = false, bool $includeUnbooked = false): GetStatementOfAccount @@ -202,7 +203,8 @@ private function createDelegate(BPD $bpd, ?UPD $upd): AbstractGetStatementOfAcco $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); + return GetStatementOfAccountXML::create( + $this->account, $this->from, $this->to, null, $this->allAccounts, $this->includeUnbooked); } if ($bpd->getLatestSupportedParameters('HIKAZS') !== null) { return GetStatementOfAccountMT940::create($this->account, $this->from, $this->to, $this->allAccounts, $this->includeUnbooked); diff --git a/src/Action/GetStatementOfAccountXML.php b/src/Action/GetStatementOfAccountXML.php index 2ed4738d..14917284 100644 --- a/src/Action/GetStatementOfAccountXML.php +++ b/src/Action/GetStatementOfAccountXML.php @@ -38,11 +38,16 @@ class GetStatementOfAccountXML extends AbstractGetStatementOfAccount 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()}. @@ -53,9 +58,13 @@ class GetStatementOfAccountXML extends AbstractGetStatementOfAccount * 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'); @@ -67,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; } @@ -83,6 +93,7 @@ public function __serialize(): array return [ parent::__serialize(), $this->account, $this->camtURN, $this->from, $this->to, $this->allAccounts, + $this->includeUnbooked, ]; } @@ -102,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) ? @@ -111,7 +123,9 @@ public function __unserialize(array $serialized): void public function getRawResponse(): array { - return $this->getBookedXML(); + return $this->includeUnbooked + ? array_merge($this->getBookedXML(), $this->getUnbookedXML()) + : $this->getBookedXML(); } /** @@ -123,6 +137,18 @@ 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 @@ -205,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/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(); } } From ac05599db85137d8727a6b7bf584d6202fc7b224 Mon Sep 17 00:00:00 2001 From: Lukas Staab Date: Sat, 15 Aug 2026 14:52:37 +0200 Subject: [PATCH 4/4] Update the statement samples for the three actions The XML sample still claimed that GetStatementOfAccount "automatically falls back to XML format when MT940 is not available", which is what sent me looking for a fallback that did not work. Describe what the three actions do instead, and point out that $includeUnbooked now applies to both formats. Co-Authored-By: Claude Opus 5 --- Samples/statementOfAccount.php | 4 ++++ Samples/statementOfAccountXML.php | 14 +++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) 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()) {