beancount-gocardless 0.1.8__py3-none-any.whl → 0.1.9__py3-none-any.whl
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.
- beancount_gocardless/__init__.py +10 -2
- beancount_gocardless/cli.py +70 -44
- beancount_gocardless/client.py +323 -326
- beancount_gocardless/importer.py +246 -125
- beancount_gocardless/models.py +538 -0
- beancount_gocardless/openapi/swagger.json +5344 -0
- beancount_gocardless/tui.py +771 -0
- beancount_gocardless/tui2.py +17 -0
- beancount_gocardless-0.1.9.dist-info/METADATA +126 -0
- beancount_gocardless-0.1.9.dist-info/RECORD +13 -0
- {beancount_gocardless-0.1.8.dist-info → beancount_gocardless-0.1.9.dist-info}/WHEEL +1 -1
- beancount_gocardless-0.1.9.dist-info/entry_points.txt +3 -0
- beancount_gocardless-0.1.9.dist-info/licenses/LICENSE +24 -0
- beancount_gocardless-0.1.8.dist-info/LICENSE +0 -19
- beancount_gocardless-0.1.8.dist-info/METADATA +0 -92
- beancount_gocardless-0.1.8.dist-info/RECORD +0 -9
- beancount_gocardless-0.1.8.dist-info/entry_points.txt +0 -3
beancount_gocardless/client.py
CHANGED
|
@@ -1,19 +1,46 @@
|
|
|
1
|
-
|
|
1
|
+
"""
|
|
2
|
+
GoCardless Bank Account Data API Client
|
|
3
|
+
Clean, typed, auto-generated client with full caching and header stripping
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Optional, Dict, Any, List
|
|
8
|
+
from datetime import datetime, timedelta
|
|
2
9
|
import requests_cache
|
|
3
10
|
import requests
|
|
4
|
-
from
|
|
5
|
-
|
|
11
|
+
from .models import (
|
|
12
|
+
Account,
|
|
13
|
+
AccountBalance,
|
|
14
|
+
AccountDetail,
|
|
15
|
+
AccountTransactions,
|
|
16
|
+
Institution,
|
|
17
|
+
Requisition,
|
|
18
|
+
EndUserAgreement,
|
|
19
|
+
ReconfirmationRetrieve,
|
|
20
|
+
PaginatedRequisitionList,
|
|
21
|
+
PaginatedEndUserAgreementList,
|
|
22
|
+
Integration,
|
|
23
|
+
IntegrationRetrieve,
|
|
24
|
+
SpectacularJWTObtain,
|
|
25
|
+
SpectacularJWTRefresh,
|
|
26
|
+
AccountInfo,
|
|
27
|
+
)
|
|
6
28
|
|
|
7
29
|
logger = logging.getLogger(__name__)
|
|
8
30
|
|
|
9
31
|
|
|
10
32
|
def strip_headers_hook(response, *args, **kwargs):
|
|
33
|
+
"""
|
|
34
|
+
Strip response headers to reduce cache size and improve privacy.
|
|
35
|
+
Only preserves essential headers needed for proper operation.
|
|
36
|
+
"""
|
|
11
37
|
to_preserve = [
|
|
12
38
|
"Content-Type",
|
|
13
39
|
"Date",
|
|
14
40
|
"Content-Encoding",
|
|
15
41
|
"Content-Language",
|
|
16
42
|
"Last-Modified",
|
|
43
|
+
"Location",
|
|
17
44
|
]
|
|
18
45
|
deleted = set()
|
|
19
46
|
to_preserve_lower = [h.lower() for h in to_preserve]
|
|
@@ -28,103 +55,48 @@ def strip_headers_hook(response, *args, **kwargs):
|
|
|
28
55
|
return response
|
|
29
56
|
|
|
30
57
|
|
|
31
|
-
class
|
|
58
|
+
class GoCardlessClient:
|
|
32
59
|
"""
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
Attributes:
|
|
36
|
-
cache_name (str, optional): The name of the cache. Defaults to 'nordigen'. Can also be a path.
|
|
37
|
-
backend (str, optional): The cache backend to use (e.g., 'sqlite', 'memory'). Defaults to 'sqlite'.
|
|
38
|
-
expire_after (int, optional): The cache expiration time in seconds. Defaults to 86400 (24 hours).
|
|
39
|
-
old_data_on_error (bool, optional): Whether to return old cached data on a request error. Defaults to False.
|
|
40
|
-
"""
|
|
41
|
-
|
|
42
|
-
cache_name: requests_cache.StrOrPath
|
|
43
|
-
backend: Optional[requests_cache.BackendSpecifier]
|
|
44
|
-
expire_after: int
|
|
45
|
-
old_data_on_error: bool
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
class HttpServiceException(Exception):
|
|
49
|
-
"""
|
|
50
|
-
Exception raised for HTTP service errors. This wraps HTTP errors from the underlying requests library.
|
|
51
|
-
|
|
52
|
-
Attributes:
|
|
53
|
-
error (str): The original error message.
|
|
54
|
-
response_text (str, optional): The full response text from the server.
|
|
55
|
-
"""
|
|
56
|
-
|
|
57
|
-
def __init__(self, error, response_text=None):
|
|
58
|
-
self.error = error
|
|
59
|
-
self.response_text = response_text
|
|
60
|
-
super().__init__(f"{error}: {response_text}")
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
class BaseService:
|
|
64
|
-
"""
|
|
65
|
-
Base class for HTTP services handling authentication and requests to the GoCardless Bank Account Data API.
|
|
66
|
-
|
|
67
|
-
Attributes:
|
|
68
|
-
BASE_URL (str): The base URL for the API.
|
|
69
|
-
DEFAULT_CACHE_OPTIONS (CacheOptions): Default caching options.
|
|
70
|
-
secret_id (str): Your GoCardless API secret ID.
|
|
71
|
-
secret_key (str): Your GoCardless API secret key.
|
|
72
|
-
token (str, optional): The current API access token.
|
|
73
|
-
session (requests_cache.CachedSession): The cached requests session.
|
|
60
|
+
Clean, typed GoCardless Bank Account Data API client with full caching support.
|
|
74
61
|
"""
|
|
75
62
|
|
|
76
63
|
BASE_URL = "https://bankaccountdata.gocardless.com/api/v2"
|
|
77
64
|
|
|
78
|
-
DEFAULT_CACHE_OPTIONS: CacheOptions = {
|
|
79
|
-
"cache_name": "nordigen",
|
|
80
|
-
"backend": "sqlite",
|
|
81
|
-
"expire_after": 0,
|
|
82
|
-
"old_data_on_error": True,
|
|
83
|
-
"match_headers": False,
|
|
84
|
-
"cache_control": False,
|
|
85
|
-
}
|
|
86
|
-
|
|
87
65
|
def __init__(
|
|
88
66
|
self,
|
|
89
67
|
secret_id: str,
|
|
90
68
|
secret_key: str,
|
|
91
|
-
cache_options: Optional[
|
|
69
|
+
cache_options: Optional[Dict[str, Any]] = None,
|
|
92
70
|
):
|
|
93
|
-
""
|
|
94
|
-
Initializes the BaseService.
|
|
95
|
-
|
|
96
|
-
Args:
|
|
97
|
-
secret_id (str): Your GoCardless API secret ID.
|
|
98
|
-
secret_key (str): Your GoCardless API secret key.
|
|
99
|
-
cache_options (CacheOptions, optional): Custom cache options. Merges with and overrides DEFAULT_CACHE_OPTIONS.
|
|
100
|
-
"""
|
|
71
|
+
logger.info("Initializing GoCardlessClient")
|
|
101
72
|
self.secret_id = secret_id
|
|
102
73
|
self.secret_key = secret_key
|
|
103
|
-
self._token = None
|
|
104
|
-
|
|
105
|
-
|
|
74
|
+
self._token: Optional[str] = None
|
|
75
|
+
|
|
76
|
+
# Default cache options that match the original client
|
|
77
|
+
default_cache_options = {
|
|
78
|
+
"cache_name": "gocardless",
|
|
79
|
+
"backend": "sqlite",
|
|
80
|
+
"expire_after": 0,
|
|
81
|
+
"old_data_on_error": True,
|
|
82
|
+
"match_headers": False,
|
|
83
|
+
"cache_control": False,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# Merge with provided options
|
|
87
|
+
cache_config = {**default_cache_options, **(cache_options or {})}
|
|
88
|
+
logger.debug("Cache config: %s", cache_config)
|
|
89
|
+
|
|
90
|
+
# Create cached session with header stripping
|
|
91
|
+
self.session = requests_cache.CachedSession(**cache_config)
|
|
106
92
|
self.session.hooks["response"].append(strip_headers_hook)
|
|
107
93
|
|
|
108
94
|
def check_cache_status(self, method: str, url: str, params=None, data=None) -> dict:
|
|
109
95
|
"""
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
NOTE: This is an approximation and relies on internal mechanisms
|
|
113
|
-
that might change. It also performs I/O to check the cache.
|
|
114
|
-
|
|
115
|
-
Args:
|
|
116
|
-
method (str): HTTP method ("GET", "POST", etc.).
|
|
117
|
-
endpoint (str): API endpoint.
|
|
118
|
-
params (dict, optional): URL parameters.
|
|
119
|
-
data (dict, optional): Request body data.
|
|
120
|
-
|
|
121
|
-
Returns:
|
|
122
|
-
dict: Information about the potential cache state:
|
|
123
|
-
{'key_exists': bool, 'is_expired': Optional[bool], 'cache_key': str}
|
|
124
|
-
'is_expired' is None if the key doesn't exist or expiration
|
|
125
|
-
cannot be reliably determined without full retrieval.
|
|
96
|
+
Check cache status for a given request.
|
|
97
|
+
This mimics the original client's cache checking functionality.
|
|
126
98
|
"""
|
|
127
|
-
headers = {"Authorization": f"Bearer {self.
|
|
99
|
+
headers = {"Authorization": f"Bearer {self._token}"} if self._token else {}
|
|
128
100
|
|
|
129
101
|
req = requests.Request(method, url, params=params, data=data, headers=headers)
|
|
130
102
|
prepared_request: requests.PreparedRequest = self.session.prepare_request(req)
|
|
@@ -135,23 +107,17 @@ class BaseService:
|
|
|
135
107
|
|
|
136
108
|
if key_exists:
|
|
137
109
|
try:
|
|
138
|
-
# Try to get the response object without triggering expiration side effects
|
|
139
|
-
# Note: This still reads from the cache backend (I/O)
|
|
140
110
|
cached_response = cache.get_response(cache_key)
|
|
141
111
|
if cached_response:
|
|
142
|
-
# is_expired is a property calculated on the CachedResponse
|
|
143
112
|
is_expired = cached_response.is_expired
|
|
144
113
|
else:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
key_exists = False # Correct the state if get_response fails
|
|
148
|
-
is_expired = True # Assume expired if get_response returns None for existing key
|
|
114
|
+
key_exists = False
|
|
115
|
+
is_expired = True
|
|
149
116
|
except Exception as e:
|
|
150
117
|
logger.error(
|
|
151
118
|
f"Error checking expiration for cache key {cache_key}: {e}"
|
|
152
119
|
)
|
|
153
|
-
|
|
154
|
-
is_expired = None # Mark as unknown
|
|
120
|
+
is_expired = None
|
|
155
121
|
|
|
156
122
|
return {
|
|
157
123
|
"key_exists": key_exists,
|
|
@@ -160,10 +126,9 @@ class BaseService:
|
|
|
160
126
|
}
|
|
161
127
|
|
|
162
128
|
@property
|
|
163
|
-
def token(self):
|
|
129
|
+
def token(self) -> str:
|
|
164
130
|
"""
|
|
165
|
-
|
|
166
|
-
Nordigen tokens don't currently have a refresh mechanism, so this just gets a new one if needed.
|
|
131
|
+
Get or refresh access token.
|
|
167
132
|
"""
|
|
168
133
|
if not self._token:
|
|
169
134
|
self.get_token()
|
|
@@ -171,257 +136,289 @@ class BaseService:
|
|
|
171
136
|
|
|
172
137
|
def get_token(self):
|
|
173
138
|
"""
|
|
174
|
-
Fetch a new API access token using credentials.
|
|
175
|
-
|
|
176
|
-
Raises:
|
|
177
|
-
HttpServiceException: If the API request fails.
|
|
139
|
+
Fetch a new API access token using credentials.
|
|
178
140
|
"""
|
|
141
|
+
logger.debug("Fetching new access token")
|
|
179
142
|
response = requests.post(
|
|
180
143
|
f"{self.BASE_URL}/token/new/",
|
|
181
144
|
data={"secret_id": self.secret_id, "secret_key": self.secret_key},
|
|
182
145
|
)
|
|
183
|
-
|
|
146
|
+
response.raise_for_status()
|
|
184
147
|
self._token = response.json()["access"]
|
|
148
|
+
logger.debug("Access token obtained")
|
|
185
149
|
|
|
186
|
-
def
|
|
187
|
-
"""
|
|
188
|
-
Check response status and handle errors.
|
|
189
|
-
|
|
190
|
-
Args:
|
|
191
|
-
response (requests.Response): The response object from the API request.
|
|
192
|
-
|
|
193
|
-
Raises:
|
|
194
|
-
HttpServiceException: If the API request returns an error status code.
|
|
195
|
-
"""
|
|
196
|
-
try:
|
|
197
|
-
response.raise_for_status()
|
|
198
|
-
except requests.exceptions.HTTPError as e:
|
|
199
|
-
raise HttpServiceException(str(e), response.text)
|
|
200
|
-
|
|
201
|
-
def _request(self, method, endpoint, params=None, data=None):
|
|
202
|
-
"""
|
|
203
|
-
Execute an HTTP request with token handling and automatic retries.
|
|
204
|
-
|
|
205
|
-
Args:
|
|
206
|
-
method (str): The HTTP method (e.g., "GET", "POST", "DELETE").
|
|
207
|
-
endpoint (str): The API endpoint (relative to BASE_URL).
|
|
208
|
-
params (dict, optional): URL parameters for the request.
|
|
209
|
-
data (dict, optional): Data to send in the request body (for POST requests).
|
|
210
|
-
|
|
211
|
-
Returns:
|
|
212
|
-
requests.Response: The response object from the API request.
|
|
213
|
-
|
|
214
|
-
Raises:
|
|
215
|
-
HttpServiceException: If the API request fails.
|
|
216
|
-
"""
|
|
150
|
+
def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
|
|
151
|
+
"""Make authenticated request with caching"""
|
|
217
152
|
url = f"{self.BASE_URL}{endpoint}"
|
|
218
|
-
headers =
|
|
153
|
+
headers = kwargs.pop("headers", {})
|
|
154
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
219
155
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
156
|
+
# Check cache status for logging
|
|
157
|
+
status = self.check_cache_status(
|
|
158
|
+
method, url, kwargs.get("params"), kwargs.get("data")
|
|
159
|
+
)
|
|
160
|
+
logger.debug(
|
|
161
|
+
f"{endpoint}: {'expired' if status.get('is_expired') else 'cache ok'}"
|
|
224
162
|
)
|
|
225
|
-
logger.info("Response headers", response.headers)
|
|
226
163
|
|
|
227
|
-
|
|
164
|
+
response = self.session.request(method, url, headers=headers, **kwargs)
|
|
165
|
+
logger.debug("Response headers: %s", response.headers)
|
|
166
|
+
|
|
167
|
+
# Handle 401 by refreshing token
|
|
228
168
|
if response.status_code == 401:
|
|
229
|
-
self.get_token()
|
|
230
|
-
headers
|
|
231
|
-
response = self.session.request(
|
|
232
|
-
method, url, headers=headers, params=params, data=data
|
|
233
|
-
)
|
|
169
|
+
self.get_token()
|
|
170
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
171
|
+
response = self.session.request(method, url, headers=headers, **kwargs)
|
|
234
172
|
|
|
235
|
-
|
|
173
|
+
response.raise_for_status()
|
|
236
174
|
return response
|
|
237
175
|
|
|
238
|
-
def
|
|
239
|
-
"""
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
"""
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
to handle authentication and HTTP requests.
|
|
284
|
-
"""
|
|
285
|
-
|
|
286
|
-
def list_banks(self, country="GB"):
|
|
287
|
-
"""
|
|
288
|
-
List available institutions (banks) for a given country.
|
|
289
|
-
|
|
290
|
-
Args:
|
|
291
|
-
country (str, optional): The two-letter country code (ISO 3166). Defaults to "GB".
|
|
292
|
-
|
|
293
|
-
Returns:
|
|
294
|
-
list: A list of dictionaries, each containing the 'name' and 'id' of a bank.
|
|
295
|
-
|
|
296
|
-
Example:
|
|
297
|
-
```python
|
|
298
|
-
client = NordigenClient(...)
|
|
299
|
-
banks = client.list_banks(country="US")
|
|
300
|
-
for bank in banks:
|
|
301
|
-
print(f"{bank['name']} (ID: {bank['id']})")
|
|
302
|
-
```
|
|
303
|
-
"""
|
|
304
|
-
return [
|
|
305
|
-
{"name": bank["name"], "id": bank["id"]}
|
|
306
|
-
for bank in self._get("/institutions/", params={"country": country})
|
|
307
|
-
]
|
|
308
|
-
|
|
309
|
-
def find_requisition_id(self, reference):
|
|
310
|
-
"""
|
|
311
|
-
Find a requisition ID by its reference string.
|
|
312
|
-
|
|
313
|
-
Args:
|
|
314
|
-
reference (str): The unique reference string associated with the requisition.
|
|
315
|
-
|
|
316
|
-
Returns:
|
|
317
|
-
str or None: The requisition ID if found, otherwise None.
|
|
318
|
-
"""
|
|
319
|
-
requisitions = self._get("/requisitions/")["results"]
|
|
320
|
-
return next(
|
|
321
|
-
(req["id"] for req in requisitions if req["reference"] == reference), None
|
|
176
|
+
def get(self, endpoint: str, params: Optional[Dict] = None) -> Dict[str, Any]:
|
|
177
|
+
"""Make GET request"""
|
|
178
|
+
response = self._request("GET", endpoint, params=params)
|
|
179
|
+
return response.json()
|
|
180
|
+
|
|
181
|
+
def post(self, endpoint: str, data: Optional[Dict] = None) -> Dict[str, Any]:
|
|
182
|
+
"""Make POST request"""
|
|
183
|
+
response = self._request("POST", endpoint, data=data)
|
|
184
|
+
return response.json()
|
|
185
|
+
|
|
186
|
+
def delete(self, endpoint: str) -> Dict[str, Any]:
|
|
187
|
+
"""Make DELETE request"""
|
|
188
|
+
response = self._request("DELETE", endpoint)
|
|
189
|
+
return response.json()
|
|
190
|
+
|
|
191
|
+
# Account methods
|
|
192
|
+
def get_account(self, account_id: str) -> Account:
|
|
193
|
+
"""Get account metadata"""
|
|
194
|
+
logger.debug("Getting account metadata for %s", account_id)
|
|
195
|
+
data = self.get(f"/accounts/{account_id}/")
|
|
196
|
+
return Account(**data)
|
|
197
|
+
|
|
198
|
+
def get_account_balances(self, account_id: str) -> AccountBalance:
|
|
199
|
+
"""Get account balances"""
|
|
200
|
+
logger.debug("Getting account balances for %s", account_id)
|
|
201
|
+
data = self.get(f"/accounts/{account_id}/balances/")
|
|
202
|
+
return AccountBalance(**data)
|
|
203
|
+
|
|
204
|
+
def get_account_details(self, account_id: str) -> AccountDetail:
|
|
205
|
+
"""Get account details"""
|
|
206
|
+
logger.debug("Getting account details for %s", account_id)
|
|
207
|
+
data = self.get(f"/accounts/{account_id}/details/")
|
|
208
|
+
return AccountDetail(**data)
|
|
209
|
+
|
|
210
|
+
def get_account_transactions(
|
|
211
|
+
self, account_id: str, days_back: int = 180
|
|
212
|
+
) -> AccountTransactions:
|
|
213
|
+
"""Get account transactions"""
|
|
214
|
+
date_from = (datetime.now() - timedelta(days=days_back)).strftime("%Y-%m-%d")
|
|
215
|
+
date_to = datetime.now().strftime("%Y-%m-%d")
|
|
216
|
+
logger.debug(
|
|
217
|
+
"Fetching transactions for account %s from %s to %s",
|
|
218
|
+
account_id,
|
|
219
|
+
date_from,
|
|
220
|
+
date_to,
|
|
322
221
|
)
|
|
323
222
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
Args:
|
|
329
|
-
reference (str): A unique reference string for this link.
|
|
330
|
-
bank_id (str): The ID of the institution (bank) to link to.
|
|
331
|
-
redirect_url (str, optional): The URL to redirect the user to after authentication. Defaults to "http://localhost".
|
|
332
|
-
|
|
333
|
-
Returns:
|
|
334
|
-
dict: A dictionary with the status of the operation. If successful, includes the link URL.
|
|
335
|
-
- status: "exists" if a requisition with the given `reference` already exists, "created" otherwise
|
|
336
|
-
- message: A descriptive message
|
|
337
|
-
- link: (If status is "created") The URL to start the linking process.
|
|
338
|
-
|
|
339
|
-
Example:
|
|
340
|
-
```python
|
|
341
|
-
client = NordigenClient(...)
|
|
342
|
-
result = client.create_link(reference="my-unique-ref", bank_id="SANDBOXFINANCE_SFIN0000")
|
|
343
|
-
if result["status"] == "created":
|
|
344
|
-
print(f"Redirect user to: {result['link']}")
|
|
345
|
-
else:
|
|
346
|
-
print(result["message"])
|
|
347
|
-
```
|
|
348
|
-
"""
|
|
349
|
-
if self.find_requisition_id(reference):
|
|
350
|
-
return {"status": "exists", "message": f"Link {reference} exists"}
|
|
351
|
-
|
|
352
|
-
response = self._post(
|
|
353
|
-
"/requisitions/",
|
|
354
|
-
data={
|
|
355
|
-
"redirect": redirect_url,
|
|
356
|
-
"institution_id": bank_id,
|
|
357
|
-
"reference": reference,
|
|
358
|
-
},
|
|
223
|
+
data = self.get(
|
|
224
|
+
f"/accounts/{account_id}/transactions/",
|
|
225
|
+
params={"date_from": date_from, "date_to": date_to},
|
|
359
226
|
)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
"
|
|
227
|
+
booked_count = len(data.get("transactions", {}).get("booked", []))
|
|
228
|
+
pending_count = len(data.get("transactions", {}).get("pending", []))
|
|
229
|
+
logger.debug(
|
|
230
|
+
"Fetched %d booked and %d pending transactions for account %s",
|
|
231
|
+
booked_count,
|
|
232
|
+
pending_count,
|
|
233
|
+
account_id,
|
|
234
|
+
)
|
|
235
|
+
return AccountTransactions(**data)
|
|
236
|
+
|
|
237
|
+
# Institutions methods
|
|
238
|
+
def get_institutions(self, country: Optional[str] = None) -> List[Institution]:
|
|
239
|
+
"""Get institutions for a country"""
|
|
240
|
+
logger.debug("Getting institutions for country %s", country)
|
|
241
|
+
params = {"country": country} if country else {}
|
|
242
|
+
data = self.get("/institutions/", params=params)
|
|
243
|
+
logger.debug("Fetched %d institutions", len(data))
|
|
244
|
+
return [Institution(**inst) for inst in data]
|
|
245
|
+
|
|
246
|
+
def get_institution(self, institution_id: str) -> Institution:
|
|
247
|
+
"""Get specific institution"""
|
|
248
|
+
data = self.get(f"/institutions/{institution_id}/")
|
|
249
|
+
return Institution(**data)
|
|
250
|
+
|
|
251
|
+
# Requisitions methods
|
|
252
|
+
def create_requisition(
|
|
253
|
+
self, redirect: str, institution_id: str, reference: str, **kwargs
|
|
254
|
+
) -> Requisition:
|
|
255
|
+
"""Create a new requisition"""
|
|
256
|
+
request_data = {
|
|
257
|
+
"redirect": redirect,
|
|
258
|
+
"institution_id": institution_id,
|
|
259
|
+
"reference": reference,
|
|
260
|
+
}
|
|
261
|
+
request_data.update(kwargs)
|
|
262
|
+
data = self.post("/requisitions/", data=request_data)
|
|
263
|
+
return Requisition(**data)
|
|
264
|
+
|
|
265
|
+
def get_requisitions(self) -> List[Requisition]:
|
|
266
|
+
"""Get all requisitions"""
|
|
267
|
+
logger.debug("Getting all requisitions")
|
|
268
|
+
data = self.get("/requisitions/")
|
|
269
|
+
logger.debug("Fetched %d requisitions", len(data.get("results", [])))
|
|
270
|
+
return [Requisition(**req) for req in data.get("results", [])]
|
|
271
|
+
|
|
272
|
+
def get_requisition(self, requisition_id: str) -> Requisition:
|
|
273
|
+
"""Get specific requisition"""
|
|
274
|
+
data = self.get(f"/requisitions/{requisition_id}/")
|
|
275
|
+
return Requisition(**data)
|
|
276
|
+
|
|
277
|
+
def delete_requisition(self, requisition_id: str) -> Dict[str, Any]:
|
|
278
|
+
"""Delete a requisition"""
|
|
279
|
+
return self.delete(f"/requisitions/{requisition_id}/")
|
|
280
|
+
|
|
281
|
+
# Agreements methods
|
|
282
|
+
def create_agreement(
|
|
283
|
+
self,
|
|
284
|
+
institution_id: str,
|
|
285
|
+
max_historical_days: int,
|
|
286
|
+
access_valid_for_days: int,
|
|
287
|
+
access_scope: List[str],
|
|
288
|
+
**kwargs,
|
|
289
|
+
) -> EndUserAgreement:
|
|
290
|
+
"""Create end user agreement"""
|
|
291
|
+
request_data = {
|
|
292
|
+
"institution_id": institution_id,
|
|
293
|
+
"max_historical_days": max_historical_days,
|
|
294
|
+
"access_valid_for_days": access_valid_for_days,
|
|
295
|
+
"access_scope": access_scope,
|
|
364
296
|
}
|
|
297
|
+
request_data.update(kwargs)
|
|
298
|
+
data = self.post("/agreements/enduser/", data=request_data)
|
|
299
|
+
return EndUserAgreement(**data)
|
|
300
|
+
|
|
301
|
+
def get_agreements(self) -> List[EndUserAgreement]:
|
|
302
|
+
"""Get all agreements"""
|
|
303
|
+
data = self.get("/agreements/enduser/")
|
|
304
|
+
return [EndUserAgreement(**ag) for ag in data.get("results", [])]
|
|
305
|
+
|
|
306
|
+
def get_agreement(self, agreement_id: str) -> EndUserAgreement:
|
|
307
|
+
"""Get specific agreement"""
|
|
308
|
+
data = self.get(f"/agreements/enduser/{agreement_id}/")
|
|
309
|
+
return EndUserAgreement(**data)
|
|
310
|
+
|
|
311
|
+
def accept_agreement(
|
|
312
|
+
self, agreement_id: str, user_agent: str, ip: str
|
|
313
|
+
) -> Dict[str, Any]:
|
|
314
|
+
"""Accept an agreement"""
|
|
315
|
+
data = self.post(
|
|
316
|
+
f"/agreements/enduser/{agreement_id}/accept/",
|
|
317
|
+
data={"user_agent": user_agent, "ip": ip},
|
|
318
|
+
)
|
|
319
|
+
return data
|
|
320
|
+
|
|
321
|
+
def reconfirm_agreement(
|
|
322
|
+
self, agreement_id: str, user_agent: str, ip: str
|
|
323
|
+
) -> ReconfirmationRetrieve:
|
|
324
|
+
"""Reconfirm an agreement"""
|
|
325
|
+
data = self.post(
|
|
326
|
+
f"/agreements/enduser/{agreement_id}/reconfirm/",
|
|
327
|
+
data={"user_agent": user_agent, "ip": ip},
|
|
328
|
+
)
|
|
329
|
+
return ReconfirmationRetrieve(**data)
|
|
365
330
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
331
|
+
# Token management endpoints (usually handled internally)
|
|
332
|
+
def get_access_token(self) -> SpectacularJWTObtain:
|
|
333
|
+
"""Get a new access token (usually handled internally)"""
|
|
334
|
+
data = self.post(
|
|
335
|
+
"/token/new/",
|
|
336
|
+
data={"secret_id": self.secret_id, "secret_key": self.secret_key},
|
|
337
|
+
)
|
|
338
|
+
return SpectacularJWTObtain(**data)
|
|
339
|
+
|
|
340
|
+
def refresh_access_token(self, refresh_token: str) -> SpectacularJWTRefresh:
|
|
341
|
+
"""Refresh access token"""
|
|
342
|
+
data = self.post("/token/refresh/", data={"refresh": refresh_token})
|
|
343
|
+
return SpectacularJWTRefresh(**data)
|
|
344
|
+
|
|
345
|
+
# Integration endpoints
|
|
346
|
+
def get_integrations(self) -> List[Integration]:
|
|
347
|
+
"""Get all integrations"""
|
|
348
|
+
data = self.get("/integrations/")
|
|
349
|
+
return [Integration(**integration) for integration in data]
|
|
350
|
+
|
|
351
|
+
def get_integration(self, integration_id: str) -> IntegrationRetrieve:
|
|
352
|
+
"""Get specific integration"""
|
|
353
|
+
data = self.get(f"/integrations/{integration_id}/")
|
|
354
|
+
return IntegrationRetrieve(**data)
|
|
355
|
+
|
|
356
|
+
# Paginated endpoints with full response models
|
|
357
|
+
def get_requisitions_paginated(
|
|
358
|
+
self, limit: Optional[int] = None, offset: Optional[int] = None
|
|
359
|
+
) -> PaginatedRequisitionList:
|
|
360
|
+
"""Get paginated requisitions"""
|
|
361
|
+
params = {}
|
|
362
|
+
if limit:
|
|
363
|
+
params["limit"] = limit
|
|
364
|
+
if offset:
|
|
365
|
+
params["offset"] = offset
|
|
366
|
+
data = self.get("/requisitions/", params=params)
|
|
367
|
+
return PaginatedRequisitionList(**data)
|
|
368
|
+
|
|
369
|
+
def get_agreements_paginated(
|
|
370
|
+
self, limit: Optional[int] = None, offset: Optional[int] = None
|
|
371
|
+
) -> PaginatedEndUserAgreementList:
|
|
372
|
+
"""Get paginated agreements"""
|
|
373
|
+
params = {}
|
|
374
|
+
if limit:
|
|
375
|
+
params["limit"] = limit
|
|
376
|
+
if offset:
|
|
377
|
+
params["offset"] = offset
|
|
378
|
+
data = self.get("/agreements/enduser/", params=params)
|
|
379
|
+
return PaginatedEndUserAgreementList(**data)
|
|
380
|
+
|
|
381
|
+
# Convenience methods for common workflows
|
|
382
|
+
def list_banks(self, country: Optional[str] = None) -> List[str]:
|
|
383
|
+
"""Quick way to list bank names for a country"""
|
|
384
|
+
institutions = self.get_institutions(country)
|
|
385
|
+
return [inst.name for inst in institutions]
|
|
386
|
+
|
|
387
|
+
def find_requisition_by_reference(self, reference: str) -> Optional[Requisition]:
|
|
388
|
+
"""Find a requisition by its reference"""
|
|
389
|
+
requisitions = self.get_requisitions()
|
|
390
|
+
return next((req for req in requisitions if req.reference == reference), None)
|
|
391
|
+
|
|
392
|
+
def create_bank_link(
|
|
393
|
+
self, reference: str, bank_id: str, redirect_url: str = "http://localhost"
|
|
394
|
+
) -> Optional[str]:
|
|
395
|
+
"""Create bank link and return the URL"""
|
|
396
|
+
existing = self.find_requisition_by_reference(reference)
|
|
397
|
+
if existing:
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
requisition = self.create_requisition(
|
|
401
|
+
redirect=redirect_url, institution_id=bank_id, reference=reference
|
|
402
|
+
)
|
|
403
|
+
return requisition.link
|
|
369
404
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
"""
|
|
405
|
+
def get_all_accounts(self) -> List[AccountInfo]:
|
|
406
|
+
"""Get all accounts from all requisitions"""
|
|
373
407
|
accounts = []
|
|
374
|
-
for req in self.
|
|
375
|
-
for account_id in req
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
408
|
+
for req in self.get_requisitions():
|
|
409
|
+
for account_id in req.accounts:
|
|
410
|
+
try:
|
|
411
|
+
account = self.get_account(account_id)
|
|
412
|
+
account_dict = account.model_dump()
|
|
413
|
+
account_dict.update(
|
|
414
|
+
{
|
|
415
|
+
"requisition_id": req.id,
|
|
416
|
+
"requisition_reference": req.reference,
|
|
417
|
+
"institution_id": req.institution_id,
|
|
418
|
+
}
|
|
419
|
+
)
|
|
420
|
+
accounts.append(account_dict)
|
|
421
|
+
except Exception:
|
|
422
|
+
# Skip accounts that can't be accessed
|
|
423
|
+
continue
|
|
389
424
|
return accounts
|
|
390
|
-
|
|
391
|
-
def delete_link(self, reference):
|
|
392
|
-
"""
|
|
393
|
-
Delete a bank link (requisition) by its reference.
|
|
394
|
-
|
|
395
|
-
Args:
|
|
396
|
-
reference (str): The unique reference string of the link to delete.
|
|
397
|
-
|
|
398
|
-
Returns:
|
|
399
|
-
dict: A dictionary with the status and a message. Status can be "deleted" or "not_found".
|
|
400
|
-
"""
|
|
401
|
-
req_id = self.find_requisition_id(reference)
|
|
402
|
-
if not req_id:
|
|
403
|
-
return {"status": "not_found", "message": f"Link {reference} not found"}
|
|
404
|
-
|
|
405
|
-
self._delete(f"/requisitions/{req_id}")
|
|
406
|
-
return {"status": "deleted", "message": f"Link {reference} removed"}
|
|
407
|
-
|
|
408
|
-
def get_transactions(self, account_id, days_back=180):
|
|
409
|
-
"""
|
|
410
|
-
Retrieve transactions for a given account.
|
|
411
|
-
|
|
412
|
-
Args:
|
|
413
|
-
account_id (str): The ID of the account.
|
|
414
|
-
days_back (int, optional): The number of days back to retrieve transactions for. Defaults to 180.
|
|
415
|
-
|
|
416
|
-
Returns:
|
|
417
|
-
dict: The 'transactions' part of the API response, or an empty dict if no transactions are found.
|
|
418
|
-
See the Nordigen API documentation for the structure of this data.
|
|
419
|
-
"""
|
|
420
|
-
date_from = (datetime.now() - timedelta(days=days_back)).strftime("%Y-%m-%d")
|
|
421
|
-
return self._get(
|
|
422
|
-
f"/accounts/{account_id}/transactions/",
|
|
423
|
-
params={
|
|
424
|
-
"date_from": date_from,
|
|
425
|
-
"date_to": datetime.now().strftime("%Y-%m-%d"),
|
|
426
|
-
},
|
|
427
|
-
).get("transactions", [])
|