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] 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: class ReportType:
NAMES: Set[str] = set() NAMES: Set[str] = set()
BY_NAME: Dict[str, ReportFunc] = {} BY_NAME: Dict[str, ReportFunc] = {}
@ -123,20 +153,16 @@ class ReportType:
raise ValueError(f"unknown report type {name!r}") from None raise ValueError(f"unknown report type {name!r}") from None
@classmethod @classmethod
def default_for(cls, groups: PostGroups) -> Tuple[ReportFunc, PostGroups]: def default_for(cls, groups: PostGroups) -> ReportFunc:
nonzero_groups = { if len(groups) == 1 and all(
key: group for key, group in groups.items() AccrualAccount.classify(group) is AccrualAccount.PAYABLE
if not group.balance().is_zero() and not AccrualAccount.PAYABLE.value.balance_paid(group.balance())
} for group in groups.values()
if len(nonzero_groups) == 1 and all(
post.account.is_under('Liabilities')
for group in nonzero_groups.values()
for post in group
): ):
report_name = 'outgoing' report_name = 'outgoing'
else: else:
report_name = 'balance' report_name = 'balance'
return cls.BY_NAME[report_name], nonzero_groups or groups return cls.BY_NAME[report_name]
class ReturnFlag(enum.IntFlag): class ReturnFlag(enum.IntFlag):
@ -303,9 +329,8 @@ def outgoing_report(groups: PostGroups,
def filter_search(postings: Iterable[data.Posting], def filter_search(postings: Iterable[data.Posting],
search_terms: Iterable[SearchTerm], search_terms: Iterable[SearchTerm],
) -> Iterable[data.Posting]: ) -> Iterable[data.Posting]:
postings = (post for post in postings if post.account.is_under( accounts = tuple(AccrualAccount.account_names())
'Assets:Receivable', 'Liabilities:Payable', postings = (post for post in postings if post.account.is_under(*accounts))
))
for meta_key, pattern in search_terms: for meta_key, pattern in search_terms:
postings = filters.filter_meta_match(postings, meta_key, re.compile(pattern)) postings = filters.filter_meta_match(postings, meta_key, re.compile(pattern))
return postings return postings
@ -365,6 +390,7 @@ def main(arglist: Optional[Sequence[str]]=None,
load_errors = [Error(source, "no books to load in configuration", None)] load_errors = [Error(source, "no books to load in configuration", None)]
postings = filter_search(data.Posting.from_entries(entries), args.search_terms) postings = filter_search(data.Posting.from_entries(entries), args.search_terms)
groups = core.RelatedPostings.group_by_meta(postings, 'invoice') groups = core.RelatedPostings.group_by_meta(postings, 'invoice')
groups = AccrualAccount.filter_paid_accruals(groups) or groups
meta_errors = consistency_check(groups) meta_errors = consistency_check(groups)
for error in load_errors: for error in load_errors:
bc_printer.print_error(error, file=stderr) 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) bc_printer.print_error(error, file=stderr)
returncode |= ReturnFlag.CONSISTENCY_ERRORS returncode |= ReturnFlag.CONSISTENCY_ERRORS
if args.report_type is None: if args.report_type is None:
args.report_type, groups = ReportType.default_for(groups) args.report_type = ReportType.default_for(groups)
if not groups: if not groups:
print("warning: no matching entries found to report", file=stderr) print("warning: no matching entries found to report", file=stderr)
returncode |= ReturnFlag.NOTHING_TO_REPORT returncode |= ReturnFlag.NOTHING_TO_REPORT

View file

@ -2,9 +2,28 @@
2020-01-01 open Assets:Receivable:Accounts 2020-01-01 open Assets:Receivable:Accounts
2020-01-01 open Expenses:FilingFees 2020-01-01 open Expenses:FilingFees
2020-01-01 open Expenses:Services:Legal 2020-01-01 open Expenses:Services:Legal
2020-01-01 open Expenses:Travel
2020-01-01 open Income:Donations 2020-01-01 open Income:Donations
2020-01-01 open Liabilities:Payable:Accounts 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" 2020-05-05 * "DonorA" "Donation pledge"
rt-id: "rt:505" rt-id: "rt:505"
invoice: "rt:505/5050" invoice: "rt:505/5050"

View file

@ -53,6 +53,13 @@ CONSISTENT_METADATA = [
class RTClient(testutil.RTClient): class RTClient(testutil.RTClient):
TICKET_DATA = { TICKET_DATA = {
'40': [
('400', 'invoice feb.csv', 'text/csv', '40.4k'),
],
'44': [
('440', 'invoice feb.csv', 'text/csv', '40.4k'),
],
'490': [],
'505': [], '505': [],
'510': [ '510': [
('4000', 'contract.pdf', 'application/pdf', '1.4m'), ('4000', 'contract.pdf', 'application/pdf', '1.4m'),
@ -184,43 +191,6 @@ def test_report_type_by_unknown_name(arg):
with pytest.raises(ValueError): with pytest.raises(ValueError):
accrual.ReportType.by_name(arg) 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( @pytest.mark.parametrize('meta_key,account', testutil.combine_values(
CONSISTENT_METADATA, CONSISTENT_METADATA,
ACCOUNTS, ACCOUNTS,
@ -364,6 +334,32 @@ def check_main_fails(arglist, config, error_flags, error_patterns):
check_output(errors, error_patterns) check_output(errors, error_patterns)
assert not output.getvalue() 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', [ @pytest.mark.parametrize('arglist', [
['--report-type=outgoing'], ['--report-type=outgoing'],
['510'], ['510'],