beancount-gocardless 0.1.0__tar.gz → 0.1.2__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,92 @@
1
+ Metadata-Version: 2.3
2
+ Name: beancount-gocardless
3
+ Version: 0.1.2
4
+ Summary:
5
+ License: MIT
6
+ Requires-Python: >=3.12
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Requires-Dist: beancount
12
+ Requires-Dist: beangulp
13
+ Requires-Dist: pyyaml
14
+ Requires-Dist: requests
15
+ Requires-Dist: requests-cache
16
+ Description-Content-Type: text/markdown
17
+
18
+ beancount-gocardless
19
+ ====================
20
+
21
+ This package provides a basic client for interacting with the GoCardless API (formerly Nordigen) and importing your data into Beancount.
22
+
23
+ This project was inspired by https://github.com/tarioch/beancounttools
24
+
25
+ Full documentation available at https://beancount-gocardless.readthedocs.io/en/latest/
26
+
27
+
28
+ **Key Features:**
29
+
30
+ - **GoCardless API Client:** A client for interacting with the GoCardless API. The client has built-in caching via `requests-cache`.
31
+ - **GoCardLess CLI**\: A command-line interface to manage authorization with the GoCardless API:
32
+
33
+ - Listing available banks in a specified country (default: GB).
34
+ - Creating a link to a specific bank using its ID.
35
+ - Listing authorized accounts.
36
+ - Deleting an existing link.
37
+ - Uses environment variables (`NORDIGEN_SECRET_ID`, `NORDIGEN_SECRET_KEY`) or command-line arguments for API credentials.
38
+ - **Beancount Importer:** A `beangulp.Importer` implementation to easily import transactions fetched from the GoCardless API directly into your Beancount ledger.
39
+
40
+ You'll need to create a GoCardLess account on https://bankaccountdata.gocardless.com/overview/ to get your credentials.
41
+
42
+ **Installation:**
43
+
44
+ ```bash
45
+ pip install beancount-gocardless
46
+ ```
47
+
48
+ **Usage**
49
+ ```yaml
50
+ #### nordigen.yaml
51
+ secret_id: $NORDIGEN_SECRET_ID
52
+ secret_key: $NORDIGEN_SECRET_KEY
53
+
54
+ cache_options: # by default, no caching if cache_options is not provided
55
+ cache_name: "nordigen"
56
+ backend: "sqlite"
57
+ expire_after: 3600
58
+ old_data_on_error: true
59
+
60
+ accounts:
61
+ - id: <REDACTED_UUID>
62
+ asset_account: "Assets:Banks:Revolut:Checking"
63
+ ```
64
+
65
+ ```python
66
+ #### my.import
67
+ #!/usr/bin/env python
68
+
69
+ import beangulp
70
+ from beancount_gocardless import NordigenImporter
71
+ from smart_importer import apply_hooks, PredictPostings, PredictPayees
72
+
73
+ importers = [
74
+ apply_hooks(
75
+ NordigenImporter(),
76
+ [
77
+ PredictPostings(),
78
+ PredictPayees(),
79
+ ],
80
+ )
81
+ ]
82
+
83
+ if __name__ == "__main__":
84
+ ingest = beangulp.Ingest(importers)
85
+ ingest()
86
+ ```
87
+
88
+ Import your data from Nordigen's API
89
+ ```bash
90
+ python my.import extract ./nordigen.yaml --existing ./ledger.bean
91
+ ```
92
+
@@ -0,0 +1,74 @@
1
+ beancount-gocardless
2
+ ====================
3
+
4
+ This package provides a basic client for interacting with the GoCardless API (formerly Nordigen) and importing your data into Beancount.
5
+
6
+ This project was inspired by https://github.com/tarioch/beancounttools
7
+
8
+ Full documentation available at https://beancount-gocardless.readthedocs.io/en/latest/
9
+
10
+
11
+ **Key Features:**
12
+
13
+ - **GoCardless API Client:** A client for interacting with the GoCardless API. The client has built-in caching via `requests-cache`.
14
+ - **GoCardLess CLI**\: A command-line interface to manage authorization with the GoCardless API:
15
+
16
+ - Listing available banks in a specified country (default: GB).
17
+ - Creating a link to a specific bank using its ID.
18
+ - Listing authorized accounts.
19
+ - Deleting an existing link.
20
+ - Uses environment variables (`NORDIGEN_SECRET_ID`, `NORDIGEN_SECRET_KEY`) or command-line arguments for API credentials.
21
+ - **Beancount Importer:** A `beangulp.Importer` implementation to easily import transactions fetched from the GoCardless API directly into your Beancount ledger.
22
+
23
+ You'll need to create a GoCardLess account on https://bankaccountdata.gocardless.com/overview/ to get your credentials.
24
+
25
+ **Installation:**
26
+
27
+ ```bash
28
+ pip install beancount-gocardless
29
+ ```
30
+
31
+ **Usage**
32
+ ```yaml
33
+ #### nordigen.yaml
34
+ secret_id: $NORDIGEN_SECRET_ID
35
+ secret_key: $NORDIGEN_SECRET_KEY
36
+
37
+ cache_options: # by default, no caching if cache_options is not provided
38
+ cache_name: "nordigen"
39
+ backend: "sqlite"
40
+ expire_after: 3600
41
+ old_data_on_error: true
42
+
43
+ accounts:
44
+ - id: <REDACTED_UUID>
45
+ asset_account: "Assets:Banks:Revolut:Checking"
46
+ ```
47
+
48
+ ```python
49
+ #### my.import
50
+ #!/usr/bin/env python
51
+
52
+ import beangulp
53
+ from beancount_gocardless import NordigenImporter
54
+ from smart_importer import apply_hooks, PredictPostings, PredictPayees
55
+
56
+ importers = [
57
+ apply_hooks(
58
+ NordigenImporter(),
59
+ [
60
+ PredictPostings(),
61
+ PredictPayees(),
62
+ ],
63
+ )
64
+ ]
65
+
66
+ if __name__ == "__main__":
67
+ ingest = beangulp.Ingest(importers)
68
+ ingest()
69
+ ```
70
+
71
+ Import your data from Nordigen's API
72
+ ```bash
73
+ python my.import extract ./nordigen.yaml --existing ./ledger.bean
74
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "beancount-gocardless"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = ""
5
5
  authors = []
6
6
  readme = "README.md"
@@ -19,6 +19,16 @@ license-files = ["LICENSE"]
19
19
  packages = [{include = "beancount_gocardless", from = "src"}]
20
20
 
21
21
 
22
+ [tool.poetry.group.dev.dependencies]
23
+ sphinx = "*"
24
+ sphinx-rtd-theme = "*"
25
+ myst_parser = "*"
26
+ pre-commit = "*"
27
+
28
+ [tool.poetry.group.lint.dependencies]
29
+ ruff = ">=0.9.8"
30
+
31
+
22
32
  [build-system]
23
33
  requires = ["poetry-core>=2.0.0,<3.0.0"]
24
34
  build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,2 @@
1
+ from .client import NordigenClient # noqa: F401
2
+ from .importer import NordigenImporter # noqa: F401
@@ -5,6 +5,12 @@ from .client import NordigenClient
5
5
 
6
6
 
7
7
  def parse_args():
8
+ """
9
+ Parses command-line arguments.
10
+
11
+ Returns:
12
+ argparse.Namespace: An object containing the parsed arguments.
13
+ """
8
14
  parser = argparse.ArgumentParser(description="Nordigen CLI Utility")
9
15
  parser.add_argument(
10
16
  "mode",
@@ -32,6 +38,11 @@ def parse_args():
32
38
 
33
39
 
34
40
  def main():
41
+ """
42
+ The main entry point for the CLI.
43
+
44
+ Executes the specified operation based on the parsed command-line arguments.
45
+ """
35
46
  args = parse_args()
36
47
 
37
48
  if not args.secret_id or not args.secret_key:
@@ -44,7 +55,7 @@ def main():
44
55
  client = NordigenClient(
45
56
  args.secret_id,
46
57
  args.secret_key,
47
- {"expire_after": 3600 * 24},
58
+ {},
48
59
  )
49
60
 
50
61
  if args.mode == "list_banks":
@@ -60,7 +71,7 @@ def main():
60
71
  accounts = client.list_accounts()
61
72
  for a in accounts:
62
73
  print(
63
- f"{a["institution_id"]} {a['name']}: {a['iban']} {a['currency']} ({a['reference']}/{a['id']})"
74
+ f"{a['institution_id']} {a['name']}: {a['iban']} {a['currency']} ({a['reference']}/{a['id']})"
64
75
  )
65
76
 
66
77
  elif args.mode == "delete_link":
@@ -0,0 +1,342 @@
1
+ from datetime import timedelta, datetime
2
+ import requests_cache
3
+ import requests
4
+ from typing import TypedDict, Optional
5
+
6
+
7
+ class CacheOptions(TypedDict, total=False):
8
+ """
9
+ Options for configuring requests-cache.
10
+
11
+ Attributes:
12
+ cache_name (str, optional): The name of the cache. Defaults to 'nordigen'. Can also be a path.
13
+ backend (str, optional): The cache backend to use (e.g., 'sqlite', 'memory'). Defaults to 'sqlite'.
14
+ expire_after (int, optional): The cache expiration time in seconds. Defaults to 86400 (24 hours).
15
+ old_data_on_error (bool, optional): Whether to return old cached data on a request error. Defaults to False.
16
+ """
17
+
18
+ cache_name: requests_cache.StrOrPath
19
+ backend: Optional[requests_cache.BackendSpecifier]
20
+ expire_after: requests_cache.ExpirationTime
21
+ old_data_on_error: bool
22
+
23
+
24
+ class HttpServiceException(Exception):
25
+ """
26
+ Exception raised for HTTP service errors. This wraps HTTP errors from the underlying requests library.
27
+
28
+ Attributes:
29
+ error (str): The original error message.
30
+ response_text (str, optional): The full response text from the server.
31
+ """
32
+
33
+ def __init__(self, error, response_text=None):
34
+ self.error = error
35
+ self.response_text = response_text
36
+ super().__init__(f"{error}: {response_text}")
37
+
38
+
39
+ class BaseService:
40
+ """
41
+ Base class for HTTP services handling authentication and requests to the GoCardless Bank Account Data API.
42
+
43
+ Attributes:
44
+ BASE_URL (str): The base URL for the API.
45
+ DEFAULT_CACHE_OPTIONS (CacheOptions): Default caching options.
46
+ secret_id (str): Your GoCardless API secret ID.
47
+ secret_key (str): Your GoCardless API secret key.
48
+ token (str, optional): The current API access token.
49
+ session (requests_cache.CachedSession): The cached requests session.
50
+ """
51
+
52
+ BASE_URL = "https://bankaccountdata.gocardless.com/api/v2"
53
+
54
+ DEFAULT_CACHE_OPTIONS: CacheOptions = {
55
+ "cache_name": "nordigen",
56
+ "backend": "sqlite",
57
+ "expire_after": 0,
58
+ "old_data_on_error": False,
59
+ }
60
+
61
+ def __init__(
62
+ self,
63
+ secret_id: str,
64
+ secret_key: str,
65
+ cache_options: Optional[CacheOptions],
66
+ ):
67
+ """
68
+ Initializes the BaseService.
69
+
70
+ Args:
71
+ secret_id (str): Your GoCardless API secret ID.
72
+ secret_key (str): Your GoCardless API secret key.
73
+ cache_options (CacheOptions, optional): Custom cache options. Merges with and overrides DEFAULT_CACHE_OPTIONS.
74
+ """
75
+ self.secret_id = secret_id
76
+ self.secret_key = secret_key
77
+ self.token = None
78
+ merged_options = {**self.DEFAULT_CACHE_OPTIONS, **(cache_options or {})}
79
+ self.session = requests_cache.CachedSession(**merged_options)
80
+
81
+ def _ensure_token_valid(self):
82
+ """
83
+ Ensure a valid token exists. Gets a new token if one doesn't exist.
84
+ Nordigen tokens don't currently have a refresh mechanism, so this just gets a new one if needed.
85
+ """
86
+ if not self.token:
87
+ self.get_token()
88
+
89
+ def get_token(self):
90
+ """
91
+ Fetch a new API access token using credentials. Sets the `self.token` attribute.
92
+
93
+ Raises:
94
+ HttpServiceException: If the API request fails.
95
+ """
96
+ response = requests.post(
97
+ f"{self.BASE_URL}/token/new/",
98
+ data={"secret_id": self.secret_id, "secret_key": self.secret_key},
99
+ )
100
+ self._handle_response(response)
101
+ self.token = response.json()["access"]
102
+
103
+ def _handle_response(self, response):
104
+ """
105
+ Check response status and handle errors.
106
+
107
+ Args:
108
+ response (requests.Response): The response object from the API request.
109
+
110
+ Raises:
111
+ HttpServiceException: If the API request returns an error status code.
112
+ """
113
+ try:
114
+ response.raise_for_status()
115
+ except requests.exceptions.HTTPError as e:
116
+ raise HttpServiceException(str(e), response.text)
117
+
118
+ def _request(self, method, endpoint, params=None, data=None):
119
+ """
120
+ Execute an HTTP request with token handling and automatic retries.
121
+
122
+ Args:
123
+ method (str): The HTTP method (e.g., "GET", "POST", "DELETE").
124
+ endpoint (str): The API endpoint (relative to BASE_URL).
125
+ params (dict, optional): URL parameters for the request.
126
+ data (dict, optional): Data to send in the request body (for POST requests).
127
+
128
+ Returns:
129
+ requests.Response: The response object from the API request.
130
+
131
+ Raises:
132
+ HttpServiceException: If the API request fails.
133
+ """
134
+ url = f"{self.BASE_URL}{endpoint}"
135
+ self._ensure_token_valid()
136
+ headers = {"Authorization": f"Bearer {self.token}"}
137
+
138
+ response = self.session.request(
139
+ method, url, headers=headers, params=params, data=data
140
+ )
141
+
142
+ # Retry once if token expired (401 Unauthorized)
143
+ if response.status_code == 401:
144
+ self.get_token() # Get a new token
145
+ headers = {"Authorization": f"Bearer {self.token}"} # Update headers
146
+ response = self.session.request(
147
+ method, url, headers=headers, params=params, data=data
148
+ )
149
+
150
+ self._handle_response(response)
151
+ return response
152
+
153
+ def _get(self, endpoint, params=None):
154
+ """
155
+ Perform a GET request and return the JSON response.
156
+
157
+ Args:
158
+ endpoint (str): The API endpoint.
159
+ params (dict, optional): URL parameters.
160
+
161
+ Returns:
162
+ dict: The JSON response from the API.
163
+ """
164
+ return self._request("GET", endpoint, params=params).json()
165
+
166
+ def _post(self, endpoint, data=None):
167
+ """
168
+ Perform a POST request and return the JSON response.
169
+
170
+ Args:
171
+ endpoint (str): The API endpoint.
172
+ data (dict, optional): Data to send in the request body.
173
+
174
+ Returns:
175
+ dict: The JSON response from the API.
176
+ """
177
+ return self._request("POST", endpoint, data=data).json()
178
+
179
+ def _delete(self, endpoint):
180
+ """
181
+ Perform a DELETE request and return the JSON response.
182
+
183
+ Args:
184
+ endpoint (str): The API endpoint.
185
+
186
+ Returns:
187
+ dict: The JSON response from the API.
188
+ """
189
+ return self._request("DELETE", endpoint).json()
190
+
191
+
192
+ class NordigenClient(BaseService):
193
+ """
194
+ Client for interacting with the Nordigen API (GoCardless Bank Account Data).
195
+
196
+ This class provides methods for listing banks, creating and managing requisitions (links),
197
+ listing accounts, deleting links, and retrieving transactions. It inherits from `BaseService`
198
+ to handle authentication and HTTP requests.
199
+ """
200
+
201
+ def list_banks(self, country="GB"):
202
+ """
203
+ List available institutions (banks) for a given country.
204
+
205
+ Args:
206
+ country (str, optional): The two-letter country code (ISO 3166). Defaults to "GB".
207
+
208
+ Returns:
209
+ list: A list of dictionaries, each containing the 'name' and 'id' of a bank.
210
+
211
+ Example:
212
+ ```python
213
+ client = NordigenClient(...)
214
+ banks = client.list_banks(country="US")
215
+ for bank in banks:
216
+ print(f"{bank['name']} (ID: {bank['id']})")
217
+ ```
218
+ """
219
+ return [
220
+ {"name": bank["name"], "id": bank["id"]}
221
+ for bank in self._get("/institutions/", params={"country": country})
222
+ ]
223
+
224
+ def find_requisition_id(self, reference):
225
+ """
226
+ Find a requisition ID by its reference string.
227
+
228
+ Args:
229
+ reference (str): The unique reference string associated with the requisition.
230
+
231
+ Returns:
232
+ str or None: The requisition ID if found, otherwise None.
233
+ """
234
+ requisitions = self._get("/requisitions/")["results"]
235
+ return next(
236
+ (req["id"] for req in requisitions if req["reference"] == reference), None
237
+ )
238
+
239
+ def create_link(self, reference, bank_id, redirect_url="http://localhost"):
240
+ """
241
+ Create a new bank link requisition.
242
+
243
+ Args:
244
+ reference (str): A unique reference string for this link.
245
+ bank_id (str): The ID of the institution (bank) to link to.
246
+ redirect_url (str, optional): The URL to redirect the user to after authentication. Defaults to "http://localhost".
247
+
248
+ Returns:
249
+ dict: A dictionary with the status of the operation. If successful, includes the link URL.
250
+ - status: "exists" if a requisition with the given `reference` already exists, "created" otherwise
251
+ - message: A descriptive message
252
+ - link: (If status is "created") The URL to start the linking process.
253
+
254
+ Example:
255
+ ```python
256
+ client = NordigenClient(...)
257
+ result = client.create_link(reference="my-unique-ref", bank_id="SANDBOXFINANCE_SFIN0000")
258
+ if result["status"] == "created":
259
+ print(f"Redirect user to: {result['link']}")
260
+ else:
261
+ print(result["message"])
262
+ ```
263
+ """
264
+ if self.find_requisition_id(reference):
265
+ return {"status": "exists", "message": f"Link {reference} exists"}
266
+
267
+ response = self._post(
268
+ "/requisitions/",
269
+ data={
270
+ "redirect": redirect_url,
271
+ "institution_id": bank_id,
272
+ "reference": reference,
273
+ },
274
+ )
275
+ return {
276
+ "status": "created",
277
+ "link": response["link"],
278
+ "message": f"Complete linking at: {response['link']}",
279
+ }
280
+
281
+ def list_accounts(self):
282
+ """
283
+ List all connected accounts with details (ID, institution, reference, IBAN, currency, name).
284
+
285
+ Returns:
286
+ list: A list of dictionaries, each representing a connected account.
287
+ """
288
+ accounts = []
289
+ for req in self._get("/requisitions/")["results"]:
290
+ for account_id in req["accounts"]:
291
+ account = self._get(f"/accounts/{account_id}")
292
+ details = self._get(f"/accounts/{account_id}/details")["account"]
293
+
294
+ accounts.append(
295
+ {
296
+ "id": account_id,
297
+ "institution_id": req.get("institution_id", ""),
298
+ "reference": req["reference"],
299
+ "iban": account.get("iban", ""),
300
+ "currency": details.get("currency", ""),
301
+ "name": details.get("name", "Unknown"),
302
+ }
303
+ )
304
+ return accounts
305
+
306
+ def delete_link(self, reference):
307
+ """
308
+ Delete a bank link (requisition) by its reference.
309
+
310
+ Args:
311
+ reference (str): The unique reference string of the link to delete.
312
+
313
+ Returns:
314
+ dict: A dictionary with the status and a message. Status can be "deleted" or "not_found".
315
+ """
316
+ req_id = self.find_requisition_id(reference)
317
+ if not req_id:
318
+ return {"status": "not_found", "message": f"Link {reference} not found"}
319
+
320
+ self._delete(f"/requisitions/{req_id}")
321
+ return {"status": "deleted", "message": f"Link {reference} removed"}
322
+
323
+ def get_transactions(self, account_id, days_back=180):
324
+ """
325
+ Retrieve transactions for a given account.
326
+
327
+ Args:
328
+ account_id (str): The ID of the account.
329
+ days_back (int, optional): The number of days back to retrieve transactions for. Defaults to 180.
330
+
331
+ Returns:
332
+ dict: The 'transactions' part of the API response, or an empty dict if no transactions are found.
333
+ See the Nordigen API documentation for the structure of this data.
334
+ """
335
+ date_from = (datetime.now() - timedelta(days=days_back)).strftime("%Y-%m-%d")
336
+ return self._get(
337
+ f"/accounts/{account_id}/transactions/",
338
+ params={
339
+ "date_from": date_from,
340
+ "date_to": datetime.now().strftime("%Y-%m-%d"),
341
+ },
342
+ ).get("transactions", [])
@@ -0,0 +1,345 @@
1
+ from datetime import date
2
+ from os import path
3
+ import beangulp
4
+ import yaml
5
+ from beancount.core import amount, data, flags
6
+ from beancount.core.number import D
7
+ from .client import NordigenClient
8
+
9
+
10
+ class NordigenImporter(beangulp.Importer):
11
+ """
12
+ An importer for Nordigen API with improved structure and extensibility.
13
+
14
+ Attributes:
15
+ config (dict): Configuration loaded from the YAML file.
16
+ _client (NordigenClient): Instance of the Nordigen API client.
17
+
18
+ """
19
+
20
+ def __init__(self):
21
+ """Initialize the NordigenImporter."""
22
+ self.config = None
23
+ self._client = None
24
+
25
+ @property
26
+ def client(self):
27
+ """
28
+ Lazily initializes and returns the Nordigen API client.
29
+
30
+ Returns:
31
+ NordigenClient: The initialized Nordigen API client.
32
+ """
33
+ if not self._client:
34
+ self._client = NordigenClient(
35
+ self.config["secret_id"],
36
+ self.config["secret_key"],
37
+ cache_options=self.config.get('cache_options', None),
38
+ )
39
+
40
+ return self._client
41
+
42
+ def identify(self, filepath: str) -> bool:
43
+ """
44
+ Identifies if the given file is a Nordigen configuration file.
45
+
46
+ Args:
47
+ filepath (str): The path to the file.
48
+
49
+ Returns:
50
+ bool: True if the file is a Nordigen configuration file, False otherwise.
51
+ """
52
+ return path.basename(filepath).endswith("nordigen.yaml")
53
+
54
+ def account(self, filepath: str) -> str:
55
+ """
56
+ Returns an empty string as account (not directly used in this importer).
57
+
58
+ Args:
59
+ filepath (str): The path to the file. Not used in this implementation.
60
+
61
+ Returns:
62
+ str: An empty string.
63
+ """
64
+ return "" # We get the account from the config file
65
+
66
+ def load_config(self, filepath: str):
67
+ """
68
+ Loads configuration from the specified YAML file.
69
+
70
+ Args:
71
+ filepath (str): The path to the YAML configuration file.
72
+
73
+ Returns:
74
+ dict: The loaded configuration dictionary. Also sets the `self.config` attribute.
75
+ """
76
+ with open(filepath, "r") as f:
77
+ raw_config = f.read()
78
+ expanded_config = path.expandvars(
79
+ raw_config
80
+ ) # Handle environment variables
81
+ self.config = yaml.safe_load(expanded_config)
82
+
83
+ return self.config
84
+
85
+ def get_transactions_data(self, account_id):
86
+ """
87
+ Retrieves transaction data for a given account ID from the Nordigen API.
88
+
89
+ Args:
90
+ account_id (str): The Nordigen account ID.
91
+
92
+ Returns:
93
+ dict: The transaction data returned by the API.
94
+ """
95
+ transactions_data = self.client.get_transactions(account_id)
96
+
97
+ return transactions_data
98
+
99
+ def get_all_transactions(self, transactions_data):
100
+ """
101
+ Combines booked and pending transactions and sorts them by date.
102
+
103
+ Args:
104
+ transactions_data (dict): The transaction data from the API,
105
+ containing 'booked' and 'pending' lists.
106
+
107
+ Returns:
108
+ list: A sorted list of tuples, where each tuple contains
109
+ a transaction dictionary and its status ('booked' or 'pending').
110
+ """
111
+ all_transactions = [
112
+ (tx, "booked") for tx in transactions_data.get("booked", [])
113
+ ] + [(tx, "pending") for tx in transactions_data.get("pending", [])]
114
+ return sorted(
115
+ all_transactions,
116
+ key=lambda x: x[0].get("valueDate") or x[0].get("bookingDate"),
117
+ )
118
+
119
+ def add_metadata(self, transaction, filing_account: str):
120
+ """
121
+ Extracts metadata from a transaction and returns it as a dictionary.
122
+
123
+ This method can be overridden in subclasses to customize metadata extraction.
124
+
125
+ Args:
126
+ transaction (dict): The transaction data from the API.
127
+ filing_account (str): The optional filing account from the configuration.
128
+
129
+ Returns:
130
+ dict: A dictionary of metadata key-value pairs.
131
+ """
132
+ metakv = {}
133
+
134
+ # Transaction ID
135
+ if "transactionId" in transaction:
136
+ metakv["nordref"] = transaction["transactionId"]
137
+
138
+ # Names
139
+ if "creditorName" in transaction:
140
+ metakv["creditorName"] = transaction["creditorName"]
141
+ if "debtorName" in transaction:
142
+ metakv["debtorName"] = transaction["debtorName"]
143
+
144
+ # Currency exchange
145
+ if "currencyExchange" in transaction:
146
+ instructedAmount = transaction["currencyExchange"]["instructedAmount"]
147
+ metakv["original"] = (
148
+ f"{instructedAmount['currency']} {instructedAmount['amount']}"
149
+ )
150
+
151
+ # Booking date if different from value date
152
+ if (
153
+ transaction.get("bookingDate")
154
+ and transaction.get("valueDate")
155
+ and transaction["bookingDate"] != transaction["valueDate"]
156
+ ):
157
+ metakv["bookingDate"] = transaction["bookingDate"]
158
+
159
+ if filing_account:
160
+ metakv["filing_account"] = filing_account
161
+
162
+ return metakv
163
+
164
+ def get_narration(self, transaction):
165
+ """
166
+ Extracts the narration from a transaction.
167
+
168
+ This method can be overridden in subclasses to customize narration extraction.
169
+
170
+ Args:
171
+ transaction (dict): The transaction data from the API.
172
+
173
+ Returns:
174
+ str: The extracted narration.
175
+ """
176
+ narration = ""
177
+
178
+ if "remittanceInformationUnstructured" in transaction:
179
+ narration += transaction["remittanceInformationUnstructured"]
180
+
181
+ if "remittanceInformationUnstructuredArray" in transaction:
182
+ narration += " ".join(transaction["remittanceInformationUnstructuredArray"])
183
+
184
+ return narration
185
+
186
+ def get_payee(self, transaction):
187
+ """
188
+ Extracts the payee from a transaction.
189
+
190
+ This method can be overridden in subclasses to customize payee extraction. The default
191
+ implementation returns an empty string.
192
+
193
+ Args:
194
+ transaction (dict): The transaction data from the API.
195
+
196
+ Returns:
197
+ str: The extracted payee (or an empty string by default).
198
+
199
+ """
200
+ return ""
201
+
202
+ def get_transaction_date(self, transaction):
203
+ """
204
+ Extracts the transaction date from a transaction. Prefers 'valueDate',
205
+ falls back to 'bookingDate'.
206
+
207
+ This method can be overridden in subclasses to customize date extraction.
208
+
209
+ Args:
210
+ transaction (dict): The transaction data from the API.
211
+
212
+ Returns:
213
+ date: The extracted transaction date, or None if no date is found.
214
+ """
215
+ date_str = transaction.get("valueDate") or transaction.get("bookingDate")
216
+ return date.fromisoformat(date_str) if date_str else None
217
+
218
+ def get_transaction_status(self, status):
219
+ """
220
+ Determines the Beancount transaction flag based on the transaction status.
221
+
222
+ This method can be overridden in subclasses to customize flag assignment. The default
223
+ implementation returns FLAG_OKAY for all transactions.
224
+
225
+ Args:
226
+ status (str): The transaction status ('booked' or 'pending').
227
+
228
+ Returns:
229
+ str: The Beancount transaction flag.
230
+ """
231
+ # Could be configured to use "!" for pending transactions status == 'pending'
232
+ return flags.FLAG_OKAY
233
+
234
+ def create_transaction_entry(
235
+ self, transaction, status, asset_account, filing_account
236
+ ):
237
+ """
238
+ Creates a Beancount transaction entry from a Nordigen transaction.
239
+
240
+ This method can be overridden in subclasses to customize entry creation.
241
+
242
+ Args:
243
+ transaction (dict): The transaction data from the API.
244
+ status (str): The transaction status ('booked' or 'pending').
245
+ asset_account (str): The Beancount asset account.
246
+ filing_account (str): The optional filing account.
247
+
248
+ Returns:
249
+ data.Transaction: The created Beancount transaction entry.
250
+ """
251
+ metakv = self.add_metadata(transaction, filing_account)
252
+ meta = data.new_metadata("", 0, metakv)
253
+
254
+ trx_date = self.get_transaction_date(transaction)
255
+ narration = self.get_narration(transaction)
256
+ payee = self.get_payee(transaction)
257
+ flag = self.get_transaction_status(status)
258
+
259
+ # Get transaction amount
260
+ tx_amount = amount.Amount(
261
+ D(str(transaction["transactionAmount"]["amount"])),
262
+ transaction["transactionAmount"]["currency"],
263
+ )
264
+
265
+ return data.Transaction(
266
+ meta,
267
+ trx_date,
268
+ flag,
269
+ payee,
270
+ narration,
271
+ data.EMPTY_SET,
272
+ data.EMPTY_SET,
273
+ [
274
+ data.Posting(
275
+ asset_account,
276
+ tx_amount,
277
+ None,
278
+ None,
279
+ None,
280
+ None,
281
+ ),
282
+ ],
283
+ )
284
+
285
+ def extract(self, filepath: str, existing: data.Entries) -> data.Entries:
286
+ """
287
+ Extracts Beancount entries from Nordigen transactions.
288
+
289
+ Args:
290
+ filepath (str): The path to the YAML configuration file.
291
+ existing (data.Entries): Existing Beancount entries (not used in this implementation).
292
+
293
+ Returns:
294
+ data.Entries: A list of Beancount transaction entries.
295
+ """
296
+ self.load_config(filepath)
297
+
298
+ entries = []
299
+ for account in self.config["accounts"]:
300
+ account_id = account["id"]
301
+ asset_account = account["asset_account"]
302
+ filing_account = account.get("filing_account", None)
303
+
304
+ transactions_data = self.get_transactions_data(account_id)
305
+ all_transactions = self.get_all_transactions(transactions_data)
306
+
307
+ for transaction, status in all_transactions:
308
+ entry = self.create_transaction_entry(
309
+ transaction, status, asset_account, filing_account
310
+ )
311
+ entries.append(entry)
312
+
313
+ return entries
314
+
315
+ def cmp(self, entry1: data.Transaction, entry2: data.Transaction):
316
+ """
317
+ Compares two transactions based on their 'nordref' metadata.
318
+
319
+ Used for sorting transactions. This assumes that 'nordref' is a unique
320
+ identifier for each transaction.
321
+
322
+ Args:
323
+ entry1 (data.Transaction): The first transaction.
324
+ entry2 (data.Transaction): The second transaction.
325
+
326
+ Returns:
327
+ int: -1 if entry1 < entry2, 0 if entry1 == entry2, 1 if entry1 > entry2.
328
+ Returns 0 if 'nordref' is not present in both.
329
+ """
330
+ if (
331
+ "nordref" in entry1.meta
332
+ and "nordref" in entry2.meta
333
+ and entry1.meta["nordref"] == entry2.meta["nordref"]
334
+ ):
335
+ return 0 # Consider them equal if nordref matches
336
+ elif (
337
+ "nordref" in entry1.meta
338
+ and "nordref" in entry2.meta
339
+ and entry1.meta["nordref"] < entry2.meta["nordref"]
340
+ ):
341
+ return -1
342
+ elif "nordref" in entry1.meta and "nordref" in entry2.meta:
343
+ return 1
344
+ else:
345
+ return 0
@@ -1,18 +0,0 @@
1
- Metadata-Version: 2.3
2
- Name: beancount-gocardless
3
- Version: 0.1.0
4
- Summary:
5
- License: MIT
6
- Requires-Python: >=3.12
7
- Classifier: License :: OSI Approved :: MIT License
8
- Classifier: Programming Language :: Python :: 3
9
- Classifier: Programming Language :: Python :: 3.12
10
- Classifier: Programming Language :: Python :: 3.13
11
- Requires-Dist: beancount
12
- Requires-Dist: beangulp
13
- Requires-Dist: pyyaml
14
- Requires-Dist: requests
15
- Requires-Dist: requests-cache
16
- Description-Content-Type: text/markdown
17
-
18
-
File without changes
@@ -1,173 +0,0 @@
1
- from datetime import date, timedelta, datetime
2
- import requests_cache
3
- import requests
4
- from typing import Protocol, TypedDict, Optional
5
-
6
-
7
- class CacheOptions(TypedDict, total=False):
8
- cache_name: requests_cache.StrOrPath
9
- backend: Optional[requests_cache.BackendSpecifier]
10
- expire_after: requests_cache.ExpirationTime
11
- old_data_on_error: bool
12
-
13
-
14
- class HttpServiceException(Exception):
15
- """Exception raised for HTTP service errors."""
16
-
17
- def __init__(self, error, response_text=None):
18
- self.error = error
19
- self.response_text = response_text
20
- super().__init__(f"{error}: {response_text}")
21
-
22
-
23
- class BaseService:
24
- """Base class for HTTP services handling authentication and requests."""
25
-
26
- BASE_URL = "https://bankaccountdata.gocardless.com/api/v2"
27
-
28
- DEFAULT_CACHE_OPTIONS: CacheOptions = {
29
- "cache_name": "nordigen",
30
- "backend": "sqlite",
31
- "expire_after": 3600 * 24,
32
- "old_data_on_error": False,
33
- }
34
-
35
- def __init__(
36
- self,
37
- secret_id: str,
38
- secret_key: str,
39
- cache_options: Optional[CacheOptions],
40
- ):
41
- self.secret_id = secret_id
42
- self.secret_key = secret_key
43
- self.token = None
44
- merged_options = {**self.DEFAULT_CACHE_OPTIONS, **(cache_options or {})}
45
- self.session = requests_cache.CachedSession(**merged_options)
46
-
47
- def _ensure_token_valid(self):
48
- """Ensure a valid token exists (no-op here as Nordigen doesn't provide refresh tokens)."""
49
- if not self.token:
50
- self.get_token()
51
-
52
- def get_token(self):
53
- """Fetch a new API token using credentials."""
54
- response = requests.post(
55
- f"{self.BASE_URL}/token/new/",
56
- data={"secret_id": self.secret_id, "secret_key": self.secret_key},
57
- )
58
- self._handle_response(response)
59
- self.token = response.json()["access"]
60
-
61
- def _handle_response(self, response):
62
- """Check response status and handle errors."""
63
- try:
64
- response.raise_for_status()
65
- except requests.exceptions.HTTPError as e:
66
- raise HttpServiceException(str(e), response.text)
67
-
68
- def _request(self, method, endpoint, params=None, data=None):
69
- """Execute an HTTP request with token handling."""
70
- url = f"{self.BASE_URL}{endpoint}"
71
- self._ensure_token_valid()
72
- headers = {"Authorization": f"Bearer {self.token}"}
73
-
74
- response = self.session.request(
75
- method, url, headers=headers, params=params, data=data
76
- )
77
-
78
- # Retry once if token expired
79
- if response.status_code == 401:
80
- self.get_token()
81
- headers = {"Authorization": f"Bearer {self.token}"}
82
- response = self.session.request(
83
- method, url, headers=headers, params=params, data=data
84
- )
85
-
86
- self._handle_response(response)
87
- return response
88
-
89
- def _get(self, endpoint, params=None):
90
- return self._request("GET", endpoint, params=params).json()
91
-
92
- def _post(self, endpoint, data=None):
93
- return self._request("POST", endpoint, data=data).json()
94
-
95
- def _delete(self, endpoint):
96
- return self._request("DELETE", endpoint).json()
97
-
98
-
99
- class NordigenClient(BaseService):
100
- """Client for interacting with the Nordigen API."""
101
-
102
- def list_banks(self, country="GB"):
103
- """List available institutions for a country."""
104
- return [
105
- {"name": bank["name"], "id": bank["id"]}
106
- for bank in self._get("/institutions/", params={"country": country})
107
- ]
108
-
109
- def find_requisition_id(self, reference):
110
- """Find requisition ID by reference."""
111
- requisitions = self._get("/requisitions/")["results"]
112
- return next(
113
- (req["id"] for req in requisitions if req["reference"] == reference), None
114
- )
115
-
116
- def create_link(self, reference, bank_id, redirect_url="http://localhost"):
117
- """Create a new bank link requisition."""
118
- if self.find_requisition_id(reference):
119
- return {"status": "exists", "message": f"Link {reference} exists"}
120
-
121
- response = self._post(
122
- "/requisitions/",
123
- data={
124
- "redirect": redirect_url,
125
- "institution_id": bank_id,
126
- "reference": reference,
127
- },
128
- )
129
- return {
130
- "status": "created",
131
- "link": response["link"],
132
- "message": f"Complete linking at: {response['link']}",
133
- }
134
-
135
- def list_accounts(self):
136
- """List all connected accounts with details."""
137
- accounts = []
138
- for req in self._get("/requisitions/")["results"]:
139
- for account_id in req["accounts"]:
140
- account = self._get(f"/accounts/{account_id}")
141
- details = self._get(f"/accounts/{account_id}/details")["account"]
142
-
143
- accounts.append(
144
- {
145
- "id": account_id,
146
- "institution_id": req.get("institution_id", ""),
147
- "reference": req["reference"],
148
- "iban": account.get("iban", ""),
149
- "currency": details.get("currency", ""),
150
- "name": details.get("name", "Unknown"),
151
- }
152
- )
153
- return accounts
154
-
155
- def delete_link(self, reference):
156
- """Delete a bank link by reference."""
157
- req_id = self.find_requisition_id(reference)
158
- if not req_id:
159
- return {"status": "not_found", "message": f"Link {reference} not found"}
160
-
161
- self._delete(f"/requisitions/{req_id}")
162
- return {"status": "deleted", "message": f"Link {reference} removed"}
163
-
164
- def get_transactions(self, account_id, days_back=180):
165
- """Retrieve transactions for an account."""
166
- date_from = (datetime.now() - timedelta(days=days_back)).strftime("%Y-%m-%d")
167
- return self._get(
168
- f"/accounts/{account_id}/transactions/",
169
- params={
170
- "date_from": date_from,
171
- "date_to": datetime.now().strftime("%Y-%m-%d"),
172
- },
173
- ).get("transactions", [])
@@ -1,183 +0,0 @@
1
- from datetime import date, timedelta, datetime
2
- from os import path
3
- import beangulp
4
- import yaml
5
- from beancount.core import amount, data, flags
6
- from beancount.core.number import D
7
- from .client import NordigenClient
8
-
9
-
10
- class NordigenImporter(beangulp.Importer):
11
- """An importer for Nordigen API with improved structure and extensibility."""
12
-
13
- def __init__(self):
14
- self.config = None
15
- self._client = None
16
-
17
- @property
18
- def client(self):
19
- if not self._client:
20
- self._client = NordigenClient(
21
- self.config["secret_id"],
22
- self.config["secret_key"],
23
- cache_options={"expire_after": 3600 * 24, "old_data_on_error": True},
24
- )
25
-
26
- return self._client
27
-
28
- def identify(self, filepath: str) -> bool:
29
- return path.basename(filepath).endswith("nordigen.yaml")
30
-
31
- def account(self, filepath: str) -> str:
32
- return ""
33
-
34
- def load_config(self, filepath: str):
35
- """Load configuration from YAML file."""
36
- with open(filepath, "r") as f:
37
- raw_config = f.read()
38
- expanded_config = path.expandvars(raw_config)
39
- self.config = yaml.safe_load(expanded_config)
40
-
41
- return self.config
42
-
43
- def get_transactions_data(self, account_id):
44
- """Get transactions data either from API or debug files."""
45
- transactions_data = self.client.get_transactions(account_id)
46
-
47
- return transactions_data
48
-
49
- def get_all_transactions(self, transactions_data):
50
- """Combine and sort booked and pending transactions."""
51
- all_transactions = [
52
- (tx, "booked") for tx in transactions_data.get("booked", [])
53
- ] + [(tx, "pending") for tx in transactions_data.get("pending", [])]
54
- return sorted(
55
- all_transactions,
56
- key=lambda x: x[0].get("valueDate") or x[0].get("bookingDate"),
57
- )
58
-
59
- def add_metadata(self, transaction, filing_account: str):
60
- """Extract metadata from transaction - overridable method."""
61
- metakv = {}
62
-
63
- # Transaction ID
64
- if "transactionId" in transaction:
65
- metakv["nordref"] = transaction["transactionId"]
66
-
67
- # Names
68
- if "creditorName" in transaction:
69
- metakv["creditorName"] = transaction["creditorName"]
70
- if "debtorName" in transaction:
71
- metakv["debtorName"] = transaction["debtorName"]
72
-
73
- # Currency exchange
74
- if "currencyExchange" in transaction:
75
- instructedAmount = transaction["currencyExchange"]["instructedAmount"]
76
- metakv["original"] = (
77
- f"{instructedAmount['currency']} {instructedAmount['amount']}"
78
- )
79
-
80
- # Booking date if different from value date
81
- if (
82
- transaction.get("bookingDate")
83
- and transaction.get("valueDate")
84
- and transaction["bookingDate"] != transaction["valueDate"]
85
- ):
86
- metakv["bookingDate"] = transaction["bookingDate"]
87
-
88
- if filing_account:
89
- metakv["filing_account"] = filing_account
90
-
91
- return metakv
92
-
93
- def get_narration(self, transaction):
94
- """Extract narration from transaction - overridable method."""
95
- narration = ""
96
-
97
- if "remittanceInformationUnstructured" in transaction:
98
- narration += transaction["remittanceInformationUnstructured"]
99
-
100
- if "remittanceInformationUnstructuredArray" in transaction:
101
- narration += " ".join(transaction["remittanceInformationUnstructuredArray"])
102
-
103
- return narration
104
-
105
- def get_payee(self, transaction):
106
- """Extract payee from transaction - overridable method."""
107
- return ""
108
-
109
- def get_transaction_date(self, transaction):
110
- """Extract transaction date - overridable method."""
111
- date_str = transaction.get("valueDate") or transaction.get("bookingDate")
112
- return date.fromisoformat(date_str) if date_str else None
113
-
114
- def get_transaction_status(self, status):
115
- """Determine transaction status flag - overridable method."""
116
- # Could be configured to use "!" for pending transactions status == 'pending'
117
- return flags.FLAG_OKAY
118
-
119
- def create_transaction_entry(
120
- self, transaction, status, asset_account, filing_account
121
- ):
122
- """Create a Beancount transaction entry - overridable method."""
123
- metakv = self.add_metadata(transaction, filing_account)
124
- meta = data.new_metadata("", 0, metakv)
125
-
126
- trx_date = self.get_transaction_date(transaction)
127
- narration = self.get_narration(transaction)
128
- payee = self.get_payee(transaction)
129
- flag = self.get_transaction_status(status)
130
-
131
- # Get transaction amount
132
- tx_amount = amount.Amount(
133
- D(str(transaction["transactionAmount"]["amount"])),
134
- transaction["transactionAmount"]["currency"],
135
- )
136
-
137
- return data.Transaction(
138
- meta,
139
- trx_date,
140
- flag,
141
- payee,
142
- narration,
143
- data.EMPTY_SET,
144
- data.EMPTY_SET,
145
- [
146
- data.Posting(
147
- asset_account,
148
- tx_amount,
149
- None,
150
- None,
151
- None,
152
- None,
153
- ),
154
- ],
155
- )
156
-
157
- def extract(self, filepath: str, existing: data.Entries) -> data.Entries:
158
- """Extract entries from Nordigen transactions."""
159
- self.load_config(filepath)
160
-
161
- entries = []
162
- for account in self.config["accounts"]:
163
- account_id = account["id"]
164
- asset_account = account["asset_account"]
165
- filing_account = account.get("filing_account", None)
166
-
167
- transactions_data = self.get_transactions_data(account_id)
168
- all_transactions = self.get_all_transactions(transactions_data)
169
-
170
- for transaction, status in all_transactions:
171
- entry = self.create_transaction_entry(
172
- transaction, status, asset_account, filing_account
173
- )
174
- entries.append(entry)
175
-
176
- return entries
177
-
178
- def cmp(self, entry1: data.Transaction, entry2: data.Transaction):
179
- return (
180
- "nordref" in entry1.meta
181
- and "nordref" in entry2.meta
182
- and entry1.meta["nordref"] == entry2.meta["nordref"]
183
- )