diff --git a/conservancy_beancount/reports/accrual.py b/conservancy_beancount/reports/accrual.py index 02fd341..3e55727 100644 --- a/conservancy_beancount/reports/accrual.py +++ b/conservancy_beancount/reports/accrual.py @@ -67,6 +67,7 @@ import re import sys from typing import ( + Any, Callable, Dict, Iterable, @@ -328,19 +329,28 @@ class SearchTerm(NamedTuple): ) return cls(key, pattern) +def _consistency_check_one_thing( + key: MetaValue, + related: core.RelatedPostings, + get_name: str, + get_func: Callable[[data.Posting], Any], +) -> Iterable[Error]: + values = {get_func(post) for post in related} + if len(values) != 1: + for post in related: + errmsg = f'inconsistent {get_name} for invoice {key}: {get_func(post)}' + yield Error(post.meta, errmsg, post.meta.txn) def consistency_check(groups: PostGroups) -> Iterable[Error]: + errfmt = 'inconsistent {} for invoice {}: {{}}' for key, related in groups.items(): + yield from _consistency_check_one_thing( + key, related, 'cost', lambda post: post.cost, + ) for checked_meta in ['contract', 'entity', 'purchase-order']: - meta_values = related.meta_values(checked_meta) - if len(meta_values) != 1: - errmsg = f'inconsistent {checked_meta} for invoice {key}' - for post in related: - yield Error( - post.meta, - f'{errmsg}: {post.meta.get(checked_meta)}', - post.meta.txn, - ) + yield from _consistency_check_one_thing( + key, related, checked_meta, lambda post: post.meta.get(checked_meta), + ) def filter_search(postings: Iterable[data.Posting], search_terms: Iterable[SearchTerm], diff --git a/setup.py b/setup.py index 92f2146..92e658a 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup setup( name='conservancy_beancount', description="Plugin, library, and reports for reading Conservancy's books", - version='1.0.8', + version='1.0.9', author='Software Freedom Conservancy', author_email='info@sfconservancy.org', license='GNU AGPLv3+', diff --git a/tests/test_reports_accrual.py b/tests/test_reports_accrual.py index 0a2d6dd..e880b0d 100644 --- a/tests/test_reports_accrual.py +++ b/tests/test_reports_accrual.py @@ -250,6 +250,20 @@ def test_consistency_check_when_inconsistent(meta_key, account): assert actual.entry is txn assert actual.source.get('lineno') == exp_lineno +def test_consistency_check_cost(): + account = ACCOUNTS[0] + invoice = 'test-cost-invoice' + txn = testutil.Transaction(postings=[ + (account, 100, 'EUR', ('1.1251', 'USD'), {'invoice': invoice, 'lineno': 1}), + (account, -100, 'EUR', ('1.125', 'USD'), {'invoice': invoice, 'lineno': 2}), + ]) + related = core.RelatedPostings(data.Posting.from_txn(txn)) + errors = list(accrual.consistency_check({invoice: related})) + for post, err in itertools.zip_longest(txn.postings, errors): + assert err.message == f'inconsistent cost for invoice {invoice}: {post.cost}' + assert err.entry is txn + assert err.source.get('lineno') == post.meta['lineno'] + def check_output(output, expect_patterns): output.seek(0) testutil.check_lines_match(iter(output), expect_patterns)