Changeset - 5085d4d8ef79
[Not reviewed]
0 5 0
Brett Smith - 4 years ago 2020-06-23 18:47:03
brettcsmith@brettcsmith.org
accrual: Outgoing report sets RT CFs for outgoing payment.
5 files changed with 106 insertions and 11 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/reports/accrual.py
Show inline comments
...
 
@@ -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
setup.py
Show inline comments
...
 
@@ -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+',
tests/books/accruals.beancount
Show inline comments
...
 
@@ -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"
tests/test_reports_accrual.py
Show inline comments
...
 
@@ -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 <mx510@example\.org>$',
 
        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: <mx510@example\.org>$',
 
        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 <mx520@example\.org>$',
 
        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 <mx310@example\.org>$',
 
        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'
tests/testutil.py
Show inline comments
...
 
@@ -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)
0 comments (0 inline, 0 general)