DrCr/drcr/reports.py

244 lines
7.0 KiB
Python

# DrCr: Web-based double-entry bookkeeping framework
# Copyright (C) 2022–2024 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 url_for
from .models import AccountConfiguration, Amount, TrialBalancer, reporting_commodity
from .webapp import all_transactions, eofy_date, sofy_date
from datetime import datetime, timedelta
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, visible=True, auto_hide=False):
self.title = title
self.entries = entries or []
self.id = id
self.visible = visible
self.auto_hide = auto_hide
def calculate(self, parent):
for entry in self.entries:
entry.calculate(self)
if self.auto_hide and self.visible:
if not any(isinstance(e, Entry) and e.visible for e in self.entries):
# Auto hide if no visible entries (other than Subtotal)
self.visible = False
# Hide next Spacer
idx = parent.entries.index(self)
if idx + 1 < len(parent.entries):
if isinstance(parent.entries[idx + 1], Spacer):
parent.entries[idx + 1].visible = False
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, visible=True, auto_hide=False, link=None, heading=False, bordered=False):
self.text = text
self.amount = amount
self.id = id
self.visible = visible
self.auto_hide = auto_hide
self.link = link
self.heading = heading
self.bordered = bordered
def calculate(self, parent):
pass
class Subtotal:
def __init__(self, text=None, *, id=None, visible=True, bordered=False, floor=0):
self.text = text
self.id = id
self.visible = visible
self.bordered = bordered
self.floor = floor
self.amount = None
def calculate(self, parent):
amount = sum(e.amount.quantity for e in parent.entries if isinstance(e, Entry))
if self.floor:
amount = (amount // self.floor) * self.floor
self.amount = Amount(amount, reporting_commodity())
class Calculated(Entry):
def __init__(self, text=None, calc=None, **kwargs):
super().__init__(text=text, **kwargs)
self.calc = calc
self.amount = None
def calculate(self, parent):
self.amount = self.calc(parent)
if self.auto_hide and self.visible:
if self.amount.quantity == 0:
self.visible = False
class Spacer:
id = None
def __init__(self, *, visible=True):
self.visible = visible
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 entries_for_kind(account_configurations, accounts, kind, neg=False, floor=0):
entries = []
for account_name, amount in accounts.items():
if kind in account_configurations.get(account_name, []) and amount.quantity != 0:
if neg:
amount = -amount
if floor:
amount.quantity = (amount.quantity // floor) * floor
entries.append(Entry(text=account_name, amount=amount, link=url_for('account_transactions', account=account_name)))
return entries
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)
day_before_sofy = sofy_date()
day_before_sofy -= timedelta(days=1)
report = Report(
title='Balance sheet',
entries=[
Section(
title='Assets',
entries=entries_for_kind(account_configurations, accounts, 'drcr.asset') + [Subtotal('Total assets', bordered=True)]
),
Spacer(),
Section(
title='Liabilities',
entries=entries_for_kind(account_configurations, accounts, 'drcr.liability', True) + [Subtotal('Total liabilities', bordered=True)]
),
Spacer(),
Section(
title='Equity',
entries=entries_for_kind(account_configurations, accounts, 'drcr.equity', True) + [
Calculated(
'Current year surplus (deficit)',
lambda _: income_statement_report().by_id('net_surplus').amount,
link=url_for('income_statement')
),
Calculated(
'Accumulated surplus (deficit)',
lambda _: income_statement_report(start_date=datetime.min, end_date=day_before_sofy).by_id('net_surplus').amount
),
Subtotal('Total equity', bordered=True)
]
),
]
)
report.calculate()
return report
def income_statement_report(start_date=None, end_date=None):
if start_date is None:
start_date = sofy_date()
if end_date is None:
end_date = eofy_date()
# Get trial balance
balancer = TrialBalancer()
balancer.apply_transactions(all_transactions(start_date=start_date, end_date=end_date))
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=entries_for_kind(account_configurations, accounts, 'drcr.income', True) + [Subtotal('Total income', id='total_income', bordered=True)]
),
Spacer(),
Section(
title='Expenses',
entries=entries_for_kind(account_configurations, accounts, 'drcr.expense') + [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