├── .coveragerc ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CHANGES.txt ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── VERSION ├── docker-compose.yaml ├── docs ├── .gitignore ├── Makefile ├── _static │ └── peter-selinger-tutorial.pdf ├── accounting-for-developers.rst ├── api │ ├── exceptions.rst │ ├── forms.rst │ ├── index.rst │ ├── models.rst │ ├── models_statements.rst │ ├── utilities_currency.rst │ ├── utilities_database.rst │ ├── utilities_money.rst │ └── views.rst ├── changelog.rst ├── conf.py ├── customising-templates.rst ├── hordak-database-triggers.rst ├── index.rst ├── installation.rst ├── make.bat ├── notes.rst ├── requirements.txt ├── settings.rst └── test-import.csv ├── example_project ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py ├── hordak ├── __init__.py ├── admin.py ├── apps.py ├── data_sources │ ├── __init__.py │ └── tellerio.py ├── defaults.py ├── exceptions.py ├── fixtures │ └── top-level-accounts.json ├── forms │ ├── __init__.py │ ├── accounts.py │ ├── statement_csv_import.py │ └── transactions.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── create_benchmark_accounts.py │ │ ├── create_benchmark_transactions.py │ │ └── create_chart_of_accounts.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_check_leg_trigger_20160903_1149.py │ ├── 0003_check_zero_amount_20160907_0921.py │ ├── 0004_auto_20161113_1932.py │ ├── 0005_account_currencies.py │ ├── 0006_auto_20161209_0108.py │ ├── 0007_auto_20161209_0111.py │ ├── 0008_auto_20161209_0129.py │ ├── 0009_bank_accounts_are_asset_accounts.py │ ├── 0010_auto_20161216_1202.py │ ├── 0011_auto_20170225_2222.py │ ├── 0012_account_full_code.py │ ├── 0013_trigger_full_account_code.py │ ├── 0014_auto_20170302_1944.py │ ├── 0015_auto_20170302_2109.py │ ├── 0016_check_account_type.py │ ├── 0017_auto_20171203_1516.py │ ├── 0018_auto_20171205_1256.py │ ├── 0019_statementimport_source.py │ ├── 0020_auto_20171205_1424.py │ ├── 0021_auto_20180329_1426.py │ ├── 0022_auto_20180825_1026.py │ ├── 0023_auto_20180825_1029.py │ ├── 0024_auto_20180827_1148.py │ ├── 0025_auto_20180829_1605.py │ ├── 0026_auto_20190723_0929.py │ ├── 0027_trigger_update_full_account_codes_effective.py │ ├── 0028_auto_20220215_1847.py │ ├── 0029_alter_leg_amount_currency_and_more.py │ ├── 0030_alter_leg_amount_currency.py │ ├── 0031_alter_account_currencies.py │ ├── 0032_check_account_type_big_int.py │ ├── 0033_alter_account_currencies.py │ ├── 0034_alter_account_currencies_alter_leg_amount_currency.py │ ├── 0035_account_currencies_json.py │ ├── 0036_remove_currencies_and_rename_account_currencies_json_to_currencies.py │ ├── 0037_auto_20230516_0142.py │ ├── 0038_alter_account_id_alter_leg_id_and_more.py │ ├── 0039_recreate_update_full_account_codes.py │ ├── 0040_alter_account_name.py │ ├── 0041_auto_20240528_2107.py │ ├── 0042_alter_account_code_alter_account_full_code.py │ ├── 0043_hordak_leg_view.py │ ├── 0044_legview.py │ ├── 0045_alter_account_currencies.py │ ├── 0046_alter_account_uuid_alter_leg_uuid_and_more.py │ ├── 0047_get_balance.mysql.sql │ ├── 0047_get_balance.pg.sql │ ├── 0047_get_balance.py │ ├── 0048_hordak_transaction_view.mysql.sql │ ├── 0048_hordak_transaction_view.pg.sql │ ├── 0048_hordak_transaction_view.py │ ├── 0049_transactionview.py │ ├── 0050_amount_to_debit_credit_add_fields.py │ ├── 0051_amount_to_debit_credit_data.py │ ├── 0052_amount_to_debit_credit_sql.mysql.sql │ ├── 0052_amount_to_debit_credit_sql.pg.sql │ ├── 0052_amount_to_debit_credit_sql.py │ ├── 0053_remove_leg_amount_remove_leg_amount_currency.py │ ├── 0054_check_debit_credit_positive.py │ ├── 0054_check_debit_credit_positive.sql │ └── __init__.py ├── models │ ├── __init__.py │ ├── core.py │ ├── db_views.py │ └── statement_csv_import.py ├── resources.py ├── templates │ ├── hordak │ │ ├── accounts │ │ │ ├── account_create.html │ │ │ ├── account_list.html │ │ │ ├── account_transactions.html │ │ │ └── account_update.html │ │ ├── base.html │ │ ├── partials │ │ │ └── form.html │ │ ├── statement_import │ │ │ ├── _import_errors.html │ │ │ ├── _import_info_boxes.html │ │ │ ├── import_create.html │ │ │ ├── import_dry_run.html │ │ │ ├── import_execute.html │ │ │ └── import_setup.html │ │ └── transactions │ │ │ ├── currency_trade.html │ │ │ ├── leg_list.html │ │ │ ├── reconcile.html │ │ │ ├── transaction_create.html │ │ │ ├── transaction_delete.html │ │ │ └── transaction_list.html │ └── registration │ │ └── login.html ├── templatetags │ ├── __init__.py │ └── hordak.py ├── tests │ ├── __init__.py │ ├── admin │ │ ├── __init__.py │ │ └── test_admin.py │ ├── data_sources │ │ ├── __init__.py │ │ └── test_tellerio.py │ ├── forms │ │ ├── __init__.py │ │ ├── test_accounts.py │ │ ├── test_statement_csv_import.py │ │ └── test_transactions.py │ ├── gnucash_files │ │ ├── .gitignore │ │ ├── CapitalGainsTestCase.gnucash │ │ ├── InitialEquityTestCase.gnucash │ │ ├── PrepaidRentTestCase.gnucash │ │ ├── README │ │ └── UtilityBillTestCase.gnucash │ ├── models │ │ ├── __init__.py │ │ ├── test_accounting_tranfer_to.py │ │ ├── test_core.py │ │ ├── test_db_views.py │ │ └── test_statement_csv_import.py │ ├── templatetags │ │ └── test_hordak.py │ ├── test_commands.py │ ├── test_resources.py │ ├── test_worked_examples.py │ ├── utilities │ │ ├── __init__.py │ │ ├── test_account_codes.py │ │ ├── test_currency.py │ │ └── test_money.py │ ├── utils.py │ └── views │ │ ├── __init__.py │ │ ├── test_accounts.py │ │ ├── test_statement_csv_import.py │ │ └── test_transactions.py ├── urls.py ├── utilities │ ├── __init__.py │ ├── account_codes.py │ ├── currency.py │ ├── db.py │ ├── db_functions.py │ ├── dreprecation.py │ ├── migrations.py │ ├── money.py │ ├── statement_import.py │ └── test.py └── views │ ├── __init__.py │ ├── accounts.py │ ├── statement_csv_import.py │ └── transactions.py ├── manage.py ├── pytest.ini ├── requirements.txt ├── requirements_test.txt ├── setup.cfg └── setup.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | 3 | omit = 4 | hordak/apps.py 5 | */migrations/* 6 | plugins = 7 | django_coverage_plugin 8 | branch = True 9 | source = hordak 10 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ '**' ] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | tests: 20 | # The type of runner that the job will run on 21 | runs-on: ubuntu-latest 22 | 23 | strategy: 24 | matrix: 25 | DJANGO_VERSION: ['4.2.*', '5.0.*', '5.1.*'] 26 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 27 | import-export: ['3.3.5', '4.0.*'] 28 | exclude: 29 | - DJANGO_VERSION: '5.0.*' 30 | python-version: '3.8' 31 | - DJANGO_VERSION: '5.0.*' 32 | python-version: '3.9' 33 | 34 | - DJANGO_VERSION: '5.1.*' 35 | python-version: '3.8' 36 | - DJANGO_VERSION: '5.1.*' 37 | python-version: '3.9' 38 | fail-fast: false 39 | 40 | services: 41 | postgres: 42 | image: postgres 43 | env: 44 | POSTGRES_PASSWORD: postgres 45 | options: >- 46 | --health-cmd pg_isready 47 | --health-interval 10s 48 | --health-timeout 5s 49 | --health-retries 5 50 | ports: 51 | - 5432:5432 52 | mariadb: 53 | image: mariadb:10.5.21 54 | env: 55 | MARIADB_DATABASE: mariadb 56 | MYSQL_ALLOW_EMPTY_PASSWORD: yes 57 | options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 58 | ports: 59 | - 3306:3306 60 | 61 | steps: 62 | - uses: actions/checkout@v2 63 | 64 | - name: Set up Python ${{ matrix.python-version }} 65 | uses: actions/setup-python@v2 66 | with: 67 | python-version: ${{ matrix.python-version }} 68 | - uses: actions/cache@v2 69 | with: 70 | path: ~/.cache/pip 71 | key: ${{ hashFiles('setup.py') }}-${{ matrix.DJANGO_VERSION }}-${{ matrix.import-export }} 72 | 73 | - name: Install 74 | run: | 75 | sudo apt-get update 76 | sudo apt-get install -y mariadb-client 77 | pip install -r requirements_test.txt 78 | python setup.py develop 79 | pip install -e . 80 | pip install Django==${{ matrix.DJANGO_VERSION }} --pre 81 | pip install django-import-export==${{ matrix.import-export }} 82 | pip install codecov 83 | 84 | - name: Check All Migrations Exist 85 | run: | 86 | PYTHONPATH=`pwd` ./manage.py makemigrations --check hordak 87 | 88 | - name: Create missing migrations 89 | if: ${{ failure() }} 90 | run: | 91 | PYTHONPATH=`pwd` ./manage.py makemigrations hordak 92 | 93 | - name: Archive created migrations for debugging 94 | if: ${{ failure() }} 95 | uses: actions/upload-artifact@v1 96 | with: 97 | name: created-migrations 98 | path: hordak/migrations 99 | 100 | - name: Testing (PostgreSQL) 101 | run: | 102 | PYTHONPATH=`pwd` python -Wall -W error::DeprecationWarning -m coverage run ./manage.py test hordak 103 | pip install -e . 104 | PYTHONPATH=`pwd` python -Wall -W error::DeprecationWarning -m coverage run --append ./manage.py test hordak # Test with subquery 105 | coverage xml && codecov 106 | env: 107 | DATABASE_URL: "postgresql://postgres:postgres@localhost/postgres" 108 | - name: Testing (MariaDB) 109 | run: | 110 | PYTHONPATH=`pwd` ./manage.py makemigrations --check hordak 111 | 112 | PYTHONPATH=`pwd` python -Wall -W error::DeprecationWarning -m coverage run ./manage.py test hordak 113 | pip install -e . 114 | PYTHONPATH=`pwd` python -Wall -W error::DeprecationWarning -m coverage run --append ./manage.py test hordak # Test with subquery 115 | coverage xml && codecov 116 | env: 117 | DATABASE_URL: "mysql://root@127.0.0.1:3306/mariadb" 118 | 119 | lint: 120 | runs-on: ubuntu-latest 121 | steps: 122 | - uses: actions/checkout@v2 123 | - name: Set up Python 124 | uses: actions/setup-python@v2 125 | - uses: actions/cache@v2 126 | with: 127 | path: ~/.cache/pip 128 | key: ${{ hashFiles('setup.py') }}-${{ matrix.DJANGO_VERSION }} 129 | 130 | - name: Install 131 | run: | 132 | pip install flake8 isort black django-stubs dj_database_url types-six types-requests types-mock 133 | pip install "django-stubs<1.13.0" # Remove this line once https://github.com/typeddjango/django-stubs/issues/1227 is fixed 134 | pip install -r requirements_test.txt 135 | python setup.py develop 136 | pip install -e . 137 | - name: Running Flake8 138 | run: flake8 139 | - name: Running isort 140 | run: python -m isort . --check-only --diff 141 | - name: Running black 142 | run: black --check . 143 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | .config.ini 4 | *.sqlite3 5 | *.pyc 6 | *.swp 7 | static/ 8 | /.idea 9 | .bash_history 10 | /build 11 | /dist 12 | /transaction_imports 13 | 14 | pyproject.toml 15 | poetry.lock 16 | .vscode 17 | 18 | .coverage 19 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | 9 | - repo: https://github.com/ambv/black 10 | rev: 23.3.0 11 | hooks: 12 | - id: black 13 | 14 | - repo: https://github.com/PyCQA/isort 15 | rev: 5.12.0 16 | hooks: 17 | - id: isort 18 | 19 | - repo: https://github.com/PyCQA/flake8 20 | rev: 6.0.0 21 | hooks: 22 | - id: flake8 23 | args: ["--config=setup.cfg"] 24 | additional_dependencies: [flake8-isort] 25 | 26 | # sets up .pre-commit-ci.yaml to ensure pre-commit dependencies stay up to date 27 | ci: 28 | autoupdate_schedule: weekly 29 | skip: [] 30 | submodules: false 31 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.9" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | python: 12 | install: 13 | - requirements: docs/requirements.txt 14 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Adam Charnock 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | include *.rst 3 | recursive-include docs * 4 | include VERSION 5 | include hordak/migrations/*.sql 6 | 7 | # added by check_manifest.py 8 | include *.py 9 | include .coveragerc 10 | recursive-include hordak *.json 11 | recursive-include example_project *.txt 12 | recursive-include hordak *.html 13 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-hordak 2 | ============= 3 | 4 | **Double entry bookkeeping in Django.** 5 | 6 | Django Hordak is the core functionality of a double entry accounting system. 7 | It provides thoroughly tested core models with relational integrity constrains to ensure consistency. 8 | 9 | Hordak also includes a basic accounting interface. This should allow you to get up-and-running quickly. 10 | However, the expectation is that you will either heavily build on this example or use one of the interfaces detailed below. 11 | 12 | Tested under Python: 3.8, 3.9, 3.10, 3.11, 3.12. Django: 4.2, 5.0 13 | 14 | `Adam Charnock`_ **is available for freelance/contract work.** 15 | 16 | .. image:: https://img.shields.io/pypi/v/django-hordak.svg 17 | :target: https://badge.fury.io/py/django-hordak 18 | 19 | .. image:: https://img.shields.io/github/license/adamcharnock/django-hordak.svg 20 | :target: https://pypi.python.org/pypi/django-hordak/ 21 | 22 | .. image:: https://coveralls.io/repos/github/adamcharnock/django-hordak/badge.svg?branch=master 23 | :target: https://coveralls.io/github/adamcharnock/django-hordak?branch=master 24 | 25 | .. image:: https://readthedocs.org/projects/django-hordak/badge/?version=latest 26 | :target: https://django-hordak.readthedocs.io/en/latest/?badge=latest 27 | :alt: Documentation Status 28 | 29 | Documentation 30 | ------------- 31 | 32 | Documentation can be found at: http://django-hordak.readthedocs.io/ 33 | 34 | 35 | .. _swiftwind: https://github.com/adamcharnock/swiftwind/ 36 | .. _simple model layer: https://github.com/adamcharnock/django-hordak/blob/master/hordak/models/core.py 37 | .. _battlecat: https://github.com/adamcharnock/battlecat 38 | .. _Adam Charnock: https://adamcharnock.com 39 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 2.0.1.dev0 2 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | postgres: 4 | image: postgres:16 5 | platform: linux/amd64 6 | environment: 7 | - POSTGRES_HOST_AUTH_METHOD=trust 8 | expose: 9 | - "5432" 10 | ports: 11 | - "5432:5432" 12 | command: postgres -c log_statement=all 13 | 14 | mariadb: 15 | image: mariadb:10.5.21 16 | platform: linux/amd64 17 | environment: 18 | - MARIADB_DATABASE=mariadb 19 | - MYSQL_ALLOW_EMPTY_PASSWORD=yes 20 | expose: 21 | - "3306" 22 | ports: 23 | - "3306:3306" 24 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | /_build_html 2 | /_build 3 | /html 4 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = DjangoHordak 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/peter-selinger-tutorial.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/docs/_static/peter-selinger-tutorial.pdf -------------------------------------------------------------------------------- /docs/api/exceptions.rst: -------------------------------------------------------------------------------- 1 | Exceptions 2 | ========== 3 | 4 | .. automodule:: hordak.exceptions 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/forms.rst: -------------------------------------------------------------------------------- 1 | Forms 2 | ===== 3 | 4 | .. contents:: 5 | 6 | As with views, Hordak provides a number of off-the-shelf forms. You may 7 | need to implement your own version of (or extend) these forms in order 8 | to provide customised functionality. 9 | 10 | SimpleTransactionForm 11 | --------------------- 12 | 13 | .. autoclass:: hordak.forms.SimpleTransactionForm 14 | 15 | TransactionForm 16 | --------------- 17 | 18 | .. autoclass:: hordak.forms.TransactionForm 19 | 20 | LegForm 21 | ------- 22 | 23 | .. autoclass:: hordak.forms.LegForm 24 | 25 | LegFormSet 26 | ---------- 27 | 28 | A formset which can be used to display multiple :class:`Leg forms `. 29 | Useful when creating transactions. 30 | -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | API Documentation 4 | ================= 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | models 10 | models_statements 11 | views 12 | forms 13 | utilities_money 14 | utilities_currency 15 | utilities_database 16 | exceptions 17 | -------------------------------------------------------------------------------- /docs/api/models.rst: -------------------------------------------------------------------------------- 1 | .. _api_models: 2 | 3 | Models (Core) 4 | ============= 5 | 6 | .. contents:: 7 | 8 | .. automodule:: hordak.models 9 | 10 | Account 11 | ------- 12 | 13 | .. autoclass:: hordak.models.Account 14 | :members: 15 | 16 | 17 | .. autoclass:: hordak.models.AccountQuerySet 18 | :members: 19 | 20 | Transaction 21 | ----------- 22 | 23 | .. autoclass:: hordak.models.Transaction 24 | :members: 25 | 26 | Leg 27 | --- 28 | 29 | .. autoclass:: hordak.models.Leg 30 | :members: 31 | 32 | .. autoclass:: hordak.models.LegQuerySet 33 | :members: 34 | 35 | 36 | LegView (Database View) 37 | ----------------------- 38 | 39 | .. autoclass:: hordak.models.LegView 40 | :members: 41 | 42 | 43 | TransactionView (Database View) 44 | ------------------------------- 45 | 46 | .. autoclass:: hordak.models.TransactionView 47 | :members: 48 | -------------------------------------------------------------------------------- /docs/api/models_statements.rst: -------------------------------------------------------------------------------- 1 | .. _api_models_statements: 2 | 3 | Models (Statements) 4 | =================== 5 | 6 | StatementImport 7 | --------------- 8 | 9 | .. autoclass:: hordak.models.StatementImport 10 | :members: 11 | 12 | StatementLine 13 | ------------- 14 | 15 | .. autoclass:: hordak.models.StatementLine 16 | :members: 17 | -------------------------------------------------------------------------------- /docs/api/utilities_currency.rst: -------------------------------------------------------------------------------- 1 | Currency Utilities 2 | ================== 3 | 4 | .. contents:: 5 | 6 | .. automodule:: hordak.utilities.currency 7 | 8 | Currency Exchange 9 | ----------------- 10 | 11 | The ``currency_exchange()`` helper function is provided to assist in creating 12 | currency conversion Transactions. 13 | 14 | .. autofunction:: hordak.utilities.currency.currency_exchange 15 | 16 | Balance 17 | ------- 18 | 19 | .. autoclass:: hordak.utilities.currency.Balance 20 | :members: 21 | 22 | Exchange Rate Backends 23 | ---------------------- 24 | 25 | .. autoclass:: hordak.utilities.currency.BaseBackend 26 | :members: 27 | :private-members: 28 | 29 | .. autoclass:: hordak.utilities.currency.FixerBackend 30 | :members: 31 | -------------------------------------------------------------------------------- /docs/api/utilities_database.rst: -------------------------------------------------------------------------------- 1 | Database Utilities 2 | ================== 3 | 4 | .. contents:: 5 | 6 | GetBalance() 7 | ------------ 8 | 9 | .. autoclass:: hordak.utilities.db_functions.GetBalance 10 | :members: __init__ 11 | -------------------------------------------------------------------------------- /docs/api/utilities_money.rst: -------------------------------------------------------------------------------- 1 | Money Utilities 2 | =============== 3 | 4 | Ratio Split 5 | ----------- 6 | 7 | .. automodule:: hordak.utilities.money 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/views.rst: -------------------------------------------------------------------------------- 1 | .. _api_views: 2 | 3 | Views 4 | ===== 5 | 6 | .. contents:: 7 | 8 | Hordak provides a number of off-the-shelf views to aid in development. You may 9 | need to implement your own version of (or extend) these views in order 10 | to provide customised functionality. 11 | 12 | Extending views 13 | --------------- 14 | 15 | To extend a view you will need to ensure Django loads it by updating your ``urls.py`` file. 16 | To do this, alter you current urls.py: 17 | 18 | .. code:: python 19 | 20 | # Replace this 21 | urlpatterns = [ 22 | ... 23 | url(r'^', include('hordak.urls', namespace='hordak')) 24 | ] 25 | 26 | And changes it as follows, copying in the patterns from hordak's root ``urls.py``: 27 | 28 | .. code:: python 29 | 30 | # With this 31 | from hordak import views as hordak_views 32 | 33 | hordak_urls = [ 34 | ... patterns from Hordak's root urls.py ... 35 | ] 36 | 37 | urlpatterns = [ 38 | url(r'^admin/', admin.site.urls), 39 | 40 | url(r'^', include(hordak_urls, namespace='hordak', app_name='hordak')), 41 | ... 42 | ] 43 | 44 | 45 | Accounts 46 | -------- 47 | 48 | AccountListView 49 | ~~~~~~~~~~~~~~~ 50 | 51 | .. autoclass:: hordak.views.AccountListView 52 | :members: 53 | :undoc-members: 54 | 55 | AccountCreateView 56 | ~~~~~~~~~~~~~~~~~ 57 | 58 | .. autoclass:: hordak.views.AccountCreateView 59 | :members: 60 | :undoc-members: 61 | 62 | AccountUpdateView 63 | ~~~~~~~~~~~~~~~~~ 64 | 65 | .. autoclass:: hordak.views.AccountUpdateView 66 | :members: 67 | :undoc-members: 68 | 69 | AccountTransactionView 70 | ~~~~~~~~~~~~~~~~~~~~~~ 71 | 72 | .. autoclass:: hordak.views.AccountTransactionsView 73 | :members: 74 | :undoc-members: 75 | 76 | Transactions 77 | ------------ 78 | 79 | TransactionCreateView 80 | ~~~~~~~~~~~~~~~~~~~~~ 81 | 82 | .. autoclass:: hordak.views.TransactionCreateView 83 | :members: 84 | :undoc-members: 85 | 86 | TransactionsReconcileView 87 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 88 | 89 | .. autoclass:: hordak.views.TransactionsReconcileView 90 | :members: template_name, model, paginate_by, context_object_name, ordering, success_url 91 | :undoc-members: 92 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | ################ 2 | Hordak Changelog 3 | ################ 4 | 5 | 6 | .. include:: ../CHANGES.txt 7 | -------------------------------------------------------------------------------- /docs/customising-templates.rst: -------------------------------------------------------------------------------- 1 | Customising Templates 2 | ===================== 3 | 4 | The easiest way to modify Hordak's default interface is to customise the default 5 | templates. 6 | 7 | .. note:: 8 | 9 | This provides a basic level of customisation. For more control you will 10 | need to extend the :ref:`views `, or create entirely new views of your own which 11 | build on Hordak's :ref:`models `. 12 | 13 | Hordak's templates can be found in `hordak/templates/hordak`_. You can override these templates by 14 | creating similarly named files in your app's own ``templates`` directory. 15 | 16 | For example, if you wish to override ``hordak/account_list.html``, you should 17 | create the file ``hordak/account_list.html`` within your own app's template directory. Your template will 18 | then be used by Django rather than the original. 19 | 20 | .. important:: 21 | 22 | By default Django searches for templates in each app's ``templates`` directory. It does 23 | this in the order listed in ``INSTALLED_APPS``. Therefore, **your app must appear before 'hordak' 24 | in 'INSTALLED_APPS'**. 25 | 26 | .. _hordak/templates/hordak: https://github.com/adamcharnock/django-hordak/tree/master/hordak/templates/hordak 27 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Django Hordak 2 | ============= 3 | 4 | Django Hordak is the core functionality of a double entry accounting system. 5 | It provides thoroughly tested core models with relational integrity constrains 6 | to ensure consistency. 7 | 8 | Hordak also includes a basic accounting interface. This should allow you to get 9 | up-and-running quickly. However, the expectation is that you will either heavily 10 | build on this example or use one of the interfaces detailed below. 11 | 12 | Interfaces which build on Hordak include: 13 | 14 | .. _interfaces: 15 | 16 | * `battlecat`_ – General purpose accounting interface (work in progress) 17 | * `swiftwind`_ – Accounting for communal households (work in progress) 18 | 19 | Requirements 20 | ------------ 21 | 22 | Hordak is tested against: 23 | 24 | * Django 4.2, 5.0 25 | * Python >= 3.8 26 | * Postgres >= 9.5, and with partial support for MariaDB >= 10.5 (MySQL >= 8.0) 27 | 28 | Other databases (e.g. SQLite) are unsupported. This is due to the database constraints we apply to 29 | ensure data integrity. 30 | 31 | .. toctree:: 32 | :maxdepth: 2 33 | :caption: Contents: 34 | 35 | installation 36 | settings 37 | customising-templates 38 | accounting-for-developers 39 | hordak-database-triggers 40 | api/index 41 | notes 42 | changelog 43 | 44 | Current limitations 45 | ------------------- 46 | 47 | Django Hordak currently does not guarantee sequential primary keys of database entities. 48 | IDs are created using regular Postgres sequences, and as a result IDs may skip numbers in 49 | certain circumstances. This may conflict with regulatory and audit requirements for 50 | some projects. This is an area for future work 51 | (`1 `_, 52 | `2 `_, 53 | `3 `_, 54 | `4 `_). 55 | 56 | Indices and tables 57 | ================== 58 | 59 | * :ref:`genindex` 60 | * :ref:`modindex` 61 | * :ref:`search` 62 | 63 | .. _swiftwind: https://github.com/adamcharnock/swiftwind 64 | .. _battlecat: https://github.com/adamcharnock/battlecat 65 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Installation using pip:: 5 | 6 | pip install django-hordak 7 | 8 | Add to installed apps: 9 | 10 | .. code:: python 11 | 12 | INSTALLED_APPS = [ 13 | ... 14 | 'mptt', 15 | 'hordak', 16 | ] 17 | 18 | .. note:: 19 | 20 | Hordak uses `django-mptt`_ to provide the account tree structure. It must therefore be listed 21 | in ``INSTALLED_APPS`` as shown above. 22 | 23 | Before continuing, ensure the ``HORDAK_DECIMAL_PLACES`` and ``HORDAK_MAX_DIGITS`` 24 | :ref:`settings ` are set as desired. 25 | Changing these values in future will require you to create your 26 | own custom database migration in order to update your schema 27 | (perhaps by using Django's ``MIGRATION_MODULES`` setting). It is 28 | therefore best to be sure of these values now. 29 | 30 | Once ready, run the migrations:: 31 | 32 | ./manage.py migrate 33 | 34 | Using the interface 35 | ------------------- 36 | 37 | Hordak comes with a basic interface. The intention is that you will either build on it, or use a 38 | :ref:`another interface `. To get started with the example interface you can add the 39 | following to your ``urls.py``: 40 | 41 | .. code:: python 42 | 43 | urlpatterns = [ 44 | ... 45 | path('', include('hordak.urls', namespace='hordak')) 46 | ] 47 | 48 | You should then be able to create a user and start the development server 49 | (assuming you ran the migrations as detailed above): 50 | 51 | .. code:: 52 | 53 | # Create a user to login as 54 | ./manage.py createsuperuser 55 | # Start the development server 56 | ./manage.py runserver 57 | 58 | And now navigate to http://127.0.0.1:8000/. 59 | 60 | 61 | Using the models 62 | ---------------- 63 | 64 | Hordak's primary purpose is to provide a set of robust models with which you can model the core of a 65 | double entry accounting system. Having completed the above setup you should be able to import these 66 | models and put them to use. 67 | 68 | .. code:: python 69 | 70 | from hordak.models import Account, Transaction, ... 71 | 72 | You can find further details in the :ref:`API documentation `. 73 | You may also find the :ref:`accounting for developers ` section useful. 74 | 75 | .. _django-mptt: https://github.com/django-mptt/django-mptt 76 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=DjangoHordak 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/notes.rst: -------------------------------------------------------------------------------- 1 | Notes 2 | ===== 3 | 4 | A collection of notes and points which may prove useful. 5 | 6 | Fixtures 7 | -------- 8 | 9 | The following should work well for creating fixtures for your Hordak data:: 10 | 11 | ./manage.py dumpdata hordak --indent=2 --natural-primary --natural-foreign > fixtures/my-fixture.json 12 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | -r ../requirements.txt 2 | -r ../requirements_test.txt 3 | sphinx 4 | sphinx-autobuild 5 | sphinx_rtd_theme 6 | -------------------------------------------------------------------------------- /docs/settings.rst: -------------------------------------------------------------------------------- 1 | .. _settings: 2 | 3 | Settings 4 | ======== 5 | 6 | You can set the following your project's ``settings.py`` file: 7 | 8 | DEFAULT_CURRENCY 9 | ---------------- 10 | 11 | Default: ``"EUR"`` (str) 12 | 13 | The default currency to use when creating new accounts 14 | 15 | CURRENCIES 16 | ---------- 17 | 18 | Default: ``[]`` (list) 19 | 20 | Any currencies (additional to ``DEFAULT_CURRENCY``) for which you wish to create accounts. 21 | For example, you may have ``"EUR"`` for your ``DEFAULT_CURRENCY``, and ``["USD", "GBP"]`` for your 22 | additional ``CURRENCIES``. 23 | To set different value, than for `dj-money`, 24 | you can use `HORDAK_CURRENCIES` setting value will be used preferentially. 25 | 26 | 27 | HORDAK_DECIMAL_PLACES 28 | --------------------- 29 | 30 | Default: ``2`` (int) 31 | 32 | Number of decimal places available within monetary values. 33 | 34 | 35 | HORDAK_MAX_DIGITS 36 | ----------------- 37 | 38 | Default: ``13`` (int) 39 | 40 | Maximum number of digits allowed in monetary values. 41 | Decimal places both right and left of decimal point are included in this count. 42 | Therefore a maximum value of 9,999,999.999 would require ``HORDAK_MAX_DIGITS=10`` 43 | and ``HORDAK_DECIMAL_PLACES=3``. 44 | 45 | HORDAK_UUID_DEFAULT 46 | ------------------- 47 | 48 | Default: ``uuid.uuid4`` (callable) 49 | 50 | A callable to be used to generate UUID values for database entities. 51 | -------------------------------------------------------------------------------- /docs/test-import.csv: -------------------------------------------------------------------------------- 1 | Number,Date,Account,Amount,Subcategory,Memo 2 | 1,30/12/2017,123456789,-30,OTH,minus 30 3 | 1,29/12/2017,123456789,29,OTH,plus 29 4 | -------------------------------------------------------------------------------- /example_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/example_project/__init__.py -------------------------------------------------------------------------------- /example_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example_project project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.10.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.10/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.10/ref/settings/ 11 | """ 12 | 13 | import os 14 | from typing import List 15 | 16 | import dj_database_url 17 | 18 | 19 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 20 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 21 | 22 | 23 | # Quick-start development settings - unsuitable for production 24 | # See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ 25 | 26 | # SECURITY WARNING: keep the secret key used in production secret! 27 | SECRET_KEY = "7lz1x9*1dfc_60vktfj4hrneiude(t%84re6*!)kpc=ifk(7@3" 28 | 29 | # SECURITY WARNING: don't run with debug turned on in production! 30 | DEBUG = True 31 | 32 | ALLOWED_HOSTS: List[str] = [] 33 | 34 | 35 | # Application definition 36 | 37 | INSTALLED_APPS = [ 38 | "django.contrib.admin", 39 | "django.contrib.auth", 40 | "django.contrib.contenttypes", 41 | "django.contrib.sessions", 42 | "django.contrib.messages", 43 | "django.contrib.staticfiles", 44 | "mptt", 45 | "django_extensions", 46 | "hordak", 47 | ] 48 | 49 | MIDDLEWARE = [ 50 | "django.middleware.security.SecurityMiddleware", 51 | "django.contrib.sessions.middleware.SessionMiddleware", 52 | "django.middleware.common.CommonMiddleware", 53 | "django.middleware.csrf.CsrfViewMiddleware", 54 | "django.contrib.auth.middleware.AuthenticationMiddleware", 55 | "django.contrib.messages.middleware.MessageMiddleware", 56 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 57 | ] 58 | 59 | ROOT_URLCONF = "example_project.urls" 60 | 61 | TEMPLATES = [ 62 | { 63 | "BACKEND": "django.template.backends.django.DjangoTemplates", 64 | "DIRS": [], 65 | "APP_DIRS": True, 66 | "OPTIONS": { 67 | "context_processors": [ 68 | "django.template.context_processors.debug", 69 | "django.template.context_processors.request", 70 | "django.contrib.auth.context_processors.auth", 71 | "django.contrib.messages.context_processors.messages", 72 | ], 73 | "debug": DEBUG, 74 | }, 75 | } 76 | ] 77 | 78 | WSGI_APPLICATION = "example_project.wsgi.application" 79 | 80 | 81 | DATABASES = { 82 | # Configure by setting the DATABASE_URL environment variable. 83 | # The default settings may work well for local development. 84 | "default": dj_database_url.config() 85 | or { 86 | "ENGINE": "django.db.backends.postgresql_psycopg2", 87 | "NAME": "postgres", 88 | "HOST": "127.0.0.1", 89 | "PORT": "5432", 90 | "USER": "postgres", 91 | "PASSWORD": "", 92 | } 93 | } 94 | 95 | 96 | # Password validation 97 | # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators 98 | 99 | AUTH_PASSWORD_VALIDATORS = [ 100 | { 101 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" 102 | }, 103 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 104 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 105 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 106 | ] 107 | 108 | 109 | # Internationalization 110 | # https://docs.djangoproject.com/en/1.10/topics/i18n/ 111 | 112 | LANGUAGE_CODE = "en-us" 113 | 114 | TIME_ZONE = "UTC" 115 | 116 | USE_I18N = True 117 | 118 | USE_TZ = True 119 | 120 | 121 | # Static files (CSS, JavaScript, Images) 122 | # https://docs.djangoproject.com/en/1.10/howto/static-files/ 123 | 124 | STATIC_URL = "/static/" 125 | 126 | LOGIN_URL = "/auth/login/" 127 | 128 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 129 | 130 | HORDAK_MAX_DIGITS = 20 131 | HORDAK_DECIMAL_PLACES = 2 132 | 133 | HORDAK_DEFAULT_CURRENCY = "GBP" 134 | HORDAK_INTERNAL_CURRENCY = "EUR" 135 | HORDAK_CURRENCIES = ["GBP", "USD", "EUR"] 136 | -------------------------------------------------------------------------------- /example_project/urls.py: -------------------------------------------------------------------------------- 1 | """example_project URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.10/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.conf.urls import url, include 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 15 | """ 16 | 17 | from django.contrib import admin 18 | from django.urls import include, path 19 | 20 | 21 | urlpatterns = [ 22 | path("admin/", admin.site.urls), 23 | path("", include("hordak.urls", namespace="hordak")), 24 | ] 25 | -------------------------------------------------------------------------------- /example_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example_project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | 15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") 16 | 17 | application = get_wsgi_application() 18 | -------------------------------------------------------------------------------- /hordak/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/hordak/__init__.py -------------------------------------------------------------------------------- /hordak/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class HordakConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "hordak" 7 | -------------------------------------------------------------------------------- /hordak/data_sources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/hordak/data_sources/__init__.py -------------------------------------------------------------------------------- /hordak/data_sources/tellerio.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from uuid import UUID 3 | 4 | import requests 5 | from django.db import transaction 6 | 7 | from hordak.models.core import StatementImport, StatementLine 8 | 9 | 10 | @transaction.atomic() 11 | def do_import(token, account_uuid, bank_account, since=None): 12 | """Import data from teller.io 13 | 14 | Returns the created StatementImport 15 | """ 16 | response = requests.get( 17 | url="https://api.teller.io/accounts/{}/transactions".format(account_uuid), 18 | headers={"Authorization": "Bearer {}".format(token)}, 19 | ) 20 | response.raise_for_status() 21 | data = response.json() 22 | 23 | statement_import = StatementImport.objects.create( 24 | source="teller.io", 25 | extra={"account_uuid": account_uuid}, 26 | bank_account=bank_account, 27 | ) 28 | 29 | for line_data in data: 30 | uuid = UUID(hex=line_data["id"]) 31 | uuid_f = str(uuid) 32 | if StatementLine.objects.filter(uuid=uuid_f): 33 | continue 34 | 35 | description = ", ".join( 36 | filter(bool, [line_data["counterparty"], line_data["description"]]) 37 | ) 38 | date = datetime.date(*map(int, line_data["date"].split("-"))) 39 | 40 | if not since or date >= since: 41 | StatementLine.objects.create( 42 | uuid=uuid, 43 | date=line_data["date"], 44 | statement_import=statement_import, 45 | amount=line_data["amount"], 46 | type=line_data["type"], 47 | description=description, 48 | source_data=line_data, 49 | ) 50 | -------------------------------------------------------------------------------- /hordak/defaults.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from django.conf import settings 4 | 5 | 6 | INTERNAL_CURRENCY = getattr(settings, "HORDAK_INTERNAL_CURRENCY", "EUR") 7 | 8 | get_internal_currency = INTERNAL_CURRENCY 9 | 10 | 11 | DEFAULT_CURRENCY = getattr(settings, "DEFAULT_CURRENCY", "EUR") 12 | 13 | 14 | def default_currency(): 15 | return DEFAULT_CURRENCY 16 | 17 | 18 | CURRENCIES = getattr(settings, "HORDAK_CURRENCIES", getattr(settings, "CURRENCIES", [])) 19 | 20 | DECIMAL_PLACES = getattr(settings, "HORDAK_DECIMAL_PLACES", 2) 21 | 22 | MAX_DIGITS = getattr(settings, "HORDAK_MAX_DIGITS", 13) 23 | 24 | UUID_DEFAULT = getattr(settings, "HORDAK_UUID_DEFAULT", uuid4) 25 | -------------------------------------------------------------------------------- /hordak/exceptions.py: -------------------------------------------------------------------------------- 1 | class HordakError(Exception): 2 | """Abstract exception type for all Hordak errors""" 3 | 4 | pass 5 | 6 | 7 | class AccountingError(HordakError): 8 | """Abstract exception type for errors specifically related to accounting""" 9 | 10 | pass 11 | 12 | 13 | class AccountTypeOnChildNode(HordakError): 14 | """Raised when trying to set a type on a child account 15 | 16 | The type of a child account is always inferred from its root account 17 | """ 18 | 19 | pass 20 | 21 | 22 | class ZeroAmountError(HordakError): 23 | """Raised when a zero amount is found on a transaction leg 24 | 25 | Transaction leg amounts must be none zero. 26 | """ 27 | 28 | pass 29 | 30 | 31 | class AccountingEquationViolationError(AccountingError): 32 | """Raised if - upon checking - the accounting equation is found to be violated. 33 | 34 | The accounting equation is: 35 | 36 | 0 = Liabilities + Equity + Income - Expenses - Assets 37 | 38 | """ 39 | 40 | pass 41 | 42 | 43 | class LossyCalculationError(HordakError): 44 | """Raised to prevent a lossy or imprecise calculation from occurring. 45 | 46 | Typically this may happen when trying to multiply/divide a monetary value 47 | by a float. 48 | """ 49 | 50 | pass 51 | 52 | 53 | class BalanceComparisonError(HordakError): 54 | """Raised when comparing a balance to an invalid value 55 | 56 | A balance must be compared against another balance or a Money instance 57 | """ 58 | 59 | pass 60 | 61 | 62 | class TradingAccountRequiredError(HordakError): 63 | """Raised when trying to perform a currency exchange via an account other than a 'trading' account""" 64 | 65 | pass 66 | 67 | 68 | class InvalidFeeCurrency(HordakError): 69 | """Raised when fee currency does not match source currency""" 70 | 71 | pass 72 | 73 | 74 | class CannotSimplifyError(HordakError): 75 | """Used internally by Currency class""" 76 | 77 | pass 78 | 79 | 80 | class NoMoreAccountCodesAvailableInSequence(HordakError): 81 | """Raised when all account codes in a sequence have been generated 82 | 83 | For example, we cannot generate an account code after "999". Or, 84 | when using alpha characters, after "ZZZ". 85 | """ 86 | 87 | pass 88 | 89 | 90 | class InvalidOrMissingAccountTypeError(Exception): 91 | """When an unexpected account type is encountered""" 92 | 93 | pass 94 | 95 | 96 | class NeitherCreditNorDebitPresentError(Exception): 97 | """When neither credit nor debit present for a Leg""" 98 | 99 | pass 100 | 101 | 102 | class BothCreditAndDebitPresentError(Exception): 103 | """When both credit and debit present for a Leg""" 104 | 105 | pass 106 | 107 | 108 | class CreditOrDebitIsNegativeError(Exception): 109 | """When either the credit and debit field of a Leg is negative""" 110 | 111 | pass 112 | -------------------------------------------------------------------------------- /hordak/fixtures/top-level-accounts.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "hordak.account", 4 | "fields": { 5 | "uuid": "JNoeszxBTlur-AMqvugX8w", 6 | "name": "Assets", 7 | "parent": null, 8 | "code": "1", 9 | "_type": "AS", 10 | "is_bank_account": false, 11 | "lft": 1, 12 | "rght": 2, 13 | "tree_id": 1, 14 | "level": 0 15 | } 16 | }, 17 | { 18 | "model": "hordak.account", 19 | "fields": { 20 | "uuid": "HXkGHKAUQZ2v5gmB1z3eeQ", 21 | "name": "Liabilities", 22 | "parent": null, 23 | "code": "2", 24 | "_type": "LI", 25 | "is_bank_account": false, 26 | "lft": 1, 27 | "rght": 2, 28 | "tree_id": 3, 29 | "level": 0 30 | } 31 | }, 32 | { 33 | "model": "hordak.account", 34 | "fields": { 35 | "uuid": "8LaL3VkORbCQvYt_c3fE5A", 36 | "name": "Equity", 37 | "parent": null, 38 | "code": "3", 39 | "_type": "EQ", 40 | "is_bank_account": false, 41 | "lft": 1, 42 | "rght": 2, 43 | "tree_id": 5, 44 | "level": 0 45 | } 46 | }, 47 | { 48 | "model": "hordak.account", 49 | "fields": { 50 | "uuid": "jF8x77xHSh2CPPmjR59JQw", 51 | "name": "Income", 52 | "parent": null, 53 | "code": "4", 54 | "_type": "IN", 55 | "is_bank_account": false, 56 | "lft": 1, 57 | "rght": 2, 58 | "tree_id": 6, 59 | "level": 0 60 | } 61 | }, 62 | { 63 | "model": "hordak.account", 64 | "fields": { 65 | "uuid": "nHQLiRNjQR-X2j4lJ3DfhA", 66 | "name": "Expenses", 67 | "parent": null, 68 | "code": "5", 69 | "_type": "EX", 70 | "is_bank_account": false, 71 | "lft": 1, 72 | "rght": 2, 73 | "tree_id": 7, 74 | "level": 0 75 | } 76 | } 77 | ] 78 | -------------------------------------------------------------------------------- /hordak/forms/__init__.py: -------------------------------------------------------------------------------- 1 | """ Forms for creating & updating Hordak objects 2 | 3 | These are provided as basic forms which may be of use when developing 4 | your accountancy app. You should be able to use them them to provide 5 | initial create/update functionality. 6 | 7 | """ 8 | 9 | from .accounts import AccountForm # noqa 10 | from .transactions import ( # noqa 11 | LegForm, 12 | LegFormSet, 13 | SimpleTransactionForm, 14 | TransactionForm, 15 | ) 16 | -------------------------------------------------------------------------------- /hordak/forms/accounts.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django import forms 4 | from djmoney.settings import CURRENCY_CHOICES 5 | 6 | from hordak.models import Account, AccountType 7 | 8 | 9 | class AccountForm(forms.ModelForm): 10 | """Form for updating & creating accounts 11 | 12 | Note that this form prevents the ``_type`` and ``currencies`` 13 | fields from being updated as this could be a problem for accounts 14 | which transactions have been created for. This could be made more 15 | liberal in future as required. 16 | """ 17 | 18 | currencies = forms.JSONField() 19 | 20 | class Meta: 21 | model = Account 22 | exclude = ["full_code"] 23 | 24 | def __init__(self, *args, **kwargs): 25 | self.is_updating = bool(kwargs.get("instance")) and kwargs["instance"].pk 26 | 27 | if not self.is_updating: 28 | # Set a sensible default account code when creating 29 | initial = kwargs.get("kwargs", {}) 30 | if "code" not in initial: 31 | # TODO: This could be made more robust 32 | try: 33 | account_code = Account.objects.latest("pk").id + 1 34 | except Account.DoesNotExist: 35 | account_code = 1 36 | initial["code"] = "{0:02d}".format(account_code) 37 | kwargs["initial"] = initial 38 | 39 | super(AccountForm, self).__init__(*args, **kwargs) 40 | 41 | if self.is_updating: 42 | # Don't allow these fields to be changed 43 | del self.fields["type"] 44 | del self.fields["currencies"] 45 | del self.fields["is_bank_account"] 46 | 47 | def _check_currencies_json(self): 48 | """Do some custom validation on the currencies json field 49 | 50 | We do this because we want to check both that it is valid json, and 51 | that it contains only currencies available. 52 | """ 53 | currencies = self.data.get("currencies") 54 | 55 | if isinstance(currencies, str): 56 | try: 57 | currencies = json.loads(currencies) 58 | except json.JSONDecodeError: 59 | if "currencies" in self.fields: 60 | self.add_error( 61 | "currencies", 62 | 'Currencies needs to be valid JSON (i.e. ["USD", "EUR"] or ["USD"])' 63 | + f" - {currencies} is not valid JSON.", 64 | ) 65 | 66 | return 67 | 68 | for currency in currencies or []: 69 | if currency not in [choice[0] for choice in CURRENCY_CHOICES]: 70 | if "currencies" in self.fields: 71 | self.add_error( 72 | "currencies", 73 | f"Select a valid choice. {currency} is not one of the available choices.", 74 | ) 75 | 76 | def clean(self): 77 | cleaned_data = super(AccountForm, self).clean() 78 | is_bank_account = ( 79 | self.instance.is_bank_account 80 | if self.is_updating 81 | else cleaned_data["is_bank_account"] 82 | ) 83 | 84 | if ( 85 | not self.is_updating 86 | and is_bank_account 87 | and cleaned_data["type"] != AccountType.asset 88 | ): 89 | raise forms.ValidationError("Bank accounts must also be asset accounts.") 90 | 91 | if ( 92 | not self.is_updating 93 | and is_bank_account 94 | and len(cleaned_data["currencies"]) > 1 95 | ): 96 | raise forms.ValidationError("Bank accounts may only have one currency.") 97 | 98 | # The currencies field is only present for creation 99 | if "currencies" in self.fields: 100 | self._check_currencies_json() 101 | 102 | return cleaned_data 103 | -------------------------------------------------------------------------------- /hordak/forms/statement_csv_import.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.forms import inlineformset_factory 3 | 4 | from hordak.models import ( 5 | Account, 6 | StatementImport, 7 | TransactionCsvImport, 8 | TransactionCsvImportColumn, 9 | ) 10 | 11 | 12 | class TransactionCsvImportForm(forms.ModelForm): 13 | bank_account = forms.ModelChoiceField( 14 | Account.objects.filter(is_bank_account=True), label="Import data for account" 15 | ) 16 | 17 | class Meta: 18 | model = TransactionCsvImport 19 | fields = ("has_headings", "file") 20 | 21 | def save(self, commit=True): 22 | exists = bool(self.instance.pk) 23 | self.instance.hordak_import = StatementImport.objects.create( 24 | bank_account=self.cleaned_data["bank_account"], source="csv" 25 | ) 26 | obj = super(TransactionCsvImportForm, self).save() 27 | if not exists: 28 | obj.create_columns() 29 | return obj 30 | 31 | 32 | class TransactionCsvImportColumnForm(forms.ModelForm): 33 | class Meta: 34 | model = TransactionCsvImportColumn 35 | fields = ("to_field",) 36 | 37 | 38 | TransactionCsvImportColumnFormSet = inlineformset_factory( 39 | parent_model=TransactionCsvImport, 40 | model=TransactionCsvImportColumn, 41 | form=TransactionCsvImportColumnForm, 42 | extra=0, 43 | can_delete=False, 44 | ) 45 | -------------------------------------------------------------------------------- /hordak/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/hordak/management/__init__.py -------------------------------------------------------------------------------- /hordak/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/hordak/management/commands/__init__.py -------------------------------------------------------------------------------- /hordak/management/commands/create_benchmark_accounts.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | import random 3 | import sys 4 | from typing import Optional 5 | 6 | from django.core.management.base import BaseCommand 7 | from django.db import connection 8 | from django.db import transaction as db_transaction 9 | from moneyed import Money 10 | 11 | from hordak.models import Account, Leg, Transaction 12 | 13 | 14 | class Command(BaseCommand): 15 | help = ( 16 | "Create accounts for benchmarking against. " 17 | "Expects `./manage.py create_chart_of_accounts` to be run first." 18 | ) 19 | 20 | def add_arguments(self, parser): 21 | parser.add_argument( 22 | "--clear", 23 | action="store_true", 24 | default=False, 25 | help="Delete all existing benchmark accounts from database", 26 | ) 27 | parser.add_argument( 28 | "--multiplier", 29 | type=int, 30 | default=10_000, 31 | help="Scale up how many accounts we create", 32 | ) 33 | 34 | def handle(self, *args, **options): 35 | m = options["multiplier"] 36 | 37 | bank: Account = Account.objects.get(name="Bank") 38 | assets: Account = Account.objects.get(name="Fixed") 39 | expenses: Account = Account.objects.get(name="Direct") 40 | income: Account = Account.objects.get(name="Income") 41 | liabilities: Account = Account.objects.get(name="Non-Current") 42 | capital: Account = Account.objects.get(name="Capital - Ordinary Shares") 43 | 44 | customer_income = Account.objects.create(name="Customer Income", parent=income) 45 | customer_liabilities = Account.objects.create( 46 | name="Customer Liabilities", parent=income 47 | ) 48 | 49 | if options["clear"]: 50 | print("Deleting existing benchmark accounts...") 51 | with connection.cursor(): 52 | Account.objects.filter(parent=customer_income).delete() 53 | Account.objects.filter(parent=customer_liabilities).delete() 54 | 55 | print("Creating: Customer income accounts...") 56 | _create_many(customer_income, "Customer Sales", count=m) 57 | 58 | print("Creating: Customer liability accounts...") 59 | _create_many(customer_liabilities, "Customer Liabilities", count=m) 60 | 61 | print("Rebuilding tree...") 62 | Account.objects.rebuild() 63 | 64 | print("Done") 65 | print("") 66 | 67 | print(f"Total accounts: {str(Account.objects.count())}") 68 | 69 | 70 | def _create_many(parent: Optional[Account], name, count: int): 71 | accounts = [] 72 | total_created = 0 73 | 74 | def _save(): 75 | with db_transaction.atomic(): 76 | Account.objects.bulk_create(accounts) 77 | sys.stdout.write(f"{round((total_created / count) * 100, 1)}% ") 78 | sys.stdout.flush() 79 | 80 | for _ in range(0, count): 81 | account = Account(parent=parent, name=f"{name} {total_created+1}") 82 | account.lft = 0 83 | account.rght = 0 84 | account.tree_id = 0 85 | account.level = 0 86 | accounts.append(account) 87 | total_created += 1 88 | if len(accounts) >= 50000: 89 | _save() 90 | accounts = [] 91 | 92 | _save() 93 | print("") 94 | -------------------------------------------------------------------------------- /hordak/management/commands/create_benchmark_transactions.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | import random 3 | import sys 4 | 5 | from django.core.management.base import BaseCommand 6 | from django.db import connection 7 | from django.db import transaction as db_transaction 8 | from moneyed import Money 9 | 10 | from hordak.models import Account, Leg, Transaction 11 | 12 | 13 | class Command(BaseCommand): 14 | help = ( 15 | "Create transactions for benchmarking against. " 16 | "Expects `./manage.py create_chart_of_accounts` to be run first." 17 | ) 18 | 19 | def add_arguments(self, parser): 20 | parser.add_argument( 21 | "--clear", 22 | action="store_true", 23 | default=False, 24 | help="Delete all existing transactions from database", 25 | ) 26 | parser.add_argument( 27 | "--multiplier", 28 | type=int, 29 | default=10_000, 30 | help="Scale up how many transactions we create", 31 | ) 32 | 33 | def handle(self, *args, **options): 34 | m = options["multiplier"] 35 | if options["clear"]: 36 | with connection.cursor() as cur: 37 | cur.execute("TRUNCATE hordak_transaction CASCADE") 38 | 39 | bank: Account = Account.objects.get(name="Bank") 40 | assets: Account = Account.objects.get(name="Fixed") 41 | expenses: Account = Account.objects.get(name="Direct") 42 | liabilities: Account = Account.objects.get(name="Non-Current") 43 | capital: Account = Account.objects.get(name="Capital - Ordinary Shares") 44 | 45 | print("Creating: Bank | Liabilities") 46 | _create_many(bank, liabilities, count=m) 47 | print("Creating: Liabilities | Capital") 48 | _create_many(liabilities, capital, count=m) 49 | print("Creating: Assets | Expenses") 50 | _create_many(assets, expenses, count=m) 51 | print("Done") 52 | print("") 53 | 54 | print( 55 | f"Bank: {str(bank.get_balance()):<15} {str(bank.legs.count()):<15}" 56 | ) 57 | print( 58 | f"Assets: {str(assets.get_balance()):<15} {str(assets.legs.count()):<15}" 59 | ) 60 | print( 61 | f"Expenses: {str(expenses.get_balance()):<15} {str(expenses.legs.count()):<15}" 62 | ) 63 | print( 64 | f"Liabilities: {str(liabilities.get_balance()):<15} {str(liabilities.legs.count()):<15}" 65 | ) 66 | print( 67 | f"Capital: {str(capital.get_balance()):<15} {str(capital.legs.count()):<15}" 68 | ) 69 | 70 | 71 | def _create_many(debit: Account, credit: Account, count: int): 72 | random.seed(f"{debit.full_code}-{credit.full_code}") 73 | transactions = [] 74 | legs = [] 75 | total_created = 0 76 | 77 | def _save(): 78 | with db_transaction.atomic(): 79 | Transaction.objects.bulk_create(transactions) 80 | Leg.objects.bulk_create(legs) 81 | sys.stdout.write(f"{round((total_created / count) * 100, 1)}% ") 82 | sys.stdout.flush() 83 | 84 | for _ in range(0, count): 85 | transaction, legs_ = _transfer_no_commit(debit, credit) 86 | transactions.append(transaction) 87 | total_created += 1 88 | legs += legs_ 89 | if len(legs) >= 50000: 90 | _save() 91 | legs = [] 92 | transactions = [] 93 | 94 | _save() 95 | print("") 96 | 97 | 98 | def _transfer_no_commit( 99 | debit: Account, credit: Account, amount=None 100 | ) -> tuple[Transaction, list[Leg]]: 101 | if not amount: 102 | amount = Money(round((random.random() + 0.1) * 100, 2), debit.currencies[0]) 103 | 104 | transaction = Transaction() 105 | legs = [] 106 | legs.append(Leg(transaction=transaction, account=debit, debit=amount)) 107 | legs.append(Leg(transaction=transaction, account=credit, credit=amount)) 108 | return transaction, legs 109 | -------------------------------------------------------------------------------- /hordak/migrations/0002_check_leg_trigger_20160903_1149.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-09-03 11:49 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | def create_trigger(apps, schema_editor): 9 | if schema_editor.connection.vendor == "postgresql": 10 | schema_editor.execute( 11 | """ 12 | CREATE OR REPLACE FUNCTION check_leg() 13 | RETURNS trigger AS 14 | $$ 15 | DECLARE 16 | transaction_sum DECIMAL(13, 2); 17 | BEGIN 18 | 19 | IF (TG_OP = 'DELETE') THEN 20 | SELECT SUM(amount) INTO transaction_sum FROM hordak_leg WHERE transaction_id = OLD.transaction_id; 21 | ELSE 22 | SELECT SUM(amount) INTO transaction_sum FROM hordak_leg WHERE transaction_id = NEW.transaction_id; 23 | END IF; 24 | 25 | IF transaction_sum != 0 THEN 26 | RAISE EXCEPTION 'Sum of transaction amounts must be 0'; 27 | END IF; 28 | RETURN NEW; 29 | END; 30 | $$ 31 | LANGUAGE plpgsql 32 | """ 33 | ) 34 | schema_editor.execute( 35 | """ 36 | CREATE CONSTRAINT TRIGGER check_leg_trigger 37 | AFTER INSERT OR UPDATE OR DELETE ON hordak_leg 38 | DEFERRABLE INITIALLY DEFERRED 39 | FOR EACH ROW EXECUTE PROCEDURE check_leg(); 40 | """ 41 | ) 42 | 43 | elif schema_editor.connection.vendor == "mysql": 44 | pass # we don't care about MySQL here since support is added in 0006 45 | else: 46 | raise NotImplementedError( 47 | "Database vendor %s not supported" % schema_editor.connection.vendor 48 | ) 49 | 50 | 51 | def drop_trigger(apps, schema_editor): 52 | if schema_editor.connection.vendor == "postgresql": 53 | schema_editor.execute("DROP TRIGGER IF EXISTS check_leg_trigger ON hordak_leg") 54 | schema_editor.execute("DROP FUNCTION check_leg()") 55 | elif schema_editor.connection.vendor == "mysql": 56 | pass 57 | else: 58 | raise NotImplementedError( 59 | "Database vendor %s not supported" % schema_editor.connection.vendor 60 | ) 61 | 62 | 63 | class Migration(migrations.Migration): 64 | dependencies = [("hordak", "0001_initial")] 65 | atomic = False 66 | 67 | operations = [ 68 | migrations.RunPython(create_trigger, reverse_code=drop_trigger), 69 | ] 70 | -------------------------------------------------------------------------------- /hordak/migrations/0003_check_zero_amount_20160907_0921.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-09-07 09:21 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [("hordak", "0002_check_leg_trigger_20160903_1149")] 10 | 11 | operations = [ 12 | migrations.RunSQL( 13 | """ALTER TABLE hordak_leg ADD CONSTRAINT zero_amount_check CHECK (amount != 0)""", 14 | """ALTER TABLE hordak_leg DROP CONSTRAINT zero_amount_check""", 15 | ) 16 | ] 17 | -------------------------------------------------------------------------------- /hordak/migrations/0005_account_currencies.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-12-09 00:20 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | import hordak.models.core 8 | 9 | 10 | class Migration(migrations.Migration): 11 | dependencies = [("hordak", "0004_auto_20161113_1932")] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="account", 16 | name="currencies", 17 | field=models.JSONField( 18 | default=["EUR"], 19 | ), 20 | preserve_default=False, 21 | ) 22 | ] 23 | -------------------------------------------------------------------------------- /hordak/migrations/0006_auto_20161209_0108.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-09-03 11:49 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | def create_trigger(apps, schema_editor): 9 | if schema_editor.connection.vendor == "postgresql": 10 | schema_editor.execute( 11 | """ 12 | CREATE OR REPLACE FUNCTION check_leg() 13 | RETURNS trigger AS 14 | $$ 15 | DECLARE 16 | tx_id INT; 17 | non_zero RECORD; 18 | BEGIN 19 | IF (TG_OP = 'DELETE') THEN 20 | tx_id := OLD.transaction_id; 21 | ELSE 22 | tx_id := NEW.transaction_id; 23 | END IF; 24 | 25 | 26 | SELECT ABS(SUM(amount)) AS total, amount_currency AS currency 27 | INTO non_zero 28 | FROM hordak_leg 29 | WHERE transaction_id = tx_id 30 | GROUP BY amount_currency 31 | HAVING ABS(SUM(amount)) > 0 32 | LIMIT 1; 33 | 34 | IF FOUND THEN 35 | -- TODO: Include transaction id in exception message below (see #93) 36 | RAISE EXCEPTION 'Sum of transaction amounts in each currency must be 0. Currency %% has non-zero total %%', 37 | non_zero.currency, non_zero.total; 38 | END IF; 39 | 40 | RETURN NEW; 41 | END; 42 | $$ 43 | LANGUAGE plpgsql; 44 | 45 | """ 46 | ) 47 | 48 | elif schema_editor.connection.vendor == "mysql": 49 | # we have to call this procedure in python via mysql_simulate_trigger(), because MySQL does not support deferred triggers 50 | schema_editor.execute( 51 | """ 52 | CREATE OR REPLACE PROCEDURE check_leg(_transaction_id INT) 53 | BEGIN 54 | DECLARE transaction_sum DECIMAL(13, 2); 55 | DECLARE transaction_currency VARCHAR(3); 56 | 57 | SELECT ABS(SUM(amount)) AS total, amount_currency AS currency 58 | INTO transaction_sum, transaction_currency 59 | FROM hordak_leg 60 | WHERE transaction_id = _transaction_id 61 | GROUP BY amount_currency 62 | HAVING ABS(SUM(amount)) > 0 63 | LIMIT 1; 64 | 65 | IF FOUND_ROWS() > 0 THEN 66 | SET @msg= CONCAT('Sum of transaction amounts must be 0, got ', transaction_sum); 67 | SIGNAL SQLSTATE '45000' SET 68 | MESSAGE_TEXT = @msg; 69 | END IF; 70 | 71 | END 72 | """ 73 | ) 74 | else: 75 | raise NotImplementedError( 76 | "Database vendor %s not supported" % schema_editor.connection.vendor 77 | ) 78 | 79 | 80 | def create_trigger_reverse(apps, schema_editor): 81 | if schema_editor.connection.vendor == "postgresql": 82 | # As per migration 0002 83 | schema_editor.execute( 84 | """ 85 | CREATE OR REPLACE FUNCTION check_leg() 86 | RETURNS trigger AS 87 | $$ 88 | DECLARE 89 | transaction_sum DECIMAL(13, 2); 90 | BEGIN 91 | 92 | IF (TG_OP = 'DELETE') THEN 93 | SELECT SUM(amount) INTO transaction_sum FROM hordak_leg WHERE transaction_id = OLD.transaction_id; 94 | ELSE 95 | SELECT SUM(amount) INTO transaction_sum FROM hordak_leg WHERE transaction_id = NEW.transaction_id; 96 | END IF; 97 | 98 | IF transaction_sum != 0 THEN 99 | RAISE EXCEPTION 'Sum of transaction amounts must be 0'; 100 | END IF; 101 | RETURN NEW; 102 | END; 103 | $$ 104 | LANGUAGE plpgsql 105 | """ 106 | ) 107 | 108 | elif schema_editor.connection.vendor == "mysql": 109 | schema_editor.execute("DROP PROCEDURE IF EXISTS check_leg") 110 | else: 111 | raise NotImplementedError( 112 | "Database vendor %s not supported" % schema_editor.connection.vendor 113 | ) 114 | 115 | 116 | class Migration(migrations.Migration): 117 | dependencies = [("hordak", "0005_account_currencies")] 118 | atomic = False 119 | 120 | operations = [ 121 | migrations.RunPython(create_trigger, create_trigger_reverse), 122 | ] 123 | -------------------------------------------------------------------------------- /hordak/migrations/0007_auto_20161209_0111.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-12-09 01:11 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | def create_trigger(apps, schema_editor): 9 | if schema_editor.connection.vendor == "postgresql": 10 | schema_editor.execute( 11 | """ 12 | CREATE OR REPLACE FUNCTION check_leg_and_account_currency_match() 13 | RETURNS trigger AS 14 | $$ 15 | DECLARE 16 | 17 | BEGIN 18 | 19 | IF (TG_OP = 'DELETE') THEN 20 | RETURN OLD; 21 | END IF; 22 | 23 | PERFORM * FROM hordak_account WHERE id = NEW.account_id AND NEW.amount_currency = ANY(currencies); 24 | 25 | IF NOT FOUND THEN 26 | RAISE EXCEPTION 'Destination account does not support currency %%', NEW.amount_currency; 27 | END IF; 28 | 29 | RETURN NEW; 30 | END; 31 | $$ 32 | LANGUAGE plpgsql 33 | """ 34 | ) 35 | schema_editor.execute( 36 | """ 37 | CREATE CONSTRAINT TRIGGER check_leg_and_account_currency_match_trigger 38 | AFTER INSERT OR UPDATE OR DELETE ON hordak_leg 39 | DEFERRABLE INITIALLY DEFERRED 40 | FOR EACH ROW EXECUTE PROCEDURE check_leg_and_account_currency_match() 41 | """ 42 | ) 43 | elif schema_editor.connection.vendor == "mysql": 44 | schema_editor.execute( 45 | """ 46 | CREATE PROCEDURE check_leg_and_account_currency_match(_account_id INT, _amount_currency VARCHAR(3)) 47 | BEGIN 48 | 49 | SELECT id, currencies INTO @accountId, @accountCurrencies FROM hordak_account WHERE id = _account_id; 50 | IF @accountId IS NULL THEN 51 | SET @msg= CONCAT('Destination Account#', _account_id, ' does not exist.'); 52 | SIGNAL SQLSTATE '45000' SET 53 | MESSAGE_TEXT = @msg; 54 | ELSEIF NOT JSON_CONTAINS(@accountCurrencies, JSON_QUOTE(_amount_currency)) THEN 55 | SET @msg= CONCAT('Destination Account#', _account_id, ' does not support currency ', _amount_currency, '. Account currencies: ', @accountCurrencies); 56 | SIGNAL SQLSTATE '45000' SET 57 | MESSAGE_TEXT = @msg; 58 | END IF; 59 | END; 60 | """ 61 | ) 62 | schema_editor.execute( 63 | """ 64 | CREATE TRIGGER check_leg_and_account_currency_match_on_insert 65 | AFTER INSERT ON hordak_leg 66 | FOR EACH ROW 67 | BEGIN 68 | CALL check_leg_and_account_currency_match(NEW.account_id, NEW.amount_currency); 69 | END; 70 | """ 71 | ) 72 | schema_editor.execute( 73 | """ 74 | CREATE TRIGGER check_leg_and_account_currency_match_on_update 75 | AFTER UPDATE ON hordak_leg 76 | FOR EACH ROW 77 | BEGIN 78 | CALL check_leg_and_account_currency_match(NEW.account_id, NEW.amount_currency); 79 | END; 80 | """ 81 | ) 82 | # DELETE trigger seems unnecessary here - there isn't any point validating the thing we've just deleted 83 | else: 84 | raise Exception( 85 | "Unsupported database vendor: %s" % schema_editor.connection.vendor 86 | ) 87 | 88 | 89 | def drop_trigger(apps, schema_editor): 90 | if schema_editor.connection.vendor == "postgresql": 91 | schema_editor.execute( 92 | "DROP TRIGGER IF EXISTS check_leg_and_account_currency_match_trigger ON hordak_leg" 93 | ) 94 | schema_editor.execute("DROP FUNCTION check_leg_and_account_currency_match()") 95 | elif schema_editor.connection.vendor == "mysql": 96 | schema_editor.execute( 97 | "DROP TRIGGER IF EXISTS check_leg_and_account_currency_match_on_insert" 98 | ) 99 | schema_editor.execute( 100 | "DROP TRIGGER IF EXISTS check_leg_and_account_currency_match_on_update" 101 | ) 102 | schema_editor.execute( 103 | "DROP PROCEDURE IF EXISTS check_leg_and_account_currency_match" 104 | ) 105 | else: 106 | raise Exception( 107 | "Unsupported database vendor: %s" % schema_editor.connection.vendor 108 | ) 109 | 110 | 111 | class Migration(migrations.Migration): 112 | dependencies = [("hordak", "0006_auto_20161209_0108")] 113 | atomic = False 114 | 115 | operations = [ 116 | migrations.RunPython(create_trigger, reverse_code=drop_trigger), 117 | ] 118 | -------------------------------------------------------------------------------- /hordak/migrations/0008_auto_20161209_0129.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-12-09 01:29 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [("hordak", "0007_auto_20161209_0111")] 10 | 11 | operations = [ 12 | migrations.RenameField("Account", "has_statements", "is_bank_account") 13 | ] 14 | -------------------------------------------------------------------------------- /hordak/migrations/0009_bank_accounts_are_asset_accounts.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-12-09 22:20 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [("hordak", "0008_auto_20161209_0129")] 10 | 11 | operations = [ 12 | migrations.RunSQL( 13 | """ 14 | ALTER TABLE hordak_account 15 | ADD CONSTRAINT bank_accounts_are_asset_accounts 16 | CHECK (is_bank_account = FALSE OR _type = 'AS') 17 | """, 18 | """ALTER TABLE hordak_account DROP CONSTRAINT bank_accounts_are_asset_accounts""", 19 | ) 20 | ] 21 | -------------------------------------------------------------------------------- /hordak/migrations/0010_auto_20161216_1202.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.4 on 2016-12-16 18:02 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [("hordak", "0009_bank_accounts_are_asset_accounts")] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="account", 14 | name="_type", 15 | field=models.CharField( 16 | blank=True, 17 | choices=[ 18 | ("AS", "Asset"), 19 | ("LI", "Liability"), 20 | ("IN", "Income"), 21 | ("EX", "Expense"), 22 | ("EQ", "Equity"), 23 | ("TR", "Currency Trading"), 24 | ], 25 | max_length=2, 26 | ), 27 | ), 28 | migrations.AlterField( 29 | model_name="account", 30 | name="is_bank_account", 31 | field=models.BooleanField( 32 | default=False, 33 | help_text="Is this a bank account. This implies we can import bank statements into it and that it only supports a single currency", 34 | ), 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /hordak/migrations/0012_account_full_code.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11b1 on 2017-03-02 19:43 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [("hordak", "0011_auto_20170225_2222")] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="account", 14 | name="full_code", 15 | field=models.CharField(db_index=True, default="", max_length=100), 16 | ) 17 | ] 18 | -------------------------------------------------------------------------------- /hordak/migrations/0013_trigger_full_account_code.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11b1 on 2017-03-02 19:43 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | def create_trigger(apps, schema_editor): 9 | if schema_editor.connection.vendor == "postgresql": 10 | schema_editor.execute( 11 | """ 12 | CREATE OR REPLACE FUNCTION update_full_account_codes() 13 | RETURNS TRIGGER AS 14 | $$ 15 | BEGIN 16 | UPDATE 17 | hordak_account AS a 18 | SET 19 | full_code = ( 20 | SELECT string_agg(code, '' order by lft) 21 | FROM hordak_account AS a2 22 | WHERE a2.lft <= a.lft AND a2.rght >= a.rght AND a.tree_id = a2.tree_id 23 | ); 24 | RETURN NULL; 25 | END; 26 | $$ 27 | LANGUAGE plpgsql; 28 | """ 29 | ) 30 | schema_editor.execute( 31 | """ 32 | CREATE TRIGGER update_full_account_codes_trigger 33 | AFTER INSERT OR UPDATE OR DELETE ON hordak_account 34 | WHEN (pg_trigger_depth() = 0) 35 | EXECUTE PROCEDURE update_full_account_codes(); 36 | """ 37 | ) 38 | elif schema_editor.connection.vendor == "mysql": 39 | pass # we don't care about MySQL here since support is added in xxxx 40 | else: 41 | raise NotImplementedError( 42 | "Don't know how to create trigger for %s" % schema_editor.connection.vendor 43 | ) 44 | 45 | 46 | def drop_trigger(apps, schema_editor): 47 | if schema_editor.connection.vendor == "postgresql": 48 | schema_editor.execute( 49 | "DROP TRIGGER IF EXISTS update_full_account_codes_trigger ON hordak_account" 50 | ) 51 | schema_editor.execute("DROP FUNCTION update_full_account_codes()") 52 | elif schema_editor.connection.vendor == "mysql": 53 | pass # we don't care about MySQL here since support is added in 0027 54 | else: 55 | raise NotImplementedError( 56 | "Don't know how to drop trigger for %s" % schema_editor.connection.vendor 57 | ) 58 | 59 | 60 | class Migration(migrations.Migration): 61 | dependencies = [("hordak", "0012_account_full_code")] 62 | 63 | operations = [ 64 | migrations.RunPython(create_trigger, reverse_code=drop_trigger), 65 | ] 66 | -------------------------------------------------------------------------------- /hordak/migrations/0014_auto_20170302_1944.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11b1 on 2017-03-02 19:44 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [("hordak", "0013_trigger_full_account_code")] 10 | 11 | operations = [ 12 | # migrations.AlterField( 13 | # model_name='account', 14 | # name='full_code', 15 | # field=models.CharField(db_index=True, max_length=100, unique=True), 16 | # ), 17 | ] 18 | -------------------------------------------------------------------------------- /hordak/migrations/0015_auto_20170302_2109.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11b1 on 2017-03-02 21:09 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [("hordak", "0014_auto_20170302_1944")] 10 | 11 | operations = [ 12 | migrations.RenameField(model_name="account", old_name="_type", new_name="type"), 13 | migrations.AlterField( 14 | model_name="account", 15 | name="full_code", 16 | field=models.CharField(db_index=True, max_length=100, unique=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /hordak/migrations/0016_check_account_type.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11b1 on 2017-03-02 20:56 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | def create_trigger(apps, schema_editor): 9 | if schema_editor.connection.vendor == "postgresql": 10 | schema_editor.execute( 11 | """ 12 | CREATE OR REPLACE FUNCTION check_account_type() 13 | RETURNS TRIGGER AS 14 | $$ 15 | BEGIN 16 | IF NEW.parent_id::BOOL THEN 17 | NEW.type = (SELECT type FROM hordak_account WHERE id = NEW.parent_id); 18 | END IF; 19 | RETURN NEW; 20 | END; 21 | $$ 22 | LANGUAGE plpgsql; 23 | """ 24 | ) 25 | schema_editor.execute( 26 | """ 27 | CREATE TRIGGER check_account_type_trigger 28 | BEFORE INSERT OR UPDATE ON hordak_account 29 | FOR EACH ROW 30 | WHEN (pg_trigger_depth() = 0) 31 | EXECUTE PROCEDURE check_account_type(); 32 | """ 33 | ) 34 | elif schema_editor.connection.vendor == "mysql": 35 | pass # we don't care about MySQL here since support is added in 0032 36 | 37 | 38 | def drop_trigger(apps, schema_editor): 39 | if schema_editor.connection.vendor == "postgresql": 40 | schema_editor.execute( 41 | "DROP TRIGGER IF EXISTS check_account_type_trigger ON hordak_account" 42 | ) 43 | schema_editor.execute("DROP FUNCTION check_account_type()") 44 | 45 | 46 | class Migration(migrations.Migration): 47 | """Set child accounts to have the same type as their parent""" 48 | 49 | dependencies = [("hordak", "0015_auto_20170302_2109")] 50 | 51 | operations = [ 52 | migrations.RunPython(create_trigger, reverse_code=drop_trigger), 53 | ] 54 | -------------------------------------------------------------------------------- /hordak/migrations/0017_auto_20171203_1516.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-12-03 15:16 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [("hordak", "0016_check_account_type")] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name="transaction", options={"get_latest_by": "date"} 14 | ) 15 | ] 16 | -------------------------------------------------------------------------------- /hordak/migrations/0018_auto_20171205_1256.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-12-05 12:56 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("hordak", "0017_auto_20171203_1516"), 11 | ("contenttypes", "0002_remove_content_type_name"), 12 | ] 13 | 14 | operations = [ 15 | migrations.RenameModel( 16 | old_name="TransactionImport", new_name="TransactionCsvImport" 17 | ), 18 | migrations.RenameModel( 19 | old_name="TransactionImportColumn", new_name="TransactionCsvImportColumn" 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /hordak/migrations/0019_statementimport_source.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-12-05 13:05 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [("hordak", "0018_auto_20171205_1256")] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="statementimport", 14 | name="source", 15 | field=models.CharField( 16 | default="csv", 17 | help_text='A value uniquely identifying where this data came from. Examples: "csv", "teller.io".', 18 | max_length=20, 19 | ), 20 | preserve_default=False, 21 | ) 22 | ] 23 | -------------------------------------------------------------------------------- /hordak/migrations/0020_auto_20171205_1424.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-12-05 14:24 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [("hordak", "0019_statementimport_source")] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="statementimport", 14 | name="extra", 15 | field=models.JSONField( 16 | default={}, 17 | help_text="Any extra data relating to the import, probably specific to the data source.", 18 | ), 19 | ), 20 | migrations.AddField( 21 | model_name="statementline", 22 | name="source_data", 23 | field=models.JSONField( 24 | default={}, help_text="Original data received from the data source." 25 | ), 26 | ), 27 | migrations.AddField( 28 | model_name="statementline", 29 | name="type", 30 | field=models.CharField(default="", max_length=50), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /hordak/migrations/0021_auto_20180329_1426.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.11 on 2018-03-29 11:26 3 | from __future__ import unicode_literals 4 | 5 | import django.db.models.deletion 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [("hordak", "0020_auto_20171205_1424")] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="statementline", 15 | name="transaction", 16 | field=models.ForeignKey( 17 | blank=True, 18 | default=None, 19 | help_text="Reconcile this statement line to this transaction", 20 | null=True, 21 | on_delete=django.db.models.deletion.SET_NULL, 22 | to="hordak.Transaction", 23 | ), 24 | ), 25 | migrations.AlterField( 26 | model_name="transactioncsvimportcolumn", 27 | name="to_field", 28 | field=models.CharField( 29 | blank=True, 30 | choices=[ 31 | (None, "-- Do not import --"), 32 | ("date", "Date"), 33 | ("amount", "Amount"), 34 | ("amount_out", "Amount (money out only)"), 35 | ("amount_in", "Amount (money in only)"), 36 | ("description", "Description / Notes"), 37 | ], 38 | default=None, 39 | max_length=20, 40 | null=True, 41 | verbose_name="Is", 42 | ), 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /hordak/migrations/0022_auto_20180825_1026.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1 on 2018-08-25 10:26 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("hordak", "0021_auto_20180329_1426")] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name="account", 12 | name="is_bank_account", 13 | field=models.BooleanField( 14 | blank=True, 15 | default=False, 16 | help_text="Is this a bank account. This implies we can import bank statements into it and that it only supports a single currency", 17 | ), 18 | ) 19 | ] 20 | -------------------------------------------------------------------------------- /hordak/migrations/0023_auto_20180825_1029.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1 on 2018-08-25 10:29 2 | 3 | from django.db import migrations, models 4 | 5 | import hordak.models.core 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [("hordak", "0022_auto_20180825_1026")] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="statementimport", 14 | name="extra", 15 | field=models.JSONField( 16 | default=hordak.models.core.json_default, 17 | help_text="Any extra data relating to the import, probably specific to the data source.", 18 | ), 19 | ), 20 | migrations.AlterField( 21 | model_name="statementline", 22 | name="source_data", 23 | field=models.JSONField( 24 | default=hordak.models.core.json_default, 25 | help_text="Original data received from the data source.", 26 | ), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /hordak/migrations/0024_auto_20180827_1148.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.8 on 2018-08-27 09:48 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | def create_trigger(apps, schema_editor): 7 | if schema_editor.connection.vendor == "postgresql": 8 | schema_editor.execute( 9 | """ 10 | CREATE OR REPLACE FUNCTION update_full_account_codes() 11 | RETURNS TRIGGER AS 12 | $$ 13 | BEGIN 14 | -- Set empty string codes to be NULL 15 | UPDATE hordak_account SET code = NULL where code = ''; 16 | 17 | -- Set full code to the combination of the parent account's codes 18 | UPDATE 19 | hordak_account AS a 20 | SET 21 | full_code = ( 22 | SELECT string_agg(code, '' order by lft) 23 | FROM hordak_account AS a2 24 | WHERE a2.lft <= a.lft AND a2.rght >= a.rght AND a.tree_id = a2.tree_id 25 | ); 26 | 27 | -- Set full codes to NULL where a parent account includes a NULL code 28 | UPDATE 29 | hordak_account AS a 30 | SET 31 | full_code = NULL 32 | WHERE 33 | ( 34 | SELECT COUNT(*) 35 | FROM hordak_account AS a2 36 | WHERE a2.lft <= a.lft AND a2.rght >= a.rght AND a.tree_id = a2.tree_id AND a2.code IS NULL 37 | ) > 0; 38 | RETURN NULL; 39 | END; 40 | $$ 41 | LANGUAGE plpgsql; 42 | """ 43 | ) 44 | elif schema_editor.connection.vendor == "mysql": 45 | pass # we don't care about MySQL here since support is added in 0027 46 | else: 47 | raise NotImplementedError( 48 | "Don't know how to create trigger for %s" % schema_editor.connection.vendor 49 | ) 50 | 51 | 52 | def drop_trigger(apps, schema_editor): 53 | if schema_editor.connection.vendor == "postgresql": 54 | # Recreate update_full_account_codes as it was in migration 0013 55 | schema_editor.execute( 56 | """ 57 | CREATE OR REPLACE FUNCTION update_full_account_codes() 58 | RETURNS TRIGGER AS 59 | $$ 60 | BEGIN 61 | UPDATE 62 | hordak_account AS a 63 | SET 64 | full_code = ( 65 | SELECT string_agg(code, '' order by lft) 66 | FROM hordak_account AS a2 67 | WHERE a2.lft <= a.lft AND a2.rght >= a.rght AND a.tree_id = a2.tree_id 68 | ); 69 | RETURN NULL; 70 | END; 71 | $$ 72 | LANGUAGE plpgsql; 73 | """ 74 | ) 75 | elif schema_editor.connection.vendor == "mysql": 76 | pass 77 | else: 78 | raise NotImplementedError( 79 | "Don't know how to drop trigger for %s" % schema_editor.connection.vendor 80 | ) 81 | 82 | 83 | class Migration(migrations.Migration): 84 | dependencies = [("hordak", "0023_auto_20180825_1029")] 85 | 86 | operations = [ 87 | migrations.AlterField( 88 | model_name="account", 89 | name="code", 90 | field=models.CharField(blank=True, max_length=3, null=True), 91 | ), 92 | migrations.AlterField( 93 | model_name="account", 94 | name="full_code", 95 | field=models.CharField( 96 | blank=True, db_index=True, max_length=100, null=True, unique=True 97 | ), 98 | ), 99 | migrations.RunPython(create_trigger, reverse_code=drop_trigger), 100 | ] 101 | -------------------------------------------------------------------------------- /hordak/migrations/0025_auto_20180829_1605.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.8 on 2018-08-29 14:05 2 | 3 | from decimal import Decimal 4 | 5 | import djmoney.models.fields 6 | from django.db import migrations, models 7 | 8 | from hordak.defaults import DECIMAL_PLACES, MAX_DIGITS 9 | 10 | 11 | class Migration(migrations.Migration): 12 | dependencies = [("hordak", "0024_auto_20180827_1148")] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="leg", 17 | name="amount", 18 | field=djmoney.models.fields.MoneyField( 19 | decimal_places=DECIMAL_PLACES, 20 | max_digits=MAX_DIGITS, 21 | default=Decimal("0.0"), 22 | default_currency="EUR", 23 | help_text="Record debits as positive, credits as negative", 24 | ), 25 | ), 26 | migrations.AlterField( 27 | model_name="statementline", 28 | name="amount", 29 | field=models.DecimalField( 30 | decimal_places=DECIMAL_PLACES, max_digits=MAX_DIGITS 31 | ), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /hordak/migrations/0026_auto_20190723_0929.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.3 on 2019-07-23 09:29 2 | 3 | import djmoney.models.fields 4 | from django.db import migrations, models 5 | 6 | from hordak.defaults import DECIMAL_PLACES, MAX_DIGITS 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("hordak", "0025_auto_20180829_1605"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="account", 17 | name="level", 18 | field=models.PositiveIntegerField(editable=False), 19 | ), 20 | migrations.AlterField( 21 | model_name="account", 22 | name="lft", 23 | field=models.PositiveIntegerField(editable=False), 24 | ), 25 | migrations.AlterField( 26 | model_name="account", 27 | name="rght", 28 | field=models.PositiveIntegerField(editable=False), 29 | ), 30 | migrations.AlterField( 31 | model_name="leg", 32 | name="amount", 33 | field=djmoney.models.fields.MoneyField( 34 | decimal_places=DECIMAL_PLACES, 35 | max_digits=MAX_DIGITS, 36 | default_currency="EUR", 37 | help_text="Record debits as positive, credits as negative", 38 | ), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /hordak/migrations/0030_alter_leg_amount_currency.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1 on 2022-08-22 14:00 2 | 3 | import djmoney.models.fields 4 | from django.db import migrations 5 | import hordak.models.core 6 | import hordak.defaults 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("hordak", "0029_alter_leg_amount_currency_and_more"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="leg", 17 | name="amount_currency", 18 | field=djmoney.models.fields.CurrencyField( 19 | choices=hordak.models.core.get_currency_choices(), 20 | default=hordak.defaults.get_internal_currency, 21 | editable=False, 22 | max_length=3, 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /hordak/migrations/0031_alter_account_currencies.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-09-18 08:51 2 | 3 | from django.db import migrations, models 4 | import hordak.models.core 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("hordak", "0030_alter_leg_amount_currency"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="account", 15 | name="currencies", 16 | field=models.JSONField( 17 | verbose_name="currencies", 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /hordak/migrations/0032_check_account_type_big_int.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-09-18 10:33 2 | 3 | from django.db import migrations 4 | 5 | 6 | def create_trigger(apps, schema_editor): 7 | if schema_editor.connection.vendor == "postgresql": 8 | schema_editor.execute( 9 | """ 10 | CREATE OR REPLACE FUNCTION check_account_type() 11 | RETURNS TRIGGER AS 12 | $$ 13 | BEGIN 14 | IF NEW.parent_id::INT::BOOL THEN 15 | NEW.type = (SELECT type FROM hordak_account WHERE id = NEW.parent_id); 16 | END IF; 17 | RETURN NEW; 18 | END; 19 | $$ 20 | LANGUAGE plpgsql; 21 | """ 22 | ) 23 | 24 | elif schema_editor.connection.vendor == "mysql": 25 | # we have to call this procedure in python via mysql_simulate_trigger(), because MySQL does not support deferred triggers 26 | schema_editor.execute( 27 | """ 28 | CREATE OR REPLACE TRIGGER check_account_type_on_insert 29 | BEFORE INSERT ON hordak_account 30 | FOR EACH ROW 31 | BEGIN 32 | IF NEW.parent_id IS NOT NULL THEN 33 | SET NEW.type = (SELECT type FROM hordak_account WHERE id = NEW.parent_id); 34 | END IF; 35 | END; 36 | """ 37 | ) 38 | schema_editor.execute( 39 | """ 40 | CREATE OR REPLACE TRIGGER check_account_type_on_update 41 | BEFORE UPDATE ON hordak_account 42 | FOR EACH ROW 43 | BEGIN 44 | IF NEW.parent_id IS NOT NULL THEN 45 | SET NEW.type = (SELECT type FROM hordak_account WHERE id = NEW.parent_id); 46 | END IF; 47 | END; 48 | """ 49 | ) 50 | else: 51 | raise NotImplementedError( 52 | "Database vendor %s not supported" % schema_editor.connection.vendor 53 | ) 54 | 55 | 56 | def drop_trigger(apps, schema_editor): 57 | if schema_editor.connection.vendor == "postgresql": 58 | schema_editor.execute("DROP FUNCTION check_account_type() CASCADE") 59 | # Recreate check_account_type as it was in migration 0016 60 | schema_editor.execute( 61 | """ 62 | CREATE OR REPLACE FUNCTION check_account_type() 63 | RETURNS TRIGGER AS 64 | $$ 65 | BEGIN 66 | IF NEW.parent_id::BOOL THEN 67 | NEW.type = (SELECT type FROM hordak_account WHERE id = NEW.parent_id); 68 | END IF; 69 | RETURN NEW; 70 | END; 71 | $$ 72 | LANGUAGE plpgsql; 73 | """ 74 | ) 75 | schema_editor.execute( 76 | """ 77 | CREATE TRIGGER check_account_type_trigger 78 | BEFORE INSERT OR UPDATE ON hordak_account 79 | FOR EACH ROW 80 | WHEN (pg_trigger_depth() = 0) 81 | EXECUTE PROCEDURE check_account_type(); 82 | """ 83 | ) 84 | elif schema_editor.connection.vendor == "mysql": 85 | schema_editor.execute("DROP TRIGGER check_account_type_on_insert") 86 | schema_editor.execute("DROP TRIGGER check_account_type_on_update") 87 | else: 88 | raise NotImplementedError( 89 | "Database vendor %s not supported" % schema_editor.connection.vendor 90 | ) 91 | 92 | 93 | class Migration(migrations.Migration): 94 | dependencies = (("hordak", "0031_alter_account_currencies"),) 95 | atomic = False 96 | 97 | operations = [ 98 | migrations.RunPython(create_trigger, reverse_code=drop_trigger), 99 | ] 100 | -------------------------------------------------------------------------------- /hordak/migrations/0033_alter_account_currencies.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.8 on 2022-11-03 22:00 2 | 3 | from django.db import migrations, models 4 | 5 | import hordak.models 6 | from hordak.defaults import DEFAULT_CURRENCY 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("hordak", "0032_check_account_type_big_int"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="account", 17 | name="currencies", 18 | field=models.JSONField( 19 | default=(DEFAULT_CURRENCY,), 20 | verbose_name="currencies", 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /hordak/migrations/0034_alter_account_currencies_alter_leg_amount_currency.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-05-02 11:58 2 | 3 | import djmoney.models.fields 4 | from django.db import migrations, models 5 | import hordak.models.core 6 | import hordak.defaults 7 | 8 | import hordak.models 9 | 10 | 11 | class Migration(migrations.Migration): 12 | dependencies = [ 13 | ("hordak", "0033_alter_account_currencies"), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name="account", 19 | name="currencies", 20 | field=models.JSONField( 21 | verbose_name="currencies", 22 | ), 23 | ), 24 | migrations.AlterField( 25 | model_name="leg", 26 | name="amount_currency", 27 | field=djmoney.models.fields.CurrencyField( 28 | choices=hordak.models.core.get_currency_choices(), 29 | default=hordak.defaults.get_internal_currency, 30 | editable=False, 31 | max_length=3, 32 | ), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /hordak/migrations/0035_account_currencies_json.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-05-16 01:31 2 | 3 | from django.db import migrations, models 4 | 5 | import hordak 6 | from hordak.defaults import DEFAULT_CURRENCY 7 | 8 | 9 | def copy_currencies_data(apps, schema_editor): 10 | # MySQL won't have any old data, because support has only now been added 11 | if schema_editor.connection.vendor == "postgresql": 12 | Account = apps.get_model("hordak", "Account") 13 | table_name = Account._meta.db_table 14 | with schema_editor.connection.cursor() as cursor: 15 | # only run this if there is data in the table (in which case we have an ARRAY to migrate); 16 | # disregard if migrations are being run on a fresh database 17 | if Account.objects.count() > 0: 18 | cursor.execute( 19 | f""" 20 | UPDATE {table_name} 21 | SET currencies_json = array_to_json(currencies)::jsonb; 22 | """ 23 | ) 24 | else: 25 | cursor.execute( 26 | f""" 27 | UPDATE {table_name} 28 | SET currencies_json = currencies; 29 | """ 30 | ) 31 | 32 | 33 | def copy_currencies_data_reverse(apps, schema_editor): 34 | if schema_editor.connection.vendor == "postgresql": 35 | Account = apps.get_model("hordak", "Account") 36 | table_name = Account._meta.db_table 37 | with schema_editor.connection.cursor() as cursor: 38 | # only run this if there is data in the table (in which case we have an ARRAY to migrate); 39 | # disregard if migrations are being run on a fresh database 40 | if Account.objects.count() > 0: 41 | cursor.execute( 42 | f""" 43 | UPDATE {table_name} 44 | SET currencies = (array_agg(ary)::text[] FROM jsonb_array_elements_text(currencies_json) as ary) 45 | """ 46 | ) 47 | 48 | 49 | class Migration(migrations.Migration): 50 | dependencies = [ 51 | ("hordak", "0034_alter_account_currencies_alter_leg_amount_currency"), 52 | ] 53 | 54 | operations = [ 55 | migrations.AddField( 56 | model_name="account", 57 | name="currencies_json", 58 | field=models.JSONField( 59 | db_index=True, 60 | default=(DEFAULT_CURRENCY,), 61 | ), 62 | ), 63 | migrations.RunPython(copy_currencies_data, copy_currencies_data_reverse), 64 | ] 65 | -------------------------------------------------------------------------------- /hordak/migrations/0036_remove_currencies_and_rename_account_currencies_json_to_currencies.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-05-16 01:36 2 | 3 | from django.db import migrations, models 4 | 5 | import hordak 6 | from hordak.defaults import DEFAULT_CURRENCY 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("hordak", "0035_account_currencies_json"), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name="account", 17 | name="currencies", 18 | ), 19 | migrations.RenameField( 20 | model_name="account", 21 | old_name="currencies_json", 22 | new_name="currencies", 23 | ), 24 | migrations.AlterField( 25 | model_name="account", 26 | name="currencies", 27 | field=models.JSONField( 28 | db_index=True, 29 | default=(DEFAULT_CURRENCY,), 30 | verbose_name="currencies", 31 | ), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /hordak/migrations/0037_auto_20230516_0142.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-05-16 01:42 2 | 3 | from django.db import migrations 4 | 5 | 6 | def create_triggers(apps, schema_editor): 7 | if schema_editor.connection.vendor == "postgresql": 8 | schema_editor.execute( 9 | """ 10 | CREATE OR REPLACE FUNCTION check_leg_and_account_currency_match() 11 | RETURNS trigger AS 12 | $$ 13 | DECLARE 14 | account RECORD; 15 | BEGIN 16 | 17 | IF (TG_OP = 'DELETE') THEN 18 | RETURN OLD; 19 | END IF; 20 | 21 | PERFORM * FROM hordak_account WHERE id = NEW.account_id AND currencies::jsonb @> to_jsonb(ARRAY[NEW.amount_currency]::text[]); 22 | 23 | IF NOT FOUND THEN 24 | SELECT * INTO account FROM hordak_account WHERE id = NEW.account_id; 25 | 26 | RAISE EXCEPTION 'Destination Account#%% does not support currency %%. Account currencies: %%', account.id, NEW.amount_currency, account.currencies; 27 | END IF; 28 | 29 | RETURN NEW; 30 | END; 31 | $$ 32 | LANGUAGE plpgsql 33 | """ 34 | ) 35 | elif schema_editor.connection.vendor == "mysql": 36 | pass # nothing to do here, we've already created the procedure in 0007_auto_20161209_0111.py 37 | else: 38 | raise NotImplementedError( 39 | "Unsupported database vendor: %s" % schema_editor.connection.vendor 40 | ) 41 | 42 | 43 | def drop_triggers(apps, schema_editor): 44 | if schema_editor.connection.vendor == "postgresql": 45 | # Recreate check_leg_and_account_currency_match as it was in migration 0007 46 | schema_editor.execute( 47 | """ 48 | CREATE OR REPLACE FUNCTION check_leg_and_account_currency_match() 49 | RETURNS trigger AS 50 | $$ 51 | DECLARE 52 | 53 | BEGIN 54 | 55 | IF (TG_OP = 'DELETE') THEN 56 | RETURN OLD; 57 | END IF; 58 | 59 | PERFORM * FROM hordak_account WHERE id = NEW.account_id AND NEW.amount_currency = ANY(currencies); 60 | 61 | IF NOT FOUND THEN 62 | RAISE EXCEPTION 'Destination account does not support currency %%', NEW.amount_currency; 63 | END IF; 64 | 65 | RETURN NEW; 66 | END; 67 | $$ 68 | LANGUAGE plpgsql 69 | """ 70 | ) 71 | elif schema_editor.connection.vendor == "mysql": 72 | pass 73 | else: 74 | raise NotImplementedError( 75 | "Unsupported database vendor: %s" % schema_editor.connection.vendor 76 | ) 77 | 78 | 79 | class Migration(migrations.Migration): 80 | dependencies = [ 81 | ( 82 | "hordak", 83 | "0036_remove_currencies_and_rename_account_currencies_json_to_currencies", 84 | ), 85 | ] 86 | 87 | operations = [ 88 | migrations.RunPython(create_triggers, drop_triggers), 89 | ] 90 | -------------------------------------------------------------------------------- /hordak/migrations/0038_alter_account_id_alter_leg_id_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.1 on 2024-05-27 11:32 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("hordak", "0037_auto_20230516_0142"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="account", 14 | name="id", 15 | field=models.BigAutoField( 16 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 17 | ), 18 | ), 19 | migrations.AlterField( 20 | model_name="leg", 21 | name="id", 22 | field=models.BigAutoField( 23 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 24 | ), 25 | ), 26 | migrations.AlterField( 27 | model_name="statementimport", 28 | name="id", 29 | field=models.BigAutoField( 30 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 31 | ), 32 | ), 33 | migrations.AlterField( 34 | model_name="statementline", 35 | name="id", 36 | field=models.BigAutoField( 37 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 38 | ), 39 | ), 40 | migrations.AlterField( 41 | model_name="transaction", 42 | name="id", 43 | field=models.BigAutoField( 44 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 45 | ), 46 | ), 47 | migrations.AlterField( 48 | model_name="transactioncsvimport", 49 | name="id", 50 | field=models.BigAutoField( 51 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 52 | ), 53 | ), 54 | migrations.AlterField( 55 | model_name="transactioncsvimportcolumn", 56 | name="id", 57 | field=models.BigAutoField( 58 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 59 | ), 60 | ), 61 | ] 62 | -------------------------------------------------------------------------------- /hordak/migrations/0039_recreate_update_full_account_codes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-09-18 10:33 2 | 3 | from django.db import migrations 4 | 5 | # WHY DOES THIS MIGRATION EXIST? 6 | # Postgres seems to need this function/trigger to be recreated 7 | # following the migration to bigint IDs. 8 | # Otherwise we get the error: 9 | # django.db.utils.ProgrammingError: type of parameter 14 (bigint) does not match that when preparing the plan (integer) 10 | 11 | 12 | def create_trigger(apps, schema_editor): 13 | if schema_editor.connection.vendor == "postgresql": 14 | schema_editor.execute( 15 | """ 16 | CREATE OR REPLACE FUNCTION check_account_type() 17 | RETURNS TRIGGER AS 18 | $$ 19 | BEGIN 20 | IF NEW.parent_id::INT::BOOL THEN 21 | NEW.type = (SELECT type FROM hordak_account WHERE id = NEW.parent_id); 22 | END IF; 23 | RETURN NEW; 24 | END; 25 | $$ 26 | LANGUAGE plpgsql; 27 | """ 28 | ) 29 | 30 | elif schema_editor.connection.vendor == "mysql": 31 | # we have to call this procedure in python via mysql_simulate_trigger(), because MySQL does not support deferred triggers 32 | pass 33 | else: 34 | raise NotImplementedError( 35 | "Database vendor %s not supported" % schema_editor.connection.vendor 36 | ) 37 | 38 | 39 | def drop_trigger(apps, schema_editor): 40 | if schema_editor.connection.vendor == "postgresql": 41 | schema_editor.execute("DROP FUNCTION check_account_type() CASCADE") 42 | # Recreate check_account_type as it was in migration 0016 43 | schema_editor.execute( 44 | """ 45 | CREATE OR REPLACE FUNCTION check_account_type() 46 | RETURNS TRIGGER AS 47 | $$ 48 | BEGIN 49 | IF NEW.parent_id::BOOL THEN 50 | NEW.type = (SELECT type FROM hordak_account WHERE id = NEW.parent_id); 51 | END IF; 52 | RETURN NEW; 53 | END; 54 | $$ 55 | LANGUAGE plpgsql; 56 | """ 57 | ) 58 | schema_editor.execute( 59 | """ 60 | CREATE TRIGGER check_account_type_trigger 61 | BEFORE INSERT OR UPDATE ON hordak_account 62 | FOR EACH ROW 63 | WHEN (pg_trigger_depth() = 0) 64 | EXECUTE PROCEDURE check_account_type(); 65 | """ 66 | ) 67 | elif schema_editor.connection.vendor == "mysql": 68 | pass 69 | else: 70 | raise NotImplementedError( 71 | "Database vendor %s not supported" % schema_editor.connection.vendor 72 | ) 73 | 74 | 75 | class Migration(migrations.Migration): 76 | dependencies = (("hordak", "0038_alter_account_id_alter_leg_id_and_more"),) 77 | atomic = False 78 | 79 | operations = [ 80 | migrations.RunPython(create_trigger, reverse_code=drop_trigger), 81 | ] 82 | -------------------------------------------------------------------------------- /hordak/migrations/0040_alter_account_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2024-05-27 13:45 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("hordak", "0039_recreate_update_full_account_codes"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="account", 14 | name="name", 15 | field=models.CharField(max_length=255, verbose_name="name"), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /hordak/migrations/0042_alter_account_code_alter_account_full_code.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-26 08:53 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("hordak", "0041_auto_20240528_2107"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="account", 14 | name="code", 15 | field=models.CharField( 16 | blank=True, max_length=6, null=True, verbose_name="code" 17 | ), 18 | ), 19 | migrations.AlterField( 20 | model_name="account", 21 | name="full_code", 22 | field=models.CharField( 23 | blank=True, 24 | db_index=True, 25 | max_length=255, 26 | null=True, 27 | unique=True, 28 | verbose_name="full_code", 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /hordak/migrations/0043_hordak_leg_view.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-26 11:49 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("hordak", "0042_alter_account_code_alter_account_full_code"), 9 | ] 10 | 11 | operations = [ 12 | # Runs as-is in both postgresql & mariadb. 13 | # Performance seems to be better on postgres, and postgres 14 | # performance can be improved further by not selecting the `account_balance` 15 | # column 16 | migrations.RunSQL( 17 | """ 18 | create view hordak_leg_view as (SELECT 19 | L.id, 20 | L.uuid, 21 | transaction_id, 22 | account_id, 23 | A.full_code as account_full_code, 24 | A.name as account_name, 25 | A.type as account_type, 26 | T.date as date, 27 | ABS(amount) as amount, 28 | amount_currency, 29 | (CASE WHEN amount > 0 THEN 'CR' ELSE 'DR' END) AS type, 30 | (CASE WHEN amount > 0 THEN ABS(amount) END) AS credit, 31 | (CASE WHEN amount < 0 THEN ABS(amount) END) AS debit, 32 | ( 33 | CASE WHEN A.lft = A.rght - 1 34 | THEN SUM(amount) OVER (PARTITION BY account_id, amount_currency ORDER BY T.date, L.id) 35 | END 36 | ) AS account_balance, 37 | T.description as transaction_description, 38 | L.description as leg_description 39 | FROM hordak_leg L 40 | INNER JOIN hordak_transaction T on L.transaction_id = T.id 41 | INNER JOIN hordak_account A on A.id = L.account_id 42 | order by T.date desc, id desc); 43 | """, 44 | """drop view hordak_leg_view;""", 45 | ) 46 | ] 47 | -------------------------------------------------------------------------------- /hordak/migrations/0045_alter_account_currencies.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-26 16:33 2 | 3 | from django.db import migrations, models 4 | 5 | from hordak.models import account_default_currencies 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("hordak", "0044_legview"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="account", 16 | name="currencies", 17 | field=models.JSONField( 18 | db_index=True, 19 | default=account_default_currencies, 20 | verbose_name="currencies", 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /hordak/migrations/0046_alter_account_uuid_alter_leg_uuid_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-26 18:43 2 | 3 | import uuid 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("hordak", "0045_alter_account_currencies"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="account", 15 | name="uuid", 16 | field=models.UUIDField( 17 | default=uuid.uuid4, editable=False, verbose_name="uuid" 18 | ), 19 | ), 20 | migrations.AlterField( 21 | model_name="leg", 22 | name="uuid", 23 | field=models.UUIDField( 24 | default=uuid.uuid4, editable=False, verbose_name="uuid" 25 | ), 26 | ), 27 | migrations.AlterField( 28 | model_name="statementimport", 29 | name="uuid", 30 | field=models.UUIDField( 31 | default=uuid.uuid4, editable=False, verbose_name="uuid" 32 | ), 33 | ), 34 | migrations.AlterField( 35 | model_name="statementline", 36 | name="uuid", 37 | field=models.UUIDField( 38 | default=uuid.uuid4, editable=False, verbose_name="uuid" 39 | ), 40 | ), 41 | migrations.AlterField( 42 | model_name="transaction", 43 | name="uuid", 44 | field=models.UUIDField( 45 | default=uuid.uuid4, editable=False, verbose_name="uuid" 46 | ), 47 | ), 48 | migrations.AlterField( 49 | model_name="transactioncsvimport", 50 | name="uuid", 51 | field=models.UUIDField( 52 | default=uuid.uuid4, editable=False, verbose_name="uuid" 53 | ), 54 | ), 55 | migrations.AlterField( 56 | model_name="transactioncsvimportcolumn", 57 | name="to_field", 58 | field=models.CharField( 59 | blank=True, 60 | choices=[ 61 | ("", "-- Do not import --"), 62 | ("date", "Date"), 63 | ("amount", "Amount"), 64 | ("amount_out", "Amount (money out only)"), 65 | ("amount_in", "Amount (money in only)"), 66 | ("description", "Description / Notes"), 67 | ], 68 | default=None, 69 | max_length=20, 70 | null=True, 71 | verbose_name="Is", 72 | ), 73 | ), 74 | ] 75 | -------------------------------------------------------------------------------- /hordak/migrations/0047_get_balance.mysql.sql: -------------------------------------------------------------------------------- 1 | -- ---- 2 | CREATE FUNCTION get_balance(account_id BIGINT, as_of DATE) 3 | RETURNS JSON 4 | BEGIN 5 | DECLARE account_lft INT; 6 | DECLARE account_rght INT; 7 | DECLARE account_tree_id INT; 8 | DECLARE result_json JSON; 9 | 10 | -- Fetch the account's hierarchical information 11 | SELECT lft, rght, tree_id INTO account_lft, account_rght, account_tree_id 12 | FROM hordak_account 13 | WHERE id = account_id; 14 | 15 | -- Prepare the result set with sums calculated in a derived table (subquery) 16 | IF as_of IS NOT NULL THEN 17 | SET result_json = ( 18 | SELECT JSON_ARRAYAGG( 19 | JSON_OBJECT( 20 | 'amount', sub.amount, 21 | 'currency', sub.currency 22 | ) 23 | ) 24 | FROM ( 25 | SELECT COALESCE(SUM(L.amount), 0.0) AS amount, L.amount_currency AS currency 26 | FROM hordak_account A2 27 | JOIN hordak_leg L ON L.account_id = A2.id 28 | JOIN hordak_transaction T ON L.transaction_id = T.id 29 | WHERE A2.lft >= account_lft AND A2.rght <= account_rght AND A2.tree_id = account_tree_id AND T.date <= as_of 30 | GROUP BY L.amount_currency 31 | ) AS sub 32 | ); 33 | ELSE 34 | SET result_json = ( 35 | SELECT JSON_ARRAYAGG( 36 | JSON_OBJECT( 37 | 'amount', sub.amount, 38 | 'currency', sub.currency 39 | ) 40 | ) 41 | FROM ( 42 | SELECT COALESCE(SUM(L.amount), 0.0) AS amount, L.amount_currency AS currency 43 | FROM hordak_account A2 44 | JOIN hordak_leg L ON L.account_id = A2.id 45 | WHERE A2.lft >= account_lft AND A2.rght <= account_rght AND A2.tree_id = account_tree_id 46 | GROUP BY L.amount_currency 47 | ) AS sub 48 | ); 49 | END IF; 50 | 51 | -- Return the JSON result 52 | RETURN result_json; 53 | END; 54 | -- - reverse: 55 | DROP FUNCTION get_balance; 56 | -------------------------------------------------------------------------------- /hordak/migrations/0047_get_balance.pg.sql: -------------------------------------------------------------------------------- 1 | ------ 2 | CREATE FUNCTION get_balance_table(account_id BIGINT, as_of DATE = NULL) 3 | RETURNS TABLE (amount DECIMAL, currency VARCHAR) AS 4 | $$ 5 | DECLARE 6 | account_lft int; 7 | account_rght int; 8 | account_tree_id int; 9 | BEGIN 10 | -- Get the account's information 11 | SELECT 12 | lft, 13 | rght, 14 | tree_id 15 | INTO 16 | account_lft, 17 | account_rght, 18 | account_tree_id 19 | FROM hordak_account 20 | WHERE id = account_id; 21 | -- TODO: OPTIMISATION: Crate get_balance_table_simple() for use when this is a leaf account, 22 | -- and defer to it when lft + 1 = rght 23 | 24 | IF as_of IS NOT NULL THEN 25 | -- If `as_of` is specified then we need an extra join onto the 26 | -- transactions table to get the transaction date 27 | RETURN QUERY 28 | SELECT 29 | COALESCE(SUM(L.amount), 0.0) as amount, 30 | L.amount_currency as currency 31 | FROM hordak_account A2 32 | INNER JOIN hordak_leg L on L.account_id = A2.id 33 | INNER JOIN hordak_transaction T on L.transaction_id = T.id 34 | WHERE 35 | -- We want to include this account and all of its children 36 | A2.lft >= account_lft AND 37 | A2.rght <= account_rght AND 38 | A2.tree_id = account_tree_id AND 39 | -- Also respect the as_of parameter 40 | T.date <= as_of 41 | GROUP BY L.amount_currency; 42 | ELSE 43 | RETURN QUERY 44 | SELECT 45 | COALESCE(SUM(L.amount), 0.0) as amount, 46 | L.amount_currency as currency 47 | FROM hordak_account A2 48 | INNER JOIN hordak_leg L on L.account_id = A2.id 49 | WHERE 50 | -- We want to include this account and all of its children 51 | A2.lft >= account_lft AND 52 | A2.rght <= account_rght AND 53 | A2.tree_id = account_tree_id 54 | GROUP BY L.amount_currency; 55 | END IF; 56 | END; 57 | $$ 58 | LANGUAGE plpgsql; 59 | --- reverse: 60 | DROP FUNCTION get_balance_table(BIGINT, DATE); 61 | 62 | 63 | ------ 64 | CREATE FUNCTION get_balance(account_id BIGINT, as_of DATE = NULL) 65 | RETURNS JSONB AS 66 | $$ 67 | BEGIN 68 | -- Convert our balance table into JSONB in the form: 69 | -- [{"amount": 100.00, "currency": "EUR"}] 70 | RETURN 71 | (SELECT jsonb_agg(jsonb_build_object('amount', amount, 'currency', currency))) 72 | FROM get_balance_table(account_id, as_of); 73 | END; 74 | $$ 75 | LANGUAGE plpgsql; 76 | --- reverse: 77 | DROP FUNCTION get_balance(BIGINT, DATE); 78 | -------------------------------------------------------------------------------- /hordak/migrations/0047_get_balance.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2024-06-27 09:11 2 | from pathlib import Path 3 | 4 | from django.db import migrations 5 | 6 | from hordak.utilities.migrations import ( 7 | select_database_type, 8 | migration_operations_from_sql, 9 | ) 10 | 11 | PATH = Path(__file__).parent 12 | 13 | 14 | class Migration(migrations.Migration): 15 | dependencies = [ 16 | ("hordak", "0046_alter_account_uuid_alter_leg_uuid_and_more"), 17 | ] 18 | 19 | operations = select_database_type( 20 | postgresql=migration_operations_from_sql(PATH / "0047_get_balance.pg.sql"), 21 | mysql=migration_operations_from_sql(PATH / "0047_get_balance.mysql.sql"), 22 | ) 23 | -------------------------------------------------------------------------------- /hordak/migrations/0048_hordak_transaction_view.mysql.sql: -------------------------------------------------------------------------------- 1 | -- ---- 2 | CREATE VIEW hordak_transaction_view AS 3 | SELECT 4 | T.*, 5 | ( 6 | SELECT JSON_ARRAYAGG(account_id) 7 | FROM hordak_leg 8 | WHERE transaction_id = T.id AND amount > 0 9 | ) AS credit_account_ids, 10 | ( 11 | SELECT JSON_ARRAYAGG(account_id) 12 | FROM hordak_leg 13 | WHERE transaction_id = T.id AND amount < 0 14 | ) AS debit_account_ids, 15 | ( 16 | SELECT JSON_ARRAYAGG(name) 17 | FROM hordak_leg JOIN hordak_account A ON A.id = hordak_leg.account_id 18 | WHERE transaction_id = T.id AND amount > 0 19 | ) AS credit_account_names, 20 | ( 21 | SELECT JSON_ARRAYAGG(name) 22 | FROM hordak_leg JOIN hordak_account A ON A.id = hordak_leg.account_id 23 | WHERE transaction_id = T.id AND amount < 0 24 | ) AS debit_account_names, 25 | ( 26 | SELECT 27 | -- TODO: MYSQL LIMITATION: Cannot handle amount calculation for multi-currency transactions 28 | CASE WHEN COUNT(DISTINCT amount_currency) < 2 THEN CONCAT('[', JSON_OBJECT('amount', SUM(amount), 'currency', amount_currency), ']') END 29 | FROM hordak_leg 30 | WHERE transaction_id = T.id AND amount > 0 31 | ) AS amount 32 | FROM 33 | hordak_transaction T 34 | GROUP BY T.id, T.uuid, T.timestamp, T.date, T.description 35 | ORDER BY T.id DESC; 36 | -- - reverse: 37 | drop view hordak_transaction_view; 38 | -------------------------------------------------------------------------------- /hordak/migrations/0048_hordak_transaction_view.pg.sql: -------------------------------------------------------------------------------- 1 | ------ 2 | create view hordak_transaction_view AS (SELECT 3 | T.*, 4 | -- Get ID and names of credited accounts 5 | -- Note that this gets unique IDs and names. If there is a 6 | -- way to implement this without DISTINCT then I would like that 7 | -- as then we can be guaranteed to get back the same number 8 | -- of account names and account IDs. 9 | ( 10 | SELECT JSONB_AGG(L_CR.account_id) 11 | FROM hordak_leg L_CR 12 | INNER JOIN hordak_account A ON A.id = L_CR.account_id 13 | WHERE L_CR.transaction_id = T.id AND L_CR.amount > 0 14 | ) AS credit_account_ids, 15 | ( 16 | SELECT JSONB_AGG(L_DR.account_id) 17 | FROM hordak_leg L_DR 18 | INNER JOIN hordak_account A ON A.id = L_DR.account_id 19 | WHERE L_DR.transaction_id = T.id AND L_DR.amount < 0 20 | ) AS debit_account_ids, 21 | ( 22 | SELECT JSONB_AGG(A.name) 23 | FROM hordak_leg L_CR 24 | INNER JOIN hordak_account A ON A.id = L_CR.account_id 25 | WHERE L_CR.transaction_id = T.id AND L_CR.amount > 0 26 | ) AS credit_account_names, 27 | ( 28 | SELECT JSONB_AGG(A.name) 29 | FROM hordak_leg L_DR 30 | INNER JOIN hordak_account A ON A.id = L_DR.account_id 31 | WHERE L_DR.transaction_id = T.id AND L_DR.amount < 0 32 | ) AS debit_account_names, 33 | JSONB_AGG(jsonb_build_object('amount', L.amount, 'currency', L.currency)) as amount 34 | FROM 35 | hordak_transaction T 36 | -- Get LEG amounts for each currency in the transaction 37 | INNER JOIN LATERAL ( 38 | SELECT SUM(amount) AS amount, amount_currency AS currency 39 | FROM hordak_leg L 40 | WHERE L.transaction_id = T.id AND L.amount > 0 41 | GROUP BY amount_currency 42 | ) L ON True 43 | GROUP BY T.id, T.uuid, T.timestamp, T.date, T.description 44 | ORDER BY T.id DESC); 45 | --- reverse: 46 | -- can be implicitly dropped by migration 0050 47 | drop view if exists hordak_transaction_view; 48 | -------------------------------------------------------------------------------- /hordak/migrations/0048_hordak_transaction_view.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2024-06-28 19:02 2 | from pathlib import Path 3 | 4 | from django.db import migrations 5 | 6 | from hordak.utilities.migrations import ( 7 | migration_operations_from_sql, 8 | select_database_type, 9 | ) 10 | 11 | PATH = Path(__file__).parent 12 | 13 | 14 | class Migration(migrations.Migration): 15 | dependencies = [ 16 | ("hordak", "0047_get_balance"), 17 | ] 18 | 19 | # TODO: TEST! 20 | 21 | operations = select_database_type( 22 | postgresql=migration_operations_from_sql( 23 | PATH / "0048_hordak_transaction_view.pg.sql" 24 | ), 25 | mysql=migration_operations_from_sql( 26 | PATH / "0048_hordak_transaction_view.mysql.sql" 27 | ), 28 | ) 29 | -------------------------------------------------------------------------------- /hordak/migrations/0049_transactionview.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2024-06-30 11:57 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import hordak.utilities.db 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("hordak", "0048_hordak_transaction_view"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="TransactionView", 16 | fields=[ 17 | ( 18 | "parent", 19 | models.OneToOneField( 20 | db_column="id", 21 | editable=False, 22 | on_delete=django.db.models.deletion.DO_NOTHING, 23 | primary_key=True, 24 | related_name="view", 25 | serialize=False, 26 | to="hordak.transaction", 27 | ), 28 | ), 29 | ("uuid", models.UUIDField(editable=False, verbose_name="uuid")), 30 | ( 31 | "timestamp", 32 | models.DateTimeField( 33 | editable=False, 34 | help_text="The creation date of this transaction object", 35 | verbose_name="timestamp", 36 | ), 37 | ), 38 | ( 39 | "date", 40 | models.DateField( 41 | editable=False, 42 | help_text="The date on which this transaction occurred", 43 | verbose_name="date", 44 | ), 45 | ), 46 | ( 47 | "description", 48 | models.TextField(editable=False, verbose_name="description"), 49 | ), 50 | ( 51 | "amount", 52 | hordak.utilities.db.BalanceField( 53 | editable=False, 54 | help_text="The total amount transferred in this transaction", 55 | ), 56 | ), 57 | ( 58 | "credit_account_ids", 59 | models.JSONField( 60 | editable=False, 61 | help_text="List of account ids for the credit legs of this transaction", 62 | ), 63 | ), 64 | ( 65 | "debit_account_ids", 66 | models.JSONField( 67 | editable=False, 68 | help_text="List of account ids for the debit legs of this transaction", 69 | ), 70 | ), 71 | ( 72 | "credit_account_names", 73 | models.JSONField( 74 | editable=False, 75 | help_text="List of account names for the credit legs of this transaction", 76 | ), 77 | ), 78 | ( 79 | "debit_account_names", 80 | models.JSONField( 81 | editable=False, 82 | help_text="List of account names for the debit legs of this transaction", 83 | ), 84 | ), 85 | ], 86 | options={ 87 | "db_table": "hordak_transaction_view", 88 | "managed": False, 89 | }, 90 | ), 91 | ] 92 | -------------------------------------------------------------------------------- /hordak/migrations/0051_amount_to_debit_credit_data.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1b1 on 2024-07-01 05:12 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("hordak", "0050_amount_to_debit_credit_add_fields"), 9 | ] 10 | 11 | operations = [ 12 | # On an M2 Macbook Pro, this took 16 seconds on 600k rows. 13 | # Therefore on 10M rows this may take around 5 minutes. 14 | migrations.RunSQL( 15 | """ 16 | UPDATE 17 | hordak_leg 18 | SET 19 | credit = CASE WHEN amount > 0 THEN amount END, 20 | debit = CASE WHEN amount < 0 THEN -amount END, 21 | currency = amount_currency 22 | """, 23 | "", 24 | ) 25 | ] 26 | -------------------------------------------------------------------------------- /hordak/migrations/0052_amount_to_debit_credit_sql.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1b1 on 2024-07-01 05:19 2 | from pathlib import Path 3 | 4 | from django.db import migrations 5 | 6 | from hordak.utilities.migrations import ( 7 | migration_operations_from_sql, 8 | select_database_type, 9 | ) 10 | 11 | PATH = Path(__file__).parent 12 | 13 | 14 | class Migration(migrations.Migration): 15 | dependencies = [ 16 | ("hordak", "0051_amount_to_debit_credit_data"), 17 | ] 18 | 19 | operations = select_database_type( 20 | postgresql=migration_operations_from_sql( 21 | PATH / "0052_amount_to_debit_credit_sql.pg.sql" 22 | ), 23 | mysql=migration_operations_from_sql( 24 | PATH / "0052_amount_to_debit_credit_sql.mysql.sql" 25 | ), 26 | ) 27 | -------------------------------------------------------------------------------- /hordak/migrations/0053_remove_leg_amount_remove_leg_amount_currency.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1b1 on 2024-07-01 06:19 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("hordak", "0052_amount_to_debit_credit_sql"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="leg", 14 | name="amount", 15 | ), 16 | migrations.RemoveField( 17 | model_name="leg", 18 | name="amount_currency", 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /hordak/migrations/0054_check_debit_credit_positive.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-08-29 17:50 2 | from pathlib import Path 3 | 4 | from django.db import migrations 5 | 6 | from hordak.utilities.migrations import ( 7 | select_database_type, 8 | migration_operations_from_sql, 9 | ) 10 | 11 | PATH = Path(__file__).parent 12 | 13 | 14 | class Migration(migrations.Migration): 15 | dependencies = [ 16 | ("hordak", "0053_remove_leg_amount_remove_leg_amount_currency"), 17 | ] 18 | 19 | operations = migration_operations_from_sql( 20 | PATH / "0054_check_debit_credit_positive.sql" 21 | ) 22 | -------------------------------------------------------------------------------- /hordak/migrations/0054_check_debit_credit_positive.sql: -------------------------------------------------------------------------------- 1 | -- ---- 2 | ALTER TABLE hordak_leg ADD CONSTRAINT hordak_leg_chk_debit_positive CHECK (debit > 0); 3 | -- - reverse: 4 | ALTER TABLE hordak_leg DROP CONSTRAINT hordak_leg_chk_debit_positive; 5 | 6 | -- ---- 7 | ALTER TABLE hordak_leg ADD CONSTRAINT hordak_leg_chk_credit_positive CHECK (credit > 0); 8 | -- - reverse: 9 | ALTER TABLE hordak_leg DROP CONSTRAINT hordak_leg_chk_credit_positive; 10 | -------------------------------------------------------------------------------- /hordak/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/hordak/migrations/__init__.py -------------------------------------------------------------------------------- /hordak/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import * # noqa 2 | from .db_views import * # noqa 3 | from .statement_csv_import import * # noqa 4 | -------------------------------------------------------------------------------- /hordak/resources.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal, DecimalException, InvalidOperation 3 | 4 | from import_export import resources 5 | 6 | from hordak.models import StatementLine, ToField 7 | from hordak.utilities.statement_import import DATE_FORMATS 8 | 9 | 10 | class StatementLineResource(resources.ModelResource): 11 | class Meta: 12 | model = StatementLine 13 | fields = ("date", "amount", "description") 14 | import_id_fields = () 15 | 16 | def __init__(self, date_format, statement_import): 17 | self.date_format = date_format 18 | self.statement_import = statement_import 19 | 20 | def before_import(self, dataset, *args, **kwargs): 21 | def _strip(s): 22 | return s.strip() if isinstance(s, str) else s 23 | 24 | # Remove whitespace 25 | for index, values in enumerate(dataset): 26 | dataset[index] = tuple(map(_strip, values)) 27 | 28 | # We're going to need this to check for duplicates (because 29 | # there could be multiple identical transactions) 30 | self.dataset = dataset 31 | similar_totals = [0] * len(self.dataset) 32 | 33 | for i, row in enumerate(dataset): 34 | num_similar = self._get_num_similar_rows(row, until=i) 35 | similar_totals[i] = num_similar 36 | 37 | # Add a new 'similar_total' column. This is a integer of how many 38 | # identical rows precede this one. 39 | self.dataset.append_col(similar_totals, header="similar_total") 40 | 41 | def before_save_instance(self, instance, *args, **kwargs): 42 | # We need to record this statement line against the parent statement import 43 | # instance passed to the constructor 44 | instance.statement_import = self.statement_import 45 | 46 | def get_instance(self, instance_loader, row): 47 | # We never update, we either create or skip 48 | return None 49 | 50 | def init_instance(self, row=None): 51 | # Attach the row to the instance as we'll need it in skip_row() 52 | instance = super(StatementLineResource, self).init_instance(row) 53 | instance._row = row 54 | return instance 55 | 56 | def skip_row(self, instance, original, *args, **kwargs): 57 | # Skip this row if the database already contains the requsite number of 58 | # rows identical to this one. 59 | return instance._row["similar_total"] < self._get_num_similar_objects(instance) 60 | 61 | def _get_num_similar_objects(self, obj): 62 | """Get any statement lines which would be considered a duplicate of obj""" 63 | return StatementLine.objects.filter( 64 | date=obj.date, amount=obj.amount, description=obj.description 65 | ).count() 66 | 67 | def _get_num_similar_rows(self, row, until=None): 68 | """Get the number of rows similar to row which precede the index `until`""" 69 | return len(list(filter(lambda r: row == r, self.dataset[:until]))) 70 | 71 | def import_obj(self, obj, data, dry_run, *args, **kwargs): 72 | return self.import_instance(obj, data, *args, dry_run=dry_run, **kwargs) 73 | 74 | def import_instance(self, instance, row, *args, **kwargs): 75 | F = ToField 76 | use_dual_amounts = F.amount_out.value in row and F.amount_in.value in row 77 | 78 | if F.date.value not in row: 79 | raise ValueError("No date column found") 80 | 81 | try: 82 | date = datetime.strptime(row[F.date.value], self.date_format).date() 83 | except ValueError: 84 | raise ValueError( 85 | "Invalid value for date. Expected {}".format( 86 | dict(DATE_FORMATS)[self.date_format] 87 | ) 88 | ) 89 | 90 | description = row[F.description.value] 91 | 92 | # Do we have in/out columns, or just one amount column? 93 | if use_dual_amounts: 94 | amount_out = row[F.amount_out.value] 95 | amount_in = row[F.amount_in.value] 96 | 97 | if amount_in and amount_out: 98 | raise ValueError("Values found for both Amount In and Amount Out") 99 | if not amount_in and not amount_out: 100 | raise ValueError("Value required for either Amount In or Amount Out") 101 | 102 | if amount_out: 103 | try: 104 | amount = abs(Decimal(amount_out)) * -1 105 | except DecimalException: 106 | raise ValueError("Invalid value found for Amount Out") 107 | else: 108 | try: 109 | amount = abs(Decimal(amount_in)) 110 | except DecimalException: 111 | raise ValueError("Invalid value found for Amount In") 112 | else: 113 | if F.amount.value not in row: 114 | raise ValueError("No amount column found") 115 | if not row[F.amount.value]: 116 | raise ValueError("No value found for amount") 117 | try: 118 | amount = Decimal(row[F.amount.value]) 119 | except InvalidOperation: 120 | raise DecimalException("Invalid value found for Amount") 121 | 122 | if amount == Decimal("0"): 123 | raise ValueError("Amount of zero not allowed") 124 | 125 | row = dict(date=date, amount=amount, description=description) 126 | try: 127 | return super(StatementLineResource, self).import_instance( 128 | instance, row, *args, **kwargs 129 | ) 130 | except AttributeError: # django-import-export < 4.0.0 131 | return super(StatementLineResource, self).import_obj( 132 | instance, row, *args, **kwargs 133 | ) 134 | -------------------------------------------------------------------------------- /hordak/templates/hordak/accounts/account_create.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/base.html' %} 2 | 3 | {% block page_name %}Create account{% endblock %} 4 | {% block page_description %}Create a new account{% endblock %} 5 | 6 | {% block content %} 7 |
8 | {% csrf_token %} 9 | {% include 'hordak/partials/form.html' with form=form %} 10 | 11 |

12 | 13 | Cancel 14 |

15 |
16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /hordak/templates/hordak/accounts/account_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/base.html' %} 2 | 3 | {% block page_name %}Accounts{% endblock %} 4 | {% block page_description %}See all accounts at a glance{% endblock %} 5 | 6 | {% block content %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {% for account in accounts %} 20 | 21 | 27 | 28 | 29 | 30 | 37 | 40 | 41 | {% empty %} 42 | 43 | 44 | 45 | {% endfor %} 46 | 47 |
NameTypeBalanceCurrencies
22 |
23 | {% if account.is_child_node %}↳{% endif %} 24 | {{ account.name }} 25 |
26 |
{{ account.get__type_display }}{{ account.balance }}{{ account.currencies|join:', ' }} 31 | {% if account.is_leaf_node %} 32 | Transactions 33 | {% else %} 34 | Parent 35 | {% endif %} 36 | 38 | Edit 39 |
No accounts exist
48 | 49 | {% endblock %} 50 | -------------------------------------------------------------------------------- /hordak/templates/hordak/accounts/account_transactions.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/base.html' %} 2 | {% load hordak %} 3 | 4 | {% block page_name %}Account {{ account.name }}{% endblock %} 5 | {% block page_description %}See all transactions for an account{% endblock %} 6 | 7 | {% block content %} 8 |
Balance: {{ account.get_balance }}
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {% for leg in legs %} 23 | 24 | 25 | 36 | 37 | 38 | 39 | 40 | 43 | 44 | 45 | {# Show the entering balance at the bottom of the list #} 46 | {% if forloop.last %} 47 | 48 | 49 | 50 | 51 | {% endif %} 52 | {% empty %} 53 | 54 | 55 | 56 | {% endfor %} 57 | 58 |
DateAccountDescriptionDebitCreditBalance
{{ leg.transaction.date }} 26 | {% if leg.is_debit %} 27 | {% for debit_leg in leg.transaction.legs.debits %} 28 | {{ debit_leg.account.name }}{% if not forloop.last %},{% endif %} 29 | {% endfor %} 30 | {% else %} 31 | {% for credit_leg in leg.transaction.legs.credits %} 32 | {{ credit_leg.account.name }}{% if not forloop.last %},{% endif %} 33 | {% endfor %} 34 | {% endif %} 35 | {{ leg.transaction.description }}{% if leg.is_debit %}{{ leg.debit }}{% endif %}{% if leg.is_credit %}{{ leg.credit }}{% endif %}{{ leg.account_balance_after }} 41 | Delete 42 |
{{ leg.account_balance_before }}
No transactions exist
59 | 60 |

61 | Back 62 |

63 | {% endblock %} 64 | -------------------------------------------------------------------------------- /hordak/templates/hordak/accounts/account_update.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/base.html' %} 2 | 3 | {% block page_name %}Edit account{% endblock %} 4 | {% block page_description %}Create a new account{% endblock %} 5 | 6 | {% block content %} 7 |
8 | {% csrf_token %} 9 | 10 |
11 | 12 |
{{ account.get__type_display }}
13 |
14 | 15 |
16 | 17 |
{{ account.currencies|join:', ' }}
18 |
19 | 20 | {% include 'hordak/partials/form.html' with form=form %} 21 | 22 |

23 | 24 | Cancel 25 |

26 |
27 | 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /hordak/templates/hordak/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Accounting 4 | {% block base_styling %} 5 | 6 | {% endblock %} 7 | {% block meta %}{% endblock %} 8 | {% block styles %}{% endblock %} 9 | {% block extra_head %}{% endblock %} 10 | 11 | 12 |

{% block page_name %}{% endblock %}

13 | {% block content %}{% endblock %} 14 | {% block scripts %}{% endblock %} 15 | {% block extra_js %}{% endblock %} 16 | 17 | 28 | 29 | -------------------------------------------------------------------------------- /hordak/templates/hordak/partials/form.html: -------------------------------------------------------------------------------- 1 | {{ form.as_div }} 2 | -------------------------------------------------------------------------------- /hordak/templates/hordak/statement_import/_import_errors.html: -------------------------------------------------------------------------------- 1 | {% if result.base_errors %} 2 |

Import errors

3 |
    4 | {% for error in result.base_errors %} 5 |
  • {{ error }}
  • 6 | {% endfor %} 7 |
8 | {% endif %} 9 | 10 | {% if result.row_errors %} 11 |

Row errors

12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {% for row_num, errors in result.row_errors %} 21 | {% for error in errors %} 22 | 23 | 24 | 25 | 26 | {% endfor %} 27 | {% endfor %} 28 | 29 |
RowError
{% if forloop.first %}Row {{ row_num }}{% endif %} {{ error.error }}
30 | {% endif %} 31 | -------------------------------------------------------------------------------- /hordak/templates/hordak/statement_import/_import_info_boxes.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 |
6 | New 7 | {{ result.totals.new }} 8 |
9 |
10 |
11 | 12 |
13 |
14 | 15 |
16 | Duplicate 17 | {{ result.totals.skip }} 18 |
19 |
20 |
21 | 22 |
23 |
24 | 25 |
26 | Errors 27 | {{ result.row_errors|length }} 28 |
29 |
30 |
31 |
32 | -------------------------------------------------------------------------------- /hordak/templates/hordak/statement_import/import_create.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/base.html' %} 2 | 3 | {% block page_name %}Import Bank Statement{% endblock %} 4 | {% block page_description %}Upload a CSV file of your bank statement.{% endblock %} 5 | 6 | {% block content %} 7 |
8 | {% csrf_token %} 9 | {% block form_content %} 10 | {% include 'hordak/partials/form.html' with form=form %} 11 | 12 | {% endblock %} 13 |
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /hordak/templates/hordak/statement_import/import_dry_run.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/base.html' %} 2 | 3 | {% block page_name %}Import Bank Statement: Dry Run{% endblock %} 4 | {% block page_description %}Let's check the data looks ok{% endblock %} 5 | 6 | {% block content %} 7 | {% if not result %} 8 |
9 | {% csrf_token %} 10 | {% block form_content %} 11 | 12 | {% endblock %} 13 |
14 | {% else %} 15 | 16 | {% block info_boxes %} 17 | {% include 'hordak/statement_import/_import_info_boxes.html' with result=result only %} 18 | {% endblock %} 19 | 20 | {% block errors %} 21 | {% include 'hordak/statement_import/_import_errors.html' with result=result only %} 22 | {% endblock %} 23 | 24 |
25 | {% csrf_token %} 26 | {% block action_form %} 27 | 28 | {% endblock %} 29 |
30 | {% endif %} 31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /hordak/templates/hordak/statement_import/import_execute.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/base.html' %} 2 | 3 | {% block page_name %} 4 | {% if result %} 5 | Import Bank Statement: Done 6 | {% else %} 7 | Import Bank Statement: Start Import 8 | {% endif %} 9 | {% endblock %} 10 | 11 | {% block content %} 12 | {% if not result %} 13 |
14 | {% csrf_token %} 15 | {% block form_content %} 16 | 17 | {% endblock %} 18 |
19 | 20 | {% else %} 21 | 22 | {% block info_boxes %} 23 | {% include 'hordak/statement_import/_import_info_boxes.html' with result=result only %} 24 | {% endblock %} 25 | 26 | {% block errors %} 27 | {% include 'hordak/statement_import/_import_errors.html' with result=result only %} 28 | {% endblock %} 29 | 30 | {% block actions %} 31 | Reconcile Transactions 32 | {% endblock %} 33 | 34 | {% endif %} 35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /hordak/templates/hordak/statement_import/import_setup.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/base.html' %} 2 | 3 | {% block page_name %}Import Bank Statement: Setup{% endblock %} 4 | {% block page_description %}Select which fields we should import{% endblock %} 5 | 6 | {% block content %} 7 |
8 | {% csrf_token %} 9 | 10 | {% block form_content %} 11 | {% include 'hordak/partials/form.html' with form=form %} 12 | {{ formset.management_form }} 13 | {% for f in formset %} 14 |
15 |
16 |

17 | {% if f.instance.column_heading %} 18 | Column: {{ f.instance.column_heading }} 19 | {% else %} 20 | Column {{ f.instance.column_number }} 21 | {% endif %} 22 |

23 | {% if f.instance.example %} 24 | Example data: {{ f.instance.example }} 25 | {% else %} 26 | No data in column 27 | {% endif %} 28 |
29 | 30 | 31 |
32 |
33 | {% include 'hordak/partials/form.html' with form=f %} 34 |
35 |
36 | {% endfor %} 37 | 38 | {% block actions %} 39 | 40 | {% endblock %} 41 | {% endblock %} 42 |
43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /hordak/templates/hordak/transactions/currency_trade.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/base.html' %} 2 | 3 | {% block page_name %}Exchange Currency{% endblock %} 4 | {% block page_description %}Represent the exchange of funds from one currency to another{% endblock %} 5 | 6 | {% block content %} 7 | 8 | {% block notes %} 9 |

10 | Currency exchanges are performed though a trading account. A trading account 11 | is unusual in that it must support multiple currencies, whereas most accounts only 12 | support one. The trading account has a balance in each currency it supports. If 13 | you convert these balances the same currency you will find your profit/loss given the 14 | exchange rates used. 15 |

16 | 17 |

18 | You can create a trading account 19 | if you have not done so already. 20 |

21 | {% endblock %} 22 | 23 | {% block form %} 24 |
25 | {% csrf_token %} 26 | {% block form_content %} 27 | {% include 'hordak/partials/form.html' with form=form %} 28 |

29 | 30 | Cancel 31 |

32 | {% endblock %} 33 |
34 | {% endblock %} 35 | 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /hordak/templates/hordak/transactions/leg_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/base.html' %} 2 | 3 | {% block page_name %}Transaction Legs{% endblock %} 4 | {% block page_description %}See all legs{% endblock %} 5 | 6 | {% block content %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {% for leg in legs %} 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {% empty %} 31 | 32 | 33 | 34 | {% endfor %} 35 | 36 |
Transaction UUIDLeg UUIDTypeAccountDebitCreditDescription
{{ leg.transaction.uuid }}{{ leg.uuid }}{{ leg.type|title }}{{ leg.account.name }}{{ leg.debit }}{{ leg.credit }}{{ leg.description }}
No leg exist
37 | 38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /hordak/templates/hordak/transactions/transaction_create.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/base.html' %} 2 | 3 | {% block page_name %}Create Transaction{% endblock %} 4 | {% block page_description %}Move money between accounts{% endblock %} 5 | 6 | {% block content %} 7 |
8 | {% csrf_token %} 9 | {% block form_content %} 10 | {% include 'hordak/partials/form.html' with form=form %} 11 |

12 | 13 | Cancel 14 |

15 | {% endblock %} 16 |
17 | 18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /hordak/templates/hordak/transactions/transaction_delete.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/base.html' %} 2 | 3 | {% block page_name %}Delete Transaction{% endblock %} 4 | 5 | {% block content %} 6 |
7 | {% csrf_token %} 8 | {% block form_content %} 9 | {% include 'hordak/partials/form.html' with form=form %} 10 |
11 |
12 | 13 |
14 |

Are you sure you wish to delete this transaction?

15 | 16 |

17 | You could alternatively create another transaction to reverse it. 18 | Deleting a transaction cannot be undone. 19 |

20 |
21 |
22 |
23 |
24 | Cancel 25 | 26 |
27 | {% endblock %} 28 |
29 | 30 | {% endblock %} 31 | ] 32 | -------------------------------------------------------------------------------- /hordak/templates/hordak/transactions/transaction_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/base.html' %} 2 | 3 | {% block page_name %}Transactions List{% endblock %} 4 | {% block page_description %}See all transactions{% endblock %} 5 | 6 | {% block content %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {% for transaction in transactions %} 18 | 19 | 20 | 21 | 30 | 31 | 32 | {% empty %} 33 | 34 | 35 | 36 | {% endfor %} 37 | 38 |
IDDateLegsDescription
{{ transaction.uuid }}{{ transaction.date }} 22 |
23 | {% for leg in transaction.legs.all %} 24 |
{{ leg.type|title }} {{ leg.account.name }}
25 |
{{ leg.type_short }} {{ leg.amount }}
26 | {% endfor %} 27 |
28 | 29 |
{{ transaction.description }}
No transaction exist
39 | 40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /hordak/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/login.html' %} 2 | -------------------------------------------------------------------------------- /hordak/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/hordak/templatetags/__init__.py -------------------------------------------------------------------------------- /hordak/templatetags/hordak.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import logging 4 | from decimal import Decimal 5 | 6 | import babel.numbers 7 | from django import template 8 | from django.utils.safestring import mark_safe 9 | from django.utils.translation import get_language, to_locale 10 | from moneyed import Money 11 | 12 | from hordak.utilities.currency import Balance 13 | 14 | 15 | register = template.Library() 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | @register.filter(name="abs") 20 | def abs_val(value): 21 | return abs(value) 22 | 23 | 24 | @register.filter() 25 | def inv(value): 26 | if not value: 27 | return value 28 | return value * -1 29 | 30 | 31 | @register.filter() 32 | def currency(value): 33 | locale = to_locale(get_language()) 34 | 35 | if value is None: 36 | return None 37 | 38 | if isinstance(value, Balance): 39 | locale_values = [] 40 | for money in value.monies(): 41 | locale_value = babel.numbers.format_currency( 42 | abs(money.amount), currency=money.currency.code, locale=locale 43 | ) 44 | locale_value = ( 45 | locale_value if money.amount >= 0 else "({})".format(locale_value) 46 | ) 47 | locale_values.append(locale_value) 48 | else: 49 | locale_value = babel.numbers.format_decimal(abs(value), locale=locale) 50 | locale_value = locale_value if value >= 0 else "({})".format(locale_value) 51 | locale_values = [locale_value] 52 | 53 | return ", ".join(locale_values) 54 | 55 | 56 | @register.filter(is_safe=True) 57 | def color_currency(value, flip=False): 58 | value = value or 0 59 | if isinstance(value, Money): 60 | value = value.amount 61 | if value > 0: 62 | css_class = "neg" if flip else "pos" 63 | elif value < 0: 64 | css_class = "pos" if flip else "neg" 65 | else: 66 | css_class = "zero" 67 | out = """
%s
""" % (css_class, currency(value)) 68 | return mark_safe(out) 69 | 70 | 71 | @register.filter(is_safe=True) 72 | def color_currency_inv(value): 73 | return color_currency(value, flip=True) 74 | 75 | 76 | @register.filter(is_safe=True) 77 | def negative(value): 78 | value = value or 0 79 | return abs(value) * -1 80 | 81 | 82 | def valid_numeric(arg): 83 | if isinstance(arg, (int, float, Decimal)): 84 | return arg 85 | try: 86 | return int(arg) 87 | except ValueError: 88 | return float(arg) 89 | 90 | 91 | # Pulled from django-mathfilters 92 | 93 | 94 | def handle_float_decimal_combinations(value, arg, operation): 95 | if isinstance(value, float) and isinstance(arg, Decimal): 96 | logger.warning( 97 | "Unsafe operation: {0!r} {1} {2!r}.".format(value, operation, arg) 98 | ) 99 | value = Decimal(str(value)) 100 | if isinstance(value, Decimal) and isinstance(arg, float): 101 | logger.warning( 102 | "Unsafe operation: {0!r} {1} {2!r}.".format(value, operation, arg) 103 | ) 104 | arg = Decimal(str(arg)) 105 | return value, arg 106 | 107 | 108 | @register.filter 109 | def sub(value, arg, is_safe=False): 110 | """Subtract the arg from the value.""" 111 | try: 112 | nvalue, narg = handle_float_decimal_combinations( 113 | valid_numeric(value), valid_numeric(arg), "-" 114 | ) 115 | return nvalue - narg 116 | except (ValueError, TypeError): 117 | try: 118 | return value - arg 119 | except Exception: 120 | return "" 121 | 122 | 123 | @register.filter(name="addition", is_safe=False) 124 | def addition(value, arg): 125 | """Float-friendly replacement for Django's built-in `add` filter.""" 126 | try: 127 | nvalue, narg = handle_float_decimal_combinations( 128 | valid_numeric(value), valid_numeric(arg), "+" 129 | ) 130 | return nvalue + narg 131 | except (ValueError, TypeError): 132 | try: 133 | return value + arg 134 | except Exception: 135 | return "" 136 | -------------------------------------------------------------------------------- /hordak/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/hordak/tests/__init__.py -------------------------------------------------------------------------------- /hordak/tests/admin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/hordak/tests/admin/__init__.py -------------------------------------------------------------------------------- /hordak/tests/data_sources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/hordak/tests/data_sources/__init__.py -------------------------------------------------------------------------------- /hordak/tests/forms/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/hordak/tests/forms/__init__.py -------------------------------------------------------------------------------- /hordak/tests/forms/test_accounts.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from hordak.forms.accounts import AccountForm 4 | from hordak.models import Account 5 | 6 | 7 | class SimpleTransactionFormTestCase(TestCase): 8 | def test_valid_data(self): 9 | form = AccountForm( 10 | { 11 | "name": "Foo account", 12 | "currencies": ["USD", "EUR"], 13 | } 14 | ) 15 | self.assertTrue(form.is_valid()) 16 | form.save() 17 | 18 | account = Account.objects.get() 19 | self.assertEqual(account.name, "Foo account") 20 | self.assertEqual(account.currencies, ["USD", "EUR"]) 21 | self.assertEqual(account.code, None) 22 | 23 | def test_valid_data_code(self): 24 | form = AccountForm( 25 | { 26 | "name": "Foo account", 27 | "currencies": ["USD", "EUR"], 28 | "code": "foo", 29 | } 30 | ) 31 | self.assertTrue(form.is_valid()) 32 | form.save() 33 | 34 | account = Account.objects.get() 35 | self.assertEqual(account.name, "Foo account") 36 | self.assertEqual(account.currencies, ["USD", "EUR"]) 37 | self.assertEqual(account.code, "foo") 38 | 39 | def test_currency_validation_error(self): 40 | """Non-existent currency doesn't validate""" 41 | form = AccountForm( 42 | { 43 | "name": "Foo account", 44 | "currencies": ["FOO"], 45 | } 46 | ) 47 | self.assertEqual( 48 | form.errors, 49 | { 50 | "currencies": [ 51 | "Select a valid choice. " "FOO is not one of the available choices." 52 | ] 53 | }, 54 | ) 55 | 56 | def test_currencies_old_format(self): 57 | """Non-existent currency doesn't validate""" 58 | form = AccountForm( 59 | { 60 | "name": "Foo account", 61 | "currencies": "USD, EUR", 62 | } 63 | ) 64 | self.assertIn( 65 | 'Currencies needs to be valid JSON (i.e. ["USD", "EUR"]' 66 | ' or ["USD"]) - USD, EUR is not valid JSON.', 67 | form.errors["currencies"], 68 | ) 69 | -------------------------------------------------------------------------------- /hordak/tests/forms/test_statement_csv_import.py: -------------------------------------------------------------------------------- 1 | from django.core.files.uploadedfile import SimpleUploadedFile 2 | from django.test import TestCase 3 | 4 | from hordak.forms.statement_csv_import import TransactionCsvImportForm 5 | from hordak.models import AccountType, TransactionCsvImport 6 | from hordak.tests.utils import DataProvider 7 | 8 | 9 | class TransactionCsvImportFormTestCase(DataProvider, TestCase): 10 | def setUp(self): 11 | self.account = self.account(is_bank_account=True, type=AccountType.asset) 12 | self.f = SimpleUploadedFile( 13 | "data.csv", b"Number,Date,Account,Amount,Subcategory,Memo" 14 | ) 15 | 16 | def test_create(self): 17 | form = TransactionCsvImportForm( 18 | data=dict(bank_account=self.account.pk), files=dict(file=self.f) 19 | ) 20 | self.assertTrue(form.is_valid(), form.errors) 21 | form.save() 22 | obj = TransactionCsvImport.objects.get() 23 | self.assertEqual(obj.columns.count(), 6) 24 | self.assertEqual(obj.hordak_import.bank_account, self.account) 25 | 26 | def test_edit(self): 27 | obj = TransactionCsvImport.objects.create( 28 | hordak_import=self.statement_import(bank_account=self.account), 29 | has_headings=True, 30 | file=self.f, 31 | ) 32 | form = TransactionCsvImportForm( 33 | data=dict(bank_account=self.account.pk), 34 | files=dict(file=self.f), 35 | instance=obj, 36 | ) 37 | self.assertTrue(form.is_valid(), form.errors) 38 | form.save() 39 | self.assertEqual(obj.columns.count(), 0) 40 | -------------------------------------------------------------------------------- /hordak/tests/gnucash_files/.gitignore: -------------------------------------------------------------------------------- 1 | *.gnucash.* 2 | -------------------------------------------------------------------------------- /hordak/tests/gnucash_files/CapitalGainsTestCase.gnucash: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/hordak/tests/gnucash_files/CapitalGainsTestCase.gnucash -------------------------------------------------------------------------------- /hordak/tests/gnucash_files/InitialEquityTestCase.gnucash: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/hordak/tests/gnucash_files/InitialEquityTestCase.gnucash -------------------------------------------------------------------------------- /hordak/tests/gnucash_files/PrepaidRentTestCase.gnucash: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/hordak/tests/gnucash_files/PrepaidRentTestCase.gnucash -------------------------------------------------------------------------------- /hordak/tests/gnucash_files/README: -------------------------------------------------------------------------------- 1 | These files correspond to the test cases in test_worked_examples.py. 2 | The idea is to verify that hordak works correctly against an 3 | implementation we can presume to be good. 4 | -------------------------------------------------------------------------------- /hordak/tests/gnucash_files/UtilityBillTestCase.gnucash: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/hordak/tests/gnucash_files/UtilityBillTestCase.gnucash -------------------------------------------------------------------------------- /hordak/tests/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/hordak/tests/models/__init__.py -------------------------------------------------------------------------------- /hordak/tests/models/test_statement_csv_import.py: -------------------------------------------------------------------------------- 1 | from django.core.files.uploadedfile import SimpleUploadedFile 2 | from django.test import TestCase 3 | 4 | from hordak.models import TransactionCsvImport 5 | from hordak.tests.utils import DataProvider 6 | 7 | 8 | class TransactionCsvImportTestCase(DataProvider, TestCase): 9 | def test_create_columns_ok(self): 10 | f = SimpleUploadedFile( 11 | "data.csv", 12 | b"Number,Date,Account,Amount,Subcategory,Memo\n" 13 | b"1,1/1/1,123456789,123,OTH,Some random notes", 14 | ) 15 | 16 | inst = TransactionCsvImport.objects.create( 17 | has_headings=True, file=f, hordak_import=self.statement_import() 18 | ) 19 | inst.create_columns() 20 | 21 | columns = inst.columns.all() 22 | 23 | self.assertEqual(columns[0].column_number, 1) 24 | self.assertEqual(columns[0].column_heading, "Number") 25 | self.assertEqual(columns[0].to_field, None) 26 | self.assertEqual(columns[0].example, "1") 27 | 28 | self.assertEqual(columns[1].column_number, 2) 29 | self.assertEqual(columns[1].column_heading, "Date") 30 | self.assertEqual(columns[1].to_field, "date") 31 | self.assertEqual(columns[1].example, "1/1/1") 32 | 33 | self.assertEqual(columns[2].column_number, 3) 34 | self.assertEqual(columns[2].column_heading, "Account") 35 | self.assertEqual(columns[2].to_field, None) 36 | self.assertEqual(columns[2].example, "123456789") 37 | 38 | self.assertEqual(columns[3].column_number, 4) 39 | self.assertEqual(columns[3].column_heading, "Amount") 40 | self.assertEqual(columns[3].to_field, "amount") 41 | self.assertEqual(columns[3].example, "123") 42 | 43 | self.assertEqual(columns[4].column_number, 5) 44 | self.assertEqual(columns[4].column_heading, "Subcategory") 45 | self.assertEqual(columns[4].to_field, None) 46 | self.assertEqual(columns[4].example, "OTH") 47 | 48 | self.assertEqual(columns[5].column_number, 6) 49 | self.assertEqual(columns[5].column_heading, "Memo") 50 | self.assertEqual(columns[5].to_field, "description") 51 | self.assertEqual(columns[5].example, "Some random notes") 52 | -------------------------------------------------------------------------------- /hordak/tests/templatetags/test_hordak.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from django.test import TestCase 4 | from django.utils.translation import activate, get_language, to_locale 5 | from moneyed import Money 6 | 7 | from hordak.templatetags.hordak import currency 8 | from hordak.utilities.currency import Balance 9 | 10 | 11 | class TestHordakTemplateTags(TestCase): 12 | def setUp(self): 13 | self.orig_locale = to_locale(get_language()) 14 | activate("en-US") 15 | 16 | def tearDown(self): 17 | activate(self.orig_locale) 18 | 19 | def test_currency_as_balance(self): 20 | bal = Balance([Money("10.00", "EUR")]) 21 | assert currency(bal) == "€10.00" 22 | 23 | def test_currency_as_val(self): 24 | bal = Decimal(10000) 25 | assert currency(bal) == "10,000" 26 | -------------------------------------------------------------------------------- /hordak/tests/test_commands.py: -------------------------------------------------------------------------------- 1 | from django.core.management import call_command 2 | from django.test.testcases import TestCase 3 | 4 | from hordak.models import Account 5 | 6 | 7 | class CreateChartOfAccountsTestCase(TestCase): 8 | def test_simple(self): 9 | call_command("create_chart_of_accounts", *["--currency", "USD"]) 10 | self.assertGreater(Account.objects.count(), 10) 11 | account = Account.objects.all()[0] 12 | self.assertEqual(account.currencies, ["USD"]) 13 | 14 | def test_multi_currency(self): 15 | call_command("create_chart_of_accounts", *["--currency", "USD", "EUR"]) 16 | self.assertGreater(Account.objects.count(), 10) 17 | account = Account.objects.all()[0] 18 | self.assertEqual(account.currencies, ["USD", "EUR"]) 19 | -------------------------------------------------------------------------------- /hordak/tests/utilities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/hordak/tests/utilities/__init__.py -------------------------------------------------------------------------------- /hordak/tests/utilities/test_account_codes.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from hordak.exceptions import NoMoreAccountCodesAvailableInSequence 4 | from hordak.utilities.account_codes import AccountCodeGenerator, get_next_account_code 5 | 6 | 7 | class AccountCodesTestCase(TestCase): 8 | def test_account_code_generator_simple(self): 9 | generator = AccountCodeGenerator(start_at="7") 10 | self.assertEqual(list(generator), ["8", "9"]) 11 | 12 | def test_account_code_generator_alpha(self): 13 | generator = AccountCodeGenerator( 14 | start_at="X", 15 | alpha=True, 16 | ) 17 | self.assertEqual(list(generator), ["Y", "Z"]) 18 | 19 | def test_account_code_generator_full_run(self): 20 | generator = AccountCodeGenerator( 21 | start_at="00", 22 | alpha=True, 23 | ) 24 | results = list(generator) 25 | self.assertEqual(len(results), 36**2 - 1) 26 | self.assertEqual(results[0], "01") 27 | self.assertEqual(results[-1], "ZZ") 28 | 29 | def test_get_next_account_code_simple(self): 30 | self.assertEqual(get_next_account_code("84"), "85") 31 | 32 | def test_get_next_account_code_alpha(self): 33 | self.assertEqual(get_next_account_code("9Z", alpha=True), "A0") 34 | 35 | def test_get_next_account_code_end_of_sequence(self): 36 | with self.assertRaises(NoMoreAccountCodesAvailableInSequence): 37 | get_next_account_code("99") 38 | -------------------------------------------------------------------------------- /hordak/tests/utilities/test_money.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from django.test import TestCase 4 | from mock import patch 5 | 6 | import hordak.utilities.money 7 | from hordak.utilities.money import ratio_split 8 | 9 | 10 | # Note: these tests assume that sorting is stable across all Python versions. 11 | 12 | 13 | @patch.object(hordak.utilities.money, "DECIMAL_PLACES", 2) 14 | class RatioSplitTestCase(TestCase): 15 | def test_extra_penny(self): 16 | values = ratio_split(Decimal("10"), [Decimal("3"), Decimal("3"), Decimal("3")]) 17 | self.assertEqual(values, [Decimal("3.34"), Decimal("3.33"), Decimal("3.33")]) 18 | 19 | def test_less_penny(self): 20 | values = ratio_split(Decimal("8"), [Decimal("3"), Decimal("3"), Decimal("3")]) 21 | self.assertEqual(values, [Decimal("2.66"), Decimal("2.67"), Decimal("2.67")]) 22 | 23 | def test_pennies(self): 24 | values = ratio_split( 25 | Decimal("-11.06"), [Decimal("1"), Decimal("1"), Decimal("1"), Decimal("1")] 26 | ) 27 | self.assertEqual( 28 | values, 29 | [Decimal("-2.77"), Decimal("-2.77"), Decimal("-2.76"), Decimal("-2.76")], 30 | ) 31 | 32 | def test_pennies_zeros(self): 33 | values = ratio_split( 34 | Decimal("11.05"), [Decimal("1"), Decimal("1"), Decimal("0")] 35 | ) 36 | self.assertEqual(values, [Decimal("5.53"), Decimal("5.52"), Decimal("0.00")]) 37 | 38 | values = ratio_split( 39 | Decimal("11.05"), [Decimal("0"), Decimal("1"), Decimal("1")] 40 | ) 41 | self.assertEqual(values, [Decimal("0.00"), Decimal("5.53"), Decimal("5.52")]) 42 | 43 | def test_all_equal(self): 44 | values = ratio_split(Decimal("30"), [Decimal("3"), Decimal("3"), Decimal("3")]) 45 | self.assertEqual(values, [Decimal("10"), Decimal("10"), Decimal("10")]) 46 | -------------------------------------------------------------------------------- /hordak/tests/utils.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.auth.models import User 3 | 4 | from hordak.models import Account, AccountType, StatementImport 5 | from hordak.utilities.currency import Balance 6 | 7 | 8 | Empty = object() 9 | 10 | 11 | class DataProvider(object): 12 | """Utility methods for providing data to test cases""" 13 | 14 | def user( 15 | self, 16 | *, 17 | username=None, 18 | email=None, 19 | password=None, 20 | is_superuser=True, 21 | is_distributor=False, 22 | **kwargs 23 | ): 24 | username = username or "user{}".format(get_user_model().objects.count() + 1) 25 | email = email or "{}@example.com".format(username) 26 | 27 | user = User.objects.create( 28 | email=email, username=username, is_superuser=is_superuser, **kwargs 29 | ) 30 | 31 | if password: 32 | user.set_password(password) 33 | user.save() 34 | 35 | return user 36 | 37 | def account( 38 | self, 39 | name=None, 40 | parent=None, 41 | type=AccountType.income, 42 | code=Empty, 43 | currencies=("EUR",), 44 | **kwargs 45 | ): 46 | """Utility for creating accounts for use in test cases 47 | 48 | Returns: 49 | Account 50 | """ 51 | name = name or "Account {}".format(Account.objects.count() + 1) 52 | code = Account.objects.filter(parent=parent).count() if code is Empty else code 53 | 54 | return Account.objects.create( 55 | name=name, 56 | parent=parent, 57 | type=None if parent else type, 58 | code=code, 59 | currencies=currencies, 60 | **kwargs 61 | ) 62 | 63 | def statement_import(self, bank_account=None, **kwargs): 64 | return StatementImport.objects.create( 65 | bank_account=bank_account or self.account(type=AccountType.asset), 66 | source="csv", 67 | **kwargs 68 | ) 69 | 70 | def login(self): 71 | user = get_user_model().objects.create(username="user") 72 | user.set_password("password") 73 | user.save() 74 | self.client.login(username="user", password="password") 75 | return user 76 | 77 | 78 | class BalanceUtils(object): 79 | def assertBalanceEqual(self, balance, value): 80 | assert not isinstance(value, Balance), ( 81 | "The second argument to assertBalanceEqual() should be a regular " 82 | "integer/Decimal type, not a Balance object. If you wish to compare " 83 | "two Balance objects then use assertEqual()" 84 | ) 85 | 86 | monies = balance.monies() 87 | assert len(monies) in ( 88 | 0, 89 | 1, 90 | ), "Can only compare balances which contain a single currency" 91 | if not monies and value == 0: 92 | # Allow comparing balances without a currency to zero 93 | return 94 | 95 | assert ( 96 | len(monies) == 1 97 | ), "Can only compare balances which contain a single currency" 98 | balance_amount = balance.monies()[0].amount 99 | assert balance_amount == value, "Balance {} != {}".format(balance_amount, value) 100 | -------------------------------------------------------------------------------- /hordak/tests/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/hordak/tests/views/__init__.py -------------------------------------------------------------------------------- /hordak/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from hordak.views import accounts, statement_csv_import, transactions 4 | 5 | 6 | app_name = "hordak" 7 | 8 | urlpatterns = [ 9 | path( 10 | "transactions/create/", 11 | transactions.TransactionCreateView.as_view(), 12 | name="transactions_create", 13 | ), 14 | path( 15 | "transactions//delete/", 16 | transactions.TransactionDeleteView.as_view(), 17 | name="transactions_delete", 18 | ), 19 | path( 20 | "transactions/currency/", 21 | transactions.CurrencyTradeView.as_view(), 22 | name="currency_trade", 23 | ), 24 | path( 25 | "transactions/reconcile/", 26 | transactions.TransactionsReconcileView.as_view(), 27 | name="transactions_reconcile", 28 | ), 29 | path( 30 | "transactions/list/", 31 | transactions.TransactionsListView.as_view(), 32 | name="transactions_list", 33 | ), 34 | path("transactions/legs/", transactions.LegsListView.as_view(), name="legs_list"), 35 | path( 36 | "statement-line//unreconcile/", 37 | transactions.UnreconcileView.as_view(), 38 | name="transactions_unreconcile", 39 | ), 40 | path("", accounts.AccountListView.as_view(), name="accounts_list"), 41 | path( 42 | "accounts/create/", accounts.AccountCreateView.as_view(), name="accounts_create" 43 | ), 44 | path( 45 | "accounts/update//", 46 | accounts.AccountUpdateView.as_view(), 47 | name="accounts_update", 48 | ), 49 | path( 50 | "accounts//", 51 | accounts.AccountTransactionsView.as_view(), 52 | name="accounts_transactions", 53 | ), 54 | path( 55 | "import/", statement_csv_import.CreateImportView.as_view(), name="import_create" 56 | ), 57 | path( 58 | "import//setup/", 59 | statement_csv_import.SetupImportView.as_view(), 60 | name="import_setup", 61 | ), 62 | path( 63 | "import//dry-run/", 64 | statement_csv_import.DryRunImportView.as_view(), 65 | name="import_dry_run", 66 | ), 67 | path( 68 | "import//run/", 69 | statement_csv_import.ExecuteImportView.as_view(), 70 | name="import_execute", 71 | ), 72 | ] 73 | -------------------------------------------------------------------------------- /hordak/utilities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-hordak/4e2cd51c1e3b0719f090fb003487cc28c8b635cb/hordak/utilities/__init__.py -------------------------------------------------------------------------------- /hordak/utilities/account_codes.py: -------------------------------------------------------------------------------- 1 | import string 2 | from typing import List 3 | 4 | from hordak.exceptions import NoMoreAccountCodesAvailableInSequence 5 | 6 | 7 | class AccountCodeGenerator: 8 | """Generate the next account codes in sequence""" 9 | 10 | def __init__(self, start_at: str, alpha=False): 11 | """Generate account codes in sequence starting at start_at 12 | 13 | Account codes will be generated with the length of the `start_at` string. 14 | For example, when `start_at="00"`, this generator will generate account 15 | codes up to `99` (or `ZZ` when `alpha=True`). 16 | 17 | Set `alpha` to `True` to include the characters A-Z when generating account codes. 18 | The progression will be in the form: 18, 19, 1A, 1B 19 | """ 20 | self.chars = string.digits 21 | if alpha: 22 | self.chars += string.ascii_uppercase 23 | 24 | self.start_at = start_at.upper() 25 | self.base = len(self.chars) 26 | self.reset_iterator() 27 | 28 | def reset_iterator(self): 29 | self.current = self._to_list(self.start_at) 30 | 31 | def _to_list(self, value: str) -> List[int]: 32 | # "0A" -> (0, 10) 33 | return [self.chars.index(v) for v in value] 34 | 35 | def _to_str(self, value: List[int]) -> str: 36 | # (0, 10) -> "0A" 37 | return "".join([self.chars[i] for i in value]) 38 | 39 | def __next__(self): 40 | # Increment the right-most value 41 | self.current[-1] += 1 42 | # Now go through each value and carry over values that exceed the number base. 43 | # We iterate from right to left, carrying over as we go. 44 | for i, _ in reversed(list(enumerate(self.current))): 45 | if self.current[i] >= self.base: 46 | if i == 0: 47 | # The left-most value is now too big, 48 | # so we have exhausted this sequence. 49 | # Stop iterating 50 | raise StopIteration() 51 | 52 | # Otherwise, do the cary-over. Set this value to 0, 53 | # and add one to the value left of us 54 | self.current[i] = 0 55 | self.current[i - 1] += 1 56 | 57 | return self._to_str(self.current) 58 | 59 | def __iter__(self): 60 | self.reset_iterator() 61 | return self 62 | 63 | 64 | def get_next_account_code(account_code: str, alpha=False): 65 | """Get the next account code in sequence""" 66 | try: 67 | return next(AccountCodeGenerator(start_at=account_code, alpha=alpha)) 68 | except StopIteration: 69 | raise NoMoreAccountCodesAvailableInSequence() 70 | -------------------------------------------------------------------------------- /hordak/utilities/db.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import List, Union 3 | 4 | from django.core.exceptions import ValidationError 5 | from django.db import models 6 | from moneyed import Money 7 | 8 | from hordak.utilities.currency import Balance 9 | 10 | 11 | class BalanceField(models.JSONField): 12 | def from_db_value(self, value, expression, connection): 13 | """ 14 | Converts the JSON string from the database to a Balance object. 15 | """ 16 | if value is None: 17 | return value 18 | try: 19 | return json_to_balance(value) 20 | except ValueError: 21 | raise ValidationError("Invalid JSON format") 22 | 23 | def to_python(self, value): 24 | """ 25 | Converts the value into Balance objects when accessing it via Python. 26 | """ 27 | if value is None: 28 | return value 29 | try: 30 | return json_to_balance(value) 31 | except ValueError: 32 | raise ValidationError("Invalid JSON format") 33 | 34 | def get_prep_value(self, value): 35 | """ 36 | Converts the Balance object to JSON string before saving to the database. 37 | """ 38 | if isinstance(value, list): 39 | return json.dumps( 40 | [{"amount": bal.amount, "currency": bal.currency} for bal in value] 41 | ) 42 | return super().get_prep_value(value) 43 | 44 | 45 | def json_to_balance(json_: Union[str, List[dict]]) -> Balance: 46 | if isinstance(json_, str): 47 | json_ = json.loads(json_) 48 | return Balance([Money(m["amount"], m["currency"]) for m in json_]) 49 | 50 | 51 | def balance_to_json(balance: Balance) -> List[dict]: 52 | return [{"amount": m.amount, "currency": m.currency.code} for m in balance] 53 | -------------------------------------------------------------------------------- /hordak/utilities/db_functions.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import date 3 | from functools import cached_property 4 | from typing import Union 5 | 6 | from django.db.models import Func 7 | from django.db.models.expressions import Combinable, Value 8 | from djmoney.models.fields import MoneyField 9 | from moneyed import Money 10 | 11 | from hordak import defaults 12 | from hordak.utilities.currency import Balance 13 | 14 | 15 | class GetBalance(Func): 16 | """Django representation of the get_balance() custom database function provided by Hordak""" 17 | 18 | function = "GET_BALANCE" 19 | 20 | def __init__( 21 | self, 22 | account_id: Union[Combinable, int], 23 | as_of: Union[Combinable, date, str] = None, 24 | as_of_leg_id: Union[Combinable, int] = None, 25 | output_field=None, 26 | **extra 27 | ): 28 | """Create a new GetBalance() 29 | 30 | Examples: 31 | 32 | .. code-block:: python 33 | 34 | from hordak.utilities.db_functions import GetBalance 35 | 36 | GetBalance(account_id=5) 37 | GetBalance(account_id=5, as_of='2000-01-01') 38 | 39 | Account.objects.all().annotate( 40 | balance=GetBalance(F("id"), as_of='2000-01-01') 41 | ) 42 | 43 | """ 44 | if as_of is not None: 45 | if not isinstance(as_of, Combinable): 46 | as_of = Value(as_of) 47 | 48 | if as_of is None and as_of_leg_id is not None: 49 | raise ValueError("as_of cannot be None when specifying as_of_leg_id") 50 | 51 | output_field = output_field or MoneyField() 52 | super().__init__( 53 | account_id, as_of, as_of_leg_id, output_field=output_field, **extra 54 | ) 55 | 56 | @cached_property 57 | def convert_value(self): 58 | # Convert the JSON output into a Balance object. Example of a JSON response: 59 | # [{"amount": 100.00, "currency": "EUR"}] 60 | def convertor(value, expression, connection): 61 | if not value: 62 | return Balance([Money("0", defaults.DEFAULT_CURRENCY)]) 63 | value = json.loads(value) 64 | return Balance([Money(v["amount"], v["currency"]) for v in value]) 65 | 66 | return convertor 67 | -------------------------------------------------------------------------------- /hordak/utilities/dreprecation.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import warnings 3 | 4 | 5 | def deprecated(reason): 6 | def decorator(func): 7 | @functools.wraps(func) 8 | def wrapper(*args, **kwargs): 9 | warnings.warn( 10 | f"{func.__name__} is deprecated: {reason}", 11 | DeprecationWarning, 12 | stacklevel=2, 13 | ) 14 | return func(*args, **kwargs) 15 | 16 | return wrapper 17 | 18 | return decorator 19 | -------------------------------------------------------------------------------- /hordak/utilities/migrations.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TypeVar 3 | 4 | from django.db import migrations 5 | 6 | 7 | def migration_operations_from_sql(file_path: Path): 8 | operations = [] 9 | sql: str = file_path.read_text(encoding="utf8").strip().strip("-") 10 | # Mysql needs to have spaces after a '--' comment 11 | sql = sql.replace("-- ----", "------").replace("-- -", "---") 12 | if not sql: 13 | return [] 14 | 15 | sql_statements = sql.split("\n------\n") 16 | for sql_statement in sql_statements: 17 | if "--- reverse:" in sql_statement: 18 | forward, reverse = sql_statement.split("--- reverse:") 19 | else: 20 | forward, reverse = sql_statement, None 21 | 22 | if _is_empty_sql_statement(forward): 23 | raise Exception("Forward SQL statement cannot be empty") 24 | 25 | if reverse is not None and _is_empty_sql_statement(reverse): 26 | # Reverse is specified, but is empty. So assume no reverse 27 | # action is required 28 | reverse = "" 29 | 30 | operations.append(migrations.RunSQL(sql=forward, reverse_sql=reverse)) 31 | 32 | return operations 33 | 34 | 35 | T = TypeVar("T", bound=migrations.RunSQL) 36 | 37 | 38 | def select_database_type(postgresql: T, mysql: T) -> T: 39 | from django.db import connections 40 | 41 | from hordak.models import Account 42 | 43 | database_name = Account.objects.get_queryset().db 44 | db_vendor = connections[database_name].vendor 45 | if db_vendor == "mysql": 46 | return mysql 47 | else: 48 | return postgresql 49 | 50 | 51 | def _is_empty_sql_statement(sql: str) -> bool: 52 | """Remove comments and strip whitespace""" 53 | lines = sql.split("\n") 54 | lines = [ 55 | line.strip() 56 | for line in lines 57 | if not line.strip().startswith("--") and line.strip() 58 | ] 59 | return not bool(lines) 60 | -------------------------------------------------------------------------------- /hordak/utilities/money.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from hordak.defaults import DECIMAL_PLACES 4 | 5 | 6 | def ratio_split(amount, ratios, precision=None): 7 | """Split in_value according to the ratios specified in `ratios` 8 | 9 | This is special in that it ensures the returned values always sum to 10 | in_value (i.e. we avoid losses or gains due to rounding errors). As a 11 | result, this method returns a list of `Decimal` values with length equal 12 | to that of `ratios`. 13 | 14 | Examples: 15 | 16 | .. code-block:: python 17 | 18 | >>> from hordak.utilities.money import ratio_split 19 | >>> from decimal import Decimal 20 | >>> ratio_split(Decimal('10'), [Decimal('1'), Decimal('2')]) 21 | [Decimal('3.33'), Decimal('6.67')] 22 | 23 | Note the returned values sum to the original input of ``10``. If we were to 24 | do this calculation in a naive fashion then the returned values would likely 25 | be ``3.33`` and ``6.66``, which would sum to ``9.99``, thereby loosing 26 | ``0.01``. 27 | 28 | Args: 29 | amount (Decimal): The amount to be split 30 | ratios (list[Decimal]): The ratios that will determine the split 31 | precision (Optional[Decimal]): How many decimal places to round to 32 | (defaults to the `HORDAK_DECIMAL_PLACES` setting) 33 | 34 | Returns: list(Decimal) 35 | 36 | """ 37 | if precision is None: 38 | precision = Decimal(10) ** Decimal(-DECIMAL_PLACES) 39 | assert amount == amount.quantize(precision), ( 40 | "Input amount is not at the required precision (%s)" % precision 41 | ) 42 | 43 | # Distribute the amount according to the ratios: 44 | ratio_total = sum(ratios) 45 | assert ratio_total > 0, "Ratio sum cannot be zero" 46 | values = [amount * ratio / ratio_total for ratio in ratios] 47 | 48 | # Now round the values to the desired number of decimal places: 49 | rounded = [v.quantize(precision) for v in values] 50 | 51 | # The rounded values may not add up to the exact amount. 52 | # Use the Largest Remainder algorithm to distribute the 53 | # difference between participants with non-zero ratios: 54 | participants = [i for i in range(len(ratios)) if ratios[i] != Decimal(0)] 55 | for p in sorted(participants, key=lambda i: rounded[i] - values[i]): 56 | total = sum(rounded) 57 | if total < amount: 58 | rounded[p] += precision 59 | elif total > amount: 60 | rounded[p] -= precision 61 | else: 62 | break 63 | 64 | rounded_sum = sum(rounded) 65 | assert rounded_sum == amount, ( 66 | "Sanity check failed, output total (%s) did not match input amount" 67 | % rounded_sum 68 | ) 69 | 70 | return rounded 71 | -------------------------------------------------------------------------------- /hordak/utilities/statement_import.py: -------------------------------------------------------------------------------- 1 | DATE_FORMATS = ( 2 | ("%d-%m-%Y", "dd-mm-yyyy"), 3 | ("%d/%m/%Y", "dd/mm/yyyy"), 4 | ("%d.%m.%Y", "dd.mm.yyyy"), 5 | ("%d-%Y-%m", "dd-yyyy-mm"), 6 | ("%d/%Y/%m", "dd/yyyy/mm"), 7 | ("%d.%Y.%m", "dd.yyyy.mm"), 8 | ("%m-%d-%Y", "mm-dd-yyyy"), 9 | ("%m/%d/%Y", "mm/dd/yyyy"), 10 | ("%m.%d.%Y", "mm.dd.yyyy"), 11 | ("%m-%Y-%d", "mm-yyyy-dd"), 12 | ("%m/%Y/%d", "mm/yyyy/dd"), 13 | ("%m.%Y.%d", "mm.yyyy.dd"), 14 | ("%Y-%d-%m", "yyyy-dd-mm"), 15 | ("%Y/%d/%m", "yyyy/dd/mm"), 16 | ("%Y.%d.%m", "yyyy.dd.mm"), 17 | ("%Y-%m-%d", "yyyy-mm-dd"), 18 | ("%Y/%m/%d", "yyyy/mm/dd"), 19 | ("%Y.%m.%d", "yyyy.mm.dd"), 20 | ("%d-%m-%y", "dd-mm-yy"), 21 | ("%d/%m/%y", "dd/mm/yy"), 22 | ("%d.%m.%y", "dd.mm.yy"), 23 | ("%d-%y-%m", "dd-yy-mm"), 24 | ("%d/%y/%m", "dd/yy/mm"), 25 | ("%d.%y.%m", "dd.yy.mm"), 26 | ("%m-%d-%y", "mm-dd-yy"), 27 | ("%m/%d/%y", "mm/dd/yy"), 28 | ("%m.%d.%y", "mm.dd.yy"), 29 | ("%m-%y-%d", "mm-yy-dd"), 30 | ("%m/%y/%d", "mm/yy/dd"), 31 | ("%m.%y.%d", "mm.yy.dd"), 32 | ("%y-%d-%m", "yy-dd-mm"), 33 | ("%y/%d/%m", "yy/dd/mm"), 34 | ("%y.%d.%m", "yy.dd.mm"), 35 | ("%y-%m-%d", "yy-mm-dd"), 36 | ("%y/%m/%d", "yy/mm/dd"), 37 | ("%y.%m.%d", "yy.mm.dd"), 38 | ) 39 | -------------------------------------------------------------------------------- /hordak/utilities/test.py: -------------------------------------------------------------------------------- 1 | from unittest import skip 2 | 3 | from django.db import connection 4 | 5 | 6 | def _id(obj): 7 | return obj 8 | 9 | 10 | def postgres_only(reason="Test is postgresql-specific"): 11 | if not connection.vendor == "postgresql": 12 | return skip(reason) 13 | return _id 14 | 15 | 16 | def mysql_only(reason="Test is postgresql-specific"): 17 | if not connection.vendor == "mysql": 18 | return skip(reason) 19 | return _id 20 | -------------------------------------------------------------------------------- /hordak/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .accounts import ( # noqa 2 | AccountCreateView, 3 | AccountListView, 4 | AccountTransactionsView, 5 | AccountUpdateView, 6 | ) 7 | from .statement_csv_import import ( # noqa 8 | AbstractImportView, 9 | CreateImportView, 10 | DryRunImportView, 11 | ExecuteImportView, 12 | SetupImportView, 13 | ) 14 | from .transactions import ( # noqa 15 | CurrencyTradeView, 16 | TransactionCreateView, 17 | TransactionDeleteView, 18 | TransactionsReconcileView, 19 | UnreconcileView, 20 | ) 21 | -------------------------------------------------------------------------------- /hordak/views/accounts.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.mixins import LoginRequiredMixin 2 | from django.urls.base import reverse_lazy 3 | from django.views.generic.detail import SingleObjectMixin 4 | from django.views.generic.edit import CreateView, UpdateView 5 | from django.views.generic.list import ListView 6 | 7 | from hordak.forms import accounts as account_forms 8 | from hordak.models import Account, Leg 9 | 10 | 11 | class AccountListView(LoginRequiredMixin, ListView): 12 | """View for listing accounts 13 | 14 | Examples: 15 | 16 | .. code-block:: python 17 | 18 | urlpatterns = [ 19 | ... 20 | url(r'^accounts/$', AccountListView.as_view(), name='accounts_list'), 21 | ] 22 | 23 | """ 24 | 25 | model = Account 26 | template_name = "hordak/accounts/account_list.html" 27 | context_object_name = "accounts" 28 | 29 | 30 | class AccountCreateView(LoginRequiredMixin, CreateView): 31 | """View for creating accounts 32 | 33 | Examples: 34 | 35 | .. code-block:: python 36 | 37 | urlpatterns = [ 38 | ... 39 | url(r'^accounts/create/$', 40 | AccountCreateView.as_view(success_url=reverse_lazy('accounts_list')), 41 | name='accounts_create'), 42 | ] 43 | 44 | """ 45 | 46 | form_class = account_forms.AccountForm 47 | template_name = "hordak/accounts/account_create.html" 48 | success_url = reverse_lazy("hordak:accounts_list") 49 | 50 | 51 | class AccountUpdateView(LoginRequiredMixin, UpdateView): 52 | """View for updating accounts 53 | 54 | Note that :class:`hordak.forms.AccountForm` prevents updating of the ``currency`` 55 | and ``type`` fields. Also note that this view expects to receive the Account's 56 | ``uuid`` field in the URL (see example below). 57 | 58 | Examples: 59 | 60 | .. code-block:: python 61 | 62 | urlpatterns = [ 63 | ... 64 | url(r'^accounts/update/(?P.+)/$', 65 | AccountUpdateView.as_view(success_url=reverse_lazy('accounts_list')), 66 | name='accounts_update'), 67 | ] 68 | 69 | """ 70 | 71 | model = Account 72 | form_class = account_forms.AccountForm 73 | template_name = "hordak/accounts/account_update.html" 74 | slug_field = "uuid" 75 | slug_url_kwarg = "uuid" 76 | context_object_name = "account" 77 | success_url = reverse_lazy("hordak:accounts_list") 78 | 79 | 80 | # TODO: remove ignore comment once https://github.com/typeddjango/django-stubs/issues/873 is fixed 81 | class AccountTransactionsView(LoginRequiredMixin, SingleObjectMixin, ListView): # type: ignore 82 | template_name = "hordak/accounts/account_transactions.html" 83 | model = Leg 84 | slug_field = "uuid" 85 | slug_url_kwarg = "uuid" 86 | 87 | def get(self, request, *args, **kwargs): 88 | self.object = self.get_object() 89 | return super(AccountTransactionsView, self).get(request, *args, **kwargs) 90 | 91 | def get_object(self, queryset=None): 92 | if queryset is None: 93 | queryset = Account.objects.all() 94 | return super(AccountTransactionsView, self).get_object(queryset) 95 | 96 | def get_context_object_name(self, obj): 97 | return "legs" if hasattr(obj, "__iter__") else "account" 98 | 99 | def get_queryset(self): 100 | queryset = super(AccountTransactionsView, self).get_queryset() 101 | queryset = ( 102 | Leg.objects.filter(account=self.object) 103 | .order_by("-transaction__date", "-pk") 104 | .select_related("transaction") 105 | ) 106 | return queryset 107 | -------------------------------------------------------------------------------- /hordak/views/statement_csv_import.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.mixins import LoginRequiredMixin 2 | from django.http import HttpResponseRedirect 3 | from django.urls import reverse 4 | from django.views.generic import CreateView, DetailView, UpdateView 5 | 6 | from hordak.forms.statement_csv_import import ( 7 | TransactionCsvImportColumnFormSet, 8 | TransactionCsvImportForm, 9 | ) 10 | from hordak.models import TransactionCsvImport 11 | from hordak.resources import StatementLineResource 12 | 13 | 14 | class CreateImportView(LoginRequiredMixin, CreateView): 15 | model = TransactionCsvImport 16 | form_class = TransactionCsvImportForm 17 | template_name = "hordak/statement_import/import_create.html" 18 | 19 | def get_success_url(self): 20 | return reverse("hordak:import_setup", args=[self.object.uuid]) 21 | 22 | 23 | class SetupImportView(LoginRequiredMixin, UpdateView): 24 | """View for setting up of the import process 25 | 26 | This involves mapping columns to import fields, and collecting 27 | the date format 28 | """ 29 | 30 | context_object_name = "transaction_import" 31 | slug_url_kwarg = "uuid" 32 | slug_field = "uuid" 33 | model = TransactionCsvImport 34 | fields = ("date_format",) 35 | template_name = "hordak/statement_import/import_setup.html" 36 | 37 | def get_context_data(self, **kwargs): 38 | context = super(SetupImportView, self).get_context_data(**kwargs) 39 | context["formset"] = TransactionCsvImportColumnFormSet(instance=self.object) 40 | return context 41 | 42 | def post(self, request, *args, **kwargs): 43 | self.object = self.get_object() 44 | form = self.get_form_class()(request.POST, request.FILES, instance=self.object) 45 | formset = TransactionCsvImportColumnFormSet(request.POST, instance=self.object) 46 | 47 | if form.is_valid() and formset.is_valid(): 48 | return self.form_valid(form, formset) 49 | else: 50 | return self.form_invalid(form, formset) 51 | 52 | def form_valid(self, form, formset): 53 | self.object = form.save() 54 | formset.instance = self.object 55 | formset.save() 56 | return HttpResponseRedirect(self.get_success_url()) 57 | 58 | def form_invalid(self, form, formset): 59 | return self.render_to_response( 60 | self.get_context_data(form=form, formset=formset) 61 | ) 62 | 63 | def get_success_url(self): 64 | return reverse("hordak:import_dry_run", args=[self.object.uuid]) 65 | 66 | 67 | class AbstractImportView(LoginRequiredMixin, DetailView): 68 | context_object_name = "transaction_import" 69 | slug_url_kwarg = "uuid" 70 | slug_field = "uuid" 71 | model = TransactionCsvImport 72 | dry_run = True 73 | 74 | def get(self, request, **kwargs): 75 | return super(AbstractImportView, self).get(request, **kwargs) 76 | 77 | def post(self, request, **kwargs): 78 | transaction_import = self.get_object() 79 | resource = StatementLineResource( 80 | date_format=transaction_import.date_format, 81 | statement_import=transaction_import.hordak_import, 82 | ) 83 | 84 | self.result = resource.import_data( 85 | dataset=transaction_import.get_dataset(), 86 | dry_run=self.dry_run, 87 | use_transactions=True, 88 | collect_failed_rows=True, 89 | ) 90 | return self.get(request, **kwargs) 91 | 92 | def get_context_data(self, **kwargs): 93 | return super(AbstractImportView, self).get_context_data( 94 | result=getattr(self, "result", None), **kwargs 95 | ) 96 | 97 | 98 | class DryRunImportView(AbstractImportView): 99 | template_name = "hordak/statement_import/import_dry_run.html" 100 | dry_run = True 101 | 102 | 103 | class ExecuteImportView(AbstractImportView): 104 | template_name = "hordak/statement_import/import_execute.html" 105 | dry_run = False 106 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") 8 | try: 9 | from django.core.management import execute_from_command_line 10 | except ImportError: 11 | # The above import may fail for some other reason. Ensure that the 12 | # issue is really that Django is missing to avoid masking other 13 | # exceptions on Python 2. 14 | try: 15 | import django # noqa 16 | except ImportError: 17 | raise ImportError( 18 | "Couldn't import Django. Are you sure it's installed and " 19 | "available on your PYTHONPATH environment variable? Did you " 20 | "forget to activate a virtual environment?" 21 | ) 22 | raise 23 | execute_from_command_line(sys.argv) 24 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE=example_project.settings 3 | addopts=--color=yes --code-highlight=yes 4 | python_files=test_*.py 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django>=4 2 | django-mptt>=0.8 3 | dj-database-url 4 | django-extensions>=1.7.3 5 | requests>=2 6 | django-money>=3 7 | py-moneyed>=3.0 8 | django-import-export>=4 9 | babel>=2.9.1 10 | openpyxl<=2.6;python_version<"3.5" 11 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | requests-mock 2 | mock 3 | pytest 4 | coveralls 5 | django-coverage-plugin 6 | psycopg2 7 | mysqlclient 8 | psycopg2-binary>=2.6.2 9 | setuptools 10 | parameterized 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [zest.releaser] 2 | current_version = 0.10.1 3 | commit = True 4 | tag = True 5 | python-file-with-version = VERSION 6 | 7 | [flake8] 8 | max-line-length = 110 9 | ignore = 10 | # additional newline in imports 11 | I202, 12 | # line break before binary operator 13 | W503, 14 | exclude = 15 | *migrations/*, 16 | docs/, 17 | .eggs/ 18 | application-import-names = admin_tools_stats 19 | import-order-style = pep8 20 | 21 | [isort] 22 | known_first_party = hordak,example_project 23 | multi_line_output = 3 24 | lines_after_imports = 2 25 | default_section = THIRDPARTY 26 | skip = .venv/ 27 | skip_glob = **/migrations/*.py 28 | include_trailing_comma = true 29 | force_grid_wrap = 0 30 | use_parentheses = true 31 | profile = black 32 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import re 4 | from os.path import exists 5 | 6 | from setuptools import find_packages, setup 7 | 8 | 9 | def parse_requirements(file_name): 10 | requirements = [] 11 | for line in open(file_name, "r").read().split("\n"): 12 | if re.match(r"(\s*#)|(\s*$)", line): 13 | continue 14 | if re.match(r"\s*-e\s+", line): 15 | requirements.append(re.sub(r"\s*-e\s+.*#egg=(.*)$", r"\1", line)) 16 | elif re.match(r"(\s*git)|(\s*hg)", line): 17 | pass 18 | else: 19 | requirements.append(line) 20 | return requirements 21 | 22 | 23 | setup( 24 | name="django-hordak", 25 | version=open("VERSION").read().strip(), 26 | author="Adam Charnock", 27 | author_email="adam@adamcharnock.com", 28 | packages=find_packages(), 29 | scripts=[], 30 | url="https://github.com/adamcharnock/django-hordak", 31 | license="MIT", 32 | description="Double entry book keeping in Django", 33 | long_description=open("README.rst").read() if exists("README.rst") else "", 34 | include_package_data=True, 35 | install_requires=parse_requirements("requirements.txt"), 36 | ) 37 | --------------------------------------------------------------------------------