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.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
' + . '
'; + + /** 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 + { + 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.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/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(); } }