├── .github ├── CODEOWNERS └── workflows │ └── python-app.yml ├── .gitignore ├── LICENSE ├── README.md ├── demo └── personal_finance.py ├── dev-requirements.txt ├── docs ├── Makefile ├── account_config.rst ├── accountant.rst ├── action.rst ├── aging.rst ├── amount_counter.rst ├── balances.rst ├── conf.py ├── event.rst ├── get_balances.rst ├── get_started.rst ├── index.rst ├── journal.rst ├── ledger.rst ├── make.bat ├── misc.rst ├── modules.rst ├── pass_journal_entries.rst ├── setup_accounting_config.rst ├── setup_chart_of_accounts.rst └── setup_events.rst ├── pyluca ├── account_config.py ├── accountant.py ├── action.py ├── aging.py ├── amount_counter.py ├── balances.py ├── event.py ├── journal.py ├── ledger.py ├── recon.py └── tests │ ├── test_accountant.py │ ├── test_action.py │ ├── test_aging.py │ ├── test_amount_counter.py │ ├── test_journal.py │ └── test_ledger.py ├── requirements.txt └── setup.py /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Global Code Owners 2 | * @rahulsekar @pskd73 @adithyadinesh96 3 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python application 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | name: run-tests 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python 3.9 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: "3.9" 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install flake8 pytest 30 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 31 | - name: Lint with flake8 32 | run: | 33 | # stop the build if there are Python syntax errors or undefined names 34 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 35 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 36 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 37 | - name: Test with pytest 38 | run: | 39 | python -m unittest pyluca/tests/test_* 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | pyluca.egg-info 3 | venv 4 | docs/_build 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Datasigns Technologies 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyluca 2 | A headless python double entry accounting package. It is python Luca Pacioli :) 3 | It is a plug and play python module, which does NOT come with any server or databases. 4 | It helps you to build your own application using core double entry accounting (pyluca) 5 | module. 6 | 7 | Read the [documentation here](https://pyluca.readthedocs.io) 8 | 9 | ## Usage 10 | On a high level, you just need to do following steps to start using it. 11 | 1. Setup accounting configuration 12 | 2. Pass journal entries 13 | 3. Get balances 14 | 15 | ## Example 16 | **You need to have basic accounting/bookkeeping knowledge to set up the configuration** 17 | 18 | Checkout out `demo/` for examples. You can event checkout `pyluca/tests` for advance usages. 19 | 1. Basic accounting configuration - `demo/personal_finance.py` 20 | 2. Creating an accountant and passing journal entries - `demo/personal_finance.py` 21 | 3. Creating events and configuring actions - `demo/personal_finance.py` 22 | 4. Ledger usage and as of any "time" - `demo/personal_finance.py` 23 | -------------------------------------------------------------------------------- /demo/personal_finance.py: -------------------------------------------------------------------------------- 1 | from pyluca.accountant import Accountant 2 | from pyluca.action import apply 3 | from pyluca.event import Event 4 | from pyluca.journal import Journal 5 | from pyluca.ledger import Ledger 6 | from datetime import datetime 7 | 8 | 9 | ''' 10 | Basic usage 11 | ''' 12 | 13 | config = { 14 | 'account_types': { 15 | 'ASSET': { 16 | 'balance_type': 'DEBIT' 17 | }, 18 | 'INCOME': { 19 | 'balance_type': 'CREDIT' 20 | }, 21 | 'LIABILITY': { 22 | 'balance_type': 'CREDIT' 23 | }, 24 | 'EXPENSE': { 25 | 'balance_type': 'DEBIT' 26 | } 27 | }, 28 | 'accounts': { 29 | 'SALARY': {'type': 'INCOME'}, 30 | 'SAVINGS_BANK': {'type': 'ASSET'}, 31 | 'MUTUAL_FUNDS': {'type': 'ASSET'}, 32 | 'LOANS': {'type': 'ASSET'}, 33 | 'CAR_EMI': {'type': 'EXPENSE'} 34 | }, 35 | 'rules': {} 36 | } 37 | 38 | accountant = Accountant(Journal(), config, 'person1') 39 | accountant.enter_journal('SAVINGS_BANK', 'SALARY', 20000, datetime(2022, 4, 30), 'March salary') 40 | accountant.enter_journal('MUTUAL_FUNDS', 'SAVINGS_BANK', 10000, datetime(2022, 5, 1), 'Invest in NIFTY 50 Index') 41 | accountant.enter_journal('CAR_EMI', 'SAVINGS_BANK', 5000, datetime(2022, 5, 5), '5th EMI') 42 | accountant.enter_journal('LOANS', 'SAVINGS_BANK', 3000, datetime(2022, 5, 5), 'Lend to Kalyan') 43 | 44 | ledger = Ledger(accountant.journal, accountant.config) 45 | assert ledger.get_account_balance('SAVINGS_BANK') == 2000 46 | assert ledger.get_account_balance('SALARY') == 20000 47 | assert ledger.get_account_balance('LOANS') == 3000 48 | assert ledger.get_account_balance('CAR_EMI') == 5000 49 | 50 | accountant.enter_journal('SAVINGS_BANK', 'LOANS', 2000, datetime(2022, 5, 15), 'Partial payback') 51 | ledger = Ledger(accountant.journal, accountant.config) 52 | assert ledger.get_account_balance('LOANS') == 1000 53 | assert ledger.get_account_balance('SAVINGS_BANK') == 4000 54 | 55 | # get pandas dataframe 56 | ledger.get_df() 57 | ''' 58 | sl_no account dr_amount ... date narration key 59 | 0 0 SAVINGS_BANK 20000 ... 2022-04-30 March salary person1 60 | 1 1 SALARY 0 ... 2022-04-30 March salary person1 61 | 2 2 MUTUAL_FUNDS 10000 ... 2022-05-01 Invest in NIFTY 50 Index person1 62 | 3 3 SAVINGS_BANK 0 ... 2022-05-01 Invest in NIFTY 50 Index person1 63 | 4 4 CAR_EMI 5000 ... 2022-05-05 5th EMI person1 64 | 5 5 SAVINGS_BANK 0 ... 2022-05-05 5th EMI person1 65 | 6 6 LOANS 3000 ... 2022-05-05 Lend to Kalyan person1 66 | 7 7 SAVINGS_BANK 0 ... 2022-05-05 Lend to Kalyan person1 67 | 8 8 SAVINGS_BANK 2000 ... 2022-05-15 Partial payback person1 68 | 9 9 LOANS 0 ... 2022-05-15 Partial payback person1 69 | 70 | [10 rows x 7 columns] 71 | ''' 72 | 73 | # get the balance sheet 74 | ledger.get_balance_sheet() 75 | ''' 76 | sl_no account dr_amount ... MUTUAL_FUNDS LOANS CAR_EMI 77 | 0 0 SAVINGS_BANK 20000 ... 0 0 0 78 | 1 1 SALARY 0 ... 0 0 0 79 | 2 2 MUTUAL_FUNDS 10000 ... 10000 0 0 80 | 3 3 SAVINGS_BANK 0 ... 10000 0 0 81 | 4 4 CAR_EMI 5000 ... 10000 0 5000 82 | 5 5 SAVINGS_BANK 0 ... 10000 0 5000 83 | 6 6 LOANS 3000 ... 10000 3000 5000 84 | 7 7 SAVINGS_BANK 0 ... 10000 3000 5000 85 | 8 8 SAVINGS_BANK 2000 ... 10000 3000 5000 86 | 9 9 LOANS 0 ... 10000 1000 5000 87 | 88 | [10 rows x 12 columns] 89 | ''' 90 | 91 | 92 | ''' 93 | Events & Actions 94 | ''' 95 | 96 | 97 | class AmountEvent(Event): 98 | """ 99 | An abstract Event which contains amount in it 100 | """ 101 | def __init__( 102 | self, 103 | event_id: str, 104 | amount: float, 105 | date: datetime, 106 | created_date: datetime, 107 | created_by: str = None 108 | ): 109 | self.amount = amount 110 | super(AmountEvent, self).__init__(event_id, date, created_date, created_by) 111 | 112 | 113 | class SalaryEvent(AmountEvent): 114 | pass 115 | 116 | 117 | class InvestMutualFundEvent(AmountEvent): 118 | pass 119 | 120 | 121 | class LendEvent(AmountEvent): 122 | pass 123 | 124 | 125 | class CollectionEvent(AmountEvent): 126 | pass 127 | 128 | 129 | config_dict = { 130 | **config, # Just extending above config 131 | 'actions_config': { 132 | 'on_event': { 133 | 'SalaryEvent': { 134 | 'actions': [ 135 | { 136 | 'dr_account': 'SAVINGS_BANK', 137 | 'cr_account': 'SALARY', 138 | 'amount': 'amount', 139 | 'narration': 'Salary credit' 140 | } 141 | ] 142 | }, 143 | 'InvestMutualFundEvent': { 144 | 'actions': [ 145 | { 146 | 'dr_account': 'MUTUAL_FUNDS', 147 | 'cr_account': 'SAVINGS_BANK', 148 | 'amount': 'amount', 149 | 'narration': 'Invest in mutual funds' 150 | } 151 | ] 152 | }, 153 | 'LendEvent': { 154 | 'actions': [ 155 | { 156 | 'dr_account': 'LOANS', 157 | 'cr_account': 'SAVINGS_BANK', 158 | 'amount': 'amount', 159 | 'narration': 'Lend' 160 | } 161 | ] 162 | }, 163 | 'CollectionEvent': { 164 | 'actions': [ 165 | { 166 | 'dr_account': 'SAVINGS_BANK', 167 | 'cr_account': 'LOANS', 168 | 'amount': 'amount', 169 | 'narration': 'Collection for the loan' 170 | } 171 | ] 172 | } 173 | } 174 | } 175 | } 176 | 177 | events = [ 178 | SalaryEvent('salary', 20000, datetime(2022, 4, 30), datetime(2022, 4, 30)), 179 | InvestMutualFundEvent('mf-1', 10000, datetime(2022, 5, 2), datetime(2022, 5, 2)), 180 | LendEvent('lend-1', 5000, datetime(2022, 5, 4), datetime(2022, 5, 4)) 181 | ] 182 | 183 | accountant = Accountant(Journal(), config_dict, 'person-1') 184 | for event in events: 185 | apply(event, accountant) 186 | 187 | ledger = Ledger(accountant.journal, accountant.config) 188 | 189 | assert ledger.get_account_balance('SALARY') == 20000 190 | assert ledger.get_account_balance('MUTUAL_FUNDS') == 10000 191 | assert ledger.get_account_balance('LOANS') == 5000 192 | 193 | events = [ 194 | CollectionEvent('coll-1', 3000, datetime(2022, 5, 20), datetime(2022, 5, 20)), 195 | ] 196 | for event in events: 197 | apply(event, accountant) 198 | assert ledger.get_account_balance('LOANS') == 2000 199 | 200 | 201 | ''' 202 | Ledger as of some time in past 203 | ''' 204 | 205 | 206 | def get_ledger_as_of(acctnt: Accountant, as_of: datetime): 207 | return Ledger(Journal([e for e in acctnt.journal.entries if e.date <= as_of]), acctnt.config) 208 | 209 | 210 | assert get_ledger_as_of(accountant, datetime(2022, 4, 29)).get_account_balance('SAVINGS_BANK') == 0 211 | assert get_ledger_as_of(accountant, datetime(2022, 4, 30)).get_account_balance('SAVINGS_BANK') == 20000 212 | assert get_ledger_as_of(accountant, datetime(2022, 5, 2)).get_account_balance('SAVINGS_BANK') == 10000 213 | assert get_ledger_as_of(accountant, datetime(2022, 5, 4)).get_account_balance('SAVINGS_BANK') == 5000 214 | assert get_ledger_as_of(accountant, datetime(2022, 5, 20)).get_account_balance('SAVINGS_BANK') == 8000 215 | 216 | assert get_ledger_as_of(accountant, datetime(2022, 5, 3, 23, 59, 59, 999)).get_account_balance('LOANS') == 0 217 | assert get_ledger_as_of(accountant, datetime(2022, 5, 4, 0, 0, 0, 1)).get_account_balance('LOANS') == 5000 218 | assert get_ledger_as_of(accountant, datetime(2022, 5, 20)).get_account_balance('LOANS') == 2000 219 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx-rtd-theme==1.2.0 2 | Sphinx==5.3.0 3 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/account_config.rst: -------------------------------------------------------------------------------- 1 | account\_config module 2 | ====================== 3 | 4 | .. automodule:: pyluca.account_config 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/accountant.rst: -------------------------------------------------------------------------------- 1 | accountant 2 | ================= 3 | 4 | .. automodule:: pyluca.accountant 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/action.rst: -------------------------------------------------------------------------------- 1 | action module 2 | ============= 3 | 4 | .. automodule:: pyluca.action 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/aging.rst: -------------------------------------------------------------------------------- 1 | aging module 2 | ============ 3 | 4 | .. automodule:: pyluca.aging 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/amount_counter.rst: -------------------------------------------------------------------------------- 1 | amount\_counter module 2 | ====================== 3 | 4 | .. automodule:: pyluca.amount_counter 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/balances.rst: -------------------------------------------------------------------------------- 1 | balances module 2 | =============== 3 | 4 | .. automodule:: pyluca.balances 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | import os 10 | import sys 11 | sys.path.insert(0, os.path.abspath('..')) 12 | 13 | 14 | project = 'pyluca' 15 | copyright = '2023, Datasigns Technologies' 16 | author = 'Datasigns Technologies' 17 | 18 | # -- General configuration --------------------------------------------------- 19 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 20 | 21 | extensions = ['sphinx.ext.todo', 'sphinx.ext.viewcode', 'sphinx.ext.autodoc'] 22 | 23 | templates_path = ['_templates'] 24 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 25 | 26 | 27 | 28 | # -- Options for HTML output ------------------------------------------------- 29 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 30 | 31 | html_theme = 'sphinx_rtd_theme' 32 | html_static_path = ['_static'] 33 | -------------------------------------------------------------------------------- /docs/event.rst: -------------------------------------------------------------------------------- 1 | event module 2 | ============ 3 | 4 | .. automodule:: pyluca.event 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/get_balances.rst: -------------------------------------------------------------------------------- 1 | Get balances 2 | ============ 3 | 4 | One of the common functionality that we would like to apply on DEAS is to get balance of an account. :class:`~pyluca.ledger.Ledger` provides handy functions which are aggregations on a :class:`~pyluca.journal.Journal`. Following is an example 5 | 6 | .. code-block:: python 7 | 8 | import Journal, Accountant, Ledger from pyluca 9 | 10 | journal = Journal() 11 | config = {...} 12 | accountant = Accountant(journal, config) 13 | accountant.enter_journal( 14 | dr_account='SAVINGS_BANK', 15 | cr_account='SALARY', 16 | amount=20000, 17 | date=datetime(2022, 4, 30), 18 | narration='March salary' 19 | ) 20 | 21 | ledger = Ledger(journal, config) 22 | print(ledger.get_account_balance('SAVINGS_BANK')) # 20000 23 | 24 | 25 | :class:`~pyluca.ledger.Ledger` takes care of the account type and does the calculation accordingly -------------------------------------------------------------------------------- /docs/get_started.rst: -------------------------------------------------------------------------------- 1 | Get started 2 | =========== 3 | 4 | pyluca is built by a financial institution that lets you set up a double-entry accounting system (DEAS) for any purpose. Assuming the readers know about what is DEAS, the building blocks of the system are Accounts, Journal Entries, Ledger & Journals, etc. 5 | 6 | Before going deep, run the following install command 7 | 8 | .. code-block:: bash 9 | 10 | pip install git+https://github.com/datasignstech/pyluca 11 | 12 | 13 | Next steps you need to do 14 | 15 | 1. :doc:`setup_accounting_config` 16 | 17 | 2. :doc:`setup_chart_of_accounts` 18 | 19 | 3. :doc:`pass_journal_entries` 20 | 21 | 4. :doc:`get_balances` 22 | 23 | 5. :doc:`misc` 24 | 25 | 6. :doc:`setup_events` 26 | 27 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. pyluca documentation master file, created by 2 | sphinx-quickstart on Mon Feb 27 22:31:48 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to pyluca's documentation! 7 | ================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents 12 | 13 | 14 | get_started 15 | setup_accounting_config 16 | setup_chart_of_accounts 17 | pass_journal_entries 18 | get_balances 19 | misc 20 | setup_events 21 | modules 22 | 23 | 24 | 25 | Indices and tables 26 | ================== 27 | 28 | * :ref:`genindex` 29 | * :ref:`modindex` 30 | * :ref:`search` 31 | -------------------------------------------------------------------------------- /docs/journal.rst: -------------------------------------------------------------------------------- 1 | journal module 2 | ============== 3 | 4 | .. automodule:: pyluca.journal 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/ledger.rst: -------------------------------------------------------------------------------- 1 | ledger module 2 | ============= 3 | 4 | .. automodule:: pyluca.ledger 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/misc.rst: -------------------------------------------------------------------------------- 1 | Misc 2 | ============ 3 | 4 | Other than the functionalities that we discussed in previous sections, **pyluca** gives more features which come handy in terms of financing. Following are few 5 | 6 | Balance sheet 7 | ************* 8 | 9 | :meth:`~pyluca.ledger.Ledger.get_balance_sheet()` provides you a DataFrame (*pandas*) of balances of each account (as columns) as of each journal entry 10 | 11 | 12 | Balances 13 | ******** 14 | 15 | :meth:`~pyluca.ledger.Ledger.get_balances()` provides you a dict of balances. Key would be the account and value is the balance 16 | 17 | 18 | Aging 19 | ***** 20 | 21 | Often, it is important to know how the balance in an account is decreased. In other words, how the positive entries age. You can use :meth:`~pyluca.ledger.Ledger.get_aging()` method to get it. It gives detailed information -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | API 2 | ====== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | account_config 8 | accountant 9 | action 10 | aging 11 | amount_counter 12 | balances 13 | event 14 | journal 15 | ledger 16 | -------------------------------------------------------------------------------- /docs/pass_journal_entries.rst: -------------------------------------------------------------------------------- 1 | Pass Journal Entries 2 | ==================== 3 | 4 | Once we have the *config* ready, we are all set to start passing the journal entries. To pass the journal entries, we need 5 | 6 | 1. A :class:`~pyluca.journal.Journal` on which the entries are added as a sequence ordered by date 7 | 8 | 2. A :class:`~pyluca.accountant.Accountant` which takes the Journal and config 9 | 10 | Here is the example 11 | 12 | .. code-block:: python 13 | 14 | import Journal, Accountant from pyluca 15 | 16 | journal = Journal() 17 | config = {...} 18 | accountant = Accountant(journal, config, key='user_1') # key will be passed to journal entries so that they can be grouped 19 | accountant.enter_journal( 20 | dr_account='SAVINGS_BANK', 21 | cr_account='SALARY', 22 | amount=20000, 23 | date=datetime(2022, 4, 30), 24 | narration='March salary' 25 | ) 26 | 27 | We successfully entered our first journal entry. Accountant maintains a serial number for each entry. You cannot enter back dated entries -------------------------------------------------------------------------------- /docs/setup_accounting_config.rst: -------------------------------------------------------------------------------- 1 | Setup Accounting Config 2 | ======================== 3 | 4 | First step in the process is to setup the very basic accounting. There are four types of accounts 5 | 6 | 1. ASSET - Debit type 7 | 8 | 2. EXPENSE - Debit type 9 | 10 | 3. INCOME - Credit type 11 | 12 | 4. LIABILITY - Credit type 13 | 14 | We need to set this up in a config dict which will be later used to pass the journal entries or anything else for that matter. 15 | 16 | .. code-block:: python 17 | 18 | config = { 19 | 'account_types': { 20 | 'ASSET': { 21 | 'balance_type': 'DEBIT' 22 | }, 23 | 'EXPENSE': { 24 | 'balance_type': 'DEBIT' 25 | }, 26 | 'LIABILITY': { 27 | 'balance_type': 'CREDIT' 28 | }, 29 | 'INCOME': { 30 | 'balance_type': 'CREDIT' 31 | } 32 | } 33 | } 34 | 35 | 36 | We will be carrying forward this config in the next section -------------------------------------------------------------------------------- /docs/setup_chart_of_accounts.rst: -------------------------------------------------------------------------------- 1 | Setup Chart of Accounts 2 | ======================== 3 | 4 | After setting up the types of accounts, we need to setup th chart of accounts that is specific to the business. This purely depends on the requirement from the finance perspective. 5 | 6 | To demonstrate, we will be setting up accounts for personal finance management. We will add *accounts* in the config we built in the previous section 7 | 8 | .. code-block:: python 9 | 10 | config = { 11 | 'account_types': ..., 12 | 'accounts': { 13 | 'SALARY': {'type': 'INCOME'}, 14 | 'SAVINGS_BANK': {'type': 'ASSET'}, 15 | 'MUTUAL_FUNDS': {'type': 'ASSET'}, 16 | 'LOANS': {'type': 'ASSET'}, 17 | 'CAR_EMI': {'type': 'EXPENSE'} 18 | } 19 | } 20 | 21 | It is recommended to maintain this config as a *json* instead of python dict so that the configuration is plain text but not any computation. -------------------------------------------------------------------------------- /docs/setup_events.rst: -------------------------------------------------------------------------------- 1 | Setup Events 2 | ============= 3 | 4 | Writing custom :meth:`~pyluca.accountant.Accountant.enter_journal()` will grow out of control once we have multiple types of journal entries. Which will ultimately lead to confusion if we don't group them correctly and maintain. 5 | 6 | In real world, the journal entries are not the direct actions/events that happen. They are **results** of real world **events**. Taking inspiration from this analogy, pyluca supports configuring different types of :class:`~pyluca.event.Event`. 7 | 8 | Separately, you can configure a set of journal entries to be passed for each type of event. 9 | 10 | Let us understand it from an example 11 | 12 | .. code-block:: python 13 | 14 | import Event from pyluca 15 | 16 | class AmountEvent(Event): 17 | """ 18 | An abstract Event which contains amount in it. 19 | Every event contains date at which it occurred 20 | """ 21 | def __init__( 22 | self, 23 | event_id: str, 24 | amount: float, 25 | date: datetime, 26 | created_date: datetime, 27 | created_by: str = None 28 | ): 29 | self.amount = amount 30 | super(AmountEvent, self).__init__(event_id, date, created_date, created_by) 31 | 32 | 33 | class SalaryEvent(AmountEvent): 34 | pass 35 | 36 | 37 | class InvestMutualFundEvent(AmountEvent): 38 | pass 39 | 40 | 41 | class LendEvent(AmountEvent): 42 | pass 43 | 44 | 45 | class CollectionEvent(AmountEvent): 46 | pass 47 | 48 | 49 | We just setup the events that happen in the real world. They are more intuitive to understand. Now we will configure the journal entries to be passed for each type of event. We just append following config to the existing config 50 | 51 | 52 | .. code-block:: python 53 | 54 | config_dict = { 55 | ... # config as explained in previous sections 56 | 'actions_config': { 57 | 'on_event': { 58 | 'SalaryEvent': { 59 | 'actions': [ 60 | { 61 | 'dr_account': 'SAVINGS_BANK', 62 | 'cr_account': 'SALARY', 63 | 'amount': 'amount', 64 | 'narration': 'Salary credit' 65 | } 66 | ] 67 | }, 68 | 'InvestMutualFundEvent': { 69 | 'actions': [ 70 | { 71 | 'dr_account': 'MUTUAL_FUNDS', 72 | 'cr_account': 'SAVINGS_BANK', 73 | 'amount': 'amount', 74 | 'narration': 'Invest in mutual funds' 75 | } 76 | ] 77 | }, 78 | 'LendEvent': { 79 | 'actions': [ 80 | { 81 | 'dr_account': 'LOANS', 82 | 'cr_account': 'SAVINGS_BANK', 83 | 'amount': 'amount', 84 | 'narration': 'Lend' 85 | } 86 | ] 87 | }, 88 | 'CollectionEvent': { 89 | 'actions': [ 90 | { 91 | 'dr_account': 'SAVINGS_BANK', 92 | 'cr_account': 'LOANS', 93 | 'amount': 'amount', 94 | 'narration': 'Collection for the loan' 95 | } 96 | ] 97 | } 98 | } 99 | } 100 | } 101 | 102 | 103 | We are all set to construct the events and apply them. This can be done by :meth:`~pyluca.action.apply()` method as below 104 | 105 | .. code-block:: python 106 | 107 | events = [ 108 | SalaryEvent('salary', 20000, datetime(2022, 4, 30), datetime(2022, 4, 30)), 109 | InvestMutualFundEvent('mf-1', 10000, datetime(2022, 5, 2), datetime(2022, 5, 2)), 110 | LendEvent('lend-1', 5000, datetime(2022, 5, 4), datetime(2022, 5, 4)) 111 | ] 112 | 113 | accountant = Accountant(Journal(), config_dict, 'person-1') 114 | for event in events: 115 | apply(event, accountant) 116 | 117 | We just apply the event whenever it happens and the result would be 118 | 119 | .. code-block:: python 120 | 121 | ledger = Ledger(accountant.journal, accountant.config) 122 | 123 | assert ledger.get_account_balance('SALARY') == 20000 124 | assert ledger.get_account_balance('MUTUAL_FUNDS') == 10000 125 | assert ledger.get_account_balance('LOANS') == 5000 126 | 127 | This is more close to the real-world. In simple words, events turned into journal entries! -------------------------------------------------------------------------------- /pyluca/account_config.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class BalanceType(Enum): 5 | CREDIT = 'CREDIT' 6 | DEBIT = 'DEBIT' 7 | -------------------------------------------------------------------------------- /pyluca/accountant.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from typing import Optional 4 | from pyluca.journal import Journal, JournalEntry 5 | from pyluca.ledger import Ledger 6 | 7 | 8 | class Accountant: 9 | def __init__(self, journal: Journal, config: dict, key: str): 10 | self.journal = journal 11 | self.config = config 12 | self.key = key 13 | self.ledger = Ledger(journal, config, key) 14 | 15 | def enter_journal( 16 | self, 17 | dr_account: str, 18 | cr_account: str, 19 | amount: float, 20 | date: datetime.datetime, 21 | narration: str, 22 | event_id: Optional[str] = None 23 | ): 24 | if amount == 0: 25 | return 26 | self.journal.add_entry( 27 | JournalEntry(len(self.journal.entries), dr_account, amount, 0, date, narration, self.key, event_id)) 28 | self.journal.add_entry( 29 | JournalEntry(len(self.journal.entries), cr_account, 0, amount, date, narration, self.key, event_id)) 30 | self.ledger.add_entry(dr_account, cr_account, amount, date, narration, event_id) 31 | 32 | def record( 33 | self, 34 | rule: str, 35 | amount: float, 36 | date: datetime.datetime, 37 | note: str = '', 38 | meta: dict = None, 39 | event_id: Optional[str] = None 40 | ): 41 | rule = self.config['rules'][rule] 42 | narration = f'{rule["narration"]} {note}' 43 | if meta: 44 | narration = f'{narration} ##{json.dumps(meta)}##' 45 | if amount > 0: 46 | self.enter_journal( 47 | rule['dr_account'], 48 | rule['cr_account'], 49 | amount, 50 | date, 51 | narration, 52 | event_id 53 | ) 54 | 55 | def adjust(self, dr_acct: str, cr_acct: str, amount: float, date: datetime.datetime): 56 | self.enter_journal(dr_acct, cr_acct, amount, date, 'Reconcile adjust') 57 | -------------------------------------------------------------------------------- /pyluca/action.py: -------------------------------------------------------------------------------- 1 | import re 2 | import json 3 | from typing import Union 4 | from pyluca.accountant import Accountant 5 | from pyluca.event import Event 6 | 7 | _OPERATOR_CONFIG = { 8 | '*': lambda a, b: a * b, 9 | '+': lambda a, b: a + b, 10 | '-': lambda a, b: a - b, 11 | '/': lambda a, b: a / b, 12 | 'min': lambda a, b: min(a, b), 13 | 'max': lambda a, b: max(a, b), 14 | '==': lambda a, b: a == b, 15 | '!=': lambda a, b: a != b, 16 | '!': lambda a, b: not a, 17 | '>': lambda a, b: a > b, 18 | '<': lambda a, b: a < b, 19 | '>=': lambda a, b: a >= b, 20 | '<=': lambda a, b: a <= b 21 | } 22 | 23 | 24 | def _apply_operator(operator: dict, event: Event, accountant: Accountant, context: dict): 25 | return _OPERATOR_CONFIG[operator['type']]( 26 | _get_param(operator['a'], event, accountant, context), 27 | _get_param(operator.get('b'), event, accountant, context) 28 | ) 29 | 30 | 31 | def _get_param( 32 | key: Union[str, list, dict], 33 | event: Event, 34 | accountant: Accountant, 35 | context: dict 36 | ): 37 | if key is None: 38 | return None 39 | if type(key) in [int, float]: 40 | return key 41 | if isinstance(key, dict) and key.get('type'): 42 | return _apply_operator(key, event, accountant, context) 43 | if key.startswith('str.'): 44 | return key.replace('str.', '') 45 | if key.startswith('context.'): 46 | next_key = key.replace('context.', '') 47 | return _get_param(context[next_key], event, accountant, context) 48 | if key.startswith('balance.'): 49 | return accountant.ledger.get_account_balance(key.replace('balance.', '')) 50 | if hasattr(event, key): 51 | return event.__getattribute__(key) 52 | raise NotImplementedError(f'param {key} not implemented') 53 | 54 | 55 | def _parse_narration(narration: str, event: Event, accountant: Accountant, context: dict): 56 | matches = re.findall("\{([^}]+)\}", narration) 57 | if matches: 58 | for match in matches: 59 | narration = re.sub(match, _get_param(match, event, accountant, context), narration) 60 | narration = re.sub(r'[{}]', '', narration) 61 | return narration 62 | 63 | 64 | def _get_narration(action: dict, event: Event, accountant: Accountant, context: dict): 65 | narration = _parse_narration(action['narration'], event, accountant, context) 66 | if action.get('meta'): 67 | meta = {k: _get_param(v, event, accountant, context) for k, v in action['meta'].items()} 68 | narration = f'{narration} ##{json.dumps(meta)}##' 69 | return narration 70 | 71 | 72 | def _apply_action( 73 | action: dict, 74 | event: Event, 75 | accountant: Accountant, 76 | context: dict, 77 | common_actions: dict, 78 | external_actions: dict 79 | ): 80 | if action.get('iff') and not _get_param(action['iff'], event, accountant, context): 81 | return 82 | action_type = action.get('type', 'je') 83 | if action_type == 'je': 84 | accountant.enter_journal( 85 | action['dr_account'], 86 | action['cr_account'], 87 | _get_param(action['amount'], event, accountant, context), 88 | event.date, 89 | _get_narration(action, event, accountant, context), 90 | event.event_id 91 | ) 92 | elif action_type.startswith('action.'): 93 | for sub_action in common_actions[action_type.replace('action.', '')]['actions']: 94 | _apply_action( 95 | sub_action, event, accountant, 96 | {**context, **action.get('context', {})}, 97 | common_actions, 98 | external_actions 99 | ) 100 | elif action_type.startswith('external_action.'): 101 | kwargs = {k: _get_param(v, event, accountant, context) for k, v in action.get('context', {}).items()} 102 | external_actions[action_type.replace('external_action.', '')](**kwargs) 103 | else: 104 | raise NotImplementedError(f'"{action_type}" is not a valid action type!') 105 | 106 | 107 | def apply(event: Event, accountant: Accountant, context: dict = None, external_actions: dict = None): 108 | context = context if context else {} 109 | event_config = accountant.config['actions_config']['on_event'][event.__class__.__name__] 110 | common_actions = accountant.config['actions_config'].get('common_actions', {}) 111 | external_actions = external_actions if external_actions else {} 112 | for action in event_config['actions']: 113 | _apply_action(action, event, accountant, context, common_actions, external_actions) 114 | -------------------------------------------------------------------------------- /pyluca/aging.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from datetime import datetime 4 | from typing import NamedTuple, List, Optional, Dict, Tuple 5 | from pyluca.account_config import BalanceType 6 | from pyluca.journal import JournalEntry 7 | from pyluca.amount_counter import AmountCounter 8 | 9 | 10 | class AccountAge(NamedTuple): 11 | date: datetime 12 | counter: AmountCounter 13 | meta: Optional[dict] 14 | journal_entry: JournalEntry 15 | 16 | 17 | class AccountAging: 18 | def __init__( 19 | self, 20 | account: str, 21 | ages: List[AccountAge], 22 | excess_amount: float, 23 | last_sl_no: int, 24 | last_unpaid_age_idx: int = 0 25 | ): 26 | self.account = account 27 | self.ages = ages 28 | self.excess_amount = excess_amount 29 | self.last_sl_no = last_sl_no 30 | self.last_unpaid_age_idx = last_unpaid_age_idx 31 | 32 | 33 | def __pay_counters( 34 | ages: List[AccountAge], 35 | amount: float, 36 | date: datetime, 37 | entry: JournalEntry, 38 | last_unpaid_age_idx 39 | ) -> Tuple[float, int]: 40 | if len(ages) == 0: 41 | return amount, last_unpaid_age_idx 42 | if amount == 0: 43 | return 0, last_unpaid_age_idx 44 | rem_amount = amount 45 | while rem_amount > 0 and last_unpaid_age_idx < len(ages): 46 | _, rem_amount = ages[last_unpaid_age_idx].counter.pay(rem_amount, date, {'entry': entry.__dict__}) 47 | if ages[last_unpaid_age_idx].counter.is_paid(): 48 | last_unpaid_age_idx += 1 49 | else: 50 | break 51 | return rem_amount, last_unpaid_age_idx 52 | 53 | 54 | def __update_account_aging(account_balance_type: str, entry: JournalEntry, aging: AccountAging): 55 | positive_amount = entry.cr_amount if account_balance_type == BalanceType.CREDIT.value else entry.dr_amount 56 | negative_amount = entry.dr_amount if account_balance_type == BalanceType.CREDIT.value else entry.cr_amount 57 | if positive_amount > 0: 58 | meta = re.match('.*##(.*)##.*', entry.narration) 59 | aging.ages.append( 60 | AccountAge( 61 | entry.date, 62 | AmountCounter(positive_amount), 63 | json.loads(meta.group(1)) if meta else None, 64 | entry 65 | ) 66 | ) 67 | aging.excess_amount, aging.last_unpaid_age_idx = __pay_counters( 68 | aging.ages, 69 | aging.excess_amount + negative_amount, 70 | entry.date, 71 | entry, 72 | aging.last_unpaid_age_idx 73 | ) 74 | aging.last_sl_no = entry.sl_no 75 | 76 | 77 | def get_account_aging( 78 | config: dict, 79 | entries: List[JournalEntry], 80 | account: str, 81 | as_of: datetime, 82 | previous_aging: AccountAging = None 83 | ) -> AccountAging: 84 | def should_entry_applied(e: JournalEntry) -> bool: 85 | return e.date <= as_of and e.account == account \ 86 | and (previous_aging is None or e.sl_no > previous_aging.last_sl_no) 87 | 88 | aging = previous_aging 89 | if aging is None: 90 | aging = AccountAging(account, [], 0, -1) 91 | 92 | if aging.account != account: 93 | raise ValueError('Invalid previous aging! account not matching') 94 | 95 | account_type = config['accounts'][account]['type'] 96 | account_balance_type = config['account_types'][account_type]['balance_type'] 97 | for entry in entries: 98 | if not should_entry_applied(entry): 99 | continue 100 | __update_account_aging(account_balance_type, entry, aging) 101 | return aging 102 | 103 | 104 | def get_accounts_aging( 105 | config: dict, 106 | entries: List[JournalEntry], 107 | accounts: List[str], 108 | as_of: datetime, 109 | previous_aging: Dict[str, AccountAging] = None 110 | ) -> Dict[str, AccountAging]: 111 | aging = previous_aging 112 | if aging is None: 113 | aging = {} 114 | for account in accounts: 115 | aging[account] = AccountAging(account, [], 0, -1) 116 | 117 | if not all([aging.get(acc) for acc in accounts]): 118 | raise ValueError('Invalid previous aging! accounts not matching') 119 | 120 | def should_entry_applied(entry: JournalEntry) -> bool: 121 | return entry.date <= as_of and aging.get(entry.account) is not None \ 122 | and (previous_aging is None or entry.sl_no > previous_aging[entry.account].last_sl_no) 123 | 124 | entries = entries[max([aging.last_sl_no for aging in previous_aging.values()]) + 1:] if previous_aging else entries 125 | for entry in entries: 126 | if not should_entry_applied(entry): 127 | continue 128 | account_type = config['accounts'][entry.account]['type'] 129 | account_balance_type = config['account_types'][account_type]['balance_type'] 130 | __update_account_aging(account_balance_type, entry, aging[entry.account]) 131 | return aging 132 | -------------------------------------------------------------------------------- /pyluca/amount_counter.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from abc import abstractmethod 3 | from typing import List, Optional, Tuple 4 | 5 | 6 | TOLERANCE_FLOATING = 1e-5 7 | 8 | 9 | class AccountPayment: 10 | def __init__(self, amount: float, date: datetime, meta: dict = None): 11 | self.amount = amount 12 | self.date = date 13 | self.meta = meta 14 | 15 | 16 | class AccountWriterInterface: 17 | @abstractmethod 18 | def write(self, amount: float, date: datetime, due_date: datetime): 19 | pass 20 | 21 | 22 | class AmountCounterInterface: 23 | @abstractmethod 24 | def pay(self, amount: float, date: datetime, meta: dict = None) -> Tuple[Optional[AccountPayment], float]: 25 | pass 26 | 27 | 28 | class AmountCounter(AmountCounterInterface): 29 | def __init__(self, total_amount: float, tolerance: float = TOLERANCE_FLOATING): 30 | self.total_amount = total_amount 31 | self.tolerance = tolerance 32 | self.paid_amount = 0 33 | self.payments: List[AccountPayment] = [] 34 | 35 | def add(self, amount: float): 36 | self.total_amount += amount 37 | 38 | def pay(self, amount: float, date: datetime, meta: dict = None): 39 | if amount < 0: 40 | raise ValueError('Pay amount should not be less than 0') 41 | if self.is_paid(): 42 | return None, amount 43 | possible_pay_amount = min(self.get_balance(), amount) 44 | if possible_pay_amount > 0: 45 | payment = AccountPayment(possible_pay_amount, date, meta) 46 | self.payments.append(payment) 47 | self.paid_amount += possible_pay_amount 48 | return payment, amount - possible_pay_amount 49 | return None, amount 50 | 51 | def get_balance(self): 52 | return self.total_amount - self.paid_amount 53 | 54 | def is_paid(self): 55 | return abs(self.get_balance()) < self.tolerance 56 | 57 | def get_paid_amount(self): 58 | return self.paid_amount 59 | -------------------------------------------------------------------------------- /pyluca/balances.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from pyluca.account_config import BalanceType 3 | 4 | 5 | def add_account_balance(config: dict, ledger: pd.DataFrame, account: str) -> pd.DataFrame: 6 | account_type = config['accounts'][account]['type'] 7 | positive_col, negative_col = 'cr_amount', 'dr_amount' 8 | if config['account_types'][account_type]['balance_type'] == BalanceType.DEBIT.value: 9 | positive_col, negative_col = 'dr_amount', 'cr_amount' 10 | 11 | balance, balances = 0, [] 12 | for i, row in ledger.iterrows(): 13 | if row['account'] == account: 14 | balance += row[positive_col] 15 | balance -= row[negative_col] 16 | balances.append(balance) 17 | ledger[account] = balances 18 | return ledger 19 | -------------------------------------------------------------------------------- /pyluca/event.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | 4 | class Event: 5 | def __init__( 6 | self, 7 | event_id: str, 8 | date: datetime.datetime, 9 | created_date: datetime.datetime, 10 | created_by: str = None 11 | ): 12 | self.event_id = event_id 13 | self.date = date 14 | self.created_date = created_date 15 | self.created_by = created_by 16 | -------------------------------------------------------------------------------- /pyluca/journal.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import List, Optional 3 | 4 | 5 | class InvalidEntryException(Exception): 6 | pass 7 | 8 | 9 | class JournalEntry: 10 | """ 11 | A struct for individual journal entry. 12 | 13 | :param sl_no: sl_no of the entry (starting from 1) 14 | :param account: The account which the entry is passed for 15 | :param dr_amount: Debit amount 16 | :param cr_amount: Credit amount 17 | :param date: Time of occurrence 18 | :param narration: A narration or description for the entry 19 | :param key: A key to group the journal entries 20 | :param event_id: Identifier for event caused this entry 21 | """ 22 | def __init__( 23 | self, sl_no: int, 24 | account: str, 25 | dr_amount: float, 26 | cr_amount: float, 27 | date: datetime.datetime, 28 | narration: str, 29 | key: str, 30 | event_id: Optional[str] 31 | ): 32 | self.sl_no = sl_no 33 | self.account = account 34 | self.dr_amount = dr_amount 35 | self.cr_amount = cr_amount 36 | self.date = date 37 | self.narration = narration 38 | self.key = key 39 | self.event_id = event_id 40 | 41 | 42 | class Journal: 43 | """ 44 | A log which maintains list of journal entries. 45 | 46 | :param entries: An optional opening journal entries 47 | """ 48 | def __init__(self, entries: List[JournalEntry] = None): 49 | self.entries: List[JournalEntry] = [] if entries is None else entries 50 | self.max_date: datetime = max([entry.date for entry in entries]) if entries else None 51 | 52 | def add_entry(self, entry: JournalEntry): 53 | """ 54 | Function which takes an entry and adds to the entries after validation on date. Raises 55 | exception if passed entry is back dated 56 | 57 | :param entry: Entry to be added 58 | :raises: InvalidEntryException 59 | """ 60 | if self.max_date is None: 61 | self.max_date = entry.date 62 | if entry.date < self.max_date: 63 | raise InvalidEntryException( 64 | f'Backdated entries cannot be added for entry_date: {entry.date.strftime("%d-%m-%Y %H:%M:%S")} and max_date: {self.max_date.strftime("%d-%m-%Y %H:%M:%S")}' 65 | ) 66 | self.entries.append(entry) 67 | self.max_date = entry.date 68 | -------------------------------------------------------------------------------- /pyluca/ledger.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, NamedTuple, Dict 2 | from datetime import datetime 3 | import pandas as pd 4 | from pyluca.account_config import BalanceType 5 | from pyluca.balances import add_account_balance 6 | from pyluca.journal import Journal 7 | 8 | 9 | class InvalidLedgerEntry(Exception): 10 | pass 11 | 12 | 13 | class LedgerEntry(NamedTuple): 14 | date: datetime 15 | dr_amount: float 16 | cr_amount: float 17 | narration: str 18 | balance: float 19 | event_id: Optional[str] 20 | sl_no: Optional[int] 21 | 22 | 23 | class AccountLedger: 24 | def __init__(self, account_name: str, balance_type: BalanceType): 25 | self.account_name = account_name 26 | self.balance_type = balance_type 27 | self.__entries: List[LedgerEntry] = [] 28 | 29 | def add_entry( 30 | self, 31 | date: datetime, 32 | dr_amount: float, 33 | cr_amount: float, 34 | narration: str, 35 | event_id: Optional[str], 36 | sl_no: Optional[int], 37 | ): 38 | if len(self.__entries) and date < self.__entries[-1].date: 39 | raise InvalidLedgerEntry("Backdated entry can't be added") 40 | balance = self.__entries[-1].balance if len(self.__entries) else 0 41 | balance += dr_amount - cr_amount if self.balance_type == BalanceType.DEBIT else cr_amount - dr_amount 42 | self.__entries.append( 43 | LedgerEntry( 44 | sl_no=sl_no, 45 | date=date, 46 | dr_amount=dr_amount, 47 | cr_amount=cr_amount, 48 | narration=narration, 49 | balance=balance, 50 | event_id=event_id 51 | ) 52 | ) 53 | 54 | def get_balance(self, as_of: Optional[datetime] = None) -> float: 55 | if as_of is None: 56 | return self.__entries[-1].balance if len(self.__entries) else 0 57 | 58 | balance = 0 59 | start, end = 0, len(self.__entries) - 1 60 | while start <= end: 61 | mid = (start + end) // 2 62 | if self.__entries[mid].date <= as_of: 63 | balance = self.__entries[mid].balance 64 | start = mid + 1 65 | else: 66 | end = mid - 1 67 | return balance 68 | 69 | def get_entries(self) -> List[LedgerEntry]: 70 | return self.__entries 71 | 72 | 73 | class Ledger: 74 | def __init__(self, journal: Journal, config: dict, key: str = ""): 75 | self.config = config 76 | self.key = key 77 | self.ledgers: Dict[str, AccountLedger] = { 78 | account: AccountLedger( 79 | account_name=account_config.get('name', account), 80 | balance_type=BalanceType[config['account_types'][account_config['type']]['balance_type']] 81 | ) 82 | for account, account_config in config['accounts'].items() 83 | } 84 | self.__sl_no: int = 0 85 | for je in journal.entries: 86 | self.ledgers[je.account].add_entry( 87 | date=je.date, 88 | dr_amount=je.dr_amount, 89 | cr_amount=je.cr_amount, 90 | narration=je.narration, 91 | event_id=je.event_id, 92 | sl_no=self.__sl_no 93 | ) 94 | self.__sl_no += 1 95 | 96 | def add_entry( 97 | self, 98 | dr_account: str, 99 | cr_account: str, 100 | amount: float, 101 | date: datetime, 102 | narration: str, 103 | event_id: Optional[str] = None 104 | ): 105 | self.ledgers[dr_account].add_entry( 106 | date=date, 107 | dr_amount=amount, 108 | cr_amount=0, 109 | narration=narration, 110 | event_id=event_id, 111 | sl_no=self.__sl_no 112 | ) 113 | self.__sl_no += 1 114 | self.ledgers[cr_account].add_entry( 115 | date=date, 116 | dr_amount=0, 117 | cr_amount=amount, 118 | narration=narration, 119 | event_id=event_id, 120 | sl_no=self.__sl_no 121 | ) 122 | self.__sl_no += 1 123 | 124 | def get_account_dr(self, account: str, as_of: Optional[datetime] = None) -> float: 125 | assert self.config['accounts'][account]['type'] in self.config['account_types'], f'Invalid account {account}' 126 | if as_of is not None: 127 | return sum([entry.dr_amount for entry in self.ledgers[account].get_entries() if entry.date <= as_of]) 128 | return sum([entry.dr_amount for entry in self.ledgers[account].get_entries()]) 129 | 130 | def get_account_cr(self, account: str, as_of: Optional[datetime] = None) -> float: 131 | assert self.config['accounts'][account]['type'] in self.config['account_types'], f'Invalid account {account}' 132 | if as_of is not None: 133 | return sum([entry.cr_amount for entry in self.ledgers[account].get_entries() if entry.date <= as_of]) 134 | return sum([entry.cr_amount for entry in self.ledgers[account].get_entries()]) 135 | 136 | def get_account_balance(self, account: str, as_of: Optional[datetime] = None) -> float: 137 | assert self.config['accounts'][account]['type'] in self.config['account_types'], f'Invalid account {account}' 138 | return self.ledgers[account].get_balance(as_of) 139 | 140 | def get_balances(self, as_of: Optional[datetime] = None) -> Dict[str, float]: 141 | return {account: ledger.get_balance(as_of) for account, ledger in self.ledgers.items()} 142 | 143 | def get_ledger(self) -> List[dict]: 144 | return sorted( 145 | [ 146 | {**entry._asdict(), 'account': account, 'key': self.key} 147 | for account, ledger in self.ledgers.items() 148 | for entry in ledger.get_entries() 149 | ], 150 | key=lambda x: x['sl_no'] 151 | ) 152 | 153 | def get_df(self) -> pd.DataFrame: 154 | ledger_df = pd.DataFrame(self.get_ledger()) 155 | if not ledger_df.empty: 156 | ledger_df.drop(columns=['balance'], inplace=True) 157 | ledger_df['account_name'] = ledger_df['account'].apply(lambda x: self.config['accounts'][x].get('name', x)) 158 | return ledger_df 159 | 160 | def add_account_balance(self, account: str, df: pd.DataFrame): 161 | return add_account_balance(self.config, df, account) 162 | 163 | def get_balance_sheet(self): 164 | df = self.get_df() 165 | for acct_name in self.config['accounts'].keys(): 166 | df = self.add_account_balance(acct_name, df) 167 | return df 168 | 169 | def get_account_type_balance(self, account_type: str, exclude_accounts: List[str] = None): 170 | balance = 0 171 | exclude_accounts = [] if exclude_accounts is None else exclude_accounts 172 | for account_name, account in self.config['accounts'].items(): 173 | if account['type'] == account_type and account_name not in exclude_accounts: 174 | balance += self.get_account_balance(account_name) 175 | return balance 176 | -------------------------------------------------------------------------------- /pyluca/recon.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from pyluca.account_config import BalanceType 3 | from pyluca.accountant import Accountant 4 | from pyluca.journal import Journal 5 | from pyluca.ledger import Ledger 6 | 7 | 8 | def _is_matching(config: dict, journal_1: Journal, journal_2: Journal) -> bool: 9 | for acct_name in config['accounts'].keys(): 10 | if Ledger(journal_1, config).get_account_balance(acct_name) \ 11 | != Ledger(journal_2, config).get_account_balance(acct_name): 12 | return False 13 | if Ledger(journal_1, config).get_account_balance('RECONCILE_CONTROL') != 0: 14 | return False 15 | if Ledger(journal_2, config).get_account_balance('RECONCILE_CONTROL') != 0: 16 | return False 17 | return True 18 | 19 | 20 | def reconcile_ledger( 21 | config: dict, 22 | closed_accountant: Accountant, 23 | current_accountant: Accountant, 24 | date: datetime.datetime 25 | ): 26 | assert _is_matching(config, closed_accountant.journal, current_accountant.journal) is False 27 | for acct_name in [a for a in config['accounts'].keys() if a not in ['RECONCILE_CONTROL']]: 28 | diff = Ledger(current_accountant.journal, config).get_account_balance(acct_name) \ 29 | - Ledger(closed_accountant.journal, config).get_account_balance(acct_name) 30 | if diff == 0: 31 | continue 32 | if config['account_types'][config['accounts'][acct_name]['type']]['balance_type'] == BalanceType.DEBIT.value: 33 | closed_accountant.adjust(acct_name, 'RECONCILE_CONTROL', diff, date) 34 | else: 35 | closed_accountant.adjust('RECONCILE_CONTROL', acct_name, diff, date) 36 | assert _is_matching(config, closed_accountant.journal, current_accountant.journal) is True 37 | -------------------------------------------------------------------------------- /pyluca/tests/test_accountant.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from datetime import datetime 3 | from unittest import TestCase 4 | 5 | from pyluca.accountant import Accountant 6 | from pyluca.journal import Journal, InvalidEntryException 7 | from pyluca.ledger import Ledger 8 | from pyluca.tests.test_aging import account_config 9 | 10 | 11 | class TestAccountant(TestCase): 12 | def test_base(self): 13 | accountant = Accountant(Journal(), account_config, 'person1') 14 | accountant.enter_journal('SAVINGS_BANK', 'SALARY', 20000, datetime(2022, 4, 30), 'April salary') 15 | self.assertEqual(len(accountant.journal.entries), 2) 16 | self.assertEqual(accountant.ledger.get_account_balance('SAVINGS_BANK'), 20000) 17 | self.assertEqual(accountant.ledger.get_account_balance('SALARY'), 20000) 18 | self.assertEqual(len(accountant.ledger.get_df()), 2) 19 | 20 | accountant.enter_journal('MUTUAL_FUNDS', 'SAVINGS_BANK', 10000, datetime(2022, 5, 1), 'ELSS') 21 | self.assertEqual(accountant.ledger.get_account_balance('SAVINGS_BANK'), 10000) 22 | self.assertEqual(accountant.ledger.get_account_balance('MUTUAL_FUNDS'), 10000) 23 | 24 | accountant.enter_journal('LOANS', 'SAVINGS_BANK', 5000, datetime(2022, 5, 2), 'Lend to Pramod') 25 | self.assertEqual(accountant.ledger.get_account_balance('LOANS'), 5000) 26 | self.assertEqual(accountant.ledger.get_account_balance('SAVINGS_BANK'), 5000) 27 | 28 | accountant.enter_journal('CAR_EMI', 'SAVINGS_BANK', 3000, datetime(2022, 5, 2), 'EMI 3/48') 29 | self.assertEqual(accountant.ledger.get_account_balance('SAVINGS_BANK'), 2000) 30 | 31 | bal, ledger, acct_type_bal = {}, Ledger(accountant.journal, accountant.config), defaultdict(int) 32 | for acct_name, acct in account_config['accounts'].items(): 33 | bal[acct_name] = ledger.get_account_balance(acct_name) 34 | acct_type_bal[acct['type']] += bal[acct_name] 35 | self.assertEqual(acct_type_bal['ASSET'], acct_type_bal['INCOME'] - acct_type_bal['EXPENSE']) 36 | 37 | def test_enter_journal(self): 38 | accountant = Accountant(Journal(), account_config, 'person2') 39 | accountant.enter_journal('SAVINGS_BANK', 'SALARY', 30000, datetime(2023, 1, 31), 'Jan salary') 40 | self.assertEqual(accountant.journal.max_date, datetime(2023, 1, 31)) 41 | 42 | self.assertRaises( 43 | InvalidEntryException, 44 | lambda: accountant.enter_journal('LOANS', 'SAVINGS_BANK', 5000, datetime(2023, 1, 1), 'Loans') 45 | ) 46 | -------------------------------------------------------------------------------- /pyluca/tests/test_action.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from unittest import TestCase 3 | from pyluca.accountant import Accountant 4 | from pyluca.action import apply 5 | from pyluca.event import Event 6 | from pyluca.journal import Journal 7 | from pyluca.ledger import Ledger 8 | 9 | personal_fin_config = { 10 | 'account_types': { 11 | 'ASSET': { 12 | 'balance_type': 'DEBIT' 13 | }, 14 | 'INCOME': { 15 | 'balance_type': 'CREDIT' 16 | }, 17 | 'LIABILITY': { 18 | 'balance_type': 'CREDIT' 19 | }, 20 | 'EXPENSE': { 21 | 'balance_type': 'DEBIT' 22 | } 23 | }, 24 | 'accounts': { 25 | 'SALARY': {'type': 'INCOME'}, 26 | 'SAVINGS_BANK': {'type': 'ASSET'}, 27 | 'MUTUAL_FUNDS': {'type': 'ASSET'}, 28 | 'LOANS': {'type': 'ASSET'}, 29 | 'CAR_EMI': {'type': 'EXPENSE'}, 30 | 'FREELANCING_INCOME': {'type': 'INCOME'}, 31 | 'LOANS_PAYBACK': {'type': 'ASSET'}, 32 | 'RISKY_LOANS': {'type': 'ASSET'}, 33 | 'MUTUAL_FUNDS_PNL': {'type': 'INCOME'}, 34 | 'CHARITY': {'type': 'EXPENSE'}, 35 | 'FIXED_DEPOSIT': {'type': 'ASSET'} 36 | }, 37 | 'rules': {}, 38 | 'actions_config': { 39 | 'common_actions': { 40 | 'charity': { 41 | 'actions': [ 42 | { 43 | 'dr_account': 'CHARITY', 44 | 'cr_account': 'SAVINGS_BANK', 45 | 'amount': {'type': '*', 'a': 'amount', 'b': 0.01}, 46 | 'narration': 'Give charity to {context.to} on {context.date}' 47 | } 48 | ] 49 | }, 50 | 'fd': { 51 | 'actions': [ 52 | { 53 | 'dr_account': 'FIXED_DEPOSIT', 54 | 'cr_account': 'SAVINGS_BANK', 55 | 'amount': 'context.another_amount', 56 | 'narration': 'Put in fixed deposit for {context.sub_narration}' 57 | } 58 | ] 59 | } 60 | }, 61 | 'on_event': { 62 | 'SalaryEvent': { 63 | 'actions': [ 64 | { 65 | 'dr_account': 'SAVINGS_BANK', 66 | 'cr_account': 'SALARY', 67 | 'amount': 'amount', 68 | 'narration': 'Salary' 69 | } 70 | ] 71 | }, 72 | 'InvestMFEvent': { 73 | 'actions': [ 74 | { 75 | 'dr_account': 'MUTUAL_FUNDS', 76 | 'cr_account': 'SAVINGS_BANK', 77 | 'amount': 'amount', 78 | 'narration': 'Invest in MF' 79 | } 80 | ] 81 | }, 82 | 'LendEvent': { 83 | 'actions': [ 84 | { 85 | 'iff': {'type': '!', 'a': 'risky'}, 86 | 'dr_account': 'LOANS', 87 | 'cr_account': 'SAVINGS_BANK', 88 | 'amount': 'amount', 89 | 'narration': 'Lend', 90 | 'meta': { 91 | 'due_date': 'due_date' 92 | } 93 | }, 94 | { 95 | 'iff': 'risky', 96 | 'dr_account': 'RISKY_LOANS', 97 | 'cr_account': 'SAVINGS_BANK', 98 | 'amount': 'amount', 99 | 'narration': 'Risky lend', 100 | 'meta': { 101 | 'due_date': 'due_date' 102 | } 103 | } 104 | ] 105 | }, 106 | 'ClearLoansEvent': { 107 | 'actions': [ 108 | { 109 | 'dr_account': 'LOANS_PAYBACK', 110 | 'cr_account': 'LOANS', 111 | 'amount': 'balance.LOANS', 112 | 'narration': 'Clear off loans' 113 | } 114 | ] 115 | }, 116 | 'LiquidLoanRepaymentsEvent': { 117 | 'actions': [ 118 | { 119 | 'dr_account': 'SAVINGS_BANK', 120 | 'cr_account': 'LOANS_PAYBACK', 121 | 'amount': 'balance.LOANS_PAYBACK', 122 | 'narration': 'Liquidate loans payback' 123 | } 124 | ] 125 | }, 126 | 'MFProfitEvent': { 127 | 'actions': [ 128 | { 129 | 'dr_account': 'MUTUAL_FUNDS', 130 | 'cr_account': 'MUTUAL_FUNDS_PNL', 131 | 'amount': { 132 | 'type': '*', 133 | 'a': 'context.multiplier', 134 | 'b': 'balance.MUTUAL_FUNDS' 135 | }, 136 | 'narration': 'Mutual fund P&L' 137 | } 138 | ] 139 | }, 140 | 'FreelancingSalaryEvent': { 141 | 'actions': [ 142 | { 143 | 'dr_account': 'SAVINGS_BANK', 144 | 'cr_account': 'SALARY', 145 | 'amount': 'amount', 146 | 'narration': 'Salary' 147 | }, 148 | { 149 | 'type': 'action.charity', 150 | 'context': { 151 | 'to': 'str.TATA Trusts', 152 | 'date': 'str.10/05/2022' 153 | } 154 | }, 155 | { 156 | 'type': 'action.fd', 157 | 'context': { 158 | 'another_amount': {'type': '*', 'a': 'amount', 'b': 0.09}, 159 | 'sub_narration': 'str.Freelancing salary' 160 | } 161 | } 162 | ] 163 | } 164 | } 165 | } 166 | } 167 | 168 | 169 | class AmountEvent(Event): 170 | def __init__( 171 | self, 172 | event_id: str, 173 | amount: float, 174 | date: datetime, 175 | created_date: datetime, 176 | created_by: str = None 177 | ): 178 | self.amount = amount 179 | super(AmountEvent, self).__init__(event_id, date, created_date, created_by) 180 | 181 | 182 | class SalaryEvent(AmountEvent): 183 | pass 184 | 185 | 186 | class InvestMFEvent(AmountEvent): 187 | pass 188 | 189 | 190 | class LendEvent(AmountEvent): 191 | def __init__( 192 | self, 193 | event_id: str, 194 | amount: float, 195 | due_date: str, 196 | date: datetime, 197 | created_date: datetime, 198 | created_by: str = None, 199 | risky: bool = False 200 | ): 201 | self.due_date = due_date 202 | self.risky = risky 203 | super(LendEvent, self).__init__(event_id, amount, date, created_date, created_by) 204 | 205 | 206 | class ClearLoansEvent(Event): 207 | pass 208 | 209 | 210 | class LiquidLoanRepaymentsEvent(Event): 211 | pass 212 | 213 | 214 | class MFProfitEvent(Event): 215 | pass 216 | 217 | 218 | class FreelancingSalaryEvent(AmountEvent): 219 | pass 220 | 221 | 222 | class TestAction(TestCase): 223 | def test_config(self): 224 | config = { 225 | 'account_types': {}, 226 | 'accounts': {}, 227 | 'rules': {}, 228 | 'actions_config': { 229 | 'on_event': { 230 | 'Salary': { 231 | 'actions': [ 232 | { 233 | 'dr_account': 'X', 234 | 'cr_account': 'Y', 235 | 'amount': 'amount', 236 | 'narration': 'test' 237 | } 238 | ] 239 | } 240 | } 241 | } 242 | } 243 | action = config['actions_config']['on_event']['Salary']['actions'][0] 244 | self.assertEqual(action.get('type'), None) 245 | self.assertEqual(action['dr_account'], 'X') 246 | self.assertEqual(action['cr_account'], 'Y') 247 | self.assertEqual(action['amount'], 'amount') 248 | self.assertEqual(action['narration'], 'test') 249 | 250 | def test_base(self): 251 | accountant = Accountant(Journal(), personal_fin_config, '1') 252 | event = SalaryEvent('1', 20000, datetime(2022, 4, 21), datetime(2022, 4, 21)) 253 | apply(event, accountant) 254 | ledger = Ledger(accountant.journal, accountant.config) 255 | self.assertEqual(ledger.get_account_balance('SALARY'), 20000) 256 | self.assertEqual(ledger.get_account_balance('SAVINGS_BANK'), 20000) 257 | 258 | event = InvestMFEvent('2', 10000, datetime(2022, 4, 21), datetime(2022, 4, 21)) 259 | apply(event, accountant) 260 | ledger = Ledger(accountant.journal, accountant.config) 261 | self.assertEqual(ledger.get_account_balance('SALARY'), 20000) 262 | self.assertEqual(ledger.get_account_balance('SAVINGS_BANK'), 10000) 263 | self.assertEqual(ledger.get_account_balance('MUTUAL_FUNDS'), 10000) 264 | 265 | event = LendEvent('3', 5000, '2022-6-21', datetime(2022, 4, 21), datetime(2022, 4, 21)) 266 | apply(event, accountant) 267 | ledger = Ledger(accountant.journal, accountant.config) 268 | self.assertEqual(ledger.get_account_balance('SAVINGS_BANK'), 5000) 269 | self.assertEqual(ledger.get_account_balance('LOANS'), 5000) 270 | 271 | event = ClearLoansEvent('4', datetime(2022, 4, 25), datetime(2022, 4, 25)) 272 | apply(event, accountant) 273 | ledger = Ledger(accountant.journal, accountant.config) 274 | self.assertEqual(ledger.get_account_balance('LOANS'), 0) 275 | 276 | event = LendEvent('5', 5000, '2022-6-21', datetime(2022, 4, 26), datetime(2022, 4, 26), risky=True) 277 | apply(event, accountant) 278 | ledger = Ledger(accountant.journal, accountant.config) 279 | self.assertEqual(ledger.get_account_balance('LOANS'), 0) 280 | self.assertEqual(ledger.get_account_balance('RISKY_LOANS'), 5000) 281 | self.assertEqual(ledger.get_account_balance('SAVINGS_BANK'), 0) 282 | 283 | apply(LiquidLoanRepaymentsEvent('7', datetime(2022, 4, 29), datetime(2022, 4, 29)), accountant) 284 | ledger = Ledger(accountant.journal, accountant.config) 285 | self.assertEqual(ledger.get_account_balance('LOANS'), 0) 286 | self.assertEqual(ledger.get_account_balance('LOANS_PAYBACK'), 0) 287 | self.assertEqual(ledger.get_account_balance('SAVINGS_BANK'), 5000) 288 | 289 | def test_context(self): 290 | accountant = Accountant(Journal(), personal_fin_config, '1') 291 | events = [ 292 | SalaryEvent('1', 20000, datetime(2022, 4, 21), datetime(2022, 4, 21)), 293 | InvestMFEvent('2', 20000, datetime(2022, 4, 21), datetime(2022, 4, 21)), 294 | MFProfitEvent('3', datetime(2022, 4, 30), datetime(2022, 4, 30)) 295 | ] 296 | for e in events: 297 | apply(e, accountant, {'multiplier': .18}) 298 | ledger = Ledger(accountant.journal, accountant.config) 299 | self.assertEqual(ledger.get_account_balance('MUTUAL_FUNDS'), 20000 * 1.18) 300 | self.assertEqual(ledger.get_account_balance('MUTUAL_FUNDS_PNL'), 20000 * .18) 301 | 302 | def test_common_action(self): 303 | accountant = Accountant(Journal(), personal_fin_config, '1') 304 | events = [ 305 | FreelancingSalaryEvent('1', 20000, datetime(2022, 4, 21), datetime(2022, 4, 21)) 306 | ] 307 | for e in events: 308 | apply(e, accountant) 309 | ledger = Ledger(accountant.journal, accountant.config) 310 | self.assertEqual(ledger.get_account_balance('CHARITY'), 200) 311 | self.assertEqual(ledger.get_account_balance('FIXED_DEPOSIT'), 1800) 312 | self.assertEqual(ledger.get_account_balance('SAVINGS_BANK'), 20000 - 200 - 1800) 313 | 314 | def test_sub_narration(self): 315 | accountant = Accountant(Journal(), personal_fin_config, '1') 316 | events = [ 317 | FreelancingSalaryEvent('1', 20000, datetime(2022, 4, 21), datetime(2022, 4, 21)) 318 | ] 319 | for e in events: 320 | apply(e, accountant) 321 | for je in accountant.journal.entries: 322 | if je.account == 'CHARITY': 323 | self.assertEqual(je.narration, 'Give charity to TATA Trusts on 10/05/2022') 324 | if je.account == 'FIXED_DEPOSIT': 325 | self.assertEqual(je.narration, 'Put in fixed deposit for Freelancing salary') 326 | 327 | def test_externals(self): 328 | config = {**personal_fin_config} 329 | config['actions_config']['on_event']['SalaryEvent']['actions'] = [ 330 | *config['actions_config']['on_event']['SalaryEvent']['actions'], 331 | { 332 | 'type': 'external_action.check_balance', 333 | 'context': { 334 | 'acct_name': 'str.SALARY', 335 | 'balance': 'balance.SALARY', 336 | 'date': 'date' 337 | } 338 | } 339 | ] 340 | 341 | local_state = {'checked': False} 342 | 343 | def __check_balance(acct_name: str, balance: str, date: datetime): 344 | assert acct_name == 'SALARY' 345 | assert balance == 20000 346 | assert date == datetime(2023, 1, 22) 347 | local_state['checked'] = True 348 | 349 | accountant = Accountant(Journal(), config, '1') 350 | events = [ 351 | SalaryEvent('1', 20000, datetime(2023, 1, 22), datetime(2023, 1, 22)) 352 | ] 353 | for e in events: 354 | apply(e, accountant, external_actions={'check_balance': __check_balance}) 355 | 356 | self.assertTrue(local_state['checked']) 357 | -------------------------------------------------------------------------------- /pyluca/tests/test_aging.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | from unittest import TestCase 4 | from pyluca.accountant import Accountant 5 | from pyluca.aging import get_account_aging, get_accounts_aging 6 | from pyluca.journal import JournalEntry, Journal 7 | from pyluca.ledger import Ledger 8 | 9 | account_config = { 10 | 'account_types': { 11 | 'ASSET': { 12 | 'balance_type': 'DEBIT' 13 | }, 14 | 'INCOME': { 15 | 'balance_type': 'CREDIT' 16 | }, 17 | 'LIABILITY': { 18 | 'balance_type': 'CREDIT' 19 | }, 20 | 'EXPENSE': { 21 | 'balance_type': 'DEBIT' 22 | } 23 | }, 24 | 'accounts': { 25 | 'SALARY': {'type': 'INCOME', 'name': 'SALARY_AMOUNT'}, 26 | 'SAVINGS_BANK': {'type': 'ASSET', 'name': 'SAVINGS_ACCOUNT'}, 27 | 'MUTUAL_FUNDS': {'type': 'ASSET'}, 28 | 'LOANS': {'type': 'ASSET'}, 29 | 'CAR_EMI': {'type': 'EXPENSE'}, 30 | 'FREELANCING_INCOME': {'type': 'INCOME'}, 31 | 'LOANS_PAYBACK': {'type': 'INCOME'} 32 | }, 33 | 'rules': {} 34 | } 35 | 36 | 37 | class TestAging(TestCase): 38 | def test_aging(self): 39 | dt = datetime.now() 40 | aging = get_account_aging(account_config, [ 41 | JournalEntry(1, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 42 | JournalEntry(2, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 43 | JournalEntry(3, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 44 | JournalEntry(4, 'SAVINGS_BANK', 0, 4000, dt, '', '1', None), 45 | ], 'SAVINGS_BANK', dt) 46 | for age in aging.ages: 47 | self.assertEqual(age.counter.is_paid(), True) 48 | 49 | aging = get_account_aging(account_config, [ 50 | JournalEntry(4, 'SAVINGS_BANK', 0, 4000, dt, '', '1', None), 51 | JournalEntry(1, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 52 | JournalEntry(2, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 53 | JournalEntry(3, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 54 | ], 'SAVINGS_BANK', dt) 55 | for age in aging.ages: 56 | self.assertEqual(age.counter.is_paid(), True) 57 | 58 | aging = get_account_aging(account_config, [ 59 | JournalEntry(4, 'SAVINGS_BANK', 0, 3000, dt, '', '1', None), 60 | JournalEntry(1, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 61 | JournalEntry(2, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 62 | JournalEntry(3, 'SAVINGS_BANK', 2000, 0, dt, '', '1', None), 63 | ], 'SAVINGS_BANK', dt) 64 | self.assertEqual(len(aging.ages), 3) 65 | self.assertEqual(aging.ages[0].counter.is_paid(), True) 66 | self.assertEqual(aging.ages[1].counter.is_paid(), True) 67 | self.assertEqual(aging.ages[2].counter.is_paid(), False) 68 | self.assertEqual(aging.ages[2].counter.get_balance(), 1000) 69 | 70 | aging = get_account_aging(account_config, [ 71 | JournalEntry(1, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 72 | JournalEntry(2, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 73 | JournalEntry(3, 'SAVINGS_BANK', 0, 3000, dt, '', '1', None), 74 | JournalEntry(4, 'SAVINGS_BANK', 2000, 0, dt, '', '1', None), 75 | ], 'SAVINGS_BANK', dt) 76 | self.assertEqual(len(aging.ages), 3) 77 | self.assertEqual(aging.ages[0].counter.is_paid(), True) 78 | self.assertEqual(aging.ages[1].counter.is_paid(), True) 79 | self.assertEqual(aging.ages[2].counter.is_paid(), False) 80 | self.assertEqual(aging.ages[2].counter.get_balance(), 1000) 81 | 82 | def test_meta(self): 83 | accountant = Accountant(Journal(), account_config, 'person1') 84 | accountant.enter_journal('SAVINGS_BANK', 'FREELANCING_INCOME', 20000, datetime(2022, 4, 30), 'XYZ client') 85 | accountant.enter_journal( 86 | 'LOANS', 'SAVINGS_BANK', 1000, datetime(2022, 5, 1), 87 | f'Lend to Pramod ##{json.dumps({"due_date": "2022-5-5"})}##' 88 | ) 89 | accountant.enter_journal('LOANS_PAYBACK', 'LOANS', 300, datetime(2022, 5, 10), 'Payback 1') 90 | accountant.enter_journal('LOANS_PAYBACK', 'LOANS', 200, datetime(2022, 5, 15), 'Payback 2') 91 | accountant.enter_journal('LOANS_PAYBACK', 'LOANS', 500, datetime(2022, 5, 20), 'Payback 3') 92 | aging = get_account_aging(account_config, accountant.journal.entries, 'LOANS', datetime(2022, 5, 25)) 93 | self.assertEqual(len(aging.ages), 1) 94 | self.assertEqual(aging.ages[0].counter.is_paid(), True) 95 | self.assertEqual(len(aging.ages[0].counter.payments), 3) 96 | due_meta = aging.ages[0].meta['due_date'].split('-') 97 | due_date = datetime(int(due_meta[0]), int(due_meta[1]), int(due_meta[2])) 98 | self.assertEqual((aging.ages[0].counter.payments[-1].date - due_date).days, 15) 99 | 100 | def test_high_precision(self): 101 | accountant = Accountant(Journal(), account_config, 'person1') 102 | accountant.enter_journal('SAVINGS_BANK', 'SALARY', 200, datetime(2022, 8, 9), 'Salary') 103 | accountant.enter_journal('LOANS', 'SAVINGS_BANK', 100.128839293829282838283823, datetime(2022, 8, 10), 'XYZ client') 104 | accountant.enter_journal('LOANS_PAYBACK', 'LOANS', 100.12883, datetime(2022, 8, 10), 'XYZ client') 105 | aging = get_account_aging(account_config, accountant.journal.entries, 'LOANS', datetime(2022, 8, 11)) 106 | age = aging.ages[0] 107 | self.assertTrue(age.counter.is_paid()) 108 | self.assertAlmostEqual(age.counter.get_balance(), 0, 4) 109 | 110 | accountant = Accountant(Journal(), account_config, 'person1') 111 | accountant.enter_journal('SAVINGS_BANK', 'SALARY', 200, datetime(2022, 8, 9), 'Salary') 112 | accountant.enter_journal('LOANS', 'SAVINGS_BANK', 100.128839293829282838283823, datetime(2022, 8, 10), 113 | 'XYZ client') 114 | accountant.enter_journal('LOANS_PAYBACK', 'LOANS', 100.1288, datetime(2022, 8, 10), 'XYZ client') 115 | aging = get_account_aging(account_config, accountant.journal.entries, 'LOANS', datetime(2022, 8, 11)) 116 | age = aging.ages[0] 117 | self.assertFalse(age.counter.is_paid()) 118 | 119 | accountant = Accountant(Journal(), account_config, 'person1') 120 | accountant.enter_journal('SAVINGS_BANK', 'SALARY', 200, datetime(2022, 8, 9), 'Salary') 121 | accountant.enter_journal('LOANS', 'SAVINGS_BANK', 100.00000387278, datetime(2022, 8, 10), 122 | 'To a') 123 | accountant.enter_journal('LOANS', 'SAVINGS_BANK', 200.00000387278, datetime(2022, 8, 10), 124 | 'To b') 125 | accountant.enter_journal('LOANS_PAYBACK', 'LOANS', 100, datetime(2022, 8, 10), 'From a') 126 | accountant.enter_journal('LOANS_PAYBACK', 'LOANS', 199.999994222222222, datetime(2022, 8, 10), 'From b') 127 | aging = get_account_aging(account_config, accountant.journal.entries, 'LOANS', datetime(2022, 8, 11)) 128 | age1, age2 = aging.ages[0], aging.ages[1] 129 | self.assertTrue(age1.counter.is_paid()) 130 | self.assertTrue(age2.counter.is_paid()) 131 | self.assertNotEqual(age1.counter.get_balance(), 0) 132 | self.assertNotEqual(age2.counter.get_balance(), 0) 133 | ledger = Ledger(accountant.journal, accountant.config) 134 | self.assertAlmostEqual(ledger.get_account_balance('LOANS'), 0, 4) 135 | 136 | def test_aging_state(self): 137 | accountant = Accountant(Journal(), account_config, 'person1') 138 | accountant.enter_journal('SAVINGS_BANK', 'SALARY', 200, datetime(2022, 8, 9), 'Salary') 139 | accountant.enter_journal('LOANS', 'SAVINGS_BANK', 100, datetime(2022, 8, 10), 140 | 'To a') 141 | accountant.enter_journal('LOANS', 'SAVINGS_BANK', 200, datetime(2022, 8, 10), 142 | 'To b') 143 | aging = get_account_aging(account_config, accountant.journal.entries, 'LOANS', datetime(2022, 8, 10)) 144 | self.assertEqual(aging.last_sl_no, 4) 145 | self.assertEqual(len(aging.ages), 2) 146 | accountant.enter_journal('LOANS_PAYBACK', 'LOANS', 100, datetime(2022, 8, 10), 'From a') 147 | accountant.enter_journal('LOANS_PAYBACK', 'LOANS', 199, datetime(2022, 8, 10), 'From b') 148 | aging = get_account_aging( 149 | account_config, accountant.journal.entries, 'LOANS', datetime(2022, 8, 10), previous_aging=aging 150 | ) 151 | self.assertEqual(Ledger(accountant.journal, accountant.config).get_account_balance('LOANS'), 1) 152 | self.assertEqual(aging.ages[0].counter.get_balance(), 0) 153 | self.assertEqual(aging.ages[1].counter.get_balance(), 1) 154 | self.assertEqual(aging.last_sl_no, 9) 155 | self.assertEqual(len(aging.ages), 2) 156 | 157 | try: 158 | get_account_aging( 159 | account_config, accountant.journal.entries, 'SAVINGS_BANK', datetime(2022, 8, 10), previous_aging=aging 160 | ) 161 | raise AssertionError('Should not have passed because wrong previous state provided') 162 | except ValueError as e: 163 | self.assertEqual(str(e), 'Invalid previous aging! account not matching') 164 | 165 | def test_get_accounts_aging(self): 166 | dt = datetime.now() 167 | aging = get_accounts_aging(account_config, [ 168 | JournalEntry(1, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 169 | JournalEntry(2, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 170 | JournalEntry(3, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 171 | JournalEntry(4, 'SAVINGS_BANK', 0, 4000, dt, '', '1', None), 172 | ], ['SAVINGS_BANK'], dt) 173 | for age in aging['SAVINGS_BANK'].ages: 174 | self.assertEqual(age.counter.is_paid(), True) 175 | 176 | aging = get_accounts_aging(account_config, [ 177 | JournalEntry(4, 'SAVINGS_BANK', 0, 4000, dt, '', '1', None), 178 | JournalEntry(1, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 179 | JournalEntry(2, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 180 | JournalEntry(3, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 181 | ], ['SAVINGS_BANK'], dt) 182 | for age in aging['SAVINGS_BANK'].ages: 183 | self.assertEqual(age.counter.is_paid(), True) 184 | 185 | aging = get_accounts_aging(account_config, [ 186 | JournalEntry(4, 'SAVINGS_BANK', 0, 3000, dt, '', '1', None), 187 | JournalEntry(1, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 188 | JournalEntry(2, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 189 | JournalEntry(3, 'SAVINGS_BANK', 2000, 0, dt, '', '1', None), 190 | ], ['SAVINGS_BANK'], dt) 191 | ages = aging['SAVINGS_BANK'].ages 192 | self.assertEqual(len(ages), 3) 193 | self.assertEqual(ages[0].counter.is_paid(), True) 194 | self.assertEqual(ages[1].counter.is_paid(), True) 195 | self.assertEqual(ages[2].counter.is_paid(), False) 196 | self.assertEqual(ages[2].counter.get_balance(), 1000) 197 | 198 | aging = get_accounts_aging(account_config, [ 199 | JournalEntry(1, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 200 | JournalEntry(2, 'SAVINGS_BANK', 1000, 0, dt, '', '1', None), 201 | JournalEntry(3, 'SAVINGS_BANK', 0, 3000, dt, '', '1', None), 202 | JournalEntry(4, 'SAVINGS_BANK', 2000, 0, dt, '', '1', None), 203 | ], ['SAVINGS_BANK'], dt) 204 | ages = aging['SAVINGS_BANK'].ages 205 | self.assertEqual(len(ages), 3) 206 | self.assertEqual(ages[0].counter.is_paid(), True) 207 | self.assertEqual(ages[1].counter.is_paid(), True) 208 | self.assertEqual(ages[2].counter.is_paid(), False) 209 | self.assertEqual(ages[2].counter.get_balance(), 1000) 210 | 211 | def test_aging_state_with_get_accounts_aging(self): 212 | accountant = Accountant(Journal(), account_config, 'person1') 213 | accountant.enter_journal('SAVINGS_BANK', 'SALARY', 200, datetime(2022, 8, 9), 'Salary') 214 | accountant.enter_journal('LOANS', 'SAVINGS_BANK', 100, datetime(2022, 8, 10), 'To a') 215 | accountant.enter_journal('LOANS', 'SAVINGS_BANK', 200, datetime(2022, 8, 10), 'To b') 216 | aging = get_accounts_aging( 217 | account_config, accountant.journal.entries, ['SAVINGS_BANK', 'LOANS'], datetime(2022, 8, 10) 218 | ) 219 | 220 | saving_bank_aging = aging['SAVINGS_BANK'] 221 | self.assertEqual(len(saving_bank_aging.ages), 1) 222 | self.assertEqual(saving_bank_aging.ages[0].counter.is_paid(), True) 223 | self.assertEqual(saving_bank_aging.ages[0].counter.get_balance(), 0) 224 | self.assertEqual(saving_bank_aging.excess_amount, 100) 225 | self.assertEqual(saving_bank_aging.last_sl_no, 5) 226 | self.assertEqual(saving_bank_aging.last_unpaid_age_idx, 1) 227 | 228 | loans_aging = aging['LOANS'] 229 | self.assertEqual(len(loans_aging.ages), 2) 230 | self.assertEqual(loans_aging.ages[0].counter.is_paid(), False) 231 | self.assertEqual(loans_aging.ages[0].counter.get_balance(), 100) 232 | self.assertEqual(loans_aging.ages[1].counter.is_paid(), False) 233 | self.assertEqual(loans_aging.ages[1].counter.get_balance(), 200) 234 | self.assertEqual(loans_aging.excess_amount, 0) 235 | self.assertEqual(loans_aging.last_sl_no, 4) 236 | self.assertEqual(loans_aging.last_unpaid_age_idx, 0) 237 | 238 | accountant.enter_journal('LOANS_PAYBACK', 'LOANS', 100, datetime(2022, 8, 11), 'From a') 239 | accountant.enter_journal('LOANS_PAYBACK', 'LOANS', 199, datetime(2022, 8, 12), 'From b') 240 | aging = get_accounts_aging( 241 | account_config, accountant.journal.entries, ['LOANS'], 242 | datetime(2022, 8, 12), previous_aging=aging 243 | ) 244 | loans_aging = aging['LOANS'] 245 | self.assertEqual(len(loans_aging.ages), 2) 246 | self.assertEqual(loans_aging.ages[0].counter.is_paid(), True) 247 | self.assertEqual(loans_aging.ages[0].counter.get_balance(), 0) 248 | self.assertEqual(loans_aging.ages[1].counter.is_paid(), False) 249 | self.assertEqual(loans_aging.ages[1].counter.get_balance(), 1) 250 | self.assertEqual(loans_aging.excess_amount, 0) 251 | self.assertEqual(loans_aging.last_sl_no, 9) 252 | self.assertEqual(loans_aging.last_unpaid_age_idx, 1) 253 | 254 | with self.assertRaises(ValueError) as e: 255 | get_accounts_aging( 256 | account_config, accountant.journal.entries, ['LOANS_PAYBACK'], 257 | datetime(2022, 8, 12), previous_aging=aging 258 | ) 259 | self.assertEqual(e.exception.__str__(), 'Invalid previous aging! accounts not matching') 260 | 261 | def test_with_meta_ac_payments(self): 262 | accountant = Accountant(Journal(), account_config, 'person1') 263 | accountant.enter_journal('SAVINGS_BANK', 'LOANS', 200, datetime(2022, 8, 9), 'Salary', 'event1') 264 | accountant.enter_journal('LOANS', 'SAVINGS_BANK', 50, datetime(2022, 8, 10), 'To a', 'event2') 265 | accountant.enter_journal('LOANS', 'SAVINGS_BANK', 150, datetime(2022, 8, 10), 'To b', 'event3') 266 | aging = get_account_aging( 267 | account_config, accountant.journal.entries, 'SAVINGS_BANK', datetime(2022, 8, 10) 268 | ) 269 | age = aging.ages[0] 270 | self.assertEqual(age.counter.is_paid(), True) 271 | self.assertEqual(len(age.counter.payments), 2) 272 | self.assertEqual(age.counter.payments[0].amount, 50) 273 | self.assertEqual(age.counter.payments[0].meta['entry']['event_id'], 'event2') 274 | self.assertEqual(age.counter.payments[1].meta['entry']['event_id'], 'event3') 275 | 276 | agings = get_accounts_aging( 277 | account_config, accountant.journal.entries, ['SAVINGS_BANK', 'LOANS'], datetime(2022, 8, 10) 278 | ) 279 | age = agings['SAVINGS_BANK'].ages[0] 280 | self.assertEqual(age.counter.payments[0].meta['entry']['event_id'], 'event2') 281 | self.assertEqual(age.counter.payments[1].meta['entry']['event_id'], 'event3') 282 | 283 | -------------------------------------------------------------------------------- /pyluca/tests/test_amount_counter.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from unittest import TestCase 3 | 4 | from pyluca.amount_counter import AmountCounter 5 | 6 | 7 | class TestAmountCounter(TestCase): 8 | def test_amount_counter(self): 9 | counter = AmountCounter(1000) 10 | now = datetime.now() 11 | self.assertEqual(counter.pay(0, now)[1], 0) 12 | self.assertEqual(counter.pay(5, now)[1], 0) 13 | self.assertEqual(counter.get_balance(), 995) 14 | self.assertEqual(counter.pay(1000, now)[1], 5) 15 | self.assertEqual(counter.get_balance(), 0) 16 | self.assertTrue(counter.is_paid()) 17 | counter.add(20) 18 | self.assertFalse(counter.is_paid()) 19 | self.assertTrue(counter.get_balance(), 20) 20 | self.assertEqual(counter.pay(10, now)[1], 0) 21 | self.assertEqual(counter.pay(100, now)[1], 90) 22 | self.assertTrue(counter.is_paid()) 23 | 24 | def test_payments(self): 25 | counter = AmountCounter(1000) 26 | counter.pay(33.3, datetime(2022, 4, 20)) 27 | self.assertEqual(counter.get_balance(), 966.7) 28 | self.assertEqual(len(counter.payments), 1) 29 | self.assertEqual(counter.payments[0].amount, 33.3) 30 | self.assertEqual(counter.payments[0].date, datetime(2022, 4, 20)) 31 | self.assertAlmostEqual(counter.pay(1000, datetime(2022, 4, 30))[1], 33.3) 32 | 33 | def test_tolerance(self): 34 | counter = AmountCounter(100, 1e-2) 35 | counter.pay(99.99, datetime(2023, 5, 26)) 36 | self.assertAlmostEqual(counter.get_balance(), 1e-2) 37 | self.assertFalse(counter.is_paid()) 38 | 39 | counter = AmountCounter(100, 1e-2) 40 | counter.pay(99.999, datetime(2023, 5, 26)) 41 | self.assertAlmostEqual(counter.get_balance(), 1e-3) 42 | self.assertTrue(counter.is_paid()) 43 | 44 | counter = AmountCounter(100, 1e-7) 45 | counter.pay(99.999, datetime(2023, 5, 26)) 46 | self.assertAlmostEqual(counter.get_balance(), 1e-3) 47 | self.assertFalse(counter.is_paid()) 48 | 49 | counter = AmountCounter(100, 1e-7) 50 | counter.pay(99.99999999, datetime(2023, 5, 26)) 51 | self.assertAlmostEqual(counter.get_balance(), 1e-8) 52 | self.assertTrue(counter.is_paid()) 53 | -------------------------------------------------------------------------------- /pyluca/tests/test_journal.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from unittest import TestCase 3 | from pyluca.journal import Journal, JournalEntry, InvalidEntryException 4 | 5 | 6 | class TestJournal(TestCase): 7 | def test_add_journal_entry(self): 8 | journal = Journal() 9 | self.assertEqual(journal.max_date, None) 10 | 11 | journal.add_entry(JournalEntry(1, 'SAVINGS_BANK', 30000, 0, datetime(2023, 1, 31), 'Jan Salary', 'person2', None)) 12 | journal.add_entry(JournalEntry(2, 'SALARY', 0, 30000, datetime(2023, 1, 31), 'Jan Salary', 'person2', None)) 13 | self.assertEqual(len(journal.entries), 2) 14 | self.assertEqual(journal.max_date, datetime(2023, 1, 31)) 15 | 16 | journal = Journal(entries=[ 17 | JournalEntry(1, 'SAVINGS_BANK', 30000, 0, datetime(2023, 1, 31), 'Jan Salary', 'person2', None), 18 | JournalEntry(2, 'SALARY', 0, 30000, datetime(2023, 1, 31), 'Jan Salary', 'person2', None), 19 | JournalEntry(3, 'LOANS', 5000, 0, datetime(2023, 2, 1), 'Lend to person2', 'person2', None), 20 | JournalEntry(4, 'SAVINGS_BANK', 0, 5000, datetime(2023, 2, 1), 'Lend to person2', 'person2', None) 21 | ]) 22 | self.assertEqual(journal.max_date, datetime(2023, 2, 1)) 23 | 24 | journal.add_entry(JournalEntry(5, 'LOANS_PAYBACK', 2500, 0, datetime(2023, 2, 2), 'Loans Payback', 'person2', None)) 25 | journal.add_entry(JournalEntry(6, 'LOANS', 0, 2500, datetime(2023, 2, 2), 'Loans Payback', 'person2', None)) 26 | self.assertEqual(journal.max_date, datetime(2023, 2, 2)) 27 | 28 | self.assertRaises(InvalidEntryException, lambda: journal.add_entry( 29 | JournalEntry(7, 'SAVINGS_BANK', 2000, 0, datetime(2023, 2, 1), 'Invest something', 'person2', None) 30 | )) 31 | 32 | -------------------------------------------------------------------------------- /pyluca/tests/test_ledger.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from unittest import TestCase 3 | from pyluca.accountant import Accountant 4 | from pyluca.journal import Journal, JournalEntry 5 | from pyluca.ledger import Ledger, AccountLedger, InvalidLedgerEntry 6 | from pyluca.account_config import BalanceType 7 | from pyluca.tests.test_aging import account_config 8 | 9 | sample_ledger_entries = [ 10 | { 11 | 'sl_no': 1, 12 | 'date': datetime(2024, 3, 1), 13 | 'dr_amount': 0, 14 | 'cr_amount': 20000, 15 | 'narration': 'salary credited', 16 | 'balance': 20000, 17 | 'event_id': None 18 | }, 19 | { 20 | 'sl_no': 2, 21 | 'date': datetime(2024, 3, 2), 22 | 'dr_amount': 5000, 23 | 'cr_amount': 0, 24 | 'narration': 'home loan emi', 25 | 'balance': 15000, 26 | 'event_id': None 27 | }, 28 | { 29 | 'sl_no': 3, 30 | 'date': datetime(2024, 3, 3), 31 | 'dr_amount': 3000, 32 | 'cr_amount': 0, 33 | 'narration': 'bike emi', 34 | 'balance': 12000, 35 | 'event_id': None 36 | }, 37 | { 38 | 'sl_no': 4, 39 | 'date': datetime(2024, 3, 4), 40 | 'dr_amount': 10000, 41 | 'cr_amount': 0, 42 | 'narration': 'Rent', 43 | 'balance': 2000, 44 | 'event_id': None 45 | }, 46 | { 47 | 'sl_no': 5, 48 | 'date': datetime(2024, 3, 5), 49 | 'dr_amount': 0, 50 | 'cr_amount': 5000, 51 | 'narration': 'borrowed from friend', 52 | 'balance': 7000, 53 | 'event_id': None 54 | }, 55 | { 56 | 'sl_no': 6, 57 | 'date': datetime(2024, 3, 6), 58 | 'dr_amount': 4000, 59 | 'cr_amount': 0, 60 | 'narration': 'SIP Investment', 61 | 'balance': 3000, 62 | 'event_id': None 63 | }, 64 | { 65 | 'sl_no': 7, 66 | 'date': datetime(2024, 3, 6), 67 | 'balance': 2000, 68 | 'cr_amount': 0, 69 | 'dr_amount': 1000, 70 | 'event_id': None, 71 | 'narration': 'shopping' 72 | } 73 | ] 74 | 75 | sample_ledger = [ 76 | { 77 | 'date': datetime(2022, 4, 30, 10, 15), 78 | 'dr_amount': 20000, 'cr_amount': 0, 79 | 'narration': 'April salary', 80 | 'balance': 20000, 81 | 'event_id': None, 82 | 'sl_no': 0, 83 | 'account': 'SAVINGS_BANK', 84 | 'key': 'loan' 85 | }, 86 | { 87 | 'date': datetime(2022, 4, 30, 10, 15), 88 | 'dr_amount': 0, 89 | 'cr_amount': 20000, 90 | 'narration': 'April salary', 91 | 'balance': 20000, 92 | 'event_id': None, 93 | 'sl_no': 1, 94 | 'account': 'SALARY', 95 | 'key': 'loan' 96 | }, 97 | { 98 | 'date': datetime(2022, 5, 1, 0, 0), 99 | 'dr_amount': 10000, 100 | 'cr_amount': 0, 101 | 'narration': 'ELSS', 102 | 'balance': 10000, 103 | 'event_id': None, 104 | 'sl_no': 2, 105 | 'account': 'MUTUAL_FUNDS', 106 | 'key': 'loan' 107 | }, 108 | { 109 | 'date': datetime(2022, 5, 1, 0, 0), 110 | 'dr_amount': 0, 111 | 'cr_amount': 10000, 112 | 'narration': 'ELSS', 113 | 'balance': 10000, 'event_id': None, 114 | 'sl_no': 3, 115 | 'account': 'SAVINGS_BANK', 116 | 'key': 'loan' 117 | }, 118 | { 119 | 'date': datetime(2022, 5, 2, 10, 40), 120 | 'dr_amount': 5000, 121 | 'cr_amount': 0, 122 | 'narration': 'Lent to friend', 123 | 'balance': 5000, 124 | 'event_id': None, 125 | 'sl_no': 4, 126 | 'account': 'LOANS', 127 | 'key': 'loan' 128 | }, 129 | { 130 | 'date': datetime(2022, 5, 2, 10, 40), 131 | 'dr_amount': 0, 132 | 'cr_amount': 5000, 133 | 'narration': 'Lent to friend', 134 | 'balance': 5000, 135 | 'event_id': None, 136 | 'sl_no': 5, 137 | 'account': 'SAVINGS_BANK', 138 | 'key': 'loan' 139 | }, 140 | { 141 | 'date': datetime(2022, 5, 2, 10, 45), 142 | 'dr_amount': 3000, 143 | 'cr_amount': 0, 144 | 'narration': 'EMI 3/48', 145 | 'balance': 3000, 146 | 'event_id': None, 147 | 'sl_no': 6, 148 | 'account': 'CAR_EMI', 149 | 'key': 'loan' 150 | }, 151 | { 152 | 'date': datetime(2022, 5, 2, 10, 45), 153 | 'dr_amount': 0, 154 | 'cr_amount': 3000, 155 | 'narration': 'EMI 3/48', 156 | 'balance': 2000, 157 | 'event_id': None, 158 | 'sl_no': 7, 159 | 'account': 'SAVINGS_BANK', 160 | 'key': 'loan' 161 | } 162 | ] 163 | 164 | 165 | class TestLedger(TestCase): 166 | def test_ledger_balance_sheet(self): 167 | accountant = Accountant(Journal(), account_config, '1') 168 | accountant.enter_journal('SAVINGS_BANK', 'SALARY', 20000, datetime(2022, 4, 30), 'April salary') 169 | accountant.enter_journal('MUTUAL_FUNDS', 'SAVINGS_BANK', 10000, datetime(2022, 5, 1), 'ELSS') 170 | accountant.enter_journal('LOANS', 'SAVINGS_BANK', 5000, datetime(2022, 5, 2), 'Lend to Pramod') 171 | accountant.enter_journal('CAR_EMI', 'SAVINGS_BANK', 3000, datetime(2022, 5, 2), 'EMI 3/48') 172 | ledger = Ledger(accountant.journal, account_config) 173 | last_row = ledger.get_balance_sheet().to_dict(orient='records')[-1] 174 | self.assertEqual(last_row['SALARY'], 20000) 175 | self.assertEqual(last_row['SAVINGS_BANK'], 2000) 176 | self.assertEqual(last_row['MUTUAL_FUNDS'], 10000) 177 | self.assertEqual(last_row['LOANS'], 5000) 178 | self.assertEqual(last_row['CAR_EMI'], 3000) 179 | 180 | def test_get_account_type_balance(self): 181 | accountant = Accountant(Journal(), account_config, '2') 182 | accountant.enter_journal('SAVINGS_BANK', 'SALARY', 20000, datetime(2022, 4, 30), 'April salary') 183 | accountant.enter_journal('MUTUAL_FUNDS', 'SAVINGS_BANK', 10000, datetime(2022, 5, 1), 'ELSS') 184 | accountant.enter_journal('LOANS', 'SAVINGS_BANK', 5000, datetime(2022, 5, 2), 'Lend to Pramod') 185 | accountant.enter_journal('CAR_EMI', 'SAVINGS_BANK', 3000, datetime(2022, 5, 2), 'EMI 3/48') 186 | ledger = Ledger(accountant.journal, account_config) 187 | self.assertEqual(17000, ledger.get_account_type_balance('ASSET')) 188 | self.assertEqual(20000, ledger.get_account_type_balance('INCOME')) 189 | self.assertEqual(3000, ledger.get_account_type_balance('EXPENSE')) 190 | 191 | self.assertEqual(15000, ledger.get_account_type_balance('ASSET', ['SAVINGS_BANK'])) 192 | self.assertEqual(10000, ledger.get_account_type_balance('ASSET', ['SAVINGS_BANK', 'LOANS'])) 193 | 194 | def test_account_name_ledger_df(self): 195 | accountant = Accountant(Journal(), account_config, '2') 196 | accountant.enter_journal('SAVINGS_BANK', 'SALARY', 20000, datetime(2022, 4, 30), 'April salary') 197 | accountant.enter_journal('MUTUAL_FUNDS', 'SAVINGS_BANK', 10000, datetime(2022, 5, 1), 'ELSS') 198 | accountant.enter_journal('LOANS', 'SAVINGS_BANK', 5000, datetime(2022, 5, 2), 'Lend to Pramod') 199 | accountant.enter_journal('CAR_EMI', 'SAVINGS_BANK', 3000, datetime(2022, 5, 2), 'EMI 3/48') 200 | ledger = Ledger(accountant.journal, account_config) 201 | ledger_df = ledger.get_df() 202 | self.assertEqual( 203 | 'SAVINGS_ACCOUNT', 204 | ledger_df[ledger_df['account'] == 'SAVINGS_BANK'].reset_index()._get_value(0, 'account_name') 205 | ) 206 | self.assertEqual( 207 | 'SALARY_AMOUNT', 208 | ledger_df[ledger_df['account'] == 'SALARY'].reset_index()._get_value(0, 'account_name') 209 | ) 210 | 211 | def test_get_accounts_balance(self): 212 | accountant = Accountant(Journal(), account_config, '3') 213 | accountant.enter_journal('SAVINGS_BANK', 'SALARY', 20000, datetime(2022, 4, 30), 'April salary') 214 | accountant.enter_journal('MUTUAL_FUNDS', 'SAVINGS_BANK', 10000, datetime(2022, 5, 1), 'ELSS') 215 | accountant.enter_journal('LOANS', 'SAVINGS_BANK', 5000, datetime(2022, 5, 2), 'Lend to Pramod') 216 | accountant.enter_journal('CAR_EMI', 'SAVINGS_BANK', 3000, datetime(2022, 5, 2), 'EMI 3/48') 217 | ledger = Ledger(accountant.journal, account_config) 218 | accounts_balance = ledger.get_balances() 219 | self.assertEqual(accounts_balance['SAVINGS_BANK'], 2000) 220 | self.assertEqual(accounts_balance['SALARY'], 20000) 221 | self.assertEqual(accounts_balance['MUTUAL_FUNDS'], 10000) 222 | self.assertEqual(accounts_balance['LOANS'], 5000) 223 | self.assertEqual(accounts_balance['CAR_EMI'], 3000) 224 | 225 | def test_get_account_balance_as_of(self): 226 | accountant = Accountant(Journal(), account_config, '3') 227 | accountant.enter_journal('SAVINGS_BANK', 'SALARY', 20000, datetime(2022, 4, 30), 'April salary') 228 | accountant.enter_journal('MUTUAL_FUNDS', 'SAVINGS_BANK', 10000, datetime(2022, 5, 1), 'ELSS') 229 | accountant.enter_journal('LOANS', 'SAVINGS_BANK', 5000, datetime(2022, 5, 2), 'Lend to Pramod') 230 | accountant.enter_journal('CAR_EMI', 'SAVINGS_BANK', 3000, datetime(2022, 5, 2), 'EMI 3/48') 231 | ledger = Ledger(accountant.journal, account_config) 232 | self.assertEqual(ledger.get_account_balance('SAVINGS_BANK', datetime(2022, 4, 29)), 0) 233 | self.assertEqual(ledger.get_account_balance('SAVINGS_BANK', datetime(2022, 4, 30)), 20000) 234 | self.assertEqual(ledger.get_account_balance('SAVINGS_BANK', datetime(2022, 5, 1)), 10000) 235 | self.assertEqual(ledger.get_account_balance('SAVINGS_BANK', datetime(2022, 5, 2)), 2000) 236 | self.assertEqual( 237 | ledger.get_account_balance('SAVINGS_BANK'), 238 | ledger.get_account_balance('SAVINGS_BANK', datetime(2022, 5, 2)) 239 | ) 240 | 241 | def test_get_accounts_balance_as_of(self): 242 | accountant = Accountant(Journal(), account_config, '3') 243 | accountant.enter_journal('SAVINGS_BANK', 'SALARY', 20000, datetime(2022, 4, 30), 'April salary') 244 | accountant.enter_journal('MUTUAL_FUNDS', 'SAVINGS_BANK', 10000, datetime(2022, 5, 1), 'ELSS') 245 | accountant.enter_journal('LOANS', 'SAVINGS_BANK', 5000, datetime(2022, 5, 2), 'Lend to Pramod') 246 | accountant.enter_journal('CAR_EMI', 'SAVINGS_BANK', 3000, datetime(2022, 5, 2), 'EMI 3/48') 247 | ledger = Ledger(accountant.journal, account_config) 248 | 249 | accounts_balance = ledger.get_balances(datetime(2022, 4, 29)) 250 | self.assertEqual(accounts_balance['SAVINGS_BANK'], 0) 251 | self.assertEqual(accounts_balance['SALARY'], 0) 252 | self.assertEqual(accounts_balance['MUTUAL_FUNDS'], 0) 253 | self.assertEqual(accounts_balance['LOANS'], 0) 254 | self.assertEqual(accounts_balance['CAR_EMI'], 0) 255 | 256 | accounts_balance = ledger.get_balances(datetime(2022, 4, 30)) 257 | self.assertEqual(accounts_balance['SAVINGS_BANK'], 20000) 258 | self.assertEqual(accounts_balance['SALARY'], 20000) 259 | 260 | accounts_balance = ledger.get_balances(datetime(2022, 5, 1)) 261 | self.assertEqual(accounts_balance['SAVINGS_BANK'], 10000) 262 | self.assertEqual(accounts_balance['MUTUAL_FUNDS'], 10000) 263 | 264 | accounts_balance = ledger.get_balances(datetime(2022, 5, 2)) 265 | self.assertEqual(accounts_balance['SAVINGS_BANK'], 2000) 266 | self.assertEqual(accounts_balance['LOANS'], 5000) 267 | self.assertEqual(accounts_balance['CAR_EMI'], 3000) 268 | 269 | self.assertEqual(ledger.get_balances(), ledger.get_balances(datetime(2022, 5, 2))) 270 | 271 | def test_account_ledger(self): 272 | ledger = AccountLedger("Savings", BalanceType.CREDIT) 273 | self.assertEqual(ledger.get_balance(), 0) 274 | ledger.add_entry(sl_no=1, date=datetime(2024, 3, 1), dr_amount=0, cr_amount=20000, narration="salary credited", 275 | event_id=None) 276 | self.assertEqual(ledger.get_balance(), 20000) 277 | self.assertEqual(ledger.get_balance(as_of=datetime(2024, 2, 29)), 0) 278 | self.assertEqual(ledger.get_balance(as_of=datetime(2024, 3, 1)), 20000) 279 | self.assertEqual(ledger.get_balance(as_of=datetime(2024, 3, 2)), 20000) 280 | with self.assertRaises(InvalidLedgerEntry) as e: 281 | ledger.add_entry(sl_no=2, date=datetime(2024, 2, 29), dr_amount=5000, cr_amount=0, narration="loan emi", 282 | event_id=None) 283 | self.assertEqual(e.exception.__str__(), "Backdated entry can't be added") 284 | ledger.add_entry(sl_no=2, date=datetime(2024, 3, 2), dr_amount=5000, cr_amount=0, narration="home loan emi", 285 | event_id=None) 286 | ledger.add_entry(sl_no=3, date=datetime(2024, 3, 3), dr_amount=3000, cr_amount=0, narration="bike emi", 287 | event_id=None) 288 | ledger.add_entry(sl_no=4, date=datetime(2024, 3, 4), dr_amount=10000, cr_amount=0, narration="Rent", 289 | event_id=None) 290 | ledger.add_entry(sl_no=5, date=datetime(2024, 3, 5), dr_amount=0, cr_amount=5000, narration="borrowed from friend", 291 | event_id=None) 292 | ledger.add_entry(sl_no=6, date=datetime(2024, 3, 6), dr_amount=4000, cr_amount=0, narration="SIP Investment", 293 | event_id=None) 294 | ledger.add_entry(sl_no=7, date=datetime(2024, 3, 6), dr_amount=1000, cr_amount=0, narration="shopping", 295 | event_id=None) 296 | self.assertEqual(ledger.get_balance(), 2000) 297 | self.assertEqual(ledger.get_balance(as_of=datetime(2024, 3, 2)), 15000) 298 | self.assertEqual(ledger.get_balance(as_of=datetime(2024, 3, 3)), 12000) 299 | self.assertEqual(ledger.get_balance(as_of=datetime(2024, 3, 4)), 2000) 300 | self.assertEqual(ledger.get_balance(as_of=datetime(2024, 3, 5)), 7000) 301 | self.assertEqual(ledger.get_balance(as_of=datetime(2024, 3, 6)), 2000) 302 | self.assertEqual(ledger.get_balance(as_of=datetime(2024, 3, 7)), 2000) 303 | self.assertEqual([entry._asdict() for entry in ledger.get_entries()], sample_ledger_entries) 304 | 305 | ledger = AccountLedger("Asset", BalanceType.DEBIT) 306 | ledger.add_entry(sl_no=1, date=datetime(2024, 3, 1), dr_amount=5000, cr_amount=0, narration="lent to friend", 307 | event_id=None) 308 | ledger.add_entry(sl_no=2, date=datetime(2024, 3, 2), dr_amount=0, cr_amount=2000, narration="received 2000", 309 | event_id=None) 310 | ledger.add_entry(sl_no=3, date=datetime(2024, 3, 3), dr_amount=3000, cr_amount=0, narration="Invested in stock", 311 | event_id=None) 312 | self.assertEqual(ledger.get_balance(), 6000) 313 | self.assertEqual(ledger.get_balance(as_of=datetime(2024, 2, 29)), 0) 314 | self.assertEqual(ledger.get_balance(as_of=datetime(2024, 3, 1)), 5000) 315 | self.assertEqual(ledger.get_balance(as_of=datetime(2024, 3, 2)), 3000) 316 | self.assertEqual(ledger.get_balance(as_of=datetime(2024, 3, 3)), 6000) 317 | self.assertEqual(ledger.get_balance(as_of=datetime(2024, 3, 4)), 6000) 318 | 319 | def test_get_ledger(self): 320 | ledger = Ledger(Journal(), account_config, 'loan') 321 | ledger.add_entry('SAVINGS_BANK', 'SALARY', 20000, datetime(2022, 4, 30, 10, 15), 'April salary') 322 | ledger.add_entry('MUTUAL_FUNDS', 'SAVINGS_BANK', 10000, datetime(2022, 5, 1, 0, 0), 'ELSS') 323 | ledger.add_entry('LOANS', 'SAVINGS_BANK', 5000, datetime(2022, 5, 2, 10, 40), 'Lent to friend') 324 | ledger.add_entry('CAR_EMI', 'SAVINGS_BANK', 3000, datetime(2022, 5, 2, 10, 45), 'EMI 3/48') 325 | self.assertEqual(ledger.get_ledger(), sample_ledger) 326 | columns = ledger.get_df().columns 327 | self.assertEqual('balance' in columns, False) 328 | self.assertEqual('account_name' in columns, True) 329 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pandas==2.1.3 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup( 4 | name='pyluca', 5 | version='3.1.3', 6 | author='datasignstech', 7 | author_email='tech+opensource@datasignstech.com', 8 | description='Double entry accounting system', 9 | url='https://github.com/datasignstech/pyluca', 10 | packages=['pyluca'], 11 | include_package_data=True, 12 | long_description='A headless python Double Entry Accounting package', 13 | long_description_content_type='text/plain', 14 | install_requires=[r for r in open('requirements.txt', 'r').read().split('\n') if r] 15 | ) 16 | --------------------------------------------------------------------------------