Changeset - f66460f34304
[Not reviewed]
0 4 0
Brett Smith - 4 years ago 2020-05-28 13:05:18
brettcsmith@brettcsmith.org
accrual: Outgoing report includes total at cost.
4 files changed with 39 insertions and 4 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/reports/accrual.py
Show inline comments
...
 
@@ -251,110 +251,118 @@ def _primary_rt_id(related: core.RelatedPostings) -> rtutil.TicketAttachmentIds:
 
        raise ValueError(f"{rt_ids_count} rt-id links found")
 
    parsed = rtutil.RT.parse(rt_ids.pop())
 
    if parsed is None:
 
        raise ValueError("rt-id is not a valid RT reference")
 
    else:
 
        return parsed
 

	
 
@ReportType.register('outgoing', 'outgoings', 'out')
 
def outgoing_report(groups: PostGroups,
 
                    out_file: TextIO,
 
                    err_file: TextIO=sys.stderr,
 
                    rt_client: Optional[rt.Rt]=None,
 
                    rt_wrapper: Optional[rtutil.RT]=None,
 
) -> None:
 
    if rt_client is None or rt_wrapper is None:
 
        raise ValueError("RT client is required but not configured")
 
    for invoice, related in groups.items():
 
        related = _since_last_nonzero(related)
 
        try:
 
            ticket_id, _ = _primary_rt_id(related)
 
            ticket = rt_client.get_ticket(ticket_id)
 
            # Note we only use this when ticket is None.
 
            errmsg = f"ticket {ticket_id} not found"
 
        except (ValueError, rt.RtError) as error:
 
            ticket = None
 
            errmsg = error.args[0]
 
        if ticket is None:
 
            print("error: can't generate outgoings report for {}"
 
                  " because no RT ticket available: {}".format(
 
                      invoice, errmsg,
 
                  ), file=err_file)
 
            continue
 

	
 
        try:
 
            rt_requestor = rt_client.get_user(ticket['Requestors'][0])
 
        except (IndexError, rt.RtError):
 
            rt_requestor = None
 
        if rt_requestor is None:
 
            requestor = ''
 
            requestor_name = ''
 
        else:
 
            requestor_name = (
 
                rt_requestor.get('RealName')
 
                or ticket.get('CF.{payment-to}')
 
                or ''
 
            )
 
            requestor = f'{requestor_name} <{rt_requestor["EmailAddress"]}>'.strip()
 

	
 
        raw_balance = -related.balance()
 
        cost_balance = -related.balance_at_cost()
 
        cost_balance_s = cost_balance.format(None)
 
        if raw_balance == cost_balance:
 
            balance_s = cost_balance_s
 
        else:
 
            balance_s = f'{raw_balance} ({cost_balance_s})'
 

	
 
        contract_links = related.all_meta_links('contract')
 
        if contract_links:
 
            contract_s = ' , '.join(rt_wrapper.iter_urls(
 
                contract_links, missing_fmt='<BROKEN RT LINK: {}>',
 
            ))
 
        else:
 
            contract_s = "NO CONTRACT GOVERNS THIS TRANSACTION"
 
        projects = [v for v in related.meta_values('project')
 
                    if isinstance(v, str)]
 

	
 
        print(
 
            "PAYMENT FOR APPROVAL:",
 
            f"REQUESTOR: {requestor}",
 
            f"TOTAL TO PAY: {-related.balance()}",
 
            f"TOTAL TO PAY: {balance_s}",
 
            f"AGREEMENT: {contract_s}",
 
            f"PAYMENT TO: {ticket.get('CF.{payment-to}') or requestor_name}",
 
            f"PAYMENT METHOD: {ticket.get('CF.{payment-method}', '')}",
 
            f"PROJECT: {', '.join(projects)}",
 
            "\nBEANCOUNT ENTRIES:\n",
 
            sep='\n', file=out_file,
 
        )
 

	
 
        last_txn: Optional[Transaction] = None
 
        for post in related:
 
            txn = post.meta.txn
 
            if txn is not last_txn:
 
                last_txn = txn
 
                txn = rt_wrapper.txn_with_urls(txn, '{}')
 
                bc_printer.print_entry(txn, file=out_file)
 

	
 
def filter_search(postings: Iterable[data.Posting],
 
                  search_terms: Iterable[SearchTerm],
 
) -> Iterable[data.Posting]:
 
    accounts = tuple(AccrualAccount.account_names())
 
    postings = (post for post in postings if post.account.is_under(*accounts))
 
    for meta_key, pattern in search_terms:
 
        postings = filters.filter_meta_match(postings, meta_key, re.compile(pattern))
 
    return postings
 

	
 
def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace:
 
    parser = argparse.ArgumentParser()
 
    parser.add_argument(
 
        '--report-type', '-t',
 
        metavar='NAME',
 
        type=ReportType.by_name,
 
        help="""The type of report to generate, either `balance` or `outgoing`.
 
If not specified, the default is `outgoing` for search criteria that return a
 
single outstanding payable, and `balance` any other time.
 
""")
 
    parser.add_argument(
 
        '--since',
 
        metavar='YEAR',
 
        type=int,
 
        default=-1,
 
        help="""How far back to search the books for related transactions.
 
You can either specify a fiscal year, or a negative offset from the current
 
fiscal year, to start loading entries from. The default is -1 (start from the
 
previous fiscal year).
 
""")
 
    parser.add_argument(
 
        'search',
 
        nargs=argparse.ZERO_OR_MORE,
setup.py
Show inline comments
 
#!/usr/bin/env python3
 

	
 
from setuptools import setup
 

	
 
setup(
 
    name='conservancy_beancount',
 
    description="Plugin, library, and reports for reading Conservancy's books",
 
    version='1.0.6',
 
    version='1.0.7',
 
    author='Software Freedom Conservancy',
 
    author_email='info@sfconservancy.org',
 
    license='GNU AGPLv3+',
 

	
 
    install_requires=[
 
        'babel>=2.6',  # Debian:python3-babel
 
        'beancount>=2.2',  # Debian:beancount
 
        'PyYAML>=3.0',  # Debian:python3-yaml
 
        'regex',  # Debian:python3-regex
 
        'rt>=2.0',
 
    ],
 
    setup_requires=[
 
        'pytest-mypy',
 
        'pytest-runner',  # Debian:python3-pytest-runner
 
    ],
 
    tests_require=[
 
        'mypy>=0.770',  # Debian:python3-mypy
 
        'pytest',  # Debian:python3-pytest
 
    ],
 

	
 
    packages=[
 
        'conservancy_beancount',
 
        'conservancy_beancount.plugin',
 
        'conservancy_beancount.reports',
 
    ],
 
    entry_points={
 
        'console_scripts': [
 
            'accrual-report = conservancy_beancount.reports.accrual:main',
 
        ],
 
    },
 
)
tests/books/accruals.beancount
Show inline comments
...
 
@@ -28,48 +28,55 @@
 
  rt-id: "rt:505"
 
  invoice: "rt:505/5050"
 
  approval: "rt:505/5040"
 
  Income:Donations  -1000.00 USD
 
  Assets:Receivable:Accounts  1000.00 USD
 

	
 
2020-05-10 * "Lawyer" "April legal services"
 
  rt-id: "rt:510"
 
  invoice: "rt:510/5100"
 
  contract: "rt:510/4000"
 
  Expenses:Services:Legal  200.00 USD
 
  Liabilities:Payable:Accounts  -200.00 USD
 

	
 
2020-05-15 * "DonorB" "Donation pledge"
 
  rt-id: "rt://ticket/515"
 
  invoice: "rt://ticket/515/attachments/5150"
 
  approval: "rt://ticket/515/attachments/5140"
 
  Income:Donations  -1500.00 USD
 
  Assets:Receivable:Accounts  1500.00 USD
 

	
 
2020-05-20 * "DonorA" "Donation made"
 
  rt-id: "rt:505"
 
  invoice: "rt:505/5050"
 
  Assets:Receivable:Accounts  -1000.00 USD
 
  Assets:Checking  1000.00 USD
 
  receipt: "DonorAWire.pdf"
 

	
 
2020-05-25 * "Lawyer" "May payment"
 
  rt-id: "rt:510"
 
  invoice: "rt:510/5100"
 
  Liabilities:Payable:Accounts  200.00 USD
 
  contract: "rt:510/4000"
 
  Assets:Checking  -200.00 USD
 
  receipt: "rt:510/5105"
 

	
 
2020-06-10 * "Lawyer" "May legal services"
 
  rt-id: "rt:510"
 
  invoice: "rt:510/6100"
 
  contract: "rt:510/4000"
 
  Expenses:Services:Legal  220.00 USD
 
  Liabilities:Payable:Accounts  -220.00 USD
 

	
 
2020-06-12 * "Lawyer" "Additional legal fees for May"
 
  rt-id: "rt:510"
 
  invoice: "rt:510/6100"
 
  contract: "rt:510/4000"
 
  Expenses:FilingFees  60.00 USD
 
  Liabilities:Payable:Accounts  -60.00 USD
 

	
 
2020-06-18 * "EuroGov" "European legal fees"
 
  rt-id: "rt:520"
 
  invoice: "rt:520/5200"
 
  contract: "rt:520/5220"
 
  Liabilities:Payable:Accounts  -1,000 EUR {1.100 USD}
 
  Expenses:FilingFees  1,000 EUR {1.100 USD}
tests/test_reports_accrual.py
Show inline comments
...
 
@@ -23,96 +23,97 @@ import re
 
import pytest
 

	
 
from . import testutil
 

	
 
from beancount import loader as bc_loader
 
from conservancy_beancount import data
 
from conservancy_beancount import rtutil
 
from conservancy_beancount.reports import accrual
 
from conservancy_beancount.reports import core
 

	
 
_accruals_load = bc_loader.load_file(testutil.test_path('books/accruals.beancount'))
 
ACCRUALS_COUNT = sum(
 
    1
 
    for entry in _accruals_load[0]
 
    for post in getattr(entry, 'postings', ())
 
    if post.account.startswith(('Assets:Receivable:', 'Liabilities:Payable:'))
 
)
 

	
 
ACCOUNTS = [
 
    'Assets:Receivable:Accounts',
 
    'Assets:Receivable:Loans',
 
    'Liabilities:Payable:Accounts',
 
    'Liabilities:Payable:Vacation',
 
]
 

	
 
CONSISTENT_METADATA = [
 
    'contract',
 
    'entity',
 
    'purchase-order',
 
]
 

	
 
class RTClient(testutil.RTClient):
 
    TICKET_DATA = {
 
        '40': [
 
            ('400', 'invoice feb.csv', 'text/csv', '40.4k'),
 
        ],
 
        '44': [
 
            ('440', 'invoice feb.csv', 'text/csv', '40.4k'),
 
        ],
 
        '490': [],
 
        '505': [],
 
        '510': [
 
            ('4000', 'contract.pdf', 'application/pdf', '1.4m'),
 
            ('5100', 'invoice april.pdf', 'application/pdf', '1.5m'),
 
            ('5105', 'payment.png', 'image/png', '51.5k'),
 
            ('6100', 'invoice may.pdf', 'application/pdf', '1.6m'),
 
        ],
 
        '515': [],
 
        '520': [],
 
    }
 

	
 

	
 
@pytest.fixture
 
def accrual_entries():
 
    return copy.deepcopy(_accruals_load[0])
 

	
 
@pytest.fixture
 
def accrual_postings():
 
    entries = copy.deepcopy(_accruals_load[0])
 
    return data.Posting.from_entries(entries)
 

	
 
def check_link_regexp(regexp, match_s, first_link_only=False):
 
    assert regexp
 
    assert re.search(regexp, match_s)
 
    assert re.search(regexp, match_s + ' postlink')
 
    assert re.search(regexp, match_s + '0') is None
 
    assert re.search(regexp, '1' + match_s) is None
 
    end_match = re.search(regexp, 'prelink ' + match_s)
 
    if first_link_only:
 
        assert end_match is None
 
    else:
 
        assert end_match
 

	
 
@pytest.mark.parametrize('link_fmt', [
 
    '{}',
 
    'rt:{}',
 
    'rt://ticket/{}',
 
])
 
def test_search_term_parse_rt_shortcuts(link_fmt):
 
    key, regexp = accrual.SearchTerm.parse(link_fmt.format(220))
 
    assert key == 'rt-id'
 
    check_link_regexp(regexp, 'rt:220', first_link_only=True)
 
    check_link_regexp(regexp, 'rt://ticket/220', first_link_only=True)
 

	
 
@pytest.mark.parametrize('link_fmt', [
 
    '{}/{}',
 
    'rt:{}/{}',
 
    'rt://ticket/{}/attachments/{}',
 
])
 
def test_search_term_parse_invoice_shortcuts(link_fmt):
 
    key, regexp = accrual.SearchTerm.parse(link_fmt.format(330, 660))
 
    assert key == 'invoice'
 
    check_link_regexp(regexp, 'rt:330/660')
 
    check_link_regexp(regexp, 'rt://ticket/330/attachments/660')
 

	
 
@pytest.mark.parametrize('key', [
 
    'approval',
...
 
@@ -236,184 +237,203 @@ def test_consistency_check_when_inconsistent(meta_key, account):
 
    for exp_lineno, (actual, exp_msg) in enumerate(itertools.zip_longest(errors, [
 
            f'inconsistent {meta_key} for invoice {invoice}: credit',
 
            f'inconsistent {meta_key} for invoice {invoice}: debit',
 
    ]), 1):
 
        assert actual.message == exp_msg
 
        assert actual.entry is txn
 
        assert actual.source.get('lineno') == exp_lineno
 

	
 
def check_output(output, expect_patterns):
 
    output.seek(0)
 
    testutil.check_lines_match(iter(output), expect_patterns)
 

	
 
@pytest.mark.parametrize('invoice,expected', [
 
    ('rt:505/5050', "Zero balance outstanding since 2020-05-05"),
 
    ('rt:510/5100', "Zero balance outstanding since 2020-05-10"),
 
    ('rt:510/6100', "-280.00 USD outstanding since 2020-06-10"),
 
    ('rt://ticket/515/attachments/5150', "1,500.00 USD outstanding since 2020-05-15",),
 
])
 
def test_balance_report(accrual_postings, invoice, expected):
 
    related = core.RelatedPostings(
 
        post for post in accrual_postings
 
        if post.meta.get('invoice') == invoice
 
        and post.account.is_under('Assets:Receivable', 'Liabilities:Payable')
 
    )
 
    output = io.StringIO()
 
    accrual.balance_report({invoice: related}, output)
 
    check_output(output, [invoice, expected])
 

	
 
def test_outgoing_report(accrual_postings):
 
    invoice = 'rt:510/6100'
 
    related = core.RelatedPostings(
 
        post for post in accrual_postings
 
        if post.meta.get('invoice') == invoice
 
        and post.account.is_under('Assets:Receivable', 'Liabilities:Payable')
 
    )
 
    output = io.StringIO()
 
    errors = io.StringIO()
 
    rt_client = RTClient()
 
    rt_cache = rtutil.RT(rt_client)
 
    accrual.outgoing_report({invoice: related}, output, errors, rt_client, rt_cache)
 
    assert not errors.getvalue()
 
    rt_url = rt_client.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'
 
    print(output.getvalue())
 
    check_output(output, [
 
        r'^PAYMENT FOR APPROVAL:$',
 
        r'^REQUESTOR: Mx\. 510 <mx510@example\.org>$',
 
        r'^TOTAL TO PAY: 280\.00 USD$',
 
        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.
 
        r'^\s*2020-06-10\s',
 
        fr'^\s+rt-id: "{rt_id_url}"$',
 
        r'^\s+Expenses:Services:Legal\s+220\.00 USD$',
 
        r'^\s*2020-06-12\s',
 
        fr'^\s+contract: "{contract_url}"$',
 
        r'^\s+Expenses:FilingFees\s+60\.00 USD$',
 
    ])
 

	
 
def test_outgoing_report_custom_field_fallbacks(accrual_postings):
 
    invoice = 'rt:510/6100'
 
    related = core.RelatedPostings(
 
        post for post in accrual_postings
 
        if post.meta.get('invoice') == invoice
 
        and post.account.is_under('Assets:Receivable', 'Liabilities:Payable')
 
    )
 
    output = io.StringIO()
 
    errors = io.StringIO()
 
    rt_client = RTClient(want_cfs=False)
 
    rt_cache = rtutil.RT(rt_client)
 
    accrual.outgoing_report({invoice: related}, output, errors, rt_client, rt_cache)
 
    assert not errors.getvalue()
 
    check_output(output, [
 
        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):
 
    invoice = 'rt:520/5200'
 
    related = core.RelatedPostings(
 
        post for post in accrual_postings
 
        if post.meta.get('invoice') == invoice
 
        and post.account.is_under('Assets:Receivable', 'Liabilities:Payable')
 
    )
 
    output = io.StringIO()
 
    errors = io.StringIO()
 
    rt_client = RTClient()
 
    rt_cache = rtutil.RT(rt_client)
 
    accrual.outgoing_report({invoice: related}, output, errors, rt_client, rt_cache)
 
    assert not errors.getvalue()
 
    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\)$',
 
    ])
 

	
 
def run_main(arglist, config=None):
 
    if config is None:
 
        config = testutil.TestConfig(
 
            books_path=testutil.test_path('books/accruals.beancount'),
 
            rt_client=RTClient(),
 
        )
 
    output = io.StringIO()
 
    errors = io.StringIO()
 
    retcode = accrual.main(arglist, output, errors, config)
 
    return retcode, output, errors
 

	
 
def check_main_fails(arglist, config, error_flags, error_patterns):
 
    retcode, output, errors = run_main(arglist, config)
 
    assert retcode > 16
 
    assert (retcode - 16) & error_flags
 
    check_output(errors, error_patterns)
 
    assert not output.getvalue()
 

	
 
@pytest.mark.parametrize('arglist', [
 
    ['--report-type=balance'],
 
    ['--report-type=outgoing'],
 
    ['entity=EarlyBird'],
 
])
 
def test_output_excludes_payments(arglist):
 
    retcode, output, errors = run_main(arglist)
 
    assert not errors.getvalue()
 
    assert retcode == 0
 
    output.seek(0)
 
    for line in output:
 
        assert not re.match(r'\brt:4\d\b', line)
 

	
 
@pytest.mark.parametrize('arglist,expect_invoice', [
 
    (['40'], 'rt:40/400'),
 
    (['44/440'], 'rt:44/440'),
 
])
 
def test_output_payments_when_only_match(arglist, expect_invoice):
 
    retcode, output, errors = run_main(arglist)
 
    assert not errors.getvalue()
 
    assert retcode == 0
 
    check_output(output, [
 
        rf'^{re.escape(expect_invoice)}:$',
 
        r' outstanding since ',
 
    ])
 

	
 
@pytest.mark.parametrize('arglist', [
 
    ['--report-type=outgoing'],
 
    ['510'],
 
    ['510/6100'],
 
    ['entity=Lawyer'],
 
])
 
def test_main_outgoing_report(arglist):
 
    retcode, output, errors = run_main(arglist)
 
    assert not errors.getvalue()
 
    assert retcode == 0
 
    rt_url = RTClient.DEFAULT_URL[:-9]
 
    rt_id_url = re.escape(f'<{rt_url}Ticket/Display.html?id=510>')
 
    contract_url = re.escape(f'<{rt_url}Ticket/Attachment/4000/4000/contract.pdf>')
 
    check_output(output, [
 
        r'^REQUESTOR: Mx\. 510 <mx510@example\.org>$',
 
        r'^TOTAL TO PAY: 280\.00 USD$',
 
        r'^TOTAL TO PAY: \$280\.00$',
 
        r'^\s*2020-06-12\s',
 
        r'^\s+Expenses:FilingFees\s+60\.00 USD$',
 
    ])
 

	
 
@pytest.mark.parametrize('arglist', [
 
    ['-t', 'balance'],
 
    ['515'],
 
    ['515/5150'],
 
    ['entity=DonorB'],
 
])
 
def test_main_balance_report(arglist):
 
    retcode, output, errors = run_main(arglist)
 
    assert not errors.getvalue()
 
    assert retcode == 0
 
    check_output(output, [
 
        r'\brt://ticket/515/attachments/5150:$',
 
        r'^\s+1,500\.00 USD outstanding since 2020-05-15$',
 
    ])
 

	
 
def test_main_no_books():
 
    check_main_fails([], testutil.TestConfig(), 1 | 8, [
 
        r':1: +no books to load in configuration\b',
 
    ])
 

	
 
@pytest.mark.parametrize('arglist', [
 
    ['499'],
 
    ['505/99999'],
 
    ['entity=NonExistent'],
 
])
 
def test_main_no_matches(arglist):
 
    check_main_fails(arglist, None, 8, [
 
        r'^warning: no matching entries found to report$',
 
    ])
 

	
 
def test_main_no_rt():
 
    config = testutil.TestConfig(
 
        books_path=testutil.test_path('books/accruals.beancount'),
 
    )
 
    check_main_fails(['-t', 'out'], config, 4, [
 
        r'^error: unable to generate outgoing report: RT client is required\b',
 
    ])
0 comments (0 inline, 0 general)