diff --git a/conservancy_beancount/reports/balance_sheet.py b/conservancy_beancount/reports/balance_sheet.py index 2a21406bdf5a8ef105513578a8c9e49b9090d972..2aec62cc9f8f3418e2c0ce10bf2e9bb5e110b6e0 100644 --- a/conservancy_beancount/reports/balance_sheet.py +++ b/conservancy_beancount/reports/balance_sheet.py @@ -22,12 +22,16 @@ import logging import os import sys +from decimal import Decimal from pathlib import Path from typing import ( + Any, + Dict, Hashable, Iterable, Iterator, + List, Mapping, NamedTuple, Optional, @@ -50,14 +54,18 @@ from .. import ranges PROGNAME = 'balance-sheet-report' logger = logging.getLogger('conservancy_beancount.tools.balance_sheet') -class Fund(enum.Enum): +class Fund(enum.IntFlag): RESTRICTED = enum.auto() UNRESTRICTED = enum.auto() + ANY = RESTRICTED | UNRESTRICTED -class Period(enum.Enum): +class Period(enum.IntFlag): OPENING = enum.auto() + PRIOR = enum.auto() PERIOD = enum.auto() + BEFORE_PERIOD = OPENING | PRIOR + ANY = OPENING | PRIOR | PERIOD class BalanceKey(NamedTuple): @@ -81,11 +89,11 @@ class Balances: fund_key: str='project', unrestricted_fund_value: str='Conservancy', ) -> None: - self.opening_range = ranges.DateRange( + self.prior_range = ranges.DateRange( cliutil.diff_year(start_date, -1), cliutil.diff_year(stop_date, -1), ) - assert self.opening_range.stop <= start_date + assert self.prior_range.stop <= start_date self.period_range = ranges.DateRange(start_date, stop_date) self.balances: Mapping[BalanceKey, core.MutableBalance] \ = collections.defaultdict(core.MutableBalance) @@ -93,34 +101,40 @@ class Balances: post_date = post.meta.date if post_date in self.period_range: period = Period.PERIOD - elif post_date < self.period_range.start: + elif post_date in self.prior_range: + period = Period.PRIOR + elif post_date < self.prior_range.start: period = Period.OPENING else: continue + if post.account == 'Expenses:CurrencyConversion': + account = data.Account('Income:CurrencyConversion') + else: + account = post.account if post.meta.get(fund_key) == unrestricted_fund_value: fund = Fund.UNRESTRICTED else: fund = Fund.RESTRICTED try: - classification_s = post.account.meta['classification'] + classification_s = account.meta['classification'] if isinstance(classification_s, str): classification = data.Account(classification_s) else: raise TypeError() except (KeyError, TypeError): - classification = post.account + classification = account try: - post_type = post.meta[self.POST_TYPES[post.account.root_part()]] + post_type = post.meta[self.POST_TYPES[account.root_part()]] except KeyError: post_type = None - key = BalanceKey(post.account, classification, period, fund, post_type) + key = BalanceKey(account, classification, period, fund, post_type) self.balances[key] += post.at_cost() def total(self, account: Optional[str]=None, classification: Optional[str]=None, - period: Optional[Period]=None, - fund: Optional[Fund]=None, + period: int=Period.ANY, + fund: int=Fund.ANY, post_type: Optional[str]=None, ) -> core.Balance: retval = core.MutableBalance() @@ -130,9 +144,9 @@ class Balances: elif not (classification is None or key.classification.is_under(classification)): pass - elif not (period is None or period is key.period): + elif not period & key.period: pass - elif not (fund is None or fund is key.fund): + elif not fund & key.fund: pass elif not (post_type is None or post_type == key.post_type): pass @@ -140,22 +154,37 @@ class Balances: retval += balance return retval - def classifications(self, account: str) -> Sequence[data.Account]: + def classifications(self, + account: str, + sort_period: int=Period.PERIOD, + ) -> Sequence[data.Account]: class_bals: Mapping[data.Account, core.MutableBalance] \ = collections.defaultdict(core.MutableBalance) for key, balance in self.balances.items(): - if key.account.is_under(account): + if not key.account.is_under(account): + pass + elif key.period & sort_period: class_bals[key.classification] += balance + else: + # Ensure the balance exists in the mapping + class_bals[key.classification] norm_func = core.normalize_amount_func(f'{account}:RootsOK') def sortkey(acct: data.Account) -> Hashable: prefix, _, _ = acct.rpartition(':') balance = norm_func(class_bals[acct]) - max_bal = max(amount.number for amount in balance.values()) + try: + max_bal = max(amount.number for amount in balance.values()) + except ValueError: + max_bal = Decimal(0) return prefix, -max_bal return sorted(class_bals, key=sortkey) class Report(core.BaseODS[Sequence[None], None]): + C_SATISFIED = 'Satisfaction of program restrictions' + EQUITY_ACCOUNTS = ['Equity', 'Income', 'Expenses'] + NO_BALANCE = core.Balance() + def __init__(self, balances: Balances, *, @@ -166,7 +195,7 @@ class Report(core.BaseODS[Sequence[None], None]): one_day = datetime.timedelta(days=1) date = balances.period_range.stop - one_day self.period_name = date.strftime(date_fmt) - date = balances.opening_range.stop - one_day + date = balances.prior_range.stop - one_day self.opening_name = date.strftime(date_fmt) def section_key(self, row: Sequence[None]) -> None: @@ -188,6 +217,7 @@ class Report(core.BaseODS[Sequence[None], None]): def write_all(self) -> None: self.write_financial_position() + self.write_activities() def walk_classifications(self, cseq: Iterable[data.Account]) \ -> Iterator[Tuple[str, Optional[data.Account]]]: @@ -201,9 +231,14 @@ class Report(core.BaseODS[Sequence[None], None]): last_prefix = parts yield f'{tabs}{tail}', classification - def walk_classifications_by_account(self, account: str) \ - -> Iterator[Tuple[str, Optional[data.Account]]]: - return self.walk_classifications(self.balances.classifications(account)) + def walk_classifications_by_account( + self, + account: str, + sort_period: int=Period.PERIOD, + ) -> Iterator[Tuple[str, Optional[data.Account]]]: + return self.walk_classifications(self.balances.classifications( + account, sort_period, + )) def write_financial_position(self) -> None: self.use_sheet("Financial Position") @@ -227,15 +262,13 @@ class Report(core.BaseODS[Sequence[None], None]): period_assets = core.MutableBalance() self.add_row(self.string_cell("Assets", stylename=self.style_bold)) self.add_row() - for text, classification in self.walk_classifications_by_account('Assets'): + for text, classification in self.walk_classifications_by_account('Assets', Period.ANY): text_cell = self.string_cell(text) if classification is None: self.add_row(text_cell) else: - prior_bal = self.balances.total( - classification=classification, period=Period.OPENING, - ) - period_bal = prior_bal + self.balances.total( + period_bal = self.balances.total(classification=classification) + prior_bal = period_bal - self.balances.total( classification=classification, period=Period.PERIOD, ) self.add_row( @@ -261,15 +294,13 @@ class Report(core.BaseODS[Sequence[None], None]): self.add_row() self.add_row(self.string_cell("Liabilities", stylename=self.style_bold)) self.add_row() - for text, classification in self.walk_classifications_by_account('Liabilities'): + for text, classification in self.walk_classifications_by_account('Liabilities', Period.ANY): text_cell = self.string_cell(text) if classification is None: self.add_row(text_cell) else: - prior_bal = -self.balances.total( - classification=classification, period=Period.OPENING, - ) - period_bal = prior_bal - self.balances.total( + period_bal = -self.balances.total(classification=classification) + prior_bal = period_bal + self.balances.total( classification=classification, period=Period.PERIOD, ) self.add_row( @@ -291,16 +322,15 @@ class Report(core.BaseODS[Sequence[None], None]): period_net = core.MutableBalance() self.add_row(self.string_cell("Net Assets", stylename=self.style_bold)) self.add_row() - accounts = ['Equity', 'Income', 'Expenses'] for fund in [Fund.UNRESTRICTED, Fund.RESTRICTED]: preposition = "Without" if fund is Fund.UNRESTRICTED else "With" - prior_bal = -sum( - (self.balances.total(account=account, period=Period.OPENING, fund=fund) - for account in accounts), core.MutableBalance(), + period_bal = -sum( + (self.balances.total(account=account, fund=fund) + for account in self.EQUITY_ACCOUNTS), core.MutableBalance(), ) - period_bal = prior_bal - sum( - (self.balances.total(account=account, period=Period.PERIOD, fund=fund) - for account in accounts), core.MutableBalance(), + prior_bal = period_bal + sum( + (self.balances.total(account=account, fund=fund, period=Period.PERIOD) + for account in self.EQUITY_ACCOUNTS), core.MutableBalance(), ) self.add_row( self.string_cell(f"{preposition} donor restrictions"), @@ -323,6 +353,163 @@ class Report(core.BaseODS[Sequence[None], None]): stylename=self.style_bottomline), ) + def write_activities(self) -> None: + self.use_sheet("Activities") + bal_kwargs: Sequence[Dict[str, Any]] = [ + {'period': Period.PERIOD, 'fund': Fund.UNRESTRICTED}, + {'period': Period.PERIOD, 'fund': Fund.RESTRICTED}, + {'period': Period.PERIOD}, + {'period': Period.PRIOR}, + ] + col_count = len(bal_kwargs) + 1 + for index in range(col_count): + col_style = self.column_style(1.5 if index else 3) + self.sheet.addElement(odf.table.TableColumn(stylename=col_style)) + self.add_row( + self.multiline_cell([ + "DRAFT Statement of Activities", + self.period_name, + ], numbercolumnsspanned=col_count, stylename=self.style_header) + ) + self.add_row() + self.add_row( + odf.table.TableCell(), + self.multiline_cell(["Without Donor", "Restrictions"], + stylename=self.style_huline), + self.multiline_cell(["With Donor", "Restrictions"], + stylename=self.style_huline), + self.multiline_cell(["Total Year Ended", self.period_name], + stylename=self.style_huline), + self.multiline_cell(["Total Year Ended", self.opening_name], + stylename=self.style_huline), + ) + + totals = [core.MutableBalance() for _ in bal_kwargs] + self.add_row(self.string_cell("Support and Revenue", stylename=self.style_bold)) + self.add_row() + for text, classification in self.walk_classifications_by_account('Income'): + text_cell = self.string_cell(text) + if classification is None: + self.add_row(text_cell) + elif classification == self.C_SATISFIED: + continue + else: + balances = [ + -self.balances.total(classification=classification, **kwargs) + for kwargs in bal_kwargs + ] + self.add_row( + text_cell, + *(self.balance_cell(bal) for bal in balances), + ) + for total, bal in zip(totals, balances): + total += bal + self.add_row( + odf.table.TableCell(), + *(self.balance_cell(total, stylename=self.style_subtotline) + for total in totals), + ) + self.add_row() + self.add_row( + self.string_cell("Net Assets released from restrictions:"), + ) + released = self.balances.total( + account='Expenses', period=Period.PERIOD, fund=Fund.RESTRICTED, + ) - self.balances.total( + classification=self.C_SATISFIED, period=Period.PERIOD, fund=Fund.RESTRICTED, + ) + totals[0] += released + totals[1] -= released + self.add_row( + self.string_cell(self.C_SATISFIED), + self.balance_cell(released), + self.balance_cell(-released), + self.balance_cell(self.NO_BALANCE), + self.balance_cell(self.NO_BALANCE), + ) + self.add_row() + self.add_row( + self.string_cell("Total Support and Revenue"), + *(self.balance_cell(total, stylename=self.style_totline) + for total in totals), + ) + + period_expenses = core.MutableBalance() + prior_expenses = core.MutableBalance() + self.add_row() + self.add_row(self.string_cell("Expenses", stylename=self.style_bold)) + self.add_row() + for text, type_value in [ + ("Program services", 'program'), + ("Management and administrative services", 'management'), + ("Fundraising", 'fundraising'), + ]: + period_bal = self.balances.total( + account='Expenses', period=Period.PERIOD, post_type=type_value, + ) + prior_bal = self.balances.total( + account='Expenses', period=Period.PRIOR, post_type=type_value, + ) + self.add_row( + self.string_cell(text), + self.balance_cell(period_bal), + self.balance_cell(self.NO_BALANCE), + self.balance_cell(period_bal), + self.balance_cell(prior_bal), + ) + period_expenses += period_bal + prior_expenses += prior_bal + period_bal = self.balances.total(account='Expenses', period=Period.PERIOD) + if (period_expenses - period_bal).clean_copy(1).is_zero(): + period_bal = period_expenses + else: + logger.warning("Period functional expenses do not match total; math in columns B+D is wrong") + prior_bal = self.balances.total(account='Expenses', period=Period.PRIOR) + if (prior_expenses - prior_bal).clean_copy(1).is_zero(): + prior_bal = prior_expenses + else: + logger.warning("Prior functional expenses do not match total; math in column E is wrong") + self.add_row( + self.string_cell("Total Expenses"), + self.balance_cell(period_bal, stylename=self.style_totline), + self.balance_cell(self.NO_BALANCE, stylename=self.style_totline), + self.balance_cell(period_bal, stylename=self.style_totline), + self.balance_cell(prior_bal, stylename=self.style_totline), + ) + + totals[0] -= period_bal + totals[2] -= period_bal + totals[3] -= prior_bal + self.add_row() + self.add_row( + self.string_cell("Change in Net Assets"), + *(self.balance_cell(total) for total in totals), + ) + + for kwargs in bal_kwargs: + if kwargs['period'] is Period.PERIOD: + kwargs['period'] = Period.BEFORE_PERIOD + else: + kwargs['period'] = Period.OPENING + beginnings = [ + -sum((self.balances.total(account=account, **kwargs) + for account in self.EQUITY_ACCOUNTS), core.MutableBalance()) + for kwargs in bal_kwargs + ] + self.add_row() + self.add_row( + self.string_cell("Beginning Net Assets"), + *(self.balance_cell(beg_bal) for beg_bal in beginnings), + ) + + self.add_row() + self.add_row( + self.string_cell("Ending Net Assets"), + *(self.balance_cell(beg_bal + tot_bal, stylename=self.style_bottomline) + for beg_bal, tot_bal in zip(beginnings, totals)), + ) + + def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace: parser = argparse.ArgumentParser(prog=PROGNAME) diff --git a/setup.py b/setup.py index 50ae439bf3bfb74eaee3a36f80e19b41477f71e3..81b09dd053f8a2247ba8d91e5b81f31319d3cc28 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup setup( name='conservancy_beancount', description="Plugin, library, and reports for reading Conservancy's books", - version='1.7.2', + version='1.7.3', author='Software Freedom Conservancy', author_email='info@sfconservancy.org', license='GNU AGPLv3+', diff --git a/tests/test_reports_balance_sheet.py b/tests/test_reports_balance_sheet.py index 5239a766eb0fa1261065b21db44097a3fdf42d43..98e14678949c89138be7f2fff9a55f7a33d0cb67 100644 --- a/tests/test_reports_balance_sheet.py +++ b/tests/test_reports_balance_sheet.py @@ -83,7 +83,7 @@ def income_expense_balances(): ({'classification': 'Postage'}, 30), ({'classification': 'Services'}, 20), ({'classification': 'Nonexistent'}, None), - ({'period': Period.OPENING, 'account': 'Income'}, '-9.60'), + ({'period': Period.PRIOR, 'account': 'Income'}, '-9.60'), ({'period': Period.PERIOD, 'account': 'Expenses'}, 26), ({'fund': Fund.RESTRICTED, 'account': 'Income'}, -10), ({'fund': Fund.UNRESTRICTED, 'account': 'Expenses'}, 25), @@ -91,10 +91,10 @@ def income_expense_balances(): ({'post_type': 'fundraising'}, 20), ({'post_type': 'management'}, 10), ({'post_type': 'Nonexistent'}, None), - ({'period': Period.OPENING, 'post_type': 'RBI'}, '-4.80'), + ({'period': Period.PRIOR, 'post_type': 'RBI'}, '-4.80'), ({'fund': Fund.RESTRICTED, 'post_type': 'program'}, 10), ({'period': Period.PERIOD, 'fund': Fund.UNRESTRICTED, 'post_type': 'RBI'}, '-2.60'), - ({'period': Period.OPENING, 'fund': Fund.RESTRICTED, 'post_type': 'program'}, '4.80'), + ({'period': Period.PRIOR, 'fund': Fund.RESTRICTED, 'post_type': 'program'}, '4.80'), ({'period': Period.PERIOD, 'fund': Fund.RESTRICTED, 'post_type': 'ΓΈ'}, None), ]) def test_balance_total(income_expense_balances, kwargs, expected):