├── requirements.txt ├── beanstatement ├── scripts │ ├── __init__.py │ └── main.py ├── templates │ ├── __init__.py │ ├── balance_sheet.mustache │ └── income_statement.mustache ├── test_resources │ ├── __init__.py │ └── minimal │ │ ├── __init__.py │ │ ├── layout │ │ └── main.bean ├── __init__.py ├── reporter_test.py └── reporter.py ├── .flake8 ├── .coveragerc ├── example ├── balance_sheet.png └── layout ├── .github └── workflows │ ├── lint.yaml │ ├── codeql-analysis.yml │ └── build.yml ├── .pre-commit-config.yaml ├── .gitignore ├── Makefile ├── setup.py └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | . -------------------------------------------------------------------------------- /beanstatement/scripts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /beanstatement/templates/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /beanstatement/test_resources/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /beanstatement/test_resources/minimal/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /beanstatement/__init__.py: -------------------------------------------------------------------------------- 1 | """Test""" 2 | 3 | __version__ = '0.8.5' 4 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 160 3 | exclude = tests/* 4 | max-complexity = 10 5 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | relative_files = True 3 | 4 | [report] 5 | exclude_lines = 6 | if __name__ == .__main__.: 7 | -------------------------------------------------------------------------------- /example/balance_sheet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e7h4n/beancount-financial-statement/HEAD/example/balance_sheet.png -------------------------------------------------------------------------------- /example/layout: -------------------------------------------------------------------------------- 1 | Assets:Current assets:Cash and cash equivalent 2 | Assets:Non-current assets:Stock and ETF 3 | Assets:Non-current assets:Taxes 4 | Assets:Non-current assets:Vacation 5 | Liabilities:Current liabilities:Accounts payable 6 | Liabilities:Current liabilities:Credit card 7 | Liabilities:Non-current liabilities 8 | Equity:Current equity:Current assets 9 | Equity:Current equity:Current liabilities 10 | Equity:Non-current equity:Non-current assets 11 | Equity:Non-current equity:Non-current liabilities 12 | -------------------------------------------------------------------------------- /beanstatement/test_resources/minimal/layout: -------------------------------------------------------------------------------- 1 | Assets:Current assets:Cash and cash equivalent 2 | Assets:Current assets:Short-term investment 3 | Assets:Current assets:Account receivables 4 | Assets:Non-current assets:Stock and index fund 5 | Assets:Non-current assets:Equipments 6 | Assets:Non-current assets:Real estate 7 | Liabilities:Current liabilities:Credit card 8 | Liabilities:Current liabilities:Account payable 9 | Liabilities:Non-current liabilities:Loan 10 | Equity:Current equity:Current assets 11 | Equity:Current equity:Current liabilities 12 | Equity:Non-current equity:Non-current assets 13 | Equity:Non-current equity:Non-current liabilities 14 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: "Lint - action" 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up Python 3.7 12 | uses: actions/setup-python@v1 13 | with: 14 | python-version: 3.7 15 | - name: Setup Node.js environment 16 | uses: actions/setup-node@v2.4.0 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install .[test] 20 | - name: Run lint 21 | run: | 22 | npm install -g pyright 23 | make code-style code-lint 24 | - name: Run test 25 | run: make test 26 | -------------------------------------------------------------------------------- /beanstatement/scripts/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # pylint: disable=no-value-for-parameter 4 | """命令行执行器""" 5 | import click 6 | from beanstatement import __version__ 7 | from beanstatement.reporter import Reporter 8 | 9 | 10 | @click.command(no_args_is_help=True) 11 | @click.option('--year', help='Year.', type=click.INT, required=True) 12 | @click.option('--month', help='Month.', type=click.INT, required=True) 13 | @click.option('--beancount', help='Beancount ledger file.', 14 | type=click.Path(exists=True), required=True) 15 | @click.version_option(__version__) 16 | def main(year, month, beancount): 17 | """Beancount financial statement tool""" 18 | 19 | reporter = Reporter(year, month, beancount) 20 | print(reporter.generate()) 21 | 22 | 23 | if __name__ == '__main__': 24 | main() 25 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | repos: 4 | - repo: https://github.com/asottile/pyupgrade 5 | rev: v2.29.0 6 | hooks: 7 | - id: pyupgrade 8 | args: ["--py36-plus"] 9 | - repo: https://github.com/asottile/reorder_python_imports 10 | rev: v2.6.0 11 | hooks: 12 | - id: reorder-python-imports 13 | args: ["--application-directories", "yuanli"] 14 | - repo: https://github.com/psf/black 15 | rev: 21.9b0 16 | hooks: 17 | - id: black 18 | - repo: https://github.com/PyCQA/flake8 19 | rev: 3.9.2 20 | hooks: 21 | - id: flake8 22 | additional_dependencies: 23 | - flake8-bugbear 24 | - flake8-implicit-str-concat 25 | - repo: https://github.com/pre-commit/pre-commit-hooks 26 | rev: v4.0.1 27 | hooks: 28 | - id: fix-byte-order-marker 29 | - id: trailing-whitespace 30 | - id: end-of-file-fixer 31 | -------------------------------------------------------------------------------- /beanstatement/reporter_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Test balance sheet statement""" 5 | 6 | import unittest 7 | from bs4 import BeautifulSoup 8 | from beanstatement.reporter import Reporter 9 | 10 | 11 | class MinimalLedger(unittest.TestCase): 12 | """Test Minimal ledger""" 13 | 14 | def test_default_greeting_set(self): 15 | """Test documentation goes here.""" 16 | reporter = Reporter(year=2021, month=8, 17 | file='beanstatement/test_resources/minimal/main.bean') 18 | report = reporter.generate() 19 | 20 | soup = BeautifulSoup(report, 'html.parser') 21 | total_equity = soup.select('tr.level-0.total')[-1].select('td')[-1].text 22 | self.assertEqual(total_equity, '12,345,678.00') 23 | 24 | def test_working_currency_title(self): 25 | """Test documentation goes here.""" 26 | reporter = Reporter(year=2021, month=8, 27 | file='beanstatement/test_resources/minimal/main.bean') 28 | report = reporter.generate() 29 | 30 | soup = BeautifulSoup(report, 'html.parser') 31 | title = soup.select('#table-title')[0].text 32 | self.assertEqual(title, 'As of (USD)') 33 | 34 | 35 | if __name__ == '__main__': 36 | unittest.main() 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | .pytest_cache/ 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # Environments 83 | .env 84 | .venv 85 | env/ 86 | venv/ 87 | ENV/ 88 | 89 | # Spyder project settings 90 | .spyderproject 91 | .spyproject 92 | 93 | # Rope project settings 94 | .ropeproject 95 | 96 | # mkdocs documentation 97 | /site 98 | 99 | # mypy 100 | .mypy_cache/ 101 | 102 | bin/ 103 | 104 | pyvenv.cfg 105 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "Code scanning - action" 2 | 3 | on: 4 | push: 5 | branches: [master, ] 6 | pull_request: 7 | branches: [master] 8 | schedule: 9 | - cron: '0 14 * * 6' 10 | 11 | jobs: 12 | CodeQL-Build: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v2 19 | with: 20 | # We must fetch at least the immediate parents so that if this is 21 | # a pull request then we can checkout the head. 22 | fetch-depth: 2 23 | 24 | # If this run was triggered by a pull request event, then checkout 25 | # the head of the pull request instead of the merge commit. 26 | - run: git checkout HEAD^2 27 | if: ${{ github.event_name == 'pull_request' }} 28 | 29 | # Initializes the CodeQL tools for scanning. 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v1 32 | # Override language selection by uncommenting this and choosing your languages 33 | # with: 34 | # languages: go, javascript, csharp, python, cpp, java 35 | 36 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 37 | # If this step fails, then you should remove it and run the build manually (see below) 38 | - name: Autobuild 39 | uses: github/codeql-action/autobuild@v1 40 | 41 | # ℹ️ Command-line programs to run using the OS shell. 42 | # 📚 https://git.io/JvXDl 43 | 44 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 45 | # and modify them (or add more) to build your code if your project 46 | # uses a compiled language 47 | 48 | #- run: | 49 | # make bootstrap 50 | # make release 51 | 52 | - name: Perform CodeQL Analysis 53 | uses: github/codeql-action/analyze@v1 54 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: "Build and Publish - action" 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up Python 3.7 12 | uses: actions/setup-python@v1 13 | with: 14 | python-version: 3.7 15 | - name: Install dependencies 16 | run: | 17 | python -m pip install .[test] 18 | - name: Run test 19 | run: make test 20 | - name: Coveralls 21 | uses: AndreMiras/coveralls-python-action@develop 22 | with: 23 | parallel: true 24 | 25 | coveralls_finish: 26 | needs: test 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Coveralls Finished 30 | uses: AndreMiras/coveralls-python-action@develop 31 | with: 32 | parallel-finished: true 33 | 34 | build: 35 | needs: test 36 | runs-on: ubuntu-latest 37 | 38 | steps: 39 | - uses: actions/checkout@v2 40 | - name: Set up Python 3.7 41 | uses: actions/setup-python@v1 42 | with: 43 | python-version: 3.7 44 | - name: Build 45 | run: | 46 | python -m pip install build --user 47 | python -m build --sdist --wheel --outdir dist/ . 48 | 49 | publish: 50 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 51 | needs: build 52 | runs-on: ubuntu-latest 53 | 54 | steps: 55 | - uses: actions/checkout@v2 56 | - name: Set up Python 3.7 57 | uses: actions/setup-python@v1 58 | with: 59 | python-version: 3.7 60 | - name: Build 61 | run: | 62 | python -m pip install build --user 63 | python -m build --sdist --wheel --outdir dist/ . 64 | - name: Publish a Python distribution to PyPI 65 | uses: pypa/gh-action-pypi-publish@release/v1 66 | with: 67 | user: __token__ 68 | password: ${{ secrets.PYPI_API_TOKEN }} 69 | -------------------------------------------------------------------------------- /beanstatement/test_resources/minimal/main.bean: -------------------------------------------------------------------------------- 1 | 1970-01-01 custom "finance-statement-option" "balance_sheet_layout" "layout" 2 | 1970-01-01 custom "finance-statement-option" "income_statement_layout" "income_layout" 3 | 1970-01-01 custom "finance-statement-option" "working_currency" "USD" 4 | 1970-01-01 custom "finance-statement-option" "fixed_expense_tag" "FIXED_EXPENSE" 5 | 1970-01-01 custom "finance-statement-option" "fixed_expense_income_statement_category" "Expense:Fixed" 6 | 1970-01-01 custom "finance-statement-option" "fixed_expense_revenue_category" "Revenue:Free cash flow:Fixed expense" 7 | 8 | 1970-01-01 open Income:Salary 9 | income_statement_category: "Income:Positive" 10 | revenue_category: "Revenue:Free cash flow:Positive" 11 | 12 | 1970-01-01 open Income:PnL 13 | income_statement_category: "Income:Passive" 14 | revenue_category: "Revenue:Passive" 15 | 16 | 1970-01-01 open Expense:Interest 17 | income_statement_category: "Expense:Passive" 18 | revenue_category: "Revenue:Free cash flow:Passive expense" 19 | 20 | 1970-01-01 open Expense:Meal 21 | income_statement_category: "Expense:Positive" 22 | revenue_category: "Revenue:Free cash flow:Expense" 23 | 24 | 2019-01-01 open Assets:US:BofA 25 | balance_sheet_category: "Assets:Current assets:Cash and cash equivalent" 26 | equity_category: "Equity:Current equity:Current assets" 27 | 28 | 1980-05-12 open Liabilities:US:Chase:Slate USD 29 | balance_sheet_category: "Liabilities:Current liabilities:Credit card" 30 | equity_category: "Equity:Current equity:Current liabilities" 31 | 32 | 2021-07-12 * 33 | Income:Salary -12,345,678 USD 34 | Assets:US:BofA 35 | 36 | 2021-08-13 * 37 | Liabilities:US:Chase:Slate -1,234.56 USD 38 | Assets:US:BofA 39 | 40 | 2021-11-01 * 41 | Income:Salary -100.00 USD 42 | Income:PnL -200.00 USD 43 | Expense:Interest 150.00 USD 44 | Expense:Meal 200.00 USD 45 | Assets:US:BofA 46 | 47 | 2021-11-02 * #FIXED_EXPENSE 48 | Expense:Meal 50.00 USD 49 | Assets:US:BofA 50 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SRC_CORE=beanstatement 2 | SRC_RESOURCES=resources 3 | PYTHON=python3 4 | PYDOC=pydoc3 5 | PIP=pip3 6 | 7 | 8 | help: ## Print help for each target 9 | $(info Things3 low-level Python API.) 10 | $(info =============================) 11 | $(info ) 12 | $(info Available commands:) 13 | $(info ) 14 | @grep '^[[:alnum:]_-]*:.* ##' $(MAKEFILE_LIST) \ 15 | | sort | awk 'BEGIN {FS=":.* ## "}; {printf "%-25s %s\n", $$1, $$2};' 16 | 17 | test: ## Test the code 18 | @type coverage >/dev/null 2>&1 || (echo "Run '$(PIP) install coverage' first." >&2 ; exit 1) 19 | @coverage run --source . -m beanstatement.reporter_test 20 | @coverage report 21 | 22 | doc: ## Document the code 23 | @$(PYDOC) src 24 | 25 | clean: ## Cleanup 26 | @rm -f $(SRC_CORE)/**/*.pyc 27 | @rm -rf $(SRC_CORE)/**/__pycache__ 28 | 29 | auto-style: ## Style the code 30 | @if type autopep8 >/dev/null 2>&1 ; then autopep8 -i -r $(SRC_CORE) ; \ 31 | else echo "SKIPPED. Run '$(PIP) install autopep8' first." >&2 ; fi 32 | 33 | code-style: ## Test the code style 34 | @if type pycodestyle >/dev/null 2>&1 ; then pycodestyle --max-line-length=120 $(SRC_CORE) ; \ 35 | else echo "SKIPPED. Run '$(PIP) install pycodestyle' first." >&2 ; fi 36 | 37 | code-count: ## Count the lines of code 38 | @if type cloc >/dev/null 2>&1 ; then cloc $(SRC_CORE) ; \ 39 | else echo "SKIPPED. Run 'brew install cloc' first." >&2 ; fi 40 | 41 | code-lint: ## Lint the code 42 | @if type pyflakes >/dev/null 2>&1 ; then pyflakes $(SRC_CORE) ; \ 43 | else echo "SKIPPED. Run '$(PIP) install pyflakes' first." >&2 ; fi 44 | @if type pylint >/dev/null 2>&1 ; then pylint $(SRC_CORE) ; \ 45 | else echo "SKIPPED. Run '$(PIP) install pylint' first." >&2 ; fi 46 | @if type flake8 >/dev/null 2>&1 ; then flake8 --max-complexity 10 $(SRC_CORE) ; \ 47 | else echo "SKIPPED. Run '$(PIP) install flake8' first." >&2 ; fi 48 | @if type pyright >/dev/null 2>&1 ; then pyright $(SRC_CORE) ; \ 49 | else echo "SKIPPED. Run 'npm install -f pyright' first." >&2 ; fi 50 | @if type mypy >/dev/null 2>&1 ; then mypy --ignore-missing-imports $(SRC_CORE) ; \ 51 | else echo "SKIPPED. Run '$(PIP) install mypy' first." >&2 ; fi 52 | 53 | upload: 54 | python3 -m build 55 | twine upload dist/* 56 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """A setuptools based setup module. 2 | 3 | See: 4 | https://packaging.python.org/guides/distributing-packages-using-setuptools/ 5 | https://github.com/pypa/sampleproject 6 | """ 7 | 8 | 9 | import pathlib 10 | from setuptools import setup, find_packages 11 | from beanstatement import __version__ 12 | 13 | here = pathlib.Path(__file__).parent.resolve() 14 | 15 | long_description = (here / 'README.md').read_text(encoding='utf-8') 16 | 17 | setup( 18 | name='beancount-financial-statement', 19 | 20 | version=__version__, 21 | 22 | description='A report generator for beancount financial statement.', 23 | 24 | long_description=long_description, 25 | 26 | long_description_content_type='text/markdown', 27 | 28 | url='https://github.com/e7h4n/beancount-financial-statement', 29 | 30 | author='e7h4n', 31 | 32 | author_email='ethan.pw@icloud.com', 33 | 34 | classifiers=[ 35 | 'Development Status :: 4 - Beta', 36 | 37 | 'Intended Audience :: Financial and Insurance Industry', 38 | 'Topic :: Office/Business :: Financial :: Accounting', 39 | 40 | 'License :: OSI Approved :: MIT License', 41 | 42 | 'Programming Language :: Python :: 3', 43 | 'Programming Language :: Python :: 3.6', 44 | 'Programming Language :: Python :: 3.7', 45 | 'Programming Language :: Python :: 3.8', 46 | 'Programming Language :: Python :: 3.9', 47 | 'Programming Language :: Python :: 3 :: Only', 48 | ], 49 | 50 | keywords='beancount, financial statement', 51 | 52 | package_dir={'beancount-financial-statement': 'src'}, 53 | 54 | python_requires='>=3.6, <4', 55 | 56 | packages=find_packages(exclude=['experiments*']), 57 | 58 | package_data = { 59 | 'beanstatement': ['templates/*.mustache'], 60 | }, 61 | 62 | install_requires=[ 63 | 'logzero>=1.7.0', 64 | 'click>=7,<9', 65 | 'pystache>=0.6.0', 66 | 'beancount>=2.3.6', 67 | ], 68 | 69 | extras_require={ 70 | 'dev': [], 71 | 'test': [ 72 | 'coverage', 73 | 'pycodestyle', 74 | 'pyflakes', 75 | 'pylint', 76 | 'flake8', 77 | 'mypy', 78 | 'pytest', 79 | 'python-coveralls', 80 | 'beautifulsoup4' 81 | ], 82 | }, 83 | 84 | entry_points={ 85 | 'console_scripts': [ 86 | 'bean-statement=beanstatement.scripts.main:main', 87 | ], 88 | }, 89 | 90 | project_urls={ 91 | 'Bug Reports': 'https://github.com/e7h4n/beancount-financial-statement/issues', 92 | 'Source': 'https://github.com/e7h4n/beancount-financial-statement/', 93 | }, 94 | ) 95 | -------------------------------------------------------------------------------- /beanstatement/templates/balance_sheet.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Balance Sheet 5 | 6 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | {{#periods}}{{/periods}} 66 | 67 | 68 | 69 | {{#sections}} 70 | 71 | 72 | {{#amounts}} 73 | 74 | {{/amounts}} 75 | 76 | {{/sections}} 77 | 78 |
Balance Sheet
As of ({{meta.working_currency}})
{{year}}.{{month}}.{{day}}
{{category}}{{.}}
79 | 80 | 81 | -------------------------------------------------------------------------------- /beanstatement/templates/income_statement.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Balance Sheet 5 | 6 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | {{#periods}}{{/periods}} 66 | 67 | 68 | 69 | {{#sections}} 70 | 71 | 72 | {{#amounts}} 73 | 74 | {{/amounts}} 75 | 76 | {{/sections}} 77 | 78 |
Balance Sheet
As of ({{meta.working_currency}})
{{year}}.{{month}}.{{day}}
{{category}}{{.}}
79 | 80 | 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Beancount 财报 2 | 3 | [![Build and Publish - action](https://github.com/e7h4n/beancount-financial-statement/actions/workflows/build.yml/badge.svg)](https://github.com/e7h4n/beancount-financial-statement/actions/workflows/build.yml) 4 | [![Lint - action](https://github.com/e7h4n/beancount-financial-statement/actions/workflows/lint.yaml/badge.svg)](https://github.com/e7h4n/beancount-financial-statement/actions/workflows/lint.yaml) 5 | [![Coverage Status](https://coveralls.io/repos/github/e7h4n/beancount-financial-statement/badge.svg?branch=master)](https://coveralls.io/github/e7h4n/beancount-financial-statement?branch=master) 6 | [![Code scanning - action](https://github.com/e7h4n/beancount-financial-statement/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/e7h4n/beancount-financial-statement/actions/workflows/codeql-analysis.yml) 7 | [![PyPI version](https://badge.fury.io/py/beancount-financial-statement.svg)](https://badge.fury.io/py/beancount-financial-statement) 8 | 9 | 一个给个人用的财报工具,分析 beancount 账本,自动生成资产负债表。 10 | 11 | ## Demo 12 | 13 | ![Balance Sheet](/example/balance_sheet.png) 14 | 15 | 可以 clone 这个仓库重现这个报告: 16 | 17 | ```bash 18 | git clone https://github.com/e7h4n/beancount-financial-statement 19 | cd beancount-financial-statement 20 | python3 -m venv . 21 | . bin/activate 22 | pip3 install . 23 | bean-statement --year 2021 --month 8 --beancount example/main.bean > /tmp/report.html 24 | open /tmp/report.html 25 | ``` 26 | 27 | ## 安装 28 | 29 | ```bash 30 | pip install beancount-financial-statement 31 | ``` 32 | 33 | 安装后可以通过命令 `bean-statement` 来使用。 34 | 35 | ## 如何使用 36 | 37 | 1. 创建一个额外的 layout.txt 文件来控制资产负债表中各个项目的顺序。 38 | 39 | 例如: 40 | 41 | ``` 42 | Assets:Current assets:Cash and cash equivalent 43 | Assets:Current assets:Short-term investment 44 | Assets:Current assets:Account receivables 45 | Assets:Non-current assets:Stock and index fund 46 | Assets:Non-current assets:Equipments 47 | Assets:Non-current assets:Real estate 48 | Liabilities:Current liabilities:Credit card 49 | Liabilities:Current liabilities:Account payable 50 | Liabilities:Non-current liabilities:Loan 51 | Equity:Current equity:Current assets 52 | Equity:Current equity:Current liabilities 53 | Equity:Non-current equity:Non-current assets 54 | Equity:Non-current equity:Non-current liabilities 55 | ``` 56 | 57 | 同时在账本开头设置 layout 文件的位置: 58 | 59 | ```beancount 60 | 1970-01-01 custom "finance-statement-option" "balance_sheet_layout" "layout.txt" 61 | ``` 62 | 63 | balance_sheet_layout 是相对于账本主文件的路径。 64 | 65 | 这样的 layout 会让资产负债表从上到下分别是资产、负债和所有者权益。 66 | 67 | Layout 中的每一项,都可以在账本中通过 `balance_sheet_category` 和 `equity_category` 来指定。 68 | 69 | 2. 在账本中给所有的 Assets 和 Liabilities 设置类别。 70 | 71 | 例如: 72 | 73 | ```beancount 74 | 2019-01-01 open Assets:US:BofA 75 | balance_sheet_category: "Assets:Current assets:Cash and cash equivalent" 76 | equity_category: "Equity:Current equity:Current assets" 77 | ``` 78 | 79 | 以上这个例子的意思是,将 `Assets:US:BofA` 这项资产,计入 `Assets:Current assets:Cash and cash equivalent` 这一分类。同时这项资产会参与 `Equity:Current equity:Current assets` 这项所有者权益的计算。 80 | 81 | 再看一个负债的例子: 82 | 83 | ```beancount 84 | 1980-05-12 open Liabilities:US:Chase:Slate USD 85 | balance_sheet_category: "Liabilities:Current liabilities:Credit card" 86 | equity_category: "Equity:Current equity:Current liabilities" 87 | ``` 88 | 89 | 这个例子的意思是,将 `Liabilities:US:Chase:Slate` 这项负债,计入 `Liabilities:Current liabilities:Credit card` 这一分类,同时这项资产会参与 `Equity:Current equity:Current liabilities` 的计算。 90 | 91 | 3. 在账本中配置财报所使用的货币。 92 | 93 | 在账本开头设置: 94 | 95 | ```beancount 96 | 1970-01-01 custom "finance-statement-option" "working_currency" "USD" 97 | ``` 98 | 99 | 这样会把生成的报表所有的货币都统一成 `working_currency`。 100 | 101 | 4. 执行命令 102 | 103 | ```bash 104 | bean-statement --year 2021 --month 8 --beancount YOUR_LEDGER_FILE_PATH 105 | ``` 106 | 107 | ## 如何贡献代码 108 | 109 | 这里有一些常用的 `make` 命令: 110 | 111 | ```bash 112 | $ make 113 | Some available commands: 114 | * test - Run unit tests and test coverage. 115 | * code-style - Check code style (pycodestyle). 116 | * code-lint - Check code lints (pyflakes, pyline). 117 | ``` 118 | 119 | 可以通过 venv 在本地快速开始一个开发环境: 120 | ```bash 121 | git clone https://github.com/e7h4n/beancount-financial-statement 122 | cd beancount-financial-statement 123 | python3 -m venv . 124 | . bin/activate 125 | pip install -e '.[test]' 126 | ``` 127 | 128 | ## Todo 129 | 130 | - [x] 资产负债表 131 | - [x] 易于使用的命令行界面 132 | - [ ] 更多的 Test Case 133 | - [ ] 完善 Pydoc 134 | - [ ] 更好的 Code Style 135 | - [ ] 更多的例子 136 | - [ ] 利润表 137 | - [ ] 现金流量表 138 | 139 | ## License 140 | 141 | MIT. 142 | -------------------------------------------------------------------------------- /beanstatement/reporter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # pylint: disable=isinstance-second-argument-not-valid-type 3 | """资产报告生成器""" 4 | import calendar 5 | import datetime 6 | import importlib.resources as pkg_resources 7 | import os 8 | from datetime import timedelta 9 | from decimal import Decimal 10 | 11 | import pystache 12 | from beancount.core.convert import convert_amount 13 | from beancount.core.data import Custom 14 | from beancount.core.data import Open 15 | from beancount.core.getters import get_account_open_close 16 | from beancount.core.number import D 17 | from beancount.core.prices import build_price_map 18 | from beancount.loader import load_file 19 | from beancount.query import query 20 | 21 | from . import templates 22 | 23 | 24 | def parse_report_layout(layout_file): 25 | """分析布局文件,生成最终的完整布局""" 26 | 27 | with open(layout_file, encoding="utf-8") as f_p: 28 | layout_content = f_p.readlines() 29 | layout_content = [x.strip() for x in layout_content] 30 | 31 | report_layout = [] 32 | unresolved = [] 33 | for line in layout_content: 34 | last_section = None 35 | for section in line.split(":"): 36 | if last_section is None: 37 | last_section = section 38 | else: 39 | last_section = last_section + ":" + section 40 | 41 | same_context = len(unresolved) > 0 and unresolved[-1][ 42 | "category" 43 | ].startswith(last_section) 44 | 45 | if same_context: 46 | continue 47 | 48 | first_unresolved = True 49 | while len(unresolved) > 0: 50 | last_unresolved_category = unresolved[-1]["category"] 51 | if last_section.startswith(last_unresolved_category): 52 | break 53 | 54 | stack_top_section = unresolved.pop() 55 | if first_unresolved is True: 56 | first_unresolved = False 57 | stack_top_section["show_amount"] = True 58 | else: 59 | report_layout.append( 60 | { 61 | "category": stack_top_section["category"], 62 | "show_total": True, 63 | "show_amount": True, 64 | } 65 | ) 66 | 67 | report_layout.append( 68 | { 69 | "category": last_section, 70 | "show_amount": False, 71 | "show_total": False, 72 | } 73 | ) 74 | unresolved.append(report_layout[-1]) 75 | 76 | first_unresolved = True 77 | while len(unresolved) > 0: 78 | stack_top_section = unresolved.pop() 79 | if first_unresolved is True: 80 | first_unresolved = False 81 | stack_top_section["show_amount"] = True 82 | else: 83 | report_layout.append( 84 | { 85 | "category": stack_top_section["category"], 86 | "show_total": True, 87 | "show_amount": True, 88 | } 89 | ) 90 | 91 | return report_layout 92 | 93 | 94 | def add_months(sourcedate, months): 95 | """add month to an existed date""" 96 | 97 | month = sourcedate.month - 1 + months 98 | year = sourcedate.year + month // 12 99 | month = month % 12 + 1 100 | day = min(sourcedate.day, calendar.monthrange(year, month)[1]) 101 | return datetime.date(year, month, day) 102 | 103 | 104 | def sum_by_map(result_map, category, inventory): 105 | """sum inventory by category""" 106 | 107 | last_token = None 108 | for token in category.split(":"): 109 | if last_token is not None: 110 | last_token = last_token + ":" + token 111 | else: 112 | last_token = token 113 | 114 | if last_token not in result_map: 115 | result_map[last_token] = inventory 116 | else: 117 | result_map[last_token] = result_map[last_token] + inventory 118 | 119 | 120 | def render(periods, sections, report_meta): 121 | """渲染模板""" 122 | 123 | template = pkg_resources.read_text(templates, "balance_sheet.mustache") 124 | 125 | return pystache.render( 126 | template, 127 | { 128 | "periods": periods, 129 | "sections": sections, 130 | "meta": report_meta, 131 | }, 132 | ) 133 | 134 | 135 | class Reporter: 136 | """资产报告生成类""" 137 | 138 | entries = None 139 | option_map = None 140 | layout = None 141 | price_map = None 142 | year = None 143 | month = None 144 | working_currency = None 145 | 146 | def __init__(self, year, month, file, target="balance_sheet"): 147 | self.year = year 148 | self.month = month 149 | (entries, _, option_map) = load_file(file) 150 | self.entries = entries 151 | self.option_map = option_map 152 | 153 | layout = None 154 | for entry in entries: 155 | if isinstance(entry, Custom) is False: 156 | continue 157 | 158 | if entry.type != "finance-statement-option": 159 | continue 160 | 161 | if len(entry.values[0]) == 0: 162 | continue 163 | 164 | if ( 165 | target == "balance_sheet" 166 | and entry.values[0].value == "balance_sheet_layout" 167 | and len(entry.values[0]) == 2 168 | ): 169 | layout = entry.values[1].value 170 | elif ( 171 | target == "income_statement" 172 | and entry.values[0].value == "income_statement_layout" 173 | and len(entry.values[0]) == 2 174 | ): 175 | layout = entry.values[1].value 176 | elif ( 177 | entry.values[0].value == "working_currency" 178 | and len(entry.values[0]) == 2 179 | ): 180 | self.working_currency = entry.values[1].value 181 | 182 | if layout is None: 183 | raise Exception( 184 | "Can't find balance_sheet_layout option, you should place a custom directive in the head of your ledger file" 185 | ) 186 | 187 | layout = os.path.join(str(os.path.dirname(file)), layout) 188 | self.layout = parse_report_layout(layout) 189 | 190 | self.price_map = build_price_map(entries) 191 | 192 | def generate(self): 193 | """Generate Report""" 194 | 195 | (category_map, equity_map, category_accounts_map) = self.__parse_category() 196 | 197 | self.__check_layout(category_map, equity_map) 198 | 199 | (periods, reports) = self.__generate_report_data(category_map, equity_map) 200 | 201 | sections = self.__merge_data_and_layout(reports, category_accounts_map) 202 | 203 | return render( 204 | periods, 205 | sections, 206 | { 207 | "working_currency": self.working_currency, 208 | }, 209 | ) 210 | 211 | def unify_currency(self, inventory): 212 | """统一转换货币""" 213 | 214 | amount = D(0) 215 | for currency in inventory.currency_pairs(): 216 | if currency[0] == self.working_currency: 217 | amount = amount + inventory.get_currency_units(currency[0]).number 218 | else: 219 | amount = ( 220 | amount 221 | + convert_amount( 222 | inventory.get_currency_units(currency[0]), 223 | self.working_currency, 224 | self.price_map, 225 | ).number 226 | ) 227 | 228 | ret = "{:,}".format(amount.copy_abs().quantize(Decimal(".01"))) 229 | if amount < 0: 230 | ret = f"({ret})" 231 | 232 | return ret 233 | 234 | def __balance_report(self, year, month, category_map, equity_map): 235 | close_on = add_months(datetime.datetime(year, month, 1), 1) 236 | 237 | ret = query.run_query( 238 | self.entries, 239 | self.option_map, 240 | f"balances at cost from CLOSE ON {str(close_on)} CLEAR", 241 | ) 242 | 243 | account_balance_map = {} 244 | for balance in ret[1]: 245 | account = balance[0] 246 | inventory = balance[1] 247 | if account not in category_map: 248 | if account.startswith("Assets"): 249 | raise Exception( 250 | f'Assets account "{account}" doesn\'t have balance sheet field' 251 | ) 252 | 253 | if account.startswith("Liabilities"): 254 | raise Exception( 255 | 'Liabilities account "{}" doesn\'t have balance sheet field'.format( 256 | account 257 | ) 258 | ) 259 | continue 260 | 261 | sum_by_map(account_balance_map, category_map[account], inventory) 262 | sum_by_map(account_balance_map, equity_map[account], inventory) 263 | 264 | return account_balance_map 265 | 266 | def __parse_category(self): 267 | category_map = {} 268 | category_accounts_map = {} 269 | equity_map = {} 270 | accounts = get_account_open_close(self.entries) 271 | for name in accounts: 272 | directives = accounts[name] 273 | open_account = None 274 | for directive in directives: 275 | if isinstance(directive, Open): 276 | open_account = directive 277 | break 278 | 279 | if open_account is None: 280 | continue 281 | 282 | if "balance_sheet_category" not in open_account.meta: 283 | continue 284 | 285 | if "equity_category" not in open_account.meta: 286 | continue 287 | 288 | category = open_account.meta["balance_sheet_category"] 289 | category_map[open_account.account] = category 290 | 291 | equity_category = open_account.meta["equity_category"] 292 | equity_map[open_account.account] = equity_category 293 | 294 | if category not in category_accounts_map: 295 | category_accounts_map[category] = [open_account.account] 296 | else: 297 | category_accounts_map[category].append(open_account.account) 298 | 299 | return (category_map, equity_map, category_accounts_map) 300 | 301 | def __check_layout(self, category_map, equity_map): 302 | assert self.layout is not None 303 | 304 | dissociated_categories = set(category_map.values()) 305 | dissociated_categories.update(equity_map.values()) 306 | dissociated_categories = dissociated_categories.difference( 307 | [x["category"] for x in self.layout] 308 | ) 309 | 310 | if len(dissociated_categories) > 0: 311 | dissociated_categories = sorted(dissociated_categories) 312 | raise Exception( 313 | 'Some dissociated categories not included in balance sheet layout file: "{}"'.format( 314 | '", "'.join(dissociated_categories) 315 | ) 316 | ) 317 | 318 | def __generate_report_data(self, category_map, equity_map): 319 | assert self.year is not None 320 | assert self.month is not None 321 | 322 | latest_month = datetime.datetime(self.year, self.month, 1) 323 | 324 | periods = [] 325 | reports = [] 326 | for i in range(12): 327 | report_date = add_months(latest_month, i - 11) 328 | report = self.__balance_report( 329 | report_date.year, report_date.month, category_map, equity_map 330 | ) 331 | reports.append(report) 332 | 333 | periods.append(add_months(report_date, 1) + timedelta(days=-1)) 334 | 335 | return (periods, reports) 336 | 337 | def __merge_data_and_layout(self, reports, category_accounts_map): 338 | assert self.layout is not None 339 | 340 | sections = [] 341 | for line in self.layout: 342 | section = { 343 | "category": line["category"].split(":")[-1], 344 | "class": "level-" + str(line["category"].count(":")), 345 | } 346 | 347 | if line["category"] in category_accounts_map: 348 | section["accounts"] = "\n".join( 349 | sorted(category_accounts_map[line["category"]]) 350 | ) 351 | 352 | if line["category"].count(":") == 0 and not line["show_total"]: 353 | section["category"] = line["category"].upper() 354 | 355 | if line["show_total"]: 356 | section["class"] = section["class"] + " total" 357 | section["category"] = "Total " + section["category"].lower() 358 | 359 | if line["show_amount"]: 360 | section["amounts"] = [] 361 | for report in reports: 362 | if line["category"] in report: 363 | amount = report[line["category"]] 364 | if line["category"].lower().find("liabilities") != -1: 365 | amount = -amount 366 | amount = self.unify_currency(amount) 367 | else: 368 | amount = "0.00" 369 | 370 | section["amounts"].append(amount) 371 | 372 | sections.append(section) 373 | 374 | return sections 375 | --------------------------------------------------------------------------------