# 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 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): 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, link=None, heading=False, bordered=False): self.text = text self.amount = amount self.id = id 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 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