Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
65 changes: 65 additions & 0 deletions Samples/creditCardStatement.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

/** @noinspection PhpUnhandledExceptionInspection */

/**
* SAMPLE - Displays the credit card transactions for a specific time range.
*
* Credit card accounts have no IBAN, so they are not part of getSEPAAccounts() and their transactions
* cannot be retrieved with GetStatementOfAccount. They use the DKKKU business transaction instead,
* which is defined by the Deutsche Kreditwirtschaft rather than by the FinTS specification, so not
* every bank offers it.
*/

// See login.php, it returns a FinTs instance that is already logged in.
/** @var \Fhp\FinTs $fints */
$fints = require_once 'login.php';

// The credit card accounts are described in the UPD, which arrived during login, so this needs no
// request to the bank at all.
$getCreditCardAccounts = \Fhp\Action\GetCreditCardAccounts::create();
$fints->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;
128 changes: 128 additions & 0 deletions Tests/Unit/Action/GetCreditCardAccountsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

namespace Fhp\Tests\Unit\Action;

use Fhp\Action\GetCreditCardAccounts;
use Fhp\Protocol\BPD;
use Fhp\Protocol\UPD;
use Fhp\Segment\BaseSegment;

/**
* Tests that credit card accounts are recognized in the UPD.
*
* The HIUPD segments below mirror the structure of a real BW-Bank/LBBW response, with all account
* numbers and names replaced. Note that the credit card account has no IBAN, which is why such
* accounts are absent from HKSPA and have to be found here instead.
*/
class GetCreditCardAccountsTest extends \PHPUnit\Framework\TestCase
{
/** Account type 50 (credit card), no IBAN, DKKKU among the permitted business transactions. */
private const HIUPD_CREDIT_CARD =
'HIUPD:12:6:4+5555000011112222::280:60050101++9999999999+50+EUR+Mustermann+Max+Example Goldcard Set++HKSAK:1+DKKKS:1+DKKKU:1+HKTAN:1\'';

/** An ordinary current account: has an IBAN, permits HKKAZ but not DKKKU. */
private const HIUPD_CURRENT_ACCOUNT =
'HIUPD:10:6:4+1234567890::280:60050101+DE02600501011234567890+9999999999+1+EUR+Mustermann+Max+Example Giro++HKSAK:1+HKKAZ:1+HKSAL:1\'';

/** A savings account, likewise without DKKKU. */
private const HIUPD_SAVINGS_ACCOUNT =
'HIUPD:11:6:4+2000130538::280:60050101+DE33600501012000130538+9999999999+10+EUR+Mustermann+Max+Example Sparkonto++HKSPA:1+HKKAZ:1\'';

/** @param string[] $rawHiupds */
private static function runAction(array $rawHiupds): GetCreditCardAccounts
{
$upd = new UPD();
$upd->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);
}
}
Loading