diff --git a/conservancy_beancount/reports/ledger.py b/conservancy_beancount/reports/ledger.py index bf2fbf9..77f840a 100644 --- a/conservancy_beancount/reports/ledger.py +++ b/conservancy_beancount/reports/ledger.py @@ -46,6 +46,7 @@ from typing import ( Any, Callable, Dict, + Hashable, Iterable, Iterator, List, @@ -55,7 +56,9 @@ from typing import ( Set, TextIO, Tuple, + Type, Union, + cast, ) from ..beancount_types import ( Transaction, @@ -64,6 +67,7 @@ from ..beancount_types import ( from pathlib import Path import odf.table # type:ignore[import] +import odf.text # type:ignore[import] from beancount.core import data as bc_data from beancount.parser import printer as bc_printer @@ -366,40 +370,6 @@ class LedgerODS(core.BaseODS[data.Posting, None]): classification_cell, ) - def write_balance_sheet(self) -> None: - self.use_sheet("Balance") - self.sheet.addElement(odf.table.TableColumn(stylename=self.column_style(3))) - self.sheet.addElement(odf.table.TableColumn(stylename=self.column_style(1.5))) - self.add_row( - self.string_cell("Account", stylename=self.style_bold), - self.string_cell("Balance", stylename=self.style_bold), - ) - self.lock_first_row() - self.add_row() - self.add_row(self.string_cell( - f"Ledger From {self.date_range.start.isoformat()}" - f" To {self.date_range.stop.isoformat()}", - stylename=self.merge_styles(self.style_centertext, self.style_bold), - numbercolumnsspanned=2, - )) - self.add_row() - for account, balance in core.account_balances(self.account_groups): - if account is core.OPENING_BALANCE_NAME: - text = f"Balance as of {self.date_range.start.isoformat()}" - style = self.merge_styles(self.style_bold, self.style_endtext) - elif account is core.ENDING_BALANCE_NAME: - text = f"Balance as of {self.date_range.stop.isoformat()}" - style = self.merge_styles( - self.style_bottomline, self.style_bold, self.style_endtext, - ) - else: - text = account - style = self.style_endtext - self.add_row( - self.string_cell(text, stylename=style), - self.balance_cell(-balance, stylename=style), - ) - def _account_tally(self, account: data.Account) -> int: return len(self.account_groups[account]) @@ -430,7 +400,7 @@ class LedgerODS(core.BaseODS[data.Posting, None]): )) for empty_acct in self.accounts.difference(self.account_groups): self.account_groups[empty_acct] = related_cls() - self.write_balance_sheet() + self.start_spreadsheet() tally_by_account_iter = ( (account, self._account_tally(account)) for account in self.accounts @@ -472,29 +442,97 @@ class LedgerODS(core.BaseODS[data.Posting, None]): self.start_sheet(sheet_names[index]) -class TransactionFilter(enum.IntFlag): - ZERO = 1 - CREDIT = 2 - DEBIT = 4 - ALL = ZERO | CREDIT | DEBIT +class FundLedgerODS(LedgerODS): + """Streamlined ledger report for a specific project fund - @classmethod - def from_arg(cls, s: str) -> 'TransactionFilter': - try: - return cls[s.upper()] - except KeyError: - raise ValueError(f"unknown transaction filter {s!r}") + This report is more appropriate to share with people who are interested in + the project fund. Differences from the main ledger report: + + * It adds a cover sheet with a high level overview of the fund balance. + * It only reports accounts that belong to funds. + * It does not use any links, since recipients likely won't have access to + be able to follow them. (It does include RT IDs as a reference point to + correspond about specific transactions.) + """ + ACCOUNT_COLUMNS = collections.OrderedDict([ + ('Income', ['program', 'rt-id', 'income-type', 'memo']), + ('Expenses', ['program', 'rt-id', 'expense-type']), + ('Equity', ['program', 'rt-id']), + ('Assets:Receivable', ['program', 'rt-id']), + ('Liabilities:Payable', ['program', 'rt-id']), + ('Assets', ['rt-id']), + ('Liabilities', ['rt-id']), + ]) + + def multilink_cell(self, links: Iterable[core.LinkType], **attrs: Any) -> odf.table.TableCell: + source = super().multilink_cell(links, **attrs) + return self.multiline_cell( + ''.join(child.data for child in link.childNodes) + for link in source.getElementsByType(odf.text.A) + ) + + def start_spreadsheet(self) -> None: + self.use_sheet("Balance") + self.sheet.addElement(odf.table.TableColumn(stylename=self.column_style(3))) + self.sheet.addElement(odf.table.TableColumn(stylename=self.column_style(1.5))) + self.add_row( + self.string_cell("Account", stylename=self.style_bold), + self.string_cell("Balance", stylename=self.style_bold), + ) + self.lock_first_row() + self.add_row() + self.add_row(self.string_cell( + f"Ledger From {self.date_range.start.isoformat()}" + f" To {self.date_range.stop.isoformat()}", + stylename=self.merge_styles(self.style_centertext, self.style_bold), + numbercolumnsspanned=2, + )) + self.add_row() + for account, balance in core.account_balances(self.account_groups): + if account is core.OPENING_BALANCE_NAME: + text = f"Balance as of {self.date_range.start.isoformat()}" + style = self.merge_styles(self.style_bold, self.style_endtext) + elif account is core.ENDING_BALANCE_NAME: + text = f"Balance as of {self.date_range.stop.isoformat()}" + style = self.merge_styles( + self.style_bottomline, self.style_bold, self.style_endtext, + ) + else: + text = account + style = self.style_endtext + self.add_row( + self.string_cell(text, stylename=style), + self.balance_cell(-balance, stylename=style), + ) + + +class ReportType(enum.IntFlag): + ZERO_TRANSACTIONS = enum.auto() + ZERO_TXNS = ZERO_TRANSACTIONS + CREDIT_TRANSACTIONS = enum.auto() + CREDIT_TXNS = CREDIT_TRANSACTIONS + DEBIT_TRANSACTIONS = enum.auto() + DEBIT_TXNS = DEBIT_TRANSACTIONS + ALL_TRANSACTIONS = ZERO_TRANSACTIONS | CREDIT_TRANSACTIONS | DEBIT_TRANSACTIONS + ALL_TXNS = ALL_TRANSACTIONS + FULL_LEDGER = enum.auto() + FUND_LEDGER = enum.auto() + PROJECT_LEDGER = FUND_LEDGER @classmethod def post_flag(cls, post: data.Posting) -> int: norm_func = core.normalize_amount_func(post.account) number = norm_func(post.units.number) if not number: - return cls.ZERO + return cls.ZERO_TRANSACTIONS elif number > 0: - return cls.CREDIT + return cls.CREDIT_TRANSACTIONS else: - return cls.DEBIT + return cls.DEBIT_TRANSACTIONS + + def _choices_sortkey(self) -> Hashable: + subtype, _, maintype = self.name.partition('_') + return (maintype, subtype) class TransactionODS(LedgerODS): @@ -527,7 +565,7 @@ class TransactionODS(LedgerODS): sheet_size: Optional[int]=None, totals_with_entries: Optional[Sequence[str]]=None, totals_without_entries: Optional[Sequence[str]]=None, - txn_filter: int=TransactionFilter.ALL, + txn_filter: int=ReportType.ALL_TRANSACTIONS, ) -> None: super().__init__( start_date, @@ -539,9 +577,9 @@ class TransactionODS(LedgerODS): totals_without_entries, ) self.txn_filter = txn_filter - if self.txn_filter == TransactionFilter.CREDIT: + if self.txn_filter == ReportType.CREDIT_TRANSACTIONS: self.report_name = "Receipts" - elif self.txn_filter == TransactionFilter.DEBIT: + elif self.txn_filter == ReportType.DEBIT_TRANSACTIONS: self.report_name = "Disbursements" else: self.report_name = "Transactions" @@ -551,18 +589,15 @@ class TransactionODS(LedgerODS): for post in postings: txn = post.meta.txn if (txn is not last_txn - and TransactionFilter.post_flag(post) & self.txn_filter): + and ReportType.post_flag(post) & self.txn_filter): yield txn last_txn = txn def metadata_columns_for(self, sheet_name: str) -> Sequence[str]: return self.METADATA_COLUMNS - def write_balance_sheet(self) -> None: - return - def _report_section_balance(self, key: data.Account, date_key: str) -> None: - if self.txn_filter == TransactionFilter.ALL: + if self.txn_filter == ReportType.ALL_TRANSACTIONS: super()._report_section_balance(key, date_key) elif date_key == 'stop': balance = core.Balance( @@ -638,7 +673,7 @@ class CashReportAction(argparse.Action): values: Union[Sequence[Any], str, None]=None, option_string: Optional[str]=None, ) -> None: - namespace.txn_filter = self.const + namespace.report_type = self.const if namespace.accounts is None: namespace.accounts = [] namespace.accounts.append('Assets:PayPal') @@ -653,7 +688,7 @@ def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace parser.add_argument( '--disbursements', action=CashReportAction, - const=TransactionFilter.DEBIT, + const=ReportType.DEBIT_TRANSACTIONS, nargs=0, help="""Shortcut to set all the necessary options to generate a cash disbursements report. @@ -661,7 +696,7 @@ disbursements report. parser.add_argument( '--receipts', action=CashReportAction, - const=TransactionFilter.CREDIT, + const=ReportType.CREDIT_TRANSACTIONS, nargs=0, help="""Shortcut to set all the necessary options to generate a cash receipts report. @@ -683,15 +718,23 @@ The default is one year ago. The default is a year after the start date, or 30 days from today if the start date was also not specified. """) - parser.add_argument( - '--transactions', '-t', - dest='txn_filter', + report_type = cliutil.EnumArgument(ReportType) + report_type_default = ReportType.FULL_LEDGER + report_type_arg = parser.add_argument( + '--report-type', '-t', metavar='TYPE', - type=TransactionFilter.from_arg, - help="""Report whole transactions rather than individual postings. -The type argument selects which type of transactions to report. Choices are -credit, debit, or all. + type=report_type.enum_type, + default=report_type_default, + help=f"""The type of report to generate. Choices are +{report_type.choices_str()}. Default is {report_type_default.name.lower()!r}. """) + # --transactions got merged into --report-type; this is backwards compatibility. + parser.add_argument( + '--transactions', + dest=report_type_arg.dest, + type=cast(Callable, report_type_arg.type), + help=argparse.SUPPRESS, + ) parser.add_argument( '--account', '-a', dest='accounts', @@ -814,18 +857,10 @@ def main(arglist: Optional[Sequence[str]]=None, rt_wrapper = config.rt_wrapper() if rt_wrapper is None: logger.warning("could not initialize RT client; spreadsheet links will be broken") + report: LedgerODS + report_cls: Type[LedgerODS] try: - if args.txn_filter is None: - report = LedgerODS( - args.start_date, - args.stop_date, - args.accounts, - rt_wrapper, - args.sheet_size, - args.show_totals, - args.add_totals, - ) - else: + if args.report_type & ReportType.ALL_TRANSACTIONS: report = TransactionODS( args.start_date, args.stop_date, @@ -834,7 +869,21 @@ def main(arglist: Optional[Sequence[str]]=None, args.sheet_size, args.show_totals, args.add_totals, - args.txn_filter, + args.report_type, + ) + else: + if args.report_type is ReportType.FUND_LEDGER: + report_cls = FundLedgerODS + else: + report_cls = LedgerODS + report = report_cls( + args.start_date, + args.stop_date, + args.accounts, + rt_wrapper, + args.sheet_size, + args.show_totals, + args.add_totals, ) except ValueError as error: logger.error("%s: %r", *error.args) diff --git a/tests/test_reports_ledger.py b/tests/test_reports_ledger.py index 34b09a3..9846145 100644 --- a/tests/test_reports_ledger.py +++ b/tests/test_reports_ledger.py @@ -32,7 +32,6 @@ Acct = data.Account _ledger_load = bc_loader.load_file(testutil.test_path('books/ledger.beancount')) DEFAULT_REPORT_SHEETS = [ - 'Balance', 'Income', 'Expenses:Payroll', 'Expenses', @@ -44,8 +43,9 @@ DEFAULT_REPORT_SHEETS = [ 'Liabilities', ] PROJECT_REPORT_SHEETS = [ - *DEFAULT_REPORT_SHEETS[:2], - *DEFAULT_REPORT_SHEETS[3:6], + 'Balance', + 'Income', + *DEFAULT_REPORT_SHEETS[2:5], 'Assets:Prepaid', 'Liabilities:UnearnedIncome', 'Liabilities:Payable', @@ -60,7 +60,7 @@ STOP_DATE = datetime.date(2020, 3, 1) REPORT_KWARGS = [ {'report_class': ledger.LedgerODS}, *({'report_class': ledger.TransactionODS, 'txn_filter': flags} - for flags in ledger.TransactionFilter), + for flags in ledger.ReportType if flags & ledger.ReportType.ALL_TRANSACTIONS), ] @pytest.fixture @@ -189,7 +189,7 @@ class ExpectedPostings(core.RelatedPostings): period_bal = core.MutableBalance() rows = self.find_section(ods, account) if (expect_totals - and txn_filter == ledger.TransactionFilter.ALL + and txn_filter == ledger.ReportType.ALL_TRANSACTIONS and account.is_under('Assets', 'Liabilities')): opening_row = testutil.ODSCell.from_row(next(rows)) assert opening_row[0].value == start_date @@ -198,7 +198,7 @@ class ExpectedPostings(core.RelatedPostings): last_txn = None for post in expect_posts: txn = post.meta.txn - post_flag = ledger.TransactionFilter.post_flag(post) + post_flag = ledger.ReportType.post_flag(post) if txn is last_txn or (not txn_filter & post_flag): continue last_txn = txn @@ -462,7 +462,7 @@ def test_main_account_limit(ledger_entries, acct_arg): assert not errors.getvalue() assert retcode == 0 ods = odf.opendocument.load(output) - assert get_sheet_names(ods) == ['Balance', 'Liabilities'] + assert get_sheet_names(ods) == ['Liabilities'] postings = data.Posting.from_entries(ledger_entries) for account, expected in ExpectedPostings.group_by_account(postings): if account == 'Liabilities:UnearnedIncome': @@ -485,7 +485,7 @@ def test_main_account_classification_splits_hierarchy(ledger_entries): assert not errors.getvalue() assert retcode == 0 ods = odf.opendocument.load(output) - assert get_sheet_names(ods) == ['Balance', 'Assets'] + assert get_sheet_names(ods) == ['Assets'] postings = data.Posting.from_entries(ledger_entries) for account, expected in ExpectedPostings.group_by_account(postings): should_find = (account == 'Assets:Checking' or account == 'Assets:PayPal') @@ -509,6 +509,7 @@ def test_main_project_report(ledger_entries, project, start_date, stop_date): retcode, output, errors = run_main([ f'--begin={start_date.isoformat()}', f'--end={stop_date.isoformat()}', + '--report-type=fund_ledger', project, ]) assert not errors.getvalue() @@ -528,9 +529,9 @@ def test_main_project_report(ledger_entries, project, start_date, stop_date): ]) def test_main_cash_report(ledger_entries, flag): if flag == '--receipts': - txn_filter = ledger.TransactionFilter.CREDIT + txn_filter = ledger.ReportType.CREDIT_TRANSACTIONS else: - txn_filter = ledger.TransactionFilter.DEBIT + txn_filter = ledger.ReportType.DEBIT_TRANSACTIONS retcode, output, errors = run_main([ flag, '-b', START_DATE.isoformat(),