Skip to content

Add credit card transaction retrieval (DKKKU) - #574

Open
jwtue wants to merge 15 commits into
nemiah:masterfrom
jwtue:feature/dkkku-credit-card-statements
Open

Add credit card transaction retrieval (DKKKU)#574
jwtue wants to merge 15 commits into
nemiah:masterfrom
jwtue:feature/dkkku-credit-card-statements

Conversation

@jwtue

@jwtue jwtue commented Jul 19, 2026

Copy link
Copy Markdown

Credit card accounts have no IBAN, so they are missing from HKSPA and their transactions cannot be
fetched with HKKAZ or HKCAZ. The only route is DKKKU, a business transaction defined by the
Deutsche Kreditwirtschaft rather than by the FinTS specification, which the Sparkassen-Finanzgruppe
offers among others.

$getCards = \Fhp\Action\GetCreditCardAccounts::create();
$fints->execute($getCards);

$getTransactions = \Fhp\Action\GetCreditCardStatement::create($getCards->getAccounts()[0], $from, $to);
$fints->execute($getTransactions);

Changes:

  • Segments DKKKU / DIKKU / DIKKUS v2 in Fhp\Segment\KKU
  • GetCreditCardAccounts, which takes the accounts from the HIUPD segments received during login
    and therefore needs no request to the bank at all
  • GetCreditCardStatement, paginateable
  • A dedicated result model, because credit card records are flat and carry fields MT940 does not have
  • Accessors on HIUPD for account, account type, holder name, product name and currency
  • Sample and README section

On the segment definitions: there is no public specification for DKKKU. The starting point was
AqBanking's declarative definition,
which turned out to be wrong in four places. All four were found by comparing against real responses
and are documented in the code:

  1. The record ends with a merchant category code that AqBanking does not list, so the segment failed
    to parse at all. It is absent for bookings without a merchant, hence optional.
  2. value2 is not a duplicate of value, but the amount in the currency the merchant charged.
  3. unknown3 is not the constant "1,", but the exchange rate — e.g. 34.99 USD * 0.86941 = 30.42 EUR.
  4. The request has a field for the number of entries, which the DIKKUS parameters announce a flag
    for. Without it the bank rejects a paginated follow-up with 9110 Unbekannter Aufbau.

Points 2 to 4 only surface on foreign currency transactions or when paginating, which is presumably
why they went unnoticed.

Tested against BW-Bank/LBBW (60050101), which advertises DIKKUS:...:2. A real query returned
~470 transactions including foreign currency bookings and settlement credits. The unit test fixtures
mirror those responses with all account numbers, names, merchants and amounts replaced.

Limitations: the corrected position of the pagination token could not be confirmed — the bank
paginated once and then not again on any later attempt with the same query. The first request of a
query is unaffected either way, since the field stays empty there. Only DIKKUS v2 at a single
institute was exercised. Also note that querying beyond the retention window announced in DIKKUS
is not rejected but answered inconsistently, so callers should stay within it.

Developed with the assistance of Claude Code. Every claim about the segment layout above was verified
against real bank responses rather than taken from the model.

jwtue added 12 commits July 19, 2026 10:32
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).
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.
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.
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++<id>+50+EUR+<name1>+<name2>+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.
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.
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.
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.
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.
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.
A one year query made the tested bank paginate, answering with "3040 Es liegen
weitere Informationen vor" and a token of the form "<card number>,<date>,<index>".
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.
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.
@Philipp91

Copy link
Copy Markdown
Contributor

Most other segment classes and also some actions have links to the respective specification documents. Is there something similar here, like some PDF you could link to?

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.
Comment thread src/Action/GetCreditCardAccounts.php
*
* One credit card transaction record, as contained in {@link DIKKUv2::$umsaetze}.
*
* There is no official specification. The layout was derived from AqBanking's declarative definition

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good reference.

Comment thread README.md Outdated
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.
@jwtue

jwtue commented Jul 19, 2026

Copy link
Copy Markdown
Author

Most other segment classes and also some actions have links to the respective specification documents. Is there something similar here, like some PDF you could link to?

there is no public document for DKKKU — it is an association-specific transaction of the Sparkassen-Finanzgruppe, and its specification is only handed out by SIZ under NDA. AqBanking's declarative definition is the only public description at implementation level, which is what the segments link. I pushed 703aafc earlier to state that on the segments themselves rather than leave the absence unexplained.

@Philipp91

Copy link
Copy Markdown
Contributor

to state that on the segments themselves rather than leave the absence unexplained.

That's great.

Comment thread src/Action/GetCreditCardStatement.php Outdated
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants