accrual: Outgoing report sets RT CFs for outgoing payment.
This commit is contained in:
parent
4789972d38
commit
5085d4d8ef
5 changed files with 106 additions and 11 deletions
|
@ -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
|
||||
|
|
2
setup.py
2
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+',
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue