beancount-gocardless 0.1.0__tar.gz → 0.1.1__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.
- beancount_gocardless-0.1.1/PKG-INFO +37 -0
- beancount_gocardless-0.1.1/README.md +20 -0
- {beancount_gocardless-0.1.0 → beancount_gocardless-0.1.1}/pyproject.toml +14 -1
- beancount_gocardless-0.1.1/src/beancount_gocardless/__init__.py +2 -0
- {beancount_gocardless-0.1.0 → beancount_gocardless-0.1.1}/src/beancount_gocardless/cli.py +12 -1
- beancount_gocardless-0.1.1/src/beancount_gocardless/client.py +342 -0
- beancount_gocardless-0.1.1/src/beancount_gocardless/importer.py +345 -0
- beancount_gocardless-0.1.0/PKG-INFO +0 -18
- beancount_gocardless-0.1.0/README.md +0 -0
- beancount_gocardless-0.1.0/src/beancount_gocardless/__init__.py +0 -0
- beancount_gocardless-0.1.0/src/beancount_gocardless/client.py +0 -173
- beancount_gocardless-0.1.0/src/beancount_gocardless/importer.py +0 -183
- {beancount_gocardless-0.1.0 → beancount_gocardless-0.1.1}/LICENSE +0 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: beancount-gocardless
|
|
3
|
+
Version: 0.1.1
|
|
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), a command-line interface (CLI) tool for authorization, and a Beancount importer for seamless integration with your financial data. It streamlines the process of fetching bank account data via the GoCardless API and incorporating it into your Beancount ledger.
|
|
22
|
+
|
|
23
|
+
**Key Features:**
|
|
24
|
+
|
|
25
|
+
* **GoCardless API Client:** A client for interacting with the GoCardless API. The client has built-in caching via `requests-cache`, which helps during testing as GoCardLess API limits the number of requests to 4/day/account.
|
|
26
|
+
* **GoCardLess CLI:** A command-line interface to manage authorization with the GoCardless API. The CLI supports:
|
|
27
|
+
* Listing available banks in a specified country (default: GB).
|
|
28
|
+
* Creating a link to a specific bank using its ID.
|
|
29
|
+
* Listing authorized accounts.
|
|
30
|
+
* Deleting an existing link.
|
|
31
|
+
* Uses environment variables (`NORDIGEN_SECRET_ID`, `NORDIGEN_SECRET_KEY`) or command-line arguments for API credentials.
|
|
32
|
+
* **Beancount Importer:** A `beangulp.Importer` implementation to easily import transactions fetched from the GoCardless API directly into your Beancount ledger.
|
|
33
|
+
|
|
34
|
+
**Installation:**
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install beancount-gocardless
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
beancount-gocardless
|
|
2
|
+
====================
|
|
3
|
+
|
|
4
|
+
This package provides a basic client for interacting with the GoCardless API (formerly Nordigen), a command-line interface (CLI) tool for authorization, and a Beancount importer for seamless integration with your financial data. It streamlines the process of fetching bank account data via the GoCardless API and incorporating it into your Beancount ledger.
|
|
5
|
+
|
|
6
|
+
**Key Features:**
|
|
7
|
+
|
|
8
|
+
* **GoCardless API Client:** A client for interacting with the GoCardless API. The client has built-in caching via `requests-cache`, which helps during testing as GoCardLess API limits the number of requests to 4/day/account.
|
|
9
|
+
* **GoCardLess CLI:** A command-line interface to manage authorization with the GoCardless API. The CLI supports:
|
|
10
|
+
* Listing available banks in a specified country (default: GB).
|
|
11
|
+
* Creating a link to a specific bank using its ID.
|
|
12
|
+
* Listing authorized accounts.
|
|
13
|
+
* Deleting an existing link.
|
|
14
|
+
* Uses environment variables (`NORDIGEN_SECRET_ID`, `NORDIGEN_SECRET_KEY`) or command-line arguments for API credentials.
|
|
15
|
+
* **Beancount Importer:** A `beangulp.Importer` implementation to easily import transactions fetched from the GoCardless API directly into your Beancount ledger.
|
|
16
|
+
|
|
17
|
+
**Installation:**
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install beancount-gocardless
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "beancount-gocardless"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.1"
|
|
4
4
|
description = ""
|
|
5
5
|
authors = []
|
|
6
6
|
readme = "README.md"
|
|
@@ -18,6 +18,19 @@ license-files = ["LICENSE"]
|
|
|
18
18
|
[tool.poetry]
|
|
19
19
|
packages = [{include = "beancount_gocardless", from = "src"}]
|
|
20
20
|
|
|
21
|
+
[tool.uv]
|
|
22
|
+
default-groups = ["dev", "lint"]
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"sphinx",
|
|
27
|
+
"sphinx-rtd-theme",
|
|
28
|
+
"myst_parser"
|
|
29
|
+
]
|
|
30
|
+
lint = [
|
|
31
|
+
"ruff>=0.9.8",
|
|
32
|
+
]
|
|
33
|
+
|
|
21
34
|
|
|
22
35
|
[build-system]
|
|
23
36
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
@@ -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:
|
|
@@ -60,7 +71,7 @@ def main():
|
|
|
60
71
|
accounts = client.list_accounts()
|
|
61
72
|
for a in accounts:
|
|
62
73
|
print(
|
|
63
|
-
f"{a[
|
|
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 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
|
+
"""
|
|
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": 3600 * 24,
|
|
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, 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 # Assuming client.py is in the same directory
|
|
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={"expire_after": 3600 * 24, "old_data_on_error": True},
|
|
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
|
|
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
|
-
)
|
|
File without changes
|