From 53073fadbd31eb25677574dc1c21aa8d57b4875c Mon Sep 17 00:00:00 2001 From: jwtue Date: Sun, 19 Jul 2026 10:32:58 +0200 Subject: [PATCH 01/15] Add DKKKU credit card statement retrieval Credit card accounts have no IBAN and can therefore not be queried through HKKAZ or HKCAZ. This adds the "DK" business transaction DKKKU, which is offered by the Sparkassen-Finanzgruppe (incl. BW-Bank/LBBW): - Segments in Fhp\Segment\KKU: DKKKUv2 (request, paginateable), DIKKU + DIKKUv2 (response) with the Kreditkartenumsatz record DEG, and DIKKUS + DIKKUSv2 (BPD parameters). - New action Fhp\Action\GetCreditCardStatement, which takes a card number (user input, not from GetSEPAAccounts) and a date range. - Dedicated result models CreditCardStatement / CreditCardTransaction, since credit card records are flat and do not fit the MT940-shaped StatementOfAccount model. There is no official specification for DKKKU. The wire format is derived from AqBanking's declarative segment definitions (SEGdef GetTransactionsCreditCard / TransactionsCreditCard, GROUPdef creditcardtransaction). Note the documented quirk that value2 is zero for weekly statements, so the amount is always taken from value. Fields that still need to be confirmed against a real bank response are marked with TODO(BW-Bank). --- src/Action/GetCreditCardStatement.php | 141 +++++++++++++++++ .../CreditCardStatement.php | 76 +++++++++ .../CreditCardTransaction.php | 146 ++++++++++++++++++ src/Segment/KKU/DIKKU.php | 23 +++ src/Segment/KKU/DIKKUS.php | 16 ++ src/Segment/KKU/DIKKUSv2.php | 20 +++ src/Segment/KKU/DIKKUv2.php | 50 ++++++ src/Segment/KKU/DKKKUv2.php | 53 +++++++ src/Segment/KKU/Kreditkartenumsatz.php | 81 ++++++++++ 9 files changed, 606 insertions(+) create mode 100644 src/Action/GetCreditCardStatement.php create mode 100644 src/Model/CreditCardStatement/CreditCardStatement.php create mode 100644 src/Model/CreditCardStatement/CreditCardTransaction.php create mode 100644 src/Segment/KKU/DIKKU.php create mode 100644 src/Segment/KKU/DIKKUS.php create mode 100644 src/Segment/KKU/DIKKUSv2.php create mode 100644 src/Segment/KKU/DIKKUv2.php create mode 100644 src/Segment/KKU/DKKKUv2.php create mode 100644 src/Segment/KKU/Kreditkartenumsatz.php diff --git a/src/Action/GetCreditCardStatement.php b/src/Action/GetCreditCardStatement.php new file mode 100644 index 00000000..44c1e953 --- /dev/null +++ b/src/Action/GetCreditCardStatement.php @@ -0,0 +1,141 @@ + $to) { + throw new \InvalidArgumentException('From-date must be before to-date'); + } + + $result = new GetCreditCardStatement(); + $result->cardNumber = $cardNumber; + $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->cardNumber, $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->cardNumber, $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'); + switch ($dikkus->getVersion()) { + case 2: + // TODO(BW-Bank): Verify the account identification. AqBanking sends a Kontoverbindung + // (ktv) plus a standalone card number. We build the ktv from the bank code and the + // card number; confirm against a real request that this is what the bank expects. + $kontoverbindung = KtvV3::create($this->cardNumber, null, Kik::create($bpd->getBankCode())); + return DKKKUv2::create($kontoverbindung, $this->cardNumber, $this->from, $this->to); + default: + throw new UnsupportedException('Unsupported DKKKU version: ' . $dikkus->getVersion()); + } + } + + 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/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..120b6a6e --- /dev/null +++ b/src/Model/CreditCardStatement/CreditCardTransaction.php @@ -0,0 +1,146 @@ +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; + } + + /** + * 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); + + // Always use $betrag (value); $betrag2 (value2) is zero for weekly statements. + $isDebit = in_array(strtoupper($umsatz->sollHabenKennzeichen), ['S', 'D'], true); + $result->creditDebit = $isDebit ? self::CD_DEBIT : self::CD_CREDIT; + $result->amount = ($isDebit ? -1 : 1) * abs($umsatz->betrag->wert); + $result->currency = $umsatz->betrag->waehrung; + + $result->purpose = trim(implode(' ', $umsatz->getVerwendungszweckLines())); + $result->reference = $umsatz->referenz; + return $result; + } + + 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/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 @@ +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..9434550c --- /dev/null +++ b/src/Segment/KKU/DKKKUv2.php @@ -0,0 +1,53 @@ +kontoverbindung = $kontoverbindung; + $result->kartennummer = $kartennummer; + $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..f3c50370 --- /dev/null +++ b/src/Segment/KKU/Kreditkartenumsatz.php @@ -0,0 +1,81 @@ +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 !== '')); + } +} From 3269c92c9265a8abcb3a5609ed1ee9c6e8079e16 Mon Sep 17 00:00:00 2001 From: jwtue Date: Sun, 19 Jul 2026 10:38:29 +0200 Subject: [PATCH 02/15] Add the DIKKUS parameter DEG based on a real BW-Bank BPD The BPD of BW-Bank/LBBW (bank code 60050101) advertises DIKKUS:45:2:3+1+1+0+90:N:J so besides the three base Geschaeftsvorfallparameter the segment carries a transaction-specific parameter group with the same three fields that the regular account statement transaction uses: Speicherzeitraum (90 days), "Eingabe Anzahl Eintraege erlaubt" (N) and "alle Konten erlaubt" (J). Without this DEG the segment failed to parse with "Too many elements", which aborted the parsing of the entire BPD message. Note that an incompletely modelled segment is worse than none at all, because detectAndParseSegment() does not fall back to AnonymousSegment on a parse error. --- src/Segment/KKU/DIKKUS.php | 4 ++-- src/Segment/KKU/DIKKUSv2.php | 14 +++++++------ .../KKU/ParameterKreditkartenumsaetze.php | 21 +++++++++++++++++++ 3 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 src/Segment/KKU/ParameterKreditkartenumsaetze.php diff --git a/src/Segment/KKU/DIKKUS.php b/src/Segment/KKU/DIKKUS.php index f7aa19f3..539f2c6f 100644 --- a/src/Segment/KKU/DIKKUS.php +++ b/src/Segment/KKU/DIKKUS.php @@ -8,9 +8,9 @@ * Segment: Kreditkartenumsätze Parameter * * BPD parameter segment for the DKKKU business transaction. Its presence in the BPD indicates that - * the bank supports credit card statement retrieval. No transaction-specific parameters beyond the - * base {@link \Fhp\Segment\BaseGeschaeftsvorfallparameter} are known. + * the bank supports credit card statement retrieval. */ interface DIKKUS extends SegmentInterface { + public function getParameter(): ParameterKreditkartenumsaetze; } diff --git a/src/Segment/KKU/DIKKUSv2.php b/src/Segment/KKU/DIKKUSv2.php index 9e2cf8f7..2ca2d76d 100644 --- a/src/Segment/KKU/DIKKUSv2.php +++ b/src/Segment/KKU/DIKKUSv2.php @@ -8,13 +8,15 @@ /** * Segment: Kreditkartenumsätze Parameter (Version 2) * - * AqBanking does not define a params segment for DKKKU, so this carries only the base - * Geschäftsvorfallparameter (maximaleAnzahlAuftraege, anzahlSignaturenMindestens, sicherheitsklasse). - * - * TODO(BW-Bank): Verify against the real BPD. If the bank sends additional parameter fields, add a - * dedicated parameter DEG here; if it uses the FinTS 2.2 format (without sicherheitsklasse), switch - * the base class to {@link \Fhp\Segment\BaseGeschaeftsvorfallparameterOld}. + * AqBanking does not define a params segment for DKKKU. The layout was derived from a real BW-Bank + * BPD, which sends `DIKKUS:45:2:3+1+1+0+90:N:J`. */ class DIKKUSv2 extends BaseGeschaeftsvorfallparameter implements DIKKUS { + public ParameterKreditkartenumsaetze $parameter; + + public function getParameter(): ParameterKreditkartenumsaetze + { + return $this->parameter; + } } diff --git a/src/Segment/KKU/ParameterKreditkartenumsaetze.php b/src/Segment/KKU/ParameterKreditkartenumsaetze.php new file mode 100644 index 00000000..6352de82 --- /dev/null +++ b/src/Segment/KKU/ParameterKreditkartenumsaetze.php @@ -0,0 +1,21 @@ + Date: Sun, 19 Jul 2026 10:50:14 +0200 Subject: [PATCH 03/15] Discover credit card accounts from the UPD Requiring the user to pass a card number was impractical: online banking masks the middle digits of the number, and a single credit card account can hold several cards with different numbers. The UPD already describes every account the user has access to, including credit card accounts (which HKSPA omits because they have no IBAN). This adds GetCreditCardAccounts, which extracts them from the HIUPD segments. It needs no request to the bank at all, because the UPD arrives during dialog initialization. An account qualifies if its list of permitted business transactions contains DKKKU. That is more precise than looking at the account type and works for HIUPD v4 as well, which does not report an account type. Only when the bank sends no such list do we fall back to the account type (50-59). GetCreditCardStatement now takes a CreditCardAccount instead of a card number string, which also means the Kontoverbindung sent in the DKKKU request is taken verbatim from the UPD rather than being reconstructed from the bank code. To support this, HIUPD gains accessors for the account, account type, holder name, product name and currency, implemented in both v4 and v6. --- src/Action/GetCreditCardAccounts.php | 102 +++++++++++++++++++++++++ src/Action/GetCreditCardStatement.php | 36 +++++---- src/Model/CreditCardAccount.php | 104 ++++++++++++++++++++++++++ src/Segment/HIUPD/HIUPD.php | 21 ++++++ src/Segment/HIUPD/HIUPDv4.php | 26 +++++++ src/Segment/HIUPD/HIUPDv6.php | 25 +++++++ 6 files changed, 301 insertions(+), 13 deletions(-) create mode 100644 src/Action/GetCreditCardAccounts.php create mode 100644 src/Model/CreditCardAccount.php diff --git a/src/Action/GetCreditCardAccounts.php b/src/Action/GetCreditCardAccounts.php new file mode 100644 index 00000000..7ed79ba8 --- /dev/null +++ b/src/Action/GetCreditCardAccounts.php @@ -0,0 +1,102 @@ +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()) + ->setCardNumber($ktv->kontonummer) + ->setSubAccount($ktv->unterkontomerkmal) + ->setBlz($ktv->kik->kreditinstitutscode ?? $bpd->getBankCode()) + ->setName($hiupd->getName1()) + ->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 []; + } + + /** + * 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 index 44c1e953..21f3f68f 100644 --- a/src/Action/GetCreditCardStatement.php +++ b/src/Action/GetCreditCardStatement.php @@ -2,6 +2,7 @@ namespace Fhp\Action; +use Fhp\Model\CreditCardAccount; use Fhp\Model\CreditCardStatement\CreditCardStatement; use Fhp\PaginateableAction; use Fhp\Protocol\BPD; @@ -21,13 +22,14 @@ * LBBW). Credit card accounts have no IBAN and can therefore not be queried through * {@link GetStatementOfAccount} (HKKAZ) or {@link GetStatementOfAccountXML} (HKCAZ). * - * The card number is user input; it does NOT come from {@link GetSEPAAccounts}. + * The account to query is obtained from {@link GetCreditCardAccounts}, which reads it from the UPD. + * Credit card accounts are NOT part of {@link GetSEPAAccounts}, because they have no IBAN. */ class GetCreditCardStatement extends PaginateableAction { // Request (if you add a field here, update __serialize() and __unserialize() as well). - /** @var string */ - private $cardNumber; + /** @var CreditCardAccount */ + private $account; /** @var \DateTime|null */ private $from; /** @var \DateTime|null */ @@ -40,19 +42,23 @@ class GetCreditCardStatement extends PaginateableAction private $statement; /** - * @param string $cardNumber The credit card number to get the transactions for (user input). + * @param CreditCardAccount $account The credit card account to get the transactions for, as + * obtained from {@link GetCreditCardAccounts}. * @param \DateTime|null $from If set, only transactions after this date (inclusive) are returned. * @param \DateTime|null $to If set, only transactions before this date (inclusive) are returned. * @return GetCreditCardStatement A new action instance. */ - public static function create(string $cardNumber, ?\DateTime $from = null, ?\DateTime $to = null): GetCreditCardStatement + public static function create(CreditCardAccount $account, ?\DateTime $from = null, ?\DateTime $to = null): GetCreditCardStatement { + if ($account->getCardNumber() === null) { + throw new \InvalidArgumentException('The credit card account must have a card number'); + } if ($from !== null && $to !== null && $from > $to) { throw new \InvalidArgumentException('From-date must be before to-date'); } $result = new GetCreditCardStatement(); - $result->cardNumber = $cardNumber; + $result->account = $account; $result->from = $from; $result->to = $to; return $result; @@ -70,7 +76,7 @@ public function __serialize(): array { return [ parent::__serialize(), - $this->cardNumber, $this->from, $this->to, + $this->account, $this->from, $this->to, ]; } @@ -89,7 +95,7 @@ public function __unserialize(array $serialized): void { list( $parentSerialized, - $this->cardNumber, $this->from, $this->to, + $this->account, $this->from, $this->to, ) = $serialized; is_array($parentSerialized) ? @@ -109,11 +115,15 @@ protected function createRequest(BPD $bpd, ?UPD $upd) $dikkus = $bpd->requireLatestSupportedParameters('DIKKUS'); switch ($dikkus->getVersion()) { case 2: - // TODO(BW-Bank): Verify the account identification. AqBanking sends a Kontoverbindung - // (ktv) plus a standalone card number. We build the ktv from the bank code and the - // card number; confirm against a real request that this is what the bank expects. - $kontoverbindung = KtvV3::create($this->cardNumber, null, Kik::create($bpd->getBankCode())); - return DKKKUv2::create($kontoverbindung, $this->cardNumber, $this->from, $this->to); + // 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->getCardNumber(), + $this->account->getSubAccount(), + Kik::create($this->account->getBlz() ?? $bpd->getBankCode()) + ); + return DKKKUv2::create($kontoverbindung, $this->account->getCardNumber(), $this->from, $this->to); default: throw new UnsupportedException('Unsupported DKKKU version: ' . $dikkus->getVersion()); } diff --git a/src/Model/CreditCardAccount.php b/src/Model/CreditCardAccount.php new file mode 100644 index 00000000..600f98cb --- /dev/null +++ b/src/Model/CreditCardAccount.php @@ -0,0 +1,104 @@ +cardNumber; + } + + public function setCardNumber(?string $cardNumber): static + { + $this->cardNumber = $cardNumber; + 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/Segment/HIUPD/HIUPD.php b/src/Segment/HIUPD/HIUPD.php index aaa54eee..e3629a57 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,24 @@ 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. */ + public function getName1(): ?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..56d3fdb8 100644 --- a/src/Segment/HIUPD/HIUPDv4.php +++ b/src/Segment/HIUPD/HIUPDv4.php @@ -39,4 +39,30 @@ 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 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..49506730 100644 --- a/src/Segment/HIUPD/HIUPDv6.php +++ b/src/Segment/HIUPD/HIUPDv6.php @@ -64,4 +64,29 @@ 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 getKontoproduktbezeichnung(): ?string + { + return $this->kontoproduktbezeichnung; + } + + public function getKontowaehrung(): ?string + { + return $this->kontowaehrung; + } } From 3573f13543ccb29d17b57cdd0fafc2afa8470d4e Mon Sep 17 00:00:00 2001 From: jwtue Date: Sun, 19 Jul 2026 11:15:56 +0200 Subject: [PATCH 04/15] Report the full account holder name for credit card accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Banks split the holder name across name1 and name2 (surname and given name), so reporting only name1 yields a misleading "Wolf" instead of "Wolf Jonas". Verified against a real BW-Bank UPD, which describes the credit card account as HIUPD:11:6:3+4563624488337000::280:60050101+++50+EUR+++SPECIAL Goldcard Set++...+DKKKU:1+... i.e. account type 50, no IBAN, and DKKKU among the permitted business transactions — which is exactly what GetCreditCardAccounts keys on. --- src/Action/GetCreditCardAccounts.php | 11 ++++++++++- src/Segment/HIUPD/HIUPD.php | 5 ++++- src/Segment/HIUPD/HIUPDv4.php | 5 +++++ src/Segment/HIUPD/HIUPDv6.php | 5 +++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Action/GetCreditCardAccounts.php b/src/Action/GetCreditCardAccounts.php index 7ed79ba8..a3fe747f 100644 --- a/src/Action/GetCreditCardAccounts.php +++ b/src/Action/GetCreditCardAccounts.php @@ -61,7 +61,7 @@ protected function createRequest(BPD $bpd, ?UPD $upd) ->setCardNumber($ktv->kontonummer) ->setSubAccount($ktv->unterkontomerkmal) ->setBlz($ktv->kik->kreditinstitutscode ?? $bpd->getBankCode()) - ->setName($hiupd->getName1()) + ->setName(self::joinName($hiupd)) ->setProductName($hiupd->getKontoproduktbezeichnung()) ->setCurrency($hiupd->getKontowaehrung()) ->setAccountType($hiupd->getKontoart()); @@ -72,6 +72,15 @@ protected function createRequest(BPD $bpd, ?UPD $upd) 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 diff --git a/src/Segment/HIUPD/HIUPD.php b/src/Segment/HIUPD/HIUPD.php index e3629a57..5666071d 100644 --- a/src/Segment/HIUPD/HIUPD.php +++ b/src/Segment/HIUPD/HIUPD.php @@ -34,9 +34,12 @@ public function getKontoverbindung(): ?KtvV3; */ public function getKontoart(): ?int; - /** @return string|null The account holder name. */ + /** @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; diff --git a/src/Segment/HIUPD/HIUPDv4.php b/src/Segment/HIUPD/HIUPDv4.php index 56d3fdb8..ebbac888 100644 --- a/src/Segment/HIUPD/HIUPDv4.php +++ b/src/Segment/HIUPD/HIUPDv4.php @@ -56,6 +56,11 @@ public function getName1(): ?string return $this->name1; } + public function getName2(): ?string + { + return $this->name2; + } + public function getKontoproduktbezeichnung(): ?string { return $this->kontoproduktbezeichnung; diff --git a/src/Segment/HIUPD/HIUPDv6.php b/src/Segment/HIUPD/HIUPDv6.php index 49506730..b0620c39 100644 --- a/src/Segment/HIUPD/HIUPDv6.php +++ b/src/Segment/HIUPD/HIUPDv6.php @@ -80,6 +80,11 @@ public function getName1(): ?string return $this->name1; } + public function getName2(): ?string + { + return $this->name2; + } + public function getKontoproduktbezeichnung(): ?string { return $this->kontoproduktbezeichnung; From 45afe9cb77dc1ef897478f80982b9a73680621b7 Mon Sep 17 00:00:00 2001 From: jwtue Date: Sun, 19 Jul 2026 11:18:07 +0200 Subject: [PATCH 05/15] Call the credit card identifier an account number, not a card number The value the bank reports is the Kontonummer of the credit card account. It looks like a card number and is derived from one, but it is not a valid card number: on the tested BW-Bank account the trailing digits are zeroed out. A single account can also cover several physical cards of different schemes (one Visa and one Mastercard in the tested case), so no single card number could identify it anyway. AqBanking calls the field accountNumber in both the request and the response, so this also brings the naming in line with the reference definition. --- src/Action/GetCreditCardAccounts.php | 2 +- src/Action/GetCreditCardStatement.php | 8 ++++---- src/Model/CreditCardAccount.php | 22 +++++++++++++++------- src/Segment/KKU/DKKKUv2.php | 18 +++++++++++------- 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/Action/GetCreditCardAccounts.php b/src/Action/GetCreditCardAccounts.php index a3fe747f..177fb7fe 100644 --- a/src/Action/GetCreditCardAccounts.php +++ b/src/Action/GetCreditCardAccounts.php @@ -58,7 +58,7 @@ protected function createRequest(BPD $bpd, ?UPD $upd) continue; } $this->accounts[] = (new CreditCardAccount()) - ->setCardNumber($ktv->kontonummer) + ->setAccountNumber($ktv->kontonummer) ->setSubAccount($ktv->unterkontomerkmal) ->setBlz($ktv->kik->kreditinstitutscode ?? $bpd->getBankCode()) ->setName(self::joinName($hiupd)) diff --git a/src/Action/GetCreditCardStatement.php b/src/Action/GetCreditCardStatement.php index 21f3f68f..c9b30ca1 100644 --- a/src/Action/GetCreditCardStatement.php +++ b/src/Action/GetCreditCardStatement.php @@ -50,8 +50,8 @@ class GetCreditCardStatement extends PaginateableAction */ public static function create(CreditCardAccount $account, ?\DateTime $from = null, ?\DateTime $to = null): GetCreditCardStatement { - if ($account->getCardNumber() === null) { - throw new \InvalidArgumentException('The credit card account must have a card number'); + if ($account->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'); @@ -119,11 +119,11 @@ protected function createRequest(BPD $bpd, ?UPD $upd) // 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->getCardNumber(), + $this->account->getAccountNumber(), $this->account->getSubAccount(), Kik::create($this->account->getBlz() ?? $bpd->getBankCode()) ); - return DKKKUv2::create($kontoverbindung, $this->account->getCardNumber(), $this->from, $this->to); + return DKKKUv2::create($kontoverbindung, $this->account->getAccountNumber(), $this->from, $this->to); default: throw new UnsupportedException('Unsupported DKKKU version: ' . $dikkus->getVersion()); } diff --git a/src/Model/CreditCardAccount.php b/src/Model/CreditCardAccount.php index 600f98cb..a3bfbf38 100644 --- a/src/Model/CreditCardAccount.php +++ b/src/Model/CreditCardAccount.php @@ -12,9 +12,17 @@ */ class CreditCardAccount { - /** The credit card number, taken from the account's Kontonummer. */ - protected ?string $cardNumber = null; - /** Distinguishes several cards belonging to the same account, if the bank uses it. */ + /** + * The account number (Kontonummer) of the credit card account. + * + * Note that this is NOT necessarily a valid card number, even though it usually looks like one: + * banks commonly derive it from one of the cards and zero out the trailing digits. A single + * account can also cover several physical cards of different schemes (e.g. one Visa and one + * Mastercard). The individual card a transaction belongs to is reported per transaction, see + * {@link \Fhp\Model\CreditCardStatement\CreditCardTransaction::getAccountNumber()}. + */ + protected ?string $accountNumber = null; + /** Distinguishes several sub-accounts belonging to the same account, if the bank uses it. */ protected ?string $subAccount = null; protected ?string $blz = null; /** The account holder name. */ @@ -25,14 +33,14 @@ class CreditCardAccount /** The FinTS account type; 50-59 denotes a credit card account. Null before HIUPD v6. */ protected ?int $accountType = null; - public function getCardNumber(): ?string + public function getAccountNumber(): ?string { - return $this->cardNumber; + return $this->accountNumber; } - public function setCardNumber(?string $cardNumber): static + public function setAccountNumber(?string $accountNumber): static { - $this->cardNumber = $cardNumber; + $this->accountNumber = $accountNumber; return $this; } diff --git a/src/Segment/KKU/DKKKUv2.php b/src/Segment/KKU/DKKKUv2.php index 9434550c..3c9ccaf0 100644 --- a/src/Segment/KKU/DKKKUv2.php +++ b/src/Segment/KKU/DKKKUv2.php @@ -19,15 +19,19 @@ * declarative definition (SEGdef "GetTransactionsCreditCard", code DKKKU, version 2). * @link https://github.com/aqbanking/aqbanking/blob/master/src/libs/plugins/backends/aqhbci/ajobs/jobgettransactions.xml * - * TODO(BW-Bank): Verify the exact wire layout against a real request. Open questions: (1) whether the - * account is identified by a Kontoverbindung (ktv) as modelled here, and how it relates to the - * standalone card number; (2) whether the trailing Aufsetzpunkt field exists in this position. + * TODO(BW-Bank): Verify the trailing Aufsetzpunkt field, which AqBanking does not list explicitly + * (it handles pagination generically for jobs marked attachable="1"). */ class DKKKUv2 extends BaseSegment implements Paginateable { public KtvV3 $kontoverbindung; - /** Max length: 30. The credit card number. */ - public string $kartennummer; + /** + * Max length: 30. The account number, repeated outside the Kontoverbindung. + * + * Note that this is not necessarily a valid card number, see + * {@link \Fhp\Model\CreditCardAccount::$accountNumber}. + */ + public string $kontonummer; /** JJJJMMTT gemäß ISO 8601. NB: AqBanking lists toDate before fromDate. */ public ?string $bisDatum = null; /** JJJJMMTT gemäß ISO 8601 */ @@ -35,11 +39,11 @@ class DKKKUv2 extends BaseSegment implements Paginateable /** Max length: 35 */ public ?string $aufsetzpunkt = null; - public static function create(KtvV3 $kontoverbindung, string $kartennummer, ?\DateTime $vonDatum, ?\DateTime $bisDatum, ?string $aufsetzpunkt = null): DKKKUv2 + public static function create(KtvV3 $kontoverbindung, string $kontonummer, ?\DateTime $vonDatum, ?\DateTime $bisDatum, ?string $aufsetzpunkt = null): DKKKUv2 { $result = DKKKUv2::createEmpty(); $result->kontoverbindung = $kontoverbindung; - $result->kartennummer = $kartennummer; + $result->kontonummer = $kontonummer; $result->vonDatum = $vonDatum?->format('Ymd'); $result->bisDatum = $bisDatum?->format('Ymd'); $result->aufsetzpunkt = $aufsetzpunkt; From 9a4dce7bfc142123c10062c4e48a6848e2f85c5d Mon Sep 17 00:00:00 2001 From: jwtue Date: Sun, 19 Jul 2026 11:26:06 +0200 Subject: [PATCH 06/15] Correct the credit card record layout against real bank data Verified against a real DIKKU response from BW-Bank/LBBW. Three corrections to AqBanking's declarative definition, which is the only public reference: - The record has one more field than AqBanking lists: the merchant category code (ISO 18245) is appended after the reference. It is absent for bookings without a merchant, such as the monthly settlement credit, so it is optional. Missing it made the segment fail to parse entirely. - AqBanking describes "value2" as a duplicate of "value" that is zero for weekly statements. It actually carries the amount in the currency the merchant charged, while "value" carries the amount billed in the account currency. For domestic transactions the two are equal, which is presumably how they came to look like duplicates. - AqBanking describes "unknown3" as always "1,". It is the exchange rate. Confirmed arithmetically, e.g. 34.99 USD x 0.86941 = 30.42 EUR. The transaction model now exposes the original amount, currency, exchange rate and merchant category code. The original amount is only reported when a currency conversion actually took place, so callers can check for null instead of comparing amounts. Also accept both 'D'/'C' and 'S'/'H' for the Soll/Haben indicator; the tested bank sends the former. --- .../CreditCardTransaction.php | 74 ++++++++++++++++++- src/Segment/KKU/Kreditkartenumsatz.php | 65 ++++++++++------ 2 files changed, 114 insertions(+), 25 deletions(-) diff --git a/src/Model/CreditCardStatement/CreditCardTransaction.php b/src/Model/CreditCardStatement/CreditCardTransaction.php index 120b6a6e..382f8f1a 100644 --- a/src/Model/CreditCardStatement/CreditCardTransaction.php +++ b/src/Model/CreditCardStatement/CreditCardTransaction.php @@ -23,8 +23,15 @@ class CreditCardTransaction protected float $amount = 0.0; protected string $creditDebit = self::CD_CREDIT; protected string $currency = 'EUR'; + /** The signed amount in the currency the merchant charged, if it differs from {@link $amount}. */ + protected ?float $originalAmount = null; + protected ?string $originalCurrency = null; + /** The exchange rate applied, or null if no conversion took place. */ + protected ?float $exchangeRate = null; protected string $purpose = ''; protected ?string $reference = null; + /** ISO 18245 merchant category code, e.g. 5411 for grocery stores. Null if the booking has no merchant. */ + protected ?string $merchantCategoryCode = null; public function getAccountNumber(): ?string { @@ -114,6 +121,53 @@ public function setReference(?string $reference): static 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. */ @@ -124,17 +178,33 @@ public static function fromSegment(Kreditkartenumsatz $umsatz): CreditCardTransa $result->valutaDate = self::parseDate($umsatz->belegdatum); $result->bookingDate = self::parseDate($umsatz->buchungsdatum); - // Always use $betrag (value); $betrag2 (value2) is zero for weekly statements. - $isDebit = in_array(strtoupper($umsatz->sollHabenKennzeichen), ['S', 'D'], true); + // $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 === '') { diff --git a/src/Segment/KKU/Kreditkartenumsatz.php b/src/Segment/KKU/Kreditkartenumsatz.php index f3c50370..83df1aaf 100644 --- a/src/Segment/KKU/Kreditkartenumsatz.php +++ b/src/Segment/KKU/Kreditkartenumsatz.php @@ -11,43 +11,56 @@ * * One credit card transaction record, as contained in {@link DIKKUv2::$umsaetze}. * - * There is no official specification. The field layout is derived from AqBanking's declarative - * definition (GROUPdef "creditcardtransaction", version 1). + * There is no official specification. The layout was derived from AqBanking's declarative definition + * (GROUPdef "creditcardtransaction") and then corrected against real BW-Bank/LBBW responses. * @link https://github.com/aqbanking/aqbanking/blob/master/src/libs/plugins/backends/aqhbci/ajobs/jobgettransactions.xml * - * NOTE: The nine "Verwendungszweck" fields are modelled as discrete elements (rather than a repeated - * array) because they sit in the middle of the record, followed by further fixed fields. + * Deviations from AqBanking's definition, all confirmed against real data: + * - AqBanking describes $ursprungsbetrag ("value2") as a duplicate of $betrag that is zero for + * weekly statements. In fact it carries the amount in the *original* currency, while $betrag + * carries the amount billed in the account currency. For domestic transactions both are equal, + * which is presumably why they looked like duplicates. + * - AqBanking describes $umrechnungskurs ("unknown3") as always "1,". It is the exchange rate, and + * is indeed 1 whenever no conversion takes place. + * - AqBanking's definition ends after the reference. Real responses append the merchant category + * code (see $branchenschluessel). It is absent for non-merchant bookings such as the monthly + * settlement debit, hence optional. * - * TODO(BW-Bank): Verify against a real record. Open questions: the allowed values of the - * Soll/Haben-Kennzeichen ('S'/'H' vs 'D'/'C'), whether the amount groups carry a currency, and the - * exact number of Verwendungszweck fields actually sent. + * The nine Verwendungszweck fields are modelled as discrete elements rather than a repeated array, + * because they sit in the middle of the record and are followed by further fixed fields. */ class Kreditkartenumsatz extends BaseDeg { - /** Max length: 30 */ + /** + * Max length: 30. Depending on the bank's vintage this is either the credit card account number + * or the number of the individual card that was used. + */ public string $kontonummer; - /** JJJJMMTT gemäß ISO 8601 (AqBanking: "valutaDate") */ + /** JJJJMMTT gemäß ISO 8601. The date the transaction was made (AqBanking: "valutaDate"). */ public ?string $belegdatum = null; - /** JJJJMMTT gemäß ISO 8601 (AqBanking: "date") */ + /** JJJJMMTT gemäß ISO 8601. The date it was booked (AqBanking: "date"). */ public ?string $buchungsdatum = null; /** Always empty (AqBanking: "unknown1", maxsize 0). */ public ?string $unbekannt1 = null; /** - * Appears identical to {@link $betrag}, but is zero for weekly statements. Do NOT use it for the - * amount; the correct value is always carried by {@link $betrag}. (Quirk documented in AqBanking.) + * The amount in the currency the merchant charged. Equal to {@link $betrag} for domestic + * transactions. For foreign currency transactions this is the original amount, e.g. 34.99 USD. + */ + public Btg $ursprungsbetrag; + /** Soll/Haben-Kennzeichen for {@link $ursprungsbetrag} ('D' = Soll/debit, 'C' = Haben/credit). */ + public string $ursprungsSollHabenKennzeichen; + /** + * The exchange rate applied, i.e. $ursprungsbetrag * $umrechnungskurs == $betrag. Exactly 1 when + * no conversion took place. */ - public Btg $betrag2; - /** Soll/Haben-Kennzeichen belonging to {@link $betrag2}. See the note on {@link $betrag2}. */ - public string $sollHabenKennzeichen2; - /** Semantics unknown (AqBanking: "unknown3", always "1,"). Max length: 30 */ - public ?string $unbekannt3 = null; - /** The transaction amount (AqBanking: "value"). */ + public ?float $umrechnungskurs = null; + /** The amount billed to the account, in the account's currency. */ public Btg $betrag; - /** Soll/Haben-Kennzeichen belonging to {@link $betrag} ('S' = Soll/debit, 'H' = Haben/credit). */ + /** Soll/Haben-Kennzeichen for {@link $betrag} ('D' = Soll/debit, 'C' = Haben/credit). */ public string $sollHabenKennzeichen; - /** Max length: 50 */ + /** Max length: 50. Usually the merchant name. */ public ?string $verwendungszweck1 = null; - /** Max length: 50 */ + /** Max length: 50. Usually the merchant location followed by the masked card number that was used. */ public ?string $verwendungszweck2 = null; /** Max length: 50 */ public ?string $verwendungszweck3 = null; @@ -63,10 +76,16 @@ class Kreditkartenumsatz extends BaseDeg public ?string $verwendungszweck8 = null; /** Max length: 50 */ public ?string $verwendungszweck9 = null; - /** Semantics unknown (AqBanking: "yesno1", always "Y"). Max length: 1 */ + /** Semantics unknown (AqBanking: "yesno1"). Observed values: "J". Max length: 1 */ public ?string $unbekannt4 = null; - /** Max length: 30. 16-digit reference number ("Referenz" in the bank's web UI). */ + /** Max length: 30. The reference number ("Referenz" in the bank's web UI). */ public ?string $referenz = null; + /** + * The ISO 18245 merchant category code (MCC) of the merchant, e.g. 5411 for grocery stores. + * Absent for bookings that have no merchant, such as the monthly settlement debit. + * Max length: 4 + */ + public ?string $branchenschluessel = null; /** @return string[] The non-empty Verwendungszweck lines, in order. */ public function getVerwendungszweckLines(): array From b5d8c8b3ea48087dff25fe0fae9358a3d0435f38 Mon Sep 17 00:00:00 2001 From: jwtue Date: Sun, 19 Jul 2026 11:32:33 +0200 Subject: [PATCH 07/15] Add tests for the credit card segments and account discovery The fixtures mirror the structure of real BW-Bank/LBBW responses with all account numbers, names, merchants and amounts replaced. What matters is the structure, so the cases that actually occurred in the wild are all covered: a domestic transaction, a foreign currency one, the monthly settlement credit (which carries no merchant category code), a second card on the same account, and the older record format that identifies the individual card and uses a date based reference. The account discovery test covers HIUPD v4 as well, which reports no account type at all, and both directions of the account type fallback. Writing the tests turned up that GetCreditCardAccounts assumed UPD::$hiupd to be an array. That holds for a UPD built by extractFromResponse, but not for a freshly constructed one, so guard against null. --- .../Unit/Action/GetCreditCardAccountsTest.php | 128 +++++++++++ Tests/Unit/Segment/DIKKUTest.php | 201 ++++++++++++++++++ src/Action/GetCreditCardAccounts.php | 2 +- 3 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 Tests/Unit/Action/GetCreditCardAccountsTest.php create mode 100644 Tests/Unit/Segment/DIKKUTest.php 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/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 index 177fb7fe..89b65f1f 100644 --- a/src/Action/GetCreditCardAccounts.php +++ b/src/Action/GetCreditCardAccounts.php @@ -49,7 +49,7 @@ protected function createRequest(BPD $bpd, ?UPD $upd) throw new \InvalidArgumentException('Cannot determine credit card accounts without UPD'); } - foreach ($upd->hiupd as $hiupd) { + foreach ($upd->hiupd ?? [] as $hiupd) { if (!self::isCreditCardAccount($hiupd)) { continue; } From 0ff8943069a9badc3ff14cfd086e6dd4f392dc11 Mon Sep 17 00:00:00 2001 From: jwtue Date: Sun, 19 Jul 2026 11:40:33 +0200 Subject: [PATCH 08/15] Test the pagination handling and document what remains unverified Adds tests for GetCreditCardStatement covering the request, the single page case, accumulation across two pages, that the result is withheld until the last page arrived, and that a serialization round trip resumes on the same page. The pagination is driven by a simulated response, because it could not be triggered for real: the tested account's transactions fit into one response and DKKKU, unlike HKKAZ, has no field to request a smaller page. That leaves the wire position of the Aufsetzpunkt unverified, which is now documented on the field. AqBanking marks the DKKKU job attachable="1" but, in contrast to HKKAZ, its segment definition has no element to carry the value, so the position here follows the general FinTS convention of a trailing optional data element instead of a documented definition. Omitting the field would be worse than guessing it: the action would re-send an identical request for every page and never make progress. --- .../Action/GetCreditCardStatementTest.php | 197 ++++++++++++++++++ src/Segment/KKU/DKKKUv2.php | 19 +- 2 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 Tests/Unit/Action/GetCreditCardStatementTest.php diff --git a/Tests/Unit/Action/GetCreditCardStatementTest.php b/Tests/Unit/Action/GetCreditCardStatementTest.php new file mode 100644 index 00000000..039e05a6 --- /dev/null +++ b/Tests/Unit/Action/GetCreditCardStatementTest.php @@ -0,0 +1,197 @@ +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 Aufsetzpunkt, 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); + } + + 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/src/Segment/KKU/DKKKUv2.php b/src/Segment/KKU/DKKKUv2.php index 3c9ccaf0..2961c5b0 100644 --- a/src/Segment/KKU/DKKKUv2.php +++ b/src/Segment/KKU/DKKKUv2.php @@ -19,8 +19,7 @@ * declarative definition (SEGdef "GetTransactionsCreditCard", code DKKKU, version 2). * @link https://github.com/aqbanking/aqbanking/blob/master/src/libs/plugins/backends/aqhbci/ajobs/jobgettransactions.xml * - * TODO(BW-Bank): Verify the trailing Aufsetzpunkt field, which AqBanking does not list explicitly - * (it handles pagination generically for jobs marked attachable="1"). + * CAVEAT: The position of {@link $aufsetzpunkt} is an educated guess, see the note on that field. */ class DKKKUv2 extends BaseSegment implements Paginateable { @@ -36,7 +35,21 @@ class DKKKUv2 extends BaseSegment implements Paginateable public ?string $bisDatum = null; /** JJJJMMTT gemäß ISO 8601 */ public ?string $vonDatum = null; - /** Max length: 35 */ + /** + * Max length: 35. + * + * UNVERIFIED: AqBanking marks the DKKKU job as attachable="1" but, unlike for HKKAZ, its segment + * definition contains no element to put the Aufsetzpunkt in. So its position here follows the + * general FinTS convention of a trailing, optional data element (as in + * {@link \Fhp\Segment\KAZ\HKKAZv7::$aufsetzpunkt}) rather than a documented definition. + * + * It could not be verified against a real bank either, because the tested account never produced + * a paginated response and DKKKU offers no way to request a smaller page. If a bank does paginate + * and rejects the follow-up request, this field is the first thing to check. + * + * Note that omitting the field entirely would be worse: the action would then re-send the + * identical request for every page and never make progress. + */ public ?string $aufsetzpunkt = null; public static function create(KtvV3 $kontoverbindung, string $kontonummer, ?\DateTime $vonDatum, ?\DateTime $bisDatum, ?string $aufsetzpunkt = null): DKKKUv2 From e4847c5d5bf32b4a0605717d14af2df61b264292 Mon Sep 17 00:00:00 2001 From: jwtue Date: Sun, 19 Jul 2026 11:45:14 +0200 Subject: [PATCH 09/15] Document credit card transactions and add a sample --- README.md | 39 ++++++++++++++++++++ Samples/creditCardStatement.php | 65 +++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 Samples/creditCardStatement.php diff --git a/README.md b/README.md index f64c0315..d90120d6 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,44 @@ 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. + * Banks typically retain only a limited period of credit card transactions; the exact number of days + is reported in the `DIKKUS` parameters. + * 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; From 2e90eb6bde9f2ba4616165557aaa899ba86a40c3 Mon Sep 17 00:00:00 2001 From: jwtue Date: Sun, 19 Jul 2026 12:08:06 +0200 Subject: [PATCH 10/15] Use the codebase's English term for the pagination token The prose in this library says "pagination token" and mentions the German spec term once as a gloss, see PaginateableAction and Rueckmeldungscode. The comments added for DKKKU used the German term throughout instead, which was inconsistent. The field name itself stays German, in line with the developer guide, which maps field names to the specification. --- Tests/Unit/Action/GetCreditCardStatementTest.php | 6 +++--- src/Segment/KKU/DKKKUv2.php | 9 +++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Tests/Unit/Action/GetCreditCardStatementTest.php b/Tests/Unit/Action/GetCreditCardStatementTest.php index 039e05a6..ef29eab2 100644 --- a/Tests/Unit/Action/GetCreditCardStatementTest.php +++ b/Tests/Unit/Action/GetCreditCardStatementTest.php @@ -19,8 +19,8 @@ * NOTE: The pagination is exercised here with a simulated response, because it could not be * triggered against a real bank: the tested account's transactions all fit into a single response, * and DKKKU offers no way to ask for a smaller page (unlike HKKAZ it has no "maximaleAnzahlEintraege" - * field). What this test therefore cannot prove is that the Aufsetzpunkt sits at the wire position - * assumed by {@link DKKKUv2::$aufsetzpunkt} — see the note there. + * field). What this test therefore cannot prove is that the pagination token sits at the wire + * position assumed by {@link DKKKUv2::$aufsetzpunkt} — see the note there. */ class GetCreditCardStatementTest extends \PHPUnit\Framework\TestCase { @@ -127,7 +127,7 @@ public function testPaginatedResponseAccumulatesAllPages() $this->assertTrue($action->hasMorePages()); $this->assertFalse($action->isDone()); - // The follow-up request must carry the Aufsetzpunkt, everything else unchanged. + // The follow-up request must carry the pagination token, everything else unchanged. $requestSegments = self::nextRequest($action, $bpd); /** @var DKKKUv2 $request */ $request = $requestSegments[0]; diff --git a/src/Segment/KKU/DKKKUv2.php b/src/Segment/KKU/DKKKUv2.php index 2961c5b0..19cc9529 100644 --- a/src/Segment/KKU/DKKKUv2.php +++ b/src/Segment/KKU/DKKKUv2.php @@ -19,7 +19,8 @@ * declarative definition (SEGdef "GetTransactionsCreditCard", code DKKKU, version 2). * @link https://github.com/aqbanking/aqbanking/blob/master/src/libs/plugins/backends/aqhbci/ajobs/jobgettransactions.xml * - * CAVEAT: The position of {@link $aufsetzpunkt} is an educated guess, see the note on that field. + * CAVEAT: The position of the pagination token {@link $aufsetzpunkt} is an educated guess, see the + * note on that field. */ class DKKKUv2 extends BaseSegment implements Paginateable { @@ -36,11 +37,11 @@ class DKKKUv2 extends BaseSegment implements Paginateable /** JJJJMMTT gemäß ISO 8601 */ public ?string $vonDatum = null; /** - * Max length: 35. + * Max length: 35. The pagination token, called "Aufsetzpunkt" in the specification. * * UNVERIFIED: AqBanking marks the DKKKU job as attachable="1" but, unlike for HKKAZ, its segment - * definition contains no element to put the Aufsetzpunkt in. So its position here follows the - * general FinTS convention of a trailing, optional data element (as in + * definition contains no element to put the token in. So its position here follows the general + * FinTS convention of a trailing, optional data element (as in * {@link \Fhp\Segment\KAZ\HKKAZv7::$aufsetzpunkt}) rather than a documented definition. * * It could not be verified against a real bank either, because the tested account never produced From df5e2b1ac27a27acf40fd72afbc32eca42787daf Mon Sep 17 00:00:00 2001 From: jwtue Date: Sun, 19 Jul 2026 12:43:36 +0200 Subject: [PATCH 11/15] Add the missing entry count field to the DKKKU request A one year query made the tested bank paginate, answering with "3040 Es liegen weitere Informationen vor" and a token of the form ",,". The follow-up request, which appended that token directly after the date range, was rejected with "9110 Ungueltige Auftragsnachricht: Unbekannter Aufbau". The DIKKUS parameters carry a flag stating whether the number of entries may be specified, which only makes sense if the request has a field for it. AqBanking's definition lists neither that field nor the pagination token, so the request now ends in the same pair of fields as HKKAZ does. This does not change the first request of a query, because both fields are optional and stay empty there. --- src/Segment/KKU/DKKKUv2.php | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/Segment/KKU/DKKKUv2.php b/src/Segment/KKU/DKKKUv2.php index 19cc9529..4208aec2 100644 --- a/src/Segment/KKU/DKKKUv2.php +++ b/src/Segment/KKU/DKKKUv2.php @@ -19,8 +19,7 @@ * declarative definition (SEGdef "GetTransactionsCreditCard", code DKKKU, version 2). * @link https://github.com/aqbanking/aqbanking/blob/master/src/libs/plugins/backends/aqhbci/ajobs/jobgettransactions.xml * - * CAVEAT: The position of the pagination token {@link $aufsetzpunkt} is an educated guess, see the - * note on that field. + * NOTE: The last two fields are missing from AqBanking's definition, see the notes on them. */ class DKKKUv2 extends BaseSegment implements Paginateable { @@ -37,19 +36,25 @@ class DKKKUv2 extends BaseSegment implements Paginateable /** JJJJMMTT gemäß ISO 8601 */ public ?string $vonDatum = null; /** - * Max length: 35. The pagination token, called "Aufsetzpunkt" in the specification. + * Only allowed if {@link ParameterKreditkartenumsaetze::$eingabeAnzahlEintraegeErlaubt} says so. * - * UNVERIFIED: AqBanking marks the DKKKU job as attachable="1" but, unlike for HKKAZ, its segment - * definition contains no element to put the token in. So its position here follows the general - * FinTS convention of a trailing, optional data element (as in - * {@link \Fhp\Segment\KAZ\HKKAZv7::$aufsetzpunkt}) rather than a documented definition. + * AqBanking's definition does not list this field, but the DIKKUS parameters have a flag stating + * whether it may be used, so the field has to exist. Confirmed by the bank rejecting a request + * that put the pagination token in this position. + */ + public ?int $maximaleAnzahlEintraege = null; + /** + * Max length: 35. The pagination token, called "Aufsetzpunkt" in the specification. * - * It could not be verified against a real bank either, because the tested account never produced - * a paginated response and DKKKU offers no way to request a smaller page. If a bank does paginate - * and rejects the follow-up request, this field is the first thing to check. + * Like {@link $maximaleAnzahlEintraege} this is missing from AqBanking's definition, so the + * position mirrors HKKAZ, which ends in the same pair of fields. * - * Note that omitting the field entirely would be worse: the action would then re-send the - * identical request for every page and never make progress. + * PARTIALLY VERIFIED: the tested bank does paginate (it answered a one year query with + * "3040 Es liegen weitere Informationen vor" and a token of the form + * ",,"), 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; From a4babd2091f719f6518a91297002efeaf35ae038 Mon Sep 17 00:00:00 2001 From: jwtue Date: Sun, 19 Jul 2026 12:50:29 +0200 Subject: [PATCH 12/15] Warn about querying beyond the advertised retention window The tested bank does not reject ranges longer than the Speicherzeitraum it announces in DIKKUS, but it answers them inconsistently: the same one year query returned the full range once, a silent truncation to roughly 180 days another time, and once an empty first page with a continuation token. No size limit is involved, a 70 KB response with 474 transactions was served in one piece. --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d90120d6..ba310e57 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,10 @@ that: 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. * Banks typically retain only a limited period of credit card transactions; the exact number of days - is reported in the `DIKKUS` parameters. + is reported as the "Speicherzeitraum" in the `DIKKUS` parameters. Asking for a longer range is not + rejected, but the tested bank answered such queries inconsistently — sometimes with the full + range, sometimes silently truncated. If you need more history, request it in windows no larger + than the advertised one. * Transactions in a foreign currency additionally report the original amount, its currency and the exchange rate that was applied. From 703aafcc62aa8f88559daeb5c36bc3b1d2f94404 Mon Sep 17 00:00:00 2001 From: jwtue Date: Sun, 19 Jul 2026 13:32:16 +0200 Subject: [PATCH 13/15] Explain why the credit card segments link no specification DKKKU is an association-specific business transaction of the Sparkassen-Finanzgruppe, and its specification is only available from SIZ under a non-disclosure agreement, so there is no document to link to the way the segments from the FinTS specification do. Say so on the segments instead of leaving the absence unexplained, and point at AqBanking as the reference the implementation is based on from every class rather than only from some. GetCreditCardAccounts does link the FinTS specification, because the HIUPD segment it reads is part of it. --- src/Action/GetCreditCardAccounts.php | 3 +++ src/Action/GetCreditCardStatement.php | 3 +++ src/Segment/KKU/DIKKUS.php | 2 ++ src/Segment/KKU/DIKKUSv2.php | 5 +++-- src/Segment/KKU/DKKKUv2.php | 10 ++++++++-- src/Segment/KKU/ParameterKreditkartenumsaetze.php | 7 ++++--- 6 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/Action/GetCreditCardAccounts.php b/src/Action/GetCreditCardAccounts.php index 89b65f1f..6c875175 100644 --- a/src/Action/GetCreditCardAccounts.php +++ b/src/Action/GetCreditCardAccounts.php @@ -16,6 +16,9 @@ * They are however described by the HIUPD segments in the UPD, which the bank sends during dialog * initialization. This action therefore needs no request to the server at all; it just interprets * data that is already available after login. + * + * @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Formals_2017-10-06_final_version.pdf + * Section: E.3 "Kontoinformation" (the HIUPD segment this reads) */ class GetCreditCardAccounts extends BaseAction { diff --git a/src/Action/GetCreditCardStatement.php b/src/Action/GetCreditCardStatement.php index c9b30ca1..18ba3ab1 100644 --- a/src/Action/GetCreditCardStatement.php +++ b/src/Action/GetCreditCardStatement.php @@ -24,6 +24,9 @@ * * The account to query is obtained from {@link GetCreditCardAccounts}, which reads it from the UPD. * Credit card accounts are NOT part of {@link GetSEPAAccounts}, because they have no IBAN. + * + * Note that DKKKU has no publicly available specification, see {@link DKKKUv2} for details and for + * the reference this implementation is based on instead. */ class GetCreditCardStatement extends PaginateableAction { diff --git a/src/Segment/KKU/DIKKUS.php b/src/Segment/KKU/DIKKUS.php index 539f2c6f..040004a8 100644 --- a/src/Segment/KKU/DIKKUS.php +++ b/src/Segment/KKU/DIKKUS.php @@ -9,6 +9,8 @@ * * BPD parameter segment for the DKKKU business transaction. Its presence in the BPD indicates that * the bank supports credit card statement retrieval. + * + * See {@link DKKKUv2} for why there is no specification document to link to. */ interface DIKKUS extends SegmentInterface { diff --git a/src/Segment/KKU/DIKKUSv2.php b/src/Segment/KKU/DIKKUSv2.php index 2ca2d76d..c48f6493 100644 --- a/src/Segment/KKU/DIKKUSv2.php +++ b/src/Segment/KKU/DIKKUSv2.php @@ -8,8 +8,9 @@ /** * Segment: Kreditkartenumsätze Parameter (Version 2) * - * AqBanking does not define a params segment for DKKKU. The layout was derived from a real BW-Bank - * BPD, which sends `DIKKUS:45:2:3+1+1+0+90:N:J`. + * AqBanking does not define a params segment for DKKKU, and there is no specification document to + * link to either, see {@link DKKKUv2}. The layout was therefore derived from a real BW-Bank BPD, + * which sends `DIKKUS:45:2:3+1+1+0+90:N:J`. */ class DIKKUSv2 extends BaseGeschaeftsvorfallparameter implements DIKKUS { diff --git a/src/Segment/KKU/DKKKUv2.php b/src/Segment/KKU/DKKKUv2.php index 4208aec2..4e75673d 100644 --- a/src/Segment/KKU/DKKKUv2.php +++ b/src/Segment/KKU/DKKKUv2.php @@ -15,8 +15,14 @@ * accounts have no IBAN and can therefore not be queried through HKKAZ (see * {@link \Fhp\Segment\KAZ\HKKAZv7}) or HKCAZ. * - * There is no official specification for this segment. The field layout is derived from AqBanking's - * declarative definition (SEGdef "GetTransactionsCreditCard", code DKKKU, version 2). + * There is no publicly available specification for this segment: DKKKU is an association-specific + * ("verbandsspezifisch") business transaction of the Sparkassen-Finanzgruppe, and its specification + * is only handed out by SIZ under a non-disclosure agreement. Unlike for the segments defined in the + * FinTS specification, there is therefore no document to link to here. + * + * The field layout is instead derived from AqBanking's declarative definition (SEGdef + * "GetTransactionsCreditCard", code DKKKU, version 2), the only public implementation-level + * description we could find, and then corrected against real responses where it was wrong. * @link https://github.com/aqbanking/aqbanking/blob/master/src/libs/plugins/backends/aqhbci/ajobs/jobgettransactions.xml * * NOTE: The last two fields are missing from AqBanking's definition, see the notes on them. diff --git a/src/Segment/KKU/ParameterKreditkartenumsaetze.php b/src/Segment/KKU/ParameterKreditkartenumsaetze.php index 6352de82..7582ba2b 100644 --- a/src/Segment/KKU/ParameterKreditkartenumsaetze.php +++ b/src/Segment/KKU/ParameterKreditkartenumsaetze.php @@ -8,9 +8,10 @@ /** * Data Element Group: Parameter Kreditkartenumsätze * - * There is no official specification. The structure was derived from a real BW-Bank BPD, which sends - * `DIKKUS:45:2:3+1+1+0+90:N:J`, i.e. the same three parameters that the regular account statement - * transaction uses (see {@link \Fhp\Segment\KAZ\ParameterKontoumsaetzeV2}). + * There is no specification document to link to, see {@link DKKKUv2}. The structure was derived from + * a real BW-Bank BPD, which sends `DIKKUS:45:2:3+1+1+0+90:N:J`, i.e. the same three parameters that + * the regular account statement transaction uses (see + * {@link \Fhp\Segment\KAZ\ParameterKontoumsaetzeV2}). */ class ParameterKreditkartenumsaetze extends BaseDeg { From 881e9ad356e0e7db069b9dabacbdc6e5c8186287 Mon Sep 17 00:00:00 2001 From: jwtue Date: Sun, 19 Jul 2026 13:38:55 +0200 Subject: [PATCH 14/15] Reject queries beyond the announced retention period Banks announce in DIKKUS how many days of credit card transactions they keep. Asking for more is not necessarily refused, but the answers become unreliable: the tested bank served the full range for one query, silently truncated another to a shorter period, and once returned an empty first page with a pagination token. Validating this up front, the way GetStatementOfAccount validates allAccounts against the BPD, turns that into a clear error and tells callers to fetch older data window by window. Also reference the specification for the account type range that identifies a credit card account, which is listed in full on HIUPDv6::$kontoart. --- README.md | 8 +++--- .../Action/GetCreditCardStatementTest.php | 28 +++++++++++++++++++ src/Action/GetCreditCardAccounts.php | 8 +++++- src/Action/GetCreditCardStatement.php | 27 ++++++++++++++++++ 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ba310e57..8bc4a454 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,10 @@ that: 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. * Banks typically retain only a limited period of credit card transactions; the exact number of days - is reported as the "Speicherzeitraum" in the `DIKKUS` parameters. Asking for a longer range is not - rejected, but the tested bank answered such queries inconsistently — sometimes with the full - range, sometimes silently truncated. If you need more history, request it in windows no larger - than the advertised one. + is reported as the "Speicherzeitraum" in the `DIKKUS` parameters. `GetCreditCardStatement` rejects + ranges that reach back further, because banks answer them inconsistently rather than refusing them + — the tested one served the full range for one query and silently truncated another. If you need + more history, fetch it window by window. * Transactions in a foreign currency additionally report the original amount, its currency and the exchange rate that was applied. diff --git a/Tests/Unit/Action/GetCreditCardStatementTest.php b/Tests/Unit/Action/GetCreditCardStatementTest.php index ef29eab2..ecbe2e32 100644 --- a/Tests/Unit/Action/GetCreditCardStatementTest.php +++ b/Tests/Unit/Action/GetCreditCardStatementTest.php @@ -179,6 +179,34 @@ public function testSerializationRoundTripPreservesTheRequest() $this->assertEquals('20260719', $request->bisDatum); } + /** The DIKKUS fixture announces a retention period of 90 days. */ + public function testRejectsRangeBeyondTheRetentionPeriod() + { + $account = (new CreditCardAccount())->setAccountNumber(self::ACCOUNT)->setBlz('60050101'); + $action = GetCreditCardStatement::create($account, new \DateTime('-200 days'), new \DateTime()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/only provides credit card transactions for the last 90 days/'); + $action->getNextRequest(self::bpd(), null); + } + + public function testAcceptsRangeWithinTheRetentionPeriod() + { + $account = (new CreditCardAccount())->setAccountNumber(self::ACCOUNT)->setBlz('60050101'); + $action = GetCreditCardStatement::create($account, new \DateTime('-89 days'), new \DateTime()); + + $this->assertCount(1, $action->getNextRequest(self::bpd(), null)); + } + + /** Without a from-date the bank decides how far back to go, so there is nothing to validate. */ + public function testAcceptsOpenEndedRange() + { + $account = (new CreditCardAccount())->setAccountNumber(self::ACCOUNT)->setBlz('60050101'); + $action = GetCreditCardStatement::create($account); + + $this->assertCount(1, $action->getNextRequest(self::bpd(), null)); + } + public function testRejectsAnAccountWithoutNumber() { $this->expectException(\InvalidArgumentException::class); diff --git a/src/Action/GetCreditCardAccounts.php b/src/Action/GetCreditCardAccounts.php index 6c875175..b426aa0c 100644 --- a/src/Action/GetCreditCardAccounts.php +++ b/src/Action/GetCreditCardAccounts.php @@ -22,7 +22,13 @@ */ class GetCreditCardAccounts extends BaseAction { - /** The lowest/highest FinTS account type ("Kontoart") that denotes a credit card account. */ + /** + * The range of account types ("Kontoart") that denotes a credit card account, see + * {@link \Fhp\Segment\HIUPD\HIUPDv6::$kontoart} for the complete list of ranges. + * + * @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Formals_2017-10-06_final_version.pdf + * Section: E.3 "Kontoinformation" + */ private const KONTOART_CREDIT_CARD_MIN = 50; private const KONTOART_CREDIT_CARD_MAX = 59; diff --git a/src/Action/GetCreditCardStatement.php b/src/Action/GetCreditCardStatement.php index 18ba3ab1..aa204553 100644 --- a/src/Action/GetCreditCardStatement.php +++ b/src/Action/GetCreditCardStatement.php @@ -116,6 +116,7 @@ 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 @@ -132,6 +133,32 @@ protected function createRequest(BPD $bpd, ?UPD $upd) } } + /** + * Banks only keep credit card transactions for a limited period, which they announce in the + * DIKKUS parameters. Asking for more is not necessarily rejected, but the answers become + * unreliable: the tested bank served the full range for one query, silently truncated another to + * a shorter period, and once returned an empty first page with a pagination token. Rather than + * let callers run into that, refuse the request and let them fetch older data window by window. + * + * @throws \InvalidArgumentException If the requested range reaches beyond the retention period. + */ + private function validateDateRange(DIKKUS $dikkus): void + { + $speicherzeitraum = $dikkus->getParameter()->speicherzeitraum; + if ($this->from === null || $speicherzeitraum <= 0) { + return; // Nothing to check against. + } + $earliest = (new \DateTime("-$speicherzeitraum days"))->setTime(0, 0); + if ($this->from < $earliest) { + throw new \InvalidArgumentException(sprintf( + 'The bank only provides credit card transactions for the last %d days, i.e. back to %s, but the requested from-date is %s.', + $speicherzeitraum, + $earliest->format('Y-m-d'), + $this->from->format('Y-m-d') + )); + } + } + public function processResponse(Message $response) { parent::processResponse($response); From 76a8445943e7d511ff304f0724f8a32bace54199 Mon Sep 17 00:00:00 2001 From: jwtue Date: Sun, 19 Jul 2026 14:01:25 +0200 Subject: [PATCH 15/15] Limit the span of a request rather than how far back it reaches Checking the from-date against the announced period contradicted the advice to fetch older transactions window by window: a request for the period before last, which is exactly such a window, was rejected. It also enforced a boundary the tested bank does not have, since it served transactions from a whole year back when asked for them. What actually broke 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, while ranges within the period were always answered consistently. So apply the limit to the span of the request, which keeps windowing possible. --- README.md | 11 ++-- .../Action/GetCreditCardStatementTest.php | 63 +++++++++++++++---- src/Action/GetCreditCardStatement.php | 33 ++++++---- 3 files changed, 78 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 8bc4a454..a1793752 100644 --- a/README.md +++ b/README.md @@ -77,11 +77,12 @@ 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. - * Banks typically retain only a limited period of credit card transactions; the exact number of days - is reported as the "Speicherzeitraum" in the `DIKKUS` parameters. `GetCreditCardStatement` rejects - ranges that reach back further, because banks answer them inconsistently rather than refusing them - — the tested one served the full range for one query and silently truncated another. If you need - more history, fetch it window by window. + * 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. diff --git a/Tests/Unit/Action/GetCreditCardStatementTest.php b/Tests/Unit/Action/GetCreditCardStatementTest.php index ecbe2e32..f00a9205 100644 --- a/Tests/Unit/Action/GetCreditCardStatementTest.php +++ b/Tests/Unit/Action/GetCreditCardStatementTest.php @@ -179,30 +179,69 @@ public function testSerializationRoundTripPreservesTheRequest() $this->assertEquals('20260719', $request->bisDatum); } - /** The DIKKUS fixture announces a retention period of 90 days. */ - public function testRejectsRangeBeyondTheRetentionPeriod() + private static function accountOnly(): CreditCardAccount { - $account = (new CreditCardAccount())->setAccountNumber(self::ACCOUNT)->setBlz('60050101'); - $action = GetCreditCardStatement::create($account, new \DateTime('-200 days'), new \DateTime()); + 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('/only provides credit card transactions for the last 90 days/'); + $this->expectExceptionMessageMatches('/spanning more than 90 days/'); $action->getNextRequest(self::bpd(), null); } - public function testAcceptsRangeWithinTheRetentionPeriod() + public function testAcceptsRangeWithinTheAnnouncedPeriod() { - $account = (new CreditCardAccount())->setAccountNumber(self::ACCOUNT)->setBlz('60050101'); - $action = GetCreditCardStatement::create($account, new \DateTime('-89 days'), new \DateTime()); + $action = GetCreditCardStatement::create( + self::accountOnly(), + new \DateTime('-89 days'), + new \DateTime() + ); $this->assertCount(1, $action->getNextRequest(self::bpd(), null)); } - /** Without a from-date the bank decides how far back to go, so there is nothing to validate. */ - public function testAcceptsOpenEndedRange() + /** + * 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() { - $account = (new CreditCardAccount())->setAccountNumber(self::ACCOUNT)->setBlz('60050101'); - $action = GetCreditCardStatement::create($account); + $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)); } diff --git a/src/Action/GetCreditCardStatement.php b/src/Action/GetCreditCardStatement.php index aa204553..1552d94f 100644 --- a/src/Action/GetCreditCardStatement.php +++ b/src/Action/GetCreditCardStatement.php @@ -134,27 +134,36 @@ protected function createRequest(BPD $bpd, ?UPD $upd) } /** - * Banks only keep credit card transactions for a limited period, which they announce in the - * DIKKUS parameters. Asking for more is not necessarily rejected, but the answers become - * unreliable: the tested bank served the full range for one query, silently truncated another to - * a shorter period, and once returned an empty first page with a pagination token. Rather than - * let callers run into that, refuse the request and let them fetch older data window by window. + * Limits how much a single request may ask for, using the period the bank announces in the + * DIKKUS parameters. * - * @throws \InvalidArgumentException If the requested range reaches beyond the retention period. + * 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; // Nothing to check against. + return; // Without a from-date the bank decides how much to return. } - $earliest = (new \DateTime("-$speicherzeitraum days"))->setTime(0, 0); - if ($this->from < $earliest) { + $to = $this->to ?? new \DateTime(); + $requestedDays = (int) $this->from->diff($to)->days; + if ($requestedDays > $speicherzeitraum) { throw new \InvalidArgumentException(sprintf( - 'The bank only provides credit card transactions for the last %d days, i.e. back to %s, but the requested from-date is %s.', + '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, - $earliest->format('Y-m-d'), - $this->from->format('Y-m-d') + $requestedDays, + $this->from->format('Y-m-d'), + $to->format('Y-m-d'), + $speicherzeitraum )); } }