Changeset - 29d4325c7abd
[Not reviewed]
0 3 0
Brett Smith - 4 years ago 2020-08-17 19:26:55
brettcsmith@brettcsmith.org
balance_sheet: Add activity report.
3 files changed with 228 insertions and 41 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/reports/balance_sheet.py
Show inline comments
...
 
@@ -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)
setup.py
Show inline comments
...
 
@@ -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+',
tests/test_reports_balance_sheet.py
Show inline comments
...
 
@@ -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):
0 comments (0 inline, 0 general)