balance_sheet: Add activity report.

This commit is contained in:
Brett Smith 2020-08-17 12:01:14 -04:00
parent 89bbf84347
commit 29d4325c7a
3 changed files with 228 additions and 41 deletions

View file

@ -22,12 +22,16 @@ import logging
import os import os
import sys import sys
from decimal import Decimal
from pathlib import Path from pathlib import Path
from typing import ( from typing import (
Any,
Dict,
Hashable, Hashable,
Iterable, Iterable,
Iterator, Iterator,
List,
Mapping, Mapping,
NamedTuple, NamedTuple,
Optional, Optional,
@ -50,14 +54,18 @@ from .. import ranges
PROGNAME = 'balance-sheet-report' PROGNAME = 'balance-sheet-report'
logger = logging.getLogger('conservancy_beancount.tools.balance_sheet') logger = logging.getLogger('conservancy_beancount.tools.balance_sheet')
class Fund(enum.Enum): class Fund(enum.IntFlag):
RESTRICTED = enum.auto() RESTRICTED = enum.auto()
UNRESTRICTED = enum.auto() UNRESTRICTED = enum.auto()
ANY = RESTRICTED | UNRESTRICTED
class Period(enum.Enum): class Period(enum.IntFlag):
OPENING = enum.auto() OPENING = enum.auto()
PRIOR = enum.auto()
PERIOD = enum.auto() PERIOD = enum.auto()
BEFORE_PERIOD = OPENING | PRIOR
ANY = OPENING | PRIOR | PERIOD
class BalanceKey(NamedTuple): class BalanceKey(NamedTuple):
@ -81,11 +89,11 @@ class Balances:
fund_key: str='project', fund_key: str='project',
unrestricted_fund_value: str='Conservancy', unrestricted_fund_value: str='Conservancy',
) -> None: ) -> None:
self.opening_range = ranges.DateRange( self.prior_range = ranges.DateRange(
cliutil.diff_year(start_date, -1), cliutil.diff_year(start_date, -1),
cliutil.diff_year(stop_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.period_range = ranges.DateRange(start_date, stop_date)
self.balances: Mapping[BalanceKey, core.MutableBalance] \ self.balances: Mapping[BalanceKey, core.MutableBalance] \
= collections.defaultdict(core.MutableBalance) = collections.defaultdict(core.MutableBalance)
@ -93,34 +101,40 @@ class Balances:
post_date = post.meta.date post_date = post.meta.date
if post_date in self.period_range: if post_date in self.period_range:
period = Period.PERIOD 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 period = Period.OPENING
else: else:
continue continue
if post.account == 'Expenses:CurrencyConversion':
account = data.Account('Income:CurrencyConversion')
else:
account = post.account
if post.meta.get(fund_key) == unrestricted_fund_value: if post.meta.get(fund_key) == unrestricted_fund_value:
fund = Fund.UNRESTRICTED fund = Fund.UNRESTRICTED
else: else:
fund = Fund.RESTRICTED fund = Fund.RESTRICTED
try: try:
classification_s = post.account.meta['classification'] classification_s = account.meta['classification']
if isinstance(classification_s, str): if isinstance(classification_s, str):
classification = data.Account(classification_s) classification = data.Account(classification_s)
else: else:
raise TypeError() raise TypeError()
except (KeyError, TypeError): except (KeyError, TypeError):
classification = post.account classification = account
try: try:
post_type = post.meta[self.POST_TYPES[post.account.root_part()]] post_type = post.meta[self.POST_TYPES[account.root_part()]]
except KeyError: except KeyError:
post_type = None 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() self.balances[key] += post.at_cost()
def total(self, def total(self,
account: Optional[str]=None, account: Optional[str]=None,
classification: Optional[str]=None, classification: Optional[str]=None,
period: Optional[Period]=None, period: int=Period.ANY,
fund: Optional[Fund]=None, fund: int=Fund.ANY,
post_type: Optional[str]=None, post_type: Optional[str]=None,
) -> core.Balance: ) -> core.Balance:
retval = core.MutableBalance() retval = core.MutableBalance()
@ -130,9 +144,9 @@ class Balances:
elif not (classification is None elif not (classification is None
or key.classification.is_under(classification)): or key.classification.is_under(classification)):
pass pass
elif not (period is None or period is key.period): elif not period & key.period:
pass pass
elif not (fund is None or fund is key.fund): elif not fund & key.fund:
pass pass
elif not (post_type is None or post_type == key.post_type): elif not (post_type is None or post_type == key.post_type):
pass pass
@ -140,22 +154,37 @@ class Balances:
retval += balance retval += balance
return retval 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] \ class_bals: Mapping[data.Account, core.MutableBalance] \
= collections.defaultdict(core.MutableBalance) = collections.defaultdict(core.MutableBalance)
for key, balance in self.balances.items(): 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 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') norm_func = core.normalize_amount_func(f'{account}:RootsOK')
def sortkey(acct: data.Account) -> Hashable: def sortkey(acct: data.Account) -> Hashable:
prefix, _, _ = acct.rpartition(':') prefix, _, _ = acct.rpartition(':')
balance = norm_func(class_bals[acct]) 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 prefix, -max_bal
return sorted(class_bals, key=sortkey) return sorted(class_bals, key=sortkey)
class Report(core.BaseODS[Sequence[None], None]): 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, def __init__(self,
balances: Balances, balances: Balances,
*, *,
@ -166,7 +195,7 @@ class Report(core.BaseODS[Sequence[None], None]):
one_day = datetime.timedelta(days=1) one_day = datetime.timedelta(days=1)
date = balances.period_range.stop - one_day date = balances.period_range.stop - one_day
self.period_name = date.strftime(date_fmt) 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) self.opening_name = date.strftime(date_fmt)
def section_key(self, row: Sequence[None]) -> None: def section_key(self, row: Sequence[None]) -> None:
@ -188,6 +217,7 @@ class Report(core.BaseODS[Sequence[None], None]):
def write_all(self) -> None: def write_all(self) -> None:
self.write_financial_position() self.write_financial_position()
self.write_activities()
def walk_classifications(self, cseq: Iterable[data.Account]) \ def walk_classifications(self, cseq: Iterable[data.Account]) \
-> Iterator[Tuple[str, Optional[data.Account]]]: -> Iterator[Tuple[str, Optional[data.Account]]]:
@ -201,9 +231,14 @@ class Report(core.BaseODS[Sequence[None], None]):
last_prefix = parts last_prefix = parts
yield f'{tabs}{tail}', classification yield f'{tabs}{tail}', classification
def walk_classifications_by_account(self, account: str) \ def walk_classifications_by_account(
-> Iterator[Tuple[str, Optional[data.Account]]]: self,
return self.walk_classifications(self.balances.classifications(account)) 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: def write_financial_position(self) -> None:
self.use_sheet("Financial Position") self.use_sheet("Financial Position")
@ -227,15 +262,13 @@ class Report(core.BaseODS[Sequence[None], None]):
period_assets = core.MutableBalance() period_assets = core.MutableBalance()
self.add_row(self.string_cell("Assets", stylename=self.style_bold)) self.add_row(self.string_cell("Assets", stylename=self.style_bold))
self.add_row() 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) text_cell = self.string_cell(text)
if classification is None: if classification is None:
self.add_row(text_cell) self.add_row(text_cell)
else: else:
prior_bal = self.balances.total( period_bal = self.balances.total(classification=classification)
classification=classification, period=Period.OPENING, prior_bal = period_bal - self.balances.total(
)
period_bal = prior_bal + self.balances.total(
classification=classification, period=Period.PERIOD, classification=classification, period=Period.PERIOD,
) )
self.add_row( self.add_row(
@ -261,15 +294,13 @@ class Report(core.BaseODS[Sequence[None], None]):
self.add_row() self.add_row()
self.add_row(self.string_cell("Liabilities", stylename=self.style_bold)) self.add_row(self.string_cell("Liabilities", stylename=self.style_bold))
self.add_row() 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) text_cell = self.string_cell(text)
if classification is None: if classification is None:
self.add_row(text_cell) self.add_row(text_cell)
else: else:
prior_bal = -self.balances.total( period_bal = -self.balances.total(classification=classification)
classification=classification, period=Period.OPENING, prior_bal = period_bal + self.balances.total(
)
period_bal = prior_bal - self.balances.total(
classification=classification, period=Period.PERIOD, classification=classification, period=Period.PERIOD,
) )
self.add_row( self.add_row(
@ -291,16 +322,15 @@ class Report(core.BaseODS[Sequence[None], None]):
period_net = core.MutableBalance() period_net = core.MutableBalance()
self.add_row(self.string_cell("Net Assets", stylename=self.style_bold)) self.add_row(self.string_cell("Net Assets", stylename=self.style_bold))
self.add_row() self.add_row()
accounts = ['Equity', 'Income', 'Expenses']
for fund in [Fund.UNRESTRICTED, Fund.RESTRICTED]: for fund in [Fund.UNRESTRICTED, Fund.RESTRICTED]:
preposition = "Without" if fund is Fund.UNRESTRICTED else "With" preposition = "Without" if fund is Fund.UNRESTRICTED else "With"
prior_bal = -sum( period_bal = -sum(
(self.balances.total(account=account, period=Period.OPENING, fund=fund) (self.balances.total(account=account, fund=fund)
for account in accounts), core.MutableBalance(), for account in self.EQUITY_ACCOUNTS), core.MutableBalance(),
) )
period_bal = prior_bal - sum( prior_bal = period_bal + sum(
(self.balances.total(account=account, period=Period.PERIOD, fund=fund) (self.balances.total(account=account, fund=fund, period=Period.PERIOD)
for account in accounts), core.MutableBalance(), for account in self.EQUITY_ACCOUNTS), core.MutableBalance(),
) )
self.add_row( self.add_row(
self.string_cell(f"{preposition} donor restrictions"), self.string_cell(f"{preposition} donor restrictions"),
@ -323,6 +353,163 @@ class Report(core.BaseODS[Sequence[None], None]):
stylename=self.style_bottomline), 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: def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog=PROGNAME) parser = argparse.ArgumentParser(prog=PROGNAME)

View file

@ -5,7 +5,7 @@ from setuptools import setup
setup( setup(
name='conservancy_beancount', name='conservancy_beancount',
description="Plugin, library, and reports for reading Conservancy's books", description="Plugin, library, and reports for reading Conservancy's books",
version='1.7.2', version='1.7.3',
author='Software Freedom Conservancy', author='Software Freedom Conservancy',
author_email='info@sfconservancy.org', author_email='info@sfconservancy.org',
license='GNU AGPLv3+', license='GNU AGPLv3+',

View file

@ -83,7 +83,7 @@ def income_expense_balances():
({'classification': 'Postage'}, 30), ({'classification': 'Postage'}, 30),
({'classification': 'Services'}, 20), ({'classification': 'Services'}, 20),
({'classification': 'Nonexistent'}, None), ({'classification': 'Nonexistent'}, None),
({'period': Period.OPENING, 'account': 'Income'}, '-9.60'), ({'period': Period.PRIOR, 'account': 'Income'}, '-9.60'),
({'period': Period.PERIOD, 'account': 'Expenses'}, 26), ({'period': Period.PERIOD, 'account': 'Expenses'}, 26),
({'fund': Fund.RESTRICTED, 'account': 'Income'}, -10), ({'fund': Fund.RESTRICTED, 'account': 'Income'}, -10),
({'fund': Fund.UNRESTRICTED, 'account': 'Expenses'}, 25), ({'fund': Fund.UNRESTRICTED, 'account': 'Expenses'}, 25),
@ -91,10 +91,10 @@ def income_expense_balances():
({'post_type': 'fundraising'}, 20), ({'post_type': 'fundraising'}, 20),
({'post_type': 'management'}, 10), ({'post_type': 'management'}, 10),
({'post_type': 'Nonexistent'}, None), ({'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), ({'fund': Fund.RESTRICTED, 'post_type': 'program'}, 10),
({'period': Period.PERIOD, 'fund': Fund.UNRESTRICTED, 'post_type': 'RBI'}, '-2.60'), ({'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), ({'period': Period.PERIOD, 'fund': Fund.RESTRICTED, 'post_type': 'ø'}, None),
]) ])
def test_balance_total(income_expense_balances, kwargs, expected): def test_balance_total(income_expense_balances, kwargs, expected):