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.
This commit is contained in:
Brett Smith 2020-05-23 10:13:17 -04:00
parent 68c2c1e6f8
commit b7aae7b3c0
3 changed files with 92 additions and 51 deletions

View file

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

View file

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

View file

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