Implement API-based generation of reports
This commit is contained in:
parent
7df955e5b6
commit
c527442ac7
@ -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 = {
|
TAX_MAPPING = {
|
||||||
'Government allowances': [],
|
'Government allowances': [],
|
||||||
'Other work-related expenses': [],
|
'Other work-related expenses': [],
|
||||||
|
@ -19,6 +19,8 @@ from markupsafe import Markup
|
|||||||
from . import AMOUNT_DPS
|
from . import AMOUNT_DPS
|
||||||
from .database import db
|
from .database import db
|
||||||
|
|
||||||
|
from itertools import groupby
|
||||||
|
|
||||||
class Transaction(db.Model):
|
class Transaction(db.Model):
|
||||||
__tablename__ = 'transactions'
|
__tablename__ = 'transactions'
|
||||||
|
|
||||||
@ -98,6 +100,14 @@ class Amount:
|
|||||||
def __neg__(self):
|
def __neg__(self):
|
||||||
return Amount(-self.quantity, self.commodity)
|
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):
|
def format(self, force_commodity=False):
|
||||||
if self.commodity == '$' and not force_commodity:
|
if self.commodity == '$' and not force_commodity:
|
||||||
return Markup('{:,.{dps}f}'.format(self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', ' '))
|
return Markup('{:,.{dps}f}'.format(self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', ' '))
|
||||||
@ -106,6 +116,12 @@ class Amount:
|
|||||||
else:
|
else:
|
||||||
return Markup('{1:,.{dps}f} {0}'.format(self.commodity, self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', ' '))
|
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):
|
def quantity_string(self):
|
||||||
if self.commodity == '$':
|
if self.commodity == '$':
|
||||||
return '{:.{dps}f}'.format(self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS)
|
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)
|
account = db.Column(db.String)
|
||||||
kind = db.Column(db.String)
|
kind = db.Column(db.String)
|
||||||
data = db.Column(db.JSON)
|
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
|
||||||
|
212
drcr/reports.py
Normal file
212
drcr/reports.py
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
@ -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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
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)
|
|
@ -31,12 +31,12 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li><a href="/general-ledger">General ledger</a></li>
|
<li><a href="/general-ledger">General ledger</a></li>
|
||||||
<li><a href="/trial-balance">Trial balance</a></li>
|
<li><a href="/trial-balance">Trial balance</a></li>
|
||||||
|
<li><a href="/balance-sheet">Balance sheet</a></li>
|
||||||
|
<li><a href="/income-statement">Income statement</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h1 class="h2 my-4">Advanced reports</h1>
|
<h1 class="h2 my-4">Advanced reports</h1>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/reports/balance-sheet">Balance sheet</a></li>
|
|
||||||
<li><a href="/reports/income-statement">Income statement</a></li>
|
|
||||||
<li><a href="/tax/summary">Tax summary</a></li>
|
<li><a href="/tax/summary">Tax summary</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
65
drcr/templates/report.html
Normal file
65
drcr/templates/report.html
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}{{ report.title }}{% endblock %}
|
||||||
|
|
||||||
|
{% macro render_section(section) %}
|
||||||
|
<tr>
|
||||||
|
<th>{{ section.title }}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
{% 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' %}
|
||||||
|
<tr{% if entry.bordered %} style="border-width:1px 0"{% endif %}>
|
||||||
|
<th>{{ entry.text }}</th>
|
||||||
|
<th class="text-end">{{ entry.amount.format_accounting() }}</th>
|
||||||
|
</tr>
|
||||||
|
{% elif entry.__class__.__name__ == 'Spacer' %}
|
||||||
|
<tr><td colspan="2"> </td></tr>
|
||||||
|
{% else %}
|
||||||
|
<tr{% if entry.bordered %} style="border-width:1px 0"{% endif %}>
|
||||||
|
<{{ 'th' if entry.heading else 'td' }}>{{ entry.text }}</{{ 'th' if entry.heading else 'td' }}>
|
||||||
|
<{{ 'th' if entry.heading else 'td' }} class="text-end">{{ entry.amount.format_accounting() }}</{{ 'th' if entry.heading else 'td' }}>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="h2 mt-4">{{ report.title }}</h1>
|
||||||
|
|
||||||
|
<table class="table table-borderless table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom-width:1px">
|
||||||
|
<th></th>
|
||||||
|
<th class="text-end">$ </th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in report.entries %}
|
||||||
|
{{ render_entry(entry) }}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
@ -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 <https://www.gnu.org/licenses/>.
|
|
||||||
#}
|
|
||||||
|
|
||||||
{% 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) %}
|
|
||||||
<tr>
|
|
||||||
{% if name == 'Current year surplus (deficit)' %}
|
|
||||||
<td><a href="/reports/income-statement">{{ name }}</a></td>
|
|
||||||
{% else %}
|
|
||||||
<td>{{ name }}</td>
|
|
||||||
{% endif %}
|
|
||||||
<td class="text-end">{{ fmtbal(accounts[name], mul) }}</td>
|
|
||||||
</tr>
|
|
||||||
{% set _ = running_total.__setattr__('quantity', running_total.quantity + accounts[name].quantity) %}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h1 class="h2 mt-4">Balance sheet</h1>
|
|
||||||
|
|
||||||
<table class="table table-borderless table-sm">
|
|
||||||
<thead>
|
|
||||||
<tr style="border-width:0 0 1px 0">
|
|
||||||
<th></th>
|
|
||||||
<th class="text-end">$ </th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<th colspan="2">Current assets</th>
|
|
||||||
</tr>
|
|
||||||
{% set _ = running_total.__setattr__('quantity', 0) %}
|
|
||||||
{% for account in BALANCE_SHEET_MAPPING['Current assets'] %}
|
|
||||||
{{ acctrow(account) }}
|
|
||||||
{% endfor %}
|
|
||||||
<tr>
|
|
||||||
<th>Total current assets</th>
|
|
||||||
<th class="text-end">{{ fmtbal(running_total) }}</th>
|
|
||||||
</tr>
|
|
||||||
{% set assets = running_total.quantity %}
|
|
||||||
|
|
||||||
<tr><td colspan="2"> </td></tr>
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
<th colspan="2">Non-current assets</th>
|
|
||||||
</tr>
|
|
||||||
{% set _ = running_total.__setattr__('quantity', 0) %}
|
|
||||||
{% for account in BALANCE_SHEET_MAPPING['Non-current assets'] %}
|
|
||||||
{{ acctrow(account) }}
|
|
||||||
{% endfor %}
|
|
||||||
<tr>
|
|
||||||
<th>Total non-current assets</th>
|
|
||||||
<th class="text-end">{{ fmtbal(running_total) }}</th>
|
|
||||||
</tr>
|
|
||||||
{% set assets = assets + running_total.quantity %}
|
|
||||||
|
|
||||||
<tr><td colspan="2"> </td></tr>
|
|
||||||
|
|
||||||
<tr style="border-width:1px 0">
|
|
||||||
<th>Total assets</th>
|
|
||||||
{% set _ = running_total.__setattr__('quantity', assets) %}
|
|
||||||
<th class="text-end">{{ fmtbal(running_total) }}</th>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr><td colspan="2"> </td></tr>
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
<th colspan="2">Current liabilities</th>
|
|
||||||
</tr>
|
|
||||||
{% set _ = running_total.__setattr__('quantity', 0) %}
|
|
||||||
{% for account in BALANCE_SHEET_MAPPING['Current liabilities'] %}
|
|
||||||
{{ acctrow(account, -1) }}
|
|
||||||
{% endfor %}
|
|
||||||
<tr>
|
|
||||||
<th>Total current liabilities</th>
|
|
||||||
<th class="text-end">{{ fmtbal(running_total, -1) }}</th>
|
|
||||||
</tr>
|
|
||||||
{% set liabilities = running_total.quantity %}
|
|
||||||
|
|
||||||
<tr><td colspan="2"> </td></tr>
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
<th colspan="2">Non-current liabilities</th>
|
|
||||||
</tr>
|
|
||||||
{% set _ = running_total.__setattr__('quantity', 0) %}
|
|
||||||
{% for account in BALANCE_SHEET_MAPPING['Non-current liabilities'] %}
|
|
||||||
{{ acctrow(account, -1) }}
|
|
||||||
{% endfor %}
|
|
||||||
<tr>
|
|
||||||
<th>Total non-current liabilities</th>
|
|
||||||
<th class="text-end">{{ fmtbal(running_total, -1) }}</th>
|
|
||||||
</tr>
|
|
||||||
{% set liabilities = liabilities + running_total.quantity %}
|
|
||||||
|
|
||||||
<tr><td colspan="2"> </td></tr>
|
|
||||||
|
|
||||||
<tr style="border-width:1px 0">
|
|
||||||
<th>Total liabilities</th>
|
|
||||||
{% set _ = running_total.__setattr__('quantity', liabilities) %}
|
|
||||||
<th class="text-end">{{ fmtbal(running_total, -1) }}</th>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr><td colspan="2"> </td></tr>
|
|
||||||
|
|
||||||
<tr style="border-width:1px 0">
|
|
||||||
<th>Net assets</th>
|
|
||||||
{% set _ = running_total.__setattr__('quantity', assets + liabilities) %}
|
|
||||||
<th class="text-end">{{ fmtbal(running_total) }}</th>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr><td colspan="2"> </td></tr>
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
<th colspan="2">Equity</th>
|
|
||||||
</tr>
|
|
||||||
{% set _ = running_total.__setattr__('quantity', 0) %}
|
|
||||||
{{ acctrow('Accumulated surplus (deficit)', -1) }}
|
|
||||||
{{ acctrow('Current year surplus (deficit)', -1) }}
|
|
||||||
<tr style="border-width:1px 0">
|
|
||||||
<th>Total equity</th>
|
|
||||||
<th class="text-end">{{ fmtbal(running_total, -1) }}</th>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% endblock %}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
|
||||||
#}
|
|
||||||
|
|
||||||
{% 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) %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ name }}</td>
|
|
||||||
<td class="text-end">{{ fmtbal(accounts[name], mul) }}</td>
|
|
||||||
</tr>
|
|
||||||
{% set _ = running_total.__setattr__('quantity', running_total.quantity + accounts[name].quantity) %}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h1 class="h2 mt-4">Income statement</h1>
|
|
||||||
|
|
||||||
<table class="table table-borderless table-sm">
|
|
||||||
<thead>
|
|
||||||
<tr style="border-width:0 0 1px 0">
|
|
||||||
<th></th>
|
|
||||||
<th class="text-end">$ </th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<th colspan="2">Income</th>
|
|
||||||
</tr>
|
|
||||||
{% set _ = running_total.__setattr__('quantity', 0) %}
|
|
||||||
{% for account in INCOME_STATEMENT_MAPPING['Income'] if account in accounts %}
|
|
||||||
{{ acctrow(account, -1) }}
|
|
||||||
{% endfor %}
|
|
||||||
<tr>
|
|
||||||
<th>Total income</th>
|
|
||||||
<th class="text-end">{{ fmtbal(running_total, -1) }}</th>
|
|
||||||
</tr>
|
|
||||||
{% set surplus = running_total.quantity %}
|
|
||||||
|
|
||||||
<tr><td colspan="2"> </td></tr>
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
<th colspan="2">Expenses</th>
|
|
||||||
</tr>
|
|
||||||
{% set _ = running_total.__setattr__('quantity', 0) %}
|
|
||||||
{% for account in INCOME_STATEMENT_MAPPING['Expenses'] if account in accounts %}
|
|
||||||
{{ acctrow(account) }}
|
|
||||||
{% endfor %}
|
|
||||||
<tr>
|
|
||||||
<th>Total expenses</th>
|
|
||||||
<th class="text-end">{{ fmtbal(running_total) }}</th>
|
|
||||||
</tr>
|
|
||||||
{% set surplus = surplus + running_total.quantity %}
|
|
||||||
|
|
||||||
<tr><td colspan="2"> </td></tr>
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
<th>Net surplus (deficit) before income tax</th>
|
|
||||||
{% set _ = running_total.__setattr__('quantity', surplus) %}
|
|
||||||
<th class="text-end">{{ fmtbal(running_total, -1) }}</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Income tax expense</td>
|
|
||||||
<td class="text-end">{{ fmtbal(accounts['Income tax']) }}</td>
|
|
||||||
{% set surplus = surplus + accounts['Income tax'].quantity %}
|
|
||||||
</tr>
|
|
||||||
<tr style="border-width:1px 0">
|
|
||||||
<th>Net surplus (deficit) after income tax</th>
|
|
||||||
{% set _ = running_total.__setattr__('quantity', surplus) %}
|
|
||||||
<th class="text-end">{{ fmtbal(running_total, -1) }}</th>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% endblock %}
|
|
@ -18,21 +18,20 @@ from flask import redirect, render_template, request
|
|||||||
|
|
||||||
from .database import db
|
from .database import db
|
||||||
from .models import AccountConfiguration, Amount, Balance, Posting, TrialBalancer
|
from .models import AccountConfiguration, Amount, Balance, Posting, TrialBalancer
|
||||||
|
from .reports import balance_sheet_report, income_statement_report
|
||||||
from .webapp import all_transactions, app
|
from .webapp import all_transactions, app
|
||||||
|
|
||||||
from itertools import groupby
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
return render_template('index.html')
|
return render_template('index.html')
|
||||||
|
|
||||||
@app.route('/chart-of-accounts')
|
@app.route('/chart-of-accounts')
|
||||||
def 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
|
# Get existing AccountConfiguration's
|
||||||
account_configurations = db.session.execute(db.select(AccountConfiguration).order_by(AccountConfiguration.account)).scalars()
|
account_configurations = AccountConfiguration.get_all()
|
||||||
account_configurations = {v: list(g) for v, g in groupby(account_configurations, lambda c: c.account)}
|
|
||||||
|
|
||||||
# TODO: Handle orphans
|
# TODO: Handle orphans
|
||||||
return render_template('chart_of_accounts.html', accounts=accounts, account_configurations=account_configurations)
|
return render_template('chart_of_accounts.html', accounts=accounts, account_configurations=account_configurations)
|
||||||
@ -84,3 +83,13 @@ def account_transactions():
|
|||||||
running_total=Amount(0, '$'),
|
running_total=Amount(0, '$'),
|
||||||
transactions=sorted(transactions, key=lambda t: t.dt)
|
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)
|
||||||
|
@ -39,7 +39,6 @@ def all_transactions():
|
|||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
from .journal import views
|
from .journal import views
|
||||||
from .reports import views
|
|
||||||
from .statements import views
|
from .statements import views
|
||||||
from .tax import views
|
from .tax import views
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user