accrual: Outgoing report sets RT CFs for outgoing payment.

This commit is contained in:
Brett Smith 2020-06-23 14:27:11 -04:00
parent 4789972d38
commit 5085d4d8ef
5 changed files with 106 additions and 11 deletions

View file

@ -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

View file

@ -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+',

View file

@ -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"

View file

@ -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'

View file

@ -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)