diff --git a/api/admin.py b/api/admin.py index 7fbacf2..96da35a 100644 --- a/api/admin.py +++ b/api/admin.py @@ -37,7 +37,7 @@ class Meta: # Admin definitions class AccountAdmin(admin.ModelAdmin): - list_display = ('name', 'type', 'sub_type', 'csv_profile') + list_display = ('name', 'type', 'sub_type', 'csv_profile', 'is_closed') class AutoTagAdmin(admin.ModelAdmin): list_display = ('account', 'search_string', 'transaction_type') diff --git a/api/statement.py b/api/statement.py index 914ede3..06206b9 100644 --- a/api/statement.py +++ b/api/statement.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, date from dateutil.relativedelta import relativedelta from collections import namedtuple from django.db.models import Sum, Case, When, Value, DecimalField @@ -48,11 +48,15 @@ def get_balances(self): class Balance: - def __init__(self, account, amount, account_type, account_sub_type, date=None, type='flow'): + def __init__( + self, + account: Account, + amount: float, + date: date, + type: str = 'flow' + ): self.account = account self.amount = amount - self.account_type = account_type - self.account_sub_type = account_sub_type self.date = date self.type = type @@ -72,17 +76,32 @@ def __init__(self, end_date): # TODO: Is there a reason this is a staticmethod? @staticmethod - def _get_balance_from_aggregates(aggregates, end_date=None, balance_type=None): + def _get_balance_from_aggregates( + aggregates, + end_date=None, + balance_type=None + ): account_balance_list = [] for aggregate in aggregates: - account_type = aggregate['account__type'] + account = aggregate['account'] debits = aggregate['debit_total'] credits = aggregate['credit_total'] - balance = Account.get_balance_from_debit_and_credit(account_type, debits=debits, credits=credits) - account_balance_list.append(Balance(aggregate['account__name'], balance, account_type, aggregate['account__sub_type'], end_date, balance_type)) + balance = Account.get_balance_from_debit_and_credit( + account.type, + debits=debits, + credits=credits + ) + account_balance_list.append( + Balance( + account, + balance, + end_date, + balance_type + ) + ) - sorted_list = sorted(account_balance_list, key=lambda k: k.account) + sorted_list = sorted(account_balance_list, key=lambda k: k.account.name) return sorted_list def get_balances(self): @@ -100,10 +119,9 @@ def get_balances(self): journal_entry__date__lte=self.end_date ) - aggregates = aggregates.values( - 'account__name', - 'account__type', - 'account__sub_type').annotate( + aggregates = list(aggregates.values( + 'account__name' + ).annotate( debit_total=Sum( Case( When(type='debit', then='amount'), @@ -118,42 +136,58 @@ def get_balances(self): default=Value(0) ) ) - ) + )) + # represented_accounts_names = [aggregate['account__name'] for aggregate in aggregates] + # represented_accounts = set(Account.objects.filter(name__in=represented_accounts_names)) + + # Convert to account objects and add accounts without any activity + type_objects = [Account.Type[type_value.upper()] for type_value in ACCOUNT_TYPES] + eligible_accounts = Account.objects.filter(type__in=type_objects) + for account in eligible_accounts: + account_in_aggregates = False + for aggregate in aggregates: + if account.name == aggregate['account__name']: + aggregate['account'] = account + account_in_aggregates = True + break + if not account_in_aggregates: + aggregates.append( + { + 'account': account, + 'account__name': account.name, + 'debit_total': 0, + 'credit_total': 0, + } + ) + + # unrepresented_accounts = eligible_accounts - represented_accounts + # for account in unrepresented_accounts: + # new_balance = Balance( + # account=account.name, + # amount=0, + # account_type=account.type, + # account_sub_type=account.sub_type, + # date=self.end_date, + # type=balance_type) + # balances.append(new_balance) + + + # TODO: Set this further up balance_type = 'flow' if type(self) == BalanceSheet: balance_type = 'stock' balances = self._get_balance_from_aggregates(aggregates, self.end_date, balance_type) - - # Add accounts without any activity - represented_accounts_names = [balance.account for balance in balances] - represented_accounts = set(Account.objects.filter(name__in=represented_accounts_names)) - - type_objects = [Account.Type[type_value.upper()] for type_value in ACCOUNT_TYPES] - eligible_accounts = set(Account.objects.filter(type__in=type_objects, is_closed=False)) - - unrepresented_accounts = eligible_accounts - represented_accounts - for account in unrepresented_accounts: - new_balance = Balance( - account=account.name, - amount=0, - account_type=account.type, - account_sub_type=account.sub_type, - date=self.end_date, - type=balance_type) - balances.append(new_balance) - - balances.sort(key=lambda balance: balance.account) return balances def get_summaries(self): summary_metrics = {} for balance in self.balances: - account_type = Account.Type(balance.account_type).label + account_type = Account.Type(balance.account.type).label if not summary_metrics.get(account_type): summary_metrics[account_type] = 0 - sub_type = Account.SubType(balance.account_sub_type).label + sub_type = Account.SubType(balance.account.sub_type).label if not summary_metrics.get(sub_type): summary_metrics[sub_type] = 0 summary_metrics[account_type] += balance.amount @@ -217,39 +251,49 @@ def get_levered_after_tax_cash_flow(self): def get_levered_after_tax_after_retirement_cash_flow(self): levered_after_tax_cash_flow = self.get_levered_after_tax_cash_flow() cash_from_investing_balances = self.get_cash_from_investing_balances() - retirement_cash_flow = sum([balance.amount for balance in cash_from_investing_balances if balance.account_sub_type == Account.SubType.SECURITIES_RETIREMENT]) + retirement_cash_flow = sum([balance.amount for balance in cash_from_investing_balances if balance.account.sub_type == Account.SubType.SECURITIES_RETIREMENT]) return levered_after_tax_cash_flow + retirement_cash_flow def get_balance_sheet_account_deltas(self): accounts = Account.objects.filter(type__in=[Account.Type.ASSET, Account.Type.LIABILITY, Account.Type.EQUITY]) account_deltas = [] for account in accounts: - starting_balance = sum([balance.amount for balance in self.start_balance_sheet.balances if balance.account == account.name]) - ending_balance = sum([balance.amount for balance in self.end_balance_sheet.balances if balance.account == account.name]) + starting_balance = sum([balance.amount for balance in self.start_balance_sheet.balances if balance.account == account]) + ending_balance = sum([balance.amount for balance in self.end_balance_sheet.balances if balance.account == account]) delta = ending_balance - starting_balance if account.type == Account.Type.ASSET: delta = delta * -1 - account_deltas.append(Balance(account.name, delta, account.type, account.sub_type, self.end_balance_sheet.end_date)) + account_deltas.append( + Balance( + account=account, + amount=delta, + date=self.end_balance_sheet.end_date + ) + ) return account_deltas def get_cash_from_operations_balances(self): + realized_net_income_account = Account( + name='Realized Net Income', + type=Account.Type.EQUITY, + sub_type=Account.SubType.RETAINED_EARNINGS + ) + net_income_less_gains_and_losses = [ Balance( - 'Realized Net Income', - self.net_income_less_gains_and_losses, - Account.Type.EQUITY, - Account.SubType.RETAINED_EARNINGS, - self.end_balance_sheet.end_date + account=realized_net_income_account, + amount=self.net_income_less_gains_and_losses, + date=self.end_balance_sheet.end_date ) ] - accounts_receivable_accounts = [balance for balance in self.balance_sheet_deltas if balance.account_sub_type == Account.SubType.ACCOUNTS_RECEIVABLE] - short_term_debt_accounts = [balance for balance in self.balance_sheet_deltas if balance.account_sub_type == Account.SubType.SHORT_TERM_DEBT] - taxes_payable_accounts = [balance for balance in self.balance_sheet_deltas if balance.account_sub_type == Account.SubType.TAXES_PAYABLE] + accounts_receivable_accounts = [balance for balance in self.balance_sheet_deltas if balance.account.sub_type == Account.SubType.ACCOUNTS_RECEIVABLE] + short_term_debt_accounts = [balance for balance in self.balance_sheet_deltas if balance.account.sub_type == Account.SubType.SHORT_TERM_DEBT] + taxes_payable_accounts = [balance for balance in self.balance_sheet_deltas if balance.account.sub_type == Account.SubType.TAXES_PAYABLE] return net_income_less_gains_and_losses + accounts_receivable_accounts + short_term_debt_accounts + taxes_payable_accounts def get_cash_from_financing_balances(self): - long_term_debt = [balance for balance in self.balance_sheet_deltas if balance.account_sub_type == Account.SubType.LONG_TERM_DEBT] + long_term_debt = [balance for balance in self.balance_sheet_deltas if balance.account.sub_type == Account.SubType.LONG_TERM_DEBT] return long_term_debt def get_cash_from_investing_balances(self): @@ -275,9 +319,14 @@ def get_cash_from_investing_balances(self): account_adjustments[account] += adjustment # Constructing balances - balances = [Balance(key.name, value, key.type, key.sub_type, self.end_balance_sheet.end_date) + balances = [ + Balance( + account=key, + amount=value, + date=self.end_balance_sheet.end_date + ) for key, value in account_adjustments.items()] - sorted_balances = sorted(balances, key=lambda k: k.account) + sorted_balances = sorted(balances, key=lambda k: k.account.name) return sorted_balances @@ -291,22 +340,29 @@ def __init__(self, end_date, start_date): self.net_income = self.get_net_income() self.investment_gains = self.get_unrealized_gains_and_losses() + + realized_net_income_account = Account( + name='Realized Net Income', + type=Account.Type.EQUITY, + sub_type=Account.SubType.RETAINED_EARNINGS + ) self.balances.append( Balance( - 'Realized Net Income', - self._get_non_investment_gains_net_income(), - Account.Type.EQUITY, - Account.SubType.RETAINED_EARNINGS, - self.end_date + account=realized_net_income_account, + amount=self._get_non_investment_gains_net_income(), + date=self.end_date ) ) + unrealized_gain_loss_account = Account( + name='Unrealized Gains/Losses', + type=Account.Type.EQUITY, + sub_type=Account.SubType.RETAINED_EARNINGS + ) self.balances.append( Balance( - 'Unrealized Gains/Losses', - self.investment_gains, - Account.Type.EQUITY, - Account.SubType.RETAINED_EARNINGS, - self.end_date + account=unrealized_gain_loss_account, + amount=self.investment_gains, + date=self.end_date ) ) self.summaries = self.get_summaries() @@ -314,32 +370,32 @@ def __init__(self, end_date, start_date): def get_net_income(self): net_income = 0 for balance in self.balances: - if balance.account_type == Account.Type.INCOME: + if balance.account.type == Account.Type.INCOME: net_income += balance.amount - elif balance.account_type == Account.Type.EXPENSE: + elif balance.account.type == Account.Type.EXPENSE: net_income -= balance.amount return net_income def get_taxable_income(self): - return sum([balance.amount for balance in self.balances if balance.account_type == Account.Type.INCOME and balance.account_sub_type not in [Account.SubType.UNREALIZED_INVESTMENT_GAINS, Account.SubType.OTHER_INCOME]]) + return sum([balance.amount for balance in self.balances if balance.account.type == Account.Type.INCOME and balance.account.sub_type not in [Account.SubType.UNREALIZED_INVESTMENT_GAINS, Account.SubType.OTHER_INCOME]]) def get_unrealized_gains_and_losses(self): - return sum([balance.amount for balance in self.balances if balance.account_sub_type == Account.SubType.UNREALIZED_INVESTMENT_GAINS]) + return sum([balance.amount for balance in self.balances if balance.account.sub_type == Account.SubType.UNREALIZED_INVESTMENT_GAINS]) def _get_non_investment_gains_net_income(self): return self.net_income - self.investment_gains def get_tax_rate(self): - taxable_income = sum([balance.amount for balance in self.balances if balance.account_sub_type in [Account.SubType.SALARY, Account.SubType.DIVIDENDS_AND_INTEREST]]) - taxes = sum([balance.amount for balance in self.balances if balance.account_sub_type == Account.SubType.TAX]) + taxable_income = sum([balance.amount for balance in self.balances if balance.account.sub_type in [Account.SubType.SALARY, Account.SubType.DIVIDENDS_AND_INTEREST]]) + taxes = sum([balance.amount for balance in self.balances if balance.account.sub_type == Account.SubType.TAX]) if taxable_income == 0: return None return taxes / taxable_income def get_savings_rate(self): non_gains_net_income = self._get_non_investment_gains_net_income() - non_gains_income = sum([balance.amount for balance in self.balances if balance.account_sub_type != Account.SubType.UNREALIZED_INVESTMENT_GAINS and balance.account_type == Account.Type.INCOME]) + non_gains_income = sum([balance.amount for balance in self.balances if balance.account.sub_type != Account.SubType.UNREALIZED_INVESTMENT_GAINS and balance.account.type == Account.Type.INCOME]) if non_gains_income == 0: return None return non_gains_net_income / non_gains_income @@ -351,9 +407,27 @@ def __init__(self, end_date): super().__init__(end_date) self.balances = self.get_balances() investment_gains_losses, net_retained_earnings = self.get_retained_earnings_values() + retained_earnings_account = Account( + name='9000-Net Retained Earnings', + type=Account.Type.EQUITY, + sub_type=Account.SubType.RETAINED_EARNINGS + ) + investment_gains_losses_account = Account( + name='9100-Investment Gains/Losses', + type=Account.Type.EQUITY, + sub_type=Account.SubType.RETAINED_EARNINGS + ) self.balances += [ - Balance('9000-Net Retained Earnings', net_retained_earnings, Account.Type.EQUITY, Account.SubType.RETAINED_EARNINGS, self.end_date), - Balance('9100-Investment Gains/Losses', investment_gains_losses, Account.Type.EQUITY, Account.SubType.RETAINED_EARNINGS, self.end_date) + Balance( + account=retained_earnings_account, + amount=net_retained_earnings, + date=self.end_date + ), + Balance( + account=investment_gains_losses_account, + amount=investment_gains_losses, + date=self.end_date + ) ] self.summaries = self.get_summaries() self.metrics = self.get_metrics() @@ -362,14 +436,14 @@ def get_retained_earnings_values(self): income_statement = IncomeStatement(end_date=self.end_date, start_date='1970-01-01') retained_earnings = income_statement.get_net_income() - investment_gains_losses = sum([balance.amount for balance in income_statement.balances if balance.account_sub_type == Account.SubType.UNREALIZED_INVESTMENT_GAINS]) + investment_gains_losses = sum([balance.amount for balance in income_statement.balances if balance.account.sub_type == Account.SubType.UNREALIZED_INVESTMENT_GAINS]) net_retained_earnings = retained_earnings - investment_gains_losses return investment_gains_losses, net_retained_earnings def get_balance(self, account): try: - balance = [balance.amount for balance in self.balances if balance.account == account.name][0] + balance = [balance.amount for balance in self.balances if balance.account == account][0] # Need this when there is no balance for a given account except IndexError: balance = 0 diff --git a/api/tests/statement/test_balance_sheet.py b/api/tests/statement/test_balance_sheet.py index e050daf..5ad5f4a 100644 --- a/api/tests/statement/test_balance_sheet.py +++ b/api/tests/statement/test_balance_sheet.py @@ -323,7 +323,7 @@ def test_create_balance_sheet(self): def test_creates_balances(self): balance_sheet = BalanceSheet('2023-01-31') chase_balance = [ - balance.amount for balance in balance_sheet.balances if balance.account == '1200-Chase' + balance.amount for balance in balance_sheet.balances if balance.account.name == '1200-Chase' ][0] self.assertEqual(len(balance_sheet.balances), 6) self.assertEqual(chase_balance, 100) diff --git a/api/tests/statement/test_cash_flow.py b/api/tests/statement/test_cash_flow.py index a1c514d..278d44d 100644 --- a/api/tests/statement/test_cash_flow.py +++ b/api/tests/statement/test_cash_flow.py @@ -333,10 +333,14 @@ def test_get_balances(self): balance_names = [ (balance.account, balance.amount) for balance in balances ] - self.assertIn(('1200-Chase', 100), balance_names) - self.assertIn(('Realized Net Income', 340), balance_names) - self.assertIn(('7000-Vanguard', 150), balance_names) - self.assertIn(('Mortgage', 50), balance_names) + account = Account.objects.get(name='1200-Chase') + self.assertIn((account, 100), balance_names) + account = Account.objects.get(name='7000-Vanguard') + self.assertIn((account, 150), balance_names) + account = Account.objects.get(name='Mortgage') + self.assertIn((account, 50), balance_names) + realized_net_income_balance = sum([tuple[1] for tuple in balance_names if tuple[0].name == 'Realized Net Income']) + self.assertEqual(realized_net_income_balance, 340) def test_levered_cash_flow(self): cash_flow_statement = CashFlowStatement( @@ -370,10 +374,14 @@ def test_get_balance_sheet_account_deltas(self): balance_names = [ (balance.account, balance.amount) for balance in balances ] - self.assertIn(('1200-Chase', 100), balance_names) - self.assertIn(('900-Ally', -440), balance_names) - self.assertIn(('7000-Vanguard', 150), balance_names) - self.assertIn(('Mortgage', 50), balance_names) + account = Account.objects.get(name='1200-Chase') + self.assertIn((account, 100), balance_names) + account = Account.objects.get(name='7000-Vanguard') + self.assertIn((account, 150), balance_names) + account = Account.objects.get(name='Mortgage') + self.assertIn((account, 50), balance_names) + account = Account.objects.get(name='900-Ally') + self.assertIn((account, -440), balance_names) def test_get_cash_from_operations_balances(self): cash_flow_statement = CashFlowStatement( @@ -385,8 +393,10 @@ def test_get_cash_from_operations_balances(self): balance_names = [ (balance.account, balance.amount) for balance in balances ] - self.assertIn(('1200-Chase', 100), balance_names) - self.assertIn(('Realized Net Income', 340), balance_names) + account = Account.objects.get(name='1200-Chase') + self.assertIn((account, 100), balance_names) + realized_net_income_balance = sum([tuple[1] for tuple in balance_names if tuple[0].name == 'Realized Net Income']) + self.assertEqual(realized_net_income_balance, 340) def test_get_cash_from_financing_balances(self): cash_flow_statement = CashFlowStatement( @@ -398,7 +408,8 @@ def test_get_cash_from_financing_balances(self): balance_names = [ (balance.account, balance.amount) for balance in balances ] - self.assertIn(('Mortgage', 50), balance_names) + account = Account.objects.get(name='Mortgage') + self.assertIn((account, 50), balance_names) def test_get_cash_from_investing_balances(self): cash_flow_statement = CashFlowStatement( @@ -410,4 +421,5 @@ def test_get_cash_from_investing_balances(self): balance_names = [ (balance.account, balance.amount) for balance in balances ] - self.assertIn(('7000-Vanguard', 150), balance_names) + account = Account.objects.get(name='7000-Vanguard') + self.assertIn((account, 150), balance_names) diff --git a/api/tests/statement/test_income.py b/api/tests/statement/test_income.py index eff05cf..9465817 100644 --- a/api/tests/statement/test_income.py +++ b/api/tests/statement/test_income.py @@ -241,14 +241,14 @@ def test_create_income_statement(self): def test_creates_balances(self): income_statement = IncomeStatement('2023-01-31', '2023-01-01') net_income = [ - balance.amount for balance in income_statement.balances if balance.account == 'Realized Net Income' + balance.amount for balance in income_statement.balances if balance.account.name == 'Realized Net Income' ][0] self.assertEqual(len(income_statement.balances), 8) self.assertEqual(net_income, 340) def test_net_income(self): income_statement = IncomeStatement('2023-01-31', '2023-01-01') - realized_net_income = [balance.amount for balance in income_statement.balances if balance.account == 'Realized Net Income'][0] + realized_net_income = [balance.amount for balance in income_statement.balances if balance.account.name == 'Realized Net Income'][0] self.assertEqual(len(income_statement.balances), 8) self.assertEqual(realized_net_income, 340) self.assertEqual(income_statement.net_income, 540) diff --git a/api/views/statement_views.py b/api/views/statement_views.py index 615a36f..b34794f 100644 --- a/api/views/statement_views.py +++ b/api/views/statement_views.py @@ -12,6 +12,14 @@ class StatementMixIn: + def _clear_closed_accounts(self, balances): + new_balances = [] + for balance in balances: + if balance.account.is_closed and balance.amount == 0: + continue + new_balances.append(balance) + return new_balances + def _get_statement_summary_dict(self, statement): type_dict = dict(Account.Type.choices) @@ -20,12 +28,12 @@ def _get_statement_summary_dict(self, statement): type_total = 0 account_balances = [] for sub_type in Account.SUBTYPE_TO_TYPE_MAP[account_type]: - balances = [balance for balance in statement.balances if balance.account_sub_type == sub_type] + balances = [balance for balance in statement.balances if balance.account.sub_type == sub_type] sub_type_total = sum([balance.amount for balance in balances]) account_balances.append( { 'name': sub_type.label, - 'balances': balances, + 'balances': self._clear_closed_accounts(balances), 'total': sub_type_total } ) @@ -95,9 +103,9 @@ def get_cash_flow_html(self, from_date, to_date): ) context = { - 'operations_flows': cash_statement.cash_from_operations_balances, - 'financing_flows': cash_statement.cash_from_financing_balances, - 'investing_flows': cash_statement.cash_from_investing_balances, + 'operations_flows': self._clear_closed_accounts(cash_statement.cash_from_operations_balances), + 'financing_flows': self._clear_closed_accounts(cash_statement.cash_from_financing_balances), + 'investing_flows': self._clear_closed_accounts(cash_statement.cash_from_investing_balances), 'cash_from_operations': sum([metric.value for metric in cash_statement.summaries if metric.name == 'Cash Flow From Operations']), 'cash_from_financing': sum([metric.value for metric in cash_statement.summaries if metric.name == 'Cash Flow From Financing']), 'cash_from_investing': sum([metric.value for metric in cash_statement.summaries if metric.name == 'Cash Flow From Investing']),