Changeset - b7aae7b3c02b
[Not reviewed]
0 3 0
Brett Smith - 4 years ago 2020-05-23 14:13:17
brettcsmith@brettcsmith.org
reports.accrual: Exclude payments from default output. RT#11294.

This makes the output more useful for broad searches like on an
entity. Invoices that cross FY boundaries will appear to be paid
without being accrued, and so would appear when we were just
filtering zeroed-out invoices.

If we integrate the aging report into this module in the future,
that'll need to follow different logic, and just filter out
zeroed-out invoices. But the basic balance report and outgoing
report are more workaday tools, where more filtering makes them
more useful.
3 files changed with 92 insertions and 51 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/reports/accrual.py
Show inline comments
...
 
@@ -102,6 +102,36 @@ ReportFunc = Callable[
 
]
 
RTObject = Mapping[str, str]
 

	
 
class Account(NamedTuple):
 
    name: str
 
    balance_paid: Callable[[core.Balance], bool]
 

	
 

	
 
class AccrualAccount(enum.Enum):
 
    PAYABLE = Account('Liabilities:Payable', core.Balance.ge_zero)
 
    RECEIVABLE = Account('Assets:Receivable', core.Balance.le_zero)
 

	
 
    @classmethod
 
    def account_names(cls) -> Iterator[str]:
 
        return (acct.value.name for acct in cls)
 

	
 
    @classmethod
 
    def classify(cls, related: core.RelatedPostings) -> 'AccrualAccount':
 
        for account in cls:
 
            account_name = account.value.name
 
            if all(post.account.is_under(account_name) for post in related):
 
                return account
 
        raise ValueError("unrecognized account set in related postings")
 

	
 
    @classmethod
 
    def filter_paid_accruals(cls, groups: PostGroups) -> PostGroups:
 
        return {
 
            key: related
 
            for key, related in groups.items()
 
            if not cls.classify(related).value.balance_paid(related.balance())
 
        }
 

	
 

	
 
class ReportType:
 
    NAMES: Set[str] = set()
 
    BY_NAME: Dict[str, ReportFunc] = {}
...
 
@@ -123,20 +153,16 @@ class ReportType:
 
            raise ValueError(f"unknown report type {name!r}") from None
 

	
 
    @classmethod
 
    def default_for(cls, groups: PostGroups) -> Tuple[ReportFunc, PostGroups]:
 
        nonzero_groups = {
 
            key: group for key, group in groups.items()
 
            if not group.balance().is_zero()
 
        }
 
        if len(nonzero_groups) == 1 and all(
 
                post.account.is_under('Liabilities')
 
                for group in nonzero_groups.values()
 
                for post in group
 
    def default_for(cls, groups: PostGroups) -> ReportFunc:
 
        if len(groups) == 1 and all(
 
                AccrualAccount.classify(group) is AccrualAccount.PAYABLE
 
                and not AccrualAccount.PAYABLE.value.balance_paid(group.balance())
 
                for group in groups.values()
 
        ):
 
            report_name = 'outgoing'
 
        else:
 
            report_name = 'balance'
 
        return cls.BY_NAME[report_name], nonzero_groups or groups
 
        return cls.BY_NAME[report_name]
 

	
 

	
 
class ReturnFlag(enum.IntFlag):
...
 
@@ -303,9 +329,8 @@ def outgoing_report(groups: PostGroups,
 
def filter_search(postings: Iterable[data.Posting],
 
                  search_terms: Iterable[SearchTerm],
 
) -> Iterable[data.Posting]:
 
    postings = (post for post in postings if post.account.is_under(
 
        'Assets:Receivable', 'Liabilities:Payable',
 
    ))
 
    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
...
 
@@ -365,6 +390,7 @@ def main(arglist: Optional[Sequence[str]]=None,
 
        load_errors = [Error(source, "no books to load in configuration", None)]
 
    postings = filter_search(data.Posting.from_entries(entries), args.search_terms)
 
    groups = core.RelatedPostings.group_by_meta(postings, 'invoice')
 
    groups = AccrualAccount.filter_paid_accruals(groups) or groups
 
    meta_errors = consistency_check(groups)
 
    for error in load_errors:
 
        bc_printer.print_error(error, file=stderr)
...
 
@@ -373,7 +399,7 @@ def main(arglist: Optional[Sequence[str]]=None,
 
        bc_printer.print_error(error, file=stderr)
 
        returncode |= ReturnFlag.CONSISTENCY_ERRORS
 
    if args.report_type is None:
 
        args.report_type, groups = ReportType.default_for(groups)
 
        args.report_type = ReportType.default_for(groups)
 
    if not groups:
 
        print("warning: no matching entries found to report", file=stderr)
 
        returncode |= ReturnFlag.NOTHING_TO_REPORT
tests/books/accruals.beancount
Show inline comments
...
 
@@ -2,9 +2,28 @@
 
2020-01-01 open Assets:Receivable:Accounts
 
2020-01-01 open Expenses:FilingFees
 
2020-01-01 open Expenses:Services:Legal
 
2020-01-01 open Expenses:Travel
 
2020-01-01 open Income:Donations
 
2020-01-01 open Liabilities:Payable:Accounts
 

	
 
2020-03-05 * "EarlyBird" "Payment for receivable from previous FY"
 
  rt-id: "rt:40"
 
  invoice: "rt:40/400"
 
  Assets:Receivable:Accounts  -500 USD
 
  Assets:Checking  500 USD
 

	
 
2020-03-06 * "EarlyBird" "Payment for payment from previous FY"
 
  rt-id: "rt:44"
 
  invoice: "rt:44/440"
 
  Liabilities:Payable:Accounts  125 USD
 
  Assets:Checking  -125 USD
 

	
 
2020-03-30 * "EarlyBird" "Travel reimbursement"
 
  rt-id: "rt:490"
 
  invoice: "rt:490/4900"
 
  Liabilities:Payable:Accounts  -75 USD
 
  Expenses:Travel  75 USD
 

	
 
2020-05-05 * "DonorA" "Donation pledge"
 
  rt-id: "rt:505"
 
  invoice: "rt:505/5050"
tests/test_reports_accrual.py
Show inline comments
...
 
@@ -53,6 +53,13 @@ CONSISTENT_METADATA = [
 

	
 
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'),
...
 
@@ -184,43 +191,6 @@ def test_report_type_by_unknown_name(arg):
 
    with pytest.raises(ValueError):
 
        accrual.ReportType.by_name(arg)
 

	
 
@pytest.mark.parametrize('invoice,expected', [
 
    # No outstanding balance
 
    ('rt:505/5050', accrual.balance_report),
 
    ('rt:510/5100', accrual.balance_report),
 
    # Outstanding receivable
 
    ('rt://ticket/515/attachments/5150', accrual.balance_report),
 
    # Outstanding payable
 
    ('rt:510/6100', accrual.outgoing_report),
 
])
 
def test_default_report_type(accrual_postings, invoice, expected):
 
    related = core.RelatedPostings()
 
    for post in accrual_postings:
 
        if (post.account.is_under('Assets:Receivable', 'Liabilities:Payable')
 
            and post.meta.get('invoice') == invoice):
 
            related.add(post)
 
    groups = {invoice: related}
 
    report_type, report_groups = accrual.ReportType.default_for(groups)
 
    assert report_type is expected
 
    assert report_groups == groups
 

	
 
@pytest.mark.parametrize('entity,exp_type,exp_invoices', [
 
    ('^Lawyer$', accrual.outgoing_report, {'rt:510/6100'}),
 
    ('^Donor[AB]$', accrual.balance_report, {'rt://ticket/515/attachments/5150'}),
 
    ('^(Lawyer|DonorB)$', accrual.balance_report,
 
     {'rt:510/6100', 'rt://ticket/515/attachments/5150'}),
 
])
 
def test_default_report_type_multi_invoices(accrual_postings, entity, exp_type, exp_invoices):
 
    groups = core.RelatedPostings.group_by_meta((
 
        post for post in accrual_postings
 
        if post.account.is_under('Assets:Receivable', 'Liabilities:Payable')
 
        and re.match(entity, post.meta.get('entity', ''))
 
    ), 'invoice')
 
    report_type, report_groups = accrual.ReportType.default_for(groups)
 
    assert report_type is exp_type
 
    assert set(report_groups.keys()) == exp_invoices
 
    assert all(len(related) > 0 for related in report_groups.values())
 

	
 
@pytest.mark.parametrize('meta_key,account', testutil.combine_values(
 
    CONSISTENT_METADATA,
 
    ACCOUNTS,
...
 
@@ -364,6 +334,32 @@ def check_main_fails(arglist, config, error_flags, error_patterns):
 
    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'],
0 comments (0 inline, 0 general)