213 lines
5.6 KiB
Python
213 lines
5.6 KiB
Python
|
# 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
|