diff --git a/conservancy_beancount/reports/budget.py b/conservancy_beancount/reports/budget.py new file mode 100644 index 0000000000000000000000000000000000000000..3b53c7bf8469eda2dc3793409aabca6f66f73b67 --- /dev/null +++ b/conservancy_beancount/reports/budget.py @@ -0,0 +1,279 @@ +"""budget.py - Budget variance report skeleton + +This report sums income and expenses based on postings' ``budget-line`` +metadata. Usually this metadata is set by rewrite rules, rather than entered in +the books directly. If there is no ``budget-line`` metadata, it falls back to +using account classifications in the account definitions. +""" +# 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 . + +import argparse +import collections +import datetime +import enum +import logging +import operator +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.style # type:ignore[import] +import odf.table # type:ignore[import] + +from beancount.parser import printer as bc_printer + +from . import core +from . import rewrite +from .. import books +from .. import cliutil +from .. import config as configmod +from .. import data +from .. import ranges + +PROGNAME = 'budget-report' +logger = logging.getLogger('conservancy_beancount.reports.budget') + +Fund = core.Fund +KWArgs = Mapping[str, Any] +Period = core.Period + +class Balances(core.Balances): + def _get_classification(self, post: data.Posting) -> data.Account: + try: + return self._get_meta_account(post.meta, 'budget-line') + except (KeyError, TypeError): + return super()._get_classification(post) + + +class Report(core.BaseODS[Sequence[None], None]): + def __init__(self, balances: core.Balances) -> None: + super().__init__() + self.balances = balances + 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: + 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'), + ) + + def write_all(self) -> None: + headers = [self.string_cell(text, stylename=self.style_huline) for text in [ + "", + "Budgeted", + "Actual", + "% Above/Below Budget", + ]] + for header_count, cell in enumerate(headers): + col_style = self.column_style(2 if header_count else 4) + self.sheet.addElement(odf.table.TableColumn(stylename=col_style)) + header_count += 1 + self.add_row(*headers) + self.lock_first_row() + self.add_row() + date_range = self.balances.period_range + self.add_row(self.multiline_cell( + ["DRAFT Budget Variance Report", + f"from {date_range.start} to {date_range.stop}"], + stylename=self.style_header, + numbercolumnsspanned=header_count, + )) + self.write_section("Support", 'Income', 'Equity', 'Liabilities:UnearnedIncome') + self.write_section("Program Activity", 'Expenses', post_meta='program') + self.write_section("Fundraising Activity", 'Expenses', post_meta='fundraising') + self.write_section("Management & Administration", 'Expenses', post_meta='management') + + def write_section(self, + name: str, + *accounts: str, + post_meta: Optional[str]=None, + ) -> None: + self.add_row(self.string_cell(name, stylename=self.style_bold)) + self.add_row() + norm_func = core.normalize_amount_func(f'{accounts[0]}:RootsOK') + for classification in self.balances.classifications(accounts[0]): + balance = norm_func(self.balances.total( + accounts, + classification, + period=Period.PERIOD, + post_meta=post_meta, + )) + self.add_row( + self.string_cell(classification), + odf.table.TableCell(), # TODO: Budgeted amount + self.balance_cell(balance), + odf.table.TableCell(), # TODO: Variance formula + ) + self.add_row() + balance = norm_func(self.balances.total( + accounts, + period=Period.PERIOD, + post_meta=post_meta, + )) + self.add_row( + self.string_cell("Totals", stylename=self.style_bold), + odf.table.TableCell(stylename=self.style_endtotal), # TODO: Budgeted amount + self.balance_cell(balance, stylename=self.style_endtotal), + odf.table.TableCell(stylename=self.style_endtotal), # TODO: Variance formula + ) + self.add_row() + + +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', + required=True, + type=cliutil.date_arg, + help="""Date to start reporting entries, inclusive, in YYYY-MM-DD format. +""") + parser.add_argument( + '--end', '--stop', '-e', + dest='stop_date', + metavar='DATE', + required=True, + type=cliutil.date_arg, + help="""Date to stop reporting entries, exclusive, in YYYY-MM-DD format. +""") + cliutil.add_rewrite_rules_argument(parser) + 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) + parser.add_argument( + 'search_terms', + metavar='FILTER', + type=cliutil.SearchTerm.arg_parser('project', 'rt-id'), + nargs=argparse.ZERO_OR_MORE, + help="""Report on postings that match this criteria. The format is +NAME=TERM. TERM is a link or word that must exist in a posting's NAME +metadata to match. A single ticket number is a shortcut for +`rt-id=rt:NUMBER`. Any other word is a shortcut for `project=TERM`. +If you specify no search terms, defaults to generating a budget for the +unrestricted fund. +""") + args = parser.parse_args(arglist) + if not args.search_terms: + args.search_terms = [cliutil.SearchTerm(args.fund_metadata_key, args.unrestricted_fund)] + return args + +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() + + 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: + entries, load_errors, options_map = books_loader.load_fy_range( + args.start_date, 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) + postings = data.Posting.from_entries(entries) + for search_term in args.search_terms: + postings = search_term.filter_postings(postings) + for rewrite_path in args.rewrite_rules: + try: + ruleset = rewrite.RewriteRuleset.from_yaml(rewrite_path) + except ValueError as error: + logger.critical("failed loading rewrite rules from %s: %s", + rewrite_path, error.args[0]) + return cliutil.ExitCode.RewriteRulesError + postings = ruleset.rewrite(postings) + + balances = Balances( + postings, + args.start_date, + args.stop_date, + 'expense-type', + 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 / 'BudgetReport_{}_{}.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()) diff --git a/setup.py b/setup.py index 401168ce410285b1a69bccedd4f2e1afc706a6d0..19f9f9ce2ce6cb08a71819d71b8fc530df2b9859 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup setup( name='conservancy_beancount', description="Plugin, library, and reports for reading Conservancy's books", - version='1.12.5', + version='1.13.0', author='Software Freedom Conservancy', author_email='info@sfconservancy.org', license='GNU AGPLv3+', @@ -40,6 +40,7 @@ setup( 'accrual-report = conservancy_beancount.reports.accrual:entry_point', 'assemble-audit-reports = conservancy_beancount.tools.audit_report:entry_point', 'balance-sheet-report = conservancy_beancount.reports.balance_sheet:entry_point', + 'budget-report = conservancy_beancount.reports.budget:entry_point', 'bean-sort = conservancy_beancount.tools.sort_entries:entry_point', 'extract-odf-links = conservancy_beancount.tools.extract_odf_links:entry_point', 'fund-report = conservancy_beancount.reports.fund:entry_point',