ebicsclient 0.1.1__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/PKG-INFO +5 -5
  2. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/README.md +4 -4
  3. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/pyproject.toml +1 -1
  4. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/src/ebicsclient/__init__.py +16 -0
  5. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/src/ebicsclient/client.py +74 -1
  6. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/src/ebicsclient/errors.py +23 -0
  7. ebicsclient-0.2.0/src/ebicsclient/formats/camt.py +216 -0
  8. ebicsclient-0.2.0/src/ebicsclient/formats/camt052.py +39 -0
  9. ebicsclient-0.2.0/src/ebicsclient/formats/camt053.py +36 -0
  10. ebicsclient-0.2.0/src/ebicsclient/formats/camt054.py +42 -0
  11. ebicsclient-0.2.0/src/ebicsclient/formats/container.py +36 -0
  12. ebicsclient-0.2.0/src/ebicsclient/formats/pain002.py +151 -0
  13. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/src/ebicsclient/models.py +133 -0
  14. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/src/ebicsclient/protocol/h005.py +47 -19
  15. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/src/ebicsclient/transport.py +29 -3
  16. ebicsclient-0.2.0/tests/data/camt052_zkb_sample.xml +2 -0
  17. ebicsclient-0.2.0/tests/data/camt054_collective_zkb_sample.xml +2 -0
  18. ebicsclient-0.2.0/tests/data/camt054_credit_zkb_sample.xml +2 -0
  19. ebicsclient-0.2.0/tests/data/camt054_debit_zkb_sample.xml +2 -0
  20. ebicsclient-0.2.0/tests/data/pain002_accp.xml +2 -0
  21. ebicsclient-0.2.0/tests/data/pain002_actc.xml +2 -0
  22. ebicsclient-0.2.0/tests/data/pain002_part.xml +2 -0
  23. ebicsclient-0.2.0/tests/data/pain002_rjct.xml +2 -0
  24. ebicsclient-0.2.0/tests/test_camt052_054.py +95 -0
  25. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tests/test_client.py +15 -0
  26. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tests/test_h005.py +53 -3
  27. ebicsclient-0.2.0/tests/test_pain002.py +111 -0
  28. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tests/test_transport.py +50 -22
  29. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/uv.lock +1 -1
  30. ebicsclient-0.1.1/src/ebicsclient/formats/camt053.py +0 -214
  31. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/.github/workflows/ci.yml +0 -0
  32. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/.github/workflows/release.yml +0 -0
  33. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/.gitignore +0 -0
  34. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/CLAUDE.md +0 -0
  35. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/CONTRIBUTING.md +0 -0
  36. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/LICENSE.md +0 -0
  37. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/docs/01-protocol-and-formats.md +0 -0
  38. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/docs/02-licensing-strategy.md +0 -0
  39. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/docs/03-library-landscape.md +0 -0
  40. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/docs/04-implementation-plan.md +0 -0
  41. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/docs/05-zkb-onboarding.md +0 -0
  42. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/docs/06-engineering-conventions.md +0 -0
  43. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/docs/07-handshake-testing.md +0 -0
  44. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/docs/08-parity-and-xsd-findings.md +0 -0
  45. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/docs/09-zkb-test-platform-settings.md +0 -0
  46. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/docs/10-btf-order-types.md +0 -0
  47. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/docs/11-certificate-profiles.md +0 -0
  48. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/examples/zkb_handshake.py +0 -0
  49. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/src/ebicsclient/certificates.py +0 -0
  50. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/src/ebicsclient/crypto.py +0 -0
  51. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/src/ebicsclient/formats/__init__.py +0 -0
  52. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/src/ebicsclient/keys.py +0 -0
  53. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/src/ebicsclient/letter.py +0 -0
  54. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/src/ebicsclient/protocol/__init__.py +0 -0
  55. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/src/ebicsclient/py.typed +0 -0
  56. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tests/crypto_helpers.py +0 -0
  57. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tests/data/camt053_zkb_sample.xml +0 -0
  58. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tests/fixtures/parity/hia.xml +0 -0
  59. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tests/fixtures/parity/hpb.xml +0 -0
  60. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tests/fixtures/parity/ini.xml +0 -0
  61. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tests/fixtures/parity/meta.json +0 -0
  62. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tests/test_c14n_vectors.py +0 -0
  63. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tests/test_camt053.py +0 -0
  64. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tests/test_certificates.py +0 -0
  65. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tests/test_crypto.py +0 -0
  66. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tests/test_errors.py +0 -0
  67. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tests/test_keys.py +0 -0
  68. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tests/test_letter.py +0 -0
  69. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tests/test_php_parity.py +0 -0
  70. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tests/test_schema_validation.py +0 -0
  71. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tools/fetch-schemas.py +0 -0
  72. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tools/php-parity/README.md +0 -0
  73. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tools/php-parity/composer.json +0 -0
  74. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tools/php-parity/composer.lock +0 -0
  75. {ebicsclient-0.1.1 → ebicsclient-0.2.0}/tools/php-parity/generate.php +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ebicsclient
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Pure-Python client for the EBICS 3.0 (H005) banking protocol
5
5
  Project-URL: Homepage, https://github.com/schulluk/ebicsclient
6
6
  Project-URL: Repository, https://github.com/schulluk/ebicsclient
@@ -232,11 +232,11 @@ a real bank statement.
232
232
  - [x] Golden regression fixture from a real ZKB statement
233
233
  - [x] CI (ruff / mypy --strict / pytest) and tag-triggered PyPI releases (Trusted Publishing)
234
234
 
235
- **Milestone 5 — Message formats** (next)
235
+ **Milestone 5 — Message formats** (parsers built against genuine ZKB messages)
236
236
 
237
- - [ ] pain.002 status-report parser (accepted / rejected transactions)
238
- - [ ] camt.052 intraday statements
239
- - [ ] camt.054 booking advices (QRR / SCOR / LSV)
237
+ - [x] pain.002 status-report parser (group / payment / transaction statuses, reason codes)
238
+ - [x] camt.052 intraday reports
239
+ - [x] camt.054 booking advices (incl. the QRR / SCOR / LSV variants via `service_option`)
240
240
 
241
241
  **Milestone 6 — Protocol conveniences & breadth**
242
242
 
@@ -114,11 +114,11 @@ a real bank statement.
114
114
  - [x] Golden regression fixture from a real ZKB statement
115
115
  - [x] CI (ruff / mypy --strict / pytest) and tag-triggered PyPI releases (Trusted Publishing)
116
116
 
117
- **Milestone 5 — Message formats** (next)
117
+ **Milestone 5 — Message formats** (parsers built against genuine ZKB messages)
118
118
 
119
- - [ ] pain.002 status-report parser (accepted / rejected transactions)
120
- - [ ] camt.052 intraday statements
121
- - [ ] camt.054 booking advices (QRR / SCOR / LSV)
119
+ - [x] pain.002 status-report parser (group / payment / transaction statuses, reason codes)
120
+ - [x] camt.052 intraday reports
121
+ - [x] camt.054 booking advices (incl. the QRR / SCOR / LSV variants via `service_option`)
122
122
 
123
123
  **Milestone 6 — Protocol conveniences & breadth**
124
124
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ebicsclient"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Pure-Python client for the EBICS 3.0 (H005) banking protocol"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -33,6 +33,7 @@ from ebicsclient.errors import (
33
33
  Retryability,
34
34
  ReturnCodeError,
35
35
  TransportError,
36
+ UnknownReturnCodeError,
36
37
  )
37
38
  from ebicsclient.keys import (
38
39
  CertificateUsage,
@@ -46,7 +47,9 @@ from ebicsclient.keys import (
46
47
  serialize_keyring,
47
48
  )
48
49
  from ebicsclient.models import (
50
+ CAMT_052,
49
51
  CAMT_053,
52
+ CAMT_054,
50
53
  PAIN_001,
51
54
  PAIN_002,
52
55
  Balance,
@@ -59,8 +62,13 @@ from ebicsclient.models import (
59
62
  InitializationState,
60
63
  Keyring,
61
64
  Letter,
65
+ Notification,
62
66
  OutputFormat,
67
+ PaymentStatus,
68
+ PaymentStatusReport,
63
69
  Statement,
70
+ StatusReason,
71
+ TransactionStatus,
64
72
  UploadPayload,
65
73
  User,
66
74
  )
@@ -76,7 +84,9 @@ except PackageNotFoundError: # running from a source tree without an install
76
84
  __version__ = "0.0.0"
77
85
 
78
86
  __all__ = [
87
+ "CAMT_052",
79
88
  "CAMT_053",
89
+ "CAMT_054",
80
90
  "PAIN_001",
81
91
  "PAIN_002",
82
92
  "Balance",
@@ -105,14 +115,20 @@ __all__ = [
105
115
  "MappingCertificateProvider",
106
116
  "MessageFormatError",
107
117
  "MissingDependencyError",
118
+ "Notification",
108
119
  "OutputFormat",
120
+ "PaymentStatus",
121
+ "PaymentStatusReport",
109
122
  "ProtocolError",
110
123
  "Retryability",
111
124
  "ReturnCodeError",
112
125
  "SelfSignedCertificateProvider",
113
126
  "Statement",
127
+ "StatusReason",
128
+ "TransactionStatus",
114
129
  "TransportError",
115
130
  "TrustAnchorVerifier",
131
+ "UnknownReturnCodeError",
116
132
  "UploadPayload",
117
133
  "User",
118
134
  "__version__",
@@ -15,9 +15,12 @@ from ebicsclient.certificates import (
15
15
  CertificateProvider,
16
16
  )
17
17
  from ebicsclient.errors import BankKeyMismatchError, ClientStateError, ReturnCodeError
18
- from ebicsclient.formats import camt053
18
+ from ebicsclient.formats import camt052, camt053, camt054, pain002
19
19
  from ebicsclient.models import (
20
+ CAMT_052,
20
21
  CAMT_053,
22
+ CAMT_054,
23
+ PAIN_002,
21
24
  Bank,
22
25
  BankKeyHashes,
23
26
  BankKeys,
@@ -25,7 +28,9 @@ from ebicsclient.models import (
25
28
  InitializationState,
26
29
  Keyring,
27
30
  Letter,
31
+ Notification,
28
32
  OutputFormat,
33
+ PaymentStatusReport,
29
34
  Statement,
30
35
  User,
31
36
  )
@@ -296,6 +301,74 @@ class Client:
296
301
  """
297
302
  return camt053.parse(self.download(CAMT_053))
298
303
 
304
+ def download_intraday_statements(self) -> list[Statement]:
305
+ """Download the intraday camt.052 account reports and parse them.
306
+
307
+ A convenience over :meth:`download` for ``STM/camt.052``. Intraday reports share
308
+ the statement shape; interim balances (if any) are in ``balances`` rather than
309
+ ``opening_balance``/``closing_balance``. The bank's keys must already be available
310
+ (call :meth:`hpb` first).
311
+
312
+ Returns:
313
+ The intraday reports the bank delivered, in document order.
314
+
315
+ Raises:
316
+ ClientStateError: the bank's keys have not been fetched yet (run HPB first).
317
+ TransportError: a request could not be delivered.
318
+ ProtocolError: a response could not be parsed.
319
+ ReturnCodeError: the bank reported a non-OK return code (e.g. no data available).
320
+ CryptoError: the order data could not be decrypted.
321
+ MessageFormatError: the downloaded camt.052 data could not be parsed.
322
+ """
323
+ return camt052.parse(self.download(CAMT_052))
324
+
325
+ def download_booking_advices(
326
+ self, btf: BusinessTransactionFormat = CAMT_054
327
+ ) -> list[Notification]:
328
+ """Download camt.054 debit/credit notifications (booking advices) and parse them.
329
+
330
+ A convenience over :meth:`download` for ``REP/camt.054``. Pass a variant BTF (the
331
+ same tuple with a ``service_option`` such as ``"XQRR"``/``"XSCR"``) for the Swiss
332
+ collective-resolution advices. The bank's keys must already be available (call
333
+ :meth:`hpb` first).
334
+
335
+ Args:
336
+ btf: The camt.054 Business Transaction Format; defaults to the plain ``CAMT_054``.
337
+
338
+ Returns:
339
+ The notifications the bank delivered, in document order.
340
+
341
+ Raises:
342
+ ClientStateError: the bank's keys have not been fetched yet (run HPB first).
343
+ TransportError: a request could not be delivered.
344
+ ProtocolError: a response could not be parsed.
345
+ ReturnCodeError: the bank reported a non-OK return code (e.g. no data available).
346
+ CryptoError: the order data could not be decrypted.
347
+ MessageFormatError: the downloaded camt.054 data could not be parsed.
348
+ """
349
+ return camt054.parse(self.download(btf))
350
+
351
+ def download_payment_status_reports(self) -> list[PaymentStatusReport]:
352
+ """Download the pain.002 payment status reports and parse them.
353
+
354
+ A convenience over :meth:`download` for the common case: it fetches
355
+ ``PSR/pain.002`` and returns the parsed reports — the bank's verdicts on
356
+ previously uploaded pain.001 files, including per-transaction rejections.
357
+ The bank's keys must already be available (call :meth:`hpb` first).
358
+
359
+ Returns:
360
+ The payment status reports the bank delivered, in document order.
361
+
362
+ Raises:
363
+ ClientStateError: the bank's keys have not been fetched yet (run HPB first).
364
+ TransportError: a request could not be delivered.
365
+ ProtocolError: a response could not be parsed.
366
+ ReturnCodeError: the bank reported a non-OK return code (e.g. no data available).
367
+ CryptoError: the order data could not be decrypted.
368
+ MessageFormatError: the downloaded pain.002 data could not be parsed.
369
+ """
370
+ return pain002.parse(self.download(PAIN_002))
371
+
299
372
  def upload(self, btf: BusinessTransactionFormat, order_data: bytes) -> str:
300
373
  """Upload order data for a Business Transaction Format and return the transaction ID.
301
374
 
@@ -161,3 +161,26 @@ class ReturnCodeError(ProtocolError):
161
161
  self.code = code
162
162
  self.text = text
163
163
  super().__init__(code if text is None else f"{code}: {text}", retryability=retryability)
164
+
165
+
166
+ class UnknownReturnCodeError(ReturnCodeError):
167
+ """The bank returned an EBICS code this client does not recognise.
168
+
169
+ Deliberately distinct from a *known* failure: the code is outside the client's
170
+ knowledge, so its meaning is unverified — it could be a bank-specific extension, a
171
+ spec code not yet in the client's table, or even a success variant (as ``011000``
172
+ once was). It is treated as a failure (fail closed, never fail open), but callers and
173
+ logs can tell it apart and verify the code against the EBICS specification and the
174
+ bank's documentation instead of trusting a masked classification.
175
+ """
176
+
177
+ def __init__(self, code: str, text: str | None = None) -> None:
178
+ self.code = code
179
+ self.text = text
180
+ detail = f" ({text})" if text is not None else ""
181
+ # Bypass ReturnCodeError.__init__ so the message carries the unknown-code framing.
182
+ ProtocolError.__init__(
183
+ self,
184
+ f"Unknown EBICS return code {code}{detail} — failing closed; verify the code "
185
+ f"against the EBICS specification and the bank's documentation",
186
+ )
@@ -0,0 +1,216 @@
1
+ """Shared parsing core for the ISO 20022 camt account-reporting family.
2
+
3
+ camt.053 statements (``BkToCstmrStmt/Stmt``), camt.052 intraday reports
4
+ (``BkToCstmrAcctRpt/Rpt``), and camt.054 debit/credit notifications
5
+ (``BkToCstmrDbtCdtNtfctn/Ntfctn``) all share one core shape: an identification, an
6
+ account, and booking entries — statements and reports additionally carry balances. This
7
+ module holds that shared machinery; the thin per-type modules
8
+ (:mod:`~ebicsclient.formats.camt053`, :mod:`~ebicsclient.formats.camt052`,
9
+ :mod:`~ebicsclient.formats.camt054`) only name their wrapper elements and models.
10
+
11
+ Element names are read namespace-agnostically: each document's camt namespace is adopted
12
+ from its root, so a different message version still parses without a code change.
13
+ """
14
+
15
+ import datetime
16
+ from decimal import Decimal, InvalidOperation
17
+
18
+ from lxml import etree
19
+
20
+ from ebicsclient.errors import MessageFormatError
21
+ from ebicsclient.models import Balance, CreditDebit, Entry, Statement
22
+
23
+ _DOCUMENT = "Document"
24
+ # Closing/opening booked balances are selected by their ISO external balance-type code.
25
+ _CLOSING_BOOKED = "CLBD"
26
+ _OPENING_BOOKED = "OPBD"
27
+
28
+
29
+ def document_items(
30
+ document: bytes, message: str, wrapper: str, item: str
31
+ ) -> list[tuple[etree._Element, str]]:
32
+ """Extract the per-account item elements from one camt XML document.
33
+
34
+ Args:
35
+ document: The raw XML document bytes.
36
+ message: The message name for error reporting (e.g. ``"camt.053"``).
37
+ wrapper: The wrapper element under ``Document`` (e.g. ``"BkToCstmrStmt"``).
38
+ item: The repeated item element (e.g. ``"Stmt"``, ``"Rpt"``, ``"Ntfctn"``).
39
+
40
+ Returns:
41
+ The item elements paired with the document's namespace, in document order.
42
+
43
+ Raises:
44
+ MessageFormatError: the document is malformed or not the expected shape.
45
+ """
46
+ root = _parse_xml(document, message)
47
+ local_name = etree.QName(root).localname
48
+ if local_name != _DOCUMENT:
49
+ raise MessageFormatError(f"Expected a {message} <Document>, got <{local_name}>")
50
+ namespace = etree.QName(root).namespace
51
+ if namespace is None:
52
+ raise MessageFormatError(f"{message} <Document> declares no namespace")
53
+ report = child(root, namespace, wrapper)
54
+ return [(element, namespace) for element in children(report, namespace, item)]
55
+
56
+
57
+ def parse_statement(statement: etree._Element, namespace: str) -> Statement:
58
+ """Parse one ``Stmt``/``Rpt`` element into a :class:`~ebicsclient.models.Statement`."""
59
+ balances = tuple(
60
+ parse_balance(balance, namespace) for balance in children(statement, namespace, "Bal")
61
+ )
62
+ return Statement(
63
+ identification=identification(statement, namespace),
64
+ iban=iban(statement, namespace),
65
+ opening_balance=balance_with_code(balances, _OPENING_BOOKED),
66
+ closing_balance=balance_with_code(balances, _CLOSING_BOOKED),
67
+ balances=balances,
68
+ entries=parse_entries(statement, namespace),
69
+ )
70
+
71
+
72
+ def identification(item: etree._Element, namespace: str) -> str:
73
+ """Read the item's ``Id``."""
74
+ return text(child(item, namespace, "Id"))
75
+
76
+
77
+ def iban(item: etree._Element, namespace: str) -> str | None:
78
+ """Read the account IBAN (``Acct/Id/IBAN``), or ``None`` if another id is used."""
79
+ account = optional_child(item, namespace, "Acct")
80
+ if account is None:
81
+ return None
82
+ account_id = optional_child(account, namespace, "Id")
83
+ if account_id is None:
84
+ return None
85
+ element = optional_child(account_id, namespace, "IBAN")
86
+ return text(element) if element is not None else None
87
+
88
+
89
+ def parse_entries(item: etree._Element, namespace: str) -> tuple[Entry, ...]:
90
+ """Parse every booking entry (``Ntry``) of one item, in document order."""
91
+ return tuple(_parse_entry(entry, namespace) for entry in children(item, namespace, "Ntry"))
92
+
93
+
94
+ def parse_balance(balance: etree._Element, namespace: str) -> Balance:
95
+ """Parse one ``Bal`` element."""
96
+ amount, currency = _amount(balance, namespace)
97
+ return Balance(
98
+ code=text(
99
+ child(child(child(balance, namespace, "Tp"), namespace, "CdOrPrtry"), namespace, "Cd")
100
+ ),
101
+ amount=amount,
102
+ currency=currency,
103
+ credit_debit=_credit_debit(balance, namespace),
104
+ date=_date(child(balance, namespace, "Dt"), namespace),
105
+ )
106
+
107
+
108
+ def balance_with_code(balances: tuple[Balance, ...], code: str) -> Balance | None:
109
+ """Return the first balance carrying the given ISO balance-type code, if any."""
110
+ return next((balance for balance in balances if balance.code == code), None)
111
+
112
+
113
+ def _parse_entry(entry: etree._Element, namespace: str) -> Entry:
114
+ amount, currency = _amount(entry, namespace)
115
+ reference = optional_child(entry, namespace, "AcctSvcrRef")
116
+ return Entry(
117
+ amount=amount,
118
+ currency=currency,
119
+ credit_debit=_credit_debit(entry, namespace),
120
+ status=_status(entry, namespace),
121
+ booking_date=_optional_date(entry, namespace, "BookgDt"),
122
+ value_date=_optional_date(entry, namespace, "ValDt"),
123
+ reference=text(reference) if reference is not None else None,
124
+ )
125
+
126
+
127
+ def _status(entry: etree._Element, namespace: str) -> str:
128
+ # The 2019 vintages wrap the status in <Sts><Cd>…</Cd></Sts>; older ones put the
129
+ # code straight in <Sts>. Accept either.
130
+ status = child(entry, namespace, "Sts")
131
+ code = optional_child(status, namespace, "Cd")
132
+ return text(code) if code is not None else text(status)
133
+
134
+
135
+ def _amount(parent: etree._Element, namespace: str) -> tuple[Decimal, str]:
136
+ element = child(parent, namespace, "Amt")
137
+ currency = element.get("Ccy")
138
+ if currency is None:
139
+ raise MessageFormatError("A camt <Amt> is missing its Ccy attribute")
140
+ value = element.text
141
+ if value is None:
142
+ raise MessageFormatError("A camt <Amt> is empty")
143
+ try:
144
+ return Decimal(value), currency
145
+ except InvalidOperation as error:
146
+ raise MessageFormatError(f"A camt <Amt> is not a decimal: {value!r}") from error
147
+
148
+
149
+ def _credit_debit(parent: etree._Element, namespace: str) -> CreditDebit:
150
+ value = text(child(parent, namespace, "CdtDbtInd"))
151
+ try:
152
+ return CreditDebit(value)
153
+ except ValueError as error:
154
+ raise MessageFormatError(f"A camt <CdtDbtInd> is not CRDT/DBIT: {value!r}") from error
155
+
156
+
157
+ def _optional_date(
158
+ parent: etree._Element, namespace: str, local_name: str
159
+ ) -> datetime.date | None:
160
+ element = optional_child(parent, namespace, local_name)
161
+ return _date(element, namespace) if element is not None else None
162
+
163
+
164
+ def _date(parent: etree._Element, namespace: str) -> datetime.date:
165
+ # A camt date choice is <Dt> (a date) or <DtTm> (a date-time); take the date part.
166
+ date = optional_child(parent, namespace, "Dt")
167
+ if date is not None:
168
+ return _parse_iso_date(text(date))
169
+ date_time = optional_child(parent, namespace, "DtTm")
170
+ if date_time is not None:
171
+ return _parse_iso_date(text(date_time)[:10])
172
+ raise MessageFormatError("A camt date is missing both <Dt> and <DtTm>")
173
+
174
+
175
+ def _parse_iso_date(value: str) -> datetime.date:
176
+ try:
177
+ return datetime.date.fromisoformat(value)
178
+ except ValueError as error:
179
+ raise MessageFormatError(f"A camt date is not an ISO date: {value!r}") from error
180
+
181
+
182
+ def child(parent: etree._Element, namespace: str, local_name: str) -> etree._Element:
183
+ """Return the required direct child, raising :class:`MessageFormatError` if absent."""
184
+ element = optional_child(parent, namespace, local_name)
185
+ if element is None:
186
+ raise MessageFormatError(f"A camt element is missing <{local_name}>")
187
+ return element
188
+
189
+
190
+ def optional_child(
191
+ parent: etree._Element, namespace: str, local_name: str
192
+ ) -> etree._Element | None:
193
+ """Return the direct child, or ``None`` if absent."""
194
+ return parent.find(f"{{{namespace}}}{local_name}")
195
+
196
+
197
+ def children(parent: etree._Element, namespace: str, local_name: str) -> list[etree._Element]:
198
+ """Return every direct child with the given name, in document order."""
199
+ return parent.findall(f"{{{namespace}}}{local_name}")
200
+
201
+
202
+ def text(element: etree._Element) -> str:
203
+ """Return the element's stripped text, raising if it is empty."""
204
+ value = element.text
205
+ if value is None:
206
+ raise MessageFormatError(f"A camt <{etree.QName(element).localname}> is empty")
207
+ return value.strip()
208
+
209
+
210
+ def _parse_xml(data: bytes, message: str) -> etree._Element:
211
+ # Hardened parser: no entity expansion, no network access (see docs/06).
212
+ parser = etree.XMLParser(resolve_entities=False, no_network=True, huge_tree=False)
213
+ try:
214
+ return etree.fromstring(data, parser=parser)
215
+ except etree.XMLSyntaxError as error:
216
+ raise MessageFormatError(f"Malformed {message} XML: {error}") from error
@@ -0,0 +1,39 @@
1
+ """Parser for ISO 20022 camt.052 intraday account reports.
2
+
3
+ A ``STM/camt.052`` download yields either a single camt.052 XML document or a ZIP
4
+ container holding several. An intraday report has the same shape as a camt.053
5
+ statement — identification, account, optional balances (interim rather than closing
6
+ codes), and booking entries — so :func:`parse` returns the same
7
+ :class:`~ebicsclient.models.Statement` model; ``opening_balance``/``closing_balance``
8
+ are usually ``None`` for intraday reports, with any interim balances in ``balances``.
9
+
10
+ The parsing core is shared with the other camt types in :mod:`ebicsclient.formats.camt`.
11
+ """
12
+
13
+ from ebicsclient.formats import camt
14
+ from ebicsclient.formats.container import extract_documents
15
+ from ebicsclient.models import Statement
16
+
17
+ _MESSAGE = "camt.052"
18
+
19
+
20
+ def parse(order_data: bytes) -> list[Statement]:
21
+ """Parse camt.052 order data into intraday account reports.
22
+
23
+ Args:
24
+ order_data: The raw order-data bytes a download returned — a single camt.052 XML
25
+ document or a ZIP container of them.
26
+
27
+ Returns:
28
+ Every report found, in document order (and, for a ZIP, in entry-name order).
29
+
30
+ Raises:
31
+ MessageFormatError: the data is not a readable camt.052 document or container.
32
+ """
33
+ return [
34
+ camt.parse_statement(element, namespace)
35
+ for document in extract_documents(order_data)
36
+ for element, namespace in camt.document_items(
37
+ document, _MESSAGE, "BkToCstmrAcctRpt", "Rpt"
38
+ )
39
+ ]
@@ -0,0 +1,36 @@
1
+ """Parser for ISO 20022 camt.053 account statements.
2
+
3
+ A ``EOP/camt.053`` download yields either a single camt.053 XML document or a ZIP
4
+ container holding several (one per statement period). :func:`parse` accepts the raw
5
+ order-data bytes from :meth:`ebicsclient.client.Client.download` in either form and
6
+ returns the statements as normalised :class:`~ebicsclient.models.Statement` models —
7
+ account, opening and closing booked balances, and booking entries.
8
+
9
+ The parsing core is shared with the other camt types in :mod:`ebicsclient.formats.camt`.
10
+ """
11
+
12
+ from ebicsclient.formats import camt
13
+ from ebicsclient.formats.container import extract_documents
14
+ from ebicsclient.models import Statement
15
+
16
+ _MESSAGE = "camt.053"
17
+
18
+
19
+ def parse(order_data: bytes) -> list[Statement]:
20
+ """Parse camt.053 order data into account statements.
21
+
22
+ Args:
23
+ order_data: The raw order-data bytes a download returned — a single camt.053 XML
24
+ document or a ZIP container of them.
25
+
26
+ Returns:
27
+ Every statement found, in document order (and, for a ZIP, in entry-name order).
28
+
29
+ Raises:
30
+ MessageFormatError: the data is not a readable camt.053 document or container.
31
+ """
32
+ return [
33
+ camt.parse_statement(element, namespace)
34
+ for document in extract_documents(order_data)
35
+ for element, namespace in camt.document_items(document, _MESSAGE, "BkToCstmrStmt", "Stmt")
36
+ ]
@@ -0,0 +1,42 @@
1
+ """Parser for ISO 20022 camt.054 debit/credit notifications (booking advices).
2
+
3
+ A ``REP/camt.054`` download yields either a single camt.054 XML document or a ZIP
4
+ container holding several — Swiss banks deliver one notification per booking advice
5
+ (credits, debits, and the QRR/SCOR/LSV collective resolutions as separate documents).
6
+ :func:`parse` returns them as :class:`~ebicsclient.models.Notification` models —
7
+ identification, account, and booking entries; notifications carry no balances.
8
+
9
+ The parsing core is shared with the other camt types in :mod:`ebicsclient.formats.camt`.
10
+ """
11
+
12
+ from ebicsclient.formats import camt
13
+ from ebicsclient.formats.container import extract_documents
14
+ from ebicsclient.models import Notification
15
+
16
+ _MESSAGE = "camt.054"
17
+
18
+
19
+ def parse(order_data: bytes) -> list[Notification]:
20
+ """Parse camt.054 order data into debit/credit notifications.
21
+
22
+ Args:
23
+ order_data: The raw order-data bytes a download returned — a single camt.054 XML
24
+ document or a ZIP container of them.
25
+
26
+ Returns:
27
+ Every notification found, in document order (and, for a ZIP, in entry-name order).
28
+
29
+ Raises:
30
+ MessageFormatError: the data is not a readable camt.054 document or container.
31
+ """
32
+ return [
33
+ Notification(
34
+ identification=camt.identification(element, namespace),
35
+ iban=camt.iban(element, namespace),
36
+ entries=camt.parse_entries(element, namespace),
37
+ )
38
+ for document in extract_documents(order_data)
39
+ for element, namespace in camt.document_items(
40
+ document, _MESSAGE, "BkToCstmrDbtCdtNtfctn", "Ntfctn"
41
+ )
42
+ ]
@@ -0,0 +1,36 @@
1
+ """Container handling shared by the message-format parsers.
2
+
3
+ EBICS download order data arrives either as a single XML document or as a ZIP container
4
+ holding several (one per message). Every format parser accepts both, so the splitting
5
+ logic lives here once.
6
+ """
7
+
8
+ import io
9
+ import zipfile
10
+
11
+ from ebicsclient.errors import MessageFormatError
12
+
13
+ _ZIP_MAGIC = b"PK\x03\x04"
14
+
15
+
16
+ def extract_documents(order_data: bytes) -> list[bytes]:
17
+ """Split raw order data into its individual XML documents.
18
+
19
+ Args:
20
+ order_data: The raw order-data bytes a download returned — a single XML document
21
+ or a ZIP container of them.
22
+
23
+ Returns:
24
+ The contained documents; for a ZIP, in entry-name order (stable and reproducible),
25
+ otherwise the input as a single-element list.
26
+
27
+ Raises:
28
+ MessageFormatError: the data looks like a ZIP but cannot be read.
29
+ """
30
+ if not order_data.startswith(_ZIP_MAGIC):
31
+ return [order_data]
32
+ try:
33
+ with zipfile.ZipFile(io.BytesIO(order_data)) as archive:
34
+ return [archive.read(name) for name in sorted(archive.namelist())]
35
+ except (zipfile.BadZipFile, OSError) as error:
36
+ raise MessageFormatError("Order data is not a readable ZIP container") from error