├── 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 | Balance Sheet
58 |
59 |
60 | |
61 | As of ({{meta.working_currency}}) |
62 |
63 |
64 | |
65 | {{#periods}}{{year}}.{{month}}.{{day}} | {{/periods}}
66 |
67 |
68 |
69 | {{#sections}}
70 |
71 | | {{category}} |
72 | {{#amounts}}
73 | {{.}} |
74 | {{/amounts}}
75 |
76 | {{/sections}}
77 |
78 |
79 |
80 |
81 |
--------------------------------------------------------------------------------
/beanstatement/templates/income_statement.mustache:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Balance Sheet
5 |
6 |
53 |
54 |
55 |
56 |
57 | Balance Sheet
58 |
59 |
60 | |
61 | As of ({{meta.working_currency}}) |
62 |
63 |
64 | |
65 | {{#periods}}{{year}}.{{month}}.{{day}} | {{/periods}}
66 |
67 |
68 |
69 | {{#sections}}
70 |
71 | | {{category}} |
72 | {{#amounts}}
73 | {{.}} |
74 | {{/amounts}}
75 |
76 | {{/sections}}
77 |
78 |
79 |
80 |
81 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Beancount 财报
2 |
3 | [](https://github.com/e7h4n/beancount-financial-statement/actions/workflows/build.yml)
4 | [](https://github.com/e7h4n/beancount-financial-statement/actions/workflows/lint.yaml)
5 | [](https://coveralls.io/github/e7h4n/beancount-financial-statement?branch=master)
6 | [](https://github.com/e7h4n/beancount-financial-statement/actions/workflows/codeql-analysis.yml)
7 | [](https://badge.fury.io/py/beancount-financial-statement)
8 |
9 | 一个给个人用的财报工具,分析 beancount 账本,自动生成资产负债表。
10 |
11 | ## Demo
12 |
13 | 
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 |
--------------------------------------------------------------------------------