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 +21 -0
- freeagent-0.1/PKG-INFO +112 -0
- freeagent-0.1/README.md +87 -0
- freeagent-0.1/pyproject.toml +49 -0
- freeagent-0.1/src/freeagent/__init__.py +27 -0
- freeagent-0.1/src/freeagent/_version.py +34 -0
- freeagent-0.1/src/freeagent/bank.py +226 -0
- freeagent-0.1/src/freeagent/base.py +202 -0
- freeagent-0.1/src/freeagent/category.py +82 -0
- freeagent-0.1/src/freeagent/payload.py +23 -0
- freeagent-0.1/src/freeagent/transaction.py +40 -0
- freeagent-0.1/src/freeagent/utils.py +114 -0
- freeagent-0.1/tests/test_bank.py +178 -0
- freeagent-0.1/tests/test_category.py +104 -0
- freeagent-0.1/tests/test_transaction.py +44 -0
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
|
+
|
freeagent-0.1/README.md
ADDED
|
@@ -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()
|