In this article, we will attempt to penetrate into the very heart of the "bloody enterprise" - accounting. First, we will conduct a study of the general ledger, accounts and balance sheet, identify their inherent properties and algorithms. We use Python and Test Driven Development technology. Here we will be engaged in prototyping, so instead of the database we will use the basic containers: lists, dictionaries and tuples. The project is being developed in accordance with Empire ERP project requirements.
The task
Space ... Planet of Empireia ... One state on the whole planet. The population works 2 hours in 2 weeks, after 2 years to retire. The chart of accounts consists of 12 positions. Accounts 1-4 are active, 5-8 are active-passive, 9-12 are passive. Company Horns & Hooves. All transactions are performed in one reporting period, at the beginning of the period there are no balances.
Project setup
We clone the project from the github:
git clone https://github.com/nomhoi/empire-erp.git
We are developing in Python 3.7.4. Set up the virtual environment, activate it and install pytest .
pip install pytest
1. General ledger
Go to the reaserch / day1 / step1 folder.
accounting.py :
DEBIT = 0 CREDIT = 1 AMOUNT = 2 class GeneralLedger(list): def __str__(self): res = '\nGeneral ledger' for e in self: res += '\n {:2} {:2} {:8.2f}'.format(e[DEBIT], e[CREDIT], e[AMOUNT]) res += "\n----------------------" return res
test_accounting.py :
import pytest from accounting import * from decimal import * @pytest.fixture def ledger(): return GeneralLedger() @pytest.mark.parametrize('entries', [ [(1, 12, 100.00), (1, 11, 100.00)] ]) def test_ledger(ledger, entries): for entry in entries: ledger.append((entry[DEBIT], entry[CREDIT], Decimal(entry[AMOUNT]))) assert len(ledger) == 2 assert ledger[0][DEBIT] == 1 assert ledger[0][CREDIT] == 12 assert ledger[0][AMOUNT] == Decimal(100.00) assert ledger[1][DEBIT] == 1 assert ledger[1][CREDIT] == 11 assert ledger[1][AMOUNT] == Decimal(100.00) print(ledger)
The main book, as we see, is presented as a list of records. Each entry is designed as a tuple. For the record of the transaction so far we use only the account numbers for debit and credit and the amount of the transaction. Dates, descriptions and other information are not needed yet, we will add them later.
The ledger latch and the parameterized test test_ledger were created in the test file. In the entries test parameter, we immediately transfer the entire list of transactions. To check, we execute the pytest -s -v command in the terminal. The test should pass, and we will see in the terminal the entire list of transactions stored in the general ledger:
General ledger 1 12 100.00 1 11 100.00
2. Accounts
Now let's add invoice support to the project. Go to the folder day1 / step2 .
accounting.py :
class GeneralLedger(list): def __init__(self, accounts=None): self.accounts = accounts def append(self, entry): if self.accounts is not None: self.accounts.append_entry(entry) super().append(entry)
The GeneralLedger class overloaded the append method. When you add a transaction to a book, we add it immediately to the accounts.
accounting.py :
class Account: def __init__(self, id, begin=Decimal(0.00)): self.id = id self.begin = begin self.end = begin self.entries = [] def append(self, id, amount): self.entries.append((id, amount)) self.end += amount class Accounts(dict): def __init__(self): self.range = range(1, 13) for i in self.range: self[i] = Account(i) def append_entry(self, entry): self[entry[DEBIT]].append(entry[CREDIT], Decimal(entry[AMOUNT])) self[entry[CREDIT]].append(entry[DEBIT], Decimal(-entry[AMOUNT]))
The Accounts class is designed as a dictionary. In keys, the account number, in values โโthe contents of the account, i.e. an instance of the Account class, which in turn contains the opening and closing balance fields and a list of transactions related to this account. Note that in this list the amounts of the debit and credit transactions are stored in one field, the debit amount is positive, the loan amount is negative.
test_accounting.py :
@pytest.fixture def accounts(): return Accounts() @pytest.fixture def ledger(accounts): return GeneralLedger(accounts)
In the test file, we added the accounts lock and adjusted the ledger lock.
test_accounting.py :
@pytest.mark.parametrize('entries', [ [(1, 12, 100.00), (1, 11, 100.00)] ]) def test_accounts(accounts, ledger, entries): for entry in entries: ledger.append((entry[DEBIT], entry[CREDIT], Decimal(entry[AMOUNT]))) assert len(ledger) == 2 assert ledger[0][DEBIT] == 1 assert ledger[0][CREDIT] == 12 assert ledger[0][AMOUNT] == Decimal(100.00) assert len(accounts) == 12 assert accounts[1].end == Decimal(200.00) assert accounts[11].end == Decimal(-100.00) assert accounts[12].end == Decimal(-100.00) print(ledger) print(accounts)
Added a new test test_accounts .
Run the test and observe the output:
General ledger 1 12 100.00 1 11 100.00 ---------------------- Account 1 beg: 0.00 0.00 12: 100.00 0.00 11: 100.00 0.00 end: 200.00 0.00 ---------------------- Account 11 beg: 0.00 0.00 1: 0.00 100.00 end: 0.00 100.00 ---------------------- Account 12 beg: 0.00 0.00 1: 0.00 100.00 end: 0.00 100.00 ----------------------
In the Account and Acconts classes, the __str__ methods are also overloaded, you can see in the project sources. The amounts of postings and balances for better clarity are presented in two columns: debit and credit.
3. Accounts: posting verification
We recall this rule:
. . - .
That is, in an instance of the Account class, the end value (final balance) on active accounts cannot be negative, and on passive accounts it cannot be positive.
Go to the folder day1 / step3 .
accounting.py :
class BalanceException(Exception): pass
Added a BalanceException .
class Account: ... def is_active(self): return True if self.id < 5 else False def is_passive(self): return True if self.id > 8 else False ...
In the Account class, a check was added to determine whether the account belongs to either active or passive.
class Accounts(dict): ... def check_balance(self, entry): if self[entry[CREDIT]].end - Decimal(entry[AMOUNT]) < 0 and self[entry[CREDIT]].is_active(): raise BalanceException('BalanceException') if self[entry[DEBIT]].end + Decimal(entry[AMOUNT]) > 0 and self[entry[DEBIT]].is_passive(): raise BalanceException('BalanceException') ...
A check was added to the Accounts.py class, if as a result of adding a new transaction a negative debit value is generated on the active account, an exception will be raised, and the same thing if a negative credit value is obtained on the passive account.
class GeneralLedger(list): ... def append(self, entry): if self.accounts is not None: self.accounts.check_balance(entry) self.accounts.append_entry(entry) super().append(entry) ...
In the GeneralLedger class , before adding the posting to the accounts, we perform a check. If an exception is raised, the posting does not fall into either the accounts or the general ledger.
test_accounting.py :
@pytest.mark.parametrize('entries, exception', [ ([(12, 1, 100.00)], BalanceException('BalanceException')), ([(12, 6, 100.00)], BalanceException('BalanceException')), ([(12, 11, 100.00)], BalanceException('BalanceException')), ([(6, 2, 100.00)], BalanceException('BalanceException')), #([(6, 7, 100.00)], BalanceException('BalanceException')), #([(6, 12, 100.00)], BalanceException('BalanceException')), ([(1, 2, 100.00)], BalanceException('BalanceException')), #([(1, 6, 100.00)], BalanceException('BalanceException')), #([(1, 12, 100.00)], BalanceException('BalanceException')), ]) def test_accounts_balance(accounts, ledger, entries, exception): for entry in entries: try: ledger.append((entry[DEBIT], entry[CREDIT], Decimal(entry[AMOUNT]))) except BalanceException as inst: assert isinstance(inst, type(exception)) assert inst.args == exception.args else: pytest.fail("Expected error but found none") assert len(ledger) == 0 assert len(accounts) == 12
The test_accounts_balance test was added to the test module . In the list of transactions, all possible combinations of transactions are listed first and all transactions that do not raise an exception are commented out. Run the test and make sure that the remaining 5 posting options raise a BalanceException .
4. Balance
Go to the folder day1 / step4 .
accounting.py :
class Balance(list): def __init__(self, accounts): self.accounts = accounts self.suma = Decimal(0.00) self.sump = Decimal(0.00) def create(self): self.suma = Decimal(0.00) self.sump = Decimal(0.00) for i in self.accounts.range: active = self.accounts[i].end if self.accounts[i].end >= 0 else Decimal(0.00) passive = -self.accounts[i].end if self.accounts[i].end < 0 else Decimal(0.00) self.append((active, passive)) self.suma += active self.sump += passive
When creating a balance, we simply collect the balances from all accounts in one table.
test_accounting.py :
@pytest.fixture def balance(accounts): return Balance(accounts)
Create a balance latch.
@pytest.mark.parametrize('entries', [ [ ( 1, 12, 200.00), # increase active and passive ],[ ( 1, 12, 200.00), # increase active and passive (12, 1, 100.00), # decrease passive and decrease active ],[ ( 1, 12, 300.00), # increase active and passive (12, 1, 100.00), # decrease passive and decrease active ( 2, 1, 100.00), # increase active and decrease active ],[ ( 1, 12, 300.00), # increase active and passive (12, 1, 100.00), # decrease passive and decrease active ( 2, 1, 100.00), # increase active and decrease active (12, 11, 100.00), # decrease passive and increase passive ] ]) def test_balance(accounts, ledger, balance, entries): for entry in entries: ledger.append(entry) balance.create() print(accounts) print(balance)
We created the test_balance test. In the parameter lists, all possible types of transactions were listed: increasing the asset and liability, decreasing the asset and liability, increasing the asset and decreasing the asset, increasing the liability and decreasing the liability. Issued 4 options for postings, so that you can step by step to see the output. For the last option, the output is as follows:
General ledger 1 12 300.00 12 1 100.00 2 1 100.00 12 11 100.00 ---------------------- Account 1 beg: 0.00 0.00 12: 300.00 0.00 12: 0.00 100.00 2: 0.00 100.00 end: 100.00 0.00 ---------------------- Account 2 beg: 0.00 0.00 1: 100.00 0.00 end: 100.00 0.00 ---------------------- Account 11 beg: 0.00 0.00 12: 0.00 100.00 end: 0.00 100.00 ---------------------- Account 12 beg: 0.00 0.00 1: 0.00 300.00 1: 100.00 0.00 11: 100.00 0.00 end: 0.00 100.00 ---------------------- Balance 1 : 100.00 0.00 2 : 100.00 0.00 3 : 0.00 0.00 4 : 0.00 0.00 5 : 0.00 0.00 6 : 0.00 0.00 7 : 0.00 0.00 8 : 0.00 0.00 9 : 0.00 0.00 10 : 0.00 0.00 11 : 0.00 100.00 12 : 0.00 100.00 ---------------------- sum: 200.00 200.00 ======================
5. Reverse
Now let's check how the reversal is performed.
@pytest.mark.parametrize('entries', [ [ ( 1, 12, 100.00), ( 1, 12,-100.00), ] ]) def test_storno(accounts, ledger, balance, entries): for entry in entries: ledger.append(entry) balance.create() print(ledger) print(accounts) print(balance)
The conclusion was as follows:
General ledger 1 12 100.00 1 12 -100.00 ---------------------- Account 1 beg: 0.00 0.00 12: 100.00 0.00 12: 0.00 100.00 end: 0.00 0.00 ---------------------- Account 12 beg: 0.00 0.00 1: 0.00 100.00 1: 100.00 0.00 end: 0.00 0.00 ---------------------- Balance 1 : 0.00 0.00 2 : 0.00 0.00 3 : 0.00 0.00 4 : 0.00 0.00 5 : 0.00 0.00 6 : 0.00 0.00 7 : 0.00 0.00 8 : 0.00 0.00 9 : 0.00 0.00 10 : 0.00 0.00 11 : 0.00 0.00 12 : 0.00 0.00 ---------------------- sum: 0.00 0.00 ======================
Everything seems to be right.
And if we use such a set of postings, then the test will pass:
( 1, 12, 100.00), (12, 1, 100.00), ( 1, 12,-100.00),
And if such a set, swap the last 2 lines in places, then we get an exception:
( 1, 12, 100.00), ( 1, 12,-100.00), (12, 1, 100.00),
Thus, in order to catch such an error, the reversal must be placed immediately after the transaction being corrected.
Conclusion
In the following articles we will continue the study of accounting and will consider all aspects of the development of the system in accordance with the list of requirements for Empire ERP.