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 datetime
|
||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
@ -471,6 +472,21 @@ class BalanceReport(BaseReport):
|
||||||
|
|
||||||
|
|
||||||
class OutgoingReport(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:
|
def __init__(self, rt_wrapper: rtutil.RT, out_file: TextIO) -> None:
|
||||||
super().__init__(out_file)
|
super().__init__(out_file)
|
||||||
self.rt_wrapper = rt_wrapper
|
self.rt_wrapper = rt_wrapper
|
||||||
|
@ -522,9 +538,12 @@ class OutgoingReport(BaseReport):
|
||||||
|
|
||||||
balance_s = posts.end_balance.format(None)
|
balance_s = posts.end_balance.format(None)
|
||||||
raw_balance = -posts.balance()
|
raw_balance = -posts.balance()
|
||||||
|
payment_amount = raw_balance.format('¤¤ #,##0.00')
|
||||||
if raw_balance != posts.end_balance:
|
if raw_balance != posts.end_balance:
|
||||||
|
payment_amount += f' ({balance_s})'
|
||||||
balance_s = f'{raw_balance} ({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'))
|
contract_links = list(posts.all_meta_links('contract'))
|
||||||
if contract_links:
|
if contract_links:
|
||||||
contract_s = ' , '.join(self.rt_wrapper.iter_urls(
|
contract_s = ' , '.join(self.rt_wrapper.iter_urls(
|
||||||
|
@ -537,10 +556,9 @@ class OutgoingReport(BaseReport):
|
||||||
|
|
||||||
yield "PAYMENT FOR APPROVAL:"
|
yield "PAYMENT FOR APPROVAL:"
|
||||||
yield f"REQUESTOR: {requestor}"
|
yield f"REQUESTOR: {requestor}"
|
||||||
|
yield f"PAYMENT TO: {payment_to}"
|
||||||
yield f"TOTAL TO PAY: {balance_s}"
|
yield f"TOTAL TO PAY: {balance_s}"
|
||||||
yield f"AGREEMENT: {contract_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 f"PROJECT: {', '.join(projects)}"
|
||||||
yield "\nBEANCOUNT ENTRIES:\n"
|
yield "\nBEANCOUNT ENTRIES:\n"
|
||||||
|
|
||||||
|
@ -550,8 +568,56 @@ class OutgoingReport(BaseReport):
|
||||||
if txn is not last_txn:
|
if txn is not last_txn:
|
||||||
last_txn = txn
|
last_txn = txn
|
||||||
txn = self.rt_wrapper.txn_with_urls(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)
|
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):
|
class ReportType(enum.Enum):
|
||||||
AGING = AgingReport
|
AGING = AgingReport
|
||||||
|
|
2
setup.py
2
setup.py
|
@ -5,7 +5,7 @@ from setuptools import setup
|
||||||
setup(
|
setup(
|
||||||
name='conservancy_beancount',
|
name='conservancy_beancount',
|
||||||
description="Plugin, library, and reports for reading Conservancy's books",
|
description="Plugin, library, and reports for reading Conservancy's books",
|
||||||
version='1.3.1',
|
version='1.4.0',
|
||||||
author='Software Freedom Conservancy',
|
author='Software Freedom Conservancy',
|
||||||
author_email='info@sfconservancy.org',
|
author_email='info@sfconservancy.org',
|
||||||
license='GNU AGPLv3+',
|
license='GNU AGPLv3+',
|
||||||
|
|
|
@ -76,6 +76,7 @@
|
||||||
contract: "rt:310/3100"
|
contract: "rt:310/3100"
|
||||||
invoice: "FIXME" ; still waiting on them to send it
|
invoice: "FIXME" ; still waiting on them to send it
|
||||||
project: "Conservancy"
|
project: "Conservancy"
|
||||||
|
payment-method: "USD USWire"
|
||||||
Liabilities:Payable:Accounts -200 USD
|
Liabilities:Payable:Accounts -200 USD
|
||||||
Expenses:Travel 200 USD
|
Expenses:Travel 200 USD
|
||||||
|
|
||||||
|
@ -84,6 +85,7 @@
|
||||||
contract: "rt:310/3100"
|
contract: "rt:310/3100"
|
||||||
invoice: "rt:310/3120"
|
invoice: "rt:310/3120"
|
||||||
project: "Conservancy"
|
project: "Conservancy"
|
||||||
|
payment-method: "USD Check"
|
||||||
Liabilities:Payable:Accounts -220 USD
|
Liabilities:Payable:Accounts -220 USD
|
||||||
Expenses:Travel 220 USD
|
Expenses:Travel 220 USD
|
||||||
|
|
||||||
|
@ -102,6 +104,7 @@
|
||||||
project: "Conservancy"
|
project: "Conservancy"
|
||||||
Expenses:Services:Legal 200.00 USD
|
Expenses:Services:Legal 200.00 USD
|
||||||
Liabilities:Payable:Accounts -200.00 USD
|
Liabilities:Payable:Accounts -200.00 USD
|
||||||
|
payment-method: "USD ACH"
|
||||||
|
|
||||||
2010-05-15 * "MatchingProgram" "May matched donations"
|
2010-05-15 * "MatchingProgram" "May matched donations"
|
||||||
invoice: "rt://ticket/515/attachments/5150"
|
invoice: "rt://ticket/515/attachments/5150"
|
||||||
|
@ -134,6 +137,7 @@
|
||||||
project: "Conservancy"
|
project: "Conservancy"
|
||||||
Expenses:Services:Legal 220.00 USD
|
Expenses:Services:Legal 220.00 USD
|
||||||
Liabilities:Payable:Accounts -220.00 USD
|
Liabilities:Payable:Accounts -220.00 USD
|
||||||
|
payment-method: "USD ACH"
|
||||||
|
|
||||||
2010-06-12 * "Lawyer" "Additional legal fees for May"
|
2010-06-12 * "Lawyer" "Additional legal fees for May"
|
||||||
rt-id: "rt:510"
|
rt-id: "rt:510"
|
||||||
|
@ -142,6 +146,7 @@
|
||||||
project: "Conservancy"
|
project: "Conservancy"
|
||||||
Expenses:FilingFees 60.00 USD
|
Expenses:FilingFees 60.00 USD
|
||||||
Liabilities:Payable:Accounts -60.00 USD
|
Liabilities:Payable:Accounts -60.00 USD
|
||||||
|
payment-method: "USD ACH"
|
||||||
|
|
||||||
2010-06-15 * "GrantCo" "2010Q2 grant"
|
2010-06-15 * "GrantCo" "2010Q2 grant"
|
||||||
rt-id: "rt:470"
|
rt-id: "rt:470"
|
||||||
|
@ -158,6 +163,7 @@
|
||||||
contract: "rt:520/5220"
|
contract: "rt:520/5220"
|
||||||
project: "Conservancy"
|
project: "Conservancy"
|
||||||
Liabilities:Payable:Accounts -1,000 EUR {1.100 USD}
|
Liabilities:Payable:Accounts -1,000 EUR {1.100 USD}
|
||||||
|
payment-method: "eur fxwire"
|
||||||
Expenses:FilingFees 1,000 EUR {1.100 USD}
|
Expenses:FilingFees 1,000 EUR {1.100 USD}
|
||||||
|
|
||||||
2010-06-20 * "StateGov" "Business registration"
|
2010-06-20 * "StateGov" "Business registration"
|
||||||
|
|
|
@ -447,7 +447,8 @@ def test_balance_report(accrual_postings, invoice, expected, caplog):
|
||||||
check_output(output, [invoice, expected])
|
check_output(output, [invoice, expected])
|
||||||
|
|
||||||
def test_outgoing_report(accrual_postings, caplog):
|
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_url = RTClient.DEFAULT_URL[:-9]
|
||||||
rt_id_url = rf'\b{re.escape(f"{rt_url}Ticket/Display.html?id=510")}\b'
|
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'
|
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, [
|
check_output(output, [
|
||||||
r'^PAYMENT FOR APPROVAL:$',
|
r'^PAYMENT FOR APPROVAL:$',
|
||||||
r'^REQUESTOR: Mx\. 510 <mx510@example\.org>$',
|
r'^REQUESTOR: Mx\. 510 <mx510@example\.org>$',
|
||||||
|
r'^PAYMENT TO: Hon\. Mx\. 510$',
|
||||||
r'^TOTAL TO PAY: \$280\.00$',
|
r'^TOTAL TO PAY: \$280\.00$',
|
||||||
fr'^AGREEMENT: {contract_url}',
|
fr'^AGREEMENT: {contract_url}',
|
||||||
r'^PAYMENT TO: Hon\. Mx\. 510$',
|
|
||||||
r'^PAYMENT METHOD: payment method 510$',
|
|
||||||
r'^BEANCOUNT ENTRIES:$',
|
r'^BEANCOUNT ENTRIES:$',
|
||||||
# For each transaction, check for the date line, a metadata, and the
|
# For each transaction, check for the date line, a metadata, and the
|
||||||
# Expenses posting.
|
# Expenses posting.
|
||||||
|
@ -469,6 +469,11 @@ def test_outgoing_report(accrual_postings, caplog):
|
||||||
fr'^\s+contract: "{contract_url}"$',
|
fr'^\s+contract: "{contract_url}"$',
|
||||||
r'^\s+Expenses:FilingFees\s+60\.00 USD$',
|
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):
|
def test_outgoing_report_custom_field_fallbacks(accrual_postings, caplog):
|
||||||
rt_client = RTClient(want_cfs=False)
|
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'^PAYMENT FOR APPROVAL:$',
|
||||||
r'^REQUESTOR: <mx510@example\.org>$',
|
r'^REQUESTOR: <mx510@example\.org>$',
|
||||||
r'^PAYMENT TO:\s*$',
|
r'^PAYMENT TO:\s*$',
|
||||||
r'^PAYMENT METHOD:\s*$',
|
|
||||||
])
|
])
|
||||||
|
|
||||||
def test_outgoing_report_fx_amounts(accrual_postings, caplog):
|
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
|
assert not caplog.records
|
||||||
check_output(output, [
|
check_output(output, [
|
||||||
r'^PAYMENT FOR APPROVAL:$',
|
r'^PAYMENT FOR APPROVAL:$',
|
||||||
r'^REQUESTOR: Mx\. 520 <mx520@example\.org>$',
|
r'^REQUESTOR: Mx\. 520 <mx520@example\.org>$',
|
||||||
r'^TOTAL TO PAY: 1,000\.00 EUR \(\$1,100.00\)$',
|
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):
|
def test_outgoing_report_multi_invoice(accrual_postings, caplog):
|
||||||
output = run_outgoing('rt:310', accrual_postings)
|
rt_client = RTClient()
|
||||||
assert not caplog.records
|
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, [
|
check_output(output, [
|
||||||
r'^PAYMENT FOR APPROVAL:$',
|
r'^PAYMENT FOR APPROVAL:$',
|
||||||
r'^REQUESTOR: Mx\. 310 <mx310@example\.org>$',
|
r'^REQUESTOR: Mx\. 310 <mx310@example\.org>$',
|
||||||
r'^TOTAL TO PAY: \$420.00$',
|
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):
|
def test_outgoing_report_without_rt_id(accrual_postings, caplog):
|
||||||
invoice = 'rt://ticket/515/attachments/5150'
|
invoice = 'rt://ticket/515/attachments/5150'
|
||||||
|
|
|
@ -348,6 +348,7 @@ class RTClient:
|
||||||
self.login_result = True
|
self.login_result = True
|
||||||
self.last_login = None
|
self.last_login = None
|
||||||
self.want_cfs = want_cfs
|
self.want_cfs = want_cfs
|
||||||
|
self.edits = {}
|
||||||
|
|
||||||
def login(self, login=None, password=None):
|
def login(self, login=None, password=None):
|
||||||
if login is None and password is None:
|
if login is None and password is None:
|
||||||
|
@ -402,10 +403,15 @@ class RTClient:
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
if self.want_cfs:
|
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}'
|
retval['CF.{payment-to}'] = f'Hon. Mx. {ticket_id_s}'
|
||||||
return retval
|
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):
|
def get_user(self, user_id):
|
||||||
user_id_s = str(user_id)
|
user_id_s = str(user_id)
|
||||||
match = re.search(r'(\d+)@', user_id_s)
|
match = re.search(r'(\d+)@', user_id_s)
|
||||||
|
|
Loading…
Add table
Reference in a new issue