diff --git a/conservancy_beancount/reports/accrual.py b/conservancy_beancount/reports/accrual.py index e81c7f2f2636a638f402029618657ac0d395ae32..b00ee283c45e6ea6c1fdf6d10511ceba125b1342 100644 --- a/conservancy_beancount/reports/accrual.py +++ b/conservancy_beancount/reports/accrual.py @@ -507,11 +507,12 @@ class OutgoingReport(BaseReport): self.rt_wrapper = rtutil.RT(rt_client) def _primary_rt_id(self, posts: AccrualPostings) -> rtutil.TicketAttachmentIds: - rt_ids = list(posts.first_meta_links('rt-id')) - rt_ids_count = len(rt_ids) - if rt_ids_count != 1: - raise ValueError(f"{rt_ids_count} rt-id links found") - parsed = rtutil.RT.parse(rt_ids.pop()) + rt_id = posts.rt_id + if rt_id is None: + raise ValueError("no rt-id links found") + elif isinstance(rt_id, Sentinel): + raise ValueError("multiple rt-id links found") + parsed = rtutil.RT.parse(rt_id) if parsed is None: raise ValueError("rt-id is not a valid RT reference") else: @@ -598,20 +599,10 @@ class ReportType(enum.Enum): except KeyError: raise ValueError(f"unknown report type {name!r}") from None - @classmethod - def default_for(cls, groups: PostGroups) -> 'ReportType': - if len(groups) == 1 and all( - group.accrual_type is AccrualAccount.PAYABLE - and not group.is_paid() - for group in groups.values() - ): - return cls.OUTGOING - else: - return cls.BALANCE - class ReturnFlag(enum.IntFlag): LOAD_ERRORS = 1 + # 2 was used in the past, it can probably be reclaimed. REPORT_ERRORS = 4 NOTHING_TO_REPORT = 8 @@ -687,6 +678,7 @@ def main(arglist: Optional[Sequence[str]]=None, config = configmod.Config() config.load_file() + returncode = 0 books_loader = config.books_loader() if books_loader is None: entries, load_errors, _ = books.Loader.load_none(config.config_file_path()) @@ -695,29 +687,37 @@ def main(arglist: Optional[Sequence[str]]=None, else: entries, load_errors, _ = books_loader.load_all(args.since) filters.remove_opening_balance_txn(entries) - - returncode = 0 - postings = filter_search(data.Posting.from_entries(entries), args.search_terms) - groups: PostGroups = dict(AccrualPostings.group_by_first_meta_link(postings, 'invoice')) for error in load_errors: bc_printer.print_error(error, file=stderr) returncode |= ReturnFlag.LOAD_ERRORS - if not groups: + + postings = list(filter_search( + data.Posting.from_entries(entries), args.search_terms, + )) + if not postings: logger.warning("no matching entries found to report") returncode |= ReturnFlag.NOTHING_TO_REPORT - - groups = { - key: posts - for source_posts in groups.values() - for key, posts in source_posts.make_consistent() - } + groups: PostGroups + if args.report_type is None or args.report_type is ReportType.OUTGOING: + groups = dict(AccrualPostings.group_by_first_meta_link(postings, 'rt-id')) + if (args.report_type is None + and len(groups) == 1 + and all(g.accrual_type is AccrualAccount.PAYABLE and not g.is_paid() + for g in groups.values()) + ): + args.report_type = ReportType.OUTGOING + if args.report_type is not ReportType.OUTGOING: + groups = { + key: group + for _, source in AccrualPostings.group_by_first_meta_link(postings, 'invoice') + for key, group in source.make_consistent() + } if args.report_type is not ReportType.AGING: groups = { key: posts for key, posts in groups.items() if not posts.is_paid() } or groups + del postings - if args.report_type is None: - args.report_type = ReportType.default_for(groups) report: Optional[BaseReport] = None output_path: Optional[Path] = None if args.report_type is ReportType.AGING: @@ -740,7 +740,7 @@ def main(arglist: Optional[Sequence[str]]=None, report = OutgoingReport(rt_client, out_file) else: out_file = cliutil.text_output(args.output_file, stdout) - report = args.report_type.value(out_file) + report = BalanceReport(out_file) if report is None: returncode |= ReturnFlag.REPORT_ERRORS diff --git a/setup.py b/setup.py index 348372729da0d6f09e4258b0a0d9e9e23370e26e..a7c1cf5167114091b019291725916695c95d15a7 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.1.8', + version='1.1.9', author='Software Freedom Conservancy', author_email='info@sfconservancy.org', license='GNU AGPLv3+', diff --git a/tests/books/accruals.beancount b/tests/books/accruals.beancount index 31de762f64b5c8802a2650259003e8b9c80ea7e4..aa6bc85fc8a9dc399bbd037bade920f39143bae8 100644 --- a/tests/books/accruals.beancount +++ b/tests/books/accruals.beancount @@ -71,7 +71,7 @@ Liabilities:Payable:Accounts 125 USD Assets:Checking -125 USD -2010-04-30 ! "Vendor" "Travel reimbursement" +2010-04-25 ! "Vendor" "First trip travel reimbursement" rt-id: "rt:310" contract: "rt:310/3100" invoice: "FIXME" ; still waiting on them to send it @@ -79,6 +79,14 @@ Liabilities:Payable:Accounts -200 USD Expenses:Travel 200 USD +2010-04-30 * "Vendor" "Second trip travel reimbursement" + rt-id: "rt:310" + contract: "rt:310/3100" + invoice: "rt:310/3120" + project: "Conservancy" + Liabilities:Payable:Accounts -220 USD + Expenses:Travel 220 USD + 2010-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 2c5d3af9c5818816e1ade0cc3b1718d86b1609b2..6b7ab910cc2f1bffd3c2f9773fc096210b52f034 100644 --- a/tests/test_reports_accrual.py +++ b/tests/test_reports_accrual.py @@ -106,7 +106,8 @@ class AgingRow(NamedTuple): AGING_AP = [ AgingRow.make_simple('2010-03-06', 'EarlyBird', -125, 'rt:44/440'), AgingRow.make_simple('2010-03-30', 'EarlyBird', 75, 'rt:490/4900'), - AgingRow.make_simple('2010-04-30', 'Vendor', 200, 'FIXME'), + AgingRow.make_simple('2010-04-25', 'Vendor', 200, 'FIXME'), + AgingRow.make_simple('2010-04-30', 'Vendor', 220, 'rt:310/3120'), AgingRow.make_simple('2010-06-10', 'Lawyer', 280, 'rt:510/6100'), AgingRow.make_simple('2010-06-18', 'EuroGov', 1100, 'rt:520/5200', orig_amount=[testutil.Amount(1000, 'EUR')]), @@ -128,6 +129,10 @@ class RTClient(testutil.RTClient): '44': [ ('440', 'invoice feb.csv', 'text/csv', '40.4k'), ], + '310': [ + ('3100', 'VendorContract.pdf', 'application/pdf', '1.7m'), + ('3120', 'VendorInvoiceB.pdf', 'application/pdf', '1.8m'), + ], '490': [], '505': [], '510': [ @@ -417,14 +422,14 @@ def check_output(output, expect_patterns): output.seek(0) testutil.check_lines_match(iter(output), expect_patterns) -def run_outgoing(invoice, postings, rt_client=None): +def run_outgoing(rt_id, postings, rt_client=None): if rt_client is None: rt_client = RTClient() if not isinstance(postings, core.RelatedPostings): - postings = accruals_by_meta(postings, invoice, wrap_type=accrual.AccrualPostings) + postings = accruals_by_meta(postings, rt_id, 'rt-id', wrap_type=accrual.AccrualPostings) output = io.StringIO() report = accrual.OutgoingReport(rt_client, output) - report.run({invoice: postings}) + report.run({rt_id: postings}) return output @pytest.mark.parametrize('invoice,expected', [ @@ -442,8 +447,7 @@ def test_balance_report(accrual_postings, invoice, expected, caplog): check_output(output, [invoice, expected]) def test_outgoing_report(accrual_postings, caplog): - invoice = 'rt:510/6100' - output = run_outgoing(invoice, accrual_postings) + output = run_outgoing('rt:510', accrual_postings) rt_url = RTClient.DEFAULT_URL[:-9] rt_id_url = rf'\b{re.escape(f"{rt_url}Ticket/Display.html?id=510")}\b' contract_url = rf'\b{re.escape(f"{rt_url}Ticket/Attachment/4000/4000/contract.pdf")}\b' @@ -467,9 +471,8 @@ def test_outgoing_report(accrual_postings, caplog): ]) def test_outgoing_report_custom_field_fallbacks(accrual_postings, caplog): - invoice = 'rt:510/6100' rt_client = RTClient(want_cfs=False) - output = run_outgoing(invoice, accrual_postings, rt_client) + output = run_outgoing('rt:510', accrual_postings, rt_client) assert not caplog.records check_output(output, [ r'^PAYMENT FOR APPROVAL:$', @@ -479,8 +482,7 @@ def test_outgoing_report_custom_field_fallbacks(accrual_postings, caplog): ]) def test_outgoing_report_fx_amounts(accrual_postings, caplog): - invoice = 'rt:520/5200' - output = run_outgoing(invoice, accrual_postings) + output = run_outgoing('rt:520 rt:525', accrual_postings) assert not caplog.records check_output(output, [ r'^PAYMENT FOR APPROVAL:$', @@ -488,9 +490,21 @@ def test_outgoing_report_fx_amounts(accrual_postings, caplog): r'^TOTAL TO PAY: 1,000\.00 EUR \(\$1,100.00\)$', ]) +def test_outgoing_report_multi_invoice(accrual_postings, caplog): + output = run_outgoing('rt:310', accrual_postings) + assert not caplog.records + check_output(output, [ + r'^PAYMENT FOR APPROVAL:$', + r'^REQUESTOR: Mx\. 310 $', + r'^TOTAL TO PAY: \$420.00$', + ]) + def test_outgoing_report_without_rt_id(accrual_postings, caplog): invoice = 'rt://ticket/515/attachments/5150' - output = run_outgoing(invoice, accrual_postings) + related = accruals_by_meta( + accrual_postings, invoice, wrap_type=accrual.AccrualPostings, + ) + output = run_outgoing(None, related) assert caplog.records log = caplog.records[0] assert log.message.startswith( @@ -524,8 +538,8 @@ def test_aging_report(accrual_postings): # Both these dates are chosen for their off-by-one potential: # the first is exactly 30 days after the 2010-06-10 payable; # the second is exactly 60 days after the 2010-05-15 receivable. - (datetime.date(2010, 7, 10), 1, 4), - (datetime.date(2010, 7, 14), 2, 4), + (datetime.date(2010, 7, 10), 1, 5), + (datetime.date(2010, 7, 14), 2, 5), ]) def test_aging_report_date_cutoffs(accrual_postings, date, recv_end, pay_end): expect_recv = AGING_AR[:recv_end] @@ -606,23 +620,23 @@ def test_output_payments_when_only_match(arglist, expect_invoice): r' outstanding since ', ]) -@pytest.mark.parametrize('arglist', [ - ['510'], - ['510/6100'], - ['entity=Lawyer'], +@pytest.mark.parametrize('arglist,expect_amount', [ + (['310'], 420), + (['310/3120'], 220), + (['entity=Vendor'], 420), ]) -def test_main_outgoing_report(arglist): +def test_main_outgoing_report(arglist, expect_amount): retcode, output, errors = run_main(arglist) assert not errors.getvalue() assert retcode == 0 rt_url = RTClient.DEFAULT_URL[:-9] - rt_id_url = re.escape(f'<{rt_url}Ticket/Display.html?id=510>') - contract_url = re.escape(f'<{rt_url}Ticket/Attachment/4000/4000/contract.pdf>') + rt_id_url = re.escape(f'<{rt_url}Ticket/Display.html?id=310>') + contract_url = re.escape(f'<{rt_url}Ticket/Attachment/3120/3120/VendorContract.pdf>') check_output(output, [ - r'^REQUESTOR: Mx\. 510 $', - r'^TOTAL TO PAY: \$280\.00$', - r'^\s*2010-06-12\s', - r'^\s+Expenses:FilingFees\s+60\.00 USD$', + r'^REQUESTOR: Mx\. 310 $', + rf'^TOTAL TO PAY: \${expect_amount}\.00$', + r'^\s*2010-04-30\s', + r'^\s+Expenses:Travel\s+220 USD$', ]) @pytest.mark.parametrize('arglist', [