239 lines
6.8 KiB
Python
239 lines
6.8 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 flask import url_for
|
|
|
|
from .models import AccountConfiguration, Amount, TrialBalancer
|
|
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) for e in self.entries):
|
|
# Auto hide if no 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, link=None, heading=False, bordered=False):
|
|
self.text = text
|
|
self.amount = amount
|
|
self.id = id
|
|
self.visible = visible
|
|
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, '$')
|
|
|
|
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)
|
|
|
|
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
|