banklink 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.
- banklink-0.1.0/.gitignore +5 -0
- banklink-0.1.0/PKG-INFO +142 -0
- banklink-0.1.0/README.md +129 -0
- banklink-0.1.0/pyproject.toml +18 -0
- banklink-0.1.0/src/banklink/__init__.py +117 -0
- banklink-0.1.0/src/banklink/_client.py +119 -0
- banklink-0.1.0/src/banklink/errors.py +30 -0
- banklink-0.1.0/src/banklink/py.typed +0 -0
- banklink-0.1.0/src/banklink/resources/__init__.py +1 -0
- banklink-0.1.0/src/banklink/resources/accounts.py +75 -0
- banklink-0.1.0/src/banklink/resources/balances.py +42 -0
- banklink-0.1.0/src/banklink/resources/link.py +82 -0
- banklink-0.1.0/src/banklink/resources/transactions.py +115 -0
- banklink-0.1.0/src/banklink/types.py +60 -0
banklink-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: banklink
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official BankLink SDK for Python
|
|
5
|
+
Project-URL: Homepage, https://banklink.co.za
|
|
6
|
+
Project-URL: Repository, https://github.com/aurvex-labs/banklink-python
|
|
7
|
+
Project-URL: Documentation, https://banklink.co.za/docs
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: banklink,fintech,open-banking,south-africa
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: httpx>=0.24
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# BankLink Python SDK
|
|
15
|
+
|
|
16
|
+
Official Python SDK for the [BankLink](https://banklink.co.za) Open Finance API. Link South African bank accounts, ingest transactions, and deliver them anywhere.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install banklink
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from banklink import BankLink
|
|
28
|
+
|
|
29
|
+
client = BankLink(api_key="bl_live_...")
|
|
30
|
+
|
|
31
|
+
# List all linked accounts
|
|
32
|
+
accounts = client.accounts.list()
|
|
33
|
+
for account in accounts.data:
|
|
34
|
+
print(account.id, account.bank, account.account_number)
|
|
35
|
+
|
|
36
|
+
# Fetch a single page of transactions
|
|
37
|
+
page = client.transactions.list("acc_123", limit=50)
|
|
38
|
+
for txn in page.data:
|
|
39
|
+
print(txn.date, txn.description, txn.amount, txn.direction)
|
|
40
|
+
|
|
41
|
+
# Auto-paginate through all transactions
|
|
42
|
+
for txn in client.transactions.list_auto_paginate("acc_123"):
|
|
43
|
+
print(txn.date, txn.amount)
|
|
44
|
+
|
|
45
|
+
# Get account balance
|
|
46
|
+
balance = client.balances.get("acc_123")
|
|
47
|
+
print(balance.balance, balance.currency)
|
|
48
|
+
|
|
49
|
+
# Trigger an on-demand sync
|
|
50
|
+
result = client.accounts.sync("acc_123")
|
|
51
|
+
print(f"Synced {result.synced} transactions, skipped {result.skipped}")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Async Support
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import asyncio
|
|
58
|
+
from banklink import AsyncBankLink
|
|
59
|
+
|
|
60
|
+
async def main():
|
|
61
|
+
async with AsyncBankLink(api_key="bl_live_...") as client:
|
|
62
|
+
accounts = await client.accounts.list()
|
|
63
|
+
for account in accounts.data:
|
|
64
|
+
print(account.id, account.bank)
|
|
65
|
+
|
|
66
|
+
# Async auto-pagination
|
|
67
|
+
async for txn in client.transactions.list_auto_paginate("acc_123"):
|
|
68
|
+
print(txn.date, txn.amount)
|
|
69
|
+
|
|
70
|
+
asyncio.run(main())
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Bank Linking
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from banklink import BankLink
|
|
77
|
+
|
|
78
|
+
client = BankLink(api_key="bl_live_...")
|
|
79
|
+
|
|
80
|
+
# Initiate a link flow
|
|
81
|
+
result = client.link.create(
|
|
82
|
+
bank_id="fnb",
|
|
83
|
+
credentials={"username": "your_username", "password": "your_password"},
|
|
84
|
+
nickname="My FNB Account",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if result.type == "otp_required":
|
|
88
|
+
# Submit OTP if required
|
|
89
|
+
result = client.link.submit_otp(
|
|
90
|
+
session_token=result.session_token,
|
|
91
|
+
otp="123456",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
print("Linked:", result.profile_id, result.account_number)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Error Handling
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from banklink import BankLink, AuthenticationError, NotFoundError, RateLimitError, BankLinkError
|
|
101
|
+
|
|
102
|
+
client = BankLink(api_key="bl_live_...")
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
account = client.accounts.get("acc_does_not_exist")
|
|
106
|
+
except AuthenticationError:
|
|
107
|
+
print("Invalid or missing API key")
|
|
108
|
+
except NotFoundError:
|
|
109
|
+
print("Account not found")
|
|
110
|
+
except RateLimitError:
|
|
111
|
+
print("Rate limit exceeded — back off and retry")
|
|
112
|
+
except BankLinkError as e:
|
|
113
|
+
print(f"API error {e.status}: [{e.code}] {e.message}")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Configuration
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from banklink import BankLink
|
|
120
|
+
|
|
121
|
+
client = BankLink(
|
|
122
|
+
api_key="bl_live_...",
|
|
123
|
+
base_url="https://api.banklink.co.za/v1", # default
|
|
124
|
+
timeout=30.0, # seconds, default 30
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Use the context manager to ensure connections are closed:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
with BankLink(api_key="bl_live_...") as client:
|
|
132
|
+
accounts = client.accounts.list()
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Requirements
|
|
136
|
+
|
|
137
|
+
- Python 3.9+
|
|
138
|
+
- [httpx](https://www.python-httpx.org/) >= 0.24 (installed automatically)
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
MIT — Copyright (c) 2026 Aurvex Labs
|
banklink-0.1.0/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# BankLink Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [BankLink](https://banklink.co.za) Open Finance API. Link South African bank accounts, ingest transactions, and deliver them anywhere.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install banklink
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from banklink import BankLink
|
|
15
|
+
|
|
16
|
+
client = BankLink(api_key="bl_live_...")
|
|
17
|
+
|
|
18
|
+
# List all linked accounts
|
|
19
|
+
accounts = client.accounts.list()
|
|
20
|
+
for account in accounts.data:
|
|
21
|
+
print(account.id, account.bank, account.account_number)
|
|
22
|
+
|
|
23
|
+
# Fetch a single page of transactions
|
|
24
|
+
page = client.transactions.list("acc_123", limit=50)
|
|
25
|
+
for txn in page.data:
|
|
26
|
+
print(txn.date, txn.description, txn.amount, txn.direction)
|
|
27
|
+
|
|
28
|
+
# Auto-paginate through all transactions
|
|
29
|
+
for txn in client.transactions.list_auto_paginate("acc_123"):
|
|
30
|
+
print(txn.date, txn.amount)
|
|
31
|
+
|
|
32
|
+
# Get account balance
|
|
33
|
+
balance = client.balances.get("acc_123")
|
|
34
|
+
print(balance.balance, balance.currency)
|
|
35
|
+
|
|
36
|
+
# Trigger an on-demand sync
|
|
37
|
+
result = client.accounts.sync("acc_123")
|
|
38
|
+
print(f"Synced {result.synced} transactions, skipped {result.skipped}")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Async Support
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import asyncio
|
|
45
|
+
from banklink import AsyncBankLink
|
|
46
|
+
|
|
47
|
+
async def main():
|
|
48
|
+
async with AsyncBankLink(api_key="bl_live_...") as client:
|
|
49
|
+
accounts = await client.accounts.list()
|
|
50
|
+
for account in accounts.data:
|
|
51
|
+
print(account.id, account.bank)
|
|
52
|
+
|
|
53
|
+
# Async auto-pagination
|
|
54
|
+
async for txn in client.transactions.list_auto_paginate("acc_123"):
|
|
55
|
+
print(txn.date, txn.amount)
|
|
56
|
+
|
|
57
|
+
asyncio.run(main())
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Bank Linking
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from banklink import BankLink
|
|
64
|
+
|
|
65
|
+
client = BankLink(api_key="bl_live_...")
|
|
66
|
+
|
|
67
|
+
# Initiate a link flow
|
|
68
|
+
result = client.link.create(
|
|
69
|
+
bank_id="fnb",
|
|
70
|
+
credentials={"username": "your_username", "password": "your_password"},
|
|
71
|
+
nickname="My FNB Account",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if result.type == "otp_required":
|
|
75
|
+
# Submit OTP if required
|
|
76
|
+
result = client.link.submit_otp(
|
|
77
|
+
session_token=result.session_token,
|
|
78
|
+
otp="123456",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
print("Linked:", result.profile_id, result.account_number)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Error Handling
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from banklink import BankLink, AuthenticationError, NotFoundError, RateLimitError, BankLinkError
|
|
88
|
+
|
|
89
|
+
client = BankLink(api_key="bl_live_...")
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
account = client.accounts.get("acc_does_not_exist")
|
|
93
|
+
except AuthenticationError:
|
|
94
|
+
print("Invalid or missing API key")
|
|
95
|
+
except NotFoundError:
|
|
96
|
+
print("Account not found")
|
|
97
|
+
except RateLimitError:
|
|
98
|
+
print("Rate limit exceeded — back off and retry")
|
|
99
|
+
except BankLinkError as e:
|
|
100
|
+
print(f"API error {e.status}: [{e.code}] {e.message}")
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from banklink import BankLink
|
|
107
|
+
|
|
108
|
+
client = BankLink(
|
|
109
|
+
api_key="bl_live_...",
|
|
110
|
+
base_url="https://api.banklink.co.za/v1", # default
|
|
111
|
+
timeout=30.0, # seconds, default 30
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Use the context manager to ensure connections are closed:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
with BankLink(api_key="bl_live_...") as client:
|
|
119
|
+
accounts = client.accounts.list()
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Requirements
|
|
123
|
+
|
|
124
|
+
- Python 3.9+
|
|
125
|
+
- [httpx](https://www.python-httpx.org/) >= 0.24 (installed automatically)
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT — Copyright (c) 2026 Aurvex Labs
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling<1.22"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "banklink"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official BankLink SDK for Python"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
keywords = ["banklink", "open-banking", "south-africa", "fintech"]
|
|
13
|
+
dependencies = ["httpx>=0.24"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://banklink.co.za"
|
|
17
|
+
Repository = "https://github.com/aurvex-labs/banklink-python"
|
|
18
|
+
Documentation = "https://banklink.co.za/docs"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""BankLink Python SDK — Official client for the BankLink Open Finance API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._client import AsyncClient, SyncClient
|
|
6
|
+
from .errors import (
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
BankLinkError,
|
|
9
|
+
InsufficientCreditsError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
)
|
|
13
|
+
from .resources.accounts import AsyncAccounts, Accounts
|
|
14
|
+
from .resources.balances import AsyncBalances, Balances
|
|
15
|
+
from .resources.link import AsyncLinkResource, LinkResource
|
|
16
|
+
from .resources.transactions import AsyncTransactions, Transactions
|
|
17
|
+
from .types import (
|
|
18
|
+
Account,
|
|
19
|
+
Balance,
|
|
20
|
+
LinkResult,
|
|
21
|
+
ListResponse,
|
|
22
|
+
SyncResult,
|
|
23
|
+
Transaction,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_DEFAULT_BASE_URL = "https://api.banklink.co.za/v1"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BankLink:
|
|
30
|
+
"""Synchronous BankLink API client.
|
|
31
|
+
|
|
32
|
+
Usage::
|
|
33
|
+
|
|
34
|
+
client = BankLink(api_key="bl_live_...")
|
|
35
|
+
accounts = client.accounts.list()
|
|
36
|
+
|
|
37
|
+
Or as a context manager::
|
|
38
|
+
|
|
39
|
+
with BankLink(api_key="bl_live_...") as client:
|
|
40
|
+
accounts = client.accounts.list()
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
api_key: str,
|
|
46
|
+
*,
|
|
47
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
48
|
+
timeout: float = 30.0,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._http = SyncClient(api_key, base_url=base_url, timeout=timeout)
|
|
51
|
+
self.accounts = Accounts(self._http)
|
|
52
|
+
self.transactions = Transactions(self._http)
|
|
53
|
+
self.balances = Balances(self._http)
|
|
54
|
+
self.link = LinkResource(self._http)
|
|
55
|
+
|
|
56
|
+
def __enter__(self) -> BankLink:
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def __exit__(self, *_: object) -> None:
|
|
60
|
+
self._http.close()
|
|
61
|
+
|
|
62
|
+
def close(self) -> None:
|
|
63
|
+
"""Close the underlying HTTP connection pool."""
|
|
64
|
+
self._http.close()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AsyncBankLink:
|
|
68
|
+
"""Asynchronous BankLink API client.
|
|
69
|
+
|
|
70
|
+
Usage::
|
|
71
|
+
|
|
72
|
+
async with AsyncBankLink(api_key="bl_live_...") as client:
|
|
73
|
+
accounts = await client.accounts.list()
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
api_key: str,
|
|
79
|
+
*,
|
|
80
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
81
|
+
timeout: float = 30.0,
|
|
82
|
+
) -> None:
|
|
83
|
+
self._http = AsyncClient(api_key, base_url=base_url, timeout=timeout)
|
|
84
|
+
self.accounts = AsyncAccounts(self._http)
|
|
85
|
+
self.transactions = AsyncTransactions(self._http)
|
|
86
|
+
self.balances = AsyncBalances(self._http)
|
|
87
|
+
self.link = AsyncLinkResource(self._http)
|
|
88
|
+
|
|
89
|
+
async def __aenter__(self) -> AsyncBankLink:
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
async def __aexit__(self, *_: object) -> None:
|
|
93
|
+
await self._http.close()
|
|
94
|
+
|
|
95
|
+
async def close(self) -> None:
|
|
96
|
+
"""Close the underlying HTTP connection pool."""
|
|
97
|
+
await self._http.close()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
# Clients
|
|
102
|
+
"BankLink",
|
|
103
|
+
"AsyncBankLink",
|
|
104
|
+
# Types
|
|
105
|
+
"Account",
|
|
106
|
+
"Transaction",
|
|
107
|
+
"Balance",
|
|
108
|
+
"SyncResult",
|
|
109
|
+
"LinkResult",
|
|
110
|
+
"ListResponse",
|
|
111
|
+
# Errors
|
|
112
|
+
"BankLinkError",
|
|
113
|
+
"AuthenticationError",
|
|
114
|
+
"InsufficientCreditsError",
|
|
115
|
+
"NotFoundError",
|
|
116
|
+
"RateLimitError",
|
|
117
|
+
]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .errors import (
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
BankLinkError,
|
|
10
|
+
InsufficientCreditsError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_DEFAULT_BASE_URL = "https://api.banklink.co.za/v1"
|
|
16
|
+
_USER_AGENT = "banklink-python/0.1.0"
|
|
17
|
+
|
|
18
|
+
_ERROR_MAP = {
|
|
19
|
+
401: AuthenticationError,
|
|
20
|
+
402: InsufficientCreditsError,
|
|
21
|
+
404: NotFoundError,
|
|
22
|
+
429: RateLimitError,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
27
|
+
if response.is_success:
|
|
28
|
+
return
|
|
29
|
+
status = response.status_code
|
|
30
|
+
try:
|
|
31
|
+
payload = response.json()
|
|
32
|
+
code = payload.get("code", "unknown_error")
|
|
33
|
+
message = payload.get("message", response.text)
|
|
34
|
+
except Exception:
|
|
35
|
+
code = "unknown_error"
|
|
36
|
+
message = response.text
|
|
37
|
+
|
|
38
|
+
exc_class = _ERROR_MAP.get(status, BankLinkError)
|
|
39
|
+
raise exc_class(status, code, message)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SyncClient:
|
|
43
|
+
"""Synchronous HTTP client for the BankLink API."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
api_key: str,
|
|
48
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
49
|
+
timeout: float = 30.0,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._base_url = base_url.rstrip("/")
|
|
52
|
+
self._http = httpx.Client(
|
|
53
|
+
headers={
|
|
54
|
+
"Authorization": f"Bearer {api_key}",
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
"User-Agent": _USER_AGENT,
|
|
57
|
+
},
|
|
58
|
+
timeout=timeout,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def request(
|
|
62
|
+
self,
|
|
63
|
+
method: str,
|
|
64
|
+
path: str,
|
|
65
|
+
body: Optional[Dict[str, Any]] = None,
|
|
66
|
+
) -> Any:
|
|
67
|
+
url = f"{self._base_url}{path}"
|
|
68
|
+
response = self._http.request(method, url, json=body)
|
|
69
|
+
_raise_for_status(response)
|
|
70
|
+
return response.json()
|
|
71
|
+
|
|
72
|
+
def get(self, path: str) -> Any:
|
|
73
|
+
return self.request("GET", path)
|
|
74
|
+
|
|
75
|
+
def post(self, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
|
|
76
|
+
return self.request("POST", path, body=body)
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
self._http.close()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AsyncClient:
|
|
83
|
+
"""Asynchronous HTTP client for the BankLink API."""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
api_key: str,
|
|
88
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
89
|
+
timeout: float = 30.0,
|
|
90
|
+
) -> None:
|
|
91
|
+
self._base_url = base_url.rstrip("/")
|
|
92
|
+
self._http = httpx.AsyncClient(
|
|
93
|
+
headers={
|
|
94
|
+
"Authorization": f"Bearer {api_key}",
|
|
95
|
+
"Content-Type": "application/json",
|
|
96
|
+
"User-Agent": _USER_AGENT,
|
|
97
|
+
},
|
|
98
|
+
timeout=timeout,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
async def request(
|
|
102
|
+
self,
|
|
103
|
+
method: str,
|
|
104
|
+
path: str,
|
|
105
|
+
body: Optional[Dict[str, Any]] = None,
|
|
106
|
+
) -> Any:
|
|
107
|
+
url = f"{self._base_url}{path}"
|
|
108
|
+
response = await self._http.request(method, url, json=body)
|
|
109
|
+
_raise_for_status(response)
|
|
110
|
+
return response.json()
|
|
111
|
+
|
|
112
|
+
async def get(self, path: str) -> Any:
|
|
113
|
+
return await self.request("GET", path)
|
|
114
|
+
|
|
115
|
+
async def post(self, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
|
|
116
|
+
return await self.request("POST", path, body=body)
|
|
117
|
+
|
|
118
|
+
async def close(self) -> None:
|
|
119
|
+
await self._http.aclose()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BankLinkError(Exception):
|
|
5
|
+
"""Base exception for all BankLink API errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, status: int, code: str, message: str) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.status = status
|
|
10
|
+
self.code = code
|
|
11
|
+
self.message = message
|
|
12
|
+
|
|
13
|
+
def __repr__(self) -> str:
|
|
14
|
+
return f"{self.__class__.__name__}(status={self.status}, code={self.code!r}, message={self.message!r})"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthenticationError(BankLinkError):
|
|
18
|
+
"""Raised when the API key is missing or invalid (HTTP 401)."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InsufficientCreditsError(BankLinkError):
|
|
22
|
+
"""Raised when the account has insufficient credits (HTTP 402)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NotFoundError(BankLinkError):
|
|
26
|
+
"""Raised when the requested resource does not exist (HTTP 404)."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RateLimitError(BankLinkError):
|
|
30
|
+
"""Raised when the rate limit has been exceeded (HTTP 429)."""
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""BankLink resource modules (accounts, transactions, balances, link)."""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ..types import Account, ListResponse, SyncResult
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .._client import AsyncClient, SyncClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parse_account(raw: dict) -> Account:
|
|
12
|
+
return Account(
|
|
13
|
+
id=raw["id"],
|
|
14
|
+
bank=raw["bank"],
|
|
15
|
+
account_number=raw.get("account_number"),
|
|
16
|
+
nickname=raw["nickname"],
|
|
17
|
+
last_synced_at=raw.get("last_synced_at"),
|
|
18
|
+
created_at=raw["created_at"],
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Accounts:
|
|
23
|
+
"""Synchronous accounts resource."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, client: SyncClient) -> None:
|
|
26
|
+
self._client = client
|
|
27
|
+
|
|
28
|
+
def list(self) -> ListResponse[Account]:
|
|
29
|
+
"""List all linked bank accounts."""
|
|
30
|
+
raw = self._client.get("/accounts")
|
|
31
|
+
return ListResponse(
|
|
32
|
+
data=[_parse_account(item) for item in raw.get("data", [])],
|
|
33
|
+
cursor=raw.get("cursor"),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def get(self, account_id: str) -> Account:
|
|
37
|
+
"""Retrieve a single bank account by ID."""
|
|
38
|
+
raw = self._client.get(f"/accounts/{account_id}")
|
|
39
|
+
return _parse_account(raw)
|
|
40
|
+
|
|
41
|
+
def sync(self, account_id: str) -> SyncResult:
|
|
42
|
+
"""Trigger an on-demand sync for the given account."""
|
|
43
|
+
raw = self._client.post(f"/accounts/{account_id}/sync")
|
|
44
|
+
return SyncResult(
|
|
45
|
+
synced=raw["synced"],
|
|
46
|
+
skipped=raw["skipped"],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AsyncAccounts:
|
|
51
|
+
"""Asynchronous accounts resource."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, client: AsyncClient) -> None:
|
|
54
|
+
self._client = client
|
|
55
|
+
|
|
56
|
+
async def list(self) -> ListResponse[Account]:
|
|
57
|
+
"""List all linked bank accounts."""
|
|
58
|
+
raw = await self._client.get("/accounts")
|
|
59
|
+
return ListResponse(
|
|
60
|
+
data=[_parse_account(item) for item in raw.get("data", [])],
|
|
61
|
+
cursor=raw.get("cursor"),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def get(self, account_id: str) -> Account:
|
|
65
|
+
"""Retrieve a single bank account by ID."""
|
|
66
|
+
raw = await self._client.get(f"/accounts/{account_id}")
|
|
67
|
+
return _parse_account(raw)
|
|
68
|
+
|
|
69
|
+
async def sync(self, account_id: str) -> SyncResult:
|
|
70
|
+
"""Trigger an on-demand sync for the given account."""
|
|
71
|
+
raw = await self._client.post(f"/accounts/{account_id}/sync")
|
|
72
|
+
return SyncResult(
|
|
73
|
+
synced=raw["synced"],
|
|
74
|
+
skipped=raw["skipped"],
|
|
75
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ..types import Balance
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .._client import AsyncClient, SyncClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Balances:
|
|
12
|
+
"""Synchronous balances resource."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, client: SyncClient) -> None:
|
|
15
|
+
self._client = client
|
|
16
|
+
|
|
17
|
+
def get(self, account_id: str) -> Balance:
|
|
18
|
+
"""Retrieve the current balance for the given account."""
|
|
19
|
+
raw = self._client.get(f"/accounts/{account_id}/balance")
|
|
20
|
+
return Balance(
|
|
21
|
+
account_id=raw["account_id"],
|
|
22
|
+
balance=float(raw["balance"]) if raw.get("balance") is not None else None,
|
|
23
|
+
currency=raw["currency"],
|
|
24
|
+
last_synced_at=raw.get("last_synced_at"),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AsyncBalances:
|
|
29
|
+
"""Asynchronous balances resource."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, client: AsyncClient) -> None:
|
|
32
|
+
self._client = client
|
|
33
|
+
|
|
34
|
+
async def get(self, account_id: str) -> Balance:
|
|
35
|
+
"""Retrieve the current balance for the given account."""
|
|
36
|
+
raw = await self._client.get(f"/accounts/{account_id}/balance")
|
|
37
|
+
return Balance(
|
|
38
|
+
account_id=raw["account_id"],
|
|
39
|
+
balance=float(raw["balance"]) if raw.get("balance") is not None else None,
|
|
40
|
+
currency=raw["currency"],
|
|
41
|
+
last_synced_at=raw.get("last_synced_at"),
|
|
42
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from ..types import LinkResult
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .._client import AsyncClient, SyncClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parse_link_result(raw: dict) -> LinkResult:
|
|
12
|
+
return LinkResult(
|
|
13
|
+
type=raw["type"],
|
|
14
|
+
profile_id=raw.get("profile_id"),
|
|
15
|
+
account_number=raw.get("account_number"),
|
|
16
|
+
session_token=raw.get("session_token"),
|
|
17
|
+
message=raw.get("message"),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LinkResource:
|
|
22
|
+
"""Synchronous link resource for connecting bank accounts."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, client: SyncClient) -> None:
|
|
25
|
+
self._client = client
|
|
26
|
+
|
|
27
|
+
def create(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
bank_id: str,
|
|
31
|
+
credentials: Dict[str, str],
|
|
32
|
+
nickname: Optional[str] = None,
|
|
33
|
+
) -> LinkResult:
|
|
34
|
+
"""Initiate a bank account link flow."""
|
|
35
|
+
body: Dict[str, object] = {
|
|
36
|
+
"bankId": bank_id,
|
|
37
|
+
"credentials": credentials,
|
|
38
|
+
}
|
|
39
|
+
if nickname is not None:
|
|
40
|
+
body["nickname"] = nickname
|
|
41
|
+
raw = self._client.post("/link", body=body)
|
|
42
|
+
return _parse_link_result(raw)
|
|
43
|
+
|
|
44
|
+
def submit_otp(self, *, session_token: str, otp: str) -> LinkResult:
|
|
45
|
+
"""Submit an OTP to complete a multi-step link flow."""
|
|
46
|
+
raw = self._client.post(
|
|
47
|
+
"/link/otp",
|
|
48
|
+
body={"session_token": session_token, "otp": otp},
|
|
49
|
+
)
|
|
50
|
+
return _parse_link_result(raw)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AsyncLinkResource:
|
|
54
|
+
"""Asynchronous link resource for connecting bank accounts."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, client: AsyncClient) -> None:
|
|
57
|
+
self._client = client
|
|
58
|
+
|
|
59
|
+
async def create(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
bank_id: str,
|
|
63
|
+
credentials: Dict[str, str],
|
|
64
|
+
nickname: Optional[str] = None,
|
|
65
|
+
) -> LinkResult:
|
|
66
|
+
"""Initiate a bank account link flow."""
|
|
67
|
+
body: Dict[str, object] = {
|
|
68
|
+
"bankId": bank_id,
|
|
69
|
+
"credentials": credentials,
|
|
70
|
+
}
|
|
71
|
+
if nickname is not None:
|
|
72
|
+
body["nickname"] = nickname
|
|
73
|
+
raw = await self._client.post("/link", body=body)
|
|
74
|
+
return _parse_link_result(raw)
|
|
75
|
+
|
|
76
|
+
async def submit_otp(self, *, session_token: str, otp: str) -> LinkResult:
|
|
77
|
+
"""Submit an OTP to complete a multi-step link flow."""
|
|
78
|
+
raw = await self._client.post(
|
|
79
|
+
"/link/otp",
|
|
80
|
+
body={"session_token": session_token, "otp": otp},
|
|
81
|
+
)
|
|
82
|
+
return _parse_link_result(raw)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, AsyncIterator, Iterator, Optional
|
|
4
|
+
|
|
5
|
+
from ..types import ListResponse, Transaction
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .._client import AsyncClient, SyncClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parse_transaction(raw: dict) -> Transaction:
|
|
12
|
+
return Transaction(
|
|
13
|
+
id=raw["id"],
|
|
14
|
+
account_id=raw["account_id"],
|
|
15
|
+
external_id=raw["external_id"],
|
|
16
|
+
date=raw["date"],
|
|
17
|
+
description=raw["description"],
|
|
18
|
+
amount=float(raw["amount"]),
|
|
19
|
+
currency=raw["currency"],
|
|
20
|
+
direction=raw["direction"],
|
|
21
|
+
balance=float(raw["balance"]) if raw.get("balance") is not None else None,
|
|
22
|
+
reference=raw.get("reference"),
|
|
23
|
+
created_at=raw["created_at"],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_path(
|
|
28
|
+
account_id: str,
|
|
29
|
+
limit: Optional[int],
|
|
30
|
+
cursor: Optional[str],
|
|
31
|
+
) -> str:
|
|
32
|
+
path = f"/accounts/{account_id}/transactions"
|
|
33
|
+
params: list[str] = []
|
|
34
|
+
if limit is not None:
|
|
35
|
+
params.append(f"limit={limit}")
|
|
36
|
+
if cursor is not None:
|
|
37
|
+
params.append(f"cursor={cursor}")
|
|
38
|
+
if params:
|
|
39
|
+
path = f"{path}?{'&'.join(params)}"
|
|
40
|
+
return path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Transactions:
|
|
44
|
+
"""Synchronous transactions resource."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, client: SyncClient) -> None:
|
|
47
|
+
self._client = client
|
|
48
|
+
|
|
49
|
+
def list(
|
|
50
|
+
self,
|
|
51
|
+
account_id: str,
|
|
52
|
+
*,
|
|
53
|
+
limit: Optional[int] = None,
|
|
54
|
+
cursor: Optional[str] = None,
|
|
55
|
+
) -> ListResponse[Transaction]:
|
|
56
|
+
"""Fetch a single page of transactions for the given account."""
|
|
57
|
+
path = _build_path(account_id, limit, cursor)
|
|
58
|
+
raw = self._client.get(path)
|
|
59
|
+
return ListResponse(
|
|
60
|
+
data=[_parse_transaction(item) for item in raw.get("data", [])],
|
|
61
|
+
cursor=raw.get("cursor"),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def list_auto_paginate(
|
|
65
|
+
self,
|
|
66
|
+
account_id: str,
|
|
67
|
+
*,
|
|
68
|
+
limit: Optional[int] = None,
|
|
69
|
+
) -> Iterator[Transaction]:
|
|
70
|
+
"""Yield all transactions across all pages automatically."""
|
|
71
|
+
cursor: Optional[str] = None
|
|
72
|
+
while True:
|
|
73
|
+
page = self.list(account_id, limit=limit, cursor=cursor)
|
|
74
|
+
yield from page.data
|
|
75
|
+
if page.cursor is None:
|
|
76
|
+
break
|
|
77
|
+
cursor = page.cursor
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AsyncTransactions:
|
|
81
|
+
"""Asynchronous transactions resource."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, client: AsyncClient) -> None:
|
|
84
|
+
self._client = client
|
|
85
|
+
|
|
86
|
+
async def list(
|
|
87
|
+
self,
|
|
88
|
+
account_id: str,
|
|
89
|
+
*,
|
|
90
|
+
limit: Optional[int] = None,
|
|
91
|
+
cursor: Optional[str] = None,
|
|
92
|
+
) -> ListResponse[Transaction]:
|
|
93
|
+
"""Fetch a single page of transactions for the given account."""
|
|
94
|
+
path = _build_path(account_id, limit, cursor)
|
|
95
|
+
raw = await self._client.get(path)
|
|
96
|
+
return ListResponse(
|
|
97
|
+
data=[_parse_transaction(item) for item in raw.get("data", [])],
|
|
98
|
+
cursor=raw.get("cursor"),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
async def list_auto_paginate(
|
|
102
|
+
self,
|
|
103
|
+
account_id: str,
|
|
104
|
+
*,
|
|
105
|
+
limit: Optional[int] = None,
|
|
106
|
+
) -> AsyncIterator[Transaction]:
|
|
107
|
+
"""Yield all transactions across all pages automatically."""
|
|
108
|
+
cursor: Optional[str] = None
|
|
109
|
+
while True:
|
|
110
|
+
page = await self.list(account_id, limit=limit, cursor=cursor)
|
|
111
|
+
for txn in page.data:
|
|
112
|
+
yield txn
|
|
113
|
+
if page.cursor is None:
|
|
114
|
+
break
|
|
115
|
+
cursor = page.cursor
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Generic, List, Optional, TypeVar
|
|
5
|
+
|
|
6
|
+
T = TypeVar("T")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Account:
|
|
11
|
+
id: str
|
|
12
|
+
bank: str
|
|
13
|
+
account_number: Optional[str]
|
|
14
|
+
nickname: str
|
|
15
|
+
last_synced_at: Optional[str]
|
|
16
|
+
created_at: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Transaction:
|
|
21
|
+
id: str
|
|
22
|
+
account_id: str
|
|
23
|
+
external_id: str
|
|
24
|
+
date: str
|
|
25
|
+
description: str
|
|
26
|
+
amount: float
|
|
27
|
+
currency: str
|
|
28
|
+
direction: str
|
|
29
|
+
balance: Optional[float]
|
|
30
|
+
reference: Optional[str]
|
|
31
|
+
created_at: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Balance:
|
|
36
|
+
account_id: str
|
|
37
|
+
balance: Optional[float]
|
|
38
|
+
currency: str
|
|
39
|
+
last_synced_at: Optional[str]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class SyncResult:
|
|
44
|
+
synced: int
|
|
45
|
+
skipped: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class LinkResult:
|
|
50
|
+
type: str
|
|
51
|
+
profile_id: Optional[str] = None
|
|
52
|
+
account_number: Optional[str] = None
|
|
53
|
+
session_token: Optional[str] = None
|
|
54
|
+
message: Optional[str] = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class ListResponse(Generic[T]):
|
|
59
|
+
data: List[T]
|
|
60
|
+
cursor: Optional[str]
|