├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml └── workflows │ ├── pre-commit.yaml │ ├── publish.yaml │ ├── test.yaml │ ├── try-build-docs.yaml │ └── try-build.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── actual ├── __init__.py ├── api │ ├── __init__.py │ ├── bank_sync.py │ └── models.py ├── cli │ ├── __init__.py │ ├── config.py │ └── main.py ├── crypto.py ├── database.py ├── exceptions.py ├── migrations.py ├── protobuf_models.py ├── queries.py ├── rules.py ├── schedules.py ├── utils │ ├── __init__.py │ ├── conversions.py │ ├── storage.py │ └── title.py └── version.py ├── codecov.yaml ├── docker └── compose.yaml ├── docs ├── API-reference │ ├── actual.md │ ├── endpoints.md │ ├── exceptions.md │ ├── models.md │ ├── queries.md │ └── rules.md ├── FAQ.md ├── command-line-interface.md ├── experimental-features.md ├── index.md ├── requirements.txt └── static │ ├── added-transaction.png │ ├── gnucash-import-screenshot.png │ ├── gnucash-screenshot.png │ └── new-budget.png ├── examples ├── csv │ ├── README.md │ ├── files │ │ └── transactions.csv │ └── import.py └── gnucash │ ├── README.md │ ├── files │ └── example.gnucash │ └── import.py ├── mkdocs.yml ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt └── tests ├── __init__.py ├── conftest.py ├── test_api.py ├── test_bank_sync.py ├── test_cli.py ├── test_crypto.py ├── test_database.py ├── test_integration.py ├── test_models.py ├── test_protobuf.py ├── test_rules.py └── test_schedules.py /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: '🐛 Bug report' 2 | description: Report an issue with Actual. 3 | labels: [bug] 4 | 5 | body: 6 | - type: checkboxes 7 | id: checks 8 | attributes: 9 | label: Checks 10 | options: 11 | - label: I have checked that this issue has not already been reported. 12 | required: true 13 | - label: I have confirmed this bug exists on the latest version of actualpy. 14 | required: true 15 | 16 | - type: textarea 17 | id: example 18 | attributes: 19 | label: Reproducible example 20 | description: > 21 | Please follow [this guide](https://matthewrocklin.com/blog/work/2018/02/28/minimal-bug-reports) on how to 22 | provide a minimal, copy-pastable example. Include the (wrong) output if applicable. 23 | value: | 24 | ```python 25 | 26 | ``` 27 | validations: 28 | required: true 29 | 30 | - type: textarea 31 | id: logs 32 | attributes: 33 | label: Log output 34 | description: > 35 | Include the stack trace, if available, of the problem being reported. 36 | render: shell 37 | 38 | - type: textarea 39 | id: problem 40 | attributes: 41 | label: Issue description 42 | description: > 43 | Provide any additional information you think might be relevant. Things like which features are being used 44 | (bank syncs, use case, etc). 45 | validations: 46 | required: true 47 | 48 | - type: textarea 49 | id: expected-behavior 50 | attributes: 51 | label: Expected behavior 52 | description: > 53 | Describe or show a code example of the expected behavior. This might be the relevant UI or code snippet where 54 | Actual will handle things correctly, but the library does not. 55 | validations: 56 | required: true 57 | 58 | - type: textarea 59 | id: version 60 | attributes: 61 | label: Installed versions 62 | description: > 63 | Describe which version (or if running to git version, which commit) of the Python library and Actual Server 64 | are being ran. 65 | value: > 66 | - actualpy version: 67 | - Actual Server version: 68 | validations: 69 | required: true 70 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: '✨ Feature request' 2 | description: Suggest a new feature or enhancement for actualpy. 3 | labels: [enhancement] 4 | 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Description 10 | description: > 11 | Describe the feature or enhancement and explain why it should be implemented. 12 | Include a code example if applicable. 13 | validations: 14 | required: true 15 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yaml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | pre-commit: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-python@v3 14 | - uses: pre-commit/action@v3.0.0 15 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish package distributions to PyPI 2 | on: 3 | # see https://github.com/orgs/community/discussions/25029#discussioncomment-3246275 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | jobs: 8 | pypi-publish: 9 | name: Upload release to PyPI 10 | runs-on: ubuntu-latest 11 | environment: 12 | name: pypi 13 | url: https://pypi.org/p/actualpy 14 | permissions: 15 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 16 | steps: 17 | # taken from https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 18 | - uses: actions/checkout@v3 19 | - name: Set up Python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: "3.11" 23 | - name: Install pypa/build 24 | run: python3 -m pip install build --user 25 | - name: Build a binary wheel and a source tarball 26 | run: python3 -m build --sdist --wheel --outdir dist/ . 27 | # retrieve your distributions here 28 | - name: Publish Python 🐍 distribution 📦 to PyPI 29 | uses: pypa/gh-action-pypi-publish@release/v1 30 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [ main ] 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install ruff pytest 25 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 26 | if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi 27 | - name: Lint with ruff 28 | run: | 29 | # stop the build if there are Python syntax errors or undefined names 30 | ruff check --select=E9,F63,F7,F82 --target-version=py39 . 31 | # default set of ruff rules with GitHub Annotations 32 | ruff check --target-version=py39 . 33 | - name: Test with pytest 34 | run: | 35 | pytest --cov=./actual --cov-report=xml 36 | - name: Upload coverage reports to Codecov 37 | uses: codecov/codecov-action@v4 38 | with: 39 | token: ${{ secrets.CODECOV_TOKEN }} 40 | -------------------------------------------------------------------------------- /.github/workflows/try-build-docs.yaml: -------------------------------------------------------------------------------- 1 | name: Try building the documentation to prevent cross-reference issues 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | try-build-docs: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-python@v4 12 | with: 13 | python-version: "3.11" 14 | - name: Install project dependencies 15 | run: python3 -m pip install --user -r docs/requirements.txt 16 | - name: Try building the documentation 17 | run: mkdocs build 18 | -------------------------------------------------------------------------------- /.github/workflows/try-build.yaml: -------------------------------------------------------------------------------- 1 | name: Test package for import errors and dependency installation 2 | on: 3 | push: 4 | branches: [ main ] 5 | jobs: 6 | try-build: 7 | name: Tries to build the package and import it 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Set up Python 12 | uses: actions/setup-python@v4 13 | with: 14 | python-version: "3.11" 15 | - name: Install locally 16 | run: python3 -m pip install . 17 | - name: Test library import 18 | run: cd / && python3 -c "from actual import Actual" 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # MacOS 156 | .DS_Store 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | .idea/ 164 | site/ 165 | docker/actual-data/ 166 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: 'https://github.com/charliermarsh/ruff-pre-commit' 3 | rev: v0.9.5 4 | hooks: 5 | - id: ruff 6 | args: 7 | - '--fix' 8 | - '--exit-non-zero-on-fix' 9 | - repo: 'https://github.com/pre-commit/pre-commit-hooks' 10 | rev: v5.0.0 11 | hooks: 12 | - id: trailing-whitespace 13 | - id: end-of-file-fixer 14 | - id: check-added-large-files 15 | args: 16 | - '--maxkb=1024' # allow screenshots 17 | - repo: 'https://github.com/pycqa/isort' 18 | rev: 6.0.0 19 | hooks: 20 | - id: isort 21 | name: isort (python) 22 | args: 23 | - '--filter-files' 24 | - repo: 'https://github.com/psf/black' 25 | rev: 25.1.0 26 | hooks: 27 | - id: black 28 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for MkDocs projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the version of Python and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.12" 12 | 13 | mkdocs: 14 | configuration: mkdocs.yml 15 | 16 | # Optionally declare the Python requirements required to build your docs 17 | python: 18 | install: 19 | - requirements: docs/requirements.txt 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | The goal is to have more features implemented and tested on the Actual API. If you have ideas, comments, bug fixes or 4 | requests feel free to open an issue or submit a pull request. 5 | 6 | To install requirements, install both requirements files: 7 | 8 | ```bash 9 | # optionally setup a venv (recommended) 10 | python3 -m venv venv && source venv/bin/activate 11 | # install requirements 12 | pip install -r requirements.txt 13 | pip install -r requirements-dev.txt 14 | ``` 15 | 16 | We use [`pre-commit`](https://pre-commit.com/) to ensure consistent formatting across different developers. To develop 17 | locally, make sure you install all development requirements, then install `pre-commit` hooks. This would make sure the 18 | formatting runs on every commit. 19 | 20 | ``` 21 | pre-commit install 22 | ``` 23 | 24 | To run tests, make sure you have docker installed ([how to install docker](https://docs.docker.com/engine/install/)). 25 | Run the tests on your machine: 26 | 27 | ```bash 28 | pytest 29 | ``` 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Brunno Vanelli 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![tests](https://github.com/bvanelli/actualpy/workflows/Tests/badge.svg)](https://github.com/bvanelli/actualpy/actions) 2 | [![codecov](https://codecov.io/github/bvanelli/actualpy/graph/badge.svg?token=N6V05MY70U)](https://codecov.io/github/bvanelli/actualpy) 3 | [![version](https://img.shields.io/pypi/v/actualpy.svg?color=52c72b)](https://pypi.org/project/actualpy/) 4 | [![pyversions](https://img.shields.io/pypi/pyversions/actualpy.svg)](https://pypi.org/project/actualpy/) 5 | [![docs](https://readthedocs.org/projects/actualpy/badge/?version=latest)](https://actualpy.readthedocs.io/) 6 | [![codestyle](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black) 7 | [![ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 8 | [![PyPI - Downloads](https://img.shields.io/pypi/dm/actualpy)](https://pypistats.org/packages/actualpy) 9 | 10 | # actualpy 11 | 12 | Python API implementation for Actual server. 13 | 14 | [Actual Budget](https://actualbudget.org/) is a superfast and privacy-focused app for managing your finances. 15 | 16 | > [!WARNING] 17 | > The [Javascript API](https://actualbudget.org/docs/api/) to interact with Actual server already exists, 18 | > and is battle-tested as it is the core of the Actual frontend libraries. If you intend to use a reliable and well 19 | > tested library, that is the way to go. 20 | 21 | # Installation 22 | 23 | Install it via Pip: 24 | 25 | ```bash 26 | pip install actualpy 27 | ``` 28 | 29 | If you want to have the latest git version, you can also install using the repository url: 30 | 31 | ```bash 32 | pip install git+https://github.com/bvanelli/actualpy.git 33 | ``` 34 | 35 | For querying basic information, you additionally install the CLI, checkout the 36 | [basic documentation](https://actualpy.readthedocs.io/en/latest/command-line-interface/) 37 | 38 | # Basic usage 39 | 40 | The most common usage would be downloading a budget to more easily build queries. This would you could handle the 41 | Actual database using SQLAlchemy instead of having to retrieve the data via the export. The following script will print 42 | every single transaction registered on the Actual budget file: 43 | 44 | ```python 45 | from actual import Actual 46 | from actual.queries import get_transactions 47 | 48 | with Actual( 49 | base_url="http://localhost:5006", # Url of the Actual Server 50 | password="", # Password for authentication 51 | encryption_password=None, # Optional: Password for the file encryption. Will not use it if set to None. 52 | # Set the file to work with. Can be either the file id or file name, if name is unique 53 | file="", 54 | # Optional: Directory to store downloaded files. Will use a temporary if not provided 55 | data_dir="", 56 | # Optional: Path to the certificate file to use for the connection, can also be set as False to disable SSL verification 57 | cert="" 58 | ) as actual: 59 | transactions = get_transactions(actual.session) 60 | for t in transactions: 61 | account_name = t.account.name if t.account else None 62 | category = t.category.name if t.category else None 63 | print(t.date, account_name, t.notes, t.amount, category) 64 | ``` 65 | 66 | The `file` will be matched to either one of the following: 67 | 68 | - The name of the budget, found top the top left cornet 69 | - The ID of the budget, a UUID that is only available if you inspect the result of the method `list_user_files` 70 | - The Sync ID of the budget, a UUID available on the frontend on the "Advanced options" 71 | - If none of those options work for you, you can search for the file manually with `list_user_files` and provide the 72 | object directly: 73 | 74 | ```python 75 | from actual import Actual 76 | 77 | with Actual("http://localhost:5006", password="mypass") as actual: 78 | actual.set_file(actual.list_user_files().data[0]) 79 | actual.download_budget() 80 | ``` 81 | 82 | Checkout [the full documentation](https://actualpy.readthedocs.io) for more examples. 83 | 84 | # Understanding how Actual handles changes 85 | 86 | The Actual budget is stored in a sqlite database hosted on the user's browser. This means all your data is fully local 87 | and can be encrypted with a local key, so that not even the server can read your statements. 88 | 89 | The Actual Server is a way of only hosting files and changes. Since re-uploading the full database on every single 90 | change is too heavy, Actual only stores one state of the "base database" and everything added by the user via frontend 91 | or via the APIs are individual changes applied on top. This means that on every change, done locally, the frontend 92 | does a SYNC request with a list of the following string parameters: 93 | 94 | - `dataset`: the name of the table where the change happened. 95 | - `row`: the row identifier for the entry that was added/update. This would be the primary key of the row (a uuid value) 96 | - `column`: the column that had the value changed 97 | - `value`: the new value. Since it's a string, the values are either prefixed by `S:` to denote a string, `N:` to denote 98 | a numeric value and `0:` to denote a null value. 99 | 100 | All individual column changes are computed for an insert or update, serialized with protobuf and sent to the server to 101 | be stored. Null values and server defaults are not required to be present in the SYNC message, unless a column is 102 | changed to null. If the file is encrypted, the protobuf content will also be encrypted, so that the server does not know 103 | what was changed. 104 | 105 | New clients can use this individual changes to then update their local copies. Whenever a SYNC request is done, the 106 | response will also contain changes that might have been done in other browsers, so that the user is informated about 107 | the latest information. 108 | 109 | But this also means that new users need to download a long list of changes, possibly making the initialization slow. 110 | Thankfully, the user is also allowed to reset the sync. When doing a reset of the file via frontend, the browser is then 111 | resetting the file completely and clearing the list of changes. This would make sure all changes are actually stored in 112 | the "base database". This is done on the frontend under *Settings > Reset sync*, and causes the current file to be 113 | reset (removed from the server) and re-uploaded again, with all changes already in place. 114 | 115 | This means that, when using this library to operate changes on the database, you have to make sure that either: 116 | 117 | - do a sync request is made using the `actual.commit()` method. This only handles pending operations that haven't yet 118 | been committed, generates a change list with them and posts them on the sync endpoint. 119 | - do a full re-upload of the database is done. 120 | -------------------------------------------------------------------------------- /actual/api/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import json 5 | from typing import List, Literal, Union 6 | 7 | import requests 8 | 9 | from actual.api.models import ( 10 | BankSyncAccountResponseDTO, 11 | BankSyncErrorDTO, 12 | BankSyncResponseDTO, 13 | BankSyncStatusDTO, 14 | BankSyncTransactionResponseDTO, 15 | BootstrapInfoDTO, 16 | Endpoints, 17 | GetUserFileInfoDTO, 18 | InfoDTO, 19 | ListUserFilesDTO, 20 | LoginDTO, 21 | StatusDTO, 22 | UploadUserFileDTO, 23 | UserGetKeyDTO, 24 | ValidateDTO, 25 | ) 26 | from actual.crypto import create_key_buffer, make_test_message 27 | from actual.exceptions import ( 28 | ActualInvalidOperationError, 29 | AuthorizationError, 30 | UnknownFileId, 31 | ) 32 | from actual.protobuf_models import SyncRequest, SyncResponse 33 | 34 | 35 | class ActualServer: 36 | def __init__( 37 | self, 38 | base_url: str = "http://localhost:5006", 39 | token: str = None, 40 | password: str = None, 41 | bootstrap: bool = False, 42 | cert: str | bool = None, 43 | extra_headers: dict[str, str] = None, 44 | ): 45 | """ 46 | Implements the low-level API for interacting with the Actual server by just implementing the API calls and 47 | response models. 48 | 49 | :param base_url: url of the running Actual server 50 | :param token: the token for authentication, if this is available (optional) 51 | :param password: the password for authentication. It will be used on the .login() method to retrieve the token. 52 | be created instead. 53 | :param bootstrap: if the server is not bootstrapped, bootstrap it with the password. 54 | :param cert: if a custom certificate should be used (i.e. self-signed certificate), it's path can be provided 55 | as a string. Set to `False` for no certificate check. 56 | :param extra_headers: additional headers to be attached to each request to the Actual server 57 | """ 58 | self.api_url: str = base_url 59 | self._token: str | None = token 60 | self._requests_session: requests.Session = requests.Session() 61 | if extra_headers: 62 | self._requests_session.headers = extra_headers 63 | if cert is not None: 64 | self._requests_session.verify = cert 65 | if token is None and password is None: 66 | raise ValueError("Either provide a valid token or a password.") 67 | # already try to log-in if password was provided 68 | if password and bootstrap and not self.needs_bootstrap().data.bootstrapped: 69 | self.bootstrap(password) 70 | elif password: 71 | self.login(password) 72 | # set default headers for the connection 73 | self._requests_session.headers.update(self.headers()) 74 | # finally call validate 75 | self.validate() 76 | 77 | def login(self, password: str, method: Literal["password", "header"] = "password") -> LoginDTO: 78 | """ 79 | Logs in on the Actual server using the password provided. Raises `AuthorizationError` if it fails to 80 | authenticate the user. 81 | 82 | :param password: password of the Actual server. 83 | :param method: the method used to authenticate with the server. Check the [official auth header documentation]( 84 | https://actualbudget.org/docs/advanced/http-header-auth/) for information. 85 | :raises AuthorizationError: if the token is invalid. 86 | """ 87 | if not password: 88 | raise AuthorizationError("Trying to login but not password was provided.") 89 | if method == "password": 90 | response = self._requests_session.post( 91 | f"{self.api_url}/{Endpoints.LOGIN}", 92 | json={"loginMethod": method, "password": password}, 93 | ) 94 | else: 95 | response = self._requests_session.post( 96 | f"{self.api_url}/{Endpoints.LOGIN}", 97 | json={"loginMethod": method}, 98 | headers={"X-ACTUAL-PASSWORD": password}, 99 | ) 100 | if response.status_code == 400 and "invalid-password" in response.text: 101 | raise AuthorizationError("Could not validate password on login.") 102 | elif response.status_code == 200 and "invalid-header" in response.text: 103 | # try the same login with the header 104 | return self.login(password, "header") 105 | elif response.status_code > 400: 106 | raise AuthorizationError(f"Server returned an HTTP error '{response.status_code}': '{response.text}'") 107 | response_dict = response.json() 108 | if response_dict["status"] == "error": 109 | # for example, when not trusting the proxy 110 | raise AuthorizationError(f"Something went wrong on login: {response_dict['reason']}") 111 | login_response = LoginDTO.model_validate(response.json()) 112 | # older versions do not return 400 but rather return empty tokens 113 | if login_response.data.token is None: 114 | raise AuthorizationError("Could not validate password on login.") 115 | self._token = login_response.data.token 116 | return login_response 117 | 118 | def headers(self, file_id: str = None, extra_headers: dict = None) -> dict: 119 | """Generates a header based on the stored token for the connection. If a `file_id` is provided, it would be 120 | used as the `X-ACTUAL-FILE-ID` header. Extra headers will be included as they are provided on the final 121 | dictionary.""" 122 | if not self._token: 123 | raise AuthorizationError("Token not available for requests. Use the login() method or provide a token.") 124 | headers = {"X-ACTUAL-TOKEN": self._token} 125 | if file_id: 126 | headers["X-ACTUAL-FILE-ID"] = file_id 127 | if extra_headers: 128 | headers.update(extra_headers) 129 | return headers 130 | 131 | def info(self) -> InfoDTO: 132 | """Gets the information from the Actual server, like the name and version.""" 133 | response = self._requests_session.get(f"{self.api_url}/{Endpoints.INFO}") 134 | response.raise_for_status() 135 | return InfoDTO.model_validate(response.json()) 136 | 137 | def validate(self) -> ValidateDTO: 138 | """Validates if the user is valid and logged in, and if the token is also valid and bound to a session.""" 139 | response = self._requests_session.get(f"{self.api_url}/{Endpoints.ACCOUNT_VALIDATE}") 140 | response.raise_for_status() 141 | return ValidateDTO.model_validate(response.json()) 142 | 143 | def needs_bootstrap(self) -> BootstrapInfoDTO: 144 | """Checks if the Actual needs bootstrap, in other words, if it needs a master password for the server.""" 145 | response = self._requests_session.get(f"{self.api_url}/{Endpoints.NEEDS_BOOTSTRAP}") 146 | response.raise_for_status() 147 | return BootstrapInfoDTO.model_validate(response.json()) 148 | 149 | def bootstrap(self, password: str) -> LoginDTO: 150 | response = self._requests_session.post(f"{self.api_url}/{Endpoints.BOOTSTRAP}", json={"password": password}) 151 | response.raise_for_status() 152 | login_response = LoginDTO.model_validate(response.json()) 153 | self._token = login_response.data.token 154 | return login_response 155 | 156 | def data_file_index(self) -> List[str]: 157 | """Gets all the migration file references for the actual server.""" 158 | response = self._requests_session.get(f"{self.api_url}/{Endpoints.DATA_FILE_INDEX}") 159 | response.raise_for_status() 160 | return response.content.decode().splitlines() 161 | 162 | def data_file(self, file_path: str) -> bytes: 163 | """Gets the content of the individual migration file from server.""" 164 | response = self._requests_session.get(f"{self.api_url}/data/{file_path}") 165 | response.raise_for_status() 166 | return response.content 167 | 168 | def reset_user_file(self, file_id: str) -> StatusDTO: 169 | """Resets the file. If the file_id is not provided, the current file set is reset. Usually used together with 170 | the upload_user_file() method.""" 171 | if file_id is None: 172 | raise UnknownFileId("Could not reset the file without a valid 'file_id'") 173 | request = self._requests_session.post( 174 | f"{self.api_url}/{Endpoints.RESET_USER_FILE}", json={"fileId": file_id, "token": self._token} 175 | ) 176 | request.raise_for_status() 177 | return StatusDTO.model_validate(request.json()) 178 | 179 | def download_user_file(self, file_id: str) -> bytes: 180 | """Downloads the user file based on the file_id provided. Returns the `bytes` from the response, which is a 181 | zipped folder of the database `db.sqlite` and the `metadata.json`. If the database is encrypted, the key id 182 | has to be retrieved additionally using user_get_key().""" 183 | db = self._requests_session.get(f"{self.api_url}/{Endpoints.DOWNLOAD_USER_FILE}", headers=self.headers(file_id)) 184 | db.raise_for_status() 185 | return db.content 186 | 187 | def upload_user_file( 188 | self, binary_data: bytes, file_id: str, file_name: str = "My Finances", encryption_meta: dict = None 189 | ) -> UploadUserFileDTO: 190 | """Uploads the binary data, which is a zip folder containing the `db.sqlite` and the `metadata.json`. If the 191 | file is encrypted, the encryption_meta has to be provided with fields `keyId`, `algorithm`, `iv` and `authTag` 192 | """ 193 | base_headers = { 194 | "X-ACTUAL-FORMAT": "2", 195 | "X-ACTUAL-FILE-ID": file_id, 196 | "X-ACTUAL-NAME": file_name, 197 | "Content-Type": "application/encrypted-file", 198 | } 199 | if encryption_meta: 200 | base_headers["X-ACTUAL-ENCRYPT-META"] = json.dumps(encryption_meta) 201 | request = self._requests_session.post( 202 | f"{self.api_url}/{Endpoints.UPLOAD_USER_FILE}", 203 | data=binary_data, 204 | headers=self.headers(extra_headers=base_headers), 205 | ) 206 | request.raise_for_status() 207 | return UploadUserFileDTO.model_validate(request.json()) 208 | 209 | def list_user_files(self) -> ListUserFilesDTO: 210 | """Lists the user files. If the response item contains `encrypt_key_id` different from `None`, then the 211 | file must be decrypted on retrieval.""" 212 | response = self._requests_session.get(f"{self.api_url}/{Endpoints.LIST_USER_FILES}") 213 | response.raise_for_status() 214 | return ListUserFilesDTO.model_validate(response.json()) 215 | 216 | def get_user_file_info(self, file_id: str) -> GetUserFileInfoDTO: 217 | """Gets the user file information, including the encryption metadata.""" 218 | response = self._requests_session.get( 219 | f"{self.api_url}/{Endpoints.GET_USER_FILE_INFO}", headers=self.headers(file_id) 220 | ) 221 | response.raise_for_status() 222 | return GetUserFileInfoDTO.model_validate(response.json()) 223 | 224 | def update_user_file_name(self, file_id: str, file_name: str) -> StatusDTO: 225 | """Updates the file name for the budget on the remote server.""" 226 | response = self._requests_session.post( 227 | f"{self.api_url}/{Endpoints.UPDATE_USER_FILE_NAME}", 228 | json={"fileId": file_id, "name": file_name, "token": self._token}, 229 | ) 230 | response.raise_for_status() 231 | return StatusDTO.model_validate(response.json()) 232 | 233 | def delete_user_file(self, file_id: str): 234 | """Deletes the user file that is loaded from the remote server.""" 235 | response = self._requests_session.post( 236 | f"{self.api_url}/{Endpoints.DELETE_USER_FILE}", json={"fileId": file_id, "token": self._token} 237 | ) 238 | return StatusDTO.model_validate(response.json()) 239 | 240 | def user_get_key(self, file_id: str) -> UserGetKeyDTO: 241 | """Gets the key information associated with a user file, including the algorithm, key, salt and iv.""" 242 | response = self._requests_session.post( 243 | f"{self.api_url}/{Endpoints.USER_GET_KEY}", 244 | json={ 245 | "fileId": file_id, 246 | "token": self._token, 247 | }, 248 | headers=self.headers(file_id), 249 | ) 250 | response.raise_for_status() 251 | return UserGetKeyDTO.model_validate(response.json()) 252 | 253 | def user_create_key(self, file_id: str, key_id: str, password: str, key_salt: str) -> StatusDTO: 254 | """Creates a new key for the user file. The key has to be used then to encrypt the local file, and this file 255 | still needs to be uploaded.""" 256 | key = create_key_buffer(password, key_salt) 257 | test_content = make_test_message(key_id, key) 258 | response = self._requests_session.post( 259 | f"{self.api_url}/{Endpoints.USER_CREATE_KEY}", 260 | json={ 261 | "fileId": file_id, 262 | "keyId": key_id, 263 | "keySalt": key_salt, 264 | "testContent": json.dumps(test_content), 265 | "token": self._token, 266 | }, 267 | ) 268 | return StatusDTO.model_validate(response.json()) 269 | 270 | def sync_sync(self, request: SyncRequest) -> SyncResponse: 271 | """Calls the sync endpoint with a request and returns the response. Both the request and response are 272 | protobuf models. The request and response are not standard REST, but rather protobuf binary serialized data. 273 | The server stores this serialized data to allow the user to replay all changes to the database and construct 274 | a local copy.""" 275 | response = self._requests_session.post( 276 | f"{self.api_url}/{Endpoints.SYNC}", 277 | headers=self.headers(request.fileId, extra_headers={"Content-Type": "application/actual-sync"}), 278 | data=SyncRequest.serialize(request), 279 | ) 280 | response.raise_for_status() 281 | parsed_response = SyncResponse.deserialize(response.content) 282 | return parsed_response # noqa 283 | 284 | def bank_sync_status(self, bank_sync: Literal["gocardless", "simplefin"] | str) -> BankSyncStatusDTO: 285 | endpoint = Endpoints.BANK_SYNC_STATUS.value.format(bank_sync=bank_sync) 286 | response = self._requests_session.post(f"{self.api_url}/{endpoint}", json={}) 287 | return BankSyncStatusDTO.model_validate(response.json()) 288 | 289 | def bank_sync_accounts(self, bank_sync: Literal["gocardless", "simplefin"]) -> BankSyncAccountResponseDTO: 290 | endpoint = Endpoints.BANK_SYNC_ACCOUNTS.value.format(bank_sync=bank_sync) 291 | response = self._requests_session.post(f"{self.api_url}/{endpoint}", json={}) 292 | return BankSyncAccountResponseDTO.validate_python(response.json()) 293 | 294 | def bank_sync_transactions( 295 | self, 296 | bank_sync: Literal["gocardless", "simplefin"] | str, 297 | account_id: str, 298 | start_date: datetime.date, 299 | requisition_id: str = None, 300 | ) -> Union[BankSyncErrorDTO, BankSyncTransactionResponseDTO]: 301 | if bank_sync == "gocardless" and requisition_id is None: 302 | raise ActualInvalidOperationError("Retrieving transactions with goCardless requires `requisition_id`") 303 | endpoint = Endpoints.BANK_SYNC_TRANSACTIONS.value.format(bank_sync=bank_sync) 304 | payload = {"accountId": account_id, "startDate": start_date.strftime("%Y-%m-%d")} 305 | if requisition_id: 306 | payload["requisitionId"] = requisition_id 307 | response = self._requests_session.post(f"{self.api_url}/{endpoint}", json=payload) 308 | return BankSyncResponseDTO.validate_python(response.json()) 309 | -------------------------------------------------------------------------------- /actual/api/bank_sync.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import decimal 5 | import enum 6 | from typing import List, Optional 7 | 8 | from pydantic import AliasChoices, BaseModel, Field 9 | 10 | from actual.utils.title import title 11 | 12 | 13 | class BankSyncTransactionDTO(BaseModel): 14 | id: str 15 | posted: int 16 | amount: str 17 | description: str 18 | payee: str 19 | memo: str 20 | 21 | 22 | class BankSyncOrgDTO(BaseModel): 23 | domain: str 24 | sfin_url: str = Field(..., alias="sfin-url") 25 | 26 | 27 | class BankSyncAccountDTO(BaseModel): 28 | org: BankSyncOrgDTO 29 | id: str 30 | name: str 31 | currency: str 32 | balance: str 33 | available_balance: str = Field(..., alias="available-balance") 34 | balance_date: int = Field(..., alias="balance-date") 35 | transactions: List[BankSyncTransactionDTO] 36 | holdings: List[dict] 37 | 38 | 39 | class BankSyncAmount(BaseModel): 40 | amount: decimal.Decimal 41 | currency: str 42 | 43 | 44 | class DebtorAccount(BaseModel): 45 | iban: str 46 | 47 | @property 48 | def masked_iban(self): 49 | return f"({self.iban[:4]} XXX {self.iban[-4:]})" 50 | 51 | 52 | class BalanceType(enum.Enum): 53 | # See https://developer.gocardless.com/bank-account-data/balance#balance_type for full documentation 54 | CLOSING_AVAILABLE = "closingAvailable" 55 | CLOSING_BOOKED = "closingBooked" 56 | CLOSING_CLEARED = "closingCleared" 57 | EXPECTED = "expected" 58 | FORWARD_AVAILABLE = "forwardAvailable" 59 | INTERIM_AVAILABLE = "interimAvailable" 60 | INTERIM_CLEARED = "interimCleared" 61 | INFORMATION = "information" 62 | INTERIM_BOOKED = "interimBooked" 63 | NON_INVOICED = "nonInvoiced" 64 | OPENING_BOOKED = "openingBooked" 65 | OPENING_AVAILABLE = "openingAvailable" 66 | OPENING_CLEARED = "openingCleared" 67 | PREVIOUSLY_CLOSED_BOOKED = "previouslyClosedBooked" 68 | 69 | 70 | class Balance(BaseModel): 71 | """An object containing the balance amount and currency.""" 72 | 73 | balance_amount: BankSyncAmount = Field(..., alias="balanceAmount") 74 | balance_type: BalanceType = Field(..., alias="balanceType") 75 | reference_date: Optional[str] = Field(None, alias="referenceDate", description="The date of the balance") 76 | 77 | 78 | class TransactionItem(BaseModel): 79 | transaction_id: Optional[str] = Field(None, alias="transactionId") 80 | booked: Optional[bool] = False 81 | transaction_amount: BankSyncAmount = Field(..., alias="transactionAmount") 82 | # these fields are generated on the server itself, so we can trust them as being correct 83 | payee_name: Optional[str] = Field(None, alias="payeeName") 84 | date: datetime.date = Field(..., alias="date") 85 | notes: Optional[str] = Field(None, alias="notes") 86 | # goCardless optional fields 87 | payee: Optional[str] = Field(None, validation_alias=AliasChoices("debtorName", "creditorName")) 88 | payee_account: Optional[DebtorAccount] = Field( 89 | None, validation_alias=AliasChoices("debtorAccount", "creditorAccount") 90 | ) 91 | booking_date: Optional[datetime.date] = Field(None, alias="bookingDate") 92 | value_date: Optional[datetime.date] = Field(None, alias="valueDate") 93 | remittance_information_unstructured: str = Field(None, alias="remittanceInformationUnstructured") 94 | remittance_information_unstructured_array: List[str] = Field( 95 | default_factory=list, alias="remittanceInformationUnstructuredArray" 96 | ) 97 | additional_information: Optional[str] = Field(None, alias="additionalInformation") 98 | # simpleFin optional fields 99 | posted_date: Optional[datetime.date] = Field(None, alias="postedDate") 100 | 101 | @property 102 | def imported_payee(self): 103 | """Deprecated method to convert the payee name. Use the payee_name instead.""" 104 | name_parts = [] 105 | name = self.payee or self.notes or self.additional_information 106 | if name: 107 | name_parts.append(title(name)) 108 | if self.payee_account and self.payee_account.iban: 109 | name_parts.append(self.payee_account.masked_iban) 110 | return " ".join(name_parts).strip() 111 | 112 | 113 | class Transactions(BaseModel): 114 | all: List[TransactionItem] = Field(..., description="List of all transactions, from newest to oldest.") 115 | booked: List[TransactionItem] 116 | pending: List[TransactionItem] 117 | 118 | 119 | class BankSyncAccountData(BaseModel): 120 | accounts: List[BankSyncAccountDTO] 121 | 122 | 123 | class BankSyncTransactionData(BaseModel): 124 | balances: List[Balance] 125 | starting_balance: int = Field(..., alias="startingBalance") 126 | transactions: Transactions 127 | # goCardless specific 128 | iban: Optional[str] = None 129 | institution_id: Optional[str] = Field(None, alias="institutionId") 130 | 131 | @property 132 | def balance(self) -> decimal.Decimal: 133 | """Starting balance of the account integration, converted to a decimal amount. 134 | 135 | For `simpleFin`, this will represent the current amount on the account, while for `goCardless` it will 136 | represent the actual initial amount before all transactions. 137 | """ 138 | return decimal.Decimal(self.starting_balance) / 100 139 | 140 | 141 | class BankSyncErrorData(BaseModel): 142 | error_type: str 143 | error_code: str 144 | status: Optional[str] = None 145 | reason: Optional[str] = None 146 | -------------------------------------------------------------------------------- /actual/api/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import enum 4 | from typing import List, Optional, Union 5 | 6 | from pydantic import BaseModel, Field, TypeAdapter 7 | 8 | from actual.api.bank_sync import ( 9 | BankSyncAccountData, 10 | BankSyncErrorData, 11 | BankSyncTransactionData, 12 | ) 13 | 14 | 15 | class Endpoints(enum.Enum): 16 | LOGIN = "account/login" 17 | INFO = "info" 18 | ACCOUNT_VALIDATE = "account/validate" 19 | NEEDS_BOOTSTRAP = "account/needs-bootstrap" 20 | BOOTSTRAP = "account/bootstrap" 21 | SYNC = "sync/sync" 22 | LIST_USER_FILES = "sync/list-user-files" 23 | GET_USER_FILE_INFO = "sync/get-user-file-info" 24 | UPDATE_USER_FILE_NAME = "sync/update-user-filename" 25 | DOWNLOAD_USER_FILE = "sync/download-user-file" 26 | UPLOAD_USER_FILE = "sync/upload-user-file" 27 | RESET_USER_FILE = "sync/reset-user-file" 28 | DELETE_USER_FILE = "sync/delete-user-file" 29 | # encryption related 30 | USER_GET_KEY = "sync/user-get-key" 31 | USER_CREATE_KEY = "sync/user-create-key" 32 | # data related 33 | DATA_FILE_INDEX = "data-file-index.txt" 34 | DEFAULT_DB = "data/default-db.sqlite" 35 | MIGRATIONS = "data/migrations" 36 | # bank sync related 37 | SECRET = "secret" 38 | BANK_SYNC_STATUS = "{bank_sync}/status" 39 | BANK_SYNC_ACCOUNTS = "{bank_sync}/accounts" 40 | BANK_SYNC_TRANSACTIONS = "{bank_sync}/transactions" 41 | 42 | def __str__(self): 43 | return self.value 44 | 45 | 46 | class BankSyncs(enum.Enum): 47 | GOCARDLESS = "gocardless" 48 | SIMPLEFIN = "simplefin" 49 | 50 | 51 | class StatusCode(enum.Enum): 52 | OK = "ok" 53 | ERROR = "error" 54 | 55 | 56 | class StatusDTO(BaseModel): 57 | status: StatusCode 58 | 59 | 60 | class ErrorStatusDTO(BaseModel): 61 | status: StatusCode 62 | reason: Optional[str] = None 63 | 64 | 65 | class TokenDTO(BaseModel): 66 | token: Optional[str] 67 | 68 | 69 | class LoginDTO(StatusDTO): 70 | data: TokenDTO 71 | 72 | 73 | class UploadUserFileDTO(StatusDTO): 74 | group_id: str = Field(..., alias="groupId") 75 | 76 | 77 | class IsValidatedDTO(BaseModel): 78 | validated: Optional[bool] 79 | 80 | 81 | class ValidateDTO(StatusDTO): 82 | data: IsValidatedDTO 83 | 84 | 85 | class EncryptMetaDTO(BaseModel): 86 | key_id: Optional[str] = Field(..., alias="keyId") 87 | algorithm: Optional[str] 88 | iv: Optional[str] 89 | auth_tag: Optional[str] = Field(..., alias="authTag") 90 | 91 | 92 | class EncryptionTestDTO(BaseModel): 93 | value: str 94 | meta: EncryptMetaDTO 95 | 96 | 97 | class EncryptionDTO(BaseModel): 98 | id: Optional[str] 99 | salt: Optional[str] 100 | test: Optional[str] 101 | 102 | def meta(self) -> EncryptionTestDTO: 103 | return EncryptionTestDTO.parse_raw(self.test) 104 | 105 | 106 | class FileDTO(BaseModel): 107 | deleted: Optional[int] 108 | file_id: Optional[str] = Field(..., alias="fileId") 109 | group_id: Optional[str] = Field(..., alias="groupId") 110 | name: Optional[str] 111 | 112 | 113 | class RemoteFileListDTO(FileDTO): 114 | encrypt_key_id: Optional[str] = Field(..., alias="encryptKeyId") 115 | 116 | 117 | class RemoteFileDTO(FileDTO): 118 | encrypt_meta: Optional[EncryptMetaDTO] = Field(..., alias="encryptMeta") 119 | 120 | 121 | class GetUserFileInfoDTO(StatusDTO): 122 | data: RemoteFileDTO 123 | 124 | 125 | class ListUserFilesDTO(StatusDTO): 126 | data: List[RemoteFileListDTO] 127 | 128 | 129 | class UserGetKeyDTO(StatusDTO): 130 | data: EncryptionDTO 131 | 132 | 133 | class BuildDTO(BaseModel): 134 | name: str 135 | description: Optional[str] 136 | version: Optional[str] 137 | 138 | 139 | class InfoDTO(BaseModel): 140 | build: BuildDTO 141 | 142 | 143 | class IsBootstrapedDTO(BaseModel): 144 | bootstrapped: bool 145 | 146 | 147 | class BootstrapInfoDTO(StatusDTO): 148 | data: IsBootstrapedDTO 149 | 150 | 151 | class IsConfiguredDTO(BaseModel): 152 | configured: bool 153 | 154 | 155 | class BankSyncStatusDTO(StatusDTO): 156 | data: IsConfiguredDTO 157 | 158 | 159 | class BankSyncAccountDTO(StatusDTO): 160 | data: BankSyncAccountData 161 | 162 | 163 | class BankSyncTransactionResponseDTO(StatusDTO): 164 | data: BankSyncTransactionData 165 | 166 | 167 | class BankSyncErrorDTO(StatusDTO): 168 | data: BankSyncErrorData 169 | 170 | 171 | BankSyncAccountResponseDTO = TypeAdapter(Union[BankSyncErrorDTO, BankSyncAccountDTO]) 172 | BankSyncResponseDTO = TypeAdapter(Union[BankSyncErrorDTO, BankSyncTransactionResponseDTO]) 173 | -------------------------------------------------------------------------------- /actual/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bvanelli/actualpy/f6e223cba34bfa047c35bdf24500e77944b6cc9f/actual/cli/__init__.py -------------------------------------------------------------------------------- /actual/cli/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from enum import Enum 3 | from pathlib import Path 4 | from typing import Dict, Optional 5 | 6 | import pydantic 7 | import yaml 8 | from rich.console import Console 9 | 10 | from actual import Actual 11 | 12 | console = Console() 13 | 14 | 15 | def default_config_path(): 16 | return Path.home() / ".actualpy" / "config.yaml" 17 | 18 | 19 | class OutputType(Enum): 20 | table = "table" 21 | json = "json" 22 | 23 | 24 | class State(pydantic.BaseModel): 25 | output: OutputType = pydantic.Field("table", alias="defaultOutput", description="Default output for CLI.") 26 | 27 | 28 | class BudgetConfig(pydantic.BaseModel): 29 | url: str = pydantic.Field(..., description="") 30 | password: str = pydantic.Field(..., description="") 31 | file_id: str = pydantic.Field(..., alias="fileId") 32 | encryption_password: Optional[str] = pydantic.Field(None, alias="encryptionPassword") 33 | 34 | model_config = pydantic.ConfigDict(populate_by_name=True) 35 | 36 | 37 | class Config(pydantic.BaseModel): 38 | default_context: str = pydantic.Field("", alias="defaultContext", description="Default budget context for CLI.") 39 | budgets: Dict[str, BudgetConfig] = pydantic.Field( 40 | default_factory=dict, description="Dict of configured budgets on CLI." 41 | ) 42 | 43 | def save(self): 44 | """Saves the current configuration to a file.""" 45 | config_path = default_config_path() 46 | os.makedirs(config_path.parent, exist_ok=True) 47 | os.makedirs(config_path.parent / "cache", exist_ok=True) 48 | with open(config_path, "w") as file: 49 | yaml.dump(self.model_dump(by_alias=True), file) 50 | 51 | @classmethod 52 | def load(cls): 53 | """Load the configuration file. If it doesn't exist, create a basic config.""" 54 | config_path = default_config_path() 55 | if not config_path.exists(): 56 | console.print(f"[yellow]Config file not found at '{config_path}'! Creating a new one...[/yellow]") 57 | # Create a basic config with default values 58 | default_config = cls() 59 | default_config.save() 60 | return default_config 61 | else: 62 | with open(config_path, "r") as file: 63 | config = yaml.safe_load(file) 64 | return cls.model_validate(config) 65 | 66 | def actual(self) -> Actual: 67 | context = self.default_context 68 | budget_config = self.budgets.get(context) 69 | if not budget_config: 70 | raise ValueError(f"Could not find budget with context '{context}'") 71 | return Actual( 72 | budget_config.url, 73 | password=budget_config.password, 74 | file=budget_config.file_id, 75 | encryption_password=budget_config.encryption_password, 76 | data_dir=default_config_path().parent / "cache" / context, 77 | ) 78 | -------------------------------------------------------------------------------- /actual/cli/main.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import pathlib 3 | import warnings 4 | from typing import Optional 5 | 6 | import typer 7 | from rich.console import Console 8 | from rich.json import JSON 9 | from rich.table import Table 10 | 11 | from actual import Actual, get_accounts, get_transactions 12 | from actual.cli.config import BudgetConfig, Config, OutputType, State 13 | from actual.queries import get_payees 14 | from actual.version import __version__ 15 | 16 | # avoid displaying warnings on a CLI 17 | warnings.filterwarnings("ignore") 18 | 19 | app = typer.Typer() 20 | 21 | console = Console() 22 | config: Config = Config.load() 23 | state: State = State() 24 | 25 | 26 | @app.callback() 27 | def main(output: OutputType = typer.Option("table", "--output", "-o", help="Output format: table or json")): 28 | if output: 29 | state.output = output 30 | 31 | 32 | @app.command() 33 | def init( 34 | url: str = typer.Option(None, "--url", help="URL of the actual server"), 35 | password: str = typer.Option(None, "--password", help="Password for the budget"), 36 | encryption_password: str = typer.Option(None, "--encryption-password", help="Encryption password for the budget"), 37 | context: str = typer.Option(None, "--context", help="Context for this budget context"), 38 | file_id: str = typer.Option(None, "--file", help="File ID or name on the remote server"), 39 | ): 40 | """ 41 | Initializes an actual budget config interactively if options are not provided. 42 | """ 43 | if not url: 44 | url = typer.prompt("Please enter the URL of the actual server", default="http://localhost:5006") 45 | 46 | if not password: 47 | password = typer.prompt("Please enter the Actual server password", hide_input=True) 48 | 49 | # test the login 50 | server = Actual(url, password=password) 51 | 52 | if not file_id: 53 | files = server.list_user_files() 54 | options = [file for file in files.data if not file.deleted] 55 | for idx, option in enumerate(options): 56 | console.print(f"[purple]({idx + 1}) {option.name}[/purple]") 57 | file_id_idx = typer.prompt("Please enter the budget index", type=int) 58 | assert file_id_idx - 1 in range(len(options)), "Did not select one of the options, exiting." 59 | server.set_file(options[file_id_idx - 1]) 60 | else: 61 | server.set_file(file_id) 62 | file_id = server._file.file_id 63 | 64 | if not encryption_password and server._file.encrypt_key_id: 65 | encryption_password = typer.prompt("Please enter the encryption password for the budget", hide_input=True) 66 | # test the file 67 | server.download_budget(encryption_password) 68 | else: 69 | encryption_password = None 70 | 71 | if not context: 72 | # take the default context name as the file name in lowercase 73 | default_context = server._file.name.lower().replace(" ", "-") 74 | context = typer.prompt("Name of the context for this budget", default=default_context) 75 | 76 | config.budgets[context] = BudgetConfig( 77 | url=url, 78 | password=password, 79 | encryption_password=encryption_password, 80 | file_id=file_id, 81 | ) 82 | if not config.default_context: 83 | config.default_context = context 84 | config.save() 85 | console.print(f"[green]Initialized budget '{context}'[/green]") 86 | 87 | 88 | @app.command() 89 | def use_context(context: str = typer.Argument(..., help="Context for this budget context")): 90 | """Sets the default context for the CLI.""" 91 | if context not in config.budgets: 92 | raise ValueError(f"Context '{context}' is not registered. Choose one from {list(config.budgets.keys())}") 93 | config.default_context = context 94 | config.save() 95 | 96 | 97 | @app.command() 98 | def remove_context(context: str = typer.Argument(..., help="Context to be removed")): 99 | """Removes a configured context from the configuration.""" 100 | if context not in config.budgets: 101 | raise ValueError(f"Context '{context}' is not registered. Choose one from {list(config.budgets.keys())}") 102 | config.budgets.pop(context) 103 | config.default_context = list(config.budgets.keys())[0] if len(config.budgets) == 1 else "" 104 | config.save() 105 | 106 | 107 | @app.command() 108 | def version(): 109 | """ 110 | Shows the library and server version. 111 | """ 112 | actual = config.actual() 113 | info = actual.info() 114 | if state.output == OutputType.table: 115 | console.print(f"Library Version: {__version__}") 116 | console.print(f"Server Version: {info.build.version}") 117 | else: 118 | console.print(JSON.from_data({"library_version": __version__, "server_version": info.build.version})) 119 | 120 | 121 | @app.command() 122 | def accounts(): 123 | """ 124 | Show all accounts. 125 | """ 126 | # Mock data for demonstration purposes 127 | accounts_data = [] 128 | with config.actual() as actual: 129 | accounts_raw_data = get_accounts(actual.session) 130 | for account in accounts_raw_data: 131 | accounts_data.append( 132 | { 133 | "name": account.name, 134 | "balance": float(account.balance), 135 | } 136 | ) 137 | 138 | if state.output == OutputType.table: 139 | table = Table(title="Accounts") 140 | table.add_column("Account Name", justify="left", style="cyan", no_wrap=True) 141 | table.add_column("Balance", justify="right", style="green") 142 | 143 | for account in accounts_data: 144 | table.add_row(account["name"], f"{account['balance']:.2f}") 145 | 146 | console.print(table) 147 | else: 148 | console.print(JSON.from_data(accounts_data)) 149 | 150 | 151 | @app.command() 152 | def transactions(): 153 | """ 154 | Show all transactions. 155 | """ 156 | transactions_data = [] 157 | with config.actual() as actual: 158 | transactions_raw_data = get_transactions(actual.session) 159 | for transaction in transactions_raw_data: 160 | transactions_data.append( 161 | { 162 | "date": transaction.get_date().isoformat(), 163 | "payee": transaction.payee.name if transaction.payee else None, 164 | "notes": transaction.notes or "", 165 | "category": (transaction.category.name if transaction.category else None), 166 | "amount": round(float(transaction.get_amount()), 2), 167 | } 168 | ) 169 | 170 | if state.output == OutputType.table: 171 | table = Table(title="Transactions") 172 | table.add_column("Date", justify="left", style="cyan", no_wrap=True) 173 | table.add_column("Payee", justify="left", style="magenta") 174 | table.add_column("Notes", justify="left", style="yellow") 175 | table.add_column("Category", justify="left", style="cyan") 176 | table.add_column("Amount", justify="right", style="green") 177 | 178 | for transaction in transactions_data: 179 | color = "green" if transaction["amount"] >= 0 else "red" 180 | table.add_row( 181 | transaction["date"], 182 | transaction["payee"], 183 | transaction["notes"], 184 | transaction["category"], 185 | f"[{color}]{transaction['amount']:.2f}[/]", 186 | ) 187 | 188 | console.print(table) 189 | else: 190 | console.print(JSON.from_data(transactions_data)) 191 | 192 | 193 | @app.command() 194 | def payees(): 195 | """ 196 | Show all payees. 197 | """ 198 | payees_data = [] 199 | with config.actual() as actual: 200 | payees_raw_data = get_payees(actual.session) 201 | for payee in payees_raw_data: 202 | payees_data.append({"name": payee.name, "balance": round(float(payee.balance), 2)}) 203 | 204 | if state.output == OutputType.table: 205 | table = Table(title="Payees") 206 | table.add_column("Name", justify="left", style="cyan", no_wrap=True) 207 | table.add_column("Balance", justify="right") 208 | 209 | for payee in payees_data: 210 | color = "green" if payee["balance"] >= 0 else "red" 211 | table.add_row( 212 | payee["name"], 213 | f"[{color}]{payee['balance']:.2f}[/]", 214 | ) 215 | console.print(table) 216 | else: 217 | console.print(JSON.from_data(payees_data)) 218 | 219 | 220 | @app.command() 221 | def export( 222 | filename: Optional[pathlib.Path] = typer.Argument( 223 | default=None, 224 | help="Name of the file to export, in zip format. " 225 | "Leave it empty to export it to the current folder with default name.", 226 | ), 227 | ): 228 | """ 229 | Generates an export from the budget (for CLI backups). 230 | """ 231 | with config.actual() as actual: 232 | if filename is None: 233 | current_date = datetime.datetime.now().strftime("%Y-%m-%d-%H%M") 234 | budget_name = actual.get_metadata().get("budgetName", "My Finances") 235 | filename = pathlib.Path(f"{current_date}-{budget_name}.zip") 236 | actual.export_data(filename) 237 | actual_metadata = actual.get_metadata() 238 | budget_name = actual_metadata["budgetName"] 239 | budget_id = actual_metadata["id"] 240 | console.print( 241 | f"[green]Exported budget '{budget_name}' (budget id '{budget_id}') to [bold]'{filename}'[/bold].[/green]" 242 | ) 243 | 244 | 245 | @app.command() 246 | def metadata(): 247 | """Displays all metadata for the current budget.""" 248 | with config.actual() as actual: 249 | actual_metadata = actual.get_metadata() 250 | if state.output == OutputType.table: 251 | table = Table(title="Metadata") 252 | table.add_column("Key", justify="left", style="cyan", no_wrap=True) 253 | table.add_column("Value", justify="left") 254 | for key, value in actual_metadata.items(): 255 | table.add_row(key, str(value)) 256 | console.print(table) 257 | else: 258 | console.print(JSON.from_data(actual_metadata)) 259 | 260 | 261 | if __name__ == "__main__": 262 | app() 263 | -------------------------------------------------------------------------------- /actual/crypto.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import os 5 | import uuid 6 | 7 | import cryptography.exceptions 8 | from cryptography.hazmat.primitives import hashes 9 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 10 | from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC 11 | 12 | from actual.exceptions import ActualDecryptionError 13 | 14 | 15 | def random_bytes(size: int = 12) -> str: 16 | return str(os.urandom(size)) 17 | 18 | 19 | def make_salt(length: int = 32) -> str: 20 | # reference generates 32 bytes of random data 21 | # github.com/actualbudget/actual/blob/70e37c0119f4ba95ccf6549f0df4aac770f1bb8f/packages/loot-core/src/server/main.ts#L1489 22 | return base64.b64encode(os.urandom(length)).decode() 23 | 24 | 25 | def create_key_buffer(password: str, key_salt: str) -> bytes: 26 | kdf = PBKDF2HMAC(algorithm=hashes.SHA512(), length=32, salt=key_salt.encode(), iterations=10_000) 27 | return kdf.derive(password.encode()) 28 | 29 | 30 | def encrypt(key_id: str, master_key: bytes, plaintext: bytes) -> dict: 31 | iv = os.urandom(12) 32 | encryptor = Cipher(algorithms.AES(master_key), modes.GCM(iv)).encryptor() 33 | value = encryptor.update(plaintext) + encryptor.finalize() 34 | auth_tag = encryptor.tag 35 | return { 36 | "value": base64.b64encode(value).decode(), 37 | "meta": { 38 | "keyId": key_id, 39 | "algorithm": "aes-256-gcm", 40 | "iv": base64.b64encode(iv).decode(), 41 | "authTag": base64.b64encode(auth_tag).decode(), 42 | }, 43 | } 44 | 45 | 46 | def decrypt(master_key: bytes, iv: bytes, ciphertext: bytes, auth_tag: bytes = None) -> bytes: 47 | decryptor = Cipher(algorithms.AES(master_key), modes.GCM(iv, auth_tag)).decryptor() 48 | try: 49 | return decryptor.update(ciphertext) + decryptor.finalize() 50 | except cryptography.exceptions.InvalidTag: 51 | raise ActualDecryptionError("Error decrypting file. Is the encryption key correct?") from None 52 | 53 | 54 | def decrypt_from_meta(master_key: bytes, ciphertext: bytes, encrypt_meta) -> bytes: 55 | iv = base64.b64decode(encrypt_meta.iv) 56 | auth_tag = base64.b64decode(encrypt_meta.auth_tag) 57 | return decrypt(master_key, iv, ciphertext, auth_tag) 58 | 59 | 60 | def make_test_message(key_id: str, key: bytes) -> dict: 61 | """Reference 62 | https://github.com/actualbudget/actual/blob/70e37c0119f4ba95ccf6549f0df4aac770f1bb8f/packages/loot-core/src/server/sync/make-test-message.ts#L10 63 | """ 64 | from actual.protobuf_models import Message 65 | 66 | m = Message(dict(dataset=random_bytes(), row=random_bytes(), column=random_bytes(), value=random_bytes())) 67 | binary_message = Message.serialize(m) 68 | # return encrypted binary message 69 | return encrypt(key_id, key, binary_message) 70 | 71 | 72 | def is_uuid(text: str, version: int = 4): 73 | """ 74 | Check if uuid_to_test is a valid UUID. Taken from [this thread](https://stackoverflow.com/a/54254115/12681470) 75 | 76 | Examples: 77 | 78 | >>> is_uuid('c9bf9e57-1685-4c89-bafb-ff5af830be8a') 79 | True 80 | >>> is_uuid('c9bf9e58') 81 | False 82 | 83 | :param text: UUID string to test 84 | :param version: expected version for the UUID 85 | :return: `True` if `text` is a valid UUID, otherwise `False`. 86 | """ 87 | try: 88 | uuid.UUID(str(text), version=version) 89 | return True 90 | except ValueError: 91 | return False 92 | -------------------------------------------------------------------------------- /actual/exceptions.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def get_exception_from_response(response: requests.Response) -> Exception: 5 | text = response.content.decode() 6 | if text == "internal-error" or response.status_code == 500: 7 | return ActualError(text) 8 | # taken from 9 | # https://github.com/actualbudget/actual-server/blob/6e9eddeb561b0d9f2bbb6301c3e2c30b4effc522/src/app-sync.js#L107 10 | elif text == "file-has-new-key": 11 | return ActualError(f"{text}: The data is encrypted with a different key") 12 | elif text == "file-has-reset": 13 | return InvalidFile( 14 | f"{text}: The changes being synced are part of an old group, which means the file has been reset. " 15 | f"User needs to re-download." 16 | ) 17 | elif text in ("file-not-found", "file-needs-upload"): 18 | raise UnknownFileId(text) 19 | elif text == "file-old-version": 20 | raise InvalidFile(f"{text}: SYNC_FORMAT_VERSION was generated with an old format") 21 | 22 | 23 | class ActualError(Exception): 24 | """General error with Actual. The error message should provide more information.""" 25 | 26 | pass 27 | 28 | 29 | class ActualInvalidOperationError(ActualError): 30 | """Invalid operation requested. Happens usually when a request has been done, but it's missing a required 31 | parameters.""" 32 | 33 | pass 34 | 35 | 36 | class AuthorizationError(ActualError): 37 | """When the login fails due to invalid credentials, or a request has been done with the wrong credentials 38 | (i.e. invalid token)""" 39 | 40 | pass 41 | 42 | 43 | class UnknownFileId(ActualError): 44 | """When the file id that has been set does not exist on the server.""" 45 | 46 | pass 47 | 48 | 49 | class InvalidZipFile(ActualError): 50 | """ 51 | The validation fails when loading a zip file, either because it's an invalid zip file or the file is corrupted. 52 | """ 53 | 54 | pass 55 | 56 | 57 | class InvalidFile(ActualError): 58 | 59 | pass 60 | 61 | 62 | class ActualDecryptionError(ActualError): 63 | """ 64 | The decryption for the file failed. This can happen for a multitude or reasons, like the password is wrong, the file 65 | is corrupted, or when the password is not provided but the file is encrypted. 66 | """ 67 | 68 | pass 69 | 70 | 71 | class ActualSplitTransactionError(ActualError): 72 | """The split transaction is invalid, most likely because the sum of splits is not equal the full amount of the 73 | transaction.""" 74 | 75 | pass 76 | 77 | 78 | class ActualBankSyncError(ActualError): 79 | """The bank sync had an error, due to the service being unavailable or due to authentication issues with the 80 | third-party service. This likely indicates a problem with the configuration of the bank sync, not an issue with 81 | this library.""" 82 | 83 | def __init__(self, error_type: str, status: str = None, reason: str = None): 84 | self.error_type, self.status, self.reason = error_type, status, reason 85 | -------------------------------------------------------------------------------- /actual/migrations.py: -------------------------------------------------------------------------------- 1 | import re 2 | import uuid 3 | import warnings 4 | from typing import List 5 | 6 | 7 | def js_migration_statements(js_file: str) -> List[str]: 8 | queries = [] 9 | matches = re.finditer(r"db\.(execQuery|runQuery)", js_file) 10 | for match in matches: 11 | start_index, end_index = match.regs[0][1], match.regs[0][1] 12 | # we now loop and find the first occasion where all parenthesis closed 13 | parenthesis_count, can_return = 0, False 14 | for i in range(start_index, len(js_file)): 15 | if js_file[i] == "(": 16 | can_return = True 17 | parenthesis_count += 1 18 | elif js_file[i] == ")": 19 | parenthesis_count -= 1 20 | if parenthesis_count == 0 and can_return: 21 | end_index = i + 1 22 | break 23 | function_call = js_file[start_index:end_index] 24 | # extract the query 25 | next_tick = function_call.find("`") 26 | next_quote = function_call.find("'") 27 | string_character = "`" if next_tick > 0 and ((next_tick < next_quote) or next_quote < 0) else "'" 28 | search = re.search(rf"{string_character}(.*?){string_character}", function_call, re.DOTALL) 29 | if not search: 30 | continue 31 | query = search.group(1) 32 | # skip empty queries 33 | if not query: 34 | continue 35 | # skip select queries 36 | if query.lower().startswith("select"): 37 | continue 38 | # if there are unknowns in the query, skip 39 | if "?" in query: 40 | warnings.warn( 41 | f"Migration query from migrations cannot be executed due to custom code, it will be skipped. Query:\n\n" 42 | f"{query}\n" 43 | ) 44 | continue 45 | # if there is an uuid generation, use it 46 | while "${uuidv4()}" in query: 47 | query = query.replace("${uuidv4()}", str(uuid.uuid4()), 1) 48 | if not query.endswith(";"): 49 | query = query + ";" 50 | queries.append(query) 51 | return queries 52 | -------------------------------------------------------------------------------- /actual/protobuf_models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import datetime 5 | import uuid 6 | from typing import List 7 | 8 | import proto 9 | 10 | from actual.crypto import decrypt, encrypt 11 | from actual.exceptions import ActualDecryptionError 12 | 13 | """ 14 | Protobuf message definitions taken from the [sync.proto file]( 15 | https://github.com/actualbudget/actual/blob/029e2f09bf6caf386523bbfa944ab845271a3932/packages/crdt/src/proto/sync.proto). 16 | 17 | They should represent how the server take requests from the client. The server side implementation is available [here]( 18 | https://github.com/actualbudget/actual-server/blob/master/src/app-sync.js#L32). 19 | """ 20 | 21 | 22 | class HULC_Client: 23 | def __init__(self, client_id: str = None, initial_count: int = 0, ts: datetime.datetime = None): 24 | self.client_id = client_id or self.random_client_id() 25 | self.initial_count = initial_count 26 | self.ts = ts or datetime.datetime(1970, 1, 1, 0, 0, 0) 27 | 28 | @classmethod 29 | def from_timestamp(cls, ts: str) -> HULC_Client: 30 | ts_string, _, rest = ts.partition("Z") 31 | segments = rest.split("-") 32 | ts = datetime.datetime.fromisoformat(ts_string) 33 | return cls(segments[-1], int(segments[-2], 16), ts) 34 | 35 | def __str__(self): 36 | count = f"{self.initial_count:0>4X}" 37 | return f"{self.ts.isoformat(timespec='milliseconds')}Z-{count}-{self.client_id}" 38 | 39 | def timestamp(self, now: datetime.datetime = None) -> str: 40 | """Actual uses Hybrid Unique Logical Clock (HULC) timestamp generator. 41 | 42 | Timestamps serialize into a 46-character collatable string. Examples: 43 | 44 | - `2015-04-24T22:23:42.123Z-1000-0123456789ABCDEF` 45 | - `2015-04-24T22:23:42.123Z-1000-A219E7A71CC18912` 46 | 47 | See [original source code]( 48 | https://github.com/actualbudget/actual/blob/a9362cc6f9b974140a760ad05816cac51c849769/packages/crdt/src/crdt/timestamp.ts) 49 | for reference. 50 | """ 51 | if not now: 52 | now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) 53 | count = f"{self.initial_count:0>4X}" 54 | self.initial_count += 1 55 | return f"{now.isoformat(timespec='milliseconds')}Z-{count}-{self.client_id}" 56 | 57 | @staticmethod 58 | def random_client_id(): 59 | """Creates a client id for the HULC request. Implementation copied [from the source code]( 60 | https://github.com/actualbudget/actual/blob/a9362cc6f9b974140a760ad05816cac51c849769/packages/crdt/src/crdt/timestamp.ts#L80) 61 | """ 62 | return str(uuid.uuid4()).replace("-", "")[-16:] 63 | 64 | 65 | class EncryptedData(proto.Message): 66 | iv = proto.Field(proto.BYTES, number=1) 67 | authTag = proto.Field(proto.BYTES, number=2) 68 | data = proto.Field(proto.BYTES, number=3) 69 | 70 | 71 | class Message(proto.Message): 72 | dataset = proto.Field(proto.STRING, number=1) 73 | row = proto.Field(proto.STRING, number=2) 74 | column = proto.Field(proto.STRING, number=3) 75 | value = proto.Field(proto.STRING, number=4) 76 | 77 | def get_value(self) -> str | int | float | None: 78 | """Serialization types from Actual. [Original source code]( 79 | https://github.com/actualbudget/actual/blob/998efb9447da6f8ce97956cbe83d6e8a3c18cf53/packages/loot-core/src/server/sync/index.ts#L154-L160) 80 | """ 81 | datatype, _, value = self.value.partition(":") 82 | if datatype == "S": 83 | return value 84 | elif datatype == "N": 85 | return float(value) 86 | elif datatype == "0": 87 | return None 88 | else: 89 | raise ValueError(f"Conversion not supported for datatype '{datatype}'") 90 | 91 | def set_value(self, value: str | int | float | None) -> str: 92 | if isinstance(value, str): 93 | datatype = "S" 94 | elif isinstance(value, int) or isinstance(value, float): 95 | datatype = "N" 96 | elif value is None: 97 | datatype = "0" 98 | else: 99 | raise ValueError(f"Conversion not supported for datatype '{type(value)}'") 100 | self.value = f"{datatype}:{value}" 101 | return self.value 102 | 103 | 104 | class MessageEnvelope(proto.Message): 105 | timestamp = proto.Field(proto.STRING, number=1) 106 | isEncrypted = proto.Field(proto.BOOL, number=2) 107 | content = proto.Field(proto.BYTES, number=3) 108 | 109 | def set_timestamp(self, client_id: str = None, now: datetime.datetime = None) -> str: 110 | self.timestamp = HULC_Client(client_id).timestamp(now) 111 | return self.timestamp 112 | 113 | 114 | class SyncRequest(proto.Message): 115 | messages = proto.RepeatedField(MessageEnvelope, number=1) 116 | fileId = proto.Field(proto.STRING, number=2) 117 | groupId = proto.Field(proto.STRING, number=3) 118 | keyId = proto.Field(proto.STRING, number=5) 119 | since = proto.Field(proto.STRING, number=6) 120 | 121 | def set_timestamp(self, client_id: str = None, now: datetime.datetime = None) -> str: 122 | self.since = HULC_Client(client_id).timestamp(now) 123 | return self.since 124 | 125 | def set_null_timestamp(self, client_id: str = None) -> str: 126 | return self.set_timestamp(client_id, datetime.datetime(1970, 1, 1, 0, 0, 0, 0)) 127 | 128 | def set_messages(self, messages: List[Message], client: HULC_Client, master_key: bytes = None): 129 | if not self.messages: 130 | self.messages = [] 131 | for message in messages: 132 | content = Message.serialize(message) 133 | is_encrypted = False 134 | if master_key is not None: 135 | encrypted_content = encrypt("", master_key, content) 136 | encrypted_data = EncryptedData( 137 | { 138 | "iv": base64.b64decode(encrypted_content["meta"]["iv"]), 139 | "authTag": base64.b64decode(encrypted_content["meta"]["authTag"]), 140 | "data": base64.b64decode(encrypted_content["value"]), 141 | } 142 | ) 143 | content = EncryptedData.serialize(encrypted_data) 144 | is_encrypted = True 145 | m = MessageEnvelope({"content": content, "isEncrypted": is_encrypted}) 146 | m.timestamp = client.timestamp() 147 | self.messages.append(m) 148 | 149 | 150 | class SyncResponse(proto.Message): 151 | messages = proto.RepeatedField(MessageEnvelope, number=1) 152 | merkle = proto.Field(proto.STRING, number=2) 153 | 154 | def get_messages(self, master_key: bytes = None) -> List[Message]: 155 | messages = [] 156 | for message in self.messages: # noqa 157 | if message.isEncrypted: 158 | if not master_key: 159 | raise ActualDecryptionError("Master key not provided and data is encrypted.") 160 | encrypted = EncryptedData.deserialize(message.content) 161 | content = decrypt(master_key, encrypted.iv, encrypted.data, encrypted.authTag) 162 | else: 163 | content = message.content 164 | messages.append(Message.deserialize(content)) 165 | return messages 166 | -------------------------------------------------------------------------------- /actual/schedules.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import enum 3 | import typing 4 | 5 | import pydantic 6 | from dateutil.rrule import ( 7 | DAILY, 8 | MONTHLY, 9 | WEEKLY, 10 | YEARLY, 11 | rrule, 12 | rruleset, 13 | weekday, 14 | weekdays, 15 | ) 16 | 17 | 18 | def date_to_datetime(date: typing.Optional[datetime.date]) -> typing.Optional[datetime.datetime]: 19 | """Converts one object from date to datetime object. The reverse is possible directly by calling datetime.date().""" 20 | if date is None: 21 | return None 22 | return datetime.datetime.combine(date, datetime.time.min) 23 | 24 | 25 | def day_to_ordinal(day: int) -> str: 26 | """Converts an integer day to an ordinal number, i.e. 1 -> 1st, 32 -> 32nd""" 27 | if 11 <= (day % 100) <= 13: 28 | suffix = "th" 29 | else: 30 | suffix = ["th", "st", "nd", "rd", "th"][min(day % 10, 4)] 31 | return f"{day}{suffix}" 32 | 33 | 34 | class EndMode(enum.Enum): 35 | AFTER_N_OCCURRENCES = "after_n_occurrences" 36 | ON_DATE = "on_date" 37 | NEVER = "never" 38 | 39 | 40 | class Frequency(enum.Enum): 41 | DAILY = "daily" 42 | WEEKLY = "weekly" 43 | MONTHLY = "monthly" 44 | YEARLY = "yearly" 45 | 46 | def as_dateutil(self) -> int: 47 | frequency_map = {"YEARLY": YEARLY, "MONTHLY": MONTHLY, "WEEKLY": WEEKLY, "DAILY": DAILY} 48 | return frequency_map[self.name] 49 | 50 | 51 | class WeekendSolveMode(enum.Enum): 52 | BEFORE = "before" 53 | AFTER = "after" 54 | 55 | 56 | class PatternType(enum.Enum): 57 | SUNDAY = "SU" 58 | MONDAY = "MO" 59 | TUESDAY = "TU" 60 | WEDNESDAY = "WE" 61 | THURSDAY = "TH" 62 | FRIDAY = "FR" 63 | SATURDAY = "SA" 64 | DAY = "day" 65 | 66 | def as_dateutil(self) -> weekday: 67 | weekday_map = {str(w): w for w in weekdays} 68 | return weekday_map[self.value] 69 | 70 | 71 | class Pattern(pydantic.BaseModel): 72 | model_config = pydantic.ConfigDict(validate_assignment=True) 73 | 74 | value: int 75 | type: PatternType 76 | 77 | def __str__(self) -> str: 78 | if self.value == -1: 79 | qualifier = "last" 80 | else: 81 | qualifier = day_to_ordinal(self.value) 82 | type_str = "" 83 | if self.type != PatternType.DAY: 84 | type_str = f" {self.type.name.lower().capitalize()}" 85 | elif self.value == -1: 86 | type_str = " day" 87 | return f"{qualifier}{type_str}" 88 | 89 | 90 | class Schedule(pydantic.BaseModel): 91 | """ 92 | Implements basic schedules. They are described in https://actualbudget.org/docs/budgeting/schedules/ 93 | 94 | Schedules are part of a rule which then compares if the date found would fit within the schedule by the .is_approx() 95 | method. If it does, and the other conditions match, the transaction will then be linked with the schedule id 96 | (stored in the database). 97 | """ 98 | 99 | model_config = pydantic.ConfigDict(validate_assignment=True) 100 | 101 | start: datetime.date = pydantic.Field(..., description="Start date of the schedule.") 102 | interval: int = pydantic.Field(1, description="Repeat every interval at frequency unit.") 103 | frequency: Frequency = pydantic.Field(Frequency.MONTHLY, description="Unit for the defined interval.") 104 | patterns: typing.List[Pattern] = pydantic.Field(default_factory=list) 105 | skip_weekend: bool = pydantic.Field( 106 | False, alias="skipWeekend", description="If should move schedule before or after a weekend." 107 | ) 108 | weekend_solve_mode: WeekendSolveMode = pydantic.Field( 109 | WeekendSolveMode.AFTER, 110 | alias="weekendSolveMode", 111 | description="When skipping weekend, the value should be set before or after the weekend interval.", 112 | ) 113 | end_mode: EndMode = pydantic.Field( 114 | EndMode.NEVER, 115 | alias="endMode", 116 | description="If the schedule should run forever or end at a certain date or number of occurrences.", 117 | ) 118 | end_occurrences: int = pydantic.Field( 119 | 1, alias="endOccurrences", description="Number of occurrences before the schedule ends." 120 | ) 121 | end_date: datetime.date = pydantic.Field(None, alias="endDate") 122 | 123 | def __str__(self) -> str: 124 | # evaluate frequency: handle the case where DAILY convert to 'dai' instead of 'day' 125 | interval = "day" if self.frequency == Frequency.DAILY else self.frequency.value.rstrip("ly") 126 | frequency = interval if self.interval == 1 else f"{self.interval} {interval}s" 127 | # evaluate 128 | if self.frequency == Frequency.YEARLY: 129 | target = f" on {self.start.strftime('%b %d')}" 130 | elif self.frequency == Frequency.MONTHLY: 131 | if not self.patterns: 132 | target = f" on the {day_to_ordinal(self.start.day)}" 133 | else: 134 | patterns_str = [] 135 | for pattern in self.patterns: 136 | patterns_str.append(str(pattern)) 137 | target = " on the " + ", ".join(patterns_str) 138 | elif self.frequency == Frequency.WEEKLY: 139 | target = f" on {self.start.strftime('%A')}" 140 | else: # DAILY 141 | target = "" 142 | # end date part 143 | if self.end_mode == EndMode.ON_DATE: 144 | end = f", until {self.end_date}" 145 | elif self.end_mode == EndMode.AFTER_N_OCCURRENCES: 146 | end = ", once" if self.end_occurrences == 1 else f", {self.end_occurrences} times" 147 | else: 148 | end = "" 149 | # weekend skips 150 | move = f" ({self.weekend_solve_mode.value} weekend)" if self.skip_weekend else "" 151 | return f"Every {frequency}{target}{end}{move}" 152 | 153 | @pydantic.model_validator(mode="after") 154 | def validate_end_date(self): 155 | if self.end_mode == EndMode.ON_DATE and self.end_date is None: 156 | raise ValueError("endDate cannot be 'None' when ") 157 | if self.end_date is None: 158 | self.end_date = self.start 159 | return self 160 | 161 | def is_approx(self, date: datetime.date, interval: datetime.timedelta = datetime.timedelta(days=2)) -> bool: 162 | """This function checks if the input date could fit inside of this schedule. It will use the interval as the 163 | maximum threshold before and after the specified date to look for. This defaults on Actual to 2 days.""" 164 | if date < self.start or (self.end_mode == EndMode.ON_DATE and self.end_date < date): 165 | return False 166 | before = self.before(date) 167 | after = self.xafter(date, 1) 168 | if before and (before - interval <= date <= before + interval): 169 | return True 170 | if after and (after[0] - interval <= date <= after[0] + interval): 171 | return True 172 | return False 173 | 174 | def rruleset(self) -> rruleset: 175 | """Returns the rruleset from dateutil library. This is used internally to calculate the schedule dates. 176 | 177 | For information on how to use this object check the official documentation https://dateutil.readthedocs.io""" 178 | rule_sets_configs = [] 179 | config = dict(freq=self.frequency.as_dateutil(), dtstart=self.start, interval=self.interval) 180 | # add termination options 181 | if self.end_mode == EndMode.ON_DATE: 182 | config["until"] = self.end_date 183 | elif self.end_mode == EndMode.AFTER_N_OCCURRENCES: 184 | config["count"] = self.end_occurrences 185 | if self.frequency == Frequency.MONTHLY and self.patterns: 186 | by_month_day, by_weekday = [], [] 187 | for p in self.patterns: 188 | if p.type == PatternType.DAY: 189 | by_month_day.append(p.value) 190 | else: # it's a weekday 191 | by_weekday.append(p.type.as_dateutil()(p.value)) 192 | # for the month or weekday rules, add a different rrule to the ruleset. This is because otherwise the rule 193 | # would only look for, for example, days that are 15 that are also Fridays, and that is not desired 194 | if by_month_day: 195 | monthly_config = config.copy() 196 | monthly_config.update({"bymonthday": by_month_day}) 197 | rule_sets_configs.append(monthly_config) 198 | if by_weekday: 199 | weekly_config = config.copy() 200 | weekly_config.update({"byweekday": by_weekday}) 201 | rule_sets_configs.append(weekly_config) 202 | # if ruleset does not contain multiple rules, add the current rule as default 203 | if not rule_sets_configs: 204 | rule_sets_configs.append(config) 205 | # create rule set 206 | rs = rruleset(cache=True) 207 | for cfg in rule_sets_configs: 208 | rs.rrule(rrule(**cfg)) 209 | return rs 210 | 211 | def do_skip_weekend( 212 | self, dt_start: datetime.datetime, value: datetime.datetime 213 | ) -> typing.Optional[datetime.datetime]: 214 | if value.weekday() in (5, 6) and self.skip_weekend: 215 | if self.weekend_solve_mode == WeekendSolveMode.AFTER: 216 | value = value + datetime.timedelta(days=7 - value.weekday()) 217 | if self.end_mode == EndMode.ON_DATE and value > date_to_datetime(self.end_date): 218 | return None 219 | else: # BEFORE 220 | value_before = value - datetime.timedelta(days=value.weekday() - 4) 221 | if value_before < dt_start: 222 | # value is in the past, skip and look for another 223 | return None 224 | value = value_before 225 | return value 226 | 227 | def before(self, date: datetime.date = None) -> typing.Optional[datetime.date]: 228 | if not date: 229 | date = datetime.date.today() 230 | dt_start = date_to_datetime(date) 231 | # we also always use the day before since today can also be a valid entry for our time 232 | rs = self.rruleset() 233 | before_datetime = rs.before(dt_start) 234 | if not before_datetime: 235 | return None 236 | with_weekend_skip = self.do_skip_weekend(date_to_datetime(self.start), before_datetime) 237 | if not with_weekend_skip: 238 | return None 239 | return with_weekend_skip.date() 240 | 241 | def xafter(self, date: datetime.date = None, count: int = 1) -> typing.List[datetime.date]: 242 | if not date: 243 | date = datetime.date.today() 244 | # dateutils only accepts datetime for evaluation 245 | dt_start = datetime.datetime.combine(date, datetime.time.min) 246 | # we also always use the day before since today can also be a valid entry for our time 247 | rs = self.rruleset() 248 | 249 | ret = [] 250 | for value in rs.xafter(dt_start, count, inc=True): 251 | if value := self.do_skip_weekend(dt_start, value): 252 | # convert back to date 253 | ret.append(value.date()) 254 | if len(ret) == count: 255 | break 256 | return sorted(ret) 257 | -------------------------------------------------------------------------------- /actual/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bvanelli/actualpy/f6e223cba34bfa047c35bdf24500e77944b6cc9f/actual/utils/__init__.py -------------------------------------------------------------------------------- /actual/utils/conversions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import decimal 5 | from typing import Tuple 6 | 7 | 8 | def date_to_int(date: datetime.date, month_only: bool = False) -> int: 9 | """ 10 | Converts a date object to an integer representation. For example, the `date(2025, 3, 10)` gets converted to 11 | `20250310`. 12 | 13 | If `month_only` is set to `True`, the day will be removed from the date. For example, the same date above gets 14 | converted to `202503`. 15 | """ 16 | date_format = "%Y%m" if month_only else "%Y%m%d" 17 | return int(datetime.date.strftime(date, date_format)) 18 | 19 | 20 | def int_to_date(date: int | str, month_only: bool = False) -> datetime.date: 21 | """ 22 | Converts an `int` or `str` object to the `datetime.date` representation. For example, the int `20250310` 23 | gets converted to `date(2025, 3, 10)`. 24 | """ 25 | date_format = "%Y%m" if month_only else "%Y%m%d" 26 | return datetime.datetime.strptime(str(date), date_format).date() 27 | 28 | 29 | def month_range(month: datetime.date) -> Tuple[datetime.date, datetime.date]: 30 | """ 31 | Range of the provided `month` as a tuple [start, end). 32 | 33 | The end date is not inclusive, as it represents the start of the next month. 34 | """ 35 | range_start = month.replace(day=1) 36 | # conversion taken from https://stackoverflow.com/a/59199379/12681470 37 | range_end = (range_start + datetime.timedelta(days=32)).replace(day=1) 38 | return range_start, range_end 39 | 40 | 41 | def current_timestamp() -> int: 42 | """Returns the current timestamp in milliseconds, using UTC time.""" 43 | return int(datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None).timestamp() * 1000) 44 | 45 | 46 | def cents_to_decimal(amount: int) -> decimal.Decimal: 47 | """Converts the number of cents to a `decimal.Decimal` object. When providing `500`, the result will be 48 | `decimal.Decimal(5.0)`. 49 | """ 50 | return decimal.Decimal(amount) / decimal.Decimal(100) 51 | 52 | 53 | def decimal_to_cents(amount: decimal.Decimal | int | float) -> int: 54 | """Converts the decimal amount (`decimal.Decimal` or `int` or `float`) to an integer value. When providing 55 | `decimal.Decimal(5.0)`, the result will be `500`.""" 56 | return int(round(amount * 100)) 57 | -------------------------------------------------------------------------------- /actual/utils/storage.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import tempfile 3 | from typing import Union 4 | 5 | 6 | def get_base_tmp_folder() -> pathlib.Path: 7 | """Returns the temporary folder that the library should use to store temporary files if the user does not provide 8 | a folder path.""" 9 | base_tmp_dir = pathlib.Path(tempfile.gettempdir()) 10 | tmp_dir = base_tmp_dir / "actualpy" 11 | tmp_dir.mkdir(exist_ok=True) 12 | return tmp_dir 13 | 14 | 15 | def get_tmp_folder(file_id: Union[str, None]) -> pathlib.Path: 16 | """Returns a base folder to store the file based on the file id. Will create the folder if not existing.""" 17 | if not file_id: 18 | folder = pathlib.Path(tempfile.mkdtemp()) 19 | else: 20 | folder = get_base_tmp_folder() / str(file_id) 21 | folder.mkdir(exist_ok=True) 22 | return folder 23 | -------------------------------------------------------------------------------- /actual/utils/title.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List 3 | 4 | conjunctions = [ 5 | "for", 6 | "and", 7 | "nor", 8 | "but", 9 | "or", 10 | "yet", 11 | "so", 12 | ] 13 | 14 | articles = [ 15 | "a", 16 | "an", 17 | "the", 18 | ] 19 | 20 | prepositions = [ 21 | "aboard", 22 | "about", 23 | "above", 24 | "across", 25 | "after", 26 | "against", 27 | "along", 28 | "amid", 29 | "among", 30 | "anti", 31 | "around", 32 | "as", 33 | "at", 34 | "before", 35 | "behind", 36 | "below", 37 | "beneath", 38 | "beside", 39 | "besides", 40 | "between", 41 | "beyond", 42 | "but", 43 | "by", 44 | "concerning", 45 | "considering", 46 | "despite", 47 | "down", 48 | "during", 49 | "except", 50 | "excepting", 51 | "excluding", 52 | "following", 53 | "for", 54 | "from", 55 | "in", 56 | "inside", 57 | "into", 58 | "like", 59 | "minus", 60 | "near", 61 | "of", 62 | "off", 63 | "on", 64 | "onto", 65 | "opposite", 66 | "over", 67 | "past", 68 | "per", 69 | "plus", 70 | "regarding", 71 | "round", 72 | "save", 73 | "since", 74 | "than", 75 | "through", 76 | "to", 77 | "toward", 78 | "towards", 79 | "under", 80 | "underneath", 81 | "unlike", 82 | "until", 83 | "up", 84 | "upon", 85 | "versus", 86 | "via", 87 | "with", 88 | "within", 89 | "without", 90 | ] 91 | 92 | specials = [ 93 | "CLI", 94 | "API", 95 | "HTTP", 96 | "HTTPS", 97 | "JSX", 98 | "DNS", 99 | "URL", 100 | "CI", 101 | "CDN", 102 | "GitHub", 103 | "CSS", 104 | "JS", 105 | "JavaScript", 106 | "TypeScript", 107 | "HTML", 108 | "WordPress", 109 | "JavaScript", 110 | "Next.js", 111 | "Node.js", 112 | ] 113 | 114 | lower_case_set = set(conjunctions + articles + prepositions) 115 | 116 | # I have no idea how/why someone came up with this, and at this point I'm too afraid to ask. 117 | # https://github.com/actualbudget/actual/blob/f02ca4e3d26f5b91f4234317e024022fcae2c13c/packages/loot-core/src/server/accounts/title/index.ts#L7 118 | character = ( 119 | "[0-9\u0041-\u005a\u0061-\u007a\u00aa\u00b5\u00ba\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u02c1\u02c6-\u02d1\u02e0" 120 | "-\u02e4\u02ec\u02ee\u0370-\u0374\u0376-\u0377\u037a-\u037d\u0386\u0388-\u038a\u038c\u038e-\u03a1\u03a3" 121 | "-\u03f5\u03f7-\u0481\u048a-\u0523\u0531-\u0556\u0559\u0561-\u0587\u05d0-\u05ea\u05f0-\u05f2\u0621-\u064a\u066e" 122 | "-\u066f\u0671-\u06d3\u06d5\u06e5-\u06e6\u06ee-\u06ef\u06fa-\u06fc\u06ff\u0710\u0712-\u072f\u074d" 123 | "-\u07a5\u07b1\u07ca-\u07ea\u07f4-\u07f5\u07fa\u0904-\u0939\u093d\u0950\u0958-\u0961\u0971-\u0972\u097b" 124 | "-\u097f\u0985-\u098c\u098f-\u0990\u0993-\u09a8\u09aa-\u09b0\u09b2\u09b6-\u09b9\u09bd\u09ce\u09dc-\u09dd\u09df" 125 | "-\u09e1\u09f0-\u09f1\u0a05-\u0a0a\u0a0f-\u0a10\u0a13-\u0a28\u0a2a-\u0a30\u0a32-\u0a33\u0a35-\u0a36\u0a38" 126 | "-\u0a39\u0a59-\u0a5c\u0a5e\u0a72-\u0a74\u0a85-\u0a8d\u0a8f-\u0a91\u0a93-\u0aa8\u0aaa-\u0ab0\u0ab2-\u0ab3\u0ab5" 127 | "-\u0ab9\u0abd\u0ad0\u0ae0-\u0ae1\u0b05-\u0b0c\u0b0f-\u0b10\u0b13-\u0b28\u0b2a-\u0b30\u0b32-\u0b33\u0b35" 128 | "-\u0b39\u0b3d\u0b5c-\u0b5d\u0b5f-\u0b61\u0b71\u0b83\u0b85-\u0b8a\u0b8e-\u0b90\u0b92-\u0b95\u0b99" 129 | "-\u0b9a\u0b9c\u0b9e-\u0b9f\u0ba3-\u0ba4\u0ba8-\u0baa\u0bae-\u0bb9\u0bd0\u0c05-\u0c0c\u0c0e-\u0c10\u0c12" 130 | "-\u0c28\u0c2a-\u0c33\u0c35-\u0c39\u0c3d\u0c58-\u0c59\u0c60-\u0c61\u0c85-\u0c8c\u0c8e-\u0c90\u0c92-\u0ca8\u0caa" 131 | "-\u0cb3\u0cb5-\u0cb9\u0cbd\u0cde\u0ce0-\u0ce1\u0d05-\u0d0c\u0d0e-\u0d10\u0d12-\u0d28\u0d2a-\u0d39\u0d3d\u0d60" 132 | "-\u0d61\u0d7a-\u0d7f\u0d85-\u0d96\u0d9a-\u0db1\u0db3-\u0dbb\u0dbd\u0dc0-\u0dc6\u0e01-\u0e30\u0e32-\u0e33\u0e40" 133 | "-\u0e46\u0e81-\u0e82\u0e84\u0e87-\u0e88\u0e8a\u0e8d\u0e94-\u0e97\u0e99-\u0e9f\u0ea1-\u0ea3\u0ea5\u0ea7\u0eaa" 134 | "-\u0eab\u0ead-\u0eb0\u0eb2-\u0eb3\u0ebd\u0ec0-\u0ec4\u0ec6\u0edc-\u0edd\u0f00\u0f40-\u0f47\u0f49-\u0f6c\u0f88" 135 | "-\u0f8b\u1000-\u102a\u103f\u1050-\u1055\u105a-\u105d\u1061\u1065-\u1066\u106e-\u1070\u1075-\u1081\u108e\u10a0" 136 | "-\u10c5\u10d0-\u10fa\u10fc\u1100-\u1159\u115f-\u11a2\u11a8-\u11f9\u1200-\u1248\u124a-\u124d\u1250" 137 | "-\u1256\u1258\u125a-\u125d\u1260-\u1288\u128a-\u128d\u1290-\u12b0\u12b2-\u12b5\u12b8-\u12be\u12c0\u12c2" 138 | "-\u12c5\u12c8-\u12d6\u12d8-\u1310\u1312-\u1315\u1318-\u135a\u1380-\u138f\u13a0-\u13f4\u1401-\u166c\u166f" 139 | "-\u1676\u1681-\u169a\u16a0-\u16ea\u16ee-\u16f0\u1700-\u170c\u170e-\u1711\u1720-\u1731\u1740-\u1751\u1760" 140 | "-\u176c\u176e-\u1770\u1780-\u17b3\u17d7\u17dc\u1820-\u1877\u1880-\u18a8\u18aa\u1900-\u191c\u1950-\u196d\u1970" 141 | "-\u1974\u1980-\u19a9\u19c1-\u19c7\u1a00-\u1a16\u1b05-\u1b33\u1b45-\u1b4b\u1b83-\u1ba0\u1bae-\u1baf\u1c00" 142 | "-\u1c23\u1c4d-\u1c4f\u1c5a-\u1c7d\u1d00-\u1dbf\u1e00-\u1f15\u1f18-\u1f1d\u1f20-\u1f45\u1f48-\u1f4d\u1f50" 143 | "-\u1f57\u1f59\u1f5b\u1f5d\u1f5f-\u1f7d\u1f80-\u1fb4\u1fb6-\u1fbc\u1fbe\u1fc2-\u1fc4\u1fc6-\u1fcc\u1fd0" 144 | "-\u1fd3\u1fd6-\u1fdb\u1fe0-\u1fec\u1ff2-\u1ff4\u1ff6-\u1ffc\u2071\u207f\u2090-\u2094\u2102\u2107\u210a" 145 | "-\u2113\u2115\u2119-\u211d\u2124\u2126\u2128\u212a-\u212d\u212f-\u2139\u213c-\u213f\u2145-\u2149\u214e\u2160" 146 | "-\u2188\u2c00-\u2c2e\u2c30-\u2c5e\u2c60-\u2c6f\u2c71-\u2c7d\u2c80-\u2ce4\u2d00-\u2d25\u2d30-\u2d65\u2d6f\u2d80" 147 | "-\u2d96\u2da0-\u2da6\u2da8-\u2dae\u2db0-\u2db6\u2db8-\u2dbe\u2dc0-\u2dc6\u2dc8-\u2dce\u2dd0-\u2dd6\u2dd8" 148 | "-\u2dde\u2e2f\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303c\u3041-\u3096\u309d-\u309f\u30a1-\u30fa\u30fc" 149 | "-\u30ff\u3105-\u312d\u3131-\u318e\u31a0-\u31b7\u31f0-\u31ff\u3400\u4db5\u4e00\u9fc3\ua000-\ua48c\ua500" 150 | "-\ua60c\ua610-\ua61f\ua62a-\ua62b\ua640-\ua65f\ua662-\ua66e\ua67f-\ua697\ua717-\ua71f\ua722-\ua788\ua78b" 151 | "-\ua78c\ua7fb-\ua801\ua803-\ua805\ua807-\ua80a\ua80c-\ua822\ua840-\ua873\ua882-\ua8b3\ua90a-\ua925\ua930" 152 | "-\ua946\uaa00-\uaa28\uaa40-\uaa42\uaa44-\uaa4b\uac00\ud7a3\uf900-\ufa2d\ufa30-\ufa6a\ufa70-\ufad9\ufb00" 153 | "-\ufb06\ufb13-\ufb17\ufb1d\ufb1f-\ufb28\ufb2a-\ufb36\ufb38-\ufb3c\ufb3e\ufb40-\ufb41\ufb43-\ufb44\ufb46" 154 | "-\ufbb1\ufbd3-\ufd3d\ufd50-\ufd8f\ufd92-\ufdc7\ufdf0-\ufdfb\ufe70-\ufe74\ufe76-\ufefc\uff21-\uff3a\uff41" 155 | "-\uff5a\uff66-\uffbe\uffc2-\uffc7\uffca-\uffcf\uffd2-\uffd7\uffda-\uffdc]" 156 | ) 157 | 158 | regex = re.compile( 159 | rf'(?:(?:(\s?(?:^|[.\(\)!?;:"-])\s*)({character}))|({character}))({character}*[’\']*{character}*)', re.UNICODE 160 | ) 161 | 162 | 163 | def convert_to_regexp(special_characters: List[str]): 164 | return [(re.compile(rf"\b{s}\b", re.IGNORECASE), s) for s in special_characters] 165 | 166 | 167 | def parse_match(match: str): 168 | first_character = match[0] 169 | if re.match(r"\s", first_character): 170 | return match[1:] 171 | if re.match(r"[()]", first_character): 172 | return None 173 | return match 174 | 175 | 176 | def replace_func(m: re.Match): 177 | lead, forced, lower, rest = m.groups() 178 | parsed_match = parse_match(m.group(0)) 179 | if not parsed_match: 180 | return m.group(0) 181 | if not forced: 182 | full_lower = (lower or "") + (rest or "") 183 | if full_lower in lower_case_set: 184 | return parsed_match 185 | return (lead or "") + (lower or forced or "").upper() + (rest or "") 186 | 187 | 188 | def title(title_str: str, custom_specials: List[str] = None): 189 | title_str = title_str.lower() 190 | title_str = regex.sub(replace_func, title_str) 191 | 192 | if not custom_specials: 193 | custom_specials = [] 194 | replace = specials + custom_specials 195 | replace_regexp = convert_to_regexp(replace) 196 | 197 | for pattern, s in replace_regexp: 198 | title_str = pattern.sub(s, title_str) 199 | 200 | return title_str 201 | -------------------------------------------------------------------------------- /actual/version.py: -------------------------------------------------------------------------------- 1 | __version_info__ = ("0", "13", "0") 2 | __version__ = ".".join(__version_info__) 3 | -------------------------------------------------------------------------------- /codecov.yaml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | threshold: 5% 6 | -------------------------------------------------------------------------------- /docker/compose.yaml: -------------------------------------------------------------------------------- 1 | # Use this script to initialize a local actual server. It serves as the target to test basic functionality of the API. 2 | services: 3 | actual: 4 | container_name: actual 5 | image: docker.io/actualbudget/actual-server:25.4.0 6 | ports: 7 | - '5006:5006' 8 | volumes: 9 | - ./actual-data:/data 10 | restart: unless-stopped 11 | -------------------------------------------------------------------------------- /docs/API-reference/actual.md: -------------------------------------------------------------------------------- 1 | # Actual 2 | 3 | ::: actual.Actual 4 | options: 5 | inherited_members: false 6 | -------------------------------------------------------------------------------- /docs/API-reference/endpoints.md: -------------------------------------------------------------------------------- 1 | # Endpoints 2 | 3 | ::: actual.api 4 | ::: actual.api.models 5 | ::: actual.api.bank_sync 6 | ::: actual.protobuf_models 7 | -------------------------------------------------------------------------------- /docs/API-reference/exceptions.md: -------------------------------------------------------------------------------- 1 | # Exceptions 2 | 3 | ::: actual.exceptions 4 | options: 5 | members: true 6 | -------------------------------------------------------------------------------- /docs/API-reference/models.md: -------------------------------------------------------------------------------- 1 | # Database models 2 | 3 | ::: actual.database 4 | options: 5 | inherited_members: false 6 | -------------------------------------------------------------------------------- /docs/API-reference/queries.md: -------------------------------------------------------------------------------- 1 | # Queries 2 | 3 | ::: actual.queries 4 | -------------------------------------------------------------------------------- /docs/API-reference/rules.md: -------------------------------------------------------------------------------- 1 | # Rules 2 | 3 | ::: actual.rules.RuleSet 4 | options: 5 | members: true 6 | ::: actual.rules.Rule 7 | options: 8 | members: true 9 | ::: actual.rules.Condition 10 | options: 11 | members: true 12 | ::: actual.rules.Action 13 | options: 14 | members: true 15 | ::: actual.rules.ValueType 16 | options: 17 | members: true 18 | ::: actual.rules.ActionType 19 | options: 20 | members: true 21 | ::: actual.rules.ConditionType 22 | options: 23 | members: true 24 | -------------------------------------------------------------------------------- /docs/FAQ.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## Can the added transactions have the bold effect of new transactions similar to the frontend? 4 | 5 | No, unfortunately this effect, that appears when you import transactions via CSV, is only stored in memory and applied 6 | via frontend. This means that the 7 | 8 | If you want to import transactions to later confirm them by hand, you can do it using the cleared flag instead. 9 | This would show the green box on the right side of the screen on/off. 10 | 11 | Read more about the cleared flag [here](https://actualbudget.org/docs/accounts/reconciliation/#work-flow). 12 | -------------------------------------------------------------------------------- /docs/command-line-interface.md: -------------------------------------------------------------------------------- 1 | # Command line interface 2 | 3 | You can try out `actualpy` directly without the need if writing a custom script. All you need to do is install the 4 | command line interface with: 5 | 6 | ```bash 7 | pip install "actualpy[cli]" 8 | ``` 9 | 10 | You should then be able to generate exports directly: 11 | 12 | ```console 13 | $ actualpy init 14 | Please enter the URL of the actual server [http://localhost:5006]: 15 | Please enter the Actual server password: 16 | (1) Test 17 | Please enter the budget index: 1 18 | Name of the context for this budget [test]: 19 | Initialized budget 'test' 20 | $ actualpy export 21 | Exported budget 'Test' (budget id 'My-Finances-0b46239') to '2024-10-04-1438-Test.zip'. 22 | ``` 23 | 24 | The configuration will be saved on the folder `.actualpy/config.yaml`. Check full help for more details: 25 | 26 | ```console 27 | $ actualpy --help 28 | 29 | Usage: actualpy [OPTIONS] COMMAND [ARGS]... 30 | 31 | ╭─ Options ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ 32 | │ --output -o [table|json] Output format: table or json [default: table] │ 33 | │ --install-completion Install completion for the current shell. │ 34 | │ --show-completion Show completion for the current shell, to copy it or customize the installation. │ 35 | │ --help Show this message and exit. │ 36 | ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ 37 | ╭─ Commands ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ 38 | │ accounts Show all accounts. │ 39 | │ export Generates an export from the budget (for CLI backups). │ 40 | │ init Initializes an actual budget config interactively if options are not provided. │ 41 | │ metadata Displays all metadata for the current budget. │ 42 | │ payees Show all payees. │ 43 | │ remove-context Removes a configured context from the configuration. │ 44 | │ transactions Show all transactions. │ 45 | │ use-context Sets the default context for the CLI. │ 46 | │ version Shows the library and server version. │ 47 | ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/experimental-features.md: -------------------------------------------------------------------------------- 1 | # Experimental features 2 | 3 | !!! danger 4 | Experimental features do not have all the testing necessary to ensure correctness in comparison to the 5 | files generated by the Javascript API. This means that this operations could in theory corrupt the data. Make sure 6 | you have backups of your data before trying any of those operations. 7 | 8 | ## Bootstraping a new server and uploading a first file 9 | 10 | The following script would generate a new empty budget on the Actual server, even if the server was not bootstrapped 11 | with an initial password. 12 | 13 | ```python 14 | from actual import Actual 15 | 16 | with Actual(base_url="http://localhost:5006", password="mypass", bootstrap=True) as actual: 17 | actual.create_budget("My budget") 18 | actual.upload_budget() 19 | ``` 20 | 21 | You will then have a freshly created new budget to use: 22 | 23 | ![created-budget](./static/new-budget.png?raw=true) 24 | 25 | If the `encryption_password` is set, the budget will additionally also be encrypted on the upload step to the server. 26 | 27 | ## Updating transactions using Bank Sync 28 | 29 | If you have either [goCardless](https://actualbudget.org/docs/advanced/bank-sync/#gocardless-setup) or 30 | [simplefin](https://actualbudget.org/docs/experimental/simplefin-sync/) integration configured, it is possible to 31 | update the transactions using just the Python API alone. This is because the actual queries to the third-party service 32 | are handled on the server, so the client does not have to do any custom API queries. 33 | 34 | To sync your account, simply call the `run_bank_sync` method: 35 | 36 | ```python 37 | from actual import Actual 38 | 39 | with Actual(base_url="http://localhost:5006", password="mypass") as actual: 40 | synchronized_transactions = actual.run_bank_sync() 41 | for transaction in synchronized_transactions: 42 | print(f"Added of modified {transaction}") 43 | # sync changes back to the server 44 | actual.commit() 45 | 46 | ``` 47 | 48 | ## Running rules 49 | 50 | Rules can be rune individually via the library. You can filter which rules are going to be run, but also check 51 | beforehand which rules actually are going to run, similar to the preview function from Actual. 52 | 53 | The most simple case is to run all rules for all transactions at once. This is equivalent as "Apply Actions" for all 54 | rules on frontend: 55 | 56 | ```python 57 | from actual import Actual 58 | from actual.queries import get_ruleset 59 | 60 | with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual: 61 | # print all rules and their human-readable descriptions 62 | print(get_ruleset(actual.session)) 63 | # run all rules 64 | actual.run_rules() 65 | # sync changes back to the server 66 | actual.commit() 67 | ``` 68 | 69 | If you are running bank sync, you can also run rules directly on the imported transactions after doing the sync: 70 | 71 | ```python 72 | from actual import Actual 73 | 74 | with Actual(base_url="http://localhost:5006", password="mypass") as actual: 75 | synchronized_transactions = actual.run_bank_sync(run_rules=True) 76 | ``` 77 | 78 | You can also manipulate the rules individually, and validate each rule that runs for each transaction, allowing you 79 | to also debug rules. This can be useful when more than one rule is modifying the same transaction, but the order of 80 | operations is not correct: 81 | 82 | ```python 83 | from actual import Actual 84 | from actual.queries import get_ruleset, get_transactions 85 | 86 | with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual: 87 | ruleset = get_ruleset(actual.session) 88 | transactions = get_transactions(actual.session) 89 | for rule in ruleset: 90 | for t in transactions: 91 | if rule.evaluate(t): 92 | print(f"Rule {rule} matches for {t}") 93 | # if you are happy with the result from the rule, apply it 94 | rule.run(t) 95 | # if you want to sync the changes back to the server, uncomment the following line 96 | # actual.commit() 97 | ``` 98 | 99 | If you are importing transactions, the rules are not running automatically. For that reason, you might need to run them 100 | individually. Use the [RuleSet.run][actual.rules.RuleSet.run] for that purpose, to run the rule after creating the 101 | transaction: 102 | 103 | ```python 104 | from actual import Actual 105 | from actual.queries import get_ruleset, reconcile_transaction 106 | from datetime import date 107 | 108 | with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual: 109 | ruleset = get_ruleset(actual.session) 110 | # we create one transaction 111 | t = reconcile_transaction(actual.session, date.today(), "Bank", "", notes="Coffee", amount=-4.50) 112 | # run the rules on the newly created transaction 113 | ruleset.run(t) 114 | # send the changes back to the server 115 | actual.commit() 116 | ``` 117 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Quickstart 2 | 3 | ## Using relationships and properties 4 | 5 | The SQLAlchemy model already contains relationships to the referenced foreign keys and some properties. For example, 6 | it's pretty simple to get the current balances for both accounts, payees and budgets: 7 | 8 | ```python 9 | from actual import Actual 10 | from actual.queries import get_accounts, get_payees, get_budgets 11 | 12 | with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual: 13 | # Print each account balance, for the entire dataset 14 | for account in get_accounts(actual.session): 15 | print(f"Balance for account {account.name} is {account.balance}") 16 | # Print each payee balance, for the entire dataset 17 | for payee in get_payees(actual.session): 18 | print(f"Balance for payee {payee.name} is {payee.balance}") 19 | # Print the leftover budget balance, for each category and the current month 20 | for budget in get_budgets(actual.session): 21 | print(f"Balance for budget {budget.category.name} is {budget.balance}") 22 | ``` 23 | 24 | You can quickly iterate over the transactions of one specific account: 25 | 26 | ```python 27 | from actual import Actual 28 | from actual.queries import get_account 29 | 30 | with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual: 31 | account = get_account(actual.session, "Bank name") 32 | for transaction in account.transactions: 33 | # Get the payee, notes and amount of each transaction 34 | print(f"Transaction ({transaction.payee.name}, {transaction.notes}) has a value of {transaction.get_amount()}") 35 | ``` 36 | 37 | ## Adding new transactions 38 | 39 | After you created your first budget (or when updating an existing budget), you can add new transactions by adding them 40 | using the [`create_transaction`][actual.queries.create_transaction] method, and commit it using 41 | [`actual.commit`][actual.Actual.commit]. You cannot use the SQLAlchemy session directly because that adds the entries 42 | to your local database, but will not sync the results back to the server (that is only possible when re-uploading the 43 | file). 44 | 45 | The method will make sure the local database is updated, but will also send a SYNC request with the added data so that 46 | it will be immediately available on the frontend: 47 | 48 | ```python 49 | import decimal 50 | import datetime 51 | from actual import Actual 52 | from actual.queries import create_transaction, create_account 53 | 54 | with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual: 55 | act = create_account(actual.session, "My account") 56 | t = create_transaction( 57 | actual.session, 58 | datetime.date.today(), 59 | act, 60 | "My payee", 61 | notes="My first transaction", 62 | amount=decimal.Decimal(-10.5), 63 | ) 64 | actual.commit() # use the actual.commit() instead of session.commit()! 65 | ``` 66 | 67 | Will produce: 68 | 69 | ![added-transaction](./static/added-transaction.png?raw=true) 70 | 71 | ## Updating existing transactions 72 | 73 | You may also update transactions using the SQLModel directly, you just need to make sure to commit the results at the 74 | end: 75 | 76 | ```python 77 | from actual import Actual 78 | from actual.queries import get_transactions 79 | 80 | 81 | with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual: 82 | for transaction in get_transactions(actual.session): 83 | # change the transactions notes 84 | if transaction.notes is not None and "my pattern" in transaction.notes: 85 | transaction.notes = transaction.notes + " my suffix!" 86 | # commit your changes! 87 | actual.commit() 88 | 89 | ``` 90 | 91 | When working with transactions, is importing to keep in mind that the value amounts are set with floating number, 92 | but the value stored on the database will be an integer (number of cents) instead. So instead of updating a 93 | transaction with [Transactions.amount][actual.database.Transactions], use the 94 | [Transactions.set_amount][actual.database.Transactions.set_amount] instead. 95 | 96 | !!! warning 97 | You can also modify the relationships, for example the `transaction.payee.name`, but you to be aware that 98 | this payee might be used for more than one transaction. Whenever the relationship is anything but 1:1, you have to 99 | track the changes already done to prevent modifying a field twice. 100 | 101 | ## Generating backups 102 | 103 | You can use actualpy to generate regular backups of your server files. Here is a script that will backup your server 104 | file on the current folder: 105 | 106 | ```python 107 | from actual import Actual 108 | from datetime import datetime 109 | 110 | with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual: 111 | current_date = datetime.now().strftime("%Y%m%d-%H%M") 112 | actual.export_data(f"actual_backup_{current_date}.zip") 113 | ``` 114 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.6.1 2 | mkdocs-material==9.5.49 3 | mkdocs-material-extensions==1.3.1 4 | mkdocstrings==0.26.1 5 | mkdocstrings-python==1.13.0 6 | griffe==1.5.5 7 | griffe-fieldz==0.2.1 8 | black==25.1.0 9 | -------------------------------------------------------------------------------- /docs/static/added-transaction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bvanelli/actualpy/f6e223cba34bfa047c35bdf24500e77944b6cc9f/docs/static/added-transaction.png -------------------------------------------------------------------------------- /docs/static/gnucash-import-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bvanelli/actualpy/f6e223cba34bfa047c35bdf24500e77944b6cc9f/docs/static/gnucash-import-screenshot.png -------------------------------------------------------------------------------- /docs/static/gnucash-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bvanelli/actualpy/f6e223cba34bfa047c35bdf24500e77944b6cc9f/docs/static/gnucash-screenshot.png -------------------------------------------------------------------------------- /docs/static/new-budget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bvanelli/actualpy/f6e223cba34bfa047c35bdf24500e77944b6cc9f/docs/static/new-budget.png -------------------------------------------------------------------------------- /examples/csv/README.md: -------------------------------------------------------------------------------- 1 | This example shows how to import a CSV file directly into Actual without having to use the UI. 2 | 3 | It also reconciles the transactions, to make sure that a transaction cannot be inserted twice into the database. 4 | 5 | The file under `files/transactions.csv` is a direct export from Actual and contains the following fields: 6 | 7 | - Account 8 | - Date 9 | - Payee 10 | - Notes 11 | - Category 12 | - Amount 13 | - Cleared 14 | -------------------------------------------------------------------------------- /examples/csv/files/transactions.csv: -------------------------------------------------------------------------------- 1 | Account,Date,Payee,Notes,Category,Amount,Cleared 2 | Current Checking Account,2024-04-01,,Paying rent,Rent,-250,Not cleared 3 | Current Savings Account,2024-01-31,Current Checking Account,Saving money,,200,Cleared 4 | Current Checking Account,2024-01-31,Current Savings Account,Saving money,,-200,Not cleared 5 | Current Checking Account,2024-01-31,,Streaming services,Online Services,-15,Not cleared 6 | Current Checking Account,2024-01-30,,Groceries,Groceries,-15,Not cleared 7 | Current Checking Account,2024-01-26,,New pants,Clothes,-40,Not cleared 8 | Current Checking Account,2024-01-26,,Groceries,Groceries,-25,Not cleared 9 | Current Checking Account,2024-01-19,,Groceries,Groceries,-25,Not cleared 10 | Current Checking Account,2024-01-18,,University book,Books,-30,Not cleared 11 | Current Checking Account,2024-01-16,,Phone contract,Phone,-15,Not cleared 12 | Current Checking Account,2024-01-13,,Cinema tickets,Entertainment:Music/Movies,-10,Not cleared 13 | Current Checking Account,2024-01-12,,Groceries,Groceries,-25,Not cleared 14 | Current Cash in Wallet,2024-01-06,,Couple of beers at a bar,Entertainment:Recreation,-25,Not cleared 15 | Current Cash in Wallet,2024-01-06,Current Checking Account,Cash withdraw,,50,Not cleared 16 | Current Checking Account,2024-01-06,Current Cash in Wallet,Cash withdraw,,-50,Not cleared 17 | Current Checking Account,2024-01-05,,Groceries,Groceries,-25,Not cleared 18 | Current Checking Account,2024-01-01,,Mobility Bonus,Other Income,100,Not cleared 19 | Current Checking Account,2024-01-01,,Salary Payment,Salary,700,Not cleared 20 | -------------------------------------------------------------------------------- /examples/csv/import.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import datetime 3 | import decimal 4 | import pathlib 5 | 6 | from actual import Actual 7 | from actual.exceptions import UnknownFileId 8 | from actual.queries import get_or_create_account, reconcile_transaction 9 | 10 | 11 | def load_csv_data(file: pathlib.Path) -> list[dict]: 12 | # load data from the csv 13 | data = [] 14 | with open(file) as csvfile: 15 | for entry in csv.DictReader(csvfile): 16 | entry: dict 17 | data.append(entry) 18 | return data 19 | 20 | 21 | def main(): 22 | file = pathlib.Path(__file__).parent / "files/transactions.csv" 23 | with Actual(password="mypass") as actual: 24 | try: 25 | actual.set_file("CSV Import") 26 | actual.download_budget() 27 | except UnknownFileId: 28 | actual.create_budget("CSV Import") 29 | actual.upload_budget() 30 | # now try to do all the changes 31 | added_transactions = [] 32 | for row in load_csv_data(file): 33 | # here, we define the basic information from the file 34 | account_name, payee, notes, category, cleared, date, amount = ( 35 | row["Account"], 36 | row["Payee"], 37 | row["Notes"], 38 | row["Category"], 39 | row["Cleared"] == "Cleared", # transform to boolean 40 | datetime.datetime.strptime(row["Date"], "%Y-%m-%d").date(), # transform to date 41 | decimal.Decimal(row["Amount"]), # transform to decimal (float is also possible) 42 | ) 43 | # we then create the required account, with empty starting balances, if it does not exist 44 | # this is required because the transaction methods will refuse to auto-create accounts 45 | account = get_or_create_account(actual.session, account_name) 46 | # reconcile transaction. Here, it is important to pass the transactions added so far because the importer 47 | # might overwrite transactions that look very similar (same value, same date) due to them being flagged as 48 | # duplicates 49 | t = reconcile_transaction( 50 | actual.session, 51 | date, 52 | account, 53 | payee, 54 | notes, 55 | category, 56 | amount, 57 | cleared=cleared, 58 | already_matched=added_transactions, 59 | ) 60 | added_transactions.append(t) 61 | if t.changed(): 62 | print(f"Added or modified {t}") 63 | # finally, the commit will push the changes to the server 64 | actual.commit() 65 | 66 | 67 | if __name__ == "__main__": 68 | main() 69 | -------------------------------------------------------------------------------- /examples/gnucash/README.md: -------------------------------------------------------------------------------- 1 | This example shows how to import a Gnucash file using entirely the Python API. It will create the budget from scratch 2 | using the remote migrations, import all transactions and the upload the new budget to the server. 3 | 4 | The API, however, only uploads the budget in full, so the partial syncs are not done. If you want to do a partial 5 | update, check the [CSV import example](../csv). 6 | 7 | This script requires a custom version of [Piecash](https://github.com/sdementen/piecash) to run that support SQLAlchemy 8 | 1.4 and beyond, since it's a requirement for SQLModel. Getting the dependencies to work together can be a little bit 9 | tricky, until Piecash finally supports SQLAlchemy>2. 10 | 11 | For the script to work, the Gnucash must be saved first as a sqlite database. This can be done by first opening the 12 | budget, and then using "Save as" and picking the sqlite format. 13 | 14 | This example folder already provides one example file to test the script with. Here is a preview from the Gnucash 15 | interface: 16 | 17 | image 18 | 19 | And the result afterward being imported: 20 | 21 | ![Actual Gnucash Import Screenshot](../../docs/static/gnucash-import-screenshot.png) 22 | -------------------------------------------------------------------------------- /examples/gnucash/files/example.gnucash: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bvanelli/actualpy/f6e223cba34bfa047c35bdf24500e77944b6cc9f/examples/gnucash/files/example.gnucash -------------------------------------------------------------------------------- /examples/gnucash/import.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import decimal 3 | import pathlib 4 | 5 | import piecash 6 | 7 | from actual import Actual 8 | from actual.queries import ( 9 | create_transaction, 10 | create_transfer, 11 | get_or_create_account, 12 | get_or_create_category, 13 | get_or_create_payee, 14 | ) 15 | 16 | 17 | def insert_transaction( 18 | session, account_source: str, expense_source: str, notes: str, date: datetime.date, value: decimal.Decimal 19 | ): 20 | # do inserts, if it's an expense or transfer 21 | payee = get_or_create_payee(session, "") # payee is non-existing on gnucash 22 | if account_source.startswith("Assets:") and expense_source.startswith("Expenses:"): 23 | account = get_or_create_account(session, account_source.replace("Assets:", "")) 24 | group_name, _, category_name = expense_source.partition(":") 25 | category = get_or_create_category(session, category_name, group_name) 26 | create_transaction(session, date, account, payee, notes, category, -value) 27 | elif account_source.startswith("Income:") and expense_source.startswith("Assets:"): 28 | expense = get_or_create_account(session, expense_source.replace("Assets:", "")) 29 | group_name, _, category_name = account_source.partition(":") 30 | category = get_or_create_category(session, category_name, group_name) 31 | create_transaction(session, date, expense, payee, notes, category, value) 32 | elif account_source.startswith("Assets:") and expense_source.startswith("Assets:"): 33 | account = get_or_create_account(session, account_source.replace("Assets:", "")) 34 | expense = get_or_create_account(session, expense_source.replace("Assets:", "")) 35 | session.flush() 36 | # transfer between accounts 37 | if value < 0: 38 | # reverse everything 39 | account, expense, value = expense, account, -value 40 | create_transfer(session, date, account, expense, value, notes) 41 | else: 42 | print(f"Could not parse transaction '{account_source}' to '{expense_source}', '{notes}', {value}") 43 | session.flush() 44 | 45 | 46 | def parse_transaction(session, transaction: piecash.Transaction): 47 | notes: str = transaction.description 48 | date: datetime.date = transaction.post_date 49 | 50 | expense_source: str = transaction.splits[0].account.fullname 51 | account_source: str = transaction.splits[1].account.fullname 52 | value: decimal.Decimal = transaction.splits[0].quantity 53 | # swap around if it's a transfer back, set it with negative value 54 | if (account_source.startswith("Expenses:") and expense_source.startswith("Assets:")) or ( 55 | account_source.startswith("Assets:") and expense_source.startswith("Income:") 56 | ): 57 | expense_source, account_source, value = account_source, expense_source, -value 58 | 59 | # create accounts for assets 60 | insert_transaction(session, account_source, expense_source, notes, date, value) 61 | 62 | 63 | def main(): 64 | with Actual(password="mypass", bootstrap=True) as actual: 65 | actual.create_budget("Gnucash Import") 66 | # go through files from gnucash and find all that match .gnucash extension 67 | path = pathlib.Path(__file__).parent / "files/" 68 | for file in path.rglob("*.gnucash"): 69 | book = piecash.open_book(str(file.absolute()), readonly=True) 70 | for transaction in book.transactions: 71 | if len(transaction.splits) > 2: 72 | print( 73 | f"Could not parse transaction {transaction.guid}. Please, make sure you support splits manually" 74 | ) 75 | continue 76 | # for the actual transaction, get account in and out 77 | parse_transaction(actual.session, transaction) 78 | 79 | # if everything goes well we upload our budget 80 | actual.session.commit() 81 | actual.upload_budget() 82 | 83 | 84 | if __name__ == "__main__": 85 | main() 86 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: actualpy Documentation 2 | strict: true 3 | site_description: A Python re-implementation of the NodeJS API for Actual Budget 4 | repo_name: bvanelli/actualpy 5 | repo_url: https://github.com/bvanelli/actualpy 6 | edit_uri: edit/main/docs/ 7 | 8 | theme: 9 | name: material 10 | palette: 11 | # Palette toggle for light mode 12 | - scheme: default 13 | toggle: 14 | icon: material/weather-night 15 | name: Switch to dark mode 16 | # Palette toggle for dark mode 17 | - scheme: slate 18 | toggle: 19 | icon: material/weather-sunny 20 | name: Switch to light mode 21 | features: 22 | - content.code.copy 23 | 24 | markdown_extensions: 25 | - admonition 26 | - pymdownx.details 27 | - pymdownx.highlight: 28 | anchor_linenums: true 29 | line_spans: __span 30 | pygments_lang_class: true 31 | - pymdownx.inlinehilite 32 | - pymdownx.snippets 33 | - pymdownx.superfences 34 | 35 | plugins: 36 | - search 37 | - autorefs 38 | - mkdocstrings: 39 | handlers: 40 | python: 41 | import: 42 | - https://docs.python.org/3/objects.inv 43 | options: 44 | docstring_style: sphinx 45 | docstring_options: 46 | ignore_init_summary: true 47 | docstring_section_style: list 48 | filters: ["!^_"] 49 | heading_level: 1 50 | inherited_members: true 51 | merge_init_into_class: true 52 | parameter_headings: true 53 | separate_signature: true 54 | members_order: source 55 | show_root_heading: true 56 | show_root_full_path: false 57 | show_signature_annotations: true 58 | show_symbol_type_heading: true 59 | show_symbol_type_toc: true 60 | signature_crossrefs: true 61 | summary: true 62 | extensions: 63 | - griffe_fieldz: { include_inherited: true } 64 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "actualpy" 7 | description = "Implementation of the Actual API to interact with Actual over Python." 8 | readme = "README.md" 9 | authors = [ 10 | { name = "Brunno Vanelli", email = "brunnovanelli@gmail.com" } 11 | ] 12 | requires-python = ">=3.9.0" 13 | dependencies = [ 14 | "requests>=2", 15 | "sqlmodel>=0.0.18", 16 | "pydantic>=2,<3", 17 | "sqlalchemy>=2", 18 | "proto-plus>=1", 19 | "protobuf>=4", 20 | "cryptography>=42", 21 | "python-dateutil>=2.9.0", 22 | ] 23 | classifiers = [ 24 | "Programming Language :: Python", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "Programming Language :: Python :: 3.13", 31 | ] 32 | keywords = ["actual", "actualbudget", "api", "client"] 33 | dynamic = ["version"] 34 | 35 | [project.optional-dependencies] 36 | cli = [ 37 | "rich>=13", 38 | "typer>=0.12.0", 39 | "pyyaml>=6", 40 | ] 41 | 42 | [project.urls] 43 | Homepage = "https://github.com/bvanelli/actualpy" 44 | Documentation = "https://actualpy.readthedocs.io/" 45 | Repository = "https://github.com/bvanelli/actualpy.git" 46 | "Bug Tracker" = "https://github.com/bvanelli/actualpy/issues" 47 | 48 | [project.scripts] 49 | actualpy = "actual.cli.main:app" 50 | 51 | [tool.setuptools.packages.find] 52 | exclude = ["docs*", "tests*", "examples*"] 53 | 54 | [tool.setuptools.dynamic] 55 | version = { attr = "actual.version.__version__" } 56 | 57 | [tool.black] 58 | line-length = 120 59 | 60 | [tool.ruff] 61 | line-length = 120 62 | 63 | [tool.ruff.lint.mccabe] 64 | max-complexity = 18 65 | 66 | [tool.isort] 67 | profile = "black" 68 | line_length = 120 69 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest-mock 2 | pytest 3 | pytest-cov 4 | testcontainers 5 | pre-commit 6 | docker>=7.1.0 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2 2 | sqlmodel>=0.0.18 3 | pydantic>=2,<3 4 | sqlalchemy>=2 5 | proto-plus>=1 6 | protobuf>=4 7 | cryptography>=42 8 | python-dateutil>=2.9.0 9 | # for the cli 10 | rich>=13 11 | typer>=0.12.0 12 | pyyaml>=6.0 13 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bvanelli/actualpy/f6e223cba34bfa047c35bdf24500e77944b6cc9f/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import tempfile 5 | 6 | import pytest 7 | from sqlmodel import Session, create_engine 8 | 9 | from actual.database import SQLModel, strong_reference_session 10 | 11 | 12 | class RequestsMock: 13 | def __init__(self, json_data: dict | list, status_code: int = 200): 14 | self.json_data = json_data 15 | self.status_code = status_code 16 | self.text = json.dumps(json_data) 17 | self.content = json.dumps(json_data).encode("utf-8") 18 | 19 | def json(self): 20 | if isinstance(self.json_data, str): 21 | return json.loads(self.json_data) 22 | return self.json_data 23 | 24 | def raise_for_status(self): 25 | if self.status_code != 200: 26 | raise ValueError 27 | 28 | 29 | @pytest.fixture 30 | def session(): 31 | with tempfile.NamedTemporaryFile() as f: 32 | sqlite_url = f"sqlite:///{f.name}" 33 | engine = create_engine(sqlite_url, connect_args={"check_same_thread": False}) 34 | SQLModel.metadata.create_all(engine) 35 | with Session(engine, autoflush=True) as session: 36 | yield strong_reference_session(session) 37 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import zipfile 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | from requests import Session 6 | 7 | from actual import Actual, reflect_model 8 | from actual.api import ListUserFilesDTO 9 | from actual.api.models import RemoteFileListDTO, StatusCode 10 | from actual.exceptions import ActualError, AuthorizationError, UnknownFileId 11 | from actual.protobuf_models import Message 12 | from tests.conftest import RequestsMock 13 | 14 | 15 | def test_api_apply(mocker, session): 16 | mocker.patch("actual.Actual.validate") 17 | actual = Actual(token="foo") 18 | actual.engine = session.bind 19 | actual._meta = reflect_model(session.bind) 20 | # not found table 21 | m = Message(dict(dataset="foo", row="foobar", column="bar")) 22 | m.set_value("foobar") 23 | with pytest.raises(ActualError, match="table 'foo' not found"): 24 | actual.apply_changes([m]) 25 | m.dataset = "accounts" 26 | with pytest.raises(ActualError, match="column 'bar' at table 'accounts' not found"): 27 | actual.apply_changes([m]) 28 | 29 | 30 | def test_rename_delete_budget_without_file(mocker): 31 | mocker.patch("actual.Actual.validate") 32 | actual = Actual(token="foo") 33 | actual._file = None 34 | with pytest.raises(UnknownFileId, match="No current file loaded"): 35 | actual.delete_budget() 36 | with pytest.raises(UnknownFileId, match="No current file loaded"): 37 | actual.rename_budget("foo") 38 | 39 | 40 | @patch.object(Session, "post", return_value=RequestsMock({"status": "error", "reason": "proxy-not-trusted"})) 41 | def test_api_login_unknown_error(_post, mocker): 42 | mocker.patch("actual.Actual.validate") 43 | actual = Actual(token="foo") 44 | actual.api_url = "localhost" 45 | actual.cert = False 46 | with pytest.raises(AuthorizationError, match="Something went wrong on login"): 47 | actual.login("foo") 48 | 49 | 50 | @patch.object(Session, "post", return_value=RequestsMock({}, status_code=403)) 51 | def test_api_login_http_error(_post, mocker): 52 | mocker.patch("actual.Actual.validate") 53 | actual = Actual(token="foo") 54 | actual.api_url = "localhost" 55 | actual.cert = False 56 | with pytest.raises(AuthorizationError, match="HTTP error '403'"): 57 | actual.login("foo") 58 | 59 | 60 | def test_no_certificate(mocker): 61 | mocker.patch("actual.Actual.validate") 62 | actual = Actual(token="foo", cert=False) 63 | assert actual._requests_session.verify is False 64 | 65 | 66 | def test_set_file_exceptions(mocker): 67 | mocker.patch("actual.Actual.validate") 68 | list_user_files = mocker.patch( 69 | "actual.Actual.list_user_files", return_value=ListUserFilesDTO(status=StatusCode.OK, data=[]) 70 | ) 71 | actual = Actual(token="foo") 72 | with pytest.raises(ActualError, match="Could not find a file id or identifier 'foo'"): 73 | actual.set_file("foo") 74 | list_user_files.return_value = ListUserFilesDTO( 75 | status=StatusCode.OK, 76 | data=[ 77 | RemoteFileListDTO(deleted=False, fileId="foo", groupId="foo", name="foo", encryptKeyId=None), 78 | RemoteFileListDTO(deleted=False, fileId="foo", groupId="foo", name="foo", encryptKeyId=None), 79 | ], 80 | ) 81 | with pytest.raises(ActualError, match="Multiple files found with identifier 'foo'"): 82 | actual.set_file("foo") 83 | 84 | 85 | def test_zip_exceptions(mocker, tmp_path): 86 | mocker.patch("actual.Actual.validate") 87 | mocker.patch("actual.Actual.create_engine") 88 | archive = tmp_path / "file.zip" 89 | with zipfile.ZipFile(archive, "w"): 90 | pass 91 | actual = Actual(token="foo") 92 | actual.import_zip(archive) 93 | # archive will use a normal temp folder since the cloudFileId is missing from metadata 94 | assert actual._data_dir.name.startswith("tmp") 95 | 96 | 97 | def test_api_extra_headers(mocker): 98 | mocker.patch("actual.Actual.validate") 99 | actual = Actual(token="foo", extra_headers={"foo": "bar"}) 100 | assert actual._requests_session.headers["foo"] == "bar" 101 | assert actual._requests_session.headers["X-ACTUAL-TOKEN"] == "foo" 102 | -------------------------------------------------------------------------------- /tests/test_bank_sync.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import datetime 3 | import decimal 4 | 5 | import pytest 6 | from requests import Session 7 | 8 | from actual import Actual, ActualBankSyncError 9 | from actual.api.bank_sync import TransactionItem 10 | from actual.database import Banks 11 | from actual.queries import create_account 12 | from tests.conftest import RequestsMock 13 | 14 | response = { 15 | "iban": "DE123", 16 | "balances": [ 17 | { 18 | "balanceType": "expected", 19 | "lastChangeDateTime": "2024-06-13T14:56:06.092039915Z", 20 | "referenceDate": "2024-06-13", 21 | "balanceAmount": {"amount": "1.49", "currency": "EUR"}, 22 | } 23 | ], 24 | "institutionId": "My Bank", 25 | "startingBalance": 149, 26 | "transactions": { 27 | "all": [ 28 | { 29 | "transactionId": "208584e9-343f-4831-8095-7b9f4a34a77e", 30 | "bookingDate": "2024-06-13", 31 | "valueDate": "2024-06-13", 32 | "date": "2024-06-13", 33 | "transactionAmount": {"amount": "9.26", "currency": "EUR"}, 34 | "payeeName": "John Doe", 35 | "debtorName": "John Doe", 36 | "notes": "Transferring Money", 37 | "remittanceInformationUnstructured": "Transferring Money", 38 | "booked": True, 39 | }, 40 | # some have creditor name 41 | { 42 | "transactionId": "a2c2fafe-334a-46a6-8d05-200c2e41397b", 43 | "mandateId": "FOOBAR", 44 | "creditorId": "FOOBAR", 45 | "bookingDate": "2024-06-13", 46 | "valueDate": "2024-06-13", 47 | "date": "2024-06-13", 48 | "transactionAmount": {"amount": "-7.77", "currency": "EUR"}, 49 | "payeeName": "Institution Gmbh (DE12 XXX 6789)", 50 | "notes": "Payment", 51 | "creditorName": "Institution GmbH", 52 | "creditorAccount": {"iban": "DE123456789"}, 53 | "remittanceInformationUnstructured": "Payment", 54 | "remittanceInformationUnstructuredArray": ["Payment"], 55 | "bankTransactionCode": "FOO-BAR", 56 | "internalTransactionId": "6118268af4dc45039a7ca21b0fdcbe96", 57 | "booked": True, 58 | }, 59 | # ignored since booked is set to false, but all required fields are also missing 60 | { 61 | "date": "2024-06-13", 62 | "transactionAmount": {"amount": "0.00", "currency": "EUR"}, 63 | "booked": False, 64 | }, 65 | ], 66 | "booked": [], 67 | "pending": [], 68 | }, 69 | } 70 | 71 | fail_response = { 72 | "error_type": "ACCOUNT_NEEDS_ATTENTION", 73 | "error_code": "ACCOUNT_NEEDS_ATTENTION", 74 | "reason": "The account needs your attention.", 75 | } 76 | 77 | 78 | def create_accounts(session, protocol: str): 79 | bank = create_account(session, "Bank") 80 | create_account(session, "Not related") 81 | bank.account_sync_source = protocol 82 | bank.bank_id = bank.account_id = "foobar" 83 | session.add(Banks(id="foobar", bank_id="foobar", name="test")) 84 | session.commit() 85 | return bank 86 | 87 | 88 | def generate_bank_sync_data(mocker, starting_balance: int = None): 89 | response_full = copy.deepcopy(response) 90 | if starting_balance: 91 | response_full["startingBalance"] = starting_balance 92 | response_empty = copy.deepcopy(response) 93 | response_empty["transactions"]["all"] = [] 94 | mocker.patch.object(Session, "get").return_value = RequestsMock({"status": "ok", "data": {"validated": True}}) 95 | main_mock = mocker.patch.object(Session, "post") 96 | main_mock.side_effect = [ 97 | RequestsMock({"status": "ok", "data": {"configured": True}}), 98 | RequestsMock({"status": "ok", "data": response_full}), 99 | RequestsMock({"status": "ok", "data": {"configured": True}}), # in case it gets called again 100 | RequestsMock({"status": "ok", "data": response_empty}), 101 | ] 102 | return main_mock 103 | 104 | 105 | @pytest.fixture 106 | def bank_sync_data_match(mocker): 107 | # call for validate 108 | return generate_bank_sync_data(mocker) 109 | 110 | 111 | @pytest.fixture 112 | def bank_sync_data_no_match(mocker): 113 | return generate_bank_sync_data(mocker, 2500) 114 | 115 | 116 | def test_full_bank_sync_go_cardless(session, bank_sync_data_match): 117 | with Actual(token="foo") as actual: 118 | actual._session = session 119 | create_accounts(session, "goCardless") 120 | 121 | # now try to run the bank sync 122 | imported_transactions = actual.run_bank_sync() 123 | session.commit() 124 | assert len(imported_transactions) == 3 125 | assert imported_transactions[0].financial_id is None 126 | assert imported_transactions[0].get_date() == datetime.date(2024, 6, 13) 127 | # goCardless provides the correct starting balance 128 | assert imported_transactions[0].get_amount() == decimal.Decimal("1.49") 129 | assert imported_transactions[0].payee.name == "Starting Balance" 130 | assert imported_transactions[0].notes is None 131 | 132 | assert imported_transactions[1].financial_id == "a2c2fafe-334a-46a6-8d05-200c2e41397b" 133 | assert imported_transactions[1].get_date() == datetime.date(2024, 6, 13) 134 | assert imported_transactions[1].get_amount() == decimal.Decimal("-7.77") 135 | # the name of the payee was normalized (from GmbH to Gmbh) and the masked iban is included 136 | assert imported_transactions[1].payee.name == "Institution Gmbh (DE12 XXX 6789)" 137 | assert imported_transactions[1].notes == "Payment" 138 | # also test the iban generation functions 139 | loaded_transaction = TransactionItem.model_validate(response["transactions"]["all"][1]) 140 | assert imported_transactions[1].payee.name == loaded_transaction.imported_payee 141 | 142 | assert imported_transactions[2].financial_id == "208584e9-343f-4831-8095-7b9f4a34a77e" 143 | assert imported_transactions[2].get_date() == datetime.date(2024, 6, 13) 144 | assert imported_transactions[2].get_amount() == decimal.Decimal("9.26") 145 | assert imported_transactions[2].payee.name == "John Doe" 146 | assert imported_transactions[2].notes == "Transferring Money" 147 | 148 | # the next call should do nothing 149 | new_imported_transactions = actual.run_bank_sync() 150 | assert new_imported_transactions == [] 151 | # assert that the call date is correctly set 152 | assert bank_sync_data_match.call_args_list[3][1]["json"]["startDate"] == "2024-06-13" 153 | 154 | 155 | def test_full_bank_sync_go_simplefin(session, bank_sync_data_match): 156 | with Actual(token="foo") as actual: 157 | actual._session = session 158 | create_accounts(session, "simpleFin") 159 | 160 | # now try to run the bank sync 161 | imported_transactions = actual.run_bank_sync("Bank") 162 | assert len(imported_transactions) == 2 163 | assert imported_transactions[0].financial_id == "a2c2fafe-334a-46a6-8d05-200c2e41397b" 164 | assert imported_transactions[0].get_date() == datetime.date(2024, 6, 13) 165 | assert imported_transactions[0].get_amount() == decimal.Decimal("-7.77") 166 | assert imported_transactions[0].payee.name == "Payment" # simplefin uses the wrong field 167 | assert imported_transactions[0].notes == "Payment" 168 | 169 | assert imported_transactions[1].financial_id == "208584e9-343f-4831-8095-7b9f4a34a77e" 170 | assert imported_transactions[1].get_date() == datetime.date(2024, 6, 13) 171 | assert imported_transactions[1].get_amount() == decimal.Decimal("9.26") 172 | assert imported_transactions[1].payee.name == "Transferring Money" # simplefin uses the wrong field 173 | assert imported_transactions[1].notes == "Transferring Money" 174 | 175 | 176 | def test_bank_sync_with_starting_balance(session, bank_sync_data_no_match): 177 | with Actual(token="foo") as actual: 178 | actual._session = session 179 | create_accounts(session, "simpleFin") 180 | # now try to run the bank sync 181 | imported_transactions = actual.run_bank_sync("Bank", run_rules=True) 182 | assert len(imported_transactions) == 3 183 | # first transaction should be the amount 184 | assert imported_transactions[0].get_date() == datetime.date(2024, 6, 13) 185 | # final amount is 2500 - (926 - 777) = 2351 186 | assert imported_transactions[0].get_amount() == decimal.Decimal("23.51") 187 | 188 | 189 | def test_bank_sync_unconfigured(mocker, session): 190 | mocker.patch.object(Session, "get").return_value = RequestsMock({"status": "ok", "data": {"validated": True}}) 191 | main_mock = mocker.patch.object(Session, "post") 192 | main_mock.return_value = RequestsMock({"status": "ok", "data": {"configured": False}}) 193 | 194 | with Actual(token="foo") as actual: 195 | actual._session = session 196 | create_accounts(session, "simplefin") 197 | assert actual.run_bank_sync() == [] 198 | 199 | 200 | def test_bank_sync_exception(session, mocker): 201 | mocker.patch.object(Session, "get").return_value = RequestsMock({"status": "ok", "data": {"validated": True}}) 202 | main_mock = mocker.patch.object(Session, "post") 203 | main_mock.side_effect = [ 204 | RequestsMock({"status": "ok", "data": {"configured": True}}), 205 | RequestsMock({"status": "ok", "data": fail_response}), 206 | ] 207 | with Actual(token="foo") as actual: 208 | actual._session = session 209 | create_accounts(session, "simplefin") 210 | 211 | # now try to run the bank sync 212 | with pytest.raises(ActualBankSyncError): 213 | actual.run_bank_sync() 214 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import pathlib 4 | from typing import List 5 | 6 | import pytest 7 | from click.testing import Result 8 | from testcontainers.core.container import DockerContainer 9 | from testcontainers.core.waiting_utils import wait_for_logs 10 | from typer.testing import CliRunner 11 | 12 | from actual import Actual, __version__ 13 | from actual.cli.config import Config, default_config_path 14 | from actual.queries import create_account, create_transaction 15 | 16 | runner = CliRunner() 17 | server_version = "25.4.0" 18 | 19 | 20 | def base_dataset(actual: Actual, budget_name: str = "Test", encryption_password: str = None): 21 | actual.create_budget(budget_name) 22 | bank = create_account(actual.session, "Bank") 23 | create_transaction( 24 | actual.session, datetime.date(2024, 9, 5), bank, "Starting Balance", category="Starting", amount=150 25 | ) 26 | create_transaction( 27 | actual.session, datetime.date(2024, 12, 24), bank, "Shopping Center", "Christmas Gifts", "Gifts", -100 28 | ) 29 | actual.commit() 30 | actual.upload_budget() 31 | if encryption_password: 32 | actual.encrypt(encryption_password) 33 | 34 | 35 | @pytest.fixture(scope="module") 36 | def actual_server(request, module_mocker, tmp_path_factory): 37 | path = pathlib.Path(tmp_path_factory.mktemp("config")) 38 | module_mocker.patch("actual.cli.config.default_config_path", return_value=path / "config.yaml") 39 | with DockerContainer(f"actualbudget/actual-server:{server_version}").with_exposed_ports(5006) as container: 40 | wait_for_logs(container, "Listening on :::5006...") 41 | # create a new budget 42 | port = container.get_exposed_port(5006) 43 | with Actual(f"http://localhost:{port}", password="mypass", bootstrap=True) as actual: 44 | base_dataset(actual) 45 | # init configuration 46 | result = invoke( 47 | [ 48 | "init", 49 | "--url", 50 | f"http://localhost:{port}", 51 | "--password", 52 | "mypass", 53 | "--file", 54 | "Test", 55 | "--context", 56 | "test", 57 | ] 58 | ) 59 | assert result.exit_code == 0 60 | yield container 61 | 62 | 63 | def invoke(command: List[str]) -> Result: 64 | from actual.cli.main import app 65 | 66 | return runner.invoke(app, command) 67 | 68 | 69 | def test_init_interactive(actual_server, mocker): 70 | # create a new encrypted file 71 | port = actual_server.get_exposed_port(5006) 72 | with Actual(f"http://localhost:{port}", password="mypass") as actual: 73 | base_dataset(actual, "Extra", "mypass") 74 | # test full prompt 75 | mock_prompt = mocker.patch("typer.prompt") 76 | mock_prompt.side_effect = [f"http://localhost:{port}", "mypass", 2, "mypass", "myextra"] 77 | assert invoke(["init"]).exit_code == 0 78 | assert invoke(["use-context", "myextra"]).exit_code == 0 79 | assert invoke(["use-context", "test"]).exit_code == 0 80 | # remove extra context 81 | assert invoke(["remove-context", "myextra"]).exit_code == 0 82 | # different context should not succeed 83 | assert invoke(["use-context", "myextra"]).exit_code != 0 84 | assert invoke(["remove-context", "myextra"]).exit_code != 0 85 | 86 | 87 | def test_load_config(actual_server): 88 | cfg = Config.load() 89 | assert cfg.default_context == "test" 90 | assert str(default_config_path()).endswith(".actualpy/config.yaml") 91 | # if the context does not exist, it should fail to load the server 92 | cfg.default_context = "foo" 93 | with pytest.raises(ValueError, match="Could not find budget with context"): 94 | cfg.actual() 95 | 96 | 97 | def test_app(actual_server): 98 | result = invoke(["version"]) 99 | assert result.exit_code == 0 100 | assert result.stdout == f"Library Version: {__version__}\nServer Version: {server_version}\n" 101 | # make sure json is valid 102 | result = invoke(["-o", "json", "version"]) 103 | assert json.loads(result.stdout) == {"library_version": __version__, "server_version": server_version} 104 | 105 | 106 | def test_metadata(actual_server): 107 | result = invoke(["metadata"]) 108 | assert result.exit_code == 0 109 | assert "" in result.stdout 110 | # make sure json is valid 111 | result = invoke(["-o", "json", "metadata"]) 112 | assert "budgetName" in json.loads(result.stdout) 113 | 114 | 115 | def test_accounts(actual_server): 116 | result = invoke(["accounts"]) 117 | assert result.exit_code == 0 118 | assert result.stdout == ( 119 | " Accounts \n" 120 | "┏━━━━━━━━━━━━━━┳━━━━━━━━━┓\n" 121 | "┃ Account Name ┃ Balance ┃\n" 122 | "┡━━━━━━━━━━━━━━╇━━━━━━━━━┩\n" 123 | "│ Bank │ 50.00 │\n" 124 | "└──────────────┴─────────┘\n" 125 | ) 126 | # make sure json is valid 127 | result = invoke(["-o", "json", "accounts"]) 128 | assert json.loads(result.stdout) == [{"name": "Bank", "balance": 50.00}] 129 | 130 | 131 | def test_transactions(actual_server): 132 | result = invoke(["transactions"]) 133 | assert result.exit_code == 0 134 | assert result.stdout == ( 135 | " Transactions \n" 136 | "┏━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━┓\n" 137 | "┃ Date ┃ Payee ┃ Notes ┃ Category ┃ Amount ┃\n" 138 | "┡━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━┩\n" 139 | "│ 2024-12-24 │ Shopping Center │ Christmas Gifts │ Gifts │ -100.00 │\n" 140 | "│ 2024-09-05 │ Starting Balance │ │ Starting │ 150.00 │\n" 141 | "└────────────┴──────────────────┴─────────────────┴──────────┴─────────┘\n" 142 | ) 143 | # make sure json is valid 144 | result = invoke(["-o", "json", "transactions"]) 145 | assert { 146 | "date": "2024-12-24", 147 | "payee": "Shopping Center", 148 | "notes": "Christmas Gifts", 149 | "category": "Gifts", 150 | "amount": -100.00, 151 | } in json.loads(result.stdout) 152 | 153 | 154 | def test_payees(actual_server): 155 | result = invoke(["payees"]) 156 | assert result.exit_code == 0 157 | assert result.stdout == ( 158 | " Payees \n" 159 | "┏━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓\n" 160 | "┃ Name ┃ Balance ┃\n" 161 | "┡━━━━━━━━━━━━━━━━━━╇━━━━━━━━━┩\n" 162 | "│ │ 0.00 │\n" # this is the payee for the account 163 | "│ Starting Balance │ 150.00 │\n" 164 | "│ Shopping Center │ -100.00 │\n" 165 | "└──────────────────┴─────────┘\n" 166 | ) 167 | # make sure json is valid 168 | result = invoke(["-o", "json", "payees"]) 169 | assert {"name": "Shopping Center", "balance": -100.00} in json.loads(result.stdout) 170 | 171 | 172 | def test_export(actual_server, mocker): 173 | export_data = mocker.patch("actual.Actual.export_data") 174 | invoke(["export"]) 175 | export_data.assert_called_once() 176 | assert export_data.call_args[0][0].name.endswith("Test.zip") 177 | 178 | # test normal file name 179 | invoke(["export", "Test.zip"]) 180 | assert export_data.call_count == 2 181 | assert export_data.call_args[0][0].name == "Test.zip" 182 | -------------------------------------------------------------------------------- /tests/test_crypto.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | import pytest 4 | 5 | from actual.api.models import EncryptMetaDTO 6 | from actual.crypto import ( 7 | create_key_buffer, 8 | decrypt, 9 | decrypt_from_meta, 10 | encrypt, 11 | make_salt, 12 | make_test_message, 13 | random_bytes, 14 | ) 15 | from actual.exceptions import ActualDecryptionError 16 | from actual.protobuf_models import HULC_Client, Message, SyncRequest, SyncResponse 17 | 18 | 19 | def test_create_key_buffer(): 20 | # Tested based on: 21 | # const crypto = require('crypto'); 22 | # console.log(crypto.pbkdf2Sync('foo', 'bar', 10000, 32, 'sha512').toString("base64")) 23 | buffer = create_key_buffer("foo", base64.b64encode(b"bar").decode()) 24 | assert base64.b64encode(buffer).decode() == "+Do1kTWpkRT0w4kl2suJLdbY1BLtyEpRCiImRtslNgQ=" 25 | 26 | 27 | def test_encrypt_decrypt(): 28 | key = create_key_buffer("foo", "bar") 29 | string_to_encrypt = b"foobar" 30 | encrypted = encrypt("foo", key, string_to_encrypt) 31 | decrypted_from_meta = decrypt_from_meta( 32 | key, base64.b64decode(encrypted["value"]), EncryptMetaDTO(**encrypted["meta"]) 33 | ) 34 | assert decrypted_from_meta == string_to_encrypt 35 | with pytest.raises(ActualDecryptionError): 36 | decrypt_from_meta(key[::-1], base64.b64decode(encrypted["value"]), EncryptMetaDTO(**encrypted["meta"])) 37 | 38 | 39 | def test_encrypt_decrypt_message(): 40 | key = create_key_buffer("foo", "bar") 41 | m = Message(dict(dataset=random_bytes(), row=random_bytes(), column=random_bytes(), value=random_bytes())) 42 | req = SyncRequest() 43 | req.set_messages([m], HULC_Client(), master_key=key) 44 | resp = SyncResponse() 45 | resp.messages = req.messages 46 | with pytest.raises(ActualDecryptionError): 47 | resp.get_messages() # should fail to get messages without a key 48 | decrypted_messages = resp.get_messages(master_key=key) 49 | assert len(decrypted_messages) == 1 50 | assert decrypted_messages[0] == m 51 | 52 | 53 | def test_create_test_message(): 54 | key = create_key_buffer(make_salt(), make_salt()) 55 | tm = make_test_message("", key) 56 | dfm = decrypt( 57 | key, base64.b64decode(tm["meta"]["iv"]), base64.b64decode(tm["value"]), base64.b64decode(tm["meta"]["authTag"]) 58 | ) 59 | m = Message.deserialize(dfm) 60 | assert isinstance(m, Message) 61 | -------------------------------------------------------------------------------- /tests/test_database.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import decimal 3 | import json 4 | from datetime import date, timedelta 5 | 6 | import pytest 7 | 8 | from actual import Actual, ActualError, reflect_model 9 | from actual.database import Notes, ReflectBudgets, ZeroBudgets 10 | from actual.queries import ( 11 | create_account, 12 | create_budget, 13 | create_rule, 14 | create_splits, 15 | create_transaction, 16 | create_transfer, 17 | get_accounts, 18 | get_accumulated_budgeted_balance, 19 | get_budgets, 20 | get_or_create_category, 21 | get_or_create_clock, 22 | get_or_create_payee, 23 | get_or_create_preference, 24 | get_preferences, 25 | get_ruleset, 26 | get_transactions, 27 | normalize_payee, 28 | reconcile_transaction, 29 | set_transaction_payee, 30 | ) 31 | from actual.rules import Action, Condition, ConditionType, Rule 32 | 33 | 34 | def test_account_relationships(session): 35 | today = date.today() 36 | bank = create_account(session, "Bank", 5000) 37 | create_account(session, "Savings") 38 | landlord = get_or_create_payee(session, "Landlord") 39 | rent = get_or_create_category(session, "Rent") 40 | rent_payment = create_transaction(session, today, "Bank", "Landlord", "Paying rent", "Rent", -1200) 41 | utilities_payment = create_transaction(session, today, "Bank", "Landlord", "Utilities", "Rent", -50) 42 | create_transfer(session, today, "Bank", "Savings", 200, "Saving money") 43 | session.commit() 44 | assert bank.balance == decimal.Decimal(3550) 45 | assert landlord.balance == decimal.Decimal(-1250) 46 | assert rent.balance == decimal.Decimal(-1250) 47 | assert rent_payment.category == rent 48 | assert len(bank.transactions) == 4 # includes starting balance and one transfer 49 | assert len(landlord.transactions) == 2 50 | assert len(rent.transactions) == 2 51 | # let's now void the utilities_payment 52 | utilities_payment.delete() 53 | session.commit() 54 | assert bank.balance == decimal.Decimal(3600) 55 | assert landlord.balance == decimal.Decimal(-1200) 56 | assert rent.balance == decimal.Decimal(-1200) 57 | assert len(bank.transactions) == 3 58 | assert len(landlord.transactions) == 1 59 | assert len(rent.transactions) == 1 60 | # delete the payee and category 61 | rent.delete() 62 | landlord.delete() 63 | session.commit() 64 | assert rent_payment.category is None 65 | assert rent_payment.payee is None 66 | # find the deleted transaction again 67 | deleted_transaction = get_transactions( 68 | session, today - timedelta(days=1), today + timedelta(days=1), "Util", bank, include_deleted=True 69 | ) 70 | assert [utilities_payment] == deleted_transaction 71 | assert get_accounts(session, "Bank") == [bank] 72 | 73 | 74 | def test_transaction(session): 75 | today = date.today() 76 | other = create_account(session, "Other") 77 | coffee = create_transaction( 78 | session, date=today, account="Other", payee="Starbucks", notes="coffee", amount=float(-9.95) 79 | ) 80 | session.commit() 81 | assert coffee.amount == -995 82 | assert len(other.transactions) == 1 83 | assert other.balance == decimal.Decimal("-9.95") 84 | 85 | 86 | def test_transaction_without_payee(session): 87 | other = create_account(session, "Other") 88 | tr = create_transaction(session, date=date.today(), account=other) 89 | assert tr.payee_id is None 90 | 91 | 92 | def test_reconcile_transaction(session): 93 | today = date.today() 94 | create_account(session, "Bank") 95 | rent_payment = create_transaction( 96 | session, today, "Bank", "Landlord", "Paying rent", "Expenses", -1200, imported_id="unique" 97 | ) 98 | unrelated = create_transaction( 99 | session, today - timedelta(days=5), "Bank", "Carshop", "Car maintenance", "Car", -1200 100 | ) 101 | session.commit() 102 | assert ( 103 | reconcile_transaction(session, today + timedelta(days=1), "Bank", category="Rent", amount=-1200).id 104 | == rent_payment.id 105 | ) 106 | session.commit() 107 | # check if the property was updated 108 | assert rent_payment.get_date() == today + timedelta(days=1) 109 | assert rent_payment.category.name == "Rent" 110 | # should still be able to match if the payee is defined, as the match is stronger 111 | assert ( 112 | reconcile_transaction( 113 | session, today - timedelta(days=5), payee="Landlord", account="Bank", amount=-1200, update_existing=False 114 | ).id 115 | == rent_payment.id 116 | ) 117 | # should not be able to match without payee 118 | assert reconcile_transaction(session, today - timedelta(days=5), account="Bank", amount=-1200).id == unrelated.id 119 | # regardless of date, the match by unique id should work 120 | assert ( 121 | reconcile_transaction( 122 | session, 123 | today - timedelta(days=30), 124 | account="Bank", 125 | amount=-1200, 126 | imported_id="unique", 127 | update_existing=False, 128 | ).id 129 | == rent_payment.id 130 | ) 131 | # but if it's too far, it will be a new transaction 132 | assert reconcile_transaction(session, today - timedelta(days=30), account="Bank", amount=-1200).id not in ( 133 | rent_payment.id, 134 | unrelated.id, 135 | ) 136 | 137 | 138 | def test_create_splits(session): 139 | bank = create_account(session, "Bank") 140 | t = create_transaction(session, date.today(), bank, category="Dining", amount=-10.0) 141 | t_taxes = create_transaction(session, date.today(), bank, category="Taxes", amount=-2.5) 142 | parent_transaction = create_splits(session, [t, t_taxes], notes="Dining") 143 | # find all children 144 | trs = get_transactions(session) 145 | assert len(trs) == 2 146 | assert t in trs 147 | assert t_taxes in trs 148 | assert all(tr.parent == parent_transaction for tr in trs) 149 | # find all parents 150 | parents = get_transactions(session, is_parent=True) 151 | assert len(parents) == 1 152 | assert len(parents[0].splits) == 2 153 | # find all with category 154 | category = get_transactions(session, category="Dining") 155 | assert len(category) == 1 156 | 157 | 158 | def test_create_splits_error(session): 159 | bank = create_account(session, "Bank") 160 | wallet = create_account(session, "Wallet") 161 | t1 = create_transaction(session, date.today(), bank, category="Dining", amount=-10.0) 162 | t2 = create_transaction(session, date.today(), wallet, category="Taxes", amount=-2.5) 163 | t3 = create_transaction(session, date.today() - timedelta(days=1), bank, category="Taxes", amount=-2.5) 164 | with pytest.raises(ActualError, match="must be the same for all transactions in splits"): 165 | create_splits(session, [t1, t2]) 166 | with pytest.raises(ActualError, match="must be the same for all transactions in splits"): 167 | create_splits(session, [t1, t3]) 168 | 169 | 170 | def test_create_transaction_without_account_error(session): 171 | with pytest.raises(ActualError): 172 | create_transaction(session, date.today(), "foo", "") 173 | with pytest.raises(ActualError): 174 | create_transaction(session, date.today(), None, "") 175 | 176 | 177 | def test_rule_insertion_method(session): 178 | # create one example transaction 179 | create_transaction(session, date(2024, 1, 4), create_account(session, "Bank"), "") 180 | session.commit() 181 | # create and run rule 182 | action = Action(field="cleared", value=1) 183 | assert action.as_dict() == {"field": "cleared", "op": "set", "type": "boolean", "value": True} 184 | condition = Condition(field="date", op=ConditionType.IS_APPROX, value=date(2024, 1, 2)) 185 | assert condition.as_dict() == {"field": "date", "op": "isapprox", "type": "date", "value": "2024-01-02"} 186 | # test full rule 187 | rule = Rule(conditions=[condition], actions=[action], operation="all", stage="pre") 188 | created_rule = create_rule(session, rule, run_immediately=True) 189 | assert [condition.as_dict()] == json.loads(created_rule.conditions) 190 | assert [action.as_dict()] == json.loads(created_rule.actions) 191 | assert created_rule.conditions_op == "and" 192 | assert created_rule.stage == "pre" 193 | trs = get_transactions(session) 194 | assert trs[0].cleared == 1 195 | session.flush() 196 | rs = get_ruleset(session) 197 | assert len(rs.rules) == 1 198 | assert str(rs) == "If all of these conditions match 'date' isapprox '2024-01-02' then set 'cleared' to 'True'" 199 | 200 | 201 | @pytest.mark.parametrize( 202 | "budget_type,budget_table", 203 | [("rollover", ZeroBudgets), ("report", ReflectBudgets), ("envelope", ZeroBudgets), ("tracking", ReflectBudgets)], 204 | ) 205 | def test_budgets(session, budget_type, budget_table): 206 | # set the config 207 | get_or_create_preference(session, "budgetType", budget_type) 208 | # insert a budget 209 | category = get_or_create_category(session, "Expenses") 210 | unrelated_category = get_or_create_category(session, "Unrelated") 211 | session.commit() 212 | create_budget(session, date(2024, 10, 7), category, 10.0) 213 | assert len(get_budgets(session)) == 1 214 | assert len(get_budgets(session, date(2024, 10, 1))) == 1 215 | assert len(get_budgets(session, date(2024, 10, 1), category)) == 1 216 | assert len(get_budgets(session, date(2024, 9, 1))) == 0 217 | budget = get_budgets(session)[0] 218 | assert isinstance(budget, budget_table) 219 | assert budget.get_amount() == 10.0 220 | assert budget.get_date() == date(2024, 10, 1) 221 | # get a budget that already exists, but re-set it 222 | create_budget(session, date(2024, 10, 7), category, 20.0) 223 | assert budget.get_amount() == 20.0 224 | assert budget.range == (date(2024, 10, 1), date(2024, 11, 1)) 225 | # insert a transaction in the range and see if they are counted on the balance 226 | bank = create_account(session, "Bank") 227 | t1 = create_transaction(session, date(2024, 10, 1), bank, category=category, amount=-10.0) 228 | t2 = create_transaction(session, date(2024, 10, 15), bank, category=category, amount=-10.0) 229 | t3 = create_transaction(session, date(2024, 10, 31), bank, category=category, amount=-15.0) 230 | # should not be counted 231 | create_transaction(session, date(2024, 10, 1), bank, category=category, amount=-15.0).delete() 232 | create_transaction(session, date(2024, 11, 1), bank, category=category, amount=-20.0) 233 | create_transaction(session, date(2024, 10, 15), bank, category=unrelated_category, amount=-20.0) 234 | assert budget.balance == -35.0 235 | budget_transactions = get_transactions(session, budget=budget) 236 | assert len(budget_transactions) == 3 237 | assert all(t in budget_transactions for t in (t1, t2, t3)) 238 | # test if it fails if category does not exist 239 | with pytest.raises(ActualError, match="Category is provided but does not exist"): 240 | get_budgets(session, category="foo") 241 | # filtering by budget will raise a warning if get_transactions with budget also provides a start-end outside range 242 | with pytest.warns(match="Provided date filters"): 243 | get_transactions(session, date(2024, 9, 1), date(2024, 9, 15), budget=budget) 244 | 245 | 246 | @pytest.mark.parametrize( 247 | "budget_type,with_reset,with_previous_value,expected_value_previous_month,expected_value_current_month", 248 | [ 249 | ("envelope", False, False, decimal.Decimal(5), decimal.Decimal(25)), 250 | ("envelope", False, True, decimal.Decimal(15), decimal.Decimal(35)), 251 | ("envelope", True, True, decimal.Decimal(-5), decimal.Decimal(20)), 252 | ("envelope", True, False, decimal.Decimal(-15), decimal.Decimal(20)), 253 | ("tracking", False, True, decimal.Decimal(-5), decimal.Decimal(20)), 254 | ], 255 | ) 256 | def test_accumulated_budget_amount( 257 | session, budget_type, with_reset, with_previous_value, expected_value_current_month, expected_value_previous_month 258 | ): 259 | get_or_create_preference(session, "budgetType", budget_type) 260 | 261 | category = get_or_create_category(session, "Expenses") 262 | bank = create_account(session, "Bank") 263 | 264 | # create three months of budgets 265 | create_budget(session, date(2025, 1, 1), category, 20.0) 266 | create_budget(session, date(2025, 2, 1), category, 20.0) 267 | create_budget(session, date(2025, 3, 1), category, 20.0) 268 | # should be considered since is an income before the beginning of the budget period 269 | if with_previous_value: 270 | create_transaction(session, date(2024, 10, 1), bank, category=category, amount=10.0) 271 | # other transactions 272 | create_transaction(session, date(2025, 1, 1), bank, category=category, amount=-10.0) 273 | create_transaction(session, date(2025, 2, 1), bank, category=category, amount=-10.0) 274 | create_transaction(session, date(2025, 2, 3), bank, category=category, amount=-15.0) 275 | # should reset rollover budget 276 | if with_reset: 277 | create_transaction(session, date(2025, 2, 4), bank, category=category, amount=-20.0) 278 | 279 | assert get_accumulated_budgeted_balance(session, date(2025, 2, 1), category) == expected_value_previous_month 280 | assert get_accumulated_budgeted_balance(session, date(2025, 3, 1), category) == expected_value_current_month 281 | 282 | 283 | def test_normalize_payee(): 284 | assert normalize_payee(" mY paYeE ") == "My Payee" 285 | assert normalize_payee(" ", raw_payee_name=True) == "" 286 | assert normalize_payee(" My PayeE ", raw_payee_name=True) == "My PayeE" 287 | 288 | 289 | def test_rollback(session): 290 | create_account(session, "Bank", 5000) 291 | session.flush() 292 | assert "messages" in session.info 293 | assert len(session.info["messages"]) 294 | session.rollback() 295 | assert "messages" not in session.info 296 | 297 | 298 | def test_model_notes(session): 299 | account_with_note = create_account(session, "Bank 1") 300 | account_without_note = create_account(session, "Bank 2") 301 | session.add(Notes(id=f"account-{account_with_note.id}", note="My note")) 302 | session.commit() 303 | assert account_with_note.notes == "My note" 304 | assert account_without_note.notes is None 305 | 306 | 307 | def test_default_imported_payee(session): 308 | t = create_transaction(session, date(2024, 1, 4), create_account(session, "Bank"), imported_payee=" foo ") 309 | session.flush() 310 | assert t.payee.name == "foo" 311 | assert t.imported_description == "foo" 312 | 313 | 314 | def test_session_error(mocker): 315 | mocker.patch("actual.Actual.validate") 316 | with Actual(token="foo") as actual: 317 | with pytest.raises(ActualError, match="No session defined"): 318 | print(actual.session) # try to access the session, should raise an exception 319 | 320 | 321 | def test_apply_changes(session, mocker): 322 | mocker.patch("actual.Actual.validate") 323 | actual = Actual(token="foo") 324 | actual._session, actual.engine, actual._meta = session, session.bind, reflect_model(session.bind) 325 | # create elements but do not commit them 326 | account = create_account(session, "Bank") 327 | transaction = create_transaction(session, date(2024, 1, 4), account, amount=35.7) 328 | session.flush() 329 | messages_size = len(session.info["messages"]) 330 | transaction.notes = "foobar" 331 | session.flush() 332 | assert len(session.info["messages"]) == messages_size + 1 333 | messages = session.info["messages"] 334 | # undo all changes, but apply via database 335 | session.rollback() 336 | actual.apply_changes(messages) 337 | # make sure elements got committed correctly 338 | accounts = get_accounts(session, "Bank") 339 | assert len(accounts) == 1 340 | assert accounts[0].id == account.id 341 | assert accounts[0].name == account.name 342 | transactions = get_transactions(session) 343 | assert len(transactions) == 1 344 | assert transactions[0].id == transaction.id 345 | assert transactions[0].notes == transaction.notes 346 | assert transactions[0].get_date() == transaction.get_date() 347 | assert transactions[0].get_amount() == transaction.get_amount() 348 | 349 | 350 | def test_get_or_create_clock(session): 351 | clock = get_or_create_clock(session) 352 | assert clock.get_timestamp().ts == datetime.datetime(1970, 1, 1, 0, 0, 0) 353 | assert clock.get_timestamp().initial_count == 0 354 | 355 | 356 | def test_get_preferences(session): 357 | assert len(get_preferences(session)) == 0 358 | preference = get_or_create_preference(session, "foo", "bar") 359 | assert preference.value == "bar" 360 | preferences = get_preferences(session) 361 | assert len(preferences) == 1 362 | assert preferences[0] == preference 363 | # update preference 364 | get_or_create_preference(session, "foo", "foobar") 365 | new_preferences = get_preferences(session) 366 | assert len(new_preferences) == 1 367 | assert new_preferences[0].value == "foobar" 368 | 369 | 370 | def test_set_payee_to_transfer(session): 371 | wallet = create_account(session, "Wallet") 372 | bank = create_account(session, "Bank") 373 | session.commit() 374 | # Create a transaction setting the payee 375 | t = create_transaction(session, date.today(), bank, wallet.payee, amount=-50) 376 | session.commit() 377 | transactions = get_transactions(session) 378 | assert len(transactions) == 2 379 | assert transactions[0].get_amount() == -transactions[1].get_amount() 380 | assert transactions[0].transferred_id == transactions[1].id 381 | assert transactions[1].transferred_id == transactions[0].id 382 | # Set this payee to something else, transaction should be deleted 383 | set_transaction_payee(session, t, None) 384 | session.commit() 385 | assert len(get_transactions(session)) == 1 386 | assert t.payee_id is None 387 | assert t.transferred_id is None 388 | # Set payee_id back, transaction should be recreated 389 | set_transaction_payee(session, t, wallet.payee.id) 390 | session.commit() 391 | assert t.payee_id == wallet.payee.id 392 | assert t.transfer.transfer == t 393 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | from sqlalchemy import delete, select 5 | from testcontainers.core.container import DockerContainer 6 | from testcontainers.core.waiting_utils import wait_for_logs 7 | 8 | from actual import Actual, js_migration_statements 9 | from actual.database import __TABLE_COLUMNS_MAP__, Dashboard, Migrations, reflect_model 10 | from actual.exceptions import ActualDecryptionError, ActualError, AuthorizationError 11 | from actual.queries import ( 12 | create_transaction, 13 | get_accounts, 14 | get_categories, 15 | get_or_create_account, 16 | get_or_create_category, 17 | get_or_create_payee, 18 | get_payees, 19 | get_rules, 20 | get_ruleset, 21 | get_schedules, 22 | get_transactions, 23 | ) 24 | 25 | VERSIONS = ["25.4.0", "25.5.0", "25.6.1"] 26 | 27 | 28 | @pytest.fixture(params=VERSIONS) # todo: support multiple versions at once 29 | def actual_server(request): 30 | # we test integration with the 5 latest versions of actual server 31 | with DockerContainer(f"actualbudget/actual-server:{request.param}").with_exposed_ports(5006) as container: 32 | wait_for_logs(container, "Listening on :::5006...") 33 | yield container 34 | 35 | 36 | def test_create_user_file(actual_server): 37 | port = actual_server.get_exposed_port(5006) 38 | with Actual(f"http://localhost:{port}", password="mypass", bootstrap=True) as actual: 39 | assert len(actual.list_user_files().data) == 0 40 | actual.create_budget("My Budget") 41 | actual.upload_budget() 42 | assert "userId" in actual.get_metadata() 43 | # add some entries to the budget 44 | acct = get_or_create_account(actual.session, "Bank") 45 | assert acct.balance == 0 46 | payee = get_or_create_payee(actual.session, "Landlord") 47 | category = get_or_create_category(actual.session, "Rent", "Fixed Costs") 48 | create_transaction(actual.session, datetime.date(2024, 5, 22), acct, payee, "Paying rent", category, -500) 49 | actual.commit() 50 | assert acct.balance == -500 51 | # list user files 52 | new_user_files = actual.list_user_files().data 53 | assert len(new_user_files) == 1 54 | assert new_user_files[-1].name == "My Budget" 55 | assert actual.info().build is not None 56 | # run rules 57 | actual.run_rules() 58 | # run bank sync 59 | assert actual.run_bank_sync() == [] 60 | # check also bank sync accounts, should fail because of no token 61 | assert actual.bank_sync_accounts("simplefin").data.error_type == "INVALID_ACCESS_TOKEN" 62 | # same test with goCardless returns 404 for some reason, so we don't do that 63 | 64 | # make sure a new instance can now retrieve the budget info 65 | with Actual(f"http://localhost:{port}", password="mypass", file="My Budget"): 66 | assert len(get_accounts(actual.session)) == 1 67 | assert len(get_payees(actual.session)) == 2 # one is the account payee 68 | assert len(get_categories(actual.session)) > 6 # there are 6 default categories 69 | assert len(get_transactions(actual.session)) == 1 70 | assert len(get_schedules(actual.session)) == 0 71 | assert len(get_rules(actual.session)) == 0 72 | assert get_ruleset(actual.session).rules == [] 73 | 74 | with pytest.raises(AuthorizationError): 75 | Actual(actual.api_url, password="mywrongpass", file="My Budget") 76 | 77 | 78 | def test_encrypted_file(actual_server): 79 | port = actual_server.get_exposed_port(5006) 80 | with Actual(f"http://localhost:{port}", password="mypass", encryption_password="mypass", bootstrap=True) as actual: 81 | actual.create_budget("My Encrypted Budget") 82 | actual.upload_budget() 83 | user_files = actual.list_user_files().data 84 | assert user_files[0].encrypt_key_id is not None 85 | # re-download budget 86 | with Actual( 87 | f"http://localhost:{port}", password="mypass", encryption_password="mypass", file="My Encrypted Budget" 88 | ) as actual: 89 | assert actual.session is not None 90 | with pytest.raises(ActualDecryptionError, match="Error decrypting file. Is the encryption key correct"): 91 | Actual( 92 | f"http://localhost:{port}", password="mypass", encryption_password="mywrongpass", file="My Encrypted Budget" 93 | ).download_budget() 94 | with pytest.raises(ActualDecryptionError, match="File is encrypted but no encryption password was provided"): 95 | Actual(f"http://localhost:{port}", password="mypass", file="My Encrypted Budget").download_budget() 96 | 97 | 98 | def test_update_file_name(actual_server): 99 | port = actual_server.get_exposed_port(5006) 100 | with Actual(f"http://localhost:{port}", password="mypass", bootstrap=True) as actual: 101 | assert len(actual.list_user_files().data) == 0 102 | actual.create_budget("My Budget") 103 | actual.upload_budget() 104 | actual.rename_budget("Other name") 105 | files = actual.list_user_files().data 106 | assert len(files) == 1 107 | assert files[0].name == "Other name" 108 | # should raise an error if budget does not exist 109 | with Actual(f"http://localhost:{port}", password="mypass") as actual: 110 | with pytest.raises(ActualError): 111 | actual.rename_budget("Failing name") 112 | 113 | 114 | def test_reimport_file_from_zip(actual_server, tmp_path): 115 | port = actual_server.get_exposed_port(5006) 116 | backup_file = f"{tmp_path}/backup.zip" 117 | # create one file 118 | with Actual(f"http://localhost:{port}", password="mypass", bootstrap=True) as actual: 119 | # add some entries to the budget 120 | actual.create_budget("My Budget") 121 | get_or_create_account(actual.session, "Bank") 122 | actual.commit() 123 | actual.upload_budget() 124 | # re-download file and save as a backup 125 | with Actual(f"http://localhost:{port}", password="mypass", file="My Budget") as actual: 126 | actual.export_data(backup_file) 127 | actual.delete_budget() 128 | # re-upload the file 129 | with Actual(f"http://localhost:{port}", password="mypass") as actual: 130 | actual.import_zip(backup_file) 131 | actual.upload_budget() 132 | # check if the account can be retrieved 133 | with Actual(f"http://localhost:{port}", password="mypass", file="My Budget") as actual: 134 | assert len(get_accounts(actual.session)) == 1 135 | 136 | 137 | def test_redownload_file(actual_server, tmp_path): 138 | port = actual_server.get_exposed_port(5006) 139 | with Actual(f"http://localhost:{port}", password="mypass", bootstrap=True) as actual: 140 | actual.create_budget("My Budget") 141 | actual.upload_budget() 142 | file_id = actual.get_metadata()["cloudFileId"] 143 | # do a normal download and see if the folder matches the fileId that was initially generated 144 | with Actual(f"http://localhost:{port}", password="mypass", file="My Budget") as actual: 145 | assert str(actual._data_dir).endswith(file_id) 146 | # download to a certain folder 147 | with Actual(f"http://localhost:{port}", password="mypass", file="My Budget", data_dir=tmp_path) as actual: 148 | get_or_create_account(actual.session, "Bank") 149 | actual.commit() 150 | assert not str(actual._data_dir).endswith(file_id) 151 | # reupload the budget 152 | with Actual(f"http://localhost:{port}", password="mypass", file="My Budget", data_dir=tmp_path) as actual: 153 | actual.reupload_budget() 154 | with pytest.warns(match="Sync id has been reset on remote database, re-downloading the budget"): 155 | with Actual(f"http://localhost:{port}", password="mypass", file="My Budget", data_dir=tmp_path): 156 | pass 157 | 158 | 159 | def test_models(actual_server): 160 | port = actual_server.get_exposed_port(5006) 161 | with Actual(f"http://localhost:{port}", password="mypass", encryption_password="mypass", bootstrap=True) as actual: 162 | actual.create_budget("My Budget") 163 | # check if the models are matching 164 | metadata = reflect_model(actual.session.bind) 165 | # check first if all tables are present 166 | for table_name, table in metadata.tables.items(): 167 | assert table_name in __TABLE_COLUMNS_MAP__, f"Missing table '{table_name}' on models." 168 | # then assert if all columns are matching the model 169 | for column_name in table.columns.keys(): 170 | assert ( 171 | column_name in __TABLE_COLUMNS_MAP__[table_name]["columns"] 172 | ), f"Missing column '{column_name}' at table '{table_name}'." 173 | 174 | 175 | def test_header_login(): 176 | # TODO: this is fixed on a previous version since header login doesn't seem to be working fully on latest version 177 | working_version = "25.3.0" 178 | with ( 179 | DockerContainer(f"actualbudget/actual-server:{working_version}") 180 | .with_env("ACTUAL_LOGIN_METHOD", "header") 181 | .with_exposed_ports(5006) as container 182 | ): 183 | port = container.get_exposed_port(5006) 184 | wait_for_logs(container, "Listening on :::5006...") 185 | with Actual(f"http://localhost:{port}", password="mypass", bootstrap=True): 186 | pass 187 | # make sure we can log in 188 | actual = Actual(f"http://localhost:{port}", password="mypass") 189 | response_login = actual.login("mypass") 190 | response_header_login = actual.login("mypass", "header") 191 | assert response_login.data.token == response_header_login.data.token 192 | 193 | 194 | def test_session_reflection_after_migrations(): 195 | with DockerContainer(f"actualbudget/actual-server:{VERSIONS[-1]}").with_exposed_ports(5006) as container: 196 | port = container.get_exposed_port(5006) 197 | wait_for_logs(container, "Listening on :::5006...") 198 | with Actual(f"http://localhost:{port}", password="mypass", bootstrap=True) as actual: 199 | actual.create_budget("My Budget") 200 | actual.upload_budget() 201 | # add a dashboard entry 202 | actual.session.add(Dashboard(id="123", x=1, y=2)) 203 | actual.commit() 204 | # revert the dashboard creation migration like it never happened 205 | Dashboard.__table__.drop(actual.engine) 206 | actual.session.exec(delete(Migrations).where(Migrations.id == 1722804019000)) 207 | actual.session.commit() 208 | # now try to download the budget, it should not fail 209 | with Actual(f"http://localhost:{port}", file="My Budget", password="mypass") as actual: 210 | assert len(actual.session.exec(select(Dashboard)).all()) > 2 # there are two default dashboards 211 | 212 | 213 | def test_empty_query_migrations(): 214 | # empty queries should not fail 215 | assert js_migration_statements("await db.runQuery('');") == [] 216 | # malformed entries should not fail 217 | assert js_migration_statements("await db.runQuery(") == [] 218 | # weird formats neither 219 | assert js_migration_statements("db.runQuery\n('update 1')") == ["update 1;"] 220 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import uuid 3 | 4 | import pytest 5 | 6 | from actual.database import ( 7 | CategoryMapping, 8 | Transactions, 9 | get_attribute_by_table_name, 10 | get_class_by_table_name, 11 | ) 12 | from actual.utils.conversions import current_timestamp 13 | 14 | 15 | def test_get_class_by_table_name(): 16 | assert get_class_by_table_name("transactions") == Transactions 17 | assert get_class_by_table_name("foo") is None 18 | 19 | 20 | def test_get_attribute_by_table_name(): 21 | assert get_attribute_by_table_name("transactions", "isParent") == "is_parent" 22 | assert get_attribute_by_table_name("transactions", "is_parent", reverse=True) == "isParent" 23 | assert get_attribute_by_table_name("transactions", "category") == "category_id" 24 | assert get_attribute_by_table_name("transactions", "category_id", reverse=True) == "category" 25 | assert get_attribute_by_table_name("transactions", "foo") is None 26 | assert get_attribute_by_table_name("transactions", "foo", reverse=True) is None 27 | assert get_attribute_by_table_name("foo", "bar") is None 28 | assert get_attribute_by_table_name("foo", "bar", reverse=True) is None 29 | 30 | 31 | def test_conversion(): 32 | t = Transactions( 33 | id=str(uuid.uuid4()), 34 | acct="foo", 35 | amount=1000, 36 | reconciled=0, 37 | cleared=0, 38 | sort_order=current_timestamp(), 39 | ) 40 | t.set_amount(10) 41 | t.set_date(datetime.date(2024, 3, 17)) 42 | # ensure fields are correctly retrieved 43 | assert t.get_amount() == 10 44 | assert t.get_date() == datetime.date(2024, 3, 17) 45 | # modified one field after-wards 46 | t.is_parent = 1 47 | conversion = t.convert() 48 | # conversion should all contain the same row id and same dataset 49 | assert all(c.dataset == "transactions" for c in conversion) 50 | assert all(c.row == conversion[0].row for c in conversion) 51 | # check fields 52 | assert [c for c in conversion if c.column == "acct"][0].get_value() == "foo" 53 | assert [c for c in conversion if c.column == "amount"][0].get_value() == 1000 54 | assert [c for c in conversion if c.column == "date"][0].get_value() == 20240317 55 | assert [c for c in conversion if c.column == "isParent"][0].get_value() == 1 56 | # make sure delete only changes the tomstone 57 | assert t.tombstone is None # server default is 0, but local copy is None 58 | t.delete() 59 | assert t.tombstone == 1 60 | 61 | 62 | def test_delete_exception(): 63 | cm = CategoryMapping(id="foo") 64 | with pytest.raises(AttributeError): 65 | cm.delete() 66 | -------------------------------------------------------------------------------- /tests/test_protobuf.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from actual.protobuf_models import ( 6 | HULC_Client, 7 | Message, 8 | MessageEnvelope, 9 | SyncRequest, 10 | SyncResponse, 11 | ) 12 | 13 | 14 | def test_timestamp(): 15 | now = datetime.datetime(2020, 10, 11, 12, 13, 14, 15 * 1000) 16 | ts = HULC_Client("foo").timestamp(now) 17 | assert ts == "2020-10-11T12:13:14.015Z-0000-foo" 18 | assert "foo" == HULC_Client.from_timestamp(ts).client_id 19 | 20 | 21 | def test_message_envelope(): 22 | me = MessageEnvelope() 23 | me.set_timestamp() 24 | assert isinstance(MessageEnvelope.serialize(me), bytes) 25 | 26 | 27 | def test_sync_request(): 28 | m = Message({"dataset": "foo", "row": "bar", "column": "foobar"}) 29 | m.set_value("example") 30 | req = SyncRequest() 31 | req.set_null_timestamp() 32 | req.set_messages([m], HULC_Client()) 33 | # create a sync response from the messages array 34 | sr = SyncResponse({"merkle": "", "messages": req.messages}) 35 | messages_decoded = sr.get_messages() 36 | assert messages_decoded == [m] 37 | 38 | 39 | def test_message_set_value(): 40 | m = Message() 41 | for data in ["foo", 1, 1.5, None]: 42 | m.set_value(data) 43 | assert m.get_value() == data 44 | with pytest.raises(ValueError): 45 | m.set_value(object()) # noqa 46 | with pytest.raises(ValueError): 47 | m.value = "T:foo" 48 | m.get_value() 49 | -------------------------------------------------------------------------------- /tests/test_rules.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import uuid 3 | 4 | import pytest 5 | 6 | from actual import ActualError 7 | from actual.exceptions import ActualSplitTransactionError 8 | from actual.queries import ( 9 | create_account, 10 | create_category, 11 | create_payee, 12 | create_transaction, 13 | ) 14 | from actual.rules import ( 15 | Action, 16 | ActionType, 17 | Condition, 18 | ConditionType, 19 | Rule, 20 | RuleSet, 21 | ValueType, 22 | condition_evaluation, 23 | ) 24 | 25 | 26 | def test_category_rule(session): 27 | # create basic items 28 | acct = create_account(session, "Bank") 29 | cat = create_category(session, "Food", "Expenses") 30 | payee = create_payee(session, "My payee") 31 | # create rule 32 | condition = Condition(field="category", op="is", value=cat) 33 | action = Action(field="description", value=payee) 34 | rule = Rule(conditions=[condition], actions=[action], operation="all") 35 | rs = RuleSet(rules=[]) 36 | assert list(rs) == [] 37 | rs.add(rule) 38 | # run for one transaction 39 | t = create_transaction(session, datetime.date(2024, 1, 1), acct, "", category=cat) 40 | rs.run(t) 41 | # evaluate if things match 42 | assert t.payee_id == payee.id 43 | assert ( 44 | str(rs) == f"If all of these conditions match 'category' is '{cat.id}' " 45 | f"then set 'description' to '{payee.id}'" 46 | ) 47 | # check if it ignores the input when making the category None 48 | t.category_id = None 49 | assert condition.run(t) is False 50 | 51 | 52 | def test_datetime_rule(session): 53 | acct = create_account(session, "Bank") 54 | t = create_transaction(session, datetime.date(2024, 1, 1), acct, "") 55 | condition = Condition(field="date", op="isapprox", value=datetime.date(2024, 1, 2)) 56 | action = Action(field="date", value="2024-01-30") 57 | rs = RuleSet(rules=[Rule(conditions=[condition], actions=[action], operation="any")]) 58 | # run only first stage 59 | rs.run([t], stage=None) 60 | target_date = datetime.date(2024, 1, 30) 61 | assert t.get_date() == target_date 62 | # try the is not 63 | assert Condition(field="date", op="is", value=target_date).run(t) is True 64 | # try the comparison operators 65 | assert Condition(field="date", op="gte", value=target_date).run(t) is True 66 | assert Condition(field="date", op="lte", value=target_date).run(t) is True 67 | assert Condition(field="date", op="gte", value=target_date - datetime.timedelta(days=1)).run(t) is True 68 | assert Condition(field="date", op="lte", value=target_date + datetime.timedelta(days=1)).run(t) is True 69 | assert Condition(field="date", op="lt", value=target_date).run(t) is False 70 | assert Condition(field="date", op="gt", value=target_date).run(t) is False 71 | assert Condition(field="date", op="gt", value=target_date - datetime.timedelta(days=1)).run(t) is True 72 | assert Condition(field="date", op="lt", value=target_date + datetime.timedelta(days=1)).run(t) is True 73 | 74 | 75 | def test_string_condition(session): 76 | acct = create_account(session, "Bank") 77 | t = create_transaction(session, datetime.date(2024, 1, 1), acct, "", "foo") 78 | assert Condition(field="notes", op="oneOf", value=["foo", "bar"]).run(t) is True 79 | assert Condition(field="notes", op="notOneOf", value=["foo", "bar"]).run(t) is False 80 | assert Condition(field="notes", op="contains", value="fo").run(t) is True 81 | assert Condition(field="notes", op="contains", value="foobar").run(t) is False 82 | assert Condition(field="notes", op="matches", value="f.*").run(t) is True 83 | assert Condition(field="notes", op="matches", value="g.*").run(t) is False 84 | assert Condition(field="notes", op="doesNotContain", value="foo").run(t) is False 85 | assert Condition(field="notes", op="doesNotContain", value="foobar").run(t) is True 86 | # case insensitive entries 87 | assert Condition(field="notes", op="oneOf", value=["FOO", "BAR"]).run(t) is True 88 | assert Condition(field="notes", op="notOneOf", value=["FOO", "BAR"]).run(t) is False 89 | assert Condition(field="notes", op="contains", value="FO").run(t) is True 90 | assert Condition(field="notes", op="contains", value="FOOBAR").run(t) is False 91 | assert Condition(field="notes", op="matches", value="F.*").run(t) is True 92 | assert Condition(field="notes", op="matches", value="G.*").run(t) is False 93 | assert Condition(field="notes", op="doesNotContain", value="FOO").run(t) is False 94 | assert Condition(field="notes", op="doesNotContain", value="FOOBAR").run(t) is True 95 | 96 | 97 | def test_has_tags(session): 98 | acct = create_account(session, "Bank") 99 | t = create_transaction(session, datetime.date(2024, 1, 1), acct, "", "foo #bar #✨ #🙂‍↔️") 100 | assert Condition(field="notes", op="hasTags", value="#bar").run(t) is True 101 | assert Condition(field="notes", op="hasTags", value="#foo").run(t) is False 102 | # test other unicode entries 103 | assert Condition(field="notes", op="hasTags", value="#emoji #✨").run(t) is True 104 | assert Condition(field="notes", op="hasTags", value="#🙂‍↔️").run(t) is True # new emojis should be supported 105 | assert Condition(field="notes", op="hasTags", value="bar").run(t) is False # individual string will not match 106 | 107 | 108 | @pytest.mark.parametrize( 109 | "op,condition_value,value,expected_result", 110 | [ 111 | ("contains", "supermarket", "Best Supermarket", True), 112 | ("contains", "supermarket", None, False), 113 | ("oneOf", ["my supermarket", "other supermarket"], "MY SUPERMARKET", True), 114 | ("oneOf", ["supermarket"], None, False), 115 | ("matches", "market", "hypermarket", True), 116 | ], 117 | ) 118 | def test_imported_payee_condition(session, op, condition_value, value, expected_result): 119 | create_account(session, "Bank") 120 | t = create_transaction(session, datetime.date(2024, 1, 1), "Bank", "", amount=5, imported_payee=value) 121 | condition = {"field": "imported_description", "type": "imported_payee", "op": op, "value": condition_value} 122 | cond = Condition.model_validate(condition) 123 | assert cond.run(t) == expected_result 124 | 125 | 126 | def test_numeric_condition(session): 127 | create_account(session, "Bank") 128 | t = create_transaction(session, datetime.date(2024, 1, 1), "Bank", "", amount=5) 129 | c1 = Condition(field="amount_inflow", op="gt", value=10.0) 130 | assert "inflow" in c1.options 131 | assert c1.run(t) is False 132 | c2 = Condition(field="amount_outflow", op="lt", value=-10.0) 133 | assert "outflow" in c2.options 134 | assert c2.run(t) is False # outflow, so the comparison should be with the positive value 135 | # isapprox condition 136 | c2 = Condition(field="amount", op="isapprox", value=5.1) 137 | assert c2.run(t) is True 138 | c3 = Condition(field="amount", op="isapprox", value=5.5) 139 | assert c3.run(t) is False 140 | # isbetween condition 141 | c4 = Condition(field="amount", op="isbetween", value={"num1": 5.0, "num2": 10.0}) 142 | assert c4.run(t) is True 143 | assert str(c4) == "'amount' isbetween (500, 1000)" # value gets converted when input as float 144 | 145 | 146 | def test_complex_rule(session): 147 | # create basic items 148 | acct = create_account(session, "Bank") 149 | cat = create_category(session, "Food", "Expenses") 150 | cat_extra = create_category(session, "Restaurants", "Expenses") 151 | payee = create_payee(session, "My payee") 152 | # create rule set 153 | rs = RuleSet( 154 | rules=[ 155 | Rule( 156 | conditions=[ 157 | Condition( 158 | field="category", 159 | op=ConditionType.ONE_OF, 160 | value=[cat], 161 | ), 162 | Condition( 163 | field="category", 164 | op=ConditionType.NOT_ONE_OF, 165 | value=[cat_extra], 166 | ), 167 | Condition(field="description", op="isNot", value=str(uuid.uuid4())), # should not match 168 | ], 169 | actions=[Action(field="cleared", value=True)], 170 | operation="all", 171 | ) 172 | ] 173 | ) 174 | t_true = create_transaction(session, datetime.date(2024, 1, 1), acct, payee, category=cat) 175 | t_false = create_transaction(session, datetime.date(2024, 1, 1), acct, payee) 176 | rs.run([t_true, t_false]) 177 | assert t_true.cleared == 1 178 | assert t_false.cleared == 0 179 | 180 | 181 | def test_invalid_inputs(): 182 | with pytest.raises(ValueError): 183 | Condition(field="amount", op="gt", value="foo") 184 | with pytest.raises(ValueError): 185 | Condition(field="amount", op="contains", value=10) 186 | with pytest.raises(ValueError): 187 | Action(field="date", value="foo") 188 | with pytest.raises(ValueError): 189 | Condition(field="description", op="is", value="foo") # not an uuid 190 | with pytest.raises(ValueError): 191 | Condition(field="amount", op="isbetween", value=5) 192 | with pytest.raises(ActualError): 193 | Action(field="notes", op="set-split-amount", value="foo").run(None) # noqa: use None instead of transaction 194 | with pytest.raises(ActualError): 195 | condition_evaluation(None, "foo", "foo") # noqa: use None instead of transaction 196 | assert Condition(field="notes", op="is", value=None).get_value() is None # noqa: handle when value is None 197 | 198 | 199 | def test_value_type_condition_validation(): 200 | assert ValueType.DATE.is_valid(ConditionType.IS_APPROX) is True 201 | assert ValueType.DATE.is_valid(ConditionType.CONTAINS) is False 202 | assert ValueType.NUMBER.is_valid(ConditionType.IS_BETWEEN) is True 203 | assert ValueType.NUMBER.is_valid(ConditionType.DOES_NOT_CONTAIN) is False 204 | assert ValueType.BOOLEAN.is_valid(ConditionType.IS) is True 205 | assert ValueType.ID.is_valid(ConditionType.NOT_ONE_OF) is True 206 | assert ValueType.ID.is_valid(ConditionType.CONTAINS) is False 207 | assert ValueType.STRING.is_valid(ConditionType.CONTAINS) is True 208 | assert ValueType.STRING.is_valid(ConditionType.GT) is False 209 | assert ValueType.IMPORTED_PAYEE.is_valid(ConditionType.CONTAINS) is True 210 | assert ValueType.IMPORTED_PAYEE.is_valid(ConditionType.GT) is False 211 | 212 | 213 | def test_value_type_value_validation(): 214 | assert ValueType.DATE.validate(20241004) is True 215 | assert ValueType.DATE.validate(123) is False 216 | assert ValueType.DATE.validate("2024-10-04") is True 217 | assert ValueType.STRING.validate("") is True 218 | assert ValueType.STRING.validate(123) is False 219 | assert ValueType.NUMBER.validate(123) is True 220 | assert ValueType.NUMBER.validate(1.23) is False # noqa: test just in case 221 | assert ValueType.NUMBER.validate("123") is False 222 | assert ValueType.ID.validate("1c1a1707-15ea-4051-b98a-e400ee2900c7") is True 223 | assert ValueType.ID.validate("foo") is False 224 | assert ValueType.BOOLEAN.validate(True) is True 225 | assert ValueType.BOOLEAN.validate("") is False 226 | assert ValueType.IMPORTED_PAYEE.validate("") is True 227 | assert ValueType.IMPORTED_PAYEE.validate(1) is False 228 | # list and NoneType 229 | assert ValueType.DATE.validate(None) 230 | assert ValueType.DATE.validate(["2024-10-04"], ConditionType.ONE_OF) is True 231 | 232 | 233 | def test_value_type_from_field(): 234 | assert ValueType.from_field("description") == ValueType.ID 235 | assert ValueType.from_field("amount") == ValueType.NUMBER 236 | assert ValueType.from_field("notes") == ValueType.STRING 237 | assert ValueType.from_field("date") == ValueType.DATE 238 | assert ValueType.from_field("cleared") == ValueType.BOOLEAN 239 | assert ValueType.from_field("imported_description") == ValueType.IMPORTED_PAYEE 240 | with pytest.raises(ValueError): 241 | ValueType.from_field("foo") 242 | 243 | 244 | @pytest.mark.parametrize( 245 | "method,value,expected_splits", 246 | [ 247 | ("remainder", None, [0.50, 4.50]), 248 | ("fixed-amount", 100, [0.40, 1.00, 3.60]), 249 | ("fixed-percent", 20, [0.50, 1.00, 3.50]), 250 | ], 251 | ) 252 | def test_set_split_amount(session, method, value, expected_splits): 253 | acct = create_account(session, "Bank") 254 | cat = create_category(session, "Food", "Expenses") 255 | payee = create_payee(session, "My payee") 256 | alternative_payee = create_payee(session, "My other payee") 257 | 258 | rs = RuleSet( 259 | rules=[ 260 | Rule( 261 | conditions=[Condition(field="category", op=ConditionType.ONE_OF, value=[cat])], 262 | actions=[ 263 | Action( 264 | field=None, 265 | op=ActionType.SET_SPLIT_AMOUNT, 266 | value=10, 267 | options={"splitIndex": 1, "method": "fixed-percent"}, 268 | ), 269 | Action( 270 | field=None, 271 | op=ActionType.SET_SPLIT_AMOUNT, 272 | value=value, 273 | options={"splitIndex": 2, "method": method}, 274 | ), 275 | # add one action that changes the second split payee 276 | Action( 277 | field="description", op=ActionType.SET, value=alternative_payee.id, options={"splitIndex": 2} 278 | ), 279 | ], 280 | ) 281 | ] 282 | ) 283 | t = create_transaction(session, datetime.date(2024, 1, 1), acct, payee, category=cat, amount=5.0) 284 | session.flush() 285 | rs.run(t) 286 | session.refresh(t) 287 | assert [float(s.get_amount()) for s in t.splits] == expected_splits 288 | # check the first split has the original payee, and the second split has the payee from the action 289 | assert t.splits[0].payee_id == payee.id 290 | assert t.splits[1].payee_id == alternative_payee.id 291 | # check string comparison 292 | assert ( 293 | str(rs.rules[0]) == f"If all of these conditions match 'category' oneOf ['{cat.id}'] then " 294 | f"allocate a fixed-percent at Split 1: 10, " 295 | f"allocate a {method} at Split 2: {value}, " 296 | f"set 'description' at Split 2 to '{alternative_payee.id}'" 297 | ) 298 | 299 | 300 | @pytest.mark.parametrize( 301 | "method,n,expected_splits", 302 | [ 303 | # test equal remainders 304 | ("remainder", 1, [5.00]), 305 | ("remainder", 2, [2.50, 2.50]), 306 | ("remainder", 3, [1.67, 1.67, 1.66]), 307 | ("remainder", 4, [1.25, 1.25, 1.25, 1.25]), 308 | ("remainder", 5, [1.00, 1.00, 1.00, 1.00, 1.00]), 309 | ("remainder", 6, [0.83, 0.83, 0.83, 0.83, 0.83, 0.85]), 310 | # and fixed amount 311 | ("fixed-amount", 1, [1.0, 4.0]), 312 | ("fixed-amount", 2, [1.0, 1.0, 3.0]), 313 | ("fixed-amount", 3, [1.0, 1.0, 1.0, 2.0]), 314 | ("fixed-amount", 4, [1.0, 1.0, 1.0, 1.0, 1.0]), 315 | ("fixed-amount", 5, [1.0, 1.0, 1.0, 1.0, 1.0]), 316 | ("fixed-amount", 6, [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0]), 317 | ], 318 | ) 319 | def test_split_amount_equal_parts(session, method, n, expected_splits): 320 | acct = create_account(session, "Bank") 321 | actions = [ 322 | Action( 323 | field=None, 324 | op=ActionType.SET_SPLIT_AMOUNT, 325 | value=100, # value is only used for fixed-amount 326 | options={"splitIndex": i + 1, "method": method}, 327 | ) 328 | for i in range(n) 329 | ] 330 | rs = Rule(conditions=[], actions=actions) 331 | t = create_transaction(session, datetime.date(2024, 1, 1), acct, "", amount=5.0) 332 | session.flush() 333 | # test split amounts 334 | splits = rs.set_split_amount(t) 335 | assert [float(s.get_amount()) for s in splits] == expected_splits 336 | 337 | 338 | def test_set_split_amount_exception(session, mocker): 339 | mocker.patch("actual.rules.sum", lambda x: 0) 340 | 341 | acct = create_account(session, "Bank") 342 | cat = create_category(session, "Food", "Expenses") 343 | payee = create_payee(session, "My payee") 344 | 345 | rs = RuleSet( 346 | rules=[ 347 | Rule( 348 | conditions=[Condition(field="category", op=ConditionType.ONE_OF, value=[cat])], 349 | actions=[ 350 | Action( 351 | field=None, 352 | op=ActionType.SET_SPLIT_AMOUNT, 353 | value=10, 354 | options={"splitIndex": 1, "method": "fixed-percent"}, 355 | ) 356 | ], 357 | ) 358 | ] 359 | ) 360 | t = create_transaction(session, datetime.date(2024, 1, 1), acct, payee, category=cat, amount=5.0) 361 | session.flush() 362 | with pytest.raises(ActualSplitTransactionError): 363 | rs.run(t) 364 | 365 | 366 | @pytest.mark.parametrize( 367 | "operation,value,note,expected", 368 | [ 369 | ("append-notes", "bar", "foo", "foobar"), 370 | ("prepend-notes", "bar", "foo", "barfoo"), 371 | ("append-notes", "bar", None, "bar"), 372 | ("prepend-notes", "bar", None, "bar"), 373 | ], 374 | ) 375 | def test_preppend_append_notes(session, operation, value, note, expected): 376 | create_account(session, "Bank") 377 | t = create_transaction(session, datetime.date(2024, 1, 1), "Bank", "", notes=note) 378 | action = Action(field="description", op=operation, value=value) 379 | action.run(t) 380 | assert t.notes == expected 381 | action.run(t) # second iteration should not update the result 382 | assert t.notes == expected 383 | assert f"{operation.split('-')[0]} to notes '{value}'" in str(action) 384 | 385 | 386 | def test_set_transfer_payee_rule(session): 387 | bank = create_account(session, "Bank") 388 | t = create_transaction(session, datetime.date(2024, 1, 1), "Bank", amount=10) 389 | action = Action(field="description", op="set", value=bank.payee.id) 390 | action.run(t) 391 | assert t.transferred_id is not None 392 | assert t.transfer is not None 393 | assert t.get_amount() == -t.transfer.get_amount() 394 | assert t.id == t.transfer.transferred_id 395 | assert t.transferred_id == t.transfer.id 396 | -------------------------------------------------------------------------------- /tests/test_schedules.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from unittest.mock import MagicMock 3 | 4 | import pytest 5 | 6 | from actual.queries import create_account, create_transaction 7 | from actual.rules import Rule 8 | from actual.schedules import Schedule, date_to_datetime 9 | 10 | 11 | def test_basic_schedules(): 12 | s = Schedule.model_validate( 13 | { 14 | "start": "2024-05-12", 15 | "frequency": "monthly", 16 | "skipWeekend": False, 17 | "endMode": "after_n_occurrences", 18 | "endOccurrences": 3, 19 | "interval": 1, 20 | } 21 | ) 22 | assert s.before(date(2024, 5, 13)) == date(2024, 5, 12) 23 | assert s.xafter(date(2024, 5, 12), 4) == [ 24 | date(2024, 5, 12), 25 | date(2024, 6, 12), 26 | date(2024, 7, 12), 27 | ] 28 | 29 | assert str(s) == "Every month on the 12th, 3 times" 30 | 31 | 32 | def test_complex_schedules(): 33 | s = Schedule.model_validate( 34 | { 35 | "start": "2024-05-08", 36 | "frequency": "monthly", 37 | "patterns": [ 38 | {"value": -1, "type": "SU"}, 39 | {"value": 2, "type": "SA"}, 40 | {"value": 10, "type": "day"}, 41 | {"value": 31, "type": "day"}, 42 | {"value": 5, "type": "day"}, 43 | ], 44 | "skipWeekend": True, 45 | "weekendSolveMode": "after", 46 | "endMode": "never", 47 | "endOccurrences": 1, 48 | "endDate": "2024-05-08", 49 | "interval": 1, 50 | } 51 | ) 52 | assert s.xafter(date(2024, 5, 10), count=5) == [ 53 | date(2024, 5, 10), 54 | date(2024, 5, 13), 55 | date(2024, 5, 27), 56 | date(2024, 5, 31), 57 | date(2024, 6, 5), 58 | ] 59 | # change the solve mode to before 60 | s.weekend_solve_mode = "before" 61 | assert s.xafter(date(2024, 5, 10), count=5) == [ 62 | date(2024, 5, 10), 63 | # according to frontend, this entry happens twice 64 | date(2024, 5, 10), 65 | date(2024, 5, 24), 66 | date(2024, 5, 31), 67 | date(2024, 6, 5), 68 | ] 69 | 70 | assert str(s) == "Every month on the last Sunday, 2nd Saturday, 10th, 31st, 5th (before weekend)" 71 | 72 | 73 | def test_skip_weekend_after_schedule(): 74 | s = Schedule.model_validate( 75 | { 76 | "start": "2024-08-14", 77 | "interval": 1, 78 | "frequency": "monthly", 79 | "patterns": [], 80 | "skipWeekend": True, 81 | "weekendSolveMode": "after", 82 | "endMode": "on_date", 83 | "endOccurrences": 1, 84 | "endDate": "2024-09-14", 85 | } 86 | ) 87 | after = s.xafter(date(2024, 9, 10), count=2) 88 | # we should ensure that dates that fall outside the endDate are not covered, even though actual will accept it 89 | assert after == [] 90 | 91 | 92 | def test_skip_weekend_before_schedule(): 93 | s = Schedule.model_validate( 94 | { 95 | "start": "2024-04-10", 96 | "interval": 1, 97 | "frequency": "monthly", 98 | "patterns": [], 99 | "skipWeekend": True, 100 | "weekendSolveMode": "before", 101 | "endMode": "never", 102 | "endOccurrences": 1, 103 | "endDate": "2024-04-10", 104 | } 105 | ) 106 | before = s.before(date(2024, 8, 14)) 107 | assert before == date(2024, 8, 9) 108 | # check that it wouldn't pick itself 109 | assert s.before(date(2024, 7, 10)) == date(2024, 6, 10) 110 | # we should ensure that dates that fall outside the endDate are not covered, even though actual will accept it 111 | s.start = date(2024, 9, 21) 112 | assert s.before(date(2024, 9, 22)) is None 113 | 114 | 115 | def test_is_approx(): 116 | # create schedule for every 1st and last day of the month (30th or 31st) 117 | s = Schedule.model_validate( 118 | { 119 | "start": "2024-05-10", 120 | "frequency": "monthly", 121 | "patterns": [ 122 | {"value": 1, "type": "day"}, 123 | {"value": -1, "type": "day"}, 124 | ], 125 | "skipWeekend": True, 126 | "weekendSolveMode": "after", 127 | "endMode": "on_date", 128 | "endOccurrences": 1, 129 | "endDate": "2024-07-01", 130 | "interval": 1, 131 | } 132 | ) 133 | # make sure the xafter is correct 134 | assert s.xafter(date(2024, 6, 1), 5) == [ 135 | date(2024, 6, 3), 136 | date(2024, 7, 1), 137 | date(2024, 7, 1), 138 | ] 139 | # compare is_approx 140 | assert s.is_approx(date(2024, 5, 1)) is False # before starting period 141 | assert s.is_approx(date(2024, 5, 30)) is True 142 | assert s.is_approx(date(2024, 5, 31)) is True 143 | assert s.is_approx(date(2024, 6, 1)) is True 144 | assert s.is_approx(date(2024, 6, 3)) is True # because 1st is also included 145 | 146 | # 30th June is a sunday, so the right date would be 1st of June 147 | assert s.is_approx(date(2024, 6, 28)) is False 148 | assert s.is_approx(date(2024, 6, 30)) is True 149 | assert s.is_approx(date(2024, 7, 1)) is True 150 | 151 | # after end date we reject everything 152 | assert s.is_approx(date(2024, 7, 2)) is False 153 | assert s.is_approx(date(2024, 7, 31)) is False 154 | 155 | assert str(s) == "Every month on the 1st, last day, until 2024-07-01 (after weekend)" 156 | 157 | 158 | def test_date_to_datetime(): 159 | dt = date(2024, 5, 1) 160 | assert date_to_datetime(dt).date() == dt 161 | assert date_to_datetime(None) is None 162 | 163 | 164 | def test_exceptions(): 165 | with pytest.raises(ValueError): 166 | # on_date is set but no date is provided 167 | Schedule.model_validate( 168 | { 169 | "start": "2024-05-12", 170 | "frequency": "monthly", 171 | "skipWeekend": False, 172 | "endMode": "on_date", 173 | "endOccurrences": 3, 174 | "interval": 1, 175 | } 176 | ) 177 | 178 | 179 | def test_strings(): 180 | assert str(Schedule(start="2024-05-12", frequency="yearly")) == "Every year on May 12" 181 | assert str(Schedule(start="2024-05-12", frequency="weekly")) == "Every week on Sunday" 182 | assert str(Schedule(start="2024-05-12", frequency="daily")) == "Every day" 183 | 184 | 185 | def test_scheduled_rule(): 186 | mock = MagicMock() 187 | acct = create_account(mock, "Bank") 188 | rule = Rule( 189 | id="d84d1400-4245-4bb9-95d0-be4524edafe9", 190 | conditions=[ 191 | { 192 | "op": "isapprox", 193 | "field": "date", 194 | "value": { 195 | "start": "2024-05-01", 196 | "frequency": "monthly", 197 | "patterns": [], 198 | "skipWeekend": False, 199 | "weekendSolveMode": "after", 200 | "endMode": "never", 201 | "endOccurrences": 1, 202 | "endDate": "2024-05-14", 203 | "interval": 1, 204 | }, 205 | }, 206 | {"op": "isapprox", "field": "amount", "value": -2000}, 207 | {"op": "is", "field": "acct", "value": acct.id}, 208 | ], 209 | stage=None, 210 | actions=[{"op": "link-schedule", "value": "df1e464f-13ae-4a97-a07e-990faeb48b2f"}], 211 | conditions_op="and", 212 | ) 213 | assert "'date' isapprox 'Every month on the 1st'" in str(rule) 214 | 215 | transaction_matching = create_transaction(mock, date(2024, 5, 2), acct, None, amount=-19) 216 | transaction_not_matching = create_transaction(mock, date(2024, 5, 2), acct, None, amount=-15) 217 | rule.run(transaction_matching) 218 | rule.run(transaction_not_matching) 219 | 220 | assert transaction_matching.schedule_id == "df1e464f-13ae-4a97-a07e-990faeb48b2f" 221 | assert transaction_not_matching.schedule_id is None 222 | --------------------------------------------------------------------------------