beancount-gocardless 0.1.7__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 +364 -308
- 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.7.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.7.dist-info/LICENSE +0 -19
- beancount_gocardless-0.1.7.dist-info/METADATA +0 -92
- beancount_gocardless-0.1.7.dist-info/RECORD +0 -9
- beancount_gocardless-0.1.7.dist-info/entry_points.txt +0 -3
beancount_gocardless/client.py
CHANGED
|
@@ -1,368 +1,424 @@
|
|
|
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
|
-
def
|
|
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
|
-
"ETag",
|
|
17
42
|
"Last-Modified",
|
|
43
|
+
"Location",
|
|
18
44
|
]
|
|
19
45
|
deleted = set()
|
|
20
46
|
to_preserve_lower = [h.lower() for h in to_preserve]
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
47
|
+
header_keys_to_check = response.headers.copy().keys()
|
|
48
|
+
for header in header_keys_to_check:
|
|
49
|
+
if header.lower() in to_preserve_lower:
|
|
50
|
+
continue
|
|
51
|
+
else:
|
|
52
|
+
response.headers.pop(header, None)
|
|
24
53
|
deleted.add(header)
|
|
25
|
-
logger.
|
|
54
|
+
logger.debug("Deleted headers: %s", ", ".join(deleted))
|
|
26
55
|
return response
|
|
27
56
|
|
|
28
57
|
|
|
29
|
-
class
|
|
30
|
-
"""
|
|
31
|
-
Options for configuring requests-cache.
|
|
32
|
-
|
|
33
|
-
Attributes:
|
|
34
|
-
cache_name (str, optional): The name of the cache. Defaults to 'nordigen'. Can also be a path.
|
|
35
|
-
backend (str, optional): The cache backend to use (e.g., 'sqlite', 'memory'). Defaults to 'sqlite'.
|
|
36
|
-
expire_after (int, optional): The cache expiration time in seconds. Defaults to 86400 (24 hours).
|
|
37
|
-
old_data_on_error (bool, optional): Whether to return old cached data on a request error. Defaults to False.
|
|
38
|
-
"""
|
|
39
|
-
|
|
40
|
-
cache_name: requests_cache.StrOrPath
|
|
41
|
-
backend: Optional[requests_cache.BackendSpecifier]
|
|
42
|
-
expire_after: requests_cache.ExpirationTime
|
|
43
|
-
old_data_on_error: bool
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
class HttpServiceException(Exception):
|
|
47
|
-
"""
|
|
48
|
-
Exception raised for HTTP service errors. This wraps HTTP errors from the underlying requests library.
|
|
49
|
-
|
|
50
|
-
Attributes:
|
|
51
|
-
error (str): The original error message.
|
|
52
|
-
response_text (str, optional): The full response text from the server.
|
|
53
|
-
"""
|
|
54
|
-
|
|
55
|
-
def __init__(self, error, response_text=None):
|
|
56
|
-
self.error = error
|
|
57
|
-
self.response_text = response_text
|
|
58
|
-
super().__init__(f"{error}: {response_text}")
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
class BaseService:
|
|
58
|
+
class GoCardlessClient:
|
|
62
59
|
"""
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
Attributes:
|
|
66
|
-
BASE_URL (str): The base URL for the API.
|
|
67
|
-
DEFAULT_CACHE_OPTIONS (CacheOptions): Default caching options.
|
|
68
|
-
secret_id (str): Your GoCardless API secret ID.
|
|
69
|
-
secret_key (str): Your GoCardless API secret key.
|
|
70
|
-
token (str, optional): The current API access token.
|
|
71
|
-
session (requests_cache.CachedSession): The cached requests session.
|
|
60
|
+
Clean, typed GoCardless Bank Account Data API client with full caching support.
|
|
72
61
|
"""
|
|
73
62
|
|
|
74
63
|
BASE_URL = "https://bankaccountdata.gocardless.com/api/v2"
|
|
75
64
|
|
|
76
|
-
DEFAULT_CACHE_OPTIONS: CacheOptions = {
|
|
77
|
-
"cache_name": "nordigen",
|
|
78
|
-
"backend": "sqlite",
|
|
79
|
-
"expire_after": 0,
|
|
80
|
-
"old_data_on_error": True,
|
|
81
|
-
"match_headers": False,
|
|
82
|
-
"cache_control": False,
|
|
83
|
-
"response_hook": cleanup_headers,
|
|
84
|
-
}
|
|
85
|
-
|
|
86
65
|
def __init__(
|
|
87
66
|
self,
|
|
88
67
|
secret_id: str,
|
|
89
68
|
secret_key: str,
|
|
90
|
-
cache_options: Optional[
|
|
69
|
+
cache_options: Optional[Dict[str, Any]] = None,
|
|
91
70
|
):
|
|
92
|
-
""
|
|
93
|
-
Initializes the BaseService.
|
|
94
|
-
|
|
95
|
-
Args:
|
|
96
|
-
secret_id (str): Your GoCardless API secret ID.
|
|
97
|
-
secret_key (str): Your GoCardless API secret key.
|
|
98
|
-
cache_options (CacheOptions, optional): Custom cache options. Merges with and overrides DEFAULT_CACHE_OPTIONS.
|
|
99
|
-
"""
|
|
71
|
+
logger.info("Initializing GoCardlessClient")
|
|
100
72
|
self.secret_id = secret_id
|
|
101
73
|
self.secret_key = secret_key
|
|
102
|
-
self.
|
|
103
|
-
|
|
104
|
-
|
|
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)
|
|
92
|
+
self.session.hooks["response"].append(strip_headers_hook)
|
|
93
|
+
|
|
94
|
+
def check_cache_status(self, method: str, url: str, params=None, data=None) -> dict:
|
|
95
|
+
"""
|
|
96
|
+
Check cache status for a given request.
|
|
97
|
+
This mimics the original client's cache checking functionality.
|
|
98
|
+
"""
|
|
99
|
+
headers = {"Authorization": f"Bearer {self._token}"} if self._token else {}
|
|
100
|
+
|
|
101
|
+
req = requests.Request(method, url, params=params, data=data, headers=headers)
|
|
102
|
+
prepared_request: requests.PreparedRequest = self.session.prepare_request(req)
|
|
103
|
+
cache = self.session.cache
|
|
104
|
+
cache_key = cache.create_key(prepared_request)
|
|
105
|
+
key_exists = cache.contains(cache_key)
|
|
106
|
+
is_expired = None
|
|
107
|
+
|
|
108
|
+
if key_exists:
|
|
109
|
+
try:
|
|
110
|
+
cached_response = cache.get_response(cache_key)
|
|
111
|
+
if cached_response:
|
|
112
|
+
is_expired = cached_response.is_expired
|
|
113
|
+
else:
|
|
114
|
+
key_exists = False
|
|
115
|
+
is_expired = True
|
|
116
|
+
except Exception as e:
|
|
117
|
+
logger.error(
|
|
118
|
+
f"Error checking expiration for cache key {cache_key}: {e}"
|
|
119
|
+
)
|
|
120
|
+
is_expired = None
|
|
105
121
|
|
|
106
|
-
|
|
122
|
+
return {
|
|
123
|
+
"key_exists": key_exists,
|
|
124
|
+
"is_expired": is_expired,
|
|
125
|
+
"cache_key": cache_key,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def token(self) -> str:
|
|
107
130
|
"""
|
|
108
|
-
|
|
109
|
-
Nordigen tokens don't currently have a refresh mechanism, so this just gets a new one if needed.
|
|
131
|
+
Get or refresh access token.
|
|
110
132
|
"""
|
|
111
|
-
if not self.
|
|
133
|
+
if not self._token:
|
|
112
134
|
self.get_token()
|
|
135
|
+
return self._token
|
|
113
136
|
|
|
114
137
|
def get_token(self):
|
|
115
138
|
"""
|
|
116
|
-
Fetch a new API access token using credentials.
|
|
117
|
-
|
|
118
|
-
Raises:
|
|
119
|
-
HttpServiceException: If the API request fails.
|
|
139
|
+
Fetch a new API access token using credentials.
|
|
120
140
|
"""
|
|
141
|
+
logger.debug("Fetching new access token")
|
|
121
142
|
response = requests.post(
|
|
122
143
|
f"{self.BASE_URL}/token/new/",
|
|
123
144
|
data={"secret_id": self.secret_id, "secret_key": self.secret_key},
|
|
124
145
|
)
|
|
125
|
-
|
|
126
|
-
self.
|
|
127
|
-
|
|
128
|
-
def _handle_response(self, response):
|
|
129
|
-
"""
|
|
130
|
-
Check response status and handle errors.
|
|
131
|
-
|
|
132
|
-
Args:
|
|
133
|
-
response (requests.Response): The response object from the API request.
|
|
134
|
-
|
|
135
|
-
Raises:
|
|
136
|
-
HttpServiceException: If the API request returns an error status code.
|
|
137
|
-
"""
|
|
138
|
-
try:
|
|
139
|
-
response.raise_for_status()
|
|
140
|
-
except requests.exceptions.HTTPError as e:
|
|
141
|
-
raise HttpServiceException(str(e), response.text)
|
|
142
|
-
|
|
143
|
-
def _request(self, method, endpoint, params=None, data=None):
|
|
144
|
-
"""
|
|
145
|
-
Execute an HTTP request with token handling and automatic retries.
|
|
146
|
+
response.raise_for_status()
|
|
147
|
+
self._token = response.json()["access"]
|
|
148
|
+
logger.debug("Access token obtained")
|
|
146
149
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
endpoint (str): The API endpoint (relative to BASE_URL).
|
|
150
|
-
params (dict, optional): URL parameters for the request.
|
|
151
|
-
data (dict, optional): Data to send in the request body (for POST requests).
|
|
152
|
-
|
|
153
|
-
Returns:
|
|
154
|
-
requests.Response: The response object from the API request.
|
|
155
|
-
|
|
156
|
-
Raises:
|
|
157
|
-
HttpServiceException: If the API request fails.
|
|
158
|
-
"""
|
|
150
|
+
def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
|
|
151
|
+
"""Make authenticated request with caching"""
|
|
159
152
|
url = f"{self.BASE_URL}{endpoint}"
|
|
160
|
-
|
|
161
|
-
headers
|
|
153
|
+
headers = kwargs.pop("headers", {})
|
|
154
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
162
155
|
|
|
163
|
-
|
|
164
|
-
|
|
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'}"
|
|
165
162
|
)
|
|
166
|
-
logger.info("Response headers", response.headers)
|
|
167
163
|
|
|
168
|
-
|
|
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
|
|
169
168
|
if response.status_code == 401:
|
|
170
|
-
self.get_token()
|
|
171
|
-
headers
|
|
172
|
-
response = self.session.request(
|
|
173
|
-
method, url, headers=headers, params=params, data=data
|
|
174
|
-
)
|
|
169
|
+
self.get_token()
|
|
170
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
171
|
+
response = self.session.request(method, url, headers=headers, **kwargs)
|
|
175
172
|
|
|
176
|
-
|
|
173
|
+
response.raise_for_status()
|
|
177
174
|
return response
|
|
178
175
|
|
|
179
|
-
def
|
|
180
|
-
"""
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
"""
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
to handle authentication and HTTP requests.
|
|
225
|
-
"""
|
|
226
|
-
|
|
227
|
-
def list_banks(self, country="GB"):
|
|
228
|
-
"""
|
|
229
|
-
List available institutions (banks) for a given country.
|
|
230
|
-
|
|
231
|
-
Args:
|
|
232
|
-
country (str, optional): The two-letter country code (ISO 3166). Defaults to "GB".
|
|
233
|
-
|
|
234
|
-
Returns:
|
|
235
|
-
list: A list of dictionaries, each containing the 'name' and 'id' of a bank.
|
|
236
|
-
|
|
237
|
-
Example:
|
|
238
|
-
```python
|
|
239
|
-
client = NordigenClient(...)
|
|
240
|
-
banks = client.list_banks(country="US")
|
|
241
|
-
for bank in banks:
|
|
242
|
-
print(f"{bank['name']} (ID: {bank['id']})")
|
|
243
|
-
```
|
|
244
|
-
"""
|
|
245
|
-
return [
|
|
246
|
-
{"name": bank["name"], "id": bank["id"]}
|
|
247
|
-
for bank in self._get("/institutions/", params={"country": country})
|
|
248
|
-
]
|
|
249
|
-
|
|
250
|
-
def find_requisition_id(self, reference):
|
|
251
|
-
"""
|
|
252
|
-
Find a requisition ID by its reference string.
|
|
253
|
-
|
|
254
|
-
Args:
|
|
255
|
-
reference (str): The unique reference string associated with the requisition.
|
|
256
|
-
|
|
257
|
-
Returns:
|
|
258
|
-
str or None: The requisition ID if found, otherwise None.
|
|
259
|
-
"""
|
|
260
|
-
requisitions = self._get("/requisitions/")["results"]
|
|
261
|
-
return next(
|
|
262
|
-
(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,
|
|
263
221
|
)
|
|
264
222
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
Args:
|
|
270
|
-
reference (str): A unique reference string for this link.
|
|
271
|
-
bank_id (str): The ID of the institution (bank) to link to.
|
|
272
|
-
redirect_url (str, optional): The URL to redirect the user to after authentication. Defaults to "http://localhost".
|
|
273
|
-
|
|
274
|
-
Returns:
|
|
275
|
-
dict: A dictionary with the status of the operation. If successful, includes the link URL.
|
|
276
|
-
- status: "exists" if a requisition with the given `reference` already exists, "created" otherwise
|
|
277
|
-
- message: A descriptive message
|
|
278
|
-
- link: (If status is "created") The URL to start the linking process.
|
|
279
|
-
|
|
280
|
-
Example:
|
|
281
|
-
```python
|
|
282
|
-
client = NordigenClient(...)
|
|
283
|
-
result = client.create_link(reference="my-unique-ref", bank_id="SANDBOXFINANCE_SFIN0000")
|
|
284
|
-
if result["status"] == "created":
|
|
285
|
-
print(f"Redirect user to: {result['link']}")
|
|
286
|
-
else:
|
|
287
|
-
print(result["message"])
|
|
288
|
-
```
|
|
289
|
-
"""
|
|
290
|
-
if self.find_requisition_id(reference):
|
|
291
|
-
return {"status": "exists", "message": f"Link {reference} exists"}
|
|
292
|
-
|
|
293
|
-
response = self._post(
|
|
294
|
-
"/requisitions/",
|
|
295
|
-
data={
|
|
296
|
-
"redirect": redirect_url,
|
|
297
|
-
"institution_id": bank_id,
|
|
298
|
-
"reference": reference,
|
|
299
|
-
},
|
|
223
|
+
data = self.get(
|
|
224
|
+
f"/accounts/{account_id}/transactions/",
|
|
225
|
+
params={"date_from": date_from, "date_to": date_to},
|
|
300
226
|
)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
"
|
|
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,
|
|
305
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,
|
|
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)
|
|
306
330
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
310
404
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
"""
|
|
405
|
+
def get_all_accounts(self) -> List[AccountInfo]:
|
|
406
|
+
"""Get all accounts from all requisitions"""
|
|
314
407
|
accounts = []
|
|
315
|
-
for req in self.
|
|
316
|
-
for account_id in req
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
330
424
|
return accounts
|
|
331
|
-
|
|
332
|
-
def delete_link(self, reference):
|
|
333
|
-
"""
|
|
334
|
-
Delete a bank link (requisition) by its reference.
|
|
335
|
-
|
|
336
|
-
Args:
|
|
337
|
-
reference (str): The unique reference string of the link to delete.
|
|
338
|
-
|
|
339
|
-
Returns:
|
|
340
|
-
dict: A dictionary with the status and a message. Status can be "deleted" or "not_found".
|
|
341
|
-
"""
|
|
342
|
-
req_id = self.find_requisition_id(reference)
|
|
343
|
-
if not req_id:
|
|
344
|
-
return {"status": "not_found", "message": f"Link {reference} not found"}
|
|
345
|
-
|
|
346
|
-
self._delete(f"/requisitions/{req_id}")
|
|
347
|
-
return {"status": "deleted", "message": f"Link {reference} removed"}
|
|
348
|
-
|
|
349
|
-
def get_transactions(self, account_id, days_back=180):
|
|
350
|
-
"""
|
|
351
|
-
Retrieve transactions for a given account.
|
|
352
|
-
|
|
353
|
-
Args:
|
|
354
|
-
account_id (str): The ID of the account.
|
|
355
|
-
days_back (int, optional): The number of days back to retrieve transactions for. Defaults to 180.
|
|
356
|
-
|
|
357
|
-
Returns:
|
|
358
|
-
dict: The 'transactions' part of the API response, or an empty dict if no transactions are found.
|
|
359
|
-
See the Nordigen API documentation for the structure of this data.
|
|
360
|
-
"""
|
|
361
|
-
date_from = (datetime.now() - timedelta(days=days_back)).strftime("%Y-%m-%d")
|
|
362
|
-
return self._get(
|
|
363
|
-
f"/accounts/{account_id}/transactions/",
|
|
364
|
-
params={
|
|
365
|
-
"date_from": date_from,
|
|
366
|
-
"date_to": datetime.now().strftime("%Y-%m-%d"),
|
|
367
|
-
},
|
|
368
|
-
).get("transactions", [])
|