ebicsclient 0.1.0__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.0 → ebicsclient-0.2.0}/.github/workflows/release.yml +4 -4
  2. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/PKG-INFO +30 -7
  3. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/README.md +29 -6
  4. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/pyproject.toml +1 -1
  5. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/src/ebicsclient/__init__.py +16 -0
  6. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/src/ebicsclient/client.py +74 -1
  7. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/src/ebicsclient/errors.py +23 -0
  8. ebicsclient-0.2.0/src/ebicsclient/formats/camt.py +216 -0
  9. ebicsclient-0.2.0/src/ebicsclient/formats/camt052.py +39 -0
  10. ebicsclient-0.2.0/src/ebicsclient/formats/camt053.py +36 -0
  11. ebicsclient-0.2.0/src/ebicsclient/formats/camt054.py +42 -0
  12. ebicsclient-0.2.0/src/ebicsclient/formats/container.py +36 -0
  13. ebicsclient-0.2.0/src/ebicsclient/formats/pain002.py +151 -0
  14. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/src/ebicsclient/models.py +133 -0
  15. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/src/ebicsclient/protocol/h005.py +47 -19
  16. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/src/ebicsclient/transport.py +29 -3
  17. ebicsclient-0.2.0/tests/data/camt052_zkb_sample.xml +2 -0
  18. ebicsclient-0.2.0/tests/data/camt054_collective_zkb_sample.xml +2 -0
  19. ebicsclient-0.2.0/tests/data/camt054_credit_zkb_sample.xml +2 -0
  20. ebicsclient-0.2.0/tests/data/camt054_debit_zkb_sample.xml +2 -0
  21. ebicsclient-0.2.0/tests/data/pain002_accp.xml +2 -0
  22. ebicsclient-0.2.0/tests/data/pain002_actc.xml +2 -0
  23. ebicsclient-0.2.0/tests/data/pain002_part.xml +2 -0
  24. ebicsclient-0.2.0/tests/data/pain002_rjct.xml +2 -0
  25. ebicsclient-0.2.0/tests/test_camt052_054.py +95 -0
  26. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tests/test_client.py +15 -0
  27. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tests/test_h005.py +53 -3
  28. ebicsclient-0.2.0/tests/test_pain002.py +111 -0
  29. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tests/test_transport.py +50 -22
  30. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/uv.lock +1 -1
  31. ebicsclient-0.1.0/src/ebicsclient/formats/camt053.py +0 -214
  32. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/.github/workflows/ci.yml +0 -0
  33. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/.gitignore +0 -0
  34. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/CLAUDE.md +0 -0
  35. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/CONTRIBUTING.md +0 -0
  36. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/LICENSE.md +0 -0
  37. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/docs/01-protocol-and-formats.md +0 -0
  38. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/docs/02-licensing-strategy.md +0 -0
  39. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/docs/03-library-landscape.md +0 -0
  40. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/docs/04-implementation-plan.md +0 -0
  41. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/docs/05-zkb-onboarding.md +0 -0
  42. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/docs/06-engineering-conventions.md +0 -0
  43. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/docs/07-handshake-testing.md +0 -0
  44. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/docs/08-parity-and-xsd-findings.md +0 -0
  45. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/docs/09-zkb-test-platform-settings.md +0 -0
  46. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/docs/10-btf-order-types.md +0 -0
  47. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/docs/11-certificate-profiles.md +0 -0
  48. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/examples/zkb_handshake.py +0 -0
  49. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/src/ebicsclient/certificates.py +0 -0
  50. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/src/ebicsclient/crypto.py +0 -0
  51. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/src/ebicsclient/formats/__init__.py +0 -0
  52. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/src/ebicsclient/keys.py +0 -0
  53. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/src/ebicsclient/letter.py +0 -0
  54. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/src/ebicsclient/protocol/__init__.py +0 -0
  55. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/src/ebicsclient/py.typed +0 -0
  56. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tests/crypto_helpers.py +0 -0
  57. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tests/data/camt053_zkb_sample.xml +0 -0
  58. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tests/fixtures/parity/hia.xml +0 -0
  59. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tests/fixtures/parity/hpb.xml +0 -0
  60. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tests/fixtures/parity/ini.xml +0 -0
  61. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tests/fixtures/parity/meta.json +0 -0
  62. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tests/test_c14n_vectors.py +0 -0
  63. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tests/test_camt053.py +0 -0
  64. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tests/test_certificates.py +0 -0
  65. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tests/test_crypto.py +0 -0
  66. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tests/test_errors.py +0 -0
  67. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tests/test_keys.py +0 -0
  68. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tests/test_letter.py +0 -0
  69. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tests/test_php_parity.py +0 -0
  70. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tests/test_schema_validation.py +0 -0
  71. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tools/fetch-schemas.py +0 -0
  72. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tools/php-parity/README.md +0 -0
  73. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tools/php-parity/composer.json +0 -0
  74. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tools/php-parity/composer.lock +0 -0
  75. {ebicsclient-0.1.0 → ebicsclient-0.2.0}/tools/php-parity/generate.php +0 -0
@@ -50,11 +50,13 @@ jobs:
50
50
  publish:
51
51
  needs: build
52
52
  runs-on: ubuntu-latest
53
- # Environment secrets are only visible to jobs that reference their environment; if
54
- # PYPI_API_TOKEN lives in a differently named environment (or is a repo secret), adjust.
55
53
  environment:
56
54
  name: pypi
57
55
  url: https://pypi.org/p/ebicsclient
56
+ permissions:
57
+ # OIDC token for PyPI Trusted Publishing — no API token or password is stored
58
+ # anywhere; PyPI trusts this repository + workflow + environment combination.
59
+ id-token: write
58
60
  steps:
59
61
  - uses: actions/download-artifact@v4
60
62
  with:
@@ -63,5 +65,3 @@ jobs:
63
65
 
64
66
  - name: Publish to PyPI
65
67
  uses: pypa/gh-action-pypi-publish@release/v1
66
- with:
67
- password: ${{ secrets.PYPI_API_TOKEN }}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ebicsclient
3
- Version: 0.1.0
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
@@ -204,22 +204,45 @@ download path, and the payment upload (envelope, authentication signature, A006
204
204
  and order-data encryption) are all accepted by the bank, and the camt.053 parser is validated against
205
205
  a real bank statement.
206
206
 
207
+ **Milestone 1 — Key ceremony** (validated live on ZKB)
208
+
207
209
  - [x] Key generation + encrypted keyring, and EBICS public-key hashes
208
- - [x] Exception model with retryability classification
209
210
  - [x] Authentication signature (inclusive Canonical XML 1.0 + RSA-SHA256)
210
211
  - [x] HTTPS transport (TLS 1.2 floor, certifi fallback via the optional `tls` extra)
211
- - [x] INI/HIA/HPB handshake — validated live on ZKB
212
+ - [x] INI/HIA/HPB handshake
212
213
  - [x] X.509 key transmission: **mit Schlüsseln** (self-signed) and **mit Zertifikaten** (CA certs)
213
214
  - [x] Initialisation letter (HTML, or PDF via the optional `pdf` extra)
215
+ - [x] Bank-key pinning across sessions (`hpb(pinned=...)`)
216
+
217
+ **Milestone 2 — Read** (validated live on ZKB)
218
+
214
219
  - [x] Order-data decryption (RSA-unwrap + AES-128-CBC)
215
220
  - [x] Statement download — `EOP/camt.053` BTD transaction (initialise → transfer → receipt)
216
221
  - [x] camt.053 parsing (balances + entries) — validated on a real ZKB statement
217
- - [x] Payment upload — `MCT/pain.001` BTU transaction with the A006 electronic signature — accepted live
222
+
223
+ **Milestone 3 — Write** (validated live on ZKB)
224
+
225
+ - [x] Order-data encryption and the A006 electronic signature (RSASSA-PSS)
226
+ - [x] Payment upload — `MCT/pain.001` BTU transaction — accepted live
227
+
228
+ **Milestone 4 — Verification & release**
229
+
230
+ - [x] Exception model with retryability classification
218
231
  - [x] Offline verification: H005 XSD validation, C14N golden vectors, ebics-client-php parity
232
+ - [x] Golden regression fixture from a real ZKB statement
233
+ - [x] CI (ruff / mypy --strict / pytest) and tag-triggered PyPI releases (Trusted Publishing)
234
+
235
+ **Milestone 5 — Message formats** (parsers built against genuine ZKB messages)
236
+
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
+
241
+ **Milestone 6 — Protocol conveniences & breadth**
219
242
 
220
- Not yet built (deliberately out of the current scope): a pain.002 status-report parser, camt.052 /
221
- camt.054 reporting, distributed signatures (EDS), and non-H005 EBICS versions — the `protocol/` and
222
- `formats/` seams are in place for these.
243
+ - [ ] Subscriber self-inspection available order types (HAA) and subscriber info (HTD)
244
+ - [ ] Distributed signatures (EDS)
245
+ - [ ] Further EBICS versions (e.g. H004) via the `protocol/` seam
223
246
 
224
247
  ## License
225
248
 
@@ -86,22 +86,45 @@ download path, and the payment upload (envelope, authentication signature, A006
86
86
  and order-data encryption) are all accepted by the bank, and the camt.053 parser is validated against
87
87
  a real bank statement.
88
88
 
89
+ **Milestone 1 — Key ceremony** (validated live on ZKB)
90
+
89
91
  - [x] Key generation + encrypted keyring, and EBICS public-key hashes
90
- - [x] Exception model with retryability classification
91
92
  - [x] Authentication signature (inclusive Canonical XML 1.0 + RSA-SHA256)
92
93
  - [x] HTTPS transport (TLS 1.2 floor, certifi fallback via the optional `tls` extra)
93
- - [x] INI/HIA/HPB handshake — validated live on ZKB
94
+ - [x] INI/HIA/HPB handshake
94
95
  - [x] X.509 key transmission: **mit Schlüsseln** (self-signed) and **mit Zertifikaten** (CA certs)
95
96
  - [x] Initialisation letter (HTML, or PDF via the optional `pdf` extra)
97
+ - [x] Bank-key pinning across sessions (`hpb(pinned=...)`)
98
+
99
+ **Milestone 2 — Read** (validated live on ZKB)
100
+
96
101
  - [x] Order-data decryption (RSA-unwrap + AES-128-CBC)
97
102
  - [x] Statement download — `EOP/camt.053` BTD transaction (initialise → transfer → receipt)
98
103
  - [x] camt.053 parsing (balances + entries) — validated on a real ZKB statement
99
- - [x] Payment upload — `MCT/pain.001` BTU transaction with the A006 electronic signature — accepted live
104
+
105
+ **Milestone 3 — Write** (validated live on ZKB)
106
+
107
+ - [x] Order-data encryption and the A006 electronic signature (RSASSA-PSS)
108
+ - [x] Payment upload — `MCT/pain.001` BTU transaction — accepted live
109
+
110
+ **Milestone 4 — Verification & release**
111
+
112
+ - [x] Exception model with retryability classification
100
113
  - [x] Offline verification: H005 XSD validation, C14N golden vectors, ebics-client-php parity
114
+ - [x] Golden regression fixture from a real ZKB statement
115
+ - [x] CI (ruff / mypy --strict / pytest) and tag-triggered PyPI releases (Trusted Publishing)
116
+
117
+ **Milestone 5 — Message formats** (parsers built against genuine ZKB messages)
118
+
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
+
123
+ **Milestone 6 — Protocol conveniences & breadth**
101
124
 
102
- Not yet built (deliberately out of the current scope): a pain.002 status-report parser, camt.052 /
103
- camt.054 reporting, distributed signatures (EDS), and non-H005 EBICS versions — the `protocol/` and
104
- `formats/` seams are in place for these.
125
+ - [ ] Subscriber self-inspection available order types (HAA) and subscriber info (HTD)
126
+ - [ ] Distributed signatures (EDS)
127
+ - [ ] Further EBICS versions (e.g. H004) via the `protocol/` seam
105
128
 
106
129
  ## License
107
130
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ebicsclient"
7
- version = "0.1.0"
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
+ ]