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:
parent
68c2c1e6f8
commit
b7aae7b3c0
3 changed files with 92 additions and 51 deletions
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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'],
|
||||||
|
|
Loading…
Reference in a new issue