Changeset - a0ff9e6834aa
[Not reviewed]
0 2 0
Brett Smith - 4 years ago 2020-08-18 19:15:02
brettcsmith@brettcsmith.org
balance_sheet: Support arbitrary date ranges.

This lets you do year-over-year comparisons of smaller ranges of time, like
a quarter.
2 files changed with 33 insertions and 19 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/reports/balance_sheet.py
Show inline comments
...
 
@@ -62,63 +62,76 @@ 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()
 
    MIDDLE = enum.auto()
 
    PERIOD = enum.auto()
 
    BEFORE_PERIOD = OPENING | PRIOR
 
    ANY = OPENING | PRIOR | PERIOD
 
    THRU_PRIOR = OPENING | PRIOR
 
    THRU_MIDDLE = THRU_PRIOR | MIDDLE
 
    ANY = THRU_MIDDLE | 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),
 
            cliutil.diff_year(stop_date, -1),
 
        )
 
        assert self.prior_range.stop <= start_date
 
        year_diff = (stop_date - start_date).days // 365
 
        if year_diff == 0:
 
            self.prior_range = ranges.DateRange(
 
                cliutil.diff_year(start_date, -1),
 
                cliutil.diff_year(stop_date, -1),
 
            )
 
            self.period_desc = "Period"
 
        else:
 
            self.prior_range = ranges.DateRange(
 
                cliutil.diff_year(start_date, -year_diff),
 
                start_date,
 
            )
 
            self.period_desc = f"Year{'s' if year_diff > 1 else ''}"
 
        self.middle_range = ranges.DateRange(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)
 
        for post in postings:
 
            post_date = post.meta.date
 
            if post_date in self.period_range:
 
            if post_date >= stop_date:
 
                continue
 
            elif post_date in self.period_range:
 
                period = Period.PERIOD
 
            elif post_date in self.middle_range:
 
                period = Period.MIDDLE
 
            elif post_date in self.prior_range:
 
                period = Period.PRIOR
 
            elif post_date < self.prior_range.start:
 
                period = Period.OPENING
 
            else:
 
                continue
 
                period = Period.OPENING
 
            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 = account.meta['classification']
 
                if isinstance(classification_s, str):
 
                    classification = data.Account(classification_s)
...
 
@@ -205,24 +218,25 @@ class Report(core.BaseODS[Sequence[None], None]):
 
    C_CASH = 'Cash'
 
    C_SATISFIED = 'Satisfaction of program restrictions'
 
    NO_BALANCE = core.Balance()
 
    SPACE = ' ' * 4
 

	
 
    def __init__(self,
 
                 balances: Balances,
 
                 *,
 
                 date_fmt: str='%B %d, %Y',
 
    ) -> None:
 
        super().__init__()
 
        self.balances = balances
 
        self.period_desc = balances.period_desc
 
        self.date_fmt = date_fmt
 
        one_day = datetime.timedelta(days=1)
 
        date = balances.period_range.stop - one_day
 
        self.period_name = date.strftime(date_fmt)
 
        date = balances.prior_range.stop - one_day
 
        self.opening_name = date.strftime(date_fmt)
 
        self.last_totals_row = odf.table.TableRow()
 

	
 
    def section_key(self, row: Sequence[None]) -> None:
 
        raise NotImplementedError("balance_sheet.Report.section_key")
 

	
 
    def init_styles(self) -> None:
...
 
@@ -350,25 +364,25 @@ class Report(core.BaseODS[Sequence[None], None]):
 
            self.string_cell(text),
 
            *(self.balance_cell(
 
                sum(sum_bals, core.MutableBalance()),
 
                stylename=stylename,
 
            ) for sum_bals in zip(*balances)),
 
        )
 
        return self.last_totals_row
 

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

	
 
        asset_totals = self.write_classifications_by_account('Assets', balance_kwargs)
 
        self.write_totals_row(
 
            "Total Assets", asset_totals, stylename=self.style_bottomline,
 
        )
 
        self.add_row()
 
        self.add_row()
 

	
 
        liabilities = self.write_classifications_by_account('Liabilities', balance_kwargs)
 
        self.write_totals_row(
 
            "Total Liabilities", liabilities, stylename=self.style_totline,
...
 
@@ -391,25 +405,25 @@ class Report(core.BaseODS[Sequence[None], None]):
 
        )
 
        self.write_totals_row(
 
            "Total Liabilities and Net Assets",
 
            liabilities, equity_totals,
 
            stylename=self.style_bottomline,
 
        )
 

	
 
    def write_activities(self) -> None:
 
        self.start_sheet(
 
            "Activities",
 
            ["Without Donor", "Restrictions"],
 
            ["With Donor", "Restrictions"],
 
            totals_prefix=["Total Year Ended"],
 
            totals_prefix=[f"Total {self.period_desc} 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},
 
        ]
 

	
 
        self.add_row(self.string_cell("Support and Revenue", stylename=self.style_bold))
 
        self.add_row()
 
        income_totals = self.write_classifications_by_account(
 
            'Income', bal_kwargs, (self.C_SATISFIED,),
...
 
@@ -473,45 +487,45 @@ class Report(core.BaseODS[Sequence[None], None]):
 
            self.NO_BALANCE,
 
            period_bal,
 
            prior_bal,
 
        ], stylename=self.style_totline, leading_rows=0)
 

	
 
        other_totals[0] -= period_bal
 
        other_totals[2] -= period_bal
 
        other_totals[3] -= prior_bal
 
        self.write_totals_row("Change in Net Assets", income_totals, other_totals)
 

	
 
        for kwargs in bal_kwargs:
 
            if kwargs['period'] is Period.PERIOD:
 
                kwargs['period'] = Period.BEFORE_PERIOD
 
                kwargs['period'] = Period.THRU_MIDDLE
 
            else:
 
                kwargs['period'] = Period.OPENING
 
        equity_totals = [
 
            -self.balances.total(account=EQUITY_ACCOUNTS, **kwargs)
 
            for kwargs in bal_kwargs
 
        ]
 
        self.write_totals_row("Beginning Net Assets", equity_totals)
 
        self.write_totals_row(
 
            "Ending Net Assets",
 
            income_totals, other_totals, equity_totals,
 
            stylename=self.style_bottomline,
 
        )
 

	
 
    def write_functional_expenses(self) -> None:
 
        self.start_sheet(
 
            "Functional Expenses",
 
            ["Program", "Services"],
 
            ["Management and", "Administrative"],
 
            ["Fundraising"],
 
            totals_prefix=["Total Year Ended"],
 
            totals_prefix=[f"Total {self.period_desc} Ended"],
 
        )
 
        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},
 
        ])
 
        self.write_totals_row(
 
            "Total Expenses",
 
            totals,
 
            stylename=self.style_bottomline,
...
 
@@ -549,40 +563,40 @@ class Report(core.BaseODS[Sequence[None], None]):
 
        period_totals = [
 
            sum(bals, core.MutableBalance())
 
            for bals in zip(equity_totals, asset_totals, liabilities)
 
        ]
 
        self.write_totals_row(
 
            "Net cash provided by operating activities",
 
            period_totals,
 
            stylename=self.style_totline,
 
        )
 
        self.write_totals_row("Net Increase in Cash", period_totals)
 
        begin_totals = [
 
            self.balances.total(classification=self.C_CASH, period=period)
 
            for period in [Period.BEFORE_PERIOD, Period.OPENING]
 
            for period in [Period.THRU_MIDDLE, Period.OPENING]
 
        ]
 
        self.write_totals_row("Beginning Cash", begin_totals)
 
        self.write_totals_row(
 
            "Ending Cash",
 
            period_totals, begin_totals,
 
            stylename=self.style_bottomline,
 
        )
 

	
 
    def write_trial_balances(self) -> None:
 
        self.start_sheet(
 
            "Trial Balances",
 
            ["Account Name"],
 
            ["Classification"],
 
            ["Balance Ending", self.opening_name],
 
            totals_prefix=["Change During", "Year Ending"],
 
            ["Balance Beginning", self.balances.period_range.start.strftime(self.date_fmt)],
 
            totals_prefix=["Change During", f"{self.period_desc} Ending"],
 
            title_fmt="Chart of Accounts with DRAFT {sheet_name}",
 
        )
 
        # Widen text columns
 
        col_style = self.column_style(3.5)
 
        for col in self.sheet.childNodes[:2]:
 
            col.setAttribute('stylename', col_style)
 
        # Patch up header row text
 
        header_row = self.sheet.lastChild
 
        header_row.removeChild(header_row.firstChild)
 
        header_row.insertBefore(self.multiline_cell(
 
            ["Balance Ending", self.period_name],
 
            stylename=header_row.lastChild.getAttribute('stylename'),
setup.py
Show inline comments
 
#!/usr/bin/env python3
 

	
 
from setuptools import setup
 

	
 
setup(
 
    name='conservancy_beancount',
 
    description="Plugin, library, and reports for reading Conservancy's books",
 
    version='1.8.3',
 
    version='1.8.4',
 
    author='Software Freedom Conservancy',
 
    author_email='info@sfconservancy.org',
 
    license='GNU AGPLv3+',
 

	
 
    install_requires=[
 
        'babel>=2.6',  # Debian:python3-babel
 
        'beancount>=2.2',  # Debian:beancount
 
        'GitPython>=2.0',  # Debian:python3-git
 
        # 1.4.1 crashes when trying to save some documents.
 
        'odfpy>=1.4.0,!=1.4.1',  # Debian:python3-odf
 
        'PyYAML>=3.0',  # Debian:python3-yaml
 
        'regex',  # Debian:python3-regex
0 comments (0 inline, 0 general)