freeagent 0.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.
freeagent-0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Duncan Bellamy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
freeagent-0.1/PKG-INFO ADDED
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: freeagent
3
+ Version: 0.1
4
+ Summary: Public class
5
+ Author-email: Duncan Bellamy <dunk@denkimushi.com>
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Dist: requests-oauthlib
11
+ Requires-Dist: coverage ; extra == "dev"
12
+ Requires-Dist: flit ; extra == "dev"
13
+ Requires-Dist: pytest ; extra == "dev"
14
+ Requires-Dist: furo ; extra == "docs"
15
+ Requires-Dist: myst-parser>=2.0 ; extra == "docs"
16
+ Requires-Dist: sphinx>=7.0 ; extra == "docs"
17
+ Requires-Dist: sphinx-autodoc-typehints ; extra == "docs"
18
+ Requires-Dist: black ; extra == "lint"
19
+ Requires-Dist: pylint ; extra == "lint"
20
+ Project-URL: Home, https://github.com/a16bitsysop/freeagentPY
21
+ Provides-Extra: dev
22
+ Provides-Extra: docs
23
+ Provides-Extra: lint
24
+
25
+ # freeagent
26
+
27
+ `freeagent` is a python library for using the freeagent API.
28
+
29
+ ## Initial setup
30
+
31
+ Create an API app entry at the [Freeagent Dev Portal](https://dev.freeagent.com)
32
+
33
+ ## Exmple
34
+
35
+ ```python
36
+ from os import environ
37
+ import json
38
+
39
+ from freeagent import FreeAgent
40
+
41
+ def _load_token():
42
+ with open("token.json", "r") as f:
43
+ token = json.load(f)
44
+ except (FileNotFoundError, json.JSONDecodeError):
45
+ token = None
46
+ return token
47
+
48
+ def _save_token(token_data):
49
+ # save the token
50
+ with open("token.json", "w") as f:
51
+ json.dump(token_data, f)
52
+
53
+ client_id = environ["FREEAGENT_ID"]
54
+ client_secret = environ["FREEAGENT_SECRET"]
55
+
56
+ token = _load_token()
57
+
58
+ freeagent_client = FreeAgent()
59
+ freeagent_client.authenticate(client_id, client_secret ,_save_token, token)
60
+
61
+ main_response = freeagent_client.get_api("users/me")
62
+ print(
63
+ f"✅ Authenticated! User info: {main_response['user']['first_name']} {main_response['user']['last_name']}"
64
+ )
65
+
66
+ paypal_id = freeagent_client.bank.get_first_paypal_id()
67
+ paypal_data = freeagent_client.bank.get_unexplained_transactions(paypal_id)
68
+ ```
69
+
70
+ ## Documentation
71
+
72
+ Full documentation is available at
73
+ 👉 [https://a16bitsysop.github.io/freeagentPY/](https://a16bitsysop.github.io/freeagentPY/)
74
+
75
+ ---
76
+
77
+ ## Running Tests
78
+
79
+ Run tests:
80
+
81
+ ```bash
82
+ pytest
83
+ ```
84
+
85
+ ## Contributing
86
+
87
+ Contributions are welcome! Please:
88
+
89
+ 1. Fork the repo
90
+ 2. Create your feature branch `git checkout -b my-feature`
91
+ 3. Edit the source code to add and test your changes
92
+ 4. Commit your changes `git commit -m 'Add some feature'`
93
+ 5. Push to your branch `git push origin my-feature`
94
+ 6. Open a Pull Request
95
+
96
+ Please follow the existing code style and write tests for new features.
97
+
98
+ ---
99
+
100
+ ## License
101
+
102
+ This project is licensed under the MIT [MIT License](https://github.com/a16bitsysop/freeagentPY/blob/main/LICENSE).
103
+
104
+ ---
105
+
106
+ ## Contact
107
+
108
+ Created and maintained by Duncan Bellamy.
109
+ Feel free to open issues or reach out on GitHub.
110
+
111
+ ---
112
+
@@ -0,0 +1,87 @@
1
+ # freeagent
2
+
3
+ `freeagent` is a python library for using the freeagent API.
4
+
5
+ ## Initial setup
6
+
7
+ Create an API app entry at the [Freeagent Dev Portal](https://dev.freeagent.com)
8
+
9
+ ## Exmple
10
+
11
+ ```python
12
+ from os import environ
13
+ import json
14
+
15
+ from freeagent import FreeAgent
16
+
17
+ def _load_token():
18
+ with open("token.json", "r") as f:
19
+ token = json.load(f)
20
+ except (FileNotFoundError, json.JSONDecodeError):
21
+ token = None
22
+ return token
23
+
24
+ def _save_token(token_data):
25
+ # save the token
26
+ with open("token.json", "w") as f:
27
+ json.dump(token_data, f)
28
+
29
+ client_id = environ["FREEAGENT_ID"]
30
+ client_secret = environ["FREEAGENT_SECRET"]
31
+
32
+ token = _load_token()
33
+
34
+ freeagent_client = FreeAgent()
35
+ freeagent_client.authenticate(client_id, client_secret ,_save_token, token)
36
+
37
+ main_response = freeagent_client.get_api("users/me")
38
+ print(
39
+ f"✅ Authenticated! User info: {main_response['user']['first_name']} {main_response['user']['last_name']}"
40
+ )
41
+
42
+ paypal_id = freeagent_client.bank.get_first_paypal_id()
43
+ paypal_data = freeagent_client.bank.get_unexplained_transactions(paypal_id)
44
+ ```
45
+
46
+ ## Documentation
47
+
48
+ Full documentation is available at
49
+ 👉 [https://a16bitsysop.github.io/freeagentPY/](https://a16bitsysop.github.io/freeagentPY/)
50
+
51
+ ---
52
+
53
+ ## Running Tests
54
+
55
+ Run tests:
56
+
57
+ ```bash
58
+ pytest
59
+ ```
60
+
61
+ ## Contributing
62
+
63
+ Contributions are welcome! Please:
64
+
65
+ 1. Fork the repo
66
+ 2. Create your feature branch `git checkout -b my-feature`
67
+ 3. Edit the source code to add and test your changes
68
+ 4. Commit your changes `git commit -m 'Add some feature'`
69
+ 5. Push to your branch `git push origin my-feature`
70
+ 6. Open a Pull Request
71
+
72
+ Please follow the existing code style and write tests for new features.
73
+
74
+ ---
75
+
76
+ ## License
77
+
78
+ This project is licensed under the MIT [MIT License](https://github.com/a16bitsysop/freeagentPY/blob/main/LICENSE).
79
+
80
+ ---
81
+
82
+ ## Contact
83
+
84
+ Created and maintained by Duncan Bellamy.
85
+ Feel free to open issues or reach out on GitHub.
86
+
87
+ ---
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["flit_scm",]
3
+ build-backend = "flit_scm:buildapi"
4
+
5
+ [project]
6
+ name = "freeagent"
7
+ authors = [{name = "Duncan Bellamy", email = "dunk@denkimushi.com"}]
8
+ readme = "README.md"
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ dynamic = ["version", "description"]
12
+ dependencies = [
13
+ "requests-oauthlib"
14
+ ]
15
+ requires-python = ">=3.8"
16
+
17
+ [project.urls]
18
+ Home = "https://github.com/a16bitsysop/freeagentPY"
19
+
20
+ [project.optional-dependencies]
21
+ dev = [
22
+ "coverage",
23
+ "flit",
24
+ "pytest",
25
+ ]
26
+ lint = [
27
+ "black",
28
+ "pylint",
29
+ ]
30
+ docs = [
31
+ "furo", # HTML theme
32
+ "myst-parser>=2.0", # For Markdown support
33
+ "sphinx>=7.0",
34
+ "sphinx-autodoc-typehints", # For cleaner type hints in docs
35
+ ]
36
+
37
+ [tool.flit.sdist]
38
+ include = ["tests", "src/freeagent/_version.py"]
39
+
40
+ [tool.setuptools_scm]
41
+ write_to = "src/freeagent/_version.py"
42
+
43
+ [tool.pytest.ini_options]
44
+ pythonpath = ["src"]
45
+
46
+ [tool.pylint.main]
47
+ output-format = "colorized"
48
+ verbose = true
49
+ ignore-patterns = ["_version.py"]
@@ -0,0 +1,27 @@
1
+ """
2
+ Public class
3
+ """
4
+
5
+ try:
6
+ from ._version import version as __version__
7
+ except ModuleNotFoundError:
8
+ # _version.py is written when building dist
9
+ __version__ = "0.0.0+local"
10
+
11
+ from .base import FreeAgentBase
12
+ from .bank import BankAPI
13
+ from .category import CategoryAPI
14
+ from .transaction import TransactionAPI
15
+ from .payload import ExplanationPayload
16
+
17
+
18
+ class FreeAgent(FreeAgentBase):
19
+ """
20
+ The main public class
21
+ """
22
+
23
+ def __init__(self):
24
+ super().__init__() # initialse base class
25
+ self.bank = BankAPI(self)
26
+ self.category = CategoryAPI(self)
27
+ self.transaction = TransactionAPI(self)
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.1'
32
+ __version_tuple__ = version_tuple = (0, 1)
33
+
34
+ __commit_id__ = commit_id = 'g4a136d763'
@@ -0,0 +1,226 @@
1
+ """
2
+ This module provides the BankAPI class to retreive information
3
+ about bank accounts on freeagent
4
+ """
5
+
6
+ from base64 import b64encode
7
+ from pathlib import Path
8
+
9
+ from .base import FreeAgentBase
10
+ from .payload import ExplanationPayload
11
+
12
+
13
+ class BankAPI(FreeAgentBase):
14
+ """
15
+ BankAPI class to retreive information
16
+ about bank accounts on freeagent
17
+
18
+ Initialize the base class
19
+
20
+ :param api_base_url: the url to use for requests, defaults to normal but
21
+ can be changed to sandbox
22
+ """
23
+
24
+ def __init__(self, parent): # pylint: disable=super-init-not-called
25
+ """
26
+ Initialize the BankAPI class
27
+ """
28
+ self.parent = parent # the main FreeAgent instance
29
+
30
+ def _check_file_size(self, path: Path) -> int:
31
+ """
32
+ Helper funtion to check file size for attaching files to explanations
33
+
34
+ :param path: pathlike Path of the file to check
35
+
36
+ :return: filesize in bytes
37
+ :raises ValueError: if the filesize is larger than 5MB (freeagent limit)
38
+ """
39
+ max_attachment_size = 5 * 1024 * 1024 # 5 MB
40
+ size = path.stat().st_size
41
+ if size > max_attachment_size:
42
+ raise ValueError(
43
+ f"Attachment too large ({size} bytes). Max allowed is 5 MB."
44
+ )
45
+ return size
46
+
47
+ def _encode_file_base64(self, path: Path) -> str:
48
+ """
49
+ Encode the passed file as base64 after checking size
50
+
51
+ :param path: pathlike Path of the file to encode
52
+
53
+ :return: string of the encoded file
54
+ """
55
+ self._check_file_size(path)
56
+ with path.open("rb") as f:
57
+ return b64encode(f.read()).decode("utf-8")
58
+
59
+ def _get_filetype(self, filename: Path) -> str:
60
+ """
61
+ Guess the filetype based on dot extension of name
62
+
63
+ :param filename: pathlike Path of the file to guess
64
+
65
+ :return: string of the filetype
66
+ :raises ValueError: if file is not a type supported by freeagent
67
+ """
68
+ allowed_types = {
69
+ ".pdf": "application/x-pdf",
70
+ ".png": "image/x-png",
71
+ ".jpeg": "image/jpeg",
72
+ ".jpg": "image/jpeg",
73
+ ".gif": "image/gif",
74
+ }
75
+ # Guess FreeAgent content type
76
+ content_type = allowed_types.get(filename.suffix.lower())
77
+ if not content_type:
78
+ raise ValueError(f"Unsupported file type for FreeAgent: {filename.suffix}")
79
+
80
+ return content_type
81
+
82
+ def attach_file_to_explanation(
83
+ self, payload: ExplanationPayload, path: Path, description: str = None
84
+ ):
85
+ """
86
+ Attach a file to an existing ExplanationPayload
87
+ freeagent supports:
88
+
89
+ - image/x-png
90
+ - image/jpeg
91
+ - image/jpg
92
+ - image/gif
93
+ - application/x-pdf
94
+
95
+ :param payload: ExplanationPayload to add the file to
96
+ :param description: optional description to use for the file on freeagent
97
+ """
98
+ file_data = self._encode_file_base64(path)
99
+ file_type = self._get_filetype(path)
100
+
101
+ payload.attachment = {
102
+ "file_name": path.name,
103
+ "description": description or "Attachment",
104
+ "content_type": file_type,
105
+ "data": file_data,
106
+ }
107
+
108
+ def explain_transaction(self, tx_obj: ExplanationPayload, dryrun: bool = False):
109
+ """
110
+ Post the explanation to freeagent in the passed ExplanationPayload tx_obj
111
+
112
+ :param tx_obj: ExplanationPayload to use
113
+ :param dry_run: if True then do not post to freeagent, only print details
114
+ """
115
+ json_data = self.serialize_for_api(tx_obj)
116
+
117
+ print(json_data["description"], json_data.get("gross_value"))
118
+ if not dryrun:
119
+ self.parent.post_api(
120
+ "bank_transaction_explanations",
121
+ "bank_transaction_explanation",
122
+ json_data,
123
+ )
124
+
125
+ def explain_update(
126
+ self, url: str, tx_obj: ExplanationPayload, dryrun: bool = False
127
+ ):
128
+ """
129
+ Update an existing explanation on freeagent with the passed url
130
+
131
+ :param url: url attribute of the bank transaction explanation to change
132
+ :param tx_obj: ExplanationPayload to use for updating the explanation
133
+ :param dry_run: if True then do not post to freeagent, only print details
134
+ """
135
+ json_data = self.serialize_for_api(tx_obj)
136
+
137
+ print(json_data["description"], json_data.get("gross_value"))
138
+ if not dryrun:
139
+ self.parent.put_api(url, "bank_transaction_explanation", json_data)
140
+
141
+ def get_unexplained_transactions(self, account_id: str) -> list:
142
+ """
143
+ Return a list of unexplained transaction objects for the bank account with id of account_id
144
+
145
+ :param account_id: account id to use, not the whole url
146
+
147
+ :return: list of the unexplained transactions
148
+ """
149
+ params = {"bank_account": account_id, "view": "unexplained"}
150
+ return self.parent.get_api("bank_transactions", params)
151
+
152
+ def _find_bank_id(self, bank_accounts: list, account_name: str) -> str:
153
+ """
154
+ Get the freeagent bank account ID for account_name
155
+
156
+ :param bank_accounts: a list of the bank accounts on freeagent
157
+ :param account_name: name of the account to find
158
+
159
+ :return: the id of the bank account or None if not found
160
+ """
161
+ for account in bank_accounts:
162
+ if account.name.lower() == account_name.lower():
163
+ return account.url.rsplit("/", 1)[-1]
164
+ return None
165
+
166
+ def get_paypal_id(self, account_name: str) -> str:
167
+ """
168
+ Get the ID of PayPal account on freeagent
169
+
170
+ :param account_name: name of the account to find
171
+
172
+ :return: ID of the named PayPal account or None
173
+ """
174
+ params = {"view": "paypal_accounts"}
175
+ response = self.parent.get_api("bank_accounts", params)
176
+ return self._find_bank_id(response, account_name)
177
+
178
+ def get_first_paypal_id(self) -> str:
179
+ """
180
+ Get the ID of the first PayPal account on freeagent
181
+
182
+ :return: ID of the first PayPal account or None if there is no PayPal account
183
+ """
184
+ params = {"view": "paypal_accounts"}
185
+ response = self.parent.get_api("bank_accounts", params)
186
+ if response:
187
+ return response[0].url.rsplit("/", 1)[-1]
188
+ return None
189
+
190
+ def get_id(self, account_name: str) -> str:
191
+ """
192
+ Get the ID of account_name searching standard bank accounts
193
+
194
+ :param account_name: name of the account to find
195
+
196
+ :return: ID of the account or None if not found
197
+ """
198
+ params = {"view": "standard_bank_accounts"}
199
+ response = self.parent.get_api("bank_accounts", params)
200
+ return self._find_bank_id(response, account_name)
201
+
202
+ def get_primary(self):
203
+ """
204
+ Get the ID of the primary bank account on freeagent (current account)
205
+
206
+ :return: ID of the account or None if not found
207
+ """
208
+ params = {"view": "standard_bank_accounts"}
209
+ response = self.parent.get_api("bank_accounts", params)
210
+ for acct in response:
211
+ if getattr(acct, "is_primary", False):
212
+ return acct.url.rsplit("/", 1)[-1]
213
+ return None
214
+
215
+ def get_primary_uri(self):
216
+ """
217
+ Get the uri for the primary bank account on freeagent (current account)
218
+
219
+ :return: uri of the account or None if not found
220
+ """
221
+ params = {"view": "standard_bank_accounts"}
222
+ response = self.parent.get_api("bank_accounts", params)
223
+ for acct in response:
224
+ if getattr(acct, "is_primary", False):
225
+ return acct.url
226
+ return None
@@ -0,0 +1,202 @@
1
+ """
2
+ Base class the other class inherit from
3
+ """
4
+
5
+ from dataclasses import asdict, is_dataclass
6
+ from datetime import date, datetime
7
+ from decimal import Decimal
8
+ from webbrowser import open as open_browser
9
+
10
+ from requests_oauthlib import OAuth2Session
11
+
12
+ from .utils import make_dataclass_from_dict
13
+
14
+
15
+ class FreeAgentBase:
16
+ """
17
+ Common functions used in other classes
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ api_base_url: str = "https://api.freeagent.com/v2/",
23
+ ):
24
+ """
25
+ Initialize the base class
26
+
27
+ :param api_base_url: the url to use for requests, defaults to normal but can be
28
+ changed to sandbox
29
+ """
30
+ self.api_base_url = api_base_url
31
+ self.session = None
32
+
33
+ def authenticate(
34
+ self, oauth_ident: str, oauth_secret: str, save_token_cb, token: str = None
35
+ ):
36
+ """
37
+ Authenticate with the freeagent API
38
+
39
+ :param oauth_ident: oauth identifier from the freeagent dev dashboard
40
+ :param oauth_secret: oauth secret from the freeagent dev dashboard
41
+ :param save_token_cb: function to call when the token is refreshed to save it
42
+ :param token: initial token, or None
43
+ """
44
+ token_url = self.api_base_url + "token_endpoint"
45
+ redirect_uri = "https://localhost/"
46
+
47
+ extra = {"client_id": oauth_ident, "client_secret": oauth_secret}
48
+
49
+ if token:
50
+ oauth = OAuth2Session(
51
+ oauth_ident,
52
+ token=token,
53
+ auto_refresh_url=token_url,
54
+ auto_refresh_kwargs=extra,
55
+ token_updater=save_token_cb,
56
+ )
57
+ elif oauth_secret:
58
+ oauth = OAuth2Session(
59
+ oauth_ident, redirect_uri=redirect_uri, scope=[self.api_base_url]
60
+ )
61
+ auth_url, _state = oauth.authorization_url(
62
+ self.api_base_url + "approve_app"
63
+ )
64
+ print("🔐 Open this URL and authorise the app:", auth_url)
65
+ open_browser(auth_url)
66
+ redirect_response = input("📋 Paste the full redirect URL here: ").strip()
67
+
68
+ token = oauth.fetch_token(
69
+ token_url,
70
+ authorization_response=redirect_response,
71
+ client_secret=oauth_secret,
72
+ )
73
+ save_token_cb(token)
74
+ else:
75
+ raise ValueError("Need oauth_secret, or oauth_token")
76
+
77
+ self.session = oauth
78
+ self.session.headers.update(
79
+ {
80
+ "Accept": "application/json",
81
+ "Content-Type": "application/json",
82
+ }
83
+ )
84
+
85
+ # Get user info and print it
86
+ user_info = self.get_api("users/me")
87
+ first_name = user_info[0].user["first_name"]
88
+ last_name = user_info[0].user["last_name"]
89
+ print(f"✅ Authenticated! User info: {first_name} {last_name}")
90
+ print()
91
+
92
+ def serialize_for_api(self, obj) -> dict[str, any]:
93
+ """
94
+ Convert dataclasses or dicts with Decimal, date, etc. into plain API-compatible dicts
95
+
96
+ :param obj: dataclass or dict to convert
97
+
98
+ return: API-compatible dict
99
+ """
100
+ if is_dataclass(obj):
101
+ obj = asdict(obj)
102
+
103
+ def convert(val):
104
+ if isinstance(val, Decimal):
105
+ return str(val)
106
+ if isinstance(val, (date, datetime)):
107
+ return val.isoformat()
108
+ if isinstance(val, dict):
109
+ return {k: convert(v) for k, v in val.items()}
110
+ if isinstance(val, list):
111
+ return [convert(i) for i in val]
112
+ return val
113
+
114
+ return {k: convert(v) for k, v in obj.items() if v is not None}
115
+
116
+ def get_api(self, endpoint: str, params: dict = None) -> list:
117
+ """
118
+ Perform an API get request, handling pagination
119
+
120
+ :param endpoint: end part of the endpoint URL
121
+ :param params: dict of "Name": Value entries for request to process into URL
122
+
123
+ :return: A list of dataclass instances
124
+ """
125
+ if params is None:
126
+ params = {}
127
+
128
+ per_page = 100
129
+ params["per_page"] = per_page
130
+ params["page"] = 1
131
+
132
+ response = self.session.get(self.api_base_url + endpoint, params=params)
133
+ response.raise_for_status()
134
+ json_data = response.json()
135
+
136
+ key = endpoint.split("/")[-1]
137
+ class_name = key.rstrip("s")
138
+
139
+ if not isinstance(json_data, dict) or key not in json_data:
140
+ return [make_dataclass_from_dict(class_name, json_data)]
141
+
142
+ items = [
143
+ make_dataclass_from_dict(class_name, item)
144
+ for item in json_data.get(key, [])
145
+ ]
146
+
147
+ if len(items) == per_page:
148
+ page = 2
149
+ while True:
150
+ params["page"] = page
151
+ response = self.session.get(self.api_base_url + endpoint, params=params)
152
+ response.raise_for_status()
153
+ json_data = response.json()
154
+
155
+ if key in json_data:
156
+ current_items = json_data[key]
157
+ items.extend(
158
+ [
159
+ make_dataclass_from_dict(class_name, item)
160
+ for item in current_items
161
+ ]
162
+ )
163
+
164
+ if len(current_items) < per_page:
165
+ break
166
+ else:
167
+ break
168
+
169
+ page += 1
170
+
171
+ return items
172
+
173
+ def put_api(self, url: str, root: str, updates: str):
174
+ """
175
+ Perform an API put request
176
+
177
+ :param url: complete url for put request
178
+ :param root: first part of payload
179
+ :param updates: second part of payload
180
+
181
+ :raises RunTimeError: if put request fails
182
+ """
183
+ payload = {root: updates}
184
+ response = self.session.put(url, json=payload)
185
+ if response.status_code != 200:
186
+ raise RuntimeError(f"PUT failed {response.status_code}: {response.text}")
187
+
188
+ def post_api(self, endpoint: str, root: str, payload: str):
189
+ """
190
+ Perform an API post request
191
+
192
+ :param endpoint: end part of url endpoint
193
+ :param root: first part of payload
194
+ :param payload: second part of payload
195
+
196
+ :raises RunTimeError: if post request fails
197
+ """
198
+ data = {root: payload}
199
+ response = self.session.post(self.api_base_url + endpoint, json=data)
200
+ if response.status_code not in (200, 201):
201
+ raise RuntimeError(f"POST failed {response.status_code}: {response.text}")
202
+ return response.json()
@@ -0,0 +1,82 @@
1
+ """
2
+ Class for getting freeagent categories
3
+ categories are cached after first run
4
+ """
5
+
6
+ from .base import FreeAgentBase
7
+ from .utils import list_to_dataclasses
8
+
9
+
10
+ class CategoryAPI(FreeAgentBase):
11
+ """
12
+ The CategoryAPI class
13
+ """
14
+
15
+ def __init__(self, parent): # pylint: disable=super-init-not-called
16
+ """
17
+ Initialize the class
18
+ """
19
+ self.parent = parent # the main FreeAgent instance
20
+ self.categories = []
21
+
22
+ def _prep_categories(self):
23
+ """
24
+ get the categories if not already done
25
+ """
26
+ if self.categories:
27
+ return
28
+
29
+ response = self.parent.get_api("categories")
30
+ if not response:
31
+ return
32
+
33
+ container = response[0]
34
+ self.categories = []
35
+ for value in vars(container).values():
36
+ if isinstance(value, list):
37
+ self.categories.extend(list_to_dataclasses("Category", value))
38
+
39
+ def get_desc_id(self, description: str) -> str:
40
+ """
41
+ Return the category id url for passed category name
42
+
43
+ :param description: name of category to find
44
+
45
+ :return: id url of the category
46
+ :raises ValueError: if category not found
47
+ """
48
+ self._prep_categories()
49
+ for cat in self.categories:
50
+ if description.lower() in cat.description.lower():
51
+ return cat.url
52
+ raise ValueError(f"Category with description '{description}' not found.")
53
+
54
+ def get_desc_nominal_code(self, description: str) -> str:
55
+ """
56
+ Return the nominal code for a given category description
57
+
58
+ :param description: The description of the category
59
+
60
+ :return: The nominal code of the category
61
+ :raises ValueError: if category not found
62
+ """
63
+ self._prep_categories()
64
+ for cat in self.categories:
65
+ if description.lower() in cat.description.lower():
66
+ return cat.nominal_code
67
+ raise ValueError(f"Category with description '{description}' not found.")
68
+
69
+ def get_nominal_code_id(self, nominal_code: int) -> str:
70
+ """
71
+ Get category id url from nominal code
72
+
73
+ :param nominal_code: nominal code of category to find
74
+
75
+ :return: id url of the category
76
+ :raises ValueError: if category not found
77
+ """
78
+ self._prep_categories()
79
+ for cat in self.categories:
80
+ if str(nominal_code) == cat.nominal_code:
81
+ return cat.url
82
+ raise ValueError(f"Category with nominal code '{nominal_code}' not found.")
@@ -0,0 +1,23 @@
1
+ """
2
+ ExplanationPayload dataclass used by this module
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import date
7
+ from decimal import Decimal
8
+ from typing import Optional, Dict
9
+
10
+
11
+ @dataclass
12
+ class ExplanationPayload:
13
+ """
14
+ dataclass used to store data for functions
15
+ """
16
+
17
+ nominal_code: str # Required
18
+ dated_on: date # Required
19
+ gross_value: Decimal # Required
20
+ description: Optional[str] = None # Optional
21
+ bank_transaction: Optional[str] = None # Required for new explanations
22
+ attachment: Optional[Dict] = None
23
+ transfer_bank_account: Optional[str] = None
@@ -0,0 +1,40 @@
1
+ """
2
+ Class for getting freeagent transactions
3
+ """
4
+
5
+ from .base import FreeAgentBase
6
+
7
+
8
+ class TransactionAPI(FreeAgentBase):
9
+ """
10
+ The TransactionAPI class
11
+ """
12
+
13
+ def __init__(self, parent): # pylint: disable=super-init-not-called
14
+ """
15
+ Initialize the class
16
+
17
+ :param api_base_url: the url to use for requests, defaults to normal but
18
+ can be changed to sandbox
19
+ """
20
+ self.parent = parent # the main FreeAgent instance
21
+
22
+ def get_transactions(
23
+ self, nominal_code: str, start_date: str, end_date: str
24
+ ) -> list:
25
+ """
26
+ Get transactions for a given category nominal code and date range.
27
+
28
+ :param nominal_code: The nominal code of the category.
29
+ :param start_date: Start date of the date range (YYYY-MM-DD).
30
+ :param end_date: End date of the date range (YYYY-MM-DD).
31
+
32
+ :return: A list of Transaction objects.
33
+ """
34
+ params = {
35
+ "nominal_code": nominal_code,
36
+ "from_date": start_date,
37
+ "to_date": end_date,
38
+ }
39
+
40
+ return self.parent.get_api("accounting/transactions", params)
@@ -0,0 +1,114 @@
1
+ """This module provides utility functions for the FreeAgent API client."""
2
+
3
+ from dataclasses import make_dataclass
4
+ from decimal import Decimal
5
+ from datetime import date, datetime
6
+ from typing import Optional, Any
7
+ import re
8
+
9
+
10
+ def _infer_type(value: Any) -> Any:
11
+ """
12
+ Infer the type of a value
13
+
14
+ :param value: The value to guess the type of
15
+
16
+ :return: The type of the value
17
+ """
18
+ inferred_type = Any
19
+ if isinstance(value, int):
20
+ inferred_type = int
21
+ elif isinstance(value, float):
22
+ inferred_type = Decimal
23
+ elif isinstance(value, str):
24
+ if re.fullmatch(r"^-?\d+\.\d+$", value):
25
+ inferred_type = Decimal
26
+ elif re.fullmatch(r"^\d{4}-\d{2}-\d{2}$", value):
27
+ inferred_type = date
28
+ elif re.fullmatch(
29
+ r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$",
30
+ value,
31
+ ):
32
+ inferred_type = datetime
33
+ else:
34
+ inferred_type = str
35
+ elif isinstance(value, dict):
36
+ inferred_type = dict
37
+ elif isinstance(value, list):
38
+ inferred_type = list
39
+ return inferred_type
40
+
41
+
42
+ def _convert_value(value: Any, target_type: Any) -> Any:
43
+ """
44
+ Convert a value to a target type
45
+
46
+ :param value: The value to convert
47
+ :param target_type: The type to convert to
48
+
49
+ :return: The converted value
50
+ """
51
+ if value is None:
52
+ return None
53
+ if target_type is Decimal:
54
+ return Decimal(value)
55
+ if target_type is date:
56
+ return datetime.strptime(value, "%Y-%m-%d").date()
57
+ if target_type is datetime:
58
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
59
+ return value
60
+
61
+
62
+ def make_dataclass_from_dict(class_name: str, data: dict, field_types: dict = None):
63
+ """
64
+ Dynamically create a dataclass from a dictionary, with optional type conversions.
65
+
66
+ :param class_name: The name to use for the dataclass
67
+ :param data: The data to turn into a dataclass
68
+ :param field_types: dict of types for the data
69
+ """
70
+ if field_types is None:
71
+ field_types = {}
72
+
73
+ inferred_types = {
74
+ key: _infer_type(value) for key, value in data.items() if key not in field_types
75
+ }
76
+ final_field_types = {**inferred_types, **field_types}
77
+
78
+ fields_to_create = [
79
+ (name, Optional[f_type], None)
80
+ for name, f_type in final_field_types.items()
81
+ if name.isidentifier()
82
+ ]
83
+
84
+ data_class = make_dataclass(class_name, fields_to_create)
85
+
86
+ converted_data = {
87
+ key: _convert_value(value, final_field_types.get(key))
88
+ for key, value in data.items()
89
+ if key in final_field_types
90
+ }
91
+
92
+ return data_class(
93
+ **{
94
+ k: v
95
+ for k, v in converted_data.items()
96
+ if k in data_class.__dataclass_fields__
97
+ }
98
+ )
99
+
100
+
101
+ def list_to_dataclasses(class_name: str, data_list: list) -> list:
102
+ """
103
+ Convert a list of dictionaries to a list of dataclasses
104
+
105
+ :param class_name: name of the dataclass
106
+ :param data_list: list of dictionaries
107
+
108
+ :return: list of dataclasses
109
+ """
110
+ return [
111
+ make_dataclass_from_dict(class_name, item)
112
+ for item in data_list
113
+ if isinstance(item, dict)
114
+ ]
@@ -0,0 +1,178 @@
1
+ """
2
+ Unit tests for the BankAPI class using offline dummy data and mocks.
3
+ Covers file handling, transaction explanations, ID lookups, and API integrations.
4
+ """
5
+
6
+ # pylint: disable=protected-access, too-few-public-methods
7
+ import unittest
8
+ from unittest.mock import MagicMock
9
+ from pathlib import Path
10
+ import tempfile
11
+ import os
12
+ import base64
13
+
14
+ # Import BankAPI from bank.py
15
+ from freeagent.bank import BankAPI
16
+
17
+
18
+ # Dummy ExplanationPayload class for testing
19
+ class DummyPayload:
20
+ """
21
+ Dummy payload class for simulating ExplanationPayload in tests.
22
+ """
23
+
24
+ def __init__(self):
25
+ self.attachment = {}
26
+ self.description = "Test"
27
+ self.gross_value = 123.45
28
+ self.nominal_code = "250"
29
+
30
+
31
+ class BankAPITestCase(unittest.TestCase):
32
+ """
33
+ Unit tests for the BankAPI class using MagicMock and dummy data.
34
+ """
35
+
36
+ def setUp(self):
37
+ # Dummy parent with API methods mocked
38
+ self.parent = MagicMock()
39
+ self.api = BankAPI(self.parent)
40
+
41
+ def test_check_file_size_allows_small_file(self):
42
+ """Test that a small file passes file size validation."""
43
+ with tempfile.NamedTemporaryFile(delete=False) as tmp:
44
+ tmp.write(b"x" * (1024 * 10)) # 10KB
45
+ tmp.flush()
46
+ size = self.api._check_file_size(Path(tmp.name))
47
+ self.assertEqual(size, 10240)
48
+ os.unlink(tmp.name)
49
+
50
+ def test_check_file_size_raises_on_large(self):
51
+ """Test that a large file raises a ValueError."""
52
+ with tempfile.NamedTemporaryFile(delete=False) as tmp:
53
+ tmp.write(b"x" * (6 * 1024 * 1024)) # 6MB
54
+ tmp.flush()
55
+ with self.assertRaises(ValueError):
56
+ self.api._check_file_size(Path(tmp.name))
57
+ os.unlink(tmp.name)
58
+
59
+ def test_encode_file_base64(self):
60
+ """Test that file contents are encoded as base64 correctly."""
61
+ with tempfile.NamedTemporaryFile(delete=False, mode="wb") as tmp:
62
+ content = b"abc123"
63
+ tmp.write(content)
64
+ tmp.flush()
65
+ b64 = self.api._encode_file_base64(Path(tmp.name))
66
+
67
+ self.assertEqual(b64, base64.b64encode(content).decode("utf-8"))
68
+ os.unlink(tmp.name)
69
+
70
+ def test_get_filetype_valid_and_invalid(self):
71
+ """Test allowed and disallowed file types."""
72
+ valid = Path("file.pdf")
73
+ self.assertEqual(self.api._get_filetype(valid), "application/x-pdf")
74
+ invalid = Path("file.exe")
75
+ with self.assertRaises(ValueError):
76
+ self.api._get_filetype(invalid)
77
+
78
+ def test_attach_file_to_explanation(self):
79
+ """Test attaching a file to an explanation payload."""
80
+ # Prepare a small file
81
+ with tempfile.NamedTemporaryFile(delete=False) as tmp:
82
+ tmp.write(b"data")
83
+ tmp.flush()
84
+ payload = DummyPayload()
85
+ self.api._get_filetype = MagicMock(return_value="application/x-pdf")
86
+ self.api._encode_file_base64 = MagicMock(return_value="ZGF0YQ==")
87
+ self.api.attach_file_to_explanation(payload, Path(tmp.name), "desc")
88
+ self.assertIn("file_name", payload.attachment)
89
+ self.assertEqual(payload.attachment["description"], "desc")
90
+ os.unlink(tmp.name)
91
+
92
+ def test_explain_transaction_dryrun(self):
93
+ """Test dry-run mode for explaining a transaction."""
94
+ payload = DummyPayload()
95
+ self.api.serialize_for_api = MagicMock(
96
+ return_value={"description": "desc", "gross_value": 111}
97
+ )
98
+ self.api.explain_transaction(payload, dryrun=True)
99
+ self.parent.post_api.assert_not_called()
100
+
101
+ def test_explain_transaction_real(self):
102
+ """Test real mode posts the explanation to parent API."""
103
+ payload = DummyPayload()
104
+ self.api.serialize_for_api = MagicMock(
105
+ return_value={"description": "desc", "gross_value": 111}
106
+ )
107
+ self.api.explain_transaction(payload, dryrun=False)
108
+ self.parent.post_api.assert_called_once()
109
+
110
+ def test_explain_update_dryrun(self):
111
+ """Test dry-run mode for updating an explanation."""
112
+ payload = DummyPayload()
113
+ self.api.serialize_for_api = MagicMock(
114
+ return_value={"description": "desc", "gross_value": 111}
115
+ )
116
+ self.api.explain_update("url", payload, dryrun=True)
117
+ self.parent.put_api.assert_not_called()
118
+
119
+ def test_explain_update_real(self):
120
+ """Test real mode updates the explanation in parent API."""
121
+ payload = DummyPayload()
122
+ self.api.serialize_for_api = MagicMock(
123
+ return_value={"description": "desc", "gross_value": 111}
124
+ )
125
+ self.api.explain_update("url", payload, dryrun=False)
126
+ self.parent.put_api.assert_called_once()
127
+
128
+ def test_get_unexplained_transactions(self):
129
+ """Test retrieval of unexplained transactions."""
130
+ dummy_return = {"transactions": [1, 2, 3]}
131
+ self.parent.get_api.return_value = dummy_return
132
+ result = self.api.get_unexplained_transactions("accid")
133
+ self.parent.get_api.assert_called_once()
134
+ self.assertEqual(result, dummy_return)
135
+
136
+ def test_get_paypal_id_works(self):
137
+ """Test finding PayPal account ID by name."""
138
+ mock_account = MagicMock()
139
+ mock_account.configure_mock(name="PayPal Account", url="http://x/y/123")
140
+ self.parent.get_api.return_value = [mock_account]
141
+ result_id = self.api.get_paypal_id("PayPal Account")
142
+ self.assertEqual(result_id, "123")
143
+
144
+ def test_get_first_paypal_id(self):
145
+ """Test retrieval of the first PayPal account ID."""
146
+ mock_account = MagicMock()
147
+ mock_account.configure_mock(url="http://x/y/456")
148
+ self.parent.get_api.return_value = [mock_account]
149
+ result_id = self.api.get_first_paypal_id()
150
+ self.assertEqual(result_id, "456")
151
+ self.parent.get_api.return_value = []
152
+ result_id = self.api.get_first_paypal_id()
153
+ self.assertIsNone(result_id)
154
+
155
+ def test_get_id(self):
156
+ """Test standard account ID lookup by name."""
157
+ mock_account = MagicMock()
158
+ mock_account.configure_mock(name="Test", url="http://x/y/789")
159
+ self.parent.get_api.return_value = [mock_account]
160
+ result_id = self.api.get_id("Test")
161
+ self.assertEqual(result_id, "789")
162
+
163
+ def test_get_primary(self):
164
+ """Test retrieval of the primary bank account ID."""
165
+ mock_account1 = MagicMock()
166
+ mock_account1.configure_mock(is_primary=False, url="http://x/y/111")
167
+ mock_account2 = MagicMock()
168
+ mock_account2.configure_mock(is_primary=True, url="http://x/y/222")
169
+ self.parent.get_api.return_value = [mock_account1, mock_account2]
170
+ result_id = self.api.get_primary()
171
+ self.assertEqual(result_id, "222")
172
+ self.parent.get_api.return_value = []
173
+ result_id = self.api.get_primary()
174
+ self.assertIsNone(result_id)
175
+
176
+
177
+ if __name__ == "__main__":
178
+ unittest.main()
@@ -0,0 +1,104 @@
1
+ """
2
+ Unit tests for the CategoryAPI class using offline dummy data and mocks.
3
+ Verifies category caching, lookup by description, and lookup by nominal code.
4
+ """
5
+
6
+ # pylint: disable=protected-access, too-few-public-methods
7
+ import unittest
8
+ from dataclasses import dataclass
9
+ from unittest.mock import MagicMock
10
+
11
+ from freeagent.category import CategoryAPI
12
+
13
+
14
+ @dataclass
15
+ class MockContainer:
16
+ """Mock container to simulate the API response structure."""
17
+
18
+ admin_expenses_categories: list
19
+ income_categories: list
20
+
21
+
22
+ class CategoryAPITestCase(unittest.TestCase):
23
+ """
24
+ Unit tests for the CategoryAPI class using MagicMock and dummy data.
25
+ """
26
+
27
+ def setUp(self):
28
+ # Set up a mock parent with get_api
29
+ self.parent = MagicMock()
30
+ self.api = CategoryAPI(self.parent)
31
+
32
+ # Data as dictionaries, as they come from the "API" container attributes
33
+ self.cat1 = {
34
+ "description": "Office Costs",
35
+ "url": "http://cat/1",
36
+ "nominal_code": "101",
37
+ }
38
+ self.cat2 = {
39
+ "description": "Travel",
40
+ "url": "http://cat/2",
41
+ "nominal_code": "202",
42
+ }
43
+ self.cat3 = {
44
+ "description": "Old Office",
45
+ "url": "http://cat/3",
46
+ "nominal_code": "303",
47
+ }
48
+
49
+ self.container = MockContainer(
50
+ admin_expenses_categories=[self.cat1, self.cat2],
51
+ income_categories=[self.cat3],
52
+ )
53
+
54
+ # get_api returns a list containing the container
55
+ self.parent.get_api.return_value = [self.container]
56
+
57
+ def test_prep_categories_fetches_once(self):
58
+ """Test that categories are fetched from the parent once and then cached."""
59
+ self.api._prep_categories()
60
+
61
+ # Verify the categories were flattened and converted
62
+ self.assertEqual(len(self.api.categories), 3)
63
+ descriptions = sorted([c.description for c in self.api.categories])
64
+ expected_descriptions = sorted(["Office Costs", "Travel", "Old Office"])
65
+ self.assertEqual(descriptions, expected_descriptions)
66
+
67
+ # Should not call get_api again if already cached
68
+ self.api._prep_categories()
69
+ self.parent.get_api.assert_called_once_with("categories")
70
+
71
+ def test_get_desc_id_finds_description(self):
72
+ """Test category lookup by description (case-insensitive, substring match)."""
73
+ url = self.api.get_desc_id("office costs")
74
+ self.assertEqual(url, "http://cat/1")
75
+ url = self.api.get_desc_id("Old office")
76
+ self.assertEqual(url, "http://cat/3")
77
+ # Case insensitive, substring match
78
+ url = self.api.get_desc_id("Travel")
79
+ self.assertEqual(url, "http://cat/2")
80
+ # Not found
81
+ with self.assertRaises(ValueError):
82
+ self.api.get_desc_id("Nonexistent")
83
+
84
+ def test_get_nominal_id_finds_code(self):
85
+ """Test category lookup by nominal code."""
86
+ url = self.api.get_nominal_code_id(101)
87
+ self.assertEqual(url, "http://cat/1")
88
+ url = self.api.get_nominal_code_id(303)
89
+ self.assertEqual(url, "http://cat/3")
90
+ with self.assertRaises(ValueError):
91
+ self.api.get_nominal_code_id(999)
92
+
93
+ def test_caching_persists_for_getters(self):
94
+ """Test that cached categories persist across lookups."""
95
+ # First call populates cache
96
+ self.api.get_desc_id("Travel")
97
+ # Change return value; should not affect already-cached results
98
+ self.parent.get_api.return_value = []
99
+ url = self.api.get_desc_id("Office")
100
+ self.assertEqual(url, "http://cat/1")
101
+
102
+
103
+ if __name__ == "__main__":
104
+ unittest.main()
@@ -0,0 +1,44 @@
1
+ """
2
+ Unit tests for the TransactionAPI class using offline dummy data and mocks.
3
+ """
4
+
5
+ # pylint: disable=protected-access, too-few-public-methods
6
+ import unittest
7
+ from unittest.mock import MagicMock
8
+
9
+ from freeagent.transaction import TransactionAPI
10
+
11
+
12
+ class TransactionAPITestCase(unittest.TestCase):
13
+ """
14
+ Unit tests for the TransactionAPI class using MagicMock and dummy data.
15
+ """
16
+
17
+ def setUp(self):
18
+ # Set up a mock parent with get_api
19
+ self.parent = MagicMock()
20
+ self.api = TransactionAPI(self.parent)
21
+
22
+ def test_get_transactions_success(self):
23
+ """Test that transactions are fetched correctly for a valid category."""
24
+ nominal_code = "123"
25
+ start_date = "2023-01-01"
26
+ end_date = "2023-01-31"
27
+ expected_transactions = {"transactions": ["transaction1", "transaction2"]}
28
+ self.parent.get_api.return_value = expected_transactions
29
+
30
+ transactions = self.api.get_transactions(nominal_code, start_date, end_date)
31
+
32
+ self.parent.get_api.assert_called_once_with(
33
+ "accounting/transactions",
34
+ {
35
+ "nominal_code": nominal_code,
36
+ "from_date": start_date,
37
+ "to_date": end_date,
38
+ },
39
+ )
40
+ self.assertEqual(transactions, expected_transactions)
41
+
42
+
43
+ if __name__ == "__main__":
44
+ unittest.main()