Changeset - b7dee6a88a81
[Not reviewed]
0 1 0
Brett Smith - 4 years ago 2020-08-18 07:27:27
brettcsmith@brettcsmith.org
balance_sheet: Refactor out Report.write_classifications_by_account.
1 file changed with 86 insertions and 159 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/reports/balance_sheet.py
Show inline comments
 
"""balance_sheet.py - Balance sheet report"""
 
# Copyright © 2020  Brett Smith
 
#
 
# 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/>.
 

	
 
import argparse
 
import collections
 
import datetime
 
import enum
 
import logging
 
import operator
 
import os
 
import sys
 

	
 
from decimal import Decimal
 
from pathlib import Path
 

	
 
from typing import (
 
    Any,
 
    Callable,
 
    Collection,
 
    Dict,
 
    Hashable,
 
    Iterable,
 
    Iterator,
 
    List,
 
    Mapping,
 
    NamedTuple,
 
    Optional,
 
    Sequence,
 
    TextIO,
 
    Tuple,
 
    Union,
 
)
 

	
 
import odf.table  # type:ignore[import]
 

	
 
from beancount.parser import printer as bc_printer
 

	
 
from . import core
 
from .. import books
 
from .. import cliutil
 
from .. import config as configmod
 
from .. import data
 
from .. import ranges
 

	
 
EQUITY_ACCOUNTS = frozenset(['Equity', 'Income', 'Expenses'])
 
PROGNAME = 'balance-sheet-report'
 
logger = logging.getLogger('conservancy_beancount.tools.balance_sheet')
 

	
 
KWArgs = Mapping[str, Any]
 

	
 
class Fund(enum.IntFlag):
 
    RESTRICTED = enum.auto()
 
    UNRESTRICTED = enum.auto()
 
    ANY = RESTRICTED | UNRESTRICTED
 

	
 

	
 
class Period(enum.IntFlag):
 
    OPENING = enum.auto()
 
    PRIOR = enum.auto()
 
    PERIOD = enum.auto()
 
    BEFORE_PERIOD = OPENING | PRIOR
 
    ANY = OPENING | PRIOR | PERIOD
 

	
 

	
 
class BalanceKey(NamedTuple):
 
    account: data.Account
 
    classification: data.Account
 
    period: Period
 
    fund: Fund
 
    post_type: Optional[str]
 

	
 

	
 
class Balances:
 
    def __init__(self,
 
                 postings: Iterable[data.Posting],
 
                 start_date: datetime.date,
 
                 stop_date: datetime.date,
 
                 fund_key: str='project',
 
                 unrestricted_fund_value: str='Conservancy',
 
    ) -> None:
 
        self.prior_range = ranges.DateRange(
 
            cliutil.diff_year(start_date, -1),
...
 
@@ -249,402 +253,325 @@ class Report(core.BaseODS[Sequence[None], None]):
 
            account, sort_period,
 
        ))
 

	
 
    def start_sheet(self,
 
                    sheet_name: str,
 
                    *headers: Iterable[str],
 
                    totals_prefix: Sequence[str]=(),
 
                    first_width: Union[float, str]=3,
 
                    width: Union[float, str]=1.5,
 
    ) -> None:
 
        header_cells: Sequence[odf.table.TableCell] = [
 
            odf.table.TableCell(),
 
            *(self.multiline_cell(header_lines, stylename=self.style_huline)
 
              for header_lines in headers),
 
            *(self.multiline_cell([*totals_prefix, date_s], stylename=self.style_huline)
 
              for date_s in [self.period_name, self.opening_name]),
 
        ]
 
        self.col_count = len(header_cells)
 
        self.use_sheet(sheet_name)
 
        for index in range(self.col_count):
 
            col_style = self.column_style(width if index else first_width)
 
            self.sheet.addElement(odf.table.TableColumn(stylename=col_style))
 
        start_date = self.balances.period_range.start.strftime(self.date_fmt)
 
        self.add_row(
 
            self.multiline_cell([
 
                f"DRAFT Statement of {sheet_name}",
 
                f"{start_date}—{self.period_name}",
 
            ], numbercolumnsspanned=self.col_count, stylename=self.style_header)
 
        )
 
        self.add_row()
 
        self.add_row(*header_cells)
 

	
 
    def write_financial_position(self) -> None:
 
        self.start_sheet("Financial Position")
 

	
 
        prior_assets = core.MutableBalance()
 
        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'):
 
            text_cell = self.string_cell(text)
 
    def write_classifications_by_account(
 
            self,
 
            account: str,
 
            balance_kwargs: Sequence[KWArgs],
 
            exclude_classifications: Collection[str]=frozenset(),
 
            text_prefix: str='',
 
            norm_func: Optional[Callable[[core.Balance], core.Balance]]=None,
 
    ) -> Sequence[core.Balance]:
 
        if norm_func is None:
 
            norm_func = core.normalize_amount_func(f'{account}:RootsOK')
 
        assert len(balance_kwargs) + 1 == self.col_count, \
 
            "called write_classifications with wrong number of balance_kwargs"
 
        retval = [core.MutableBalance() for _ in balance_kwargs]
 
        for text, classification in self.walk_classifications_by_account(account):
 
            text_cell = self.string_cell(text_prefix + text)
 
            if classification is None:
 
                if not text[0].isspace():
 
                    self.add_row()
 
                self.add_row(text_cell)
 
            elif classification in exclude_classifications:
 
                pass
 
            else:
 
                period_bal = self.balances.total(classification=classification)
 
                prior_bal = period_bal - self.balances.total(
 
                    classification=classification, period=Period.PERIOD,
 
                )
 
                self.add_row(
 
                    text_cell,
 
                    self.balance_cell(period_bal),
 
                    self.balance_cell(prior_bal),
 
                )
 
                prior_assets += prior_bal
 
                period_assets += period_bal
 
                row = self.add_row(text_cell)
 
                for kwargs, total_bal in zip(balance_kwargs, retval):
 
                    balance = norm_func(self.balances.total(
 
                        classification=classification, **kwargs,
 
                    ))
 
                    row.addElement(self.balance_cell(balance))
 
                    total_bal += balance
 
        return retval
 

	
 
    def write_financial_position(self) -> None:
 
        self.start_sheet("Financial Position")
 
        balance_kwargs: Sequence[KWArgs] = [
 
            {'period': Period.ANY},
 
            {'period': Period.BEFORE_PERIOD},
 
        ]
 

	
 
        asset_totals = self.write_classifications_by_account('Assets', balance_kwargs)
 
        self.add_row()
 
        self.add_row(
 
            self.string_cell("Total Assets"),
 
            self.balance_cell(period_assets, stylename=self.style_bottomline),
 
            self.balance_cell(prior_assets, stylename=self.style_bottomline),
 
            *(self.balance_cell(balance, stylename=self.style_bottomline)
 
              for balance in asset_totals),
 
        )
 
        self.add_row()
 
        self.add_row()
 

	
 
        prior_liabilities = core.MutableBalance()
 
        period_liabilities = core.MutableBalance()
 
        self.add_row(self.string_cell("Liabilities and Net Assets",
 
                                      stylename=self.style_bold))
 
        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'):
 
            text_cell = self.string_cell(text)
 
            if classification is None:
 
                self.add_row(text_cell)
 
            else:
 
                period_bal = -self.balances.total(classification=classification)
 
                prior_bal = period_bal + self.balances.total(
 
                    classification=classification, period=Period.PERIOD,
 
                )
 
                self.add_row(
 
                    text_cell,
 
                    self.balance_cell(period_bal),
 
                    self.balance_cell(prior_bal),
 
                )
 
                prior_liabilities += prior_bal
 
                period_liabilities += period_bal
 
        liabilities = self.write_classifications_by_account('Liabilities', balance_kwargs)
 
        self.add_row(
 
            self.string_cell("Total Liabilities"),
 
            self.balance_cell(period_liabilities, stylename=self.style_totline),
 
            self.balance_cell(prior_liabilities, stylename=self.style_totline),
 
            *(self.balance_cell(balance, stylename=self.style_totline)
 
              for balance in liabilities),
 
        )
 
        self.add_row()
 
        self.add_row()
 

	
 
        prior_net = core.MutableBalance()
 
        period_net = core.MutableBalance()
 
        equity_totals = [core.MutableBalance() for _ in balance_kwargs]
 
        self.add_row(self.string_cell("Net Assets", stylename=self.style_bold))
 
        self.add_row()
 
        for fund in [Fund.UNRESTRICTED, Fund.RESTRICTED]:
 
            preposition = "Without" if fund is Fund.UNRESTRICTED else "With"
 
            period_bal = -self.balances.total(account=EQUITY_ACCOUNTS, fund=fund)
 
            prior_bal = period_bal + self.balances.total(
 
                account=EQUITY_ACCOUNTS, fund=fund, period=Period.PERIOD,
 
            )
 
            self.add_row(
 
                self.string_cell(f"{preposition} donor restrictions"),
 
                self.balance_cell(period_bal),
 
                self.balance_cell(prior_bal),
 
            )
 
            prior_net += prior_bal
 
            period_net += period_bal
 
            row = self.add_row(self.string_cell(f"{preposition} donor restrictions"))
 
            for kwargs, total_bal in zip(balance_kwargs, equity_totals):
 
                balance = -self.balances.total(account=EQUITY_ACCOUNTS, fund=fund, **kwargs)
 
                row.addElement(self.balance_cell(balance))
 
                total_bal += balance
 
        self.add_row(
 
            self.string_cell("Total Net Assets"),
 
            self.balance_cell(period_net, stylename=self.style_subtotline),
 
            self.balance_cell(prior_net, stylename=self.style_subtotline),
 
            *(self.balance_cell(balance, stylename=self.style_subtotline)
 
              for balance in equity_totals),
 
        )
 
        self.add_row()
 
        self.add_row(
 
            self.string_cell("Total Liabilities and Net Assets"),
 
            self.balance_cell(period_liabilities + period_net,
 
                              stylename=self.style_bottomline),
 
            self.balance_cell(prior_liabilities + prior_net,
 
                              stylename=self.style_bottomline),
 
            *(self.balance_cell(ltot + etot, stylename=self.style_bottomline)
 
              for ltot, etot in zip(liabilities, equity_totals)),
 
        )
 

	
 
    def write_activities(self) -> None:
 
        self.start_sheet(
 
            "Activities",
 
            ["Without Donor", "Restrictions"],
 
            ["With Donor", "Restrictions"],
 
            totals_prefix=["Total Year Ended"],
 
        )
 
        bal_kwargs: Sequence[Dict[str, Any]] = [
 
            {'period': Period.PERIOD, 'fund': Fund.UNRESTRICTED},
 
            {'period': Period.PERIOD, 'fund': Fund.RESTRICTED},
 
            {'period': Period.PERIOD},
 
            {'period': Period.PRIOR},
 
        ]
 

	
 
        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),
 
        income_totals = self.write_classifications_by_account(
 
            'Income', bal_kwargs, (self.C_SATISFIED,),
 
        )
 
                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),
 
              for total in income_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
 
        other_totals = [core.MutableBalance() for _ in bal_kwargs]
 
        other_totals[0] += released
 
        other_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.balance_cell(bal) for bal in other_totals),
 
        )
 
        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),
 
            *(self.balance_cell(inctot + otot, stylename=self.style_totline)
 
              for inctot, otot in zip(income_totals, other_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
 
        other_totals[0] -= period_bal
 
        other_totals[2] -= period_bal
 
        other_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),
 
            *(self.balance_cell(inctot + otot)
 
              for inctot, otot in zip(income_totals, other_totals)),
 
        )
 

	
 
        for kwargs in bal_kwargs:
 
            if kwargs['period'] is Period.PERIOD:
 
                kwargs['period'] = Period.BEFORE_PERIOD
 
            else:
 
                kwargs['period'] = Period.OPENING
 
        beginnings = [
 
        equity_totals = [
 
            -self.balances.total(account=EQUITY_ACCOUNTS, **kwargs)
 
            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.balance_cell(beg_bal) for beg_bal in equity_totals),
 
        )
 

	
 
        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)),
 
            *(self.balance_cell(inctot + otot + eqtot, stylename=self.style_bottomline)
 
              for inctot, otot, eqtot in zip(income_totals, other_totals, equity_totals)),
 
        )
 

	
 
    def write_functional_expenses(self) -> None:
 
        self.start_sheet(
 
            "Functional Expenses",
 
            ["Program", "Services"],
 
            ["Management and", "Administrative"],
 
            ["Fundraising"],
 
            totals_prefix=["Total Year Ended"],
 
        )
 
        bal_kwargs: Sequence[Dict[str, Any]] = [
 
        totals = self.write_classifications_by_account('Expenses', [
 
            {'period': Period.PERIOD, 'post_type': 'program'},
 
            {'period': Period.PERIOD, 'post_type': 'management'},
 
            {'period': Period.PERIOD, 'post_type': 'fundraising'},
 
            {'period': Period.PERIOD},
 
            {'period': Period.PRIOR},
 
        ]
 

	
 
        totals = [core.MutableBalance() for _ in bal_kwargs]
 
        for text, classification in self.walk_classifications_by_account('Expenses'):
 
            text_cell = self.string_cell(text)
 
            if classification is None:
 
                if not text[0].isspace():
 
                    self.add_row()
 
                self.add_row(text_cell)
 
            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),
 
                )
 
                break_bal = sum(balances[:3], core.MutableBalance())
 
                if not (break_bal - balances[3]).clean_copy(1).is_zero():
 
                    logger.warning(
 
                        "Functional expenses breakdown does not match total on row %s",
 
                        len(self.sheet.childNodes) - self.col_count,
 
                    )
 
                for total, bal in zip(totals, balances):
 
                    total += bal
 
        ])
 
        self.add_row()
 
        self.add_row(
 
            self.string_cell("Total Expenses"),
 
            *(self.balance_cell(tot_bal, stylename=self.style_bottomline)
 
              for tot_bal in totals),
 
        )
 

	
 
    def write_cash_flows(self) -> None:
 
        self.start_sheet("Cash Flows")
 
        bal_kwargs: Sequence[Dict[str, Any]] = [
 
            {'period': Period.PERIOD},
 
            {'period': Period.PRIOR},
 
        ]
 
        norm_func = operator.neg
 

	
 
        self.add_row(self.string_cell(
 
            "Cash Flows from Operating Activities",
 
            stylename=self.style_bold,
 
        ))
 
        self.add_row()
 

	
 
        totals = [
 
        equity_totals = [
 
            -self.balances.total(account=EQUITY_ACCOUNTS, **kwargs)
 
            for kwargs in bal_kwargs
 
        ]
 
        self.add_row(
 
            self.string_cell("Change in Net Assets"),
 
            *(self.balance_cell(bal) for bal in totals),
 
            *(self.balance_cell(bal) for bal in equity_totals),
 
        )
 
        self.add_row(self.string_cell(
 
            "(Increase) decrease in operating assets:",
 
        ))
 
        for text, classification in self.walk_classifications_by_account('Assets'):
 
            text_cell = self.string_cell(self.SPACE + text)
 
            if classification is None:
 
                self.add_row(text_cell)
 
            elif classification == self.C_CASH:
 
                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),
 
        asset_totals = self.write_classifications_by_account(
 
            'Assets', bal_kwargs, (self.C_CASH,), self.SPACE, norm_func,
 
        )
 
                for total, bal in zip(totals, balances):
 
                    total += bal
 
        self.add_row(self.string_cell(
 
            "Increase (decrease) in operating liabilities:",
 
        ))
 
        for text, classification in self.walk_classifications_by_account('Liabilities'):
 
            text_cell = self.string_cell(self.SPACE + text)
 
            if classification is None:
 
                self.add_row(text_cell)
 
            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),
 
        liabilities = self.write_classifications_by_account(
 
            'Liabilities', bal_kwargs, (), self.SPACE, norm_func,
 
        )
 
                for total, bal in zip(totals, balances):
 
                    total += bal
 
        totals = [
 
            sum(bals, core.MutableBalance())
 
            for bals in zip(equity_totals, asset_totals, liabilities)
 
        ]
 
        self.add_row(
 
            self.string_cell("Net cash provided by operating activites"),
 
            *(self.balance_cell(tot_bal, stylename=self.style_totline)
 
              for tot_bal in totals),
 
        )
 
        self.add_row()
 

	
 
        self.add_row(
 
            self.string_cell("Net Increase in Cash"),
 
            *(self.balance_cell(tot_bal) for tot_bal in totals),
 
        )
 
        self.add_row()
 
        balances = [
 
            self.balances.total(classification=self.C_CASH, period=period)
 
            for period in [Period.BEFORE_PERIOD, Period.OPENING]
 
        ]
 
        self.add_row(
 
            self.string_cell("Beginning Cash"),
 
            *(self.balance_cell(bal) for bal in balances),
 
        )
 
        self.add_row()
 
        self.add_row(
 
            self.string_cell("Ending Cash"),
 
            *(self.balance_cell(tot + bal, stylename=self.style_bottomline)
 
              for tot, bal in zip(totals, balances)),
 
        )
 

	
 

	
 
def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace:
 
    parser = argparse.ArgumentParser(prog=PROGNAME)
 
    cliutil.add_version_argument(parser)
 
    parser.add_argument(
0 comments (0 inline, 0 general)