diff --git a/conservancy_beancount/reports/accrual.py b/conservancy_beancount/reports/accrual.py index 7137421..3d1fbd1 100644 --- a/conservancy_beancount/reports/accrual.py +++ b/conservancy_beancount/reports/accrual.py @@ -102,6 +102,36 @@ ReportFunc = Callable[ ] RTObject = Mapping[str, str] +class Account(NamedTuple): + name: str + balance_paid: Callable[[core.Balance], bool] + + +class AccrualAccount(enum.Enum): + PAYABLE = Account('Liabilities:Payable', core.Balance.ge_zero) + RECEIVABLE = Account('Assets:Receivable', core.Balance.le_zero) + + @classmethod + def account_names(cls) -> Iterator[str]: + return (acct.value.name for acct in cls) + + @classmethod + def classify(cls, related: core.RelatedPostings) -> 'AccrualAccount': + for account in cls: + account_name = account.value.name + if all(post.account.is_under(account_name) for post in related): + return account + raise ValueError("unrecognized account set in related postings") + + @classmethod + def filter_paid_accruals(cls, groups: PostGroups) -> PostGroups: + return { + key: related + for key, related in groups.items() + if not cls.classify(related).value.balance_paid(related.balance()) + } + + class ReportType: NAMES: Set[str] = set() BY_NAME: Dict[str, ReportFunc] = {} @@ -123,20 +153,16 @@ class ReportType: raise ValueError(f"unknown report type {name!r}") from None @classmethod - def default_for(cls, groups: PostGroups) -> Tuple[ReportFunc, PostGroups]: - nonzero_groups = { - key: group for key, group in groups.items() - if not group.balance().is_zero() - } - if len(nonzero_groups) == 1 and all( - post.account.is_under('Liabilities') - for group in nonzero_groups.values() - for post in group + def default_for(cls, groups: PostGroups) -> ReportFunc: + if len(groups) == 1 and all( + AccrualAccount.classify(group) is AccrualAccount.PAYABLE + and not AccrualAccount.PAYABLE.value.balance_paid(group.balance()) + for group in groups.values() ): report_name = 'outgoing' else: report_name = 'balance' - return cls.BY_NAME[report_name], nonzero_groups or groups + return cls.BY_NAME[report_name] class ReturnFlag(enum.IntFlag): @@ -303,9 +329,8 @@ def outgoing_report(groups: PostGroups, def filter_search(postings: Iterable[data.Posting], search_terms: Iterable[SearchTerm], ) -> Iterable[data.Posting]: - postings = (post for post in postings if post.account.is_under( - 'Assets:Receivable', 'Liabilities:Payable', - )) + accounts = tuple(AccrualAccount.account_names()) + postings = (post for post in postings if post.account.is_under(*accounts)) for meta_key, pattern in search_terms: postings = filters.filter_meta_match(postings, meta_key, re.compile(pattern)) return postings @@ -365,6 +390,7 @@ def main(arglist: Optional[Sequence[str]]=None, load_errors = [Error(source, "no books to load in configuration", None)] postings = filter_search(data.Posting.from_entries(entries), args.search_terms) groups = core.RelatedPostings.group_by_meta(postings, 'invoice') + groups = AccrualAccount.filter_paid_accruals(groups) or groups meta_errors = consistency_check(groups) for error in load_errors: bc_printer.print_error(error, file=stderr) @@ -373,7 +399,7 @@ def main(arglist: Optional[Sequence[str]]=None, bc_printer.print_error(error, file=stderr) returncode |= ReturnFlag.CONSISTENCY_ERRORS if args.report_type is None: - args.report_type, groups = ReportType.default_for(groups) + args.report_type = ReportType.default_for(groups) if not groups: print("warning: no matching entries found to report", file=stderr) returncode |= ReturnFlag.NOTHING_TO_REPORT diff --git a/tests/books/accruals.beancount b/tests/books/accruals.beancount index 5bd8034..58b5284 100644 --- a/tests/books/accruals.beancount +++ b/tests/books/accruals.beancount @@ -2,9 +2,28 @@ 2020-01-01 open Assets:Receivable:Accounts 2020-01-01 open Expenses:FilingFees 2020-01-01 open Expenses:Services:Legal +2020-01-01 open Expenses:Travel 2020-01-01 open Income:Donations 2020-01-01 open Liabilities:Payable:Accounts +2020-03-05 * "EarlyBird" "Payment for receivable from previous FY" + rt-id: "rt:40" + invoice: "rt:40/400" + Assets:Receivable:Accounts -500 USD + Assets:Checking 500 USD + +2020-03-06 * "EarlyBird" "Payment for payment from previous FY" + rt-id: "rt:44" + invoice: "rt:44/440" + Liabilities:Payable:Accounts 125 USD + Assets:Checking -125 USD + +2020-03-30 * "EarlyBird" "Travel reimbursement" + rt-id: "rt:490" + invoice: "rt:490/4900" + Liabilities:Payable:Accounts -75 USD + Expenses:Travel 75 USD + 2020-05-05 * "DonorA" "Donation pledge" rt-id: "rt:505" invoice: "rt:505/5050" diff --git a/tests/test_reports_accrual.py b/tests/test_reports_accrual.py index fe5b2f7..85b5e3c 100644 --- a/tests/test_reports_accrual.py +++ b/tests/test_reports_accrual.py @@ -53,6 +53,13 @@ CONSISTENT_METADATA = [ class RTClient(testutil.RTClient): TICKET_DATA = { + '40': [ + ('400', 'invoice feb.csv', 'text/csv', '40.4k'), + ], + '44': [ + ('440', 'invoice feb.csv', 'text/csv', '40.4k'), + ], + '490': [], '505': [], '510': [ ('4000', 'contract.pdf', 'application/pdf', '1.4m'), @@ -184,43 +191,6 @@ def test_report_type_by_unknown_name(arg): with pytest.raises(ValueError): accrual.ReportType.by_name(arg) -@pytest.mark.parametrize('invoice,expected', [ - # No outstanding balance - ('rt:505/5050', accrual.balance_report), - ('rt:510/5100', accrual.balance_report), - # Outstanding receivable - ('rt://ticket/515/attachments/5150', accrual.balance_report), - # Outstanding payable - ('rt:510/6100', accrual.outgoing_report), -]) -def test_default_report_type(accrual_postings, invoice, expected): - related = core.RelatedPostings() - for post in accrual_postings: - if (post.account.is_under('Assets:Receivable', 'Liabilities:Payable') - and post.meta.get('invoice') == invoice): - related.add(post) - groups = {invoice: related} - report_type, report_groups = accrual.ReportType.default_for(groups) - assert report_type is expected - assert report_groups == groups - -@pytest.mark.parametrize('entity,exp_type,exp_invoices', [ - ('^Lawyer$', accrual.outgoing_report, {'rt:510/6100'}), - ('^Donor[AB]$', accrual.balance_report, {'rt://ticket/515/attachments/5150'}), - ('^(Lawyer|DonorB)$', accrual.balance_report, - {'rt:510/6100', 'rt://ticket/515/attachments/5150'}), -]) -def test_default_report_type_multi_invoices(accrual_postings, entity, exp_type, exp_invoices): - groups = core.RelatedPostings.group_by_meta(( - post for post in accrual_postings - if post.account.is_under('Assets:Receivable', 'Liabilities:Payable') - and re.match(entity, post.meta.get('entity', '')) - ), 'invoice') - report_type, report_groups = accrual.ReportType.default_for(groups) - assert report_type is exp_type - assert set(report_groups.keys()) == exp_invoices - assert all(len(related) > 0 for related in report_groups.values()) - @pytest.mark.parametrize('meta_key,account', testutil.combine_values( CONSISTENT_METADATA, ACCOUNTS, @@ -364,6 +334,32 @@ def check_main_fails(arglist, config, error_flags, error_patterns): check_output(errors, error_patterns) assert not output.getvalue() +@pytest.mark.parametrize('arglist', [ + ['--report-type=balance'], + ['--report-type=outgoing'], + ['entity=EarlyBird'], +]) +def test_output_excludes_payments(arglist): + retcode, output, errors = run_main(arglist) + assert not errors.getvalue() + assert retcode == 0 + output.seek(0) + for line in output: + assert not re.match(r'\brt:4\d\b', line) + +@pytest.mark.parametrize('arglist,expect_invoice', [ + (['40'], 'rt:40/400'), + (['44/440'], 'rt:44/440'), +]) +def test_output_payments_when_only_match(arglist, expect_invoice): + retcode, output, errors = run_main(arglist) + assert not errors.getvalue() + assert retcode == 0 + check_output(output, [ + rf'^{re.escape(expect_invoice)}:$', + r' outstanding since ', + ]) + @pytest.mark.parametrize('arglist', [ ['--report-type=outgoing'], ['510'],