diff --git a/README.md b/README.md index f64c0315..a1793752 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A PHP library implementing the following functions of the FinTS/HBCI protocol: * Get accounts * Get balance * Get transactions + * Get credit card transactions (see [below](#credit-card-transactions)) * Execute direct debit * Execute transfer * Note that any other functions mentioned in @@ -43,6 +44,48 @@ Fill out the required configuration in `init.php` (server details can be obtaine Then execute `tanModesAndMedia.php` and later `login.php`. Once you are able to login without any issues, you can move on to the other examples. +## Credit card transactions + +Credit card accounts have no IBAN. They are therefore not returned by `GetSEPAAccounts` (HKSPA) and +their transactions cannot be retrieved with `GetStatementOfAccount` (HKKAZ) or +`GetStatementOfAccountXML` (HKCAZ). Instead they use `DKKKU`, a business transaction defined by the +Deutsche Kreditwirtschaft rather than by the FinTS specification, which not every bank offers. + +The accounts are found in the UPD, which the bank sends during login, so listing them costs no +request: + +```php +$getCards = \Fhp\Action\GetCreditCardAccounts::create(); +$fints->execute($getCards); +$cards = $getCards->getAccounts(); + +$getTransactions = \Fhp\Action\GetCreditCardStatement::create($cards[0], $from, $to); +$fints->execute($getTransactions); +if ($getTransactions->needsTan()) { + handleStrongAuthentication($getTransactions); +} +foreach ($getTransactions->getStatement()->getTransactions() as $transaction) { + echo $transaction->getBookingDate()->format('Y-m-d') . ' ' + . $transaction->getAmount() . ' ' . $transaction->getCurrency() . ' ' + . $transaction->getPurpose() . PHP_EOL; +} +``` + +See [Samples/creditCardStatement.php](/Samples/creditCardStatement.php) for a complete example. Note +that: + + * A single account can cover several physical cards, possibly of different schemes. The account + number looks like a card number but generally is not a valid one. Which card a transaction belongs + to is usually indicated in its purpose. + * A single request may not span more than the period the bank reports as "Speicherzeitraum" in the + `DIKKUS` parameters, and `GetCreditCardStatement` rejects wider ranges. Banks do not refuse them + themselves, they just answer inconsistently — the tested one served a range twice that period in + full on one attempt and returned an empty page on the next, and silently truncated a much wider + one. Note that the period is not necessarily how far back the data goes: the tested bank happily + served much older transactions when asked for them in windows of the permitted width. + * Transactions in a foreign currency additionally report the original amount, its currency and the + exchange rate that was applied. + ## Banks with special needs If you are developing an online banking application with this library, please be aware of the following exceptions: diff --git a/Samples/creditCardStatement.php b/Samples/creditCardStatement.php new file mode 100644 index 00000000..87cfe20f --- /dev/null +++ b/Samples/creditCardStatement.php @@ -0,0 +1,65 @@ +execute($getCreditCardAccounts); +$creditCardAccounts = $getCreditCardAccounts->getAccounts(); + +if (count($creditCardAccounts) === 0) { + echo 'No credit card accounts found. Your bank may not support DKKKU.' . PHP_EOL; + return; +} + +echo 'Credit card accounts:' . PHP_EOL; +foreach ($creditCardAccounts as $creditCardAccount) { + echo ' ' . $creditCardAccount->getAccountNumber() + . ' (' . $creditCardAccount->getProductName() . ')' . PHP_EOL; +} + +// Just pick the first one, for demonstration purposes. +$oneAccount = $creditCardAccounts[0]; + +$from = new \DateTime('-30 days'); +$to = new \DateTime(); +$getStatement = \Fhp\Action\GetCreditCardStatement::create($oneAccount, $from, $to); +$fints->execute($getStatement); +if ($getStatement->needsTan()) { + handleStrongAuthentication($getStatement); // See login.php for the implementation. +} + +$statement = $getStatement->getStatement(); +if ($statement->getBalance() !== null) { + echo 'Balance: ' . $statement->getBalance() . ' ' . $statement->getBalanceCurrency() . PHP_EOL; +} +echo 'Transactions:' . PHP_EOL; +echo '=======================================' . PHP_EOL; +foreach ($statement->getTransactions() as $transaction) { + echo 'Booking date: ' . $transaction->getBookingDate()?->format('Y-m-d') . PHP_EOL; + echo 'Amount : ' . $transaction->getAmount() . ' ' . $transaction->getCurrency() . PHP_EOL; + // Transactions in a foreign currency also report what the merchant originally charged. + if ($transaction->getOriginalAmount() !== null) { + echo 'Original : ' . $transaction->getOriginalAmount() . ' ' . $transaction->getOriginalCurrency() + . ' (rate ' . $transaction->getExchangeRate() . ')' . PHP_EOL; + } + echo 'Purpose : ' . $transaction->getPurpose() . PHP_EOL; + echo 'Reference : ' . $transaction->getReference() . PHP_EOL; + echo 'Category : ' . ($transaction->getMerchantCategoryCode() ?? '-') . PHP_EOL; + echo '=======================================' . PHP_EOL . PHP_EOL; +} +echo 'Found ' . count($statement->getTransactions()) . ' transactions.' . PHP_EOL; diff --git a/Tests/Unit/Action/GetCreditCardAccountsTest.php b/Tests/Unit/Action/GetCreditCardAccountsTest.php new file mode 100644 index 00000000..e554e840 --- /dev/null +++ b/Tests/Unit/Action/GetCreditCardAccountsTest.php @@ -0,0 +1,128 @@ +hiupd = array_map(function (string $raw) { + return BaseSegment::parse($raw); + }, $rawHiupds); + + $action = GetCreditCardAccounts::create(); + $action->getNextRequest(new BPD(), $upd); + return $action; + } + + public function testFindsOnlyTheCreditCardAccount() + { + $action = self::runAction([ + self::HIUPD_CURRENT_ACCOUNT, + self::HIUPD_CREDIT_CARD, + self::HIUPD_SAVINGS_ACCOUNT, + ]); + + $accounts = $action->getAccounts(); + $this->assertCount(1, $accounts); + + $account = $accounts[0]; + $this->assertEquals('5555000011112222', $account->getAccountNumber()); + $this->assertEquals('60050101', $account->getBlz()); + $this->assertEquals(50, $account->getAccountType()); + $this->assertEquals('Example Goldcard Set', $account->getProductName()); + $this->assertEquals('EUR', $account->getCurrency()); + // Banks split the holder name across two fields. + $this->assertEquals('Mustermann Max', $account->getName()); + } + + /** The data comes from the UPD, so no request to the bank is necessary. */ + public function testNeedsNoRequest() + { + $upd = new UPD(); + $upd->hiupd = [BaseSegment::parse(self::HIUPD_CREDIT_CARD)]; + + $action = GetCreditCardAccounts::create(); + $requestSegments = $action->getNextRequest(new BPD(), $upd); + + $this->assertEmpty($requestSegments); + $this->assertTrue($action->isDone()); + $this->assertCount(1, $action->getAccounts()); + } + + public function testReturnsNothingWithoutCreditCardAccounts() + { + $action = self::runAction([self::HIUPD_CURRENT_ACCOUNT, self::HIUPD_SAVINGS_ACCOUNT]); + $this->assertCount(0, $action->getAccounts()); + } + + /** + * HIUPD v4 does not report an account type at all, so the list of permitted business transactions + * is the only criterion that works across versions. + */ + public function testFindsAccountInOlderSegmentVersion() + { + $action = self::runAction([ + 'HIUPD:9:4:4+5555000011112244::280:60050101+9999999999+EUR+Mustermann+Erika+Legacy Card++DKKKU:1\'', + ]); + + $accounts = $action->getAccounts(); + $this->assertCount(1, $accounts); + $this->assertEquals('5555000011112244', $accounts[0]->getAccountNumber()); + $this->assertNull($accounts[0]->getAccountType()); + } + + /** If the bank sends no list of permitted transactions, fall back to the account type. */ + public function testFallsBackToAccountType() + { + $action = self::runAction([ + 'HIUPD:12:6:4+5555000011112255::280:60050101++9999999999+55+EUR+Mustermann+Max+Example Card\'', + ]); + + $accounts = $action->getAccounts(); + $this->assertCount(1, $accounts); + $this->assertEquals(55, $accounts[0]->getAccountType()); + } + + /** An explicit list that lacks DKKKU wins over the account type. */ + public function testAccountTypeDoesNotOverrideAnExplicitList() + { + $action = self::runAction([ + 'HIUPD:12:6:4+5555000011112266::280:60050101++9999999999+50+EUR+Mustermann+Max+Example Card++HKSAK:1+HKTAN:1\'', + ]); + + $this->assertCount(0, $action->getAccounts()); + } + + public function testRequiresUpd() + { + $this->expectException(\InvalidArgumentException::class); + GetCreditCardAccounts::create()->getNextRequest(new BPD(), null); + } +} diff --git a/Tests/Unit/Action/GetCreditCardStatementTest.php b/Tests/Unit/Action/GetCreditCardStatementTest.php new file mode 100644 index 00000000..f00a9205 --- /dev/null +++ b/Tests/Unit/Action/GetCreditCardStatementTest.php @@ -0,0 +1,264 @@ +rueckmeldungscode = Rueckmeldungscode::AUFSETZPUNKT; + $rueckmeldung->rueckmeldungstext = 'Es liegen weitere Informationen vor.'; + $rueckmeldung->rueckmeldungsparameter = [$paginationToken]; + + $hirms = HIRMSv2::createEmpty(); + $hirms->rueckmeldung = [$rueckmeldung]; + $segments[] = $hirms; + } + $segments[] = $dikku; + return Message::createPlainMessage($segments); + } + + private static function action(): GetCreditCardStatement + { + $account = (new CreditCardAccount())->setAccountNumber(self::ACCOUNT)->setBlz('60050101'); + return GetCreditCardStatement::create($account, new \DateTime('2026-04-20'), new \DateTime('2026-07-19')); + } + + private static function bpd(): BPD + { + $bpd = new BPD(); + $bpd->parameters['DIKKUS'] = [2 => BaseSegment::parse('DIKKUS:45:2:3+1+1+0+90:N:J\'')]; + return $bpd; + } + + /** + * Requests the next page the way FinTs::execute() does, which includes assigning segment numbers. + * Without those the request segments cannot be serialized. + * + * @return BaseSegment[] + */ + private static function nextRequest(GetCreditCardStatement $action, BPD $bpd): array + { + $requestSegments = $action->getNextRequest($bpd, null); + Message::setSegmentNumbers($requestSegments, 3); + $action->setRequestSegmentNumbers(array_map(function (BaseSegment $segment) { + return $segment->getSegmentNumber(); + }, $requestSegments)); + return $requestSegments; + } + + public function testBuildsRequest() + { + $action = self::action(); + $requestSegments = self::nextRequest($action, self::bpd()); + + $this->assertCount(1, $requestSegments); + /** @var DKKKUv2 $request */ + $request = $requestSegments[0]; + $this->assertInstanceOf(DKKKUv2::class, $request); + $this->assertEquals(self::ACCOUNT, $request->kontonummer); + $this->assertEquals(self::ACCOUNT, $request->kontoverbindung->kontonummer); + $this->assertEquals('60050101', $request->kontoverbindung->kik->kreditinstitutscode); + $this->assertEquals('20260420', $request->vonDatum); + $this->assertEquals('20260719', $request->bisDatum); + $this->assertNull($request->aufsetzpunkt); + } + + public function testSinglePageResponse() + { + $action = self::action(); + self::nextRequest($action, self::bpd()); + $action->processResponse(self::response(self::dikku(self::RECORD_1, self::RECORD_2))); + + $this->assertFalse($action->hasMorePages()); + $this->assertTrue($action->isDone()); + $this->assertCount(2, $action->getStatement()->getTransactions()); + } + + public function testPaginatedResponseAccumulatesAllPages() + { + $action = self::action(); + $bpd = self::bpd(); + + // First page: two records, and the bank announces that more are available. + self::nextRequest($action, $bpd); + $action->processResponse(self::response(self::dikku(self::RECORD_1, self::RECORD_2), 'PAGE2')); + + $this->assertTrue($action->hasMorePages()); + $this->assertFalse($action->isDone()); + + // The follow-up request must carry the pagination token, everything else unchanged. + $requestSegments = self::nextRequest($action, $bpd); + /** @var DKKKUv2 $request */ + $request = $requestSegments[0]; + $this->assertEquals('PAGE2', $request->aufsetzpunkt); + $this->assertEquals(self::ACCOUNT, $request->kontonummer); + $this->assertEquals('20260420', $request->vonDatum); + + // Second page: one more record, no further announcement. + $action->processResponse(self::response(self::dikku(self::RECORD_3))); + + $this->assertFalse($action->hasMorePages()); + $this->assertTrue($action->isDone()); + + $transactions = $action->getStatement()->getTransactions(); + $this->assertCount(3, $transactions); + $this->assertEquals(-12.34, $transactions[0]->getAmount()); + $this->assertEquals(-56.78, $transactions[1]->getAmount()); + $this->assertEquals(-9.99, $transactions[2]->getAmount()); + } + + /** The result must not be built before the last page arrived, since records span pages. */ + public function testStatementIsUnavailableWhileMorePagesFollow() + { + $action = self::action(); + self::nextRequest($action, self::bpd()); + $action->processResponse(self::response(self::dikku(self::RECORD_1), 'PAGE2')); + + $this->expectException(\Fhp\Protocol\ActionIncompleteException::class); + $action->getStatement(); + } + + public function testSerializationRoundTripPreservesTheRequest() + { + $action = self::action(); + self::nextRequest($action, self::bpd()); + $action->processResponse(self::response(self::dikku(self::RECORD_1), 'PAGE2')); + + /** @var GetCreditCardStatement $restored */ + $restored = unserialize(serialize($action)); + + // The restored action must continue with the same page and produce the same request. + $this->assertTrue($restored->hasMorePages()); + $requestSegments = self::nextRequest($restored, self::bpd()); + /** @var DKKKUv2 $request */ + $request = $requestSegments[0]; + $this->assertEquals('PAGE2', $request->aufsetzpunkt); + $this->assertEquals(self::ACCOUNT, $request->kontonummer); + $this->assertEquals('20260420', $request->vonDatum); + $this->assertEquals('20260719', $request->bisDatum); + } + + private static function accountOnly(): CreditCardAccount + { + return (new CreditCardAccount())->setAccountNumber(self::ACCOUNT)->setBlz('60050101'); + } + + /** The DIKKUS fixture announces a period of 90 days. */ + public function testRejectsRangeSpanningMoreThanTheAnnouncedPeriod() + { + $action = GetCreditCardStatement::create( + self::accountOnly(), + new \DateTime('-200 days'), + new \DateTime() + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/spanning more than 90 days/'); + $action->getNextRequest(self::bpd(), null); + } + + public function testAcceptsRangeWithinTheAnnouncedPeriod() + { + $action = GetCreditCardStatement::create( + self::accountOnly(), + new \DateTime('-89 days'), + new \DateTime() + ); + + $this->assertCount(1, $action->getNextRequest(self::bpd(), null)); + } + + /** + * Older transactions have to be fetched window by window, so a short range far in the past must + * be accepted even though it starts beyond the announced period. + */ + public function testAcceptsShortRangeFarInThePast() + { + $action = GetCreditCardStatement::create( + self::accountOnly(), + new \DateTime('-180 days'), + new \DateTime('-90 days') + ); + + $requestSegments = $action->getNextRequest(self::bpd(), null); + $this->assertCount(1, $requestSegments); + /** @var DKKKUv2 $request */ + $request = $requestSegments[0]; + $this->assertEquals((new \DateTime('-180 days'))->format('Ymd'), $request->vonDatum); + $this->assertEquals((new \DateTime('-90 days'))->format('Ymd'), $request->bisDatum); + } + + /** Without a to-date the range extends to today, which still must not be too wide. */ + public function testRejectsOpenEndedRangeStartingTooFarBack() + { + $action = GetCreditCardStatement::create(self::accountOnly(), new \DateTime('-200 days')); + + $this->expectException(\InvalidArgumentException::class); + $action->getNextRequest(self::bpd(), null); + } + + /** Without a from-date the bank decides how much to return, so there is nothing to validate. */ + public function testAcceptsRangeWithoutFromDate() + { + $action = GetCreditCardStatement::create(self::accountOnly()); + + $this->assertCount(1, $action->getNextRequest(self::bpd(), null)); + } + + public function testRejectsAnAccountWithoutNumber() + { + $this->expectException(\InvalidArgumentException::class); + GetCreditCardStatement::create(new CreditCardAccount()); + } + + public function testRejectsReversedDateRange() + { + $this->expectException(\InvalidArgumentException::class); + GetCreditCardStatement::create( + (new CreditCardAccount())->setAccountNumber(self::ACCOUNT), + new \DateTime('2026-07-19'), + new \DateTime('2026-04-20') + ); + } +} diff --git a/Tests/Unit/Segment/DIKKUTest.php b/Tests/Unit/Segment/DIKKUTest.php new file mode 100644 index 00000000..03fac8a1 --- /dev/null +++ b/Tests/Unit/Segment/DIKKUTest.php @@ -0,0 +1,201 @@ +assertEquals('DIKKU', $dikku->getName()); + $this->assertEquals(2, $dikku->getVersion()); + $this->assertEquals(self::ACCOUNT, $dikku->getKontonummer()); + + $saldo = $dikku->getSaldo(); + $this->assertNotNull($saldo); + // 'D' means the customer owes the money, so the amount is negative. + $this->assertEquals(-1234.56, $saldo->getAmount()); + $this->assertEquals('EUR', $saldo->getCurrency()); + $this->assertEquals('2026-07-19', $saldo->getTimestamp()->format('Y-m-d')); + } + + public function testParsesDomesticRecord() + { + $dikku = DIKKUv2::parse(self::buildSegment(self::RECORD_DOMESTIC)); + $this->assertCount(1, $dikku->getUmsaetze()); + $umsatz = $dikku->getUmsaetze()[0]; + + $this->assertEquals('5555000011112222', $umsatz->kontonummer); + $this->assertEquals('20260717', $umsatz->belegdatum); + $this->assertEquals('20260718', $umsatz->buchungsdatum); + $this->assertEquals(12.34, $umsatz->betrag->wert); + $this->assertEquals('EUR', $umsatz->betrag->waehrung); + $this->assertEquals('D', $umsatz->sollHabenKennzeichen); + $this->assertEquals(1.0, $umsatz->umrechnungskurs); + $this->assertEquals(12.34, $umsatz->ursprungsbetrag->wert); + $this->assertEquals('EUR', $umsatz->ursprungsbetrag->waehrung); + $this->assertEquals('1000000000000001', $umsatz->referenz); + $this->assertEquals('5411', $umsatz->branchenschluessel); + + // Only the first two of the nine Verwendungszweck fields are populated. + $this->assertEquals(['EXAMPLE SHOP', 'BERLIN 555500******2233'], $umsatz->getVerwendungszweckLines()); + $this->assertNull($umsatz->verwendungszweck3); + $this->assertNull($umsatz->verwendungszweck9); + } + + /** + * AqBanking documents the original amount as a duplicate of the billed amount and the exchange + * rate as always "1,". Both are wrong, which only shows on foreign currency transactions. + */ + public function testParsesForeignCurrencyRecord() + { + $dikku = DIKKUv2::parse(self::buildSegment(self::RECORD_FOREIGN_CURRENCY)); + $umsatz = $dikku->getUmsaetze()[0]; + + $this->assertEquals(50.0, $umsatz->ursprungsbetrag->wert); + $this->assertEquals('USD', $umsatz->ursprungsbetrag->waehrung); + $this->assertEquals(0.9, $umsatz->umrechnungskurs); + $this->assertEquals(45.0, $umsatz->betrag->wert); + $this->assertEquals('EUR', $umsatz->betrag->waehrung); + $this->assertEqualsWithDelta( + $umsatz->betrag->wert, + $umsatz->ursprungsbetrag->wert * $umsatz->umrechnungskurs, + 0.005 + ); + } + + public function testParsesRecordWithoutMerchantCategoryCode() + { + $dikku = DIKKUv2::parse(self::buildSegment(self::RECORD_SETTLEMENT)); + $umsatz = $dikku->getUmsaetze()[0]; + + $this->assertEquals('C', $umsatz->sollHabenKennzeichen); + $this->assertEquals(1000.0, $umsatz->betrag->wert); + $this->assertEquals('1000000000000003', $umsatz->referenz); + $this->assertNull($umsatz->branchenschluessel); + } + + public function testDecodesUmlautsFromIso8859() + { + $dikku = DIKKUv2::parse(self::buildSegment(self::RECORD_SECOND_CARD)); + $umsatz = $dikku->getUmsaetze()[0]; + + // The wire format is ISO-8859-1, the parser is expected to hand out UTF-8. + $this->assertEquals('Tübingen 544444******2244', $umsatz->verwendungszweck2); + } + + public function testParsesLegacyFormatRecord() + { + $dikku = DIKKUv2::parse(self::buildSegment(self::RECORD_LEGACY_FORMAT)); + $umsatz = $dikku->getUmsaetze()[0]; + + $this->assertEquals('5555000011112233', $umsatz->kontonummer); + $this->assertEquals('2026043055550000111122331', $umsatz->referenz); + $this->assertNull($umsatz->branchenschluessel); + } + + public function testParsesAllRecordsTogether() + { + $dikku = DIKKUv2::parse(self::buildSegment( + self::RECORD_DOMESTIC, + self::RECORD_FOREIGN_CURRENCY, + self::RECORD_SETTLEMENT, + self::RECORD_SECOND_CARD, + self::RECORD_LEGACY_FORMAT + )); + $this->assertCount(5, $dikku->getUmsaetze()); + } + + public function testMapsToModel() + { + $dikku = DIKKUv2::parse(self::buildSegment( + self::RECORD_DOMESTIC, + self::RECORD_FOREIGN_CURRENCY, + self::RECORD_SETTLEMENT + )); + $statement = CreditCardStatement::fromSegments([$dikku]); + + $this->assertEquals(self::ACCOUNT, $statement->getAccountNumber()); + $this->assertEquals(-1234.56, $statement->getBalance()); + $this->assertEquals('EUR', $statement->getBalanceCurrency()); + $this->assertCount(3, $statement->getTransactions()); + + [$domestic, $foreign, $settlement] = $statement->getTransactions(); + + $this->assertEquals(-12.34, $domestic->getAmount()); + $this->assertEquals(CreditCardTransaction::CD_DEBIT, $domestic->getCreditDebit()); + $this->assertEquals('2026-07-18', $domestic->getBookingDate()->format('Y-m-d')); + $this->assertEquals('2026-07-17', $domestic->getValutaDate()->format('Y-m-d')); + $this->assertEquals('EXAMPLE SHOP BERLIN 555500******2233', $domestic->getPurpose()); + $this->assertEquals('5411', $domestic->getMerchantCategoryCode()); + // No conversion took place, so no original amount is reported. + $this->assertNull($domestic->getOriginalAmount()); + $this->assertNull($domestic->getExchangeRate()); + + $this->assertEquals(-45.0, $foreign->getAmount()); + $this->assertEquals('EUR', $foreign->getCurrency()); + $this->assertEquals(-50.0, $foreign->getOriginalAmount()); + $this->assertEquals('USD', $foreign->getOriginalCurrency()); + $this->assertEquals(0.9, $foreign->getExchangeRate()); + + // A credit is positive and has no merchant category code. + $this->assertEquals(1000.0, $settlement->getAmount()); + $this->assertEquals(CreditCardTransaction::CD_CREDIT, $settlement->getCreditDebit()); + $this->assertNull($settlement->getMerchantCategoryCode()); + } + + public function testEmptyStatement() + { + $statement = CreditCardStatement::fromSegments([]); + $this->assertCount(0, $statement->getTransactions()); + $this->assertNull($statement->getBalance()); + $this->assertNull($statement->getAccountNumber()); + } +} diff --git a/src/Action/GetCreditCardAccounts.php b/src/Action/GetCreditCardAccounts.php new file mode 100644 index 00000000..b426aa0c --- /dev/null +++ b/src/Action/GetCreditCardAccounts.php @@ -0,0 +1,120 @@ +ensureDone(); + return $this->accounts; + } + + protected function createRequest(BPD $bpd, ?UPD $upd) + { + if ($upd === null) { + throw new \InvalidArgumentException('Cannot determine credit card accounts without UPD'); + } + + foreach ($upd->hiupd ?? [] as $hiupd) { + if (!self::isCreditCardAccount($hiupd)) { + continue; + } + $ktv = $hiupd->getKontoverbindung(); + if ($ktv === null || $ktv->kontonummer === null) { + continue; + } + $this->accounts[] = (new CreditCardAccount()) + ->setAccountNumber($ktv->kontonummer) + ->setSubAccount($ktv->unterkontomerkmal) + ->setBlz($ktv->kik->kreditinstitutscode ?? $bpd->getBankCode()) + ->setName(self::joinName($hiupd)) + ->setProductName($hiupd->getKontoproduktbezeichnung()) + ->setCurrency($hiupd->getKontowaehrung()) + ->setAccountType($hiupd->getKontoart()); + } + + // Everything was computed from the UPD, so no request to the server is necessary. + $this->isDone = true; + return []; + } + + /** Banks split the account holder name across two fields, e.g. "Mustermann" and "Max". */ + private static function joinName(HIUPD $hiupd): ?string + { + $parts = array_filter([$hiupd->getName1(), $hiupd->getName2()], function (?string $part) { + return $part !== null && $part !== ''; + }); + return count($parts) === 0 ? null : implode(' ', $parts); + } + + /** + * Prefers the explicit list of permitted business transactions, because that directly states + * whether credit card transactions can be retrieved for the account. Only if the bank does not + * send such a list do we fall back to the account type. + */ + private static function isCreditCardAccount(HIUPD $hiupd): bool + { + $erlaubteGeschaeftsvorfaelle = $hiupd->getErlaubteGeschaeftsvorfaelle(); + if (count($erlaubteGeschaeftsvorfaelle) > 0) { + foreach ($erlaubteGeschaeftsvorfaelle as $erlaubterGeschaeftsvorfall) { + if ($erlaubterGeschaeftsvorfall->getGeschaeftsvorfall() === self::REQUEST_NAME) { + return true; + } + } + return false; + } + + $kontoart = $hiupd->getKontoart(); + return $kontoart !== null + && $kontoart >= self::KONTOART_CREDIT_CARD_MIN + && $kontoart <= self::KONTOART_CREDIT_CARD_MAX; + } + + public function processResponse(Message $response) + { + throw new \AssertionError('GetCreditCardAccounts never sends a request'); + } +} diff --git a/src/Action/GetCreditCardStatement.php b/src/Action/GetCreditCardStatement.php new file mode 100644 index 00000000..1552d94f --- /dev/null +++ b/src/Action/GetCreditCardStatement.php @@ -0,0 +1,190 @@ +getAccountNumber() === null) { + throw new \InvalidArgumentException('The credit card account must have an account number'); + } + if ($from !== null && $to !== null && $from > $to) { + throw new \InvalidArgumentException('From-date must be before to-date'); + } + + $result = new GetCreditCardStatement(); + $result->account = $account; + $result->from = $from; + $result->to = $to; + 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, + ]; + } + + /** + * @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, + ) = $serialized; + + is_array($parentSerialized) ? + parent::__unserialize($parentSerialized) : + parent::unserialize($parentSerialized); + } + + public function getStatement(): CreditCardStatement + { + $this->ensureDone(); + return $this->statement; + } + + protected function createRequest(BPD $bpd, ?UPD $upd) + { + /** @var DIKKUS $dikkus */ + $dikkus = $bpd->requireLatestSupportedParameters('DIKKUS'); + $this->validateDateRange($dikkus); + switch ($dikkus->getVersion()) { + case 2: + // AqBanking sends a Kontoverbindung (ktv) plus a standalone card number. The + // Kontoverbindung is taken verbatim from the UPD (via GetCreditCardAccounts), so it + // matches exactly what the bank told us about this account. + $kontoverbindung = KtvV3::create( + $this->account->getAccountNumber(), + $this->account->getSubAccount(), + Kik::create($this->account->getBlz() ?? $bpd->getBankCode()) + ); + return DKKKUv2::create($kontoverbindung, $this->account->getAccountNumber(), $this->from, $this->to); + default: + throw new UnsupportedException('Unsupported DKKKU version: ' . $dikkus->getVersion()); + } + } + + /** + * Limits how much a single request may ask for, using the period the bank announces in the + * DIKKUS parameters. + * + * That period is specified as the time for which the bank keeps transactions, but the tested + * bank served data well beyond it, so it is not a hard boundary there. What did break was asking + * for too much at once: a range twice the announced period was served in full on one attempt and + * answered with an empty page plus a pagination token on the next, and a range eight times as + * long came back silently truncated. Ranges within the announced period were always answered + * consistently. The limit is therefore applied to the span of the request rather than to how far + * back it reaches, which also leaves callers the option of walking through older data window by + * window. + * + * @throws \InvalidArgumentException If the requested range spans more than the announced period. + */ + private function validateDateRange(DIKKUS $dikkus): void + { + $speicherzeitraum = $dikkus->getParameter()->speicherzeitraum; + if ($this->from === null || $speicherzeitraum <= 0) { + return; // Without a from-date the bank decides how much to return. + } + $to = $this->to ?? new \DateTime(); + $requestedDays = (int) $this->from->diff($to)->days; + if ($requestedDays > $speicherzeitraum) { + throw new \InvalidArgumentException(sprintf( + 'The bank answers requests spanning more than %d days inconsistently, but the requested range spans %d days (%s to %s). Fetch older transactions in separate requests of at most %d days each.', + $speicherzeitraum, + $requestedDays, + $this->from->format('Y-m-d'), + $to->format('Y-m-d'), + $speicherzeitraum + )); + } + } + + public function processResponse(Message $response) + { + parent::processResponse($response); + + // Banks send just 3010 and no DIKKU in case there are no transactions. + $isUnavailable = $response->findRueckmeldung(Rueckmeldungscode::NICHT_VERFUEGBAR) !== null; + if (!$isUnavailable) { + /** @var DIKKU $segment */ + foreach ($response->findSegments(DIKKU::class) as $segment) { + $this->responseSegments[] = $segment; + } + } + + // Note: Pagination boundaries may cut between records, so only build the result once all pages + // have been received. + if (!$this->hasMorePages()) { + $this->statement = CreditCardStatement::fromSegments($this->responseSegments); + } + } +} diff --git a/src/Model/CreditCardAccount.php b/src/Model/CreditCardAccount.php new file mode 100644 index 00000000..a3bfbf38 --- /dev/null +++ b/src/Model/CreditCardAccount.php @@ -0,0 +1,112 @@ +accountNumber; + } + + public function setAccountNumber(?string $accountNumber): static + { + $this->accountNumber = $accountNumber; + return $this; + } + + public function getSubAccount(): ?string + { + return $this->subAccount; + } + + public function setSubAccount(?string $subAccount): static + { + $this->subAccount = $subAccount; + return $this; + } + + public function getBlz(): ?string + { + return $this->blz; + } + + public function setBlz(?string $blz): static + { + $this->blz = $blz; + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): static + { + $this->name = $name; + return $this; + } + + public function getProductName(): ?string + { + return $this->productName; + } + + public function setProductName(?string $productName): static + { + $this->productName = $productName; + return $this; + } + + public function getCurrency(): ?string + { + return $this->currency; + } + + public function setCurrency(?string $currency): static + { + $this->currency = $currency; + return $this; + } + + public function getAccountType(): ?int + { + return $this->accountType; + } + + public function setAccountType(?int $accountType): static + { + $this->accountType = $accountType; + return $this; + } +} diff --git a/src/Model/CreditCardStatement/CreditCardStatement.php b/src/Model/CreditCardStatement/CreditCardStatement.php new file mode 100644 index 00000000..d700ed84 --- /dev/null +++ b/src/Model/CreditCardStatement/CreditCardStatement.php @@ -0,0 +1,76 @@ +transactions; + } + + public function addTransaction(CreditCardTransaction $transaction): static + { + $this->transactions[] = $transaction; + return $this; + } + + public function getAccountNumber(): ?string + { + return $this->accountNumber; + } + + /** @return float|null The booked balance, if the bank reported one. */ + public function getBalance(): ?float + { + return $this->balance; + } + + public function getBalanceCurrency(): ?string + { + return $this->balanceCurrency; + } + + public function getBalanceDate(): ?\DateTime + { + return $this->balanceDate; + } + + /** + * @param DIKKU[] $segments The DIKKU response segments (one per request segment / page). + */ + public static function fromSegments(array $segments): CreditCardStatement + { + $result = new CreditCardStatement(); + foreach ($segments as $segment) { + if ($result->accountNumber === null) { + $result->accountNumber = $segment->getKontonummer(); + } + $saldo = $segment->getSaldo(); + if ($saldo !== null && $result->balance === null) { + $result->balance = $saldo->getAmount(); + $result->balanceCurrency = $saldo->getCurrency(); + $result->balanceDate = $saldo->getTimestamp(); + } + foreach ($segment->getUmsaetze() as $umsatz) { + $result->addTransaction(CreditCardTransaction::fromSegment($umsatz)); + } + } + return $result; + } +} diff --git a/src/Model/CreditCardStatement/CreditCardTransaction.php b/src/Model/CreditCardStatement/CreditCardTransaction.php new file mode 100644 index 00000000..382f8f1a --- /dev/null +++ b/src/Model/CreditCardStatement/CreditCardTransaction.php @@ -0,0 +1,216 @@ +accountNumber; + } + + public function setAccountNumber(?string $accountNumber): static + { + $this->accountNumber = $accountNumber; + return $this; + } + + public function getValutaDate(): ?\DateTime + { + return $this->valutaDate; + } + + public function setValutaDate(?\DateTime $valutaDate): static + { + $this->valutaDate = $valutaDate; + return $this; + } + + public function getBookingDate(): ?\DateTime + { + return $this->bookingDate; + } + + public function setBookingDate(?\DateTime $bookingDate): static + { + $this->bookingDate = $bookingDate; + return $this; + } + + public function getAmount(): float + { + return $this->amount; + } + + public function setAmount(float $amount): static + { + $this->amount = $amount; + return $this; + } + + public function getCreditDebit(): string + { + return $this->creditDebit; + } + + public function setCreditDebit(string $creditDebit): static + { + $this->creditDebit = $creditDebit; + return $this; + } + + public function getCurrency(): string + { + return $this->currency; + } + + public function setCurrency(string $currency): static + { + $this->currency = $currency; + return $this; + } + + public function getPurpose(): string + { + return $this->purpose; + } + + public function setPurpose(string $purpose): static + { + $this->purpose = $purpose; + return $this; + } + + public function getReference(): ?string + { + return $this->reference; + } + + public function setReference(?string $reference): static + { + $this->reference = $reference; + return $this; + } + + /** @return float|null The signed amount the merchant charged, if a currency conversion took place. */ + public function getOriginalAmount(): ?float + { + return $this->originalAmount; + } + + public function setOriginalAmount(?float $originalAmount): static + { + $this->originalAmount = $originalAmount; + return $this; + } + + public function getOriginalCurrency(): ?string + { + return $this->originalCurrency; + } + + public function setOriginalCurrency(?string $originalCurrency): static + { + $this->originalCurrency = $originalCurrency; + return $this; + } + + /** @return float|null The exchange rate applied, or null if no conversion took place. */ + public function getExchangeRate(): ?float + { + return $this->exchangeRate; + } + + public function setExchangeRate(?float $exchangeRate): static + { + $this->exchangeRate = $exchangeRate; + return $this; + } + + /** @return string|null The ISO 18245 merchant category code, e.g. 5411 for grocery stores. */ + public function getMerchantCategoryCode(): ?string + { + return $this->merchantCategoryCode; + } + + public function setMerchantCategoryCode(?string $merchantCategoryCode): static + { + $this->merchantCategoryCode = $merchantCategoryCode; + return $this; + } + + /** + * Builds a model transaction from a parsed {@link Kreditkartenumsatz} record. + */ + public static function fromSegment(Kreditkartenumsatz $umsatz): CreditCardTransaction + { + $result = new CreditCardTransaction(); + $result->accountNumber = $umsatz->kontonummer; + $result->valutaDate = self::parseDate($umsatz->belegdatum); + $result->bookingDate = self::parseDate($umsatz->buchungsdatum); + + // $betrag is the amount actually billed to the account, in the account's currency. + $isDebit = self::isDebit($umsatz->sollHabenKennzeichen); + $result->creditDebit = $isDebit ? self::CD_DEBIT : self::CD_CREDIT; + $result->amount = ($isDebit ? -1 : 1) * abs($umsatz->betrag->wert); + $result->currency = $umsatz->betrag->waehrung; + + // Only report the original amount when a conversion actually took place, so that callers can + // simply check for null instead of comparing amounts. + if ($umsatz->ursprungsbetrag->waehrung !== $umsatz->betrag->waehrung) { + $originalIsDebit = self::isDebit($umsatz->ursprungsSollHabenKennzeichen); + $result->originalAmount = ($originalIsDebit ? -1 : 1) * abs($umsatz->ursprungsbetrag->wert); + $result->originalCurrency = $umsatz->ursprungsbetrag->waehrung; + $result->exchangeRate = $umsatz->umrechnungskurs; + } + + $result->purpose = trim(implode(' ', $umsatz->getVerwendungszweckLines())); + $result->reference = $umsatz->referenz; + $result->merchantCategoryCode = $umsatz->branchenschluessel; + return $result; + } + + /** Banks use 'D'/'C' (Debit/Credit); 'S'/'H' (Soll/Haben) is accepted as well. */ + private static function isDebit(string $sollHabenKennzeichen): bool + { + return in_array(strtoupper($sollHabenKennzeichen), ['D', 'S'], true); + } + + private static function parseDate(?string $date): ?\DateTime + { + if ($date === null || $date === '') { + return null; + } + $parsed = \DateTime::createFromFormat('Ymd', $date); + return $parsed === false ? null : $parsed->setTime(0, 0); + } +} diff --git a/src/Segment/HIUPD/HIUPD.php b/src/Segment/HIUPD/HIUPD.php index aaa54eee..5666071d 100644 --- a/src/Segment/HIUPD/HIUPD.php +++ b/src/Segment/HIUPD/HIUPD.php @@ -3,6 +3,7 @@ namespace Fhp\Segment\HIUPD; use Fhp\Model\SEPAAccount; +use Fhp\Segment\Common\KtvV3; /** * Segment: Kontoinformation @@ -21,4 +22,27 @@ public function matchesAccount(SEPAAccount $account): bool; * @return ErlaubteGeschaeftsvorfaelle[] */ public function getErlaubteGeschaeftsvorfaelle(): array; + + /** @return KtvV3|null The account this segment describes. */ + public function getKontoverbindung(): ?KtvV3; + + /** + * @return int|null The account type, if the bank reported one (not available before HIUPD v6): + * 1-9 Kontokorrent/Giro, 10-19 Spar, 20-29 Festgeld, 30-39 Wertpapierdepot, + * 40-49 Kredit/Darlehen, 50-59 Kreditkarte, 60-69 Fonds-Depot, 70-79 Bauspar, + * 80-89 Versicherung, 90-99 Sonstige. + */ + public function getKontoart(): ?int; + + /** @return string|null The account holder name (usually the surname). */ + public function getName1(): ?string; + + /** @return string|null A second account holder name part (usually the given name). */ + public function getName2(): ?string; + + /** @return string|null The bank's product name for this account. */ + public function getKontoproduktbezeichnung(): ?string; + + /** @return string|null The account currency. */ + public function getKontowaehrung(): ?string; } diff --git a/src/Segment/HIUPD/HIUPDv4.php b/src/Segment/HIUPD/HIUPDv4.php index 4429fddc..ebbac888 100644 --- a/src/Segment/HIUPD/HIUPDv4.php +++ b/src/Segment/HIUPD/HIUPDv4.php @@ -39,4 +39,35 @@ public function getErlaubteGeschaeftsvorfaelle(): array { return $this->erlaubteGeschaeftsvorfaelle ?? []; } + + public function getKontoverbindung(): ?\Fhp\Segment\Common\KtvV3 + { + return $this->kontoverbindung; + } + + /** This segment version does not carry the account type yet. */ + public function getKontoart(): ?int + { + return null; + } + + public function getName1(): ?string + { + return $this->name1; + } + + public function getName2(): ?string + { + return $this->name2; + } + + public function getKontoproduktbezeichnung(): ?string + { + return $this->kontoproduktbezeichnung; + } + + public function getKontowaehrung(): ?string + { + return $this->kontowaehrung; + } } diff --git a/src/Segment/HIUPD/HIUPDv6.php b/src/Segment/HIUPD/HIUPDv6.php index 3ecd9d6f..b0620c39 100644 --- a/src/Segment/HIUPD/HIUPDv6.php +++ b/src/Segment/HIUPD/HIUPDv6.php @@ -64,4 +64,34 @@ public function getErlaubteGeschaeftsvorfaelle(): array { return $this->erlaubteGeschaeftsvorfaelle ?? []; } + + public function getKontoverbindung(): ?\Fhp\Segment\Common\KtvV3 + { + return $this->kontoverbindung; + } + + public function getKontoart(): ?int + { + return $this->kontoart; + } + + public function getName1(): ?string + { + return $this->name1; + } + + public function getName2(): ?string + { + return $this->name2; + } + + public function getKontoproduktbezeichnung(): ?string + { + return $this->kontoproduktbezeichnung; + } + + public function getKontowaehrung(): ?string + { + return $this->kontowaehrung; + } } diff --git a/src/Segment/KKU/DIKKU.php b/src/Segment/KKU/DIKKU.php new file mode 100644 index 00000000..56efb7cf --- /dev/null +++ b/src/Segment/KKU/DIKKU.php @@ -0,0 +1,23 @@ +parameter; + } +} diff --git a/src/Segment/KKU/DIKKUv2.php b/src/Segment/KKU/DIKKUv2.php new file mode 100644 index 00000000..f89407c9 --- /dev/null +++ b/src/Segment/KKU/DIKKUv2.php @@ -0,0 +1,50 @@ +kontonummer; + } + + public function getSaldo(): ?Sdo + { + return $this->saldo; + } + + public function getUmsaetze(): array + { + return $this->umsaetze ?? []; + } +} diff --git a/src/Segment/KKU/DKKKUv2.php b/src/Segment/KKU/DKKKUv2.php new file mode 100644 index 00000000..4e75673d --- /dev/null +++ b/src/Segment/KKU/DKKKUv2.php @@ -0,0 +1,82 @@ +,,"), and it rejected a follow-up that omitted + * {@link $maximaleAnzahlEintraege} with "9110 Ungueltige Auftragsnachricht: Unbekannter Aufbau". + * That the layout below is accepted could not be confirmed yet, because the bank did not + * paginate again on any later attempt with the same query. + */ + public ?string $aufsetzpunkt = null; + + public static function create(KtvV3 $kontoverbindung, string $kontonummer, ?\DateTime $vonDatum, ?\DateTime $bisDatum, ?string $aufsetzpunkt = null): DKKKUv2 + { + $result = DKKKUv2::createEmpty(); + $result->kontoverbindung = $kontoverbindung; + $result->kontonummer = $kontonummer; + $result->vonDatum = $vonDatum?->format('Ymd'); + $result->bisDatum = $bisDatum?->format('Ymd'); + $result->aufsetzpunkt = $aufsetzpunkt; + return $result; + } + + public function setPaginationToken(string $paginationToken) + { + $this->aufsetzpunkt = $paginationToken; + } +} diff --git a/src/Segment/KKU/Kreditkartenumsatz.php b/src/Segment/KKU/Kreditkartenumsatz.php new file mode 100644 index 00000000..83df1aaf --- /dev/null +++ b/src/Segment/KKU/Kreditkartenumsatz.php @@ -0,0 +1,100 @@ +verwendungszweck1, $this->verwendungszweck2, $this->verwendungszweck3, + $this->verwendungszweck4, $this->verwendungszweck5, $this->verwendungszweck6, + $this->verwendungszweck7, $this->verwendungszweck8, $this->verwendungszweck9, + ]; + return array_values(array_filter($lines, fn ($line) => $line !== null && $line !== '')); + } +} diff --git a/src/Segment/KKU/ParameterKreditkartenumsaetze.php b/src/Segment/KKU/ParameterKreditkartenumsaetze.php new file mode 100644 index 00000000..7582ba2b --- /dev/null +++ b/src/Segment/KKU/ParameterKreditkartenumsaetze.php @@ -0,0 +1,22 @@ +