open-banking-io 0.1.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.
@@ -0,0 +1,22 @@
1
+ # .NET
2
+ bin/
3
+ obj/
4
+ *.user
5
+
6
+ # Node
7
+ node_modules/
8
+ *.tsbuildinfo
9
+
10
+ # Python
11
+ __pycache__/
12
+ *.egg-info/
13
+ .venv/
14
+ .pytest_cache/
15
+ *.pyc
16
+
17
+ # Build output (both node tsup + python build)
18
+ dist/
19
+ build/
20
+
21
+ # misc
22
+ .DS_Store
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: open-banking-io
3
+ Version: 0.1.0
4
+ Summary: Server-to-server client for open-banking.io with local zero-knowledge envelope decryption.
5
+ Project-URL: Homepage, https://open-banking.io
6
+ Project-URL: Repository, https://github.com/open-banking-io/clients
7
+ Author: open-banking.io
8
+ License: MIT
9
+ Keywords: banking,ecdh,open-banking,psd2,zero-knowledge
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: cryptography>=42.0
12
+ Requires-Dist: httpx>=0.27
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest-httpserver>=1.0; extra == 'dev'
15
+ Requires-Dist: pytest>=8.0; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # open-banking-io (Python)
19
+
20
+ Server-to-server client for [open-banking.io](https://open-banking.io). It authenticates with your
21
+ **API key** and decrypts the **zero-knowledge** data envelopes locally with your exported **private
22
+ key** — the service only ever returns ciphertext it cannot read.
23
+
24
+ ```bash
25
+ pip install open-banking-io
26
+ ```
27
+
28
+ ```python
29
+ from open_banking_io import OpenBankingClient
30
+
31
+ # Load the credentials .json you exported from the app (API key + private key).
32
+ with OpenBankingClient.from_credentials("credentials.json") as client:
33
+ for account in client.get_accounts():
34
+ booked = next((b for b in account.balances if b.type == "ITBD"), None)
35
+ label = account.display_name or account.owner_name
36
+ print(f"{label} {account.iban}: {booked.amount if booked else None} {account.currency}")
37
+
38
+ page = client.get_transactions(account.id, limit=50)
39
+ for t in page.items:
40
+ print(f" {t.booking_date} {t.creditor_name or t.debtor_name} {t.amount} {t.currency}")
41
+
42
+ # Trigger an online sync (decrypts the account uid locally and posts it):
43
+ client.sync(account.id)
44
+ ```
45
+
46
+ Or construct it explicitly:
47
+
48
+ ```python
49
+ client = OpenBankingClient(api_base_url, api_key, private_key_pkcs8)
50
+ ```
51
+
52
+ ## API
53
+
54
+ - `get_accounts() -> list[Account]` — decrypts each account's envelope, display name and balances.
55
+ - `get_transactions(account_id, *, date_from=None, date_to=None, limit=None, offset=None) -> TransactionPage`
56
+ - `get_connections() -> list[Connection]`
57
+ - `sync(account_id) -> SyncResult` — decrypts the account uid locally and posts it.
58
+ - `sync_all() -> SyncAllResult` — syncs every account that has an active session.
59
+
60
+ Amounts are exposed as `decimal.Decimal`. Models are plain `@dataclass`es.
61
+
62
+ ## Encryption
63
+
64
+ Envelopes use **ECDH P-256 → HKDF-SHA256 → AES-256-GCM**. Decryption requires the private key from
65
+ your credentials bundle and happens entirely in-process. See the
66
+ [repo README](https://github.com/open-banking-io/clients) for the full scheme and the other language
67
+ clients (.NET, Node).
68
+
69
+ ## Development
70
+
71
+ ```bash
72
+ python -m venv .venv
73
+ .venv/bin/pip install -e .[dev]
74
+ .venv/bin/pytest -q
75
+ ```
76
+
77
+ MIT licensed.
@@ -0,0 +1,60 @@
1
+ # open-banking-io (Python)
2
+
3
+ Server-to-server client for [open-banking.io](https://open-banking.io). It authenticates with your
4
+ **API key** and decrypts the **zero-knowledge** data envelopes locally with your exported **private
5
+ key** — the service only ever returns ciphertext it cannot read.
6
+
7
+ ```bash
8
+ pip install open-banking-io
9
+ ```
10
+
11
+ ```python
12
+ from open_banking_io import OpenBankingClient
13
+
14
+ # Load the credentials .json you exported from the app (API key + private key).
15
+ with OpenBankingClient.from_credentials("credentials.json") as client:
16
+ for account in client.get_accounts():
17
+ booked = next((b for b in account.balances if b.type == "ITBD"), None)
18
+ label = account.display_name or account.owner_name
19
+ print(f"{label} {account.iban}: {booked.amount if booked else None} {account.currency}")
20
+
21
+ page = client.get_transactions(account.id, limit=50)
22
+ for t in page.items:
23
+ print(f" {t.booking_date} {t.creditor_name or t.debtor_name} {t.amount} {t.currency}")
24
+
25
+ # Trigger an online sync (decrypts the account uid locally and posts it):
26
+ client.sync(account.id)
27
+ ```
28
+
29
+ Or construct it explicitly:
30
+
31
+ ```python
32
+ client = OpenBankingClient(api_base_url, api_key, private_key_pkcs8)
33
+ ```
34
+
35
+ ## API
36
+
37
+ - `get_accounts() -> list[Account]` — decrypts each account's envelope, display name and balances.
38
+ - `get_transactions(account_id, *, date_from=None, date_to=None, limit=None, offset=None) -> TransactionPage`
39
+ - `get_connections() -> list[Connection]`
40
+ - `sync(account_id) -> SyncResult` — decrypts the account uid locally and posts it.
41
+ - `sync_all() -> SyncAllResult` — syncs every account that has an active session.
42
+
43
+ Amounts are exposed as `decimal.Decimal`. Models are plain `@dataclass`es.
44
+
45
+ ## Encryption
46
+
47
+ Envelopes use **ECDH P-256 → HKDF-SHA256 → AES-256-GCM**. Decryption requires the private key from
48
+ your credentials bundle and happens entirely in-process. See the
49
+ [repo README](https://github.com/open-banking-io/clients) for the full scheme and the other language
50
+ clients (.NET, Node).
51
+
52
+ ## Development
53
+
54
+ ```bash
55
+ python -m venv .venv
56
+ .venv/bin/pip install -e .[dev]
57
+ .venv/bin/pytest -q
58
+ ```
59
+
60
+ MIT licensed.
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "open-banking-io"
7
+ version = "0.1.0"
8
+ description = "Server-to-server client for open-banking.io with local zero-knowledge envelope decryption."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "open-banking.io" }]
13
+ keywords = ["open-banking", "psd2", "banking", "zero-knowledge", "ecdh"]
14
+ dependencies = [
15
+ "cryptography>=42.0",
16
+ "httpx>=0.27",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=8.0",
22
+ "pytest-httpserver>=1.0",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://open-banking.io"
27
+ Repository = "https://github.com/open-banking-io/clients"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/open_banking_io"]
@@ -0,0 +1,29 @@
1
+ """open-banking.io Python client.
2
+
3
+ Server-to-server client that authenticates with an API key and decrypts the
4
+ zero-knowledge data envelopes locally with your exported private key.
5
+ """
6
+
7
+ from .client import OpenBankingClient
8
+ from .models import (
9
+ Account,
10
+ Balance,
11
+ Connection,
12
+ SyncAllResult,
13
+ SyncResult,
14
+ Transaction,
15
+ TransactionPage,
16
+ )
17
+
18
+ __all__ = [
19
+ "OpenBankingClient",
20
+ "Account",
21
+ "Balance",
22
+ "Transaction",
23
+ "TransactionPage",
24
+ "Connection",
25
+ "SyncResult",
26
+ "SyncAllResult",
27
+ ]
28
+
29
+ __version__ = "0.1.0"
@@ -0,0 +1,283 @@
1
+ """Server-to-server client for open-banking.io.
2
+
3
+ Authenticates with an API key (``X-Api-Key``) and decrypts the zero-knowledge data
4
+ envelopes locally with the exported private key -- the service only ever returns
5
+ ciphertext it cannot read.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from datetime import date, datetime
13
+ from decimal import Decimal
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from . import envelope
19
+ from .models import (
20
+ Account,
21
+ Balance,
22
+ Connection,
23
+ SyncAllResult,
24
+ SyncResult,
25
+ Transaction,
26
+ TransactionPage,
27
+ )
28
+
29
+
30
+ def _parse_date(value: str | None) -> date | None:
31
+ if not value:
32
+ return None
33
+ return date.fromisoformat(value)
34
+
35
+
36
+ def _parse_datetime(value: str | None) -> datetime | None:
37
+ if not value:
38
+ return None
39
+ # Normalize a trailing 'Z' which datetime.fromisoformat handles only on 3.11+.
40
+ if value.endswith("Z"):
41
+ value = value[:-1] + "+00:00"
42
+ return datetime.fromisoformat(value)
43
+
44
+
45
+ def _parse_decimal(value: str | None) -> Decimal:
46
+ if value is None or value == "":
47
+ return Decimal(0)
48
+ return Decimal(value)
49
+
50
+
51
+ def _parse_decimal_nullable(value: str | None) -> Decimal | None:
52
+ if value is None or value == "":
53
+ return None
54
+ return Decimal(value)
55
+
56
+
57
+ class OpenBankingClient:
58
+ """Decrypting client for the open-banking.io API."""
59
+
60
+ def __init__(
61
+ self,
62
+ api_base_url: str,
63
+ api_key: str,
64
+ private_key_pkcs8: str,
65
+ http_client: httpx.Client | None = None,
66
+ ) -> None:
67
+ if not api_base_url or not api_base_url.strip():
68
+ raise ValueError("api_base_url is required")
69
+ if not api_key or not api_key.strip():
70
+ raise ValueError("api_key is required")
71
+ if not private_key_pkcs8 or not private_key_pkcs8.strip():
72
+ raise ValueError("private_key_pkcs8 is required")
73
+
74
+ self._private_key = envelope.load_private_key(private_key_pkcs8)
75
+ self._owns_http = http_client is None
76
+ self._http = http_client or httpx.Client()
77
+ self._http.base_url = httpx.URL(api_base_url.rstrip("/") + "/")
78
+ self._http.headers["X-Api-Key"] = api_key
79
+
80
+ # -- Construction ----------------------------------------------------------
81
+
82
+ @classmethod
83
+ def from_credentials(
84
+ cls, path_or_json: str, http_client: httpx.Client | None = None
85
+ ) -> "OpenBankingClient":
86
+ """Builds a client from a credentials-bundle JSON string or a path to a bundle file."""
87
+ if os.path.exists(path_or_json):
88
+ with open(path_or_json, "r", encoding="utf-8") as fh:
89
+ raw = fh.read()
90
+ else:
91
+ raw = path_or_json
92
+
93
+ bundle = json.loads(raw)
94
+ api_base_url = bundle.get("apiBaseUrl", "")
95
+ api_key = bundle.get("apiKey")
96
+ if not api_key:
97
+ raise ValueError("The credentials bundle has no apiKey")
98
+
99
+ enc_key = bundle.get("encryptionKey") or {}
100
+ private_key = enc_key.get("privateKey") or enc_key.get("privateKeyPkcs8B64")
101
+ if not private_key:
102
+ raise ValueError("The credentials bundle has no encryption private key")
103
+
104
+ return cls(api_base_url, api_key, private_key, http_client)
105
+
106
+ # -- Public API ------------------------------------------------------------
107
+
108
+ def get_accounts(self) -> list[Account]:
109
+ """Lists the user's accounts with all sensitive fields decrypted."""
110
+ wires = self._get_account_wires()
111
+ return [self._map_account(w) for w in wires]
112
+
113
+ def get_transactions(
114
+ self,
115
+ account_id: str,
116
+ *,
117
+ date_from: date | str | None = None,
118
+ date_to: date | str | None = None,
119
+ limit: int | None = None,
120
+ offset: int | None = None,
121
+ ) -> TransactionPage:
122
+ """Returns a page of an account's statement, newest first, with decrypted fields."""
123
+ params: dict[str, Any] = {}
124
+ if date_from is not None:
125
+ params["from"] = date_from.isoformat() if isinstance(date_from, date) else date_from
126
+ if date_to is not None:
127
+ params["to"] = date_to.isoformat() if isinstance(date_to, date) else date_to
128
+ if limit is not None:
129
+ params["limit"] = limit
130
+ if offset is not None:
131
+ params["offset"] = offset
132
+
133
+ resp = self._http.get(f"api/accounts/{account_id}/transactions", params=params)
134
+ resp.raise_for_status()
135
+ page = resp.json()
136
+
137
+ items = [self._map_transaction(t) for t in page.get("items", [])]
138
+ return TransactionPage(items=items, total=page.get("total", 0))
139
+
140
+ def get_connections(self) -> list[Connection]:
141
+ """Lists the user's bank connections."""
142
+ resp = self._http.get("api/connections")
143
+ resp.raise_for_status()
144
+ return [
145
+ Connection(
146
+ session_id=c.get("sessionId", ""),
147
+ aspsp_name=c.get("aspspName", ""),
148
+ aspsp_country=c.get("aspspCountry", ""),
149
+ valid_until=_parse_datetime(c.get("validUntil")),
150
+ status=c.get("status", ""),
151
+ account_count=c.get("accountCount", 0),
152
+ last_synced_at=_parse_datetime(c.get("lastSyncedAt")),
153
+ psu_type=c.get("psuType"),
154
+ )
155
+ for c in resp.json()
156
+ ]
157
+
158
+ def sync(self, account_id: str) -> SyncResult:
159
+ """Triggers an online sync of one account.
160
+
161
+ Decrypts that account's Enable Banking uid and posts it, so the service can
162
+ fetch fresh data without ever holding the uid in plaintext.
163
+ """
164
+ wires = self._get_account_wires()
165
+ account = next((a for a in wires if a.get("id") == account_id), None)
166
+ if account is None:
167
+ raise ValueError(f"Account {account_id} not found")
168
+ uid = self._decrypt_uid(account)
169
+ if uid is None:
170
+ raise ValueError(
171
+ "Account has no active session (reconnect required) -- cannot sync"
172
+ )
173
+
174
+ resp = self._http.post(f"api/accounts/{account_id}/sync", json={"uid": uid})
175
+ resp.raise_for_status()
176
+ result = resp.json()
177
+ return SyncResult(
178
+ new_transactions=result.get("newTransactions", 0),
179
+ total_fetched=result.get("totalFetched", 0),
180
+ )
181
+
182
+ def sync_all(self) -> SyncAllResult:
183
+ """Triggers an online sync of every account that has an active session."""
184
+ wires = self._get_account_wires()
185
+ items = []
186
+ for a in wires:
187
+ uid = self._decrypt_uid(a)
188
+ if uid is not None:
189
+ items.append({"accountId": a.get("id"), "uid": uid})
190
+
191
+ resp = self._http.post("api/sync", json={"items": items})
192
+ resp.raise_for_status()
193
+ result = resp.json()
194
+ return SyncAllResult(
195
+ accounts=result.get("accounts", 0),
196
+ new_transactions=result.get("newTransactions", 0),
197
+ )
198
+
199
+ # -- Internals -------------------------------------------------------------
200
+
201
+ def _get_account_wires(self) -> list[dict[str, Any]]:
202
+ resp = self._http.get("api/accounts")
203
+ resp.raise_for_status()
204
+ return resp.json()
205
+
206
+ def _decrypt_uid(self, account: dict[str, Any]) -> str | None:
207
+ payload = envelope.decrypt_to_json(self._private_key, account.get("uidEnc"))
208
+ return payload.get("uid") if payload else None
209
+
210
+ def _map_account(self, a: dict[str, Any]) -> Account:
211
+ acc = envelope.decrypt_to_json(self._private_key, a.get("enc")) or {}
212
+ name = envelope.decrypt_to_json(self._private_key, a.get("displayNameEnc")) or {}
213
+
214
+ balances = []
215
+ for b in a.get("balances", []):
216
+ dec = envelope.decrypt_to_json(self._private_key, b.get("enc")) or {}
217
+ balances.append(
218
+ Balance(
219
+ type=b.get("type", ""),
220
+ currency=b.get("currency", ""),
221
+ reference_date=_parse_date(b.get("referenceDate")),
222
+ name=dec.get("name"),
223
+ amount=_parse_decimal(dec.get("amount")),
224
+ )
225
+ )
226
+
227
+ return Account(
228
+ id=a.get("id", ""),
229
+ aspsp_name=a.get("aspspName", ""),
230
+ aspsp_country=a.get("aspspCountry", ""),
231
+ currency=a.get("currency", ""),
232
+ account_type=a.get("accountType"),
233
+ bic=a.get("bic"),
234
+ needs_reconnect=a.get("needsReconnect", False),
235
+ iban=acc.get("iban"),
236
+ bban=acc.get("bban"),
237
+ owner_name=acc.get("ownerName"),
238
+ account_name=acc.get("accountName"),
239
+ product=acc.get("product"),
240
+ display_name=name.get("displayName"),
241
+ balances=balances,
242
+ )
243
+
244
+ def _map_transaction(self, t: dict[str, Any]) -> Transaction:
245
+ d = envelope.decrypt_to_json(self._private_key, t.get("enc")) or {}
246
+ return Transaction(
247
+ id=t.get("id", ""),
248
+ currency=t.get("currency", ""),
249
+ credit_debit_indicator=t.get("creditDebitIndicator", ""),
250
+ status=t.get("status"),
251
+ booking_date=_parse_date(t.get("bookingDate")),
252
+ value_date=_parse_date(t.get("valueDate")),
253
+ transaction_date=_parse_date(t.get("transactionDate")),
254
+ bank_transaction_code=t.get("bankTransactionCode"),
255
+ amount=_parse_decimal(d.get("amount")),
256
+ creditor_name=d.get("creditorName"),
257
+ creditor_iban=d.get("creditorIban"),
258
+ creditor_bban=d.get("creditorBban"),
259
+ creditor_agent_bic=d.get("creditorAgentBic"),
260
+ debtor_name=d.get("debtorName"),
261
+ debtor_iban=d.get("debtorIban"),
262
+ debtor_bban=d.get("debtorBban"),
263
+ debtor_agent_bic=d.get("debtorAgentBic"),
264
+ remittance_information=d.get("remittanceInformation"),
265
+ note=d.get("note"),
266
+ reference_number=d.get("referenceNumber"),
267
+ exchange_rate=d.get("exchangeRate"),
268
+ merchant_category_code=d.get("merchantCategoryCode"),
269
+ balance_after_transaction=_parse_decimal_nullable(d.get("balanceAfter")),
270
+ balance_after_currency=d.get("balanceAfterCurrency"),
271
+ )
272
+
273
+ # -- Lifecycle -------------------------------------------------------------
274
+
275
+ def close(self) -> None:
276
+ if self._owns_http:
277
+ self._http.close()
278
+
279
+ def __enter__(self) -> "OpenBankingClient":
280
+ return self
281
+
282
+ def __exit__(self, *exc: object) -> None:
283
+ self.close()
@@ -0,0 +1,70 @@
1
+ """Decrypts open-banking.io's zero-knowledge data envelopes.
2
+
3
+ Scheme: ephemeral ECDH on NIST P-256 -> HKDF-SHA256 -> AES-256-GCM.
4
+ Wire: ``version(1)=0x01 | ephemeralPublicKeyRaw(65) | nonce(12) | tag(16) | ciphertext``.
5
+ Only the user's private key can decrypt -- the service stores ciphertext it cannot read.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import json
12
+ from typing import Any
13
+
14
+ from cryptography.hazmat.primitives import hashes, serialization
15
+ from cryptography.hazmat.primitives.asymmetric import ec
16
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
17
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
18
+
19
+ _VERSION = 0x01
20
+ _POINT_LEN = 65
21
+ _NONCE_LEN = 12
22
+ _TAG_LEN = 16
23
+ _HKDF_SALT = b"\x00" * 32
24
+ _HKDF_INFO = b"bank.core.ci/zk/v1"
25
+
26
+
27
+ def load_private_key(private_key_pkcs8_b64: str) -> ec.EllipticCurvePrivateKey:
28
+ """Loads a base64 PKCS#8 EC (SECP256R1) private key."""
29
+ der = base64.b64decode(private_key_pkcs8_b64)
30
+ key = serialization.load_der_private_key(der, password=None)
31
+ if not isinstance(key, ec.EllipticCurvePrivateKey):
32
+ raise ValueError("Private key is not an EC key")
33
+ return key
34
+
35
+
36
+ def decrypt(private_key: ec.EllipticCurvePrivateKey, envelope: bytes) -> bytes:
37
+ """Decrypts the raw bytes of a zero-knowledge envelope."""
38
+ if len(envelope) < 1 + _POINT_LEN + _NONCE_LEN + _TAG_LEN or envelope[0] != _VERSION:
39
+ raise ValueError("Invalid or unsupported envelope")
40
+
41
+ eph_pub_bytes = envelope[1 : 1 + _POINT_LEN]
42
+ nonce = envelope[1 + _POINT_LEN : 1 + _POINT_LEN + _NONCE_LEN]
43
+ tag = envelope[
44
+ 1 + _POINT_LEN + _NONCE_LEN : 1 + _POINT_LEN + _NONCE_LEN + _TAG_LEN
45
+ ]
46
+ ciphertext = envelope[1 + _POINT_LEN + _NONCE_LEN + _TAG_LEN :]
47
+
48
+ eph_pub = ec.EllipticCurvePublicKey.from_encoded_point(
49
+ ec.SECP256R1(), eph_pub_bytes
50
+ )
51
+ shared = private_key.exchange(ec.ECDH(), eph_pub)
52
+
53
+ key = HKDF(
54
+ algorithm=hashes.SHA256(),
55
+ length=32,
56
+ salt=_HKDF_SALT,
57
+ info=_HKDF_INFO,
58
+ ).derive(shared)
59
+
60
+ return AESGCM(key).decrypt(nonce, ciphertext + tag, None)
61
+
62
+
63
+ def decrypt_to_json(
64
+ private_key: ec.EllipticCurvePrivateKey, envelope_b64: str | None
65
+ ) -> dict[str, Any] | None:
66
+ """Decrypts a base64 envelope and parses its JSON payload. ``None`` in -> ``None``."""
67
+ if envelope_b64 is None:
68
+ return None
69
+ plaintext = decrypt(private_key, base64.b64decode(envelope_b64))
70
+ return json.loads(plaintext)
@@ -0,0 +1,102 @@
1
+ """Public, decrypted models for the open-banking.io client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import date, datetime
7
+ from decimal import Decimal
8
+
9
+
10
+ @dataclass
11
+ class Balance:
12
+ """A balance snapshot. ``type`` is the ISO 20022 code (ITBD booked, ITAV available, ...)."""
13
+
14
+ type: str
15
+ name: str | None
16
+ amount: Decimal
17
+ currency: str
18
+ reference_date: date | None
19
+
20
+
21
+ @dataclass
22
+ class Account:
23
+ """A bank account with its sensitive fields decrypted."""
24
+
25
+ id: str
26
+ aspsp_name: str
27
+ aspsp_country: str
28
+ currency: str
29
+ account_type: str | None
30
+ bic: str | None
31
+ needs_reconnect: bool
32
+ iban: str | None
33
+ bban: str | None
34
+ owner_name: str | None
35
+ account_name: str | None
36
+ product: str | None
37
+ display_name: str | None
38
+ balances: list[Balance] = field(default_factory=list)
39
+
40
+
41
+ @dataclass
42
+ class Transaction:
43
+ """A statement transaction with its sensitive fields decrypted."""
44
+
45
+ id: str
46
+ currency: str
47
+ credit_debit_indicator: str
48
+ status: str | None
49
+ booking_date: date | None
50
+ value_date: date | None
51
+ transaction_date: date | None
52
+ bank_transaction_code: str | None
53
+ amount: Decimal
54
+ creditor_name: str | None
55
+ creditor_iban: str | None
56
+ creditor_bban: str | None
57
+ creditor_agent_bic: str | None
58
+ debtor_name: str | None
59
+ debtor_iban: str | None
60
+ debtor_bban: str | None
61
+ debtor_agent_bic: str | None
62
+ remittance_information: str | None
63
+ note: str | None
64
+ reference_number: str | None
65
+ exchange_rate: str | None
66
+ merchant_category_code: str | None
67
+ balance_after_transaction: Decimal | None
68
+ balance_after_currency: str | None
69
+
70
+
71
+ @dataclass
72
+ class TransactionPage:
73
+ """A page of transactions, newest first."""
74
+
75
+ items: list[Transaction]
76
+ total: int
77
+
78
+
79
+ @dataclass
80
+ class Connection:
81
+ """A bank connection (consent)."""
82
+
83
+ session_id: str
84
+ aspsp_name: str
85
+ aspsp_country: str
86
+ valid_until: datetime | None
87
+ status: str
88
+ account_count: int
89
+ last_synced_at: datetime | None
90
+ psu_type: str | None
91
+
92
+
93
+ @dataclass
94
+ class SyncResult:
95
+ new_transactions: int
96
+ total_fetched: int
97
+
98
+
99
+ @dataclass
100
+ class SyncAllResult:
101
+ accounts: int
102
+ new_transactions: int
@@ -0,0 +1,37 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ # python/tests/ -> python/ -> repo root -> fixtures/
7
+ FIXTURES = Path(__file__).resolve().parents[2] / "fixtures"
8
+
9
+
10
+ def load_fixture(*parts: str) -> dict | list:
11
+ with open(FIXTURES.joinpath(*parts), "r", encoding="utf-8") as fh:
12
+ return json.load(fh)
13
+
14
+
15
+ @pytest.fixture
16
+ def fixtures_dir() -> Path:
17
+ return FIXTURES
18
+
19
+
20
+ @pytest.fixture
21
+ def keypair() -> dict:
22
+ return load_fixture("keypair.json")
23
+
24
+
25
+ @pytest.fixture
26
+ def credentials() -> dict:
27
+ return load_fixture("credentials.json")
28
+
29
+
30
+ @pytest.fixture
31
+ def envelopes() -> dict:
32
+ return load_fixture("envelopes.json")
33
+
34
+
35
+ @pytest.fixture
36
+ def expected() -> dict:
37
+ return load_fixture("expected.json")
@@ -0,0 +1,50 @@
1
+ """Wire-format interop: decrypt the shared fixtures and assert they match expected."""
2
+
3
+ from decimal import Decimal
4
+
5
+ from open_banking_io import envelope
6
+
7
+
8
+ def _priv(keypair):
9
+ return envelope.load_private_key(keypair["privateKeyPkcs8B64"])
10
+
11
+
12
+ def test_account_envelope(keypair, envelopes, expected):
13
+ priv = _priv(keypair)
14
+ acc = envelope.decrypt_to_json(priv, envelopes["account"])
15
+ assert acc == expected["account"]
16
+ assert acc["iban"] == "DK6466952001724927"
17
+
18
+
19
+ def test_display_name_envelope(keypair, envelopes, expected):
20
+ priv = _priv(keypair)
21
+ name = envelope.decrypt_to_json(priv, envelopes["displayName"])
22
+ assert name == expected["displayName"]
23
+ assert name["displayName"] == "Drift"
24
+
25
+
26
+ def test_uid_envelope(keypair, envelopes, expected):
27
+ priv = _priv(keypair)
28
+ uid = envelope.decrypt_to_json(priv, envelopes["uid"])
29
+ assert uid["uid"] == expected["uid"]["uid"]
30
+ assert uid["uid"] == "c5d93aa7-5e23-4da0-ba88-42b9a584492c"
31
+
32
+
33
+ def test_balance_envelope(keypair, envelopes, expected):
34
+ priv = _priv(keypair)
35
+ bal = envelope.decrypt_to_json(priv, envelopes["balance"])
36
+ # The shared balance envelope is the booked (ITBD) balance.
37
+ assert Decimal(bal["amount"]) == Decimal(expected["balances"]["ITBD"]["amount"])
38
+ assert Decimal(bal["amount"]) == Decimal("828.13")
39
+ assert bal["name"] == "Tatic"
40
+
41
+
42
+ def test_transaction_envelope(keypair, envelopes, expected):
43
+ priv = _priv(keypair)
44
+ txn = envelope.decrypt_to_json(priv, envelopes["transaction"])
45
+ exp = expected["transaction"]
46
+ assert Decimal(txn["amount"]) == Decimal(exp["amount"])
47
+ assert Decimal(txn["amount"]) == Decimal("194.23")
48
+ assert txn["creditorName"] == "One.com"
49
+ assert txn["merchantCategoryCode"] == "4816"
50
+ assert Decimal(txn["balanceAfter"]) == Decimal("633.90")
@@ -0,0 +1,182 @@
1
+ """Integration test: serve the shared API fixtures over HTTP and exercise the client."""
2
+
3
+ import json
4
+ from decimal import Decimal
5
+
6
+ import httpx
7
+ import pytest
8
+ from werkzeug import Response
9
+
10
+ from open_banking_io import OpenBankingClient
11
+
12
+ API_KEY = "obk_test_3f8b9c2e1a7d4655b0e9f2c1a8d7e6f5"
13
+
14
+
15
+ def _json_response(data, status=200) -> Response:
16
+ return Response(json.dumps(data), status=status, mimetype="application/json")
17
+
18
+
19
+ def _unauthorized() -> Response:
20
+ return _json_response({"error": "unauthorized"}, status=401)
21
+
22
+
23
+ @pytest.fixture
24
+ def server(httpserver, fixtures_dir):
25
+ account_id = "11111111-1111-4111-8111-111111111111"
26
+ captured: dict = {}
27
+
28
+ def load(path):
29
+ return json.loads((fixtures_dir / "api" / path).read_text())
30
+
31
+ def static_handler(path):
32
+ data = load(path)
33
+
34
+ def handler(request):
35
+ if request.headers.get("X-Api-Key") != API_KEY:
36
+ return _unauthorized()
37
+ return _json_response(data)
38
+
39
+ return handler
40
+
41
+ httpserver.expect_request("/api/accounts", method="GET").respond_with_handler(
42
+ static_handler("accounts.json")
43
+ )
44
+ httpserver.expect_request(
45
+ f"/api/accounts/{account_id}/transactions", method="GET"
46
+ ).respond_with_handler(static_handler("transactions.json"))
47
+ httpserver.expect_request("/api/connections", method="GET").respond_with_handler(
48
+ static_handler("connections.json")
49
+ )
50
+
51
+ sync_data = load("sync.json")
52
+
53
+ def sync_handler(request):
54
+ if request.headers.get("X-Api-Key") != API_KEY:
55
+ return _unauthorized()
56
+ captured["sync_body"] = json.loads(request.get_data())
57
+ return _json_response(sync_data)
58
+
59
+ httpserver.expect_request(
60
+ f"/api/accounts/{account_id}/sync", method="POST"
61
+ ).respond_with_handler(sync_handler)
62
+
63
+ sync_all_data = load("sync-all.json")
64
+
65
+ def sync_all_handler(request):
66
+ if request.headers.get("X-Api-Key") != API_KEY:
67
+ return _unauthorized()
68
+ captured["sync_all_body"] = json.loads(request.get_data())
69
+ return _json_response(sync_all_data)
70
+
71
+ httpserver.expect_request("/api/sync", method="POST").respond_with_handler(
72
+ sync_all_handler
73
+ )
74
+
75
+ httpserver._captured = captured # type: ignore[attr-defined]
76
+ return httpserver
77
+
78
+
79
+ def _client(server, credentials):
80
+ return OpenBankingClient(
81
+ api_base_url=server.url_for("").rstrip("/"),
82
+ api_key=credentials["apiKey"],
83
+ private_key_pkcs8=credentials["encryptionKey"]["privateKey"],
84
+ )
85
+
86
+
87
+ def test_get_accounts_decrypts(server, credentials):
88
+ with _client(server, credentials) as client:
89
+ accounts = client.get_accounts()
90
+
91
+ assert len(accounts) == 1
92
+ acc = accounts[0]
93
+ assert acc.iban == "DK6466952001724927"
94
+ assert acc.owner_name == "Tatic ApS"
95
+ assert acc.display_name == "Drift"
96
+ assert acc.aspsp_name == "Lunar"
97
+
98
+ by_type = {b.type: b for b in acc.balances}
99
+ assert by_type["ITBD"].amount == Decimal("828.13")
100
+ assert by_type["ITAV"].amount == Decimal("633.90")
101
+
102
+
103
+ def test_get_transactions_decrypts(server, credentials):
104
+ with _client(server, credentials) as client:
105
+ page = client.get_transactions(
106
+ "11111111-1111-4111-8111-111111111111", limit=50
107
+ )
108
+
109
+ assert page.total == 1
110
+ txn = page.items[0]
111
+ assert txn.amount == Decimal("194.23")
112
+ assert txn.creditor_name == "One.com"
113
+ assert txn.merchant_category_code == "4816"
114
+ assert txn.balance_after_transaction == Decimal("633.90")
115
+ assert txn.credit_debit_indicator == "DBIT"
116
+
117
+
118
+ def test_get_connections(server, credentials):
119
+ with _client(server, credentials) as client:
120
+ connections = client.get_connections()
121
+
122
+ assert len(connections) == 1
123
+ conn = connections[0]
124
+ assert conn.session_id == "22222222-2222-4222-8222-222222222222"
125
+ assert conn.aspsp_name == "Lunar"
126
+ assert conn.account_count == 1
127
+ assert conn.psu_type == "business"
128
+
129
+
130
+ def test_sync_posts_decrypted_uid(server, credentials):
131
+ with _client(server, credentials) as client:
132
+ result = client.sync("11111111-1111-4111-8111-111111111111")
133
+
134
+ assert result.total_fetched == 1
135
+ body = server._captured["sync_body"]
136
+ assert body == {"uid": "c5d93aa7-5e23-4da0-ba88-42b9a584492c"}
137
+
138
+
139
+ def test_sync_all_posts_decrypted_uids(server, credentials):
140
+ with _client(server, credentials) as client:
141
+ result = client.sync_all()
142
+
143
+ assert result.accounts == 1
144
+ body = server._captured["sync_all_body"]
145
+ assert body["items"][0]["uid"] == "c5d93aa7-5e23-4da0-ba88-42b9a584492c"
146
+ assert body["items"][0]["accountId"] == "11111111-1111-4111-8111-111111111111"
147
+
148
+
149
+ def test_wrong_api_key_raises(server, credentials):
150
+ client = OpenBankingClient(
151
+ api_base_url=server.url_for("").rstrip("/"),
152
+ api_key="wrong-key",
153
+ private_key_pkcs8=credentials["encryptionKey"]["privateKey"],
154
+ )
155
+ with pytest.raises(httpx.HTTPStatusError):
156
+ client.get_accounts()
157
+ client.close()
158
+
159
+
160
+ def test_wrong_private_key_raises(server, credentials):
161
+ # A structurally valid but different EC key must fail to decrypt (GCM auth tag).
162
+ import base64
163
+
164
+ from cryptography.hazmat.primitives import serialization
165
+ from cryptography.hazmat.primitives.asymmetric import ec
166
+
167
+ other = ec.generate_private_key(ec.SECP256R1())
168
+ der = other.private_bytes(
169
+ encoding=serialization.Encoding.DER,
170
+ format=serialization.PrivateFormat.PKCS8,
171
+ encryption_algorithm=serialization.NoEncryption(),
172
+ )
173
+ wrong_key = base64.b64encode(der).decode()
174
+
175
+ client = OpenBankingClient(
176
+ api_base_url=server.url_for("").rstrip("/"),
177
+ api_key=credentials["apiKey"],
178
+ private_key_pkcs8=wrong_key,
179
+ )
180
+ with pytest.raises(Exception):
181
+ client.get_accounts()
182
+ client.close()