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.
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ build/
5
+ .venv/
@@ -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
@@ -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]