diff --git a/conservancy_beancount/reports/accrual.py b/conservancy_beancount/reports/accrual.py index f8f7958..0298720 100644 --- a/conservancy_beancount/reports/accrual.py +++ b/conservancy_beancount/reports/accrual.py @@ -71,6 +71,7 @@ import collections import datetime import enum import logging +import re import sys from pathlib import Path @@ -471,6 +472,21 @@ class BalanceReport(BaseReport): class OutgoingReport(BaseReport): + PAYMENT_METHODS = { + 'ach': 'ACH', + 'check': 'Check', + 'creditcard': 'Credit Card', + 'debitcard': 'Debit Card', + 'fxwire': 'International Wire', + 'paypal': 'PayPal', + 'uswire': 'Domestic Wire', + 'vendorportal': 'Vendor Portal', + } + PAYMENT_METHOD_RE = re.compile( + rf'^([A-Z]{{3}})\s+({"|".join(PAYMENT_METHODS)})$', + re.IGNORECASE, + ) + def __init__(self, rt_wrapper: rtutil.RT, out_file: TextIO) -> None: super().__init__(out_file) self.rt_wrapper = rt_wrapper @@ -522,9 +538,12 @@ class OutgoingReport(BaseReport): balance_s = posts.end_balance.format(None) raw_balance = -posts.balance() + payment_amount = raw_balance.format('¤¤ #,##0.00') if raw_balance != posts.end_balance: + payment_amount += f' ({balance_s})' balance_s = f'{raw_balance} ({balance_s})' + payment_to = ticket.get('CF.{payment-to}') or requestor_name contract_links = list(posts.all_meta_links('contract')) if contract_links: contract_s = ' , '.join(self.rt_wrapper.iter_urls( @@ -537,10 +556,9 @@ class OutgoingReport(BaseReport): yield "PAYMENT FOR APPROVAL:" yield f"REQUESTOR: {requestor}" + yield f"PAYMENT TO: {payment_to}" yield f"TOTAL TO PAY: {balance_s}" yield f"AGREEMENT: {contract_s}" - yield f"PAYMENT TO: {ticket.get('CF.{payment-to}') or requestor_name}" - yield f"PAYMENT METHOD: {ticket.get('CF.{payment-method}', '')}" yield f"PROJECT: {', '.join(projects)}" yield "\nBEANCOUNT ENTRIES:\n" @@ -550,8 +568,56 @@ class OutgoingReport(BaseReport): if txn is not last_txn: last_txn = txn txn = self.rt_wrapper.txn_with_urls(txn, '{}') + # Suppress payment-method metadata from the report. + txn.meta.pop('payment-method', None) + for txn_post in txn.postings: + if txn_post.meta: + txn_post.meta.pop('payment-method', None) yield bc_printer.format_entry(txn) + cf_targets = { + 'payment-amount': payment_amount, + 'payment-to': payment_to, + } + payment_methods = posts.meta_values('payment-method') + payment_methods.discard(None) + payment_method_count = len(payment_methods) + if payment_method_count != 1: + self.logger.warning( + "cannot set payment-method for rt:%s: %s metadata values found", + ticket_id, payment_method_count, + ) + else: + payment_method = payment_methods.pop() + if isinstance(payment_method, str): + match = self.PAYMENT_METHOD_RE.fullmatch(payment_method) + else: + match = None + if match is None: + self.logger.warning( + "cannot set payment-method for rt:%s: invalid value %r", + ticket_id, payment_method, + ) + else: + cf_targets['payment-method'] = '{} {}'.format( + match.group(1).upper(), + self.PAYMENT_METHODS[match.group(2).lower()], + ) + + cf_updates = { + f'CF_{key}': value + for key, value in cf_targets.items() + if ticket.get(f'CF.{{{key}}}') != value + } + if cf_updates: + try: + ok = self.rt_client.edit_ticket(ticket_id, **cf_updates) + except rt.RtError: + self.logger.debug("RT exception on edit_ticket", exc_info=True) + ok = False + if not ok: + self.logger.warning("failed to set custom fields for rt:%s", ticket_id) + class ReportType(enum.Enum): AGING = AgingReport diff --git a/setup.py b/setup.py index 1cae3a0..850e3f9 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.3.1', + version='1.4.0', 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 229a483..2106d89 100644 --- a/tests/books/accruals.beancount +++ b/tests/books/accruals.beancount @@ -76,6 +76,7 @@ contract: "rt:310/3100" invoice: "FIXME" ; still waiting on them to send it project: "Conservancy" + payment-method: "USD USWire" Liabilities:Payable:Accounts -200 USD Expenses:Travel 200 USD @@ -84,6 +85,7 @@ contract: "rt:310/3100" invoice: "rt:310/3120" project: "Conservancy" + payment-method: "USD Check" Liabilities:Payable:Accounts -220 USD Expenses:Travel 220 USD @@ -102,6 +104,7 @@ project: "Conservancy" Expenses:Services:Legal 200.00 USD Liabilities:Payable:Accounts -200.00 USD + payment-method: "USD ACH" 2010-05-15 * "MatchingProgram" "May matched donations" invoice: "rt://ticket/515/attachments/5150" @@ -134,6 +137,7 @@ project: "Conservancy" Expenses:Services:Legal 220.00 USD Liabilities:Payable:Accounts -220.00 USD + payment-method: "USD ACH" 2010-06-12 * "Lawyer" "Additional legal fees for May" rt-id: "rt:510" @@ -142,6 +146,7 @@ project: "Conservancy" Expenses:FilingFees 60.00 USD Liabilities:Payable:Accounts -60.00 USD + payment-method: "USD ACH" 2010-06-15 * "GrantCo" "2010Q2 grant" rt-id: "rt:470" @@ -158,6 +163,7 @@ contract: "rt:520/5220" project: "Conservancy" Liabilities:Payable:Accounts -1,000 EUR {1.100 USD} + payment-method: "eur fxwire" Expenses:FilingFees 1,000 EUR {1.100 USD} 2010-06-20 * "StateGov" "Business registration" diff --git a/tests/test_reports_accrual.py b/tests/test_reports_accrual.py index f99706c..a17fd43 100644 --- a/tests/test_reports_accrual.py +++ b/tests/test_reports_accrual.py @@ -447,7 +447,8 @@ def test_balance_report(accrual_postings, invoice, expected, caplog): check_output(output, [invoice, expected]) def test_outgoing_report(accrual_postings, caplog): - output = run_outgoing('rt:510', accrual_postings) + rt_client = RTClient() + output = run_outgoing('rt:510', accrual_postings, rt_client) 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' @@ -455,10 +456,9 @@ def test_outgoing_report(accrual_postings, caplog): check_output(output, [ r'^PAYMENT FOR APPROVAL:$', r'^REQUESTOR: Mx\. 510 $', + r'^PAYMENT TO: Hon\. Mx\. 510$', r'^TOTAL TO PAY: \$280\.00$', fr'^AGREEMENT: {contract_url}', - r'^PAYMENT TO: Hon\. Mx\. 510$', - r'^PAYMENT METHOD: payment method 510$', r'^BEANCOUNT ENTRIES:$', # For each transaction, check for the date line, a metadata, and the # Expenses posting. @@ -469,6 +469,11 @@ def test_outgoing_report(accrual_postings, caplog): fr'^\s+contract: "{contract_url}"$', r'^\s+Expenses:FilingFees\s+60\.00 USD$', ]) + assert rt_client.edits == {'510': { + 'CF_payment-amount': 'USD 280.00', + 'CF_payment-method': 'USD ACH', + }} + assert 'payment-method:' not in output.getvalue() def test_outgoing_report_custom_field_fallbacks(accrual_postings, caplog): rt_client = RTClient(want_cfs=False) @@ -478,26 +483,38 @@ def test_outgoing_report_custom_field_fallbacks(accrual_postings, caplog): r'^PAYMENT FOR APPROVAL:$', r'^REQUESTOR: $', r'^PAYMENT TO:\s*$', - r'^PAYMENT METHOD:\s*$', ]) def test_outgoing_report_fx_amounts(accrual_postings, caplog): - output = run_outgoing('rt:520 rt:525', accrual_postings) + rt_client = RTClient() + output = run_outgoing('rt:520 rt:525', accrual_postings, rt_client) assert not caplog.records check_output(output, [ r'^PAYMENT FOR APPROVAL:$', r'^REQUESTOR: Mx\. 520 $', r'^TOTAL TO PAY: 1,000\.00 EUR \(\$1,100.00\)$', ]) + assert rt_client.edits == {'520': { + 'CF_payment-amount': 'EUR 1,000.00 ($1,100.00)', + 'CF_payment-method': 'EUR International Wire', + }} + assert 'payment-method:' not in output.getvalue() def test_outgoing_report_multi_invoice(accrual_postings, caplog): - output = run_outgoing('rt:310', accrual_postings) - assert not caplog.records + rt_client = RTClient() + output = run_outgoing('rt:310', accrual_postings, rt_client) + log, = caplog.records + assert log.levelname == 'WARNING' + assert log.message.startswith('cannot set payment-method for rt:310: ') check_output(output, [ r'^PAYMENT FOR APPROVAL:$', r'^REQUESTOR: Mx\. 310 $', r'^TOTAL TO PAY: \$420.00$', ]) + assert rt_client.edits == {'310': { + 'CF_payment-amount': 'USD 420.00', + }} + assert 'payment-method:' not in output.getvalue() def test_outgoing_report_without_rt_id(accrual_postings, caplog): invoice = 'rt://ticket/515/attachments/5150' diff --git a/tests/testutil.py b/tests/testutil.py index 33f2150..856bf59 100644 --- a/tests/testutil.py +++ b/tests/testutil.py @@ -348,6 +348,7 @@ class RTClient: self.login_result = True self.last_login = None self.want_cfs = want_cfs + self.edits = {} def login(self, login=None, password=None): if login is None and password is None: @@ -402,10 +403,15 @@ class RTClient: ], } if self.want_cfs: - retval['CF.{payment-method}'] = f'payment method {ticket_id_s}' + retval['CF.{payment-amount}'] = '' + retval['CF.{payment-method}'] = '' retval['CF.{payment-to}'] = f'Hon. Mx. {ticket_id_s}' return retval + def edit_ticket(self, ticket_id, **kwargs): + self.edits.setdefault(str(ticket_id), {}).update(kwargs) + return True + def get_user(self, user_id): user_id_s = str(user_id) match = re.search(r'(\d+)@', user_id_s)