balance_sheet: Start report with financial position. RT#11854.
This commit is contained in:
parent
385f5a20da
commit
a87d4bfc6c
3 changed files with 549 additions and 1 deletions
425
conservancy_beancount/reports/balance_sheet.py
Normal file
425
conservancy_beancount/reports/balance_sheet.py
Normal file
|
@ -0,0 +1,425 @@
|
||||||
|
"""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 os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from typing import (
|
||||||
|
Hashable,
|
||||||
|
Iterable,
|
||||||
|
Iterator,
|
||||||
|
Mapping,
|
||||||
|
NamedTuple,
|
||||||
|
Optional,
|
||||||
|
Sequence,
|
||||||
|
TextIO,
|
||||||
|
Tuple,
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
PROGNAME = 'balance-sheet-report'
|
||||||
|
logger = logging.getLogger('conservancy_beancount.tools.balance_sheet')
|
||||||
|
|
||||||
|
class Fund(enum.Enum):
|
||||||
|
RESTRICTED = enum.auto()
|
||||||
|
UNRESTRICTED = enum.auto()
|
||||||
|
|
||||||
|
|
||||||
|
class Period(enum.Enum):
|
||||||
|
OPENING = enum.auto()
|
||||||
|
PERIOD = enum.auto()
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceKey(NamedTuple):
|
||||||
|
account: data.Account
|
||||||
|
classification: data.Account
|
||||||
|
period: Period
|
||||||
|
fund: Fund
|
||||||
|
post_type: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class Balances:
|
||||||
|
POST_TYPES = {
|
||||||
|
'Income': 'income-type',
|
||||||
|
'Expenses': 'expense-type',
|
||||||
|
}
|
||||||
|
|
||||||
|
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.opening_range = ranges.DateRange(
|
||||||
|
cliutil.diff_year(start_date, -1),
|
||||||
|
cliutil.diff_year(stop_date, -1),
|
||||||
|
)
|
||||||
|
assert self.opening_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:
|
||||||
|
period = Period.PERIOD
|
||||||
|
elif post_date < self.period_range.start:
|
||||||
|
period = Period.OPENING
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if post.meta.get(fund_key) == unrestricted_fund_value:
|
||||||
|
fund = Fund.UNRESTRICTED
|
||||||
|
else:
|
||||||
|
fund = Fund.RESTRICTED
|
||||||
|
try:
|
||||||
|
classification_s = post.account.meta['classification']
|
||||||
|
if isinstance(classification_s, str):
|
||||||
|
classification = data.Account(classification_s)
|
||||||
|
else:
|
||||||
|
raise TypeError()
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
classification = post.account
|
||||||
|
try:
|
||||||
|
post_type = post.meta[self.POST_TYPES[post.account.root_part()]]
|
||||||
|
except KeyError:
|
||||||
|
post_type = None
|
||||||
|
key = BalanceKey(post.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,
|
||||||
|
post_type: Optional[str]=None,
|
||||||
|
) -> core.Balance:
|
||||||
|
retval = core.MutableBalance()
|
||||||
|
for key, balance in self.balances.items():
|
||||||
|
if not (account is None or key.account.is_under(account)):
|
||||||
|
pass
|
||||||
|
elif not (classification is None
|
||||||
|
or key.classification.is_under(classification)):
|
||||||
|
pass
|
||||||
|
elif not (period is None or period is key.period):
|
||||||
|
pass
|
||||||
|
elif not (fund is None or fund is key.fund):
|
||||||
|
pass
|
||||||
|
elif not (post_type is None or post_type == key.post_type):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
retval += balance
|
||||||
|
return retval
|
||||||
|
|
||||||
|
def classifications(self, account: str) -> 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):
|
||||||
|
class_bals[key.classification] += balance
|
||||||
|
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())
|
||||||
|
return prefix, -max_bal
|
||||||
|
return sorted(class_bals, key=sortkey)
|
||||||
|
|
||||||
|
|
||||||
|
class Report(core.BaseODS[Sequence[None], None]):
|
||||||
|
def __init__(self,
|
||||||
|
balances: Balances,
|
||||||
|
*,
|
||||||
|
date_fmt: str='%B %d, %Y',
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.balances = balances
|
||||||
|
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
|
||||||
|
self.opening_name = date.strftime(date_fmt)
|
||||||
|
|
||||||
|
def section_key(self, row: Sequence[None]) -> None:
|
||||||
|
raise NotImplementedError("balance_sheet.Report.section_key")
|
||||||
|
|
||||||
|
def init_styles(self) -> None:
|
||||||
|
super().init_styles()
|
||||||
|
self.style_header = self.merge_styles(self.style_bold, self.style_centertext)
|
||||||
|
self.style_huline = self.merge_styles(
|
||||||
|
self.style_header,
|
||||||
|
self.border_style(core.Border.BOTTOM, '1pt'),
|
||||||
|
)
|
||||||
|
self.style_subtotline = self.border_style(core.Border.TOP, '1pt')
|
||||||
|
self.style_totline = self.border_style(core.Border.TOP | core.Border.BOTTOM, '1pt')
|
||||||
|
self.style_bottomline = self.merge_styles(
|
||||||
|
self.style_subtotline,
|
||||||
|
self.border_style(core.Border.BOTTOM, '2pt', 'double'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def write_all(self) -> None:
|
||||||
|
self.write_financial_position()
|
||||||
|
|
||||||
|
def walk_classifications(self, cseq: Iterable[data.Account]) \
|
||||||
|
-> Iterator[Tuple[str, Optional[data.Account]]]:
|
||||||
|
last_prefix: Sequence[str] = []
|
||||||
|
for classification in cseq:
|
||||||
|
parts = classification.split(':')
|
||||||
|
tail = parts.pop()
|
||||||
|
tabs = '\t' * len(parts)
|
||||||
|
if parts != last_prefix:
|
||||||
|
yield f'{tabs[1:]}{parts[-1]}', 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 write_financial_position(self) -> None:
|
||||||
|
self.use_sheet("Financial Position")
|
||||||
|
for width in [3, 1.5, 1.5]:
|
||||||
|
col_style = self.column_style(width)
|
||||||
|
self.sheet.addElement(odf.table.TableColumn(stylename=col_style))
|
||||||
|
self.add_row(
|
||||||
|
self.multiline_cell([
|
||||||
|
"DRAFT Statement of Financial Position",
|
||||||
|
self.period_name,
|
||||||
|
], numbercolumnsspanned=3, stylename=self.style_header)
|
||||||
|
)
|
||||||
|
self.add_row()
|
||||||
|
self.add_row(
|
||||||
|
odf.table.TableCell(),
|
||||||
|
self.string_cell(self.period_name, stylename=self.style_huline),
|
||||||
|
self.string_cell(self.opening_name, stylename=self.style_huline),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
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(
|
||||||
|
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
|
||||||
|
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.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:
|
||||||
|
prior_bal = -self.balances.total(
|
||||||
|
classification=classification, period=Period.OPENING,
|
||||||
|
)
|
||||||
|
period_bal = prior_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
|
||||||
|
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.add_row()
|
||||||
|
self.add_row()
|
||||||
|
|
||||||
|
prior_net = core.MutableBalance()
|
||||||
|
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 = prior_bal - sum(
|
||||||
|
(self.balances.total(account=account, period=Period.PERIOD, fund=fund)
|
||||||
|
for account in accounts), core.MutableBalance(),
|
||||||
|
)
|
||||||
|
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
|
||||||
|
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.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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(prog=PROGNAME)
|
||||||
|
cliutil.add_version_argument(parser)
|
||||||
|
parser.add_argument(
|
||||||
|
'--begin', '--start', '-b',
|
||||||
|
dest='start_date',
|
||||||
|
metavar='DATE',
|
||||||
|
type=cliutil.date_arg,
|
||||||
|
help="""Date to start reporting entries, inclusive, in YYYY-MM-DD format.
|
||||||
|
The default is one year ago.
|
||||||
|
""")
|
||||||
|
parser.add_argument(
|
||||||
|
'--end', '--stop', '-e',
|
||||||
|
dest='stop_date',
|
||||||
|
metavar='DATE',
|
||||||
|
type=cliutil.date_arg,
|
||||||
|
help="""Date to stop reporting entries, exclusive, in YYYY-MM-DD format.
|
||||||
|
The default is a year after the start date.
|
||||||
|
""")
|
||||||
|
parser.add_argument(
|
||||||
|
'--fund-metadata-key', '-m',
|
||||||
|
metavar='KEY',
|
||||||
|
default='project',
|
||||||
|
help="""Name of the fund metadata key. Default %(default)s.
|
||||||
|
""")
|
||||||
|
parser.add_argument(
|
||||||
|
'--unrestricted-fund', '-u',
|
||||||
|
metavar='PROJECT',
|
||||||
|
default='Conservancy',
|
||||||
|
help="""Name of the unrestricted fund. Default %(default)s.
|
||||||
|
""")
|
||||||
|
parser.add_argument(
|
||||||
|
'--output-file', '-O',
|
||||||
|
metavar='PATH',
|
||||||
|
type=Path,
|
||||||
|
help="""Write the report to this file, or stdout when PATH is `-`.
|
||||||
|
""")
|
||||||
|
cliutil.add_loglevel_argument(parser)
|
||||||
|
return parser.parse_args(arglist)
|
||||||
|
|
||||||
|
def main(arglist: Optional[Sequence[str]]=None,
|
||||||
|
stdout: TextIO=sys.stdout,
|
||||||
|
stderr: TextIO=sys.stderr,
|
||||||
|
config: Optional[configmod.Config]=None,
|
||||||
|
) -> int:
|
||||||
|
args = parse_arguments(arglist)
|
||||||
|
cliutil.set_loglevel(logger, args.loglevel)
|
||||||
|
if config is None:
|
||||||
|
config = configmod.Config()
|
||||||
|
config.load_file()
|
||||||
|
|
||||||
|
if args.stop_date is None:
|
||||||
|
if args.start_date is None:
|
||||||
|
args.stop_date = datetime.date.today()
|
||||||
|
else:
|
||||||
|
args.stop_date = cliutil.diff_year(args.start_date, 1)
|
||||||
|
if args.start_date is None:
|
||||||
|
args.start_date = cliutil.diff_year(args.stop_date, -1)
|
||||||
|
|
||||||
|
returncode = 0
|
||||||
|
books_loader = config.books_loader()
|
||||||
|
if books_loader is None:
|
||||||
|
entries, load_errors, options_map = books.Loader.load_none(config.config_file_path())
|
||||||
|
returncode = cliutil.ExitCode.NoConfiguration
|
||||||
|
else:
|
||||||
|
start_fy = config.fiscal_year_begin().for_date(args.start_date) - 1
|
||||||
|
entries, load_errors, options_map = books_loader.load_fy_range(start_fy, args.stop_date)
|
||||||
|
if load_errors:
|
||||||
|
returncode = cliutil.ExitCode.BeancountErrors
|
||||||
|
elif not entries:
|
||||||
|
returncode = cliutil.ExitCode.NoDataLoaded
|
||||||
|
for error in load_errors:
|
||||||
|
bc_printer.print_error(error, file=stderr)
|
||||||
|
|
||||||
|
data.Account.load_from_books(entries, options_map)
|
||||||
|
balances = Balances(
|
||||||
|
data.Posting.from_entries(entries),
|
||||||
|
args.start_date,
|
||||||
|
args.stop_date,
|
||||||
|
args.fund_metadata_key,
|
||||||
|
args.unrestricted_fund,
|
||||||
|
)
|
||||||
|
report = Report(balances)
|
||||||
|
report.set_common_properties(config.books_repo())
|
||||||
|
report.write_all()
|
||||||
|
if args.output_file is None:
|
||||||
|
out_dir_path = config.repository_path() or Path()
|
||||||
|
args.output_file = out_dir_path / 'BalanceSheet_{}_{}.ods'.format(
|
||||||
|
args.start_date.isoformat(), args.stop_date.isoformat(),
|
||||||
|
)
|
||||||
|
logger.info("Writing report to %s", args.output_file)
|
||||||
|
ods_file = cliutil.bytes_output(args.output_file, stdout)
|
||||||
|
report.save_file(ods_file)
|
||||||
|
return returncode
|
||||||
|
|
||||||
|
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
exit(entry_point())
|
3
setup.py
3
setup.py
|
@ -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.1',
|
version='1.7.2',
|
||||||
author='Software Freedom Conservancy',
|
author='Software Freedom Conservancy',
|
||||||
author_email='info@sfconservancy.org',
|
author_email='info@sfconservancy.org',
|
||||||
license='GNU AGPLv3+',
|
license='GNU AGPLv3+',
|
||||||
|
@ -37,6 +37,7 @@ setup(
|
||||||
entry_points={
|
entry_points={
|
||||||
'console_scripts': [
|
'console_scripts': [
|
||||||
'accrual-report = conservancy_beancount.reports.accrual:entry_point',
|
'accrual-report = conservancy_beancount.reports.accrual:entry_point',
|
||||||
|
'balance-sheet-report = conservancy_beancount.reports.balance_sheet:entry_point',
|
||||||
'extract-odf-links = conservancy_beancount.tools.extract_odf_links:entry_point',
|
'extract-odf-links = conservancy_beancount.tools.extract_odf_links:entry_point',
|
||||||
'fund-report = conservancy_beancount.reports.fund:entry_point',
|
'fund-report = conservancy_beancount.reports.fund:entry_point',
|
||||||
'ledger-report = conservancy_beancount.reports.ledger:entry_point',
|
'ledger-report = conservancy_beancount.reports.ledger:entry_point',
|
||||||
|
|
122
tests/test_reports_balance_sheet.py
Normal file
122
tests/test_reports_balance_sheet.py
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
"""test_reports_balance_sheet.py - Unit tests for 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 datetime
|
||||||
|
import io
|
||||||
|
import itertools
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from . import testutil
|
||||||
|
|
||||||
|
import odf.opendocument
|
||||||
|
|
||||||
|
from beancount.core.data import Open
|
||||||
|
|
||||||
|
from conservancy_beancount import data
|
||||||
|
from conservancy_beancount.reports import balance_sheet
|
||||||
|
|
||||||
|
Fund = balance_sheet.Fund
|
||||||
|
Period = balance_sheet.Period
|
||||||
|
|
||||||
|
clean_account_meta = pytest.fixture(scope='module')(testutil.clean_account_meta)
|
||||||
|
|
||||||
|
@pytest.fixture(scope='module')
|
||||||
|
def income_expense_balances():
|
||||||
|
txns = []
|
||||||
|
prior_date = datetime.date(2019, 2, 2)
|
||||||
|
period_date = datetime.date(2019, 4, 4)
|
||||||
|
for (acct, post_type), fund in itertools.product([
|
||||||
|
('Income:Donations', 'Donations'),
|
||||||
|
('Income:Sales', 'RBI'),
|
||||||
|
('Expenses:Postage', 'fundraising'),
|
||||||
|
('Expenses:Postage', 'management'),
|
||||||
|
('Expenses:Postage', 'program'),
|
||||||
|
('Expenses:Services', 'fundraising'),
|
||||||
|
('Expenses:Services', 'program'),
|
||||||
|
], ['Conservancy', 'Alpha']):
|
||||||
|
root_acct, _, classification = acct.partition(':')
|
||||||
|
try:
|
||||||
|
data.Account(acct).meta
|
||||||
|
except KeyError:
|
||||||
|
data.Account.load_opening(Open(
|
||||||
|
{'classification': classification},
|
||||||
|
datetime.date(2000, 1, 1),
|
||||||
|
acct, None, None,
|
||||||
|
))
|
||||||
|
meta = {
|
||||||
|
'project': fund,
|
||||||
|
f'{root_acct.lower().rstrip("s")}-type': post_type,
|
||||||
|
}
|
||||||
|
sign = '' if root_acct == 'Expenses' else '-'
|
||||||
|
txns.append(testutil.Transaction(date=prior_date, postings=[
|
||||||
|
(acct, f'{sign}2.40', meta),
|
||||||
|
]))
|
||||||
|
txns.append(testutil.Transaction(date=period_date, postings=[
|
||||||
|
(acct, f'{sign}2.60', meta),
|
||||||
|
]))
|
||||||
|
return balance_sheet.Balances(
|
||||||
|
data.Posting.from_entries(txns),
|
||||||
|
datetime.date(2019, 3, 1),
|
||||||
|
datetime.date(2020, 3, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('kwargs,expected', [
|
||||||
|
({'account': 'Income:Donations'}, -10),
|
||||||
|
({'account': 'Income'}, -20),
|
||||||
|
({'account': 'Income:Nonexistent'}, None),
|
||||||
|
({'classification': 'Postage'}, 30),
|
||||||
|
({'classification': 'Services'}, 20),
|
||||||
|
({'classification': 'Nonexistent'}, None),
|
||||||
|
({'period': Period.OPENING, 'account': 'Income'}, '-9.60'),
|
||||||
|
({'period': Period.PERIOD, 'account': 'Expenses'}, 26),
|
||||||
|
({'fund': Fund.RESTRICTED, 'account': 'Income'}, -10),
|
||||||
|
({'fund': Fund.UNRESTRICTED, 'account': 'Expenses'}, 25),
|
||||||
|
({'post_type': 'Donations'}, -10),
|
||||||
|
({'post_type': 'fundraising'}, 20),
|
||||||
|
({'post_type': 'management'}, 10),
|
||||||
|
({'post_type': 'Nonexistent'}, None),
|
||||||
|
({'period': Period.OPENING, '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.PERIOD, 'fund': Fund.RESTRICTED, 'post_type': 'ø'}, None),
|
||||||
|
])
|
||||||
|
def test_balance_total(income_expense_balances, kwargs, expected):
|
||||||
|
actual = income_expense_balances.total(**kwargs)
|
||||||
|
if expected is None:
|
||||||
|
assert not actual
|
||||||
|
else:
|
||||||
|
assert actual == {'USD': testutil.Amount(expected)}
|
||||||
|
|
||||||
|
def run_main(arglist=[], config=None):
|
||||||
|
if config is None:
|
||||||
|
config = testutil.TestConfig(books_path=testutil.test_path('books/fund.beancount'))
|
||||||
|
stdout = io.BytesIO()
|
||||||
|
stderr = io.StringIO()
|
||||||
|
retcode = balance_sheet.main(['-O', '-'] + arglist, stdout, stderr, config)
|
||||||
|
stdout.seek(0)
|
||||||
|
stderr.seek(0)
|
||||||
|
return retcode, stdout, stderr
|
||||||
|
|
||||||
|
def test_main():
|
||||||
|
retcode, stdout, stderr = run_main()
|
||||||
|
assert retcode == 0
|
||||||
|
assert not stderr.getvalue()
|
||||||
|
report = odf.opendocument.load(stdout)
|
||||||
|
assert report.spreadsheet.childNodes
|
Loading…
Reference in a new issue