From c527442ac7236d7207bbcc7b5bd3267819a5a7ae Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Wed, 4 Jan 2023 00:27:44 +1100 Subject: [PATCH] Implement API-based generation of reports --- drcr/config.py.example | 23 -- drcr/models.py | 30 +++ drcr/reports.py | 212 +++++++++++++++++++ drcr/reports/__init__.py | 0 drcr/reports/views.py | 78 ------- drcr/templates/index.html | 4 +- drcr/templates/report.html | 65 ++++++ drcr/templates/reports/balance_sheet.html | 149 ------------- drcr/templates/reports/income_statement.html | 96 --------- drcr/views.py | 19 +- drcr/webapp.py | 1 - 11 files changed, 323 insertions(+), 354 deletions(-) create mode 100644 drcr/reports.py delete mode 100644 drcr/reports/__init__.py delete mode 100644 drcr/reports/views.py create mode 100644 drcr/templates/report.html delete mode 100644 drcr/templates/reports/balance_sheet.html delete mode 100644 drcr/templates/reports/income_statement.html diff --git a/drcr/config.py.example b/drcr/config.py.example index 9a104e6..fe7b711 100644 --- a/drcr/config.py.example +++ b/drcr/config.py.example @@ -1,26 +1,3 @@ -COA_MAPPING = { - 'Accounts Payable': 'Accounts payable', - 'Accounts Receivable': 'Accounts receivable', - 'Cash at Bank': 'Cash and cash equivalents', - 'Depreciation': 'Depreciation', - 'Land': 'Property, plant and equipment', - 'Opening Balances': 'Accumulated surplus (deficit)', - 'Operating Costs': 'Operating costs', - 'Purchases': 'Purchases' -} - -BALANCE_SHEET_MAPPING = { - 'Current assets': ['Accounts receivable', 'Cash and cash equivalents'], - 'Non-current assets': ['Property, plant and equipment'], - 'Current liabilities': ['Accounts payable'], - 'Non-current liabilities': [] -} - -INCOME_STATEMENT_MAPPING = { - 'Income': ['Sales'], - 'Expenses': ['Operating costs', 'Purchases'] -} - TAX_MAPPING = { 'Government allowances': [], 'Other work-related expenses': [], diff --git a/drcr/models.py b/drcr/models.py index deabf2b..1a4736e 100644 --- a/drcr/models.py +++ b/drcr/models.py @@ -19,6 +19,8 @@ from markupsafe import Markup from . import AMOUNT_DPS from .database import db +from itertools import groupby + class Transaction(db.Model): __tablename__ = 'transactions' @@ -98,6 +100,14 @@ class Amount: def __neg__(self): return Amount(-self.quantity, self.commodity) + def __add__(self, other): + if self.commodity != other.commodity: + raise ValueError('Cannot add incompatible commodities {} and {}'.format(self.commodity, other.commodity)) + return Amount(self.quantity + other.quantity, self.commodity) + + def __sub__(self, other): + return self + (-other) + def format(self, force_commodity=False): if self.commodity == '$' and not force_commodity: return Markup('{:,.{dps}f}'.format(self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', ' ')) @@ -106,6 +116,12 @@ class Amount: else: return Markup('{1:,.{dps}f} {0}'.format(self.commodity, self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', ' ')) + def format_accounting(self): + if self.quantity >= 0: + return Markup('{:,.{dps}f} '.format(self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', ' ')) + else: + return Markup('({:,.{dps}f})'.format(-self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', ' ')) + def quantity_string(self): if self.commodity == '$': return '{:.{dps}f}'.format(self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS) @@ -194,3 +210,17 @@ class AccountConfiguration(db.Model): account = db.Column(db.String) kind = db.Column(db.String) data = db.Column(db.JSON) + + @staticmethod + def get_all(): + account_configurations = db.session.execute(db.select(AccountConfiguration).order_by(AccountConfiguration.account)).scalars() + account_configurations = {v: list(g) for v, g in groupby(account_configurations, lambda c: c.account)} + + return account_configurations + + @staticmethod + def get_all_kinds(): + account_configurations = AccountConfiguration.get_all() + kinds = {k: [vv.kind for vv in v] for k, v in account_configurations.items()} + + return kinds diff --git a/drcr/reports.py b/drcr/reports.py new file mode 100644 index 0000000..47546fc --- /dev/null +++ b/drcr/reports.py @@ -0,0 +1,212 @@ +# DrCr: Web-based double-entry bookkeeping framework +# Copyright (C) 2022–2023 Lee Yingtong Li (RunasSudo) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from .models import AccountConfiguration, Amount, TrialBalancer +from .webapp import all_transactions + +class Report: + def __init__(self, title=None, entries=None): + self.title = title + self.entries = entries or [] + + def calculate(self): + """Calculate all subtotals, etc.""" + + for entry in self.entries: + entry.calculate(self) + + def by_id(self, id): + # TODO: Make more efficient? + + for entry in self.entries: + if entry.id == id: + return entry + if isinstance(entry, Section): + result = entry.by_id(id) + if result is not None: + return result + + return None + +class Section: + def __init__(self, title=None, entries=None, *, id=None): + self.title = title + self.entries = entries or [] + self.id = id + + def calculate(self, parent): + for entry in self.entries: + entry.calculate(self) + + def by_id(self, id): + # TODO: Make more efficient? + + for entry in self.entries: + if entry.id == id: + return entry + if isinstance(entry, Section): + result = entry.by_id(id) + if result is not None: + return result + + return None + +class Entry: + def __init__(self, text=None, amount=None, *, id=None, heading=False, bordered=False): + self.text = text + self.amount = amount + self.id = id + self.heading = heading + self.bordered = bordered + + def calculate(self, parent): + pass + +class Subtotal: + def __init__(self, text=None, *, id=None, bordered=False): + self.text = text + self.id = id + self.bordered = bordered + + self.amount = None + + def calculate(self, parent): + self.amount = Amount( + sum(e.amount.quantity for e in parent.entries if isinstance(e, Entry)), + '$' + ) + +class Calculated(Entry): + def __init__(self, text=None, calc=None, *, id=None, heading=False, bordered=False): + self.text = text + self.calc = calc + self.id = id + self.heading = heading + self.bordered = bordered + + self.amount = None + + def calculate(self, parent): + self.amount = self.calc(parent) + +class Spacer: + id = None + + def calculate(self, parent): + pass + +def validate_accounts(accounts, account_configurations): + for account in accounts: + n = sum(1 for c in account_configurations.get(account, []) if c in ('drcr.asset', 'drcr.liability', 'drcr.equity', 'drcr.income', 'drcr.expense')) + if n != 1: + raise Exception('Account "{}" mapped to {} account types (expected 1)'.format(account, n)) + +def balance_sheet_report(): + # Get trial balance + balancer = TrialBalancer() + balancer.apply_transactions(all_transactions()) + + accounts = dict(sorted(balancer.accounts.items())) + + # Get account configurations + account_configurations = AccountConfiguration.get_all_kinds() + validate_accounts(accounts, account_configurations) + + report = Report( + title='Balance sheet', + entries=[ + Section( + title='Assets', + entries=[ + Entry(text=account_name, amount=amount) + for account_name, amount in accounts.items() + if 'drcr.asset' in account_configurations.get(account_name, []) + ] + [Subtotal('Total assets', bordered=True)] + ), + Spacer(), + Section( + title='Liabilities', + entries=[ + Entry(text=account_name, amount=-amount) + for account_name, amount in accounts.items() + if 'drcr.liability' in account_configurations.get(account_name, []) + ] + [Subtotal('Total liabilities', bordered=True)] + ), + Spacer(), + Section( + title='Equity', + entries=[ + Entry(text=account_name, amount=-amount) + for account_name, amount in accounts.items() + if 'drcr.equity' in account_configurations.get(account_name, []) + ] + [ + Calculated( + 'Current year surplus (deficit)', + lambda _: income_statement_report().by_id('net_surplus').amount + ), + Subtotal('Total equity', bordered=True) + ] + ), + ] + ) + report.calculate() + + return report + +def income_statement_report(): + # Get trial balance + balancer = TrialBalancer() + balancer.apply_transactions(all_transactions()) + + accounts = dict(sorted(balancer.accounts.items())) + + # Get account configurations + account_configurations = AccountConfiguration.get_all_kinds() + validate_accounts(accounts, account_configurations) + + report = Report( + title='Income statement', + entries=[ + Section( + title='Income', + entries=[ + Entry(text=account_name, amount=-amount) + for account_name, amount in accounts.items() + if 'drcr.income' in account_configurations.get(account_name, []) + ] + [Subtotal('Total income', id='total_income', bordered=True)] + ), + Spacer(), + Section( + title='Expenses', + entries=[ + Entry(text=account_name, amount=amount) + for account_name, amount in accounts.items() + if 'drcr.expense' in account_configurations.get(account_name, []) + ] + [Subtotal('Total expenses', id='total_expenses', bordered=True)] + ), + Spacer(), + Calculated( + 'Net surplus (deficit)', + lambda r: r.by_id('total_income').amount - r.by_id('total_expenses').amount, + id='net_surplus', + heading=True, + bordered=True + ) + ] + ) + report.calculate() + + return report diff --git a/drcr/reports/__init__.py b/drcr/reports/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/drcr/reports/views.py b/drcr/reports/views.py deleted file mode 100644 index 5b8c8c6..0000000 --- a/drcr/reports/views.py +++ /dev/null @@ -1,78 +0,0 @@ -# DrCr: Web-based double-entry bookkeeping framework -# Copyright (C) 2022–2023 Lee Yingtong Li (RunasSudo) -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from flask import render_template - -from ..config import BALANCE_SHEET_MAPPING, COA_MAPPING, INCOME_STATEMENT_MAPPING -from ..models import Amount, TrialBalancer -from ..webapp import all_transactions, app - -def get_trial_balance(): - # Get trial balance and validate COA - balancer = TrialBalancer() - balancer.apply_transactions(all_transactions()) - - # Classify accounts - for source_account, destination_account in COA_MAPPING.items(): - balancer.transfer_balance(source_account, destination_account) - - # Validate COA - for account in balancer.accounts: - if account in BALANCE_SHEET_MAPPING['Current assets']: - continue - if account in BALANCE_SHEET_MAPPING['Non-current assets']: - continue - if account in BALANCE_SHEET_MAPPING['Current liabilities']: - continue - if account in BALANCE_SHEET_MAPPING['Non-current liabilities']: - continue - if account in INCOME_STATEMENT_MAPPING['Income']: - continue - if account in INCOME_STATEMENT_MAPPING['Expenses']: - continue - if account == 'Accumulated surplus (deficit)': - continue - if account == 'Income tax': - continue - - raise Exception('Account "{}" is not mapped to a report'.format(account)) - - return balancer - -#@app.route('/reports/mapped-trial-balance') -#def mapped_trial_balance(): -# balancer = get_trial_balance() -# -# total_dr = Amount(sum(v.quantity for v in balancer.accounts.values() if v.quantity > 0), '$') -# total_cr = Amount(sum(v.quantity for v in balancer.accounts.values() if v.quantity < 0), '$') -# -# return render_template('trial_balance.html', accounts=dict(sorted(balancer.accounts.items())), total_dr=total_dr, total_cr=total_cr) - -@app.route('/reports/balance-sheet') -def balance_sheet(): - balancer = get_trial_balance() - - # Transfer surplus to balance sheet - for account in INCOME_STATEMENT_MAPPING['Income'] + INCOME_STATEMENT_MAPPING['Expenses'] + ['Income tax']: - balancer.transfer_balance(account, 'Current year surplus (deficit)') - - return render_template('reports/balance_sheet.html', accounts=balancer.accounts, running_total=Amount(0, '$'), BALANCE_SHEET_MAPPING=BALANCE_SHEET_MAPPING) - -@app.route('/reports/income-statement') -def income_statement(): - balancer = get_trial_balance() - - return render_template('reports/income_statement.html', accounts=balancer.accounts, running_total=Amount(0, '$'), INCOME_STATEMENT_MAPPING=INCOME_STATEMENT_MAPPING) diff --git a/drcr/templates/index.html b/drcr/templates/index.html index 374583a..0320bf9 100644 --- a/drcr/templates/index.html +++ b/drcr/templates/index.html @@ -31,12 +31,12 @@

Advanced reports

{% endblock %} diff --git a/drcr/templates/report.html b/drcr/templates/report.html new file mode 100644 index 0000000..79b24d0 --- /dev/null +++ b/drcr/templates/report.html @@ -0,0 +1,65 @@ +{# DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022–2023 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +#} + +{% extends 'base.html' %} +{% block title %}{{ report.title }}{% endblock %} + +{% macro render_section(section) %} + + {{ section.title }} + + + {% for entry in section.entries %} + {{ render_entry(entry) }} + {% endfor %} +{% endmacro %} + +{% macro render_entry(entry) %} + {% if entry.__class__.__name__ == 'Section' %} + {{ render_section(entry) }} + {% elif entry.__class__.__name__ == 'Subtotal' %} + + {{ entry.text }} + {{ entry.amount.format_accounting() }} + + {% elif entry.__class__.__name__ == 'Spacer' %} +   + {% else %} + + <{{ 'th' if entry.heading else 'td' }}>{{ entry.text }} + <{{ 'th' if entry.heading else 'td' }} class="text-end">{{ entry.amount.format_accounting() }} + + {% endif %} +{% endmacro %} + +{% block content %} +

{{ report.title }}

+ + + + + + + + + + {% for entry in report.entries %} + {{ render_entry(entry) }} + {% endfor %} + +
+{% endblock %} diff --git a/drcr/templates/reports/balance_sheet.html b/drcr/templates/reports/balance_sheet.html deleted file mode 100644 index f4a5e1f..0000000 --- a/drcr/templates/reports/balance_sheet.html +++ /dev/null @@ -1,149 +0,0 @@ -{# DrCr: Web-based double-entry bookkeeping framework - Copyright (C) 2022 Lee Yingtong Li (RunasSudo) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -#} - -{% extends 'base.html' %} -{% block title %}Balance sheet{% endblock %} - -{% macro fmtbal(amount, mul=1) %} - {# FIXME: Honour AMOUNT_DPS #} - {% if amount.quantity * mul >= 0 %} - {{ '{:,.2f}'.format(amount.quantity|abs / 100) }}  - {% else %} - ({{ '{:,.2f}'.format(amount.quantity|abs / 100) }}) - {% endif %} -{% endmacro %} - -{% macro acctrow(name, mul=1) %} - - {% if name == 'Current year surplus (deficit)' %} - {{ name }} - {% else %} - {{ name }} - {% endif %} - {{ fmtbal(accounts[name], mul) }} - - {% set _ = running_total.__setattr__('quantity', running_total.quantity + accounts[name].quantity) %} -{% endmacro %} - -{% block content %} -

Balance sheet

- - - - - - - - - - - - - {% set _ = running_total.__setattr__('quantity', 0) %} - {% for account in BALANCE_SHEET_MAPPING['Current assets'] %} - {{ acctrow(account) }} - {% endfor %} - - - - - {% set assets = running_total.quantity %} - - - - - - - {% set _ = running_total.__setattr__('quantity', 0) %} - {% for account in BALANCE_SHEET_MAPPING['Non-current assets'] %} - {{ acctrow(account) }} - {% endfor %} - - - - - {% set assets = assets + running_total.quantity %} - - - - - - {% set _ = running_total.__setattr__('quantity', assets) %} - - - - - - - - - {% set _ = running_total.__setattr__('quantity', 0) %} - {% for account in BALANCE_SHEET_MAPPING['Current liabilities'] %} - {{ acctrow(account, -1) }} - {% endfor %} - - - - - {% set liabilities = running_total.quantity %} - - - - - - - {% set _ = running_total.__setattr__('quantity', 0) %} - {% for account in BALANCE_SHEET_MAPPING['Non-current liabilities'] %} - {{ acctrow(account, -1) }} - {% endfor %} - - - - - {% set liabilities = liabilities + running_total.quantity %} - - - - - - {% set _ = running_total.__setattr__('quantity', liabilities) %} - - - - - - - - {% set _ = running_total.__setattr__('quantity', assets + liabilities) %} - - - - - - - - - {% set _ = running_total.__setattr__('quantity', 0) %} - {{ acctrow('Accumulated surplus (deficit)', -1) }} - {{ acctrow('Current year surplus (deficit)', -1) }} - - - - - -
Current assets
Total current assets{{ fmtbal(running_total) }}
 
Non-current assets
Total non-current assets{{ fmtbal(running_total) }}
 
Total assets{{ fmtbal(running_total) }}
 
Current liabilities
Total current liabilities{{ fmtbal(running_total, -1) }}
 
Non-current liabilities
Total non-current liabilities{{ fmtbal(running_total, -1) }}
 
Total liabilities{{ fmtbal(running_total, -1) }}
 
Net assets{{ fmtbal(running_total) }}
 
Equity
Total equity{{ fmtbal(running_total, -1) }}
-{% endblock %} diff --git a/drcr/templates/reports/income_statement.html b/drcr/templates/reports/income_statement.html deleted file mode 100644 index 97a626b..0000000 --- a/drcr/templates/reports/income_statement.html +++ /dev/null @@ -1,96 +0,0 @@ -{# DrCr: Web-based double-entry bookkeeping framework - Copyright (C) 2022–2023 Lee Yingtong Li (RunasSudo) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -#} - -{% extends 'base.html' %} -{% block title %}Income statement{% endblock %} - -{% macro fmtbal(amount, mul=1) %} - {# FIXME: Honour AMOUNT_DPS #} - {% if amount.quantity * mul >= 0 %} - {{ '{:,.2f}'.format(amount.quantity|abs / 100) }}  - {% else %} - ({{ '{:,.2f}'.format(amount.quantity|abs / 100) }}) - {% endif %} -{% endmacro %} - -{% macro acctrow(name, mul=1) %} - - {{ name }} - {{ fmtbal(accounts[name], mul) }} - - {% set _ = running_total.__setattr__('quantity', running_total.quantity + accounts[name].quantity) %} -{% endmacro %} - -{% block content %} -

Income statement

- - - - - - - - - - - - - {% set _ = running_total.__setattr__('quantity', 0) %} - {% for account in INCOME_STATEMENT_MAPPING['Income'] if account in accounts %} - {{ acctrow(account, -1) }} - {% endfor %} - - - - - {% set surplus = running_total.quantity %} - - - - - - - {% set _ = running_total.__setattr__('quantity', 0) %} - {% for account in INCOME_STATEMENT_MAPPING['Expenses'] if account in accounts %} - {{ acctrow(account) }} - {% endfor %} - - - - - {% set surplus = surplus + running_total.quantity %} - - - - - - {% set _ = running_total.__setattr__('quantity', surplus) %} - - - - - - {% set surplus = surplus + accounts['Income tax'].quantity %} - - - - {% set _ = running_total.__setattr__('quantity', surplus) %} - - - -
Income
Total income{{ fmtbal(running_total, -1) }}
 
Expenses
Total expenses{{ fmtbal(running_total) }}
 
Net surplus (deficit) before income tax{{ fmtbal(running_total, -1) }}
Income tax expense{{ fmtbal(accounts['Income tax']) }}
Net surplus (deficit) after income tax{{ fmtbal(running_total, -1) }}
-{% endblock %} diff --git a/drcr/views.py b/drcr/views.py index d9fb282..2e981ac 100644 --- a/drcr/views.py +++ b/drcr/views.py @@ -18,21 +18,20 @@ from flask import redirect, render_template, request from .database import db from .models import AccountConfiguration, Amount, Balance, Posting, TrialBalancer +from .reports import balance_sheet_report, income_statement_report from .webapp import all_transactions, app -from itertools import groupby - @app.route('/') def index(): return render_template('index.html') @app.route('/chart-of-accounts') def chart_of_accounts(): - accounts = sorted(db.session.execute(db.select(Posting.account)).unique().scalars().all()) + #accounts = sorted(db.session.execute(db.select(Posting.account)).unique().scalars().all()) + accounts = sorted(list(set(p.account for t in all_transactions() for p in t.postings))) # Get existing AccountConfiguration's - account_configurations = db.session.execute(db.select(AccountConfiguration).order_by(AccountConfiguration.account)).scalars() - account_configurations = {v: list(g) for v, g in groupby(account_configurations, lambda c: c.account)} + account_configurations = AccountConfiguration.get_all() # TODO: Handle orphans return render_template('chart_of_accounts.html', accounts=accounts, account_configurations=account_configurations) @@ -84,3 +83,13 @@ def account_transactions(): running_total=Amount(0, '$'), transactions=sorted(transactions, key=lambda t: t.dt) ) + +@app.route('/balance-sheet') +def balance_sheet(): + report = balance_sheet_report() + return render_template('report.html', report=report) + +@app.route('/income-statement') +def income_statement(): + report = income_statement_report() + return render_template('report.html', report=report) diff --git a/drcr/webapp.py b/drcr/webapp.py index 465f2b8..1cfb869 100644 --- a/drcr/webapp.py +++ b/drcr/webapp.py @@ -39,7 +39,6 @@ def all_transactions(): from . import views from .journal import views -from .reports import views from .statements import views from .tax import views