Skip to content

Commit 9843597

Browse files
author
mcdax
committed
feat: add get_all_transactions() method and document API pagination limitations
This commit introduces several improvements for transaction handling: - Add get_all_transactions() method to fetch up to 500 transactions in one call (works around API's paging-first=0-only limitation) - Document comdirect API pagination restrictions (paging-first only accepts 0, max 500 transactions via paging-count) - Add comprehensive strategies for fetching >500 transactions using date filtering and local storage - Implement token persistence in examples to avoid repeated Push-TAN authentication - Add remittance_lines property for parsing multi-line remittanceInfo fields - Update README with pagination limitations, PyPI installation instructions, and advanced usage patterns - Add COMDIRECT_API.md documenting transaction endpoint pagination behavior
1 parent a92b036 commit 9843597

15 files changed

Lines changed: 2615 additions & 2296 deletions

COMDIRECT_API.md

Lines changed: 943 additions & 0 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 238 additions & 35 deletions
Large diffs are not rendered by default.

comdirect_api.feature

Lines changed: 63 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ Feature: Comdirect API Client Library
178178
And an account with accountId exists
179179
When the user requests transactions for the accountId
180180
Then the library should check token expiry before request
181-
And the library should log "DEBUG: Fetching transactions for account {accountId}"
181+
And the library should log "DEBUG: Fetching ALL transactions for account {accountId}"
182182
And the library should GET /api/banking/v1/accounts/{accountId}/transactions
183183
And the library should include Authorization header with bearer token
184184
And the library should include x-http-request-info header
@@ -192,53 +192,48 @@ Feature: Comdirect API Client Library
192192
And an account with accountId exists
193193
When the user requests transactions with transactionDirection="DEBIT"
194194
Then the library should add query parameter "transactionDirection=DEBIT"
195-
And the library should log "DEBUG: Fetching transactions (direction: DEBIT)"
195+
And the library should log "DEBUG: Fetching ALL transactions for account {accountId} (direction: DEBIT)"
196196
And the library should return only debit transactions
197197

198-
Scenario: Retrieve transactions with pagination
199-
Given the library is authenticated with valid tokens
200-
And an account with accountId exists
201-
When the user requests transactions with paging_first=10
202-
Then the library should add query parameter "paging-first=10"
203-
And the library should log "DEBUG: Fetching transactions (starting at: 10)"
204-
And the library should return transactions starting from index 10
205-
206198
Scenario: Retrieve transactions with booking status filter
207199
Given the library is authenticated with valid tokens
208200
And an account with accountId exists
209201
When the user requests transactions with transactionState="BOOKED"
210202
Then the library should add query parameter "transactionState=BOOKED"
211-
And the library should log "DEBUG: Fetching BOOKED transactions"
203+
And the library should log "DEBUG: Fetching ALL transactions for account {accountId} (state: BOOKED)"
212204
And the library should return only booked transactions
213205

214-
Scenario: Parse transaction response into typed structures
215-
Given the library receives a valid transactions response
216-
When the library parses the response
217-
Then each transaction should be converted to a Transaction object
218-
And each Transaction should have a bookingStatus property of type str
219-
And each Transaction should have a bookingDate property of type Optional[date]
220-
And each Transaction should have an amount property of type Optional[AmountValue]
221-
And each Transaction should have optional remitter property of type Optional[AccountInformation]
222-
And each Transaction should have optional creditor property of type Optional[AccountInformation]
223-
And each Transaction should have a reference property of type str
224-
And each Transaction should have a transactionType property of type Optional[EnumText]
225-
And each Transaction should have a remittanceInfo property of type Optional[str]
226-
And negative amounts should indicate outgoing transactions
227-
And positive amounts should indicate incoming transactions
228-
And the library should log "DEBUG: Parsed {count} transaction objects"
229-
230-
Scenario: Handle transactions with null optional fields gracefully
231-
Given the library receives a transactions response with null fields
232-
And some transactions have null amount
233-
And some transactions have null transactionType
234-
And some transactions have null remittanceInfo
235-
And some transactions have null bookingDate
236-
And some AccountInformation objects have null iban
237-
When the library parses the response
238-
Then the library should not raise an exception
239-
And Transaction objects should be created with None values for null fields
240-
And the library should safely handle from_dict() calls on null nested objects
241-
And the library should log "DEBUG: Parsed {count} transaction objects"
206+
Scenario: Parse transaction response into typed structures
207+
Given the library receives a valid transactions response
208+
When the library parses the response
209+
Then each transaction should be converted to a Transaction object
210+
And each Transaction should have a bookingStatus property of type str
211+
And each Transaction should have a bookingDate property of type Optional[date]
212+
And each Transaction should have an amount property of type Optional[AmountValue]
213+
And each Transaction should have optional remitter property of type Optional[AccountInformation]
214+
And each Transaction should have optional creditor property of type Optional[AccountInformation]
215+
And each Transaction should have a reference property of type str
216+
And each Transaction should have a transactionType property of type Optional[EnumText]
217+
And each Transaction should have a remittanceLines field of type list[str]
218+
And each Transaction should expose a remittance_lines property returning the same list
219+
And negative amounts should indicate outgoing transactions
220+
And positive amounts should indicate incoming transactions
221+
And the library should log "DEBUG: Parsed {count} transaction objects"
222+
223+
224+
Scenario: Handle transactions with null optional fields gracefully
225+
Given the library receives a transactions response with null fields
226+
And some transactions have null amount
227+
And some transactions have null transactionType
228+
And some transactions have null bookingDate
229+
And some AccountInformation objects have null iban
230+
When the library parses the response
231+
Then the library should not raise an exception
232+
And Transaction objects should be created with None values for null fields
233+
And Transaction objects should have an empty remittanceLines list when remittanceInfo is missing or empty
234+
And the library should safely handle from_dict() calls on null nested objects
235+
And the library should log "DEBUG: Parsed {count} transaction objects"
236+
242237

243238
# ============================================================================
244239
# Error Handling and Logging
@@ -551,3 +546,32 @@ Feature: Comdirect API Client Library
551546
And transaction.debtor should be populated from "debtor"
552547
And "deptor" should be ignored
553548

549+
# ============================================================================
550+
# New: remittance line marker handling
551+
# ============================================================================
552+
553+
Scenario: Parse remittanceInfo with numbered line prefixes into remittanceLines
554+
Given the library receives a valid transactions response
555+
And some transactions have remittanceInfo values with numbered line prefixes
556+
When the library parses the response
557+
Then each Transaction should have a remittanceLines field of type list[str]
558+
And each Transaction should expose a remittance_lines property returning the same list
559+
And the library should detect numbered line markers (01, 02, 03, ... up to 99)
560+
And the library should support both short test-format remittanceInfo strings
561+
And the library should support long fixed-width remittanceInfo strings from the real API
562+
And for long-format strings, the library should detect markers using approximate spacing between markers
563+
And the library should extract the content after each marker as a logical line
564+
And the library should populate remittanceLines with one entry per logical line, in order
565+
And example "01AA02 N84G BFT2 Y5KY 02End-to-End-Ref.: 03nicht angegeben " should produce remittanceLines:
566+
| AA02 N84G BFT2 Y5KY
567+
| End-to-End-Ref.:
568+
| nicht angegeben
569+
And example "01Storno Echtzeitüberweisung 02AA02 N84G BFT2 Y5KY 03Rückgabegrund: 04Auf Veranlassung der Bank " should produce remittanceLines:
570+
| Storno Echtzeitüberweisung
571+
| AA02 N84G BFT2 Y5KY
572+
| Rückgabegrund:
573+
| Auf Veranlassung der Bank
574+
And the library should strip trailing whitespace from each line
575+
And the library should skip empty lines after stripping
576+
577+

comdirect_client/client.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -762,22 +762,24 @@ async def get_transactions(
762762
account_id: str,
763763
transaction_state: Optional[str] = None,
764764
transaction_direction: Optional[str] = None,
765-
paging_first: Optional[int] = None,
766765
with_attributes: bool = True,
767766
without_attributes: Optional[str] = None,
768767
) -> list[Transaction]:
769-
"""Retrieve transactions for a specific account.
768+
"""Retrieve **all available** transactions for a specific account.
769+
770+
This method now uses the API's maximum page size to fetch up to
771+
500 transactions in a single call (API limit). For accounts with more
772+
than 500 transactions, only the most recent 500 will be returned.
770773
771774
Args:
772775
account_id: Account UUID (from AccountBalance.accountId)
773776
transaction_state: Optional filter: "BOOKED", "NOTBOOKED", or "BOTH" (default: "BOTH")
774777
transaction_direction: Optional filter: "CREDIT", "DEBIT", or "CREDIT_AND_DEBIT" (default: "CREDIT_AND_DEBIT")
775-
paging_first: Optional index of first transaction for pagination (default: 0)
776778
with_attributes: Include account details in response (default: True)
777779
without_attributes: Comma-separated list of attributes to exclude (optional)
778780
779781
Returns:
780-
List of Transaction objects
782+
List of Transaction objects (up to 500 per call)
781783
782784
Raises:
783785
TokenExpiredError: If authentication token is expired
@@ -788,14 +790,12 @@ async def get_transactions(
788790
"""
789791
await self._ensure_authenticated()
790792

791-
# Build query parameters
792-
params: dict[str, str] = {}
793+
# Build query parameters with maximum page size
794+
params: dict[str, str] = {"paging-count": "500"}
793795
if transaction_state:
794796
params["transactionState"] = transaction_state
795797
if transaction_direction:
796798
params["transactionDirection"] = transaction_direction
797-
if paging_first is not None:
798-
params["paging-first"] = str(paging_first)
799799
if not with_attributes:
800800
params["without-attr"] = "account"
801801
if without_attributes:
@@ -804,13 +804,11 @@ async def get_transactions(
804804
else:
805805
params["without-attr"] = without_attributes
806806

807-
log_msg = f"Fetching transactions for account {account_id[:8]}..."
807+
log_msg = f"Fetching ALL transactions for account {account_id[:8]}... (max: {params['paging-count']})"
808808
if transaction_direction:
809809
log_msg += f" (direction: {transaction_direction})"
810810
if transaction_state:
811811
log_msg += f" (state: {transaction_state})"
812-
if paging_first is not None:
813-
log_msg += f" (starting at: {paging_first})"
814812
logger.debug(log_msg)
815813

816814
try:
@@ -821,7 +819,7 @@ async def get_transactions(
821819
"Accept": "application/json",
822820
"x-http-request-info": self._get_request_info_header(),
823821
},
824-
params=params if params else None,
822+
params=params,
825823
)
826824

827825
if response.status_code == 401:
@@ -835,7 +833,6 @@ async def get_transactions(
835833
account_id,
836834
transaction_state,
837835
transaction_direction,
838-
paging_first,
839836
with_attributes,
840837
without_attributes,
841838
)
@@ -856,9 +853,18 @@ async def get_transactions(
856853
data = response.json()
857854

858855
transactions = [Transaction.from_dict(item) for item in data["values"]]
859-
logger.info(f"Retrieved {len(transactions)} transactions")
856+
logger.info(
857+
f"Retrieved {len(transactions)} transactions for account {account_id[:8]}..."
858+
)
860859
logger.debug(f"Parsed {len(transactions)} transaction objects")
861860

861+
# Log pagination info for debugging
862+
if "paging" in data:
863+
paging_info = data["paging"]
864+
logger.debug(
865+
f"Pagination: index={paging_info.get('index')}, matches={paging_info.get('matches')}"
866+
)
867+
862868
return transactions
863869

864870
except httpx.TimeoutException as e:

comdirect_client/models.py

Lines changed: 119 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Data models for the Comdirect API client."""
22

3-
from dataclasses import dataclass
3+
from dataclasses import dataclass, field
44
from datetime import date
55
from decimal import Decimal
66
from typing import Any, Optional
@@ -100,6 +100,108 @@ def from_dict(cls, data: dict[str, Any]) -> "AccountBalance":
100100
)
101101

102102

103+
def _parse_remittance_info(remittance: Optional[str]) -> list[str]:
104+
"""Parse Comdirect remittanceInfo string into logical lines.
105+
106+
The Comdirect API encodes line breaks in remittanceInfo by prefixing each
107+
logical line with a two-digit sequence number ("01", "02", ...).
108+
109+
Two formats are supported:
110+
1. Long format (real API data): Markers at ~37-char intervals
111+
2. Short format (test data): Markers with variable/close spacing
112+
113+
This function adapts to detect which format is used and extracts lines accordingly.
114+
"""
115+
116+
if not remittance:
117+
return []
118+
119+
text = remittance.strip()
120+
if not text:
121+
return []
122+
123+
length = len(text)
124+
125+
# Find the first marker (01)
126+
first_pos = -1
127+
if length >= 2 and text[0:2] == "01":
128+
first_pos = 0
129+
else:
130+
for i in range(length - 2):
131+
if text[i].isspace() and text[i + 1 : i + 3] == "01":
132+
first_pos = i + 1
133+
break
134+
135+
if first_pos == -1:
136+
# No valid starting marker – treat entire string as single line
137+
return [text]
138+
139+
# Detect format based on total length and marker spacing
140+
# Long format typically has 37-char intervals and total length > 100
141+
# Short format has variable/close spacing and total length < 100
142+
is_long_format = length > 100
143+
144+
marker_positions: list[int] = [first_pos]
145+
expected_marker = 2
146+
147+
if is_long_format:
148+
# Long format: use spacing heuristic with tolerance
149+
expected_pos = first_pos + 37
150+
tolerance = 15
151+
152+
while expected_marker <= 99:
153+
found = False
154+
search_start = max(marker_positions[-1] + 20, expected_pos - tolerance)
155+
search_end = min(length - 1, expected_pos + tolerance)
156+
157+
for pos in range(search_start, search_end):
158+
if pos + 1 < length and text[pos].isdigit() and text[pos + 1].isdigit():
159+
marker_value = int(text[pos : pos + 2])
160+
if marker_value == expected_marker:
161+
marker_positions.append(pos)
162+
expected_pos = pos + 37
163+
expected_marker += 1
164+
found = True
165+
break
166+
167+
if not found:
168+
break
169+
else:
170+
# Short format: scan for markers that appear after whitespace
171+
# This avoids false positives in timestamps like "2020-01-03T20:07:16"
172+
while expected_marker <= 99:
173+
found = False
174+
search_start = marker_positions[-1] + 2
175+
176+
for pos in range(search_start, length - 1):
177+
# Marker must be after whitespace (not in middle of numbers/text)
178+
if (
179+
text[pos].isdigit()
180+
and text[pos + 1].isdigit()
181+
and (pos == 0 or text[pos - 1].isspace())
182+
):
183+
marker_value = int(text[pos : pos + 2])
184+
if marker_value == expected_marker:
185+
marker_positions.append(pos)
186+
expected_marker += 1
187+
found = True
188+
break
189+
190+
if not found:
191+
break
192+
193+
# Extract lines between markers
194+
lines: list[str] = []
195+
for idx, pos in enumerate(marker_positions):
196+
start = pos + 2 # skip the two-digit marker
197+
end = marker_positions[idx + 1] if idx + 1 < len(marker_positions) else length
198+
line = text[start:end].strip()
199+
if line:
200+
lines.append(line)
201+
202+
return lines if lines else [text]
203+
204+
103205
@dataclass
104206
class Transaction:
105207
"""Bank account transaction.
@@ -116,8 +218,9 @@ class Transaction:
116218
newTransaction: bool
117219
amount: Optional[AmountValue] = None
118220
transactionType: Optional[EnumText] = None
119-
remittanceInfo: Optional[str] = None
221+
remittanceLines: list[str] = field(default_factory=list)
120222
bookingDate: Optional[date] = None
223+
121224
remitter: Optional[AccountInformation] = None
122225
debtor: Optional[AccountInformation] = None
123226
creditor: Optional[AccountInformation] = None
@@ -141,13 +244,15 @@ def from_dict(cls, data: dict[str, Any]) -> "Transaction":
141244
if data.get("transactionType"):
142245
transaction_type = EnumText.from_dict(data["transactionType"])
143246

247+
remittance_lines = _parse_remittance_info(data.get("remittanceInfo"))
248+
144249
return cls(
145250
bookingStatus=data["bookingStatus"],
146251
amount=amount,
147252
reference=data["reference"],
148253
valutaDate=data["valutaDate"],
149254
transactionType=transaction_type,
150-
remittanceInfo=data.get("remittanceInfo"),
255+
remittanceLines=remittance_lines,
151256
newTransaction=data["newTransaction"],
152257
bookingDate=booking_date,
153258
remitter=(
@@ -165,3 +270,14 @@ def from_dict(cls, data: dict[str, Any]) -> "Transaction":
165270
directDebitCreditorId=data.get("directDebitCreditorId"),
166271
directDebitMandateId=data.get("directDebitMandateId"),
167272
)
273+
274+
@property
275+
def remittance_lines(self) -> list[str]:
276+
"""Return remittance lines for this transaction.
277+
278+
The raw `remittanceInfo` string from the API is parsed once in
279+
`from_dict` into `remittanceLines`. This property simply returns
280+
that list.
281+
"""
282+
283+
return self.remittanceLines

0 commit comments

Comments
 (0)