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.
@@ -1,19 +1,46 @@
1
- from datetime import timedelta, datetime
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 typing import TypedDict, Optional
5
- import logging
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 CacheOptions(TypedDict, total=False):
58
+ class GoCardlessClient:
32
59
  """
33
- Options for configuring requests-cache.
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[CacheOptions],
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
- merged_options = {**self.DEFAULT_CACHE_OPTIONS, **(cache_options or {})}
105
- self.session = requests_cache.CachedSession(**merged_options)
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
- Attempts to predict the cache status for a given request.
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.token}"}
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
- # get_response might return None if item expired and configured to delete
146
- # Or if backend consistency issue. Treat as expired/absent.
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
- # Cannot determine expiration reliably
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
- Ensure a token exists. Gets a new token if one doesn't exist.
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. Sets the `self._token` attribute.
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
- self._handle_response(response)
146
+ response.raise_for_status()
184
147
  self._token = response.json()["access"]
148
+ logger.debug("Access token obtained")
185
149
 
186
- def _handle_response(self, response):
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 = {"Authorization": f"Bearer {self.token}"}
153
+ headers = kwargs.pop("headers", {})
154
+ headers["Authorization"] = f"Bearer {self.token}"
219
155
 
220
- status = self.check_cache_status(method, url, params, data)
221
- logger.debug(f"{endpoint}: {'expired' if status["is_expired"] else 'cache ok'}")
222
- response = self.session.request(
223
- method, url, headers=headers, params=params, data=data
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
- # Retry once if token expired (401 Unauthorized)
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() # Get a new token
230
- headers = {"Authorization": f"Bearer {self.token}"} # Update 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
- self._handle_response(response)
173
+ response.raise_for_status()
236
174
  return response
237
175
 
238
- def _get(self, endpoint, params=None):
239
- """
240
- Perform a GET request and return the JSON response.
241
-
242
- Args:
243
- endpoint (str): The API endpoint.
244
- params (dict, optional): URL parameters.
245
-
246
- Returns:
247
- dict: The JSON response from the API.
248
- """
249
- return self._request("GET", endpoint, params=params).json()
250
-
251
- def _post(self, endpoint, data=None):
252
- """
253
- Perform a POST request and return the JSON response.
254
-
255
- Args:
256
- endpoint (str): The API endpoint.
257
- data (dict, optional): Data to send in the request body.
258
-
259
- Returns:
260
- dict: The JSON response from the API.
261
- """
262
- return self._request("POST", endpoint, data=data).json()
263
-
264
- def _delete(self, endpoint):
265
- """
266
- Perform a DELETE request and return the JSON response.
267
-
268
- Args:
269
- endpoint (str): The API endpoint.
270
-
271
- Returns:
272
- dict: The JSON response from the API.
273
- """
274
- return self._request("DELETE", endpoint).json()
275
-
276
-
277
- class NordigenClient(BaseService):
278
- """
279
- Client for interacting with the Nordigen API (GoCardless Bank Account Data).
280
-
281
- This class provides methods for listing banks, creating and managing requisitions (links),
282
- listing accounts, deleting links, and retrieving transactions. It inherits from `BaseService`
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
- def create_link(self, reference, bank_id, redirect_url="http://localhost"):
325
- """
326
- Create a new bank link requisition.
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
- return {
361
- "status": "created",
362
- "link": response["link"],
363
- "message": f"Complete linking at: {response['link']}",
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
- def list_accounts(self):
367
- """
368
- List all connected accounts with details (ID, institution, reference, IBAN, currency, name).
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
- Returns:
371
- list: A list of dictionaries, each representing a connected account.
372
- """
405
+ def get_all_accounts(self) -> List[AccountInfo]:
406
+ """Get all accounts from all requisitions"""
373
407
  accounts = []
374
- for req in self._get("/requisitions/")["results"]:
375
- for account_id in req["accounts"]:
376
- account = self._get(f"/accounts/{account_id}")
377
- details = self._get(f"/accounts/{account_id}/details")["account"]
378
-
379
- accounts.append(
380
- {
381
- "id": account_id,
382
- "institution_id": req.get("institution_id", ""),
383
- "reference": req["reference"],
384
- "iban": account.get("iban", ""),
385
- "currency": details.get("currency", ""),
386
- "name": details.get("name", "Unknown"),
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", [])