├── .envrc ├── .github ├── FUNDING.yml └── workflows │ ├── codeql-analysis.yml │ ├── pr-lints.yml │ └── release.yml ├── .gitignore ├── .replit ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── SECURITY.md ├── design.md ├── example.beancount ├── flake.lock ├── flake.nix ├── pyproject.toml ├── src └── fava_envelope │ ├── __init__.py │ ├── cli │ └── __main__.py │ ├── modules │ ├── __init__.py │ ├── beancount_envelope.py │ └── main.py │ └── templates │ └── EnvelopeBudget.html ├── tox.ini └── uv.lock /.envrc: -------------------------------------------------------------------------------- 1 | watch_file uv.lock 2 | use flake 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # repo: bryall/fava_envelope 2 | # filename: FUNDING.YML 3 | 4 | github: bryall 5 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '38 9 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.github/workflows/pr-lints.yml: -------------------------------------------------------------------------------- 1 | name: pr-lints 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | semantic-title: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: amannn/action-semantic-pull-request@v4 15 | with: 16 | subjectPattern: ^[a-z].+$ 17 | subjectPatternError: | 18 | The subject "{subject}" found in the pull request title "{title}" should start with a lowercase. 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | branches: [master] 5 | 6 | jobs: 7 | release-please: 8 | runs-on: ubuntu-latest 9 | outputs: 10 | release_created: ${{ steps.release.outputs.release_created }} 11 | steps: 12 | - uses: google-github-actions/release-please-action@v3 13 | id: release 14 | with: 15 | release-type: python 16 | package-name: fava-envelope 17 | bump-minor-pre-major: true 18 | publish: 19 | runs-on: ubuntu-latest 20 | needs: [release-please] 21 | if: needs.release-please.outputs.release_created 22 | permissions: 23 | # IMPORTANT: this permission is mandatory for trusted publishing 24 | id-token: write 25 | steps: 26 | - uses: actions/checkout@v3 27 | 28 | - uses: pdm-project/setup-pdm@v3 29 | 30 | - name: Publish package distributions to PyPI 31 | run: pdm publish 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .pre-commit-config.yaml 2 | .direnv 3 | src/fava_envelope/__pycache__ 4 | src/fava_envelope/cli/__pycache__/__main__.cpython-311.pyc 5 | .venv 6 | -------------------------------------------------------------------------------- /.replit: -------------------------------------------------------------------------------- 1 | language = "python3" 2 | run = "fava -H 0.0.0.0 example.beancount" 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.5.9](https://github.com/polarmutex/fava-envelope/compare/v0.5.8...v0.5.9) (2024-07-05) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * add check to row to prevent error ([1332490](https://github.com/polarmutex/fava-envelope/commit/133249066ede736aff56745f661ca5c2d00ca71d)) 9 | * allow more than one allocate in a given month ([4340381](https://github.com/polarmutex/fava-envelope/commit/4340381f8ef7db6a32ce0279e631d029c28b9083)) 10 | 11 | ## [0.5.8](https://github.com/polarmutex/fava-envelope/compare/v0.5.7...v0.5.8) (2023-09-17) 12 | 13 | 14 | ### Bug Fixes 15 | 16 | * pandas v2 upgrade ([653befa](https://github.com/polarmutex/fava-envelope/commit/653befac73f17000935dbdd4efc43f5e4f6f212a)) 17 | 18 | ## [0.5.7](https://github.com/polarmutex/fava-envelope/compare/v0.5.6...v0.5.7) (2023-09-17) 19 | 20 | 21 | ### Bug Fixes 22 | 23 | * fava new release change ([5328f71](https://github.com/polarmutex/fava-envelope/commit/5328f71ba0d37f3c0cd991526c761819c449001a)) 24 | * github action release ([66fd333](https://github.com/polarmutex/fava-envelope/commit/66fd333c4765bb1f450a91fde64b79eac9a08e89)) 25 | 26 | ## [0.5.6](https://github.com/polarmutex/fava-envelope/compare/v0.5.5...v0.5.6) (2022-10-25) 27 | 28 | 29 | ### Bug Fixes 30 | 31 | * pypi release builds ([6479022](https://github.com/polarmutex/fava-envelope/commit/6479022d08d2b226687dfe133e06a13f93896151)) 32 | 33 | ## [0.5.5](https://github.com/polarmutex/fava-envelope/compare/v0.5.4...v0.5.5) (2022-10-24) 34 | 35 | 36 | ### Bug Fixes 37 | 38 | * error from pre-commit ([3998f9c](https://github.com/polarmutex/fava-envelope/commit/3998f9c08fb4892d10e34fef475787a89a9ad08d)) 39 | * fava ledger.entries deprecation ([39016f4](https://github.com/polarmutex/fava-envelope/commit/39016f444b5de4a1564081317f41131a4fa8ad1f)) 40 | * html files not included on install ([871e78a](https://github.com/polarmutex/fava-envelope/commit/871e78aac1503627d9525d4c7f87929bb1483956)) 41 | * nix to use poetry2nix ([e972bec](https://github.com/polarmutex/fava-envelope/commit/e972bec9fdcfcebbdd20891e809867362047872c)) 42 | 43 | ### [0.5.4](https://github.com/polarmutex/fava-envelope/compare/v0.5.3...v0.5.4) (2022-05-19) 44 | 45 | 46 | ### Bug Fixes 47 | 48 | * [#34](https://github.com/polarmutex/fava-envelope/issues/34) remove unused line in example ([d88d52c](https://github.com/polarmutex/fava-envelope/commit/d88d52c8e303a3ea5af9f23535c552d3632d9692)) 49 | * security dependency updates ([3e8d4b6](https://github.com/polarmutex/fava-envelope/commit/3e8d4b6f3cf47725c70e4ed4a703139c4fa4f073)) 50 | 51 | 52 | ### Documentation 53 | 54 | * changlog edits ([fe6fc3a](https://github.com/polarmutex/fava-envelope/commit/fe6fc3a3dba23fcdffad5f0eb1822496afe371b3)) 55 | * changlog edits ([c1e59e0](https://github.com/polarmutex/fava-envelope/commit/c1e59e0bbe9f6640a793610bf5a7aac1a0be7d1c)) 56 | 57 | ### [0.5.3](https://github.com/polarmutex/fava-envelope/compare/v0.5.2...v0.5.3) (2022-05-19) 58 | 59 | ### Features 60 | 61 | * Added negative rollover option 62 | * Add rudimentary ability to see future months 63 | 64 | ### Bug Fixes 65 | 66 | * replace url_for_current with url_for (for compatibility with fava 1.20 and up) ([910b3ad](https://github.com/polarmutex/fava-envelope/commit/910b3ad742683e747660c09430e56415ee44d8c3)) 67 | 68 | ### [0.5.2](https://github.com/polarmutex/fava-envelope/compare/v0.5.1...v0.5.2) (2021-07-19) 69 | 70 | ### Bug Fixes 71 | 72 | * bug where tables were not displaying on the latest fava 73 | 74 | ### [0.5.1](https://github.com/polarmutex/fava-envelope/compare/0.5...v0.5.1) (2021-01-29) 75 | 76 | ### Features 77 | 78 | * Adding multiple budgets in multiple currencies capacity to fava_envelope 79 | 80 | ### Bug Fixes 81 | 82 | * bug where it would not load page for month selected 83 | * add checks for lastest fava which changed querytable api 84 | * Fixed a typo in get_currencies() 85 | * probably should not hard code 2020 86 | * allow months with no income by setting the default to 0 87 | * use beancounts operating_currency if available 88 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Brian Ryall 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 src/fava_envelope/templates/*.html 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install_reqs: 2 | pip install -r ./requirements.in 3 | update_req_txt: 4 | pip freeze > requirements.txt 5 | pre-commit: 6 | pre-commit run --all-files 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fava-envelope 2 | 3 | A beancount fava extension to add a envelope budgeting capability to fava and beancount. It is developed as an fava plugin and CLI. 4 | 5 | ![PyPI](https://img.shields.io/pypi/v/fava-envelope?color=success&label=pypi%20package) 6 | ![GitHub last commit](https://img.shields.io/github/last-commit/bryall/fava-envelope) 7 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 8 | [![Run on Repl.it](https://repl.it/badge/github/bryall/fava-envelope)](https://repl.it/github/bryall/fava-envelope) 9 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/polarmutex/fava-envelope/master.svg)](https://results.pre-commit.ci/latest/github/polarmutex/fava-envelope/master) 10 | 11 | ## Repl.it 12 | Click the repl.it link to be able to see the plugin in action 13 | 1. Click the link 14 | 2. Click Run 15 | 3. When the web pane opens, click the open in new tab ( have not figurec ouf why it is not showing in the initial window ) 16 | 4. Click the "Fava Envelope" link in fava to see the plugin 17 | 18 | ## Installation via pip 19 | ``` 20 | python -m pip install fava-envelope 21 | ``` 22 | 23 | ## TODO 24 | 25 | * add example file for screenshots and testing 26 | * Add testing 27 | * add charts 28 | 29 | ## Running fava-envelope 30 | 31 | ## Load the Extension 32 | Add this to your beancount journal, and start fava as normal 33 | ``` 34 | 2000-01-01 custom "fava-extension" "fava_envelope" "{}" 35 | ``` 36 | 37 | You should now see 'Envelope' in your fava window. You must set up a budget (see below), or else Fava will report a 404 error. 38 | 39 | ## Setting up budget 40 | 41 | ### Set the budget start date 42 | start date in the format <4 digit year>-<2 digit month> 43 | ``` 44 | 2020-01-01 custom "envelope" "start date" "2020-01" 45 | ``` 46 | 47 | ### Budget months ahead 48 | If you want to see future months (to budget ahead), set this parameter 49 | ``` 50 | 2020-01-01 custom "envelope" "months ahead" "2" 51 | ``` 52 | The default is 0 53 | 54 | ## Set up Budget Accounts 55 | You will need to specify the Assets and Liabilities you want included in your budget (For example ignoring Investment accounts). you can use regular expression in these statements 56 | ``` 57 | 2020-01-01 custom "envelope" "budget account" "Assets:Checking" 58 | 2020-01-01 custom "envelope" "budget account" "Liabilities:Credit-Cards:*" 59 | ``` 60 | 61 | ### Set up mappings 62 | By default fava-envelope will use the Assets/Liabilities/Income/Expenses buckets that are not listed in the budget accounts. this directive allows you to map them to another bucket 63 | ``` 64 | 2020-01-01 custom "envelope" "mapping" "Expenses:Food:*" "Expenses:Food" 65 | ``` 66 | 67 | ### Allocate money to a bucket 68 | ``` 69 | 2020-01-31 custom "envelope" "allocate" "Expenses:Food" 100.00 70 | ``` 71 | 72 | ### Set up operating currency 73 | The envelopes will read the operating currency from the core beancount option. 74 | ``` 75 | option "operating_currency" "EUR" 76 | ``` 77 | It will default to USD if this option is not set. Only a single currency is supported for the budget. 78 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 0.5.x | :white_check_mark: | 11 | | < 0.5 | :x: | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | Submit vulnerablities as issues in this project 16 | -------------------------------------------------------------------------------- /design.md: -------------------------------------------------------------------------------- 1 | # Design Goals for the Envelope Extension 2 | 3 | ## Reference for Design Considerations 4 | 5 | [Beancount Envelope Budgeting Discussion](https://groups.google.com/forum/m/#!msg/beancount/kQMPTY5Q4ko/49hbQdyKCgAJ) 6 | [fava issue](https://github.com/beancount/fava/issues/909) 7 | 8 | ## Design 9 | 10 | My initial goal is make the extension a reporting feature on the beancount 11 | journal. I do not want to have to do automatic transactions or doubling the 12 | postings to achieve envelope budgeting. 13 | 14 | The envelope transfers will be handled by custom directives 15 | 16 | The planned configurations options are the following 17 | - The start of the budget 18 | - the Assets to be used in the budget 19 | - the envelope names 20 | - the mappings of expenses/income to envelopes 21 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-compat": { 4 | "flake": false, 5 | "locked": { 6 | "lastModified": 1696426674, 7 | "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", 8 | "owner": "edolstra", 9 | "repo": "flake-compat", 10 | "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", 11 | "type": "github" 12 | }, 13 | "original": { 14 | "owner": "edolstra", 15 | "repo": "flake-compat", 16 | "type": "github" 17 | } 18 | }, 19 | "flake-parts": { 20 | "inputs": { 21 | "nixpkgs-lib": [ 22 | "nixpkgs" 23 | ] 24 | }, 25 | "locked": { 26 | "lastModified": 1736143030, 27 | "narHash": "sha256-+hu54pAoLDEZT9pjHlqL9DNzWz0NbUn8NEAHP7PQPzU=", 28 | "owner": "hercules-ci", 29 | "repo": "flake-parts", 30 | "rev": "b905f6fc23a9051a6e1b741e1438dbfc0634c6de", 31 | "type": "github" 32 | }, 33 | "original": { 34 | "owner": "hercules-ci", 35 | "repo": "flake-parts", 36 | "type": "github" 37 | } 38 | }, 39 | "git-hooks-nix": { 40 | "inputs": { 41 | "flake-compat": "flake-compat", 42 | "gitignore": "gitignore", 43 | "nixpkgs": [ 44 | "nixpkgs" 45 | ] 46 | }, 47 | "locked": { 48 | "lastModified": 1735882644, 49 | "narHash": "sha256-3FZAG+pGt3OElQjesCAWeMkQ7C/nB1oTHLRQ8ceP110=", 50 | "owner": "cachix", 51 | "repo": "git-hooks.nix", 52 | "rev": "a5a961387e75ae44cc20f0a57ae463da5e959656", 53 | "type": "github" 54 | }, 55 | "original": { 56 | "owner": "cachix", 57 | "repo": "git-hooks.nix", 58 | "type": "github" 59 | } 60 | }, 61 | "gitignore": { 62 | "inputs": { 63 | "nixpkgs": [ 64 | "git-hooks-nix", 65 | "nixpkgs" 66 | ] 67 | }, 68 | "locked": { 69 | "lastModified": 1709087332, 70 | "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", 71 | "owner": "hercules-ci", 72 | "repo": "gitignore.nix", 73 | "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 74 | "type": "github" 75 | }, 76 | "original": { 77 | "owner": "hercules-ci", 78 | "repo": "gitignore.nix", 79 | "type": "github" 80 | } 81 | }, 82 | "nixpkgs": { 83 | "locked": { 84 | "lastModified": 1736523798, 85 | "narHash": "sha256-Xb8mke6UCYjge9kPR9o4P1nVrhk7QBbKv3xQ9cj7h2s=", 86 | "owner": "NixOS", 87 | "repo": "nixpkgs", 88 | "rev": "130595eba61081acde9001f43de3248d8888ac4a", 89 | "type": "github" 90 | }, 91 | "original": { 92 | "owner": "NixOS", 93 | "ref": "nixos-unstable", 94 | "repo": "nixpkgs", 95 | "type": "github" 96 | } 97 | }, 98 | "pyproject-build-systems": { 99 | "inputs": { 100 | "nixpkgs": [ 101 | "nixpkgs" 102 | ], 103 | "pyproject-nix": [ 104 | "pyproject-nix" 105 | ], 106 | "uv2nix": [ 107 | "uv2nix" 108 | ] 109 | }, 110 | "locked": { 111 | "lastModified": 1736122544, 112 | "narHash": "sha256-3Pb/yiQa2MpWPWshxluQR2n1/Bq21hoGqJf3UAC3+ts=", 113 | "owner": "pyproject-nix", 114 | "repo": "build-system-pkgs", 115 | "rev": "68b4c6dae0c47974bda803cf4e87921776f6081d", 116 | "type": "github" 117 | }, 118 | "original": { 119 | "owner": "pyproject-nix", 120 | "repo": "build-system-pkgs", 121 | "type": "github" 122 | } 123 | }, 124 | "pyproject-nix": { 125 | "inputs": { 126 | "nixpkgs": [ 127 | "nixpkgs" 128 | ] 129 | }, 130 | "locked": { 131 | "lastModified": 1736563971, 132 | "narHash": "sha256-E1bXJQPvp4jxqMFisiQNj3S8ctKOKHdsqWmKateHtYo=", 133 | "owner": "pyproject-nix", 134 | "repo": "pyproject.nix", 135 | "rev": "64fedcac9fb75016f8f421a5a5587352d6482df6", 136 | "type": "github" 137 | }, 138 | "original": { 139 | "owner": "pyproject-nix", 140 | "repo": "pyproject.nix", 141 | "type": "github" 142 | } 143 | }, 144 | "root": { 145 | "inputs": { 146 | "flake-parts": "flake-parts", 147 | "git-hooks-nix": "git-hooks-nix", 148 | "nixpkgs": "nixpkgs", 149 | "pyproject-build-systems": "pyproject-build-systems", 150 | "pyproject-nix": "pyproject-nix", 151 | "uv2nix": "uv2nix" 152 | } 153 | }, 154 | "uv2nix": { 155 | "inputs": { 156 | "nixpkgs": [ 157 | "nixpkgs" 158 | ], 159 | "pyproject-nix": [ 160 | "pyproject-nix" 161 | ] 162 | }, 163 | "locked": { 164 | "lastModified": 1736642135, 165 | "narHash": "sha256-YHuU4qgTbfU5Vw3k24WWg3EdmHQ2qZN25if6JwqQTxc=", 166 | "owner": "pyproject-nix", 167 | "repo": "uv2nix", 168 | "rev": "1daa3dd83abcfa95c08d6b3847e672bd90e0c9d8", 169 | "type": "github" 170 | }, 171 | "original": { 172 | "owner": "pyproject-nix", 173 | "repo": "uv2nix", 174 | "type": "github" 175 | } 176 | } 177 | }, 178 | "root": "root", 179 | "version": 7 180 | } 181 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "nix derivation for fava-envelope"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | 7 | flake-parts.url = "github:hercules-ci/flake-parts"; 8 | flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; 9 | 10 | pyproject-nix = { 11 | url = "github:pyproject-nix/pyproject.nix"; 12 | inputs.nixpkgs.follows = "nixpkgs"; 13 | }; 14 | 15 | uv2nix = { 16 | url = "github:pyproject-nix/uv2nix"; 17 | inputs.pyproject-nix.follows = "pyproject-nix"; 18 | inputs.nixpkgs.follows = "nixpkgs"; 19 | }; 20 | 21 | pyproject-build-systems = { 22 | url = "github:pyproject-nix/build-system-pkgs"; 23 | inputs.pyproject-nix.follows = "pyproject-nix"; 24 | inputs.uv2nix.follows = "uv2nix"; 25 | inputs.nixpkgs.follows = "nixpkgs"; 26 | }; 27 | 28 | git-hooks-nix = { 29 | url = "github:cachix/git-hooks.nix"; 30 | inputs.nixpkgs.follows = "nixpkgs"; 31 | }; 32 | }; 33 | 34 | outputs = { 35 | # self, 36 | flake-parts, 37 | nixpkgs, 38 | uv2nix, 39 | pyproject-nix, 40 | pyproject-build-systems, 41 | git-hooks-nix, 42 | ... 43 | } @ inputs: 44 | flake-parts.lib.mkFlake 45 | {inherit inputs;} 46 | { 47 | systems = [ 48 | "x86_64-linux" 49 | "aarch64-linux" 50 | "x86_64-darwin" 51 | "aarch64-darwin" 52 | ]; 53 | 54 | imports = [ 55 | git-hooks-nix.flakeModule 56 | ]; 57 | 58 | perSystem = { 59 | config, 60 | system, 61 | ... 62 | }: let 63 | inherit (nixpkgs) lib; 64 | pkgs = import nixpkgs { 65 | inherit system; 66 | overlays = []; 67 | }; 68 | 69 | workspace = uv2nix.lib.workspace.loadWorkspace {workspaceRoot = ./.;}; 70 | 71 | overlay = workspace.mkPyprojectOverlay { 72 | sourcePreference = "wheel"; 73 | }; 74 | 75 | editableOverlay = workspace.mkEditablePyprojectOverlay { 76 | root = "$REPO_ROOT"; 77 | }; 78 | 79 | # Python sets grouped per system 80 | pythonSets = let 81 | # Base Python package set from pyproject.nix 82 | baseSet = pkgs.callPackage pyproject-nix.build.packages { 83 | python = pkgs.python312; 84 | }; 85 | 86 | # An overlay of build fixups & test additions 87 | pyprojectOverrides = _final: prev: { 88 | }; 89 | 90 | buildSystemOverrides = final: prev: let 91 | inherit (final) resolveBuildSystem; 92 | inherit (builtins) mapAttrs; 93 | # Build system dependencies specified in the shape expected by resolveBuildSystem 94 | # The empty lists below are lists of optional dependencies. 95 | # 96 | # A package `foo` with specification written as: 97 | # `setuptools-scm[toml]` in pyproject.toml would be written as 98 | # `foo.setuptools-scm = [ "toml" ]` in Nix 99 | buildSysOverrides = { 100 | # curlify.setuptools = []; 101 | # fava-dashboards = { 102 | # hatchling = []; 103 | # hatch-vcs = []; 104 | # }; 105 | # fava-envelope.pdm = []; 106 | # ofxparse.setuptools = []; 107 | # ofxtools.setuptools = []; 108 | }; 109 | in 110 | mapAttrs ( 111 | name: spec: 112 | prev.${name}.overrideAttrs (old: { 113 | nativeBuildInputs = old.nativeBuildInputs ++ resolveBuildSystem spec; 114 | }) 115 | ) 116 | buildSysOverrides; 117 | in 118 | baseSet.overrideScope ( 119 | lib.composeManyExtensions [ 120 | pyproject-build-systems.overlays.default 121 | overlay 122 | pyprojectOverrides 123 | buildSystemOverrides 124 | ] 125 | ); 126 | in { 127 | pre-commit.settings.hooks = { 128 | black.enable = true; 129 | ruff.enable = true; 130 | }; 131 | 132 | devShells.default = let 133 | pkgs = nixpkgs.legacyPackages.${system}; 134 | editablePythonSet = pythonSets.overrideScope editableOverlay; 135 | venv = editablePythonSet.mkVirtualEnv "fava-envelope-dev-env" { 136 | fava-envelope = ["dev"]; 137 | }; 138 | in 139 | pkgs.mkShell { 140 | env = { 141 | UV_NO_SYNC = "1"; 142 | UV_PYTHON = "${venv}/bin/python"; 143 | UV_PYTHON_DOWNLOADS = "never"; 144 | }; 145 | packages = with pkgs; [ 146 | venv 147 | uv 148 | ]; 149 | shellHook = '' 150 | unset PYTHONPATH 151 | export REPO_ROOT=$(git rev-parse --show-toplevel) 152 | ${config.pre-commit.installationScript} 153 | # set a venv folder for basedpyright 154 | venv="$(cd $(dirname $(which python)); cd ..; pwd)" 155 | ln -Tsf "$venv" .venv 156 | ''; 157 | }; 158 | packages = { 159 | }; 160 | }; 161 | }; 162 | } 163 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "fava-envelope" 3 | version = "0.5.9" 4 | description = "" 5 | requires-python = ">=3.9" 6 | readme = "README.md" 7 | license = {text = "MIT"} 8 | keywords = ["fava", "budget", "envelope"] 9 | authors = [ 10 | {email = "me@brianryall.xyz"}, 11 | {name = "Brian Ryall"} 12 | ] 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3 :: Only", 17 | "Programming Language :: Python :: 3.7", 18 | "Programming Language :: Python :: 3.8", 19 | "Programming Language :: Python :: 3.9", 20 | ] 21 | 22 | dependencies = [ 23 | "fava>=1.26", 24 | "pandas>=2.2.2", 25 | "Click >= 7", 26 | "beanquery>=0.1.0", 27 | "beancount>=3.0.0", 28 | ] 29 | 30 | [dependency-groups] 31 | dev = [ 32 | {include-group = "test"}, 33 | {include-group = "typing"}, 34 | {include-group = "lint"}, 35 | ] 36 | typing = [ 37 | "pandas-stubs>=2.2.2", 38 | "mypy>=1.13.0", 39 | ] 40 | test = [ 41 | "pytest-cov>=6.0.0", 42 | "pytest-django>=4.9.0", 43 | "pytest>=8.3.3", 44 | ] 45 | lint = [ 46 | "ruff>=0.7.2", 47 | ] 48 | 49 | [build-system] 50 | requires = ["hatchling"] 51 | build-backend = "hatchling.build" 52 | 53 | [project.urls] 54 | homepage = "https://github.com/polarmutex/fava-envelope" 55 | documentation = "https://github.com/polarmutex/fava-envelope" 56 | repository = "https://github.com/polarmutex/fava-envelope" 57 | changelog = "https://github.com/polarmutex/fava-envelope/master/CHANGELOG.md" 58 | 59 | [tool.black] 60 | line-length = 79 61 | 62 | [tool.ruff.lint] 63 | select = [ 64 | "F", # flake8 65 | "E", # pycodestyle 66 | "W", # pycodestyle 67 | # "C90", # McCabe cyclomatic complexity 68 | "I", # isort 69 | "N", # pep8-naming 70 | # "D", # docstyle 71 | "UP", # pyupgrade 72 | # "PD", # pandas-vet 73 | ] 74 | 75 | [tool.basedpyright] 76 | # many settings are not enabled even in strict mode, which is why basedpyright includes an "all" option 77 | # you can then decide which rules you want to disable 78 | typeCheckingMode = "standard" 79 | exclude = [ 80 | ".venv", 81 | ".direnv", 82 | ] 83 | useLibraryCodeForTypes = true 84 | venvPath="." 85 | venv=".venv" 86 | -------------------------------------------------------------------------------- /src/fava_envelope/__init__.py: -------------------------------------------------------------------------------- 1 | # from beancount.core.number import Decimal 2 | # from fava import __version__ as fava_version 3 | # from fava.context import g 4 | from __future__ import annotations 5 | 6 | import datetime 7 | import re 8 | from collections import defaultdict, namedtuple 9 | from dataclasses import dataclass 10 | from decimal import Decimal 11 | 12 | import pandas as pd 13 | from beancount.core import account_types, amount, convert, data, inventory 14 | from beancount.parser import options 15 | from beanquery import query 16 | from fava.ext import FavaExtensionBase 17 | from fava.helpers import FavaAPIError 18 | 19 | # from .modules.beancount_envelope import BeancountEnvelope 20 | 21 | ExtensionConfig = namedtuple("ExtensionConfig", ["budgets"]) 22 | BudgetConfig = namedtuple( 23 | "BudgetConfig", 24 | [ 25 | "name", 26 | "start_date", 27 | "end_date", 28 | "budget_accounts", 29 | "mappings", 30 | "income_accounts", 31 | "currency", 32 | ], 33 | ) 34 | Account = str 35 | Month = int 36 | Year = int 37 | MonthTuple = tuple[Year, Month] 38 | 39 | 40 | @dataclass(frozen=True) 41 | class BudgetCtx: 42 | months: list[str] 43 | top: pd.DataFrame 44 | envelopes: pd.DataFrame 45 | 46 | 47 | class EnvelopeBudget(FavaExtensionBase): 48 | report_title = "Envelope Budget" 49 | 50 | # config is only loaded on fava start and will not be reloaed on file change 51 | def read_extension_config(self) -> ExtensionConfig: 52 | cfg = self.config if isinstance(self.config, dict) else {} 53 | 54 | procesed_config: ExtensionConfig = ExtensionConfig(budgets=[]) 55 | 56 | for b in cfg.get("budgets", []): 57 | start_date_str = b.get("start date", "2000-01") # TODO 58 | start_date = datetime.datetime.strptime( 59 | start_date_str, "%Y-%m" 60 | ).date() 61 | 62 | today = datetime.date.today() 63 | end_date = datetime.date(today.year, today.month, today.day) 64 | 65 | processed_budget_accounts = [] 66 | for ba in b.get("budget accounts", ["Assets:*"]): 67 | processed_budget_accounts.append(re.compile(ba)) 68 | 69 | processed_mappings = [] 70 | for m in b.get("mappings", []): 71 | map_set = (re.compile(m.get("from")), m.get("to")) 72 | processed_mappings.append(map_set) 73 | 74 | processed_budget = BudgetConfig( 75 | name=b.get("name", ""), 76 | start_date=start_date, 77 | end_date=end_date, 78 | budget_accounts=processed_budget_accounts, 79 | mappings=processed_mappings, 80 | income_accounts=b.get("inc account", ""), # TODO 81 | currency=b.get("currency", "USD"), # TODO 82 | ) 83 | procesed_config.budgets.append(processed_budget) 84 | 85 | return procesed_config 86 | 87 | def after_load_file(self) -> None: 88 | self.extension_config = self.read_extension_config() 89 | 90 | self.budgets = [] 91 | 92 | for budget in self.extension_config.budgets: 93 | print(f"processiing budget {budget}") 94 | bc = self.process_budget(budget) 95 | self.budgets.append(bc) 96 | # print(self.budgets[0].top) 97 | # print(self.budgets[0].envelopes) 98 | 99 | def process_budget(self, cfg: BudgetConfig) -> BudgetCtx: 100 | months = [] 101 | cur = cfg.start_date 102 | end = cfg.end_date 103 | while cur < end: 104 | months.append(f"{cur.year}-{str(cur.month).zfill(2)}") 105 | month = cur.month - 1 + 1 106 | year = cur.year + month // 12 107 | month = month % 12 + 1 108 | cur = datetime.date(year, month, 1) 109 | 110 | activity = self.calc_budget_accconut_activity(cfg) 111 | envelope_df = self.build_envelope_df(months, activity) 112 | top_df = self.build_top_df(cfg, months, activity) 113 | 114 | # Set available 115 | for index, row in envelope_df.iterrows(): 116 | for index2, month in enumerate(months): 117 | if index2 == 0: 118 | row[month, "available"] = ( 119 | row[month, "budgeted"] + row[month, "activity"] 120 | ) 121 | else: 122 | prev_available = row[months[index2 - 1], "available"] 123 | if prev_available > 0: 124 | row[month, "available"] = ( 125 | prev_available 126 | + row[month, "budgeted"] 127 | + row[month, "activity"] 128 | ) 129 | else: 130 | row[month, "available"] = ( 131 | row[month, "budgeted"] + row[month, "activity"] 132 | ) 133 | # Set overspent 134 | for index, month in enumerate(months): 135 | if index == 0: 136 | top_df.loc["Overspent", month] = Decimal(0.00) 137 | else: 138 | overspent = Decimal(0.00) 139 | for index2, row in envelope_df.iterrows(): 140 | if row[months[index - 1], "available"] < Decimal(0.00): 141 | overspent += Decimal( 142 | row[months[index - 1], "available"] 143 | ) 144 | top_df.loc["Overspent", month] = overspent 145 | 146 | # Set Budgeted for month 147 | for month in months: 148 | top_df.loc["Budgeted", month] = Decimal( 149 | -1 * envelope_df[month, "budgeted"].sum() 150 | ) 151 | 152 | # Adjust Avail Income 153 | for index, month in enumerate(months): 154 | if index == 0: 155 | continue 156 | else: 157 | prev_month = months[index - 1] 158 | top_df.loc["Avail Income", month] = ( 159 | top_df.loc["Avail Income", month] 160 | + top_df.loc["Avail Income", prev_month] 161 | + top_df.loc["Overspent", prev_month] 162 | + top_df.loc["Budgeted", prev_month] 163 | ) 164 | 165 | # Set Budgeted in the future 166 | for index, month in enumerate(months): 167 | sum_total = top_df[month].sum() 168 | if (index == len(months) - 1) or sum_total < 0: 169 | top_df.loc["Budgeted Future", month] = Decimal(0.00) 170 | else: 171 | next_month = months[index + 1] 172 | opp_budgeted_next_month = ( 173 | top_df.loc["Budgeted", next_month] * -1 174 | ) 175 | if opp_budgeted_next_month < sum_total: 176 | top_df.loc["Budgeted Future", month] = Decimal( 177 | -1 * opp_budgeted_next_month 178 | ) 179 | else: 180 | top_df.loc["Budgeted Future", month] = Decimal( 181 | -1 * sum_total 182 | ) 183 | 184 | # Set to be budgeted 185 | for index, month in enumerate(months): 186 | top_df.loc["To Be Budgeted", month] = Decimal(top_df[month].sum()) 187 | 188 | return BudgetCtx(months=months, top=top_df, envelopes=envelope_df) 189 | 190 | def bootstrap(self, id: int, month: Month): 191 | if not 0 <= id < len(self.budgets): 192 | raise FavaAPIError( 193 | f"invalid dashboard ID: {id}, maybe no budgets defined" 194 | ) 195 | return { 196 | "budgets": self.extension_config.budgets, 197 | "months": self.budgets[id].months, 198 | "top": self.generate_income_query_tables( 199 | self.budgets[id].top, month 200 | ), 201 | "envelopes": self.generate_envelope_query_tables( 202 | self.budgets[id].envelopes, month 203 | ), 204 | } 205 | 206 | def build_top_df(self, cfg, months, balances) -> pd.DataFrame: 207 | df = pd.DataFrame(columns=months) 208 | 209 | # Calculate Starting Balance Income 210 | starting_balance = Decimal(0.0) 211 | query_str = ( 212 | f"select account, convert(sum(position),'{cfg.currency}')" 213 | + f" from close on {months[0]}-01 group by 1 order by 1;" 214 | ) 215 | rows = query.run_query( 216 | self.ledger.all_entries, 217 | self.ledger.options, 218 | query_str, 219 | numberify=True, 220 | ) 221 | for row in rows[1]: 222 | if any(regexp.match(row[0]) for regexp in cfg.budget_accounts): 223 | if row[1] is not None: 224 | starting_balance += row[1] 225 | print(starting_balance) 226 | 227 | df.loc["Avail Income", months[0]] = starting_balance 228 | 229 | df = df.fillna(Decimal(0.00)) 230 | 231 | print(df) 232 | 233 | for account in balances.keys(): 234 | if account != "Income": 235 | continue 236 | for month in balances[account]: 237 | month_str = f"{str(month[0])}-{str(month[1]).zfill(2)}" 238 | total = balances[account].get(month) * -1 239 | df.loc["Avail Income", month_str] = Decimal(total) 240 | 241 | print(df) 242 | 243 | return df 244 | 245 | def build_envelope_df(self, months, balances) -> pd.DataFrame: 246 | column_index = pd.MultiIndex.from_product( 247 | [months, ["budgeted", "activity", "available"]], 248 | names=["Month", "col"], 249 | ) 250 | df = pd.DataFrame(columns=column_index) 251 | df.index.name = "Envelopes" 252 | 253 | for account in balances.keys(): 254 | if account == "Income": 255 | continue 256 | for month in balances[account]: 257 | month_str = f"{str(month[0])}-{str(month[1]).zfill(2)}" 258 | total = balances[account].get(month) * -1 259 | df.loc[account, (month_str, "activity")] = amount.Decimal( 260 | total 261 | ) 262 | 263 | df = df.fillna(Decimal(0.00)) 264 | 265 | # Set available 266 | for index, row in df.iterrows(): 267 | for index2, month in enumerate(months): 268 | if index2 == 0: 269 | row[month, "available"] = ( 270 | row[month, "budgeted"] + row[month, "activity"] 271 | ) 272 | else: 273 | prev_available = row[months[index2 - 1], "available"] 274 | if prev_available > 0: 275 | row[month, "available"] = ( 276 | prev_available 277 | + row[month, "budgeted"] 278 | + row[month, "activity"] 279 | ) 280 | else: 281 | row[month, "available"] = ( 282 | row[month, "budgeted"] + row[month, "activity"] 283 | ) 284 | return df 285 | 286 | def calc_budget_accconut_activity( 287 | self, cfg 288 | ) -> dict[Account, dict[MonthTuple, amount.Decimal]]: 289 | acctypes = options.get_account_types(self.ledger.options) 290 | 291 | balances = defaultdict(lambda: defaultdict(inventory.Inventory)) 292 | for entry in data.filter_txns(self.ledger.all_entries): 293 | # Check entry in date range 294 | if entry.date < cfg.start_date or entry.date > cfg.end_date: 295 | continue 296 | month = (entry.date.year, entry.date.month) 297 | 298 | contains_budget_accounts = False 299 | for posting in entry.postings: 300 | if any( 301 | regexp.match(posting.account) 302 | for regexp in cfg.budget_accounts 303 | ): 304 | contains_budget_accounts = True 305 | break 306 | if not contains_budget_accounts: 307 | continue 308 | 309 | for posting in entry.postings: 310 | account = posting.account 311 | for regexp, target_account in cfg.mappings: 312 | if regexp.match(account): 313 | account = target_account 314 | break 315 | account_type = account_types.get_account_type(account) 316 | if posting.units.currency != cfg.currency: 317 | orig = posting.units.number 318 | if posting.price is not None: 319 | converted = posting.price.number * orig 320 | posting = data.Posting( 321 | posting.account, 322 | amount.Amount(converted, cfg.currency), 323 | posting.cost, 324 | None, 325 | posting.flag, 326 | posting.meta, 327 | ) 328 | else: 329 | continue 330 | 331 | if account_type == acctypes.income or ( 332 | any( 333 | regexp.match(account) for regexp in cfg.income_accounts 334 | ) 335 | ): 336 | account = "Income" 337 | elif any( 338 | regexp.match(posting.account) 339 | for regexp in cfg.budget_accounts 340 | ): 341 | continue 342 | 343 | balances[account][month].add_position(posting) 344 | 345 | # Reduce the final balances to numbers 346 | sbalances: dict[Account, dict[MonthTuple, amount.Decimal]] = ( 347 | defaultdict(dict) 348 | ) 349 | for account, months in sorted(balances.items()): 350 | for month, balance in sorted(months.items()): 351 | year, mth = month 352 | date = datetime.date(year, mth, 1) 353 | balance = balance.reduce( 354 | convert.get_value, self.ledger.prices, date 355 | ) 356 | balance = balance.reduce( 357 | convert.convert_position, 358 | cfg.currency, 359 | self.ledger.prices, 360 | date, 361 | ) 362 | try: 363 | pos = balance.get_only_position() 364 | except AssertionError: 365 | raise 366 | total = ( 367 | pos.units.number 368 | if pos and pos.units 369 | else amount.Decimal(0) 370 | ) 371 | sbalances[account][month] = total 372 | 373 | return sbalances 374 | 375 | # def get_currencies(self): 376 | # if "currencies" in self.config: 377 | # return self.config["currencies"] 378 | # else: 379 | # return None 380 | 381 | # def check_month_in_available_months(self, month, currency): 382 | # if month: 383 | # if month in self.get_budgets_months_available(currency): 384 | # return True 385 | # return False 386 | 387 | # def generate_budget_df(self, currency): 388 | # self.currency = currency 389 | # module = BeancountEnvelope( 390 | # g.ledger.all_entries, self.ledger.options, self.currency 391 | # ) 392 | # ( 393 | # self.income_tables, 394 | # self.envelope_tables, 395 | # self.currency, 396 | # ) = module.envelope_tables() 397 | 398 | def get_budget_months(self, id: int): 399 | if not 0 <= id < len(self.budgets): 400 | raise FavaAPIError( 401 | f"invalid dashboard ID: {id}, maybe no budgets defined" 402 | ) 403 | return self.budgets[id].months 404 | 405 | def generate_income_query_tables(self, df, month): 406 | income_table_types = [] 407 | income_table_types.append(("Name", str(str))) 408 | income_table_types.append(("Amount", str(Decimal))) 409 | 410 | income_table_rows = [] 411 | 412 | if month is not None: 413 | income_table_rows.append( 414 | { 415 | "Name": "Funds for month", 416 | "Amount": df.loc["Avail Income", month], 417 | } 418 | ) 419 | income_table_rows.append( 420 | { 421 | "Name": "Overspent in prev month", 422 | "Amount": df.loc["Overspent", month], 423 | } 424 | ) 425 | income_table_rows.append( 426 | { 427 | "Name": "Budgeted for month", 428 | "Amount": df.loc["Budgeted", month], 429 | } 430 | ) 431 | income_table_rows.append( 432 | { 433 | "Name": "To be budgeted for month", 434 | "Amount": df.loc["To Be Budgeted", month], 435 | } 436 | ) 437 | income_table_rows.append( 438 | { 439 | "Name": "Budgeted in the future", 440 | "Amount": df.loc["Budgeted Future", month], 441 | } 442 | ) 443 | 444 | return income_table_types, income_table_rows 445 | 446 | def generate_envelope_query_tables(self, df, month): 447 | envelope_table_types = [] 448 | envelope_table_types.append(("Account", str(str))) 449 | envelope_table_types.append(("Budgeted", str(Decimal))) 450 | envelope_table_types.append(("Activity", str(Decimal))) 451 | envelope_table_types.append(("Available", str(Decimal))) 452 | 453 | envelope_table_rows = [] 454 | 455 | if month is not None: 456 | for index, e_row in df.iterrows(): 457 | row = {} 458 | row["Account"] = index 459 | row["Budgeted"] = e_row[month, "budgeted"] 460 | row["Activity"] = e_row[month, "activity"] 461 | row["Available"] = e_row[month, "available"] 462 | envelope_table_rows.append(row) 463 | 464 | return envelope_table_types, envelope_table_rows 465 | -------------------------------------------------------------------------------- /src/fava_envelope/cli/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import click 4 | from beancount import loader 5 | from fava.core.tree import Tree 6 | 7 | 8 | @click.command() 9 | @click.argument("beancount-file", type=click.Path(exists=True)) 10 | def cli(beancount_file): 11 | entries, _, options_map = loader.load_file(beancount_file) 12 | tree = Tree(entries) 13 | print(tree) 14 | 15 | 16 | if __name__ == "__main__": 17 | cli() 18 | -------------------------------------------------------------------------------- /src/fava_envelope/modules/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polarmutex/fava-envelope/38572dd7dd844f7a13d6cbed4148ba3996926f8a/src/fava_envelope/modules/__init__.py -------------------------------------------------------------------------------- /src/fava_envelope/modules/beancount_envelope.py: -------------------------------------------------------------------------------- 1 | # Debug 2 | from __future__ import annotations 3 | 4 | import collections 5 | import datetime 6 | import logging 7 | import re 8 | 9 | import pandas as pd 10 | from beancount.core import ( 11 | account_types, 12 | amount, 13 | convert, 14 | data, 15 | inventory, 16 | prices, 17 | ) 18 | from beancount.core.data import Custom 19 | from beancount.core.number import Decimal 20 | from beancount.parser import options 21 | from beancount.query import query 22 | from dateutil.relativedelta import relativedelta 23 | 24 | 25 | class BeancountEnvelope: 26 | def __init__(self, entries, options_map, currency): 27 | self.entries = entries 28 | self.options_map = options_map 29 | self.currency = currency 30 | self.negative_rollover = False 31 | self.months_ahead = 0 32 | 33 | if self.currency: 34 | self.etype = "envelope" + self.currency 35 | else: 36 | self.etype = "envelope" 37 | 38 | ( 39 | self.start_date, 40 | self.budget_accounts, 41 | self.mappings, 42 | self.income_accounts, 43 | self.months_ahead, 44 | ) = self._find_envelop_settings() 45 | 46 | if not self.currency: 47 | self.currency = self._find_currency(options_map) 48 | 49 | decimal_precison = "0.00" 50 | self.Q = Decimal(decimal_precison) 51 | 52 | # Compute start of period 53 | # TODO get start date from journal 54 | today = datetime.date.today() 55 | self.date_start = datetime.datetime.strptime( 56 | self.start_date, "%Y-%m" 57 | ).date() 58 | 59 | # TODO should be able to assert errors 60 | 61 | # Compute end of period 62 | self.date_end = datetime.date( 63 | today.year, today.month, today.day 64 | ) + relativedelta(months=+self.months_ahead) 65 | 66 | self.price_map = prices.build_price_map(entries) 67 | self.acctypes = options.get_account_types(options_map) 68 | 69 | def _find_currency(self, options_map): 70 | default_currency = "USD" 71 | opt_currency = options_map.get("operating_currency") 72 | currency = opt_currency[0] if opt_currency else default_currency 73 | if len(currency) == 3: 74 | return currency 75 | 76 | logging.warning( 77 | f"invalid operating currency: {currency}," 78 | + "defaulting to {default_currency}" 79 | ) 80 | return default_currency 81 | 82 | def _find_envelop_settings(self): 83 | start_date = None 84 | budget_accounts = [] 85 | mappings = [] 86 | income_accounts = [] 87 | months_ahead = 0 88 | 89 | for e in self.entries: 90 | if isinstance(e, Custom) and e.type == self.etype: 91 | if e.values[0].value == "start date": 92 | start_date = e.values[1].value 93 | if e.values[0].value == "budget account": 94 | budget_accounts.append(re.compile(e.values[1].value)) 95 | if e.values[0].value == "mapping": 96 | map_set = ( 97 | re.compile(e.values[1].value), 98 | e.values[2].value, 99 | ) 100 | mappings.append(map_set) 101 | if e.values[0].value == "income account": 102 | income_accounts.append(re.compile(e.values[1].value)) 103 | if e.values[0].value == "currency": 104 | self.currency = e.values[1].value 105 | if e.values[0].value == "negative rollover": 106 | if e.values[1].value == "allow": 107 | self.negative_rollover = True 108 | if e.values[0].value == "months ahead": 109 | months_ahead = int(e.values[1].value) 110 | return ( 111 | start_date, 112 | budget_accounts, 113 | mappings, 114 | income_accounts, 115 | months_ahead, 116 | ) 117 | 118 | def envelope_tables(self): 119 | months = [] 120 | date_current = self.date_start 121 | while date_current < self.date_end: 122 | months.append( 123 | f"{date_current.year}-{str(date_current.month).zfill(2)}" 124 | ) 125 | month = date_current.month - 1 + 1 126 | year = date_current.year + month // 12 127 | month = month % 12 + 1 128 | date_current = datetime.date(year, month, 1) 129 | 130 | # Create Income DataFrame 131 | column_index = pd.MultiIndex.from_product([months], names=["Month"]) 132 | self.income_df = pd.DataFrame(columns=months) 133 | 134 | # Create Envelopes DataFrame 135 | column_index = pd.MultiIndex.from_product( 136 | [months, ["budgeted", "activity", "available"]], 137 | names=["Month", "col"], 138 | ) 139 | self.envelope_df = pd.DataFrame(columns=column_index) 140 | self.envelope_df.index.name = "Envelopes" 141 | 142 | self._calculate_budget_activity() 143 | self._calc_budget_budgeted() 144 | 145 | # Calculate Starting Balance Income 146 | starting_balance = Decimal(0.0) 147 | query_str = ( 148 | f"select account, convert(sum(position),'{self.currency}')" 149 | + f" from close on {months[0]}-01 group by 1 order by 1;" 150 | ) 151 | rows = query.run_query( 152 | self.entries, self.options_map, query_str, numberify=True 153 | ) 154 | for row in rows[1]: 155 | if any(regexp.match(row[0]) for regexp in self.budget_accounts): 156 | if len(row) > 1 and row[1] is not None: 157 | starting_balance += row[1] 158 | self.income_df.loc["Avail Income", months[0]] += starting_balance 159 | 160 | self.envelope_df.fillna(Decimal(0.00), inplace=True) 161 | 162 | # Set available 163 | for index, row in self.envelope_df.iterrows(): 164 | for index2, month in enumerate(months): 165 | if index2 == 0: 166 | self.envelope_df.loc[index, (month, "available")] = ( 167 | row[month, "budgeted"] + row[month, "activity"] 168 | ) 169 | else: 170 | prev_available = self.envelope_df[ 171 | months[index2 - 1], "available" 172 | ][index] 173 | if prev_available > 0 or self.negative_rollover: 174 | self.envelope_df.loc[index, (month, "available")] = ( 175 | prev_available 176 | + row[month, "budgeted"] 177 | + row[month, "activity"] 178 | ) 179 | else: 180 | self.envelope_df.loc[index, (month, "available")] = ( 181 | row[month, "budgeted"] + row[month, "activity"] 182 | ) 183 | 184 | # Set overspent 185 | for index, month in enumerate(months): 186 | if index == 0: 187 | self.income_df.loc["Overspent", month] = Decimal(0.00) 188 | else: 189 | overspent = Decimal(0.00) 190 | for index2, row in self.envelope_df.iterrows(): 191 | if row[months[index - 1], "available"] < Decimal(0.00): 192 | overspent += Decimal( 193 | row[months[index - 1], "available"] 194 | ) 195 | self.income_df.loc["Overspent", month] = overspent 196 | 197 | # Set Budgeted for month 198 | for month in months: 199 | self.income_df.loc["Budgeted", month] = Decimal( 200 | -1 * self.envelope_df[month, "budgeted"].sum() 201 | ) 202 | 203 | # Adjust Avail Income 204 | for index, month in enumerate(months): 205 | if index == 0: 206 | continue 207 | else: 208 | prev_month = months[index - 1] 209 | self.income_df.loc["Avail Income", month] = ( 210 | self.income_df.loc["Avail Income", month] 211 | + self.income_df.loc["Avail Income", prev_month] 212 | + self.income_df.loc["Overspent", prev_month] 213 | + self.income_df.loc["Budgeted", prev_month] 214 | ) 215 | 216 | # Set Budgeted in the future 217 | for index, month in enumerate(months): 218 | sum_total = self.income_df[month].sum() 219 | if (index == len(months) - 1) or sum_total < 0: 220 | self.income_df.loc["Budgeted Future", month] = Decimal(0.00) 221 | else: 222 | next_month = months[index + 1] 223 | opp_budgeted_next_month = ( 224 | self.income_df.loc["Budgeted", next_month] * -1 225 | ) 226 | if opp_budgeted_next_month < sum_total: 227 | self.income_df.loc["Budgeted Future", month] = Decimal( 228 | -1 * opp_budgeted_next_month 229 | ) 230 | else: 231 | self.income_df.loc["Budgeted Future", month] = Decimal( 232 | -1 * sum_total 233 | ) 234 | 235 | # Set to be budgeted 236 | for index, month in enumerate(months): 237 | self.income_df.loc["To Be Budgeted", month] = Decimal( 238 | self.income_df[month].sum() 239 | ) 240 | 241 | return self.income_df, self.envelope_df, self.currency 242 | 243 | def _calculate_budget_activity(self): 244 | # Accumulate expenses for the period 245 | balances = collections.defaultdict( 246 | lambda: collections.defaultdict(inventory.Inventory) 247 | ) 248 | all_months = set() 249 | 250 | for entry in data.filter_txns(self.entries): 251 | # Check entry in date range 252 | if entry.date < self.date_start or entry.date > self.date_end: 253 | continue 254 | 255 | month = (entry.date.year, entry.date.month) 256 | # TODO domwe handle no transaction in a month? 257 | all_months.add(month) 258 | 259 | # TODO 260 | contains_budget_accounts = False 261 | for posting in entry.postings: 262 | if any( 263 | regexp.match(posting.account) 264 | for regexp in self.budget_accounts 265 | ): 266 | contains_budget_accounts = True 267 | break 268 | 269 | if not contains_budget_accounts: 270 | continue 271 | 272 | for posting in entry.postings: 273 | account = posting.account 274 | for regexp, target_account in self.mappings: 275 | if regexp.match(account): 276 | account = target_account 277 | break 278 | 279 | account_type = account_types.get_account_type(account) 280 | if posting.units.currency != self.currency: 281 | orig = posting.units.number 282 | if posting.price is not None: 283 | converted = posting.price.number * orig 284 | posting = data.Posting( 285 | posting.account, 286 | amount.Amount(converted, self.currency), 287 | posting.cost, 288 | None, 289 | posting.flag, 290 | posting.meta, 291 | ) 292 | else: 293 | continue 294 | 295 | if account_type == self.acctypes.income or ( 296 | any( 297 | regexp.match(account) 298 | for regexp in self.income_accounts 299 | ) 300 | ): 301 | account = "Income" 302 | elif any( 303 | regexp.match(posting.account) 304 | for regexp in self.budget_accounts 305 | ): 306 | continue 307 | # TODO WARn of any assets / liabilities left 308 | 309 | # TODO 310 | balances[account][month].add_position(posting) 311 | 312 | # Reduce the final balances to numbers 313 | sbalances = collections.defaultdict(dict) 314 | for account, months in sorted(balances.items()): 315 | for month, balance in sorted(months.items()): 316 | year, mth = month 317 | date = datetime.date(year, mth, 1) 318 | balance = balance.reduce( 319 | convert.get_value, self.price_map, date 320 | ) 321 | balance = balance.reduce( 322 | convert.convert_position, 323 | self.currency, 324 | self.price_map, 325 | date, 326 | ) 327 | try: 328 | pos = balance.get_only_position() 329 | except AssertionError: 330 | print(balance) 331 | raise 332 | total = pos.units.number if pos and pos.units else None 333 | sbalances[account][month] = total 334 | 335 | # Pivot the table 336 | header_months = sorted(all_months) 337 | # header = ["account"]+["{}-{:02d}".format(*m) for m in header_months] 338 | self.income_df.loc["Avail Income", :] = Decimal(0.00) 339 | 340 | for account in sorted(sbalances.keys()): 341 | for month in header_months: 342 | total = sbalances[account].get(month, None) 343 | temp = total.quantize(self.Q) if total else 0.00 344 | # swap sign to be more human readable 345 | temp *= -1 346 | 347 | month_str = f"{str(month[0])}-{str(month[1]).zfill(2)}" 348 | if account == "Income": 349 | self.income_df.loc["Avail Income", month_str] = Decimal( 350 | temp 351 | ) 352 | else: 353 | self.envelope_df.loc[account, (month_str, "budgeted")] = ( 354 | Decimal(0.00) 355 | ) 356 | self.envelope_df.loc[account, (month_str, "activity")] = ( 357 | Decimal(temp) 358 | ) 359 | self.envelope_df.loc[account, (month_str, "available")] = ( 360 | Decimal(0.00) 361 | ) 362 | 363 | def _calc_budget_budgeted(self): 364 | # rows = {} 365 | for e in self.entries: 366 | if isinstance(e, Custom) and e.type == self.etype: 367 | if e.values[0].value == "allocate": 368 | month = f"{e.date.year}-{e.date.month:02}" 369 | try: 370 | _ = self.envelope_df.loc[ 371 | e.values[1].value, (month, "budgeted") 372 | ] 373 | except KeyError: 374 | self.envelope_df.loc[ 375 | e.values[1].value, (month, "budgeted") 376 | ] = Decimal(0.00) 377 | 378 | self.envelope_df.loc[ 379 | e.values[1].value, (month, "budgeted") 380 | ] = Decimal( 381 | self.envelope_df.loc[ 382 | e.values[1].value, (month, "budgeted") 383 | ] 384 | ) + Decimal( 385 | e.values[2].value 386 | ) 387 | -------------------------------------------------------------------------------- /src/fava_envelope/modules/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import logging 5 | 6 | from beancount import loader 7 | 8 | from fava_envelope.modules.beancount_envelope import BeancountEnvelope 9 | 10 | 11 | def main(): 12 | logging.basicConfig( 13 | level=logging.INFO, format="%(levelname)-8s: %(message)s" 14 | ) 15 | parser = argparse.ArgumentParser(description="beancount_envelope") 16 | parser.add_argument("filename", help="path to beancount journal file") 17 | args = parser.parse_args() 18 | 19 | # Read beancount input file 20 | entries, errors, options_map = loader.load_file(args.filename) 21 | ext = BeancountEnvelope(entries, options_map, None) 22 | df1, df2, df3 = ext.envelope_tables() 23 | print(df1) 24 | print(df2) 25 | print(df3) 26 | 27 | 28 | if __name__ == "__main__": 29 | main() 30 | -------------------------------------------------------------------------------- /src/fava_envelope/templates/EnvelopeBudget.html: -------------------------------------------------------------------------------- 1 | {% set budget_id = request.args.get('budget', '0') | int %} 2 | 3 | {% set month = request.args.get('month') %} 4 | {% if not month%} 5 | {% set month = extension.get_budget_months(budget_id)[-1] %} 6 | {% endif %} 7 | 8 | {% set bootstrap = extension.bootstrap(budget_id, month) %} 9 | 10 |
11 | {% for budget in bootstrap.budgets %} 12 |

13 | {%- if budget_id == loop.index0 -%} 14 | {{ budget.name }} 15 | {%- else -%} 16 | {{ budget.name }} 17 | {%- endif -%} 18 |

19 | {% endfor %} 20 |
21 | 22 |
23 | {% for m in bootstrap.months %} 24 |

25 | {%- if month == m -%} 26 | {{ m }} 27 | {%- else -%} 28 | {{ m }} 29 | {%- endif -%} 30 |

31 | {% endfor %} 32 |
33 | 34 |

{{ month }}

35 | 36 | {% import "_query_table.html" as querytable with context %} 37 | 38 | {{ querytable.querytable(ledger, None, bootstrap.top[0], bootstrap.top[1]) }} 39 | {{ querytable.querytable(ledger, None, bootstrap.envelopes[0], bootstrap.envelopes[1]) }} 40 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37,py38,py39,pypy3,pre-commit 3 | 4 | [testenv] 5 | deps = -rrequirements-dev.txt 6 | commands = 7 | coverage erase 8 | coverage run -m pytest {posargs:tests} 9 | coverage report 10 | 11 | [testenv:pre-commit] 12 | skip_install = true 13 | deps = pre-commit 14 | commands = pre-commit run --all-files --show-diff-on-failure 15 | --------------------------------------------------------------------------------