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
...
 
@@ -104,2 +104,32 @@ 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:
...
 
@@ -125,11 +155,7 @@ class ReportType:
 
    @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()
 
        ):
...
 
@@ -138,3 +164,3 @@ class ReportType:
 
            report_name = 'balance'
 
        return cls.BY_NAME[report_name], nonzero_groups or groups
 
        return cls.BY_NAME[report_name]
 

	
...
 
@@ -305,5 +331,4 @@ def filter_search(postings: Iterable[data.Posting],
 
) -> 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:
...
 
@@ -367,2 +392,3 @@ def main(arglist: Optional[Sequence[str]]=None,
 
    groups = core.RelatedPostings.group_by_meta(postings, 'invoice')
 
    groups = AccrualAccount.filter_paid_accruals(groups) or groups
 
    meta_errors = consistency_check(groups)
...
 
@@ -375,3 +401,3 @@ def main(arglist: Optional[Sequence[str]]=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:
tests/books/accruals.beancount
Show inline comments
...
 
@@ -4,2 +4,3 @@
 
2020-01-01 open Expenses:Services:Legal
 
2020-01-01 open Expenses:Travel
 
2020-01-01 open Income:Donations
...
 
@@ -7,2 +8,20 @@
 

	
 
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"
tests/test_reports_accrual.py
Show inline comments
...
 
@@ -55,2 +55,9 @@ 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': [],
...
 
@@ -186,39 +193,2 @@ def test_report_type_by_unknown_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(
...
 
@@ -366,2 +336,28 @@ def check_main_fails(arglist, config, error_flags, error_patterns):
 

	
 
@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', [
0 comments (0 inline, 0 general)