diff --git a/conservancy_beancount/plugin/core.py b/conservancy_beancount/plugin/core.py index c9c8af18ad5d5f2703e834ad4052533c9044bd30..550b5a2c4b8e9a5566ac6372361eb95de3f975bf 100644 --- a/conservancy_beancount/plugin/core.py +++ b/conservancy_beancount/plugin/core.py @@ -31,6 +31,7 @@ from typing import ( Iterator, Mapping, Optional, + Sequence, Type, TypeVar, ) @@ -243,26 +244,29 @@ class _NormalizePostingMetadataHook(_PostingHook): class _RequireLinksPostingMetadataHook(_PostingHook): """Base class to require that posting metadata include links""" # This base class confirms that a posting's metadata has one or more links - # under METADATA_KEY. - # Most subclasses only need to define METADATA_KEY and _run_on_post. - METADATA_KEY: str + # under one of the metadata keys listed in CHECKED_METADATA. + # Most subclasses only need to define CHECKED_METADATA and _run_on_post. + CHECKED_METADATA: Sequence[MetaKey] def __init_subclass__(cls) -> None: super().__init_subclass__() - cls.HOOK_GROUPS = cls.HOOK_GROUPS.union(['metadata', cls.METADATA_KEY]) - - def _check_links(self, txn: Transaction, post: data.Posting, key: MetaKey) -> None: - try: - problem = not post.meta.get_links(key) - value = None - except TypeError: - problem = True - value = post.meta[key] - if problem: - raise errormod.InvalidMetadataError(txn, key, value, post) + cls.HOOK_GROUPS = cls.HOOK_GROUPS.union(cls.CHECKED_METADATA).union('metadata') + + def _check_metadata(self, + txn: Transaction, + post: data.Posting, + keys: Sequence[MetaKey], + ) -> Iterable[errormod.InvalidMetadataError]: + have_docs = False + for key in keys: + try: + links = post.meta.get_links(key) + except TypeError as error: + yield errormod.InvalidMetadataError(txn, key, post.meta[key], post) + else: + have_docs = have_docs or any(links) + if not have_docs: + yield errormod.InvalidMetadataError(txn, '/'.join(keys), None, post) def post_run(self, txn: Transaction, post: data.Posting) -> errormod.Iter: - try: - self._check_links(txn, post, self.METADATA_KEY) - except errormod.Error as error: - yield error + return self._check_metadata(txn, post, self.CHECKED_METADATA) diff --git a/conservancy_beancount/plugin/meta_approval.py b/conservancy_beancount/plugin/meta_approval.py index 416e23d65ce7f77c52a68dc9c0641b55459d2f22..e8f388ea03c02d463b958cb1c5b025ef87b86982 100644 --- a/conservancy_beancount/plugin/meta_approval.py +++ b/conservancy_beancount/plugin/meta_approval.py @@ -25,8 +25,7 @@ from ..beancount_types import ( ) class MetaApproval(core._RequireLinksPostingMetadataHook): - METADATA_KEY = 'approval' - CREDIT_CARD_ACCT = 'Liabilities:CreditCard' + CHECKED_METADATA = ['approval'] def __init__(self, config: configmod.Config) -> None: self.payment_threshold = config.payment_threshold() diff --git a/conservancy_beancount/plugin/meta_invoice.py b/conservancy_beancount/plugin/meta_invoice.py index a9e25194f23725fdd49e741c486bfae92400018d..1c1c09cd007c0318d0698cb5c9e380a1dd0c6dbf 100644 --- a/conservancy_beancount/plugin/meta_invoice.py +++ b/conservancy_beancount/plugin/meta_invoice.py @@ -23,7 +23,7 @@ from ..beancount_types import ( ) class MetaInvoice(core._RequireLinksPostingMetadataHook): - METADATA_KEY = 'invoice' + CHECKED_METADATA = ['invoice'] def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool: return post.account.is_under( diff --git a/conservancy_beancount/plugin/meta_receipt.py b/conservancy_beancount/plugin/meta_receipt.py index 475c845258f51ebbae5ff28d2658a6e155fa4e54..937be0f9340eef82e09e82345b27c667476d39b1 100644 --- a/conservancy_beancount/plugin/meta_receipt.py +++ b/conservancy_beancount/plugin/meta_receipt.py @@ -29,10 +29,8 @@ from typing import ( Callable, ) -_CheckMethod = Callable[[Transaction, data.Posting, MetaKey], None] - class MetaReceipt(core._RequireLinksPostingMetadataHook): - METADATA_KEY = 'receipt' + CHECKED_METADATA = ['receipt'] def __init__(self, config: configmod.Config) -> None: self.payment_threshold = abs(config.payment_threshold()) @@ -44,43 +42,35 @@ class MetaReceipt(core._RequireLinksPostingMetadataHook): and abs(post.units.number) >= self.payment_threshold ) - def _check_check_id(self, txn: Transaction, post: data.Posting, key: MetaKey) -> None: - value = post.meta.get(key) - if (not isinstance(value, Decimal) - or value < 1 - or value % 1): - raise errormod.InvalidMetadataError(txn, key, value, post, Decimal) - - def post_run(self, txn: Transaction, post: data.Posting) -> errormod.Iter: + def _run_checking_debit(self, txn: Transaction, post: data.Posting) -> errormod.Iter: + receipt_errors = list(self._check_metadata(txn, post, self.CHECKED_METADATA)) + if not receipt_errors: + return + for error in receipt_errors: + if error.value is not None: + yield error try: - self._check_links(txn, post, self.METADATA_KEY) - except errormod.InvalidMetadataError as error: - receipt_error = error + check_id = post.meta['check-id'] + except KeyError: + check_id_ok = False else: - return + check_id_ok = (isinstance(check_id, Decimal) + and check_id >= 1 + and not check_id % 1) + if not check_id_ok: + yield errormod.InvalidMetadataError(txn, 'check-id', check_id, post, Decimal) + if not check_id_ok: + yield errormod.InvalidMetadataError(txn, 'receipt/check-id', post=post) - check_method: _CheckMethod = self._check_links - if post.account.is_checking(): - if post.is_debit(): - check_method = self._check_check_id - fallback_key = 'check-id' - else: - fallback_key = 'check' + def post_run(self, txn: Transaction, post: data.Posting) -> errormod.Iter: + keys = list(self.CHECKED_METADATA) + is_checking = post.account.is_checking() + if is_checking and post.is_debit(): + return self._run_checking_debit(txn, post) + elif is_checking: + keys.append('check') elif post.account.is_credit_card() and not post.is_credit(): - fallback_key = 'invoice' + keys.append('invoice') elif post.account.is_under('Assets:PayPal') and not post.is_debit(): - fallback_key = 'paypal-id' - else: - yield receipt_error - return - - try: - check_method(txn, post, fallback_key) - except errormod.InvalidMetadataError as fallback_error: - if receipt_error.value is None and fallback_error.value is None: - yield errormod.InvalidMetadataError( - txn, f"{self.METADATA_KEY} or {fallback_key}", None, post, - ) - else: - yield receipt_error - yield fallback_error + keys.append('paypal-id') + return self._check_metadata(txn, post, keys) diff --git a/conservancy_beancount/plugin/meta_receivable_documentation.py b/conservancy_beancount/plugin/meta_receivable_documentation.py index 9369877051d9e636338ffa906c5fd9f8aaedcf49..e95e577630f9bfa7a23f2cc9cb4236708b1b608a 100644 --- a/conservancy_beancount/plugin/meta_receivable_documentation.py +++ b/conservancy_beancount/plugin/meta_receivable_documentation.py @@ -32,12 +32,7 @@ from typing import ( class MetaReceivableDocumentation(core._RequireLinksPostingMetadataHook): HOOK_GROUPS = frozenset(['network', 'rt']) - SUPPORTING_METADATA = frozenset([ - 'approval', - 'contract', - 'purchase-order', - ]) - METADATA_KEY = '/'.join(sorted(SUPPORTING_METADATA)) + CHECKED_METADATA = ['approval', 'contract', 'purchase-order'] # Conservancy invoice filenames have followed two patterns. # The pre-RT pattern: `YYYY-MM-DD_Entity_invoice-YYYYMMDDNN??_as-sent.pdf` # The RT pattern: `ProjectInvoice-30NNNN??.pdf` @@ -74,21 +69,3 @@ class MetaReceivableDocumentation(core._RequireLinksPostingMetadataHook): ticket_id, attachment_id = rt_args invoice_link = self.rt.url(ticket_id, attachment_id) or invoice_link return self.ISSUED_INVOICE_RE.search(invoice_link) is not None - - def post_run(self, txn: Transaction, post: data.Posting) -> errormod.Iter: - errors: Dict[MetaKey, Optional[errormod.InvalidMetadataError]] = { - key: None for key in self.SUPPORTING_METADATA - } - have_support = False - for key in errors: - try: - self._check_links(txn, post, key) - except errormod.InvalidMetadataError as key_error: - errors[key] = key_error - else: - have_support = True - for key, error in errors.items(): - if error is not None and error.value is not None: - yield error - if not have_support: - yield errormod.InvalidMetadataError(txn, self.METADATA_KEY, None, post) diff --git a/tests/test_meta_invoice.py b/tests/test_meta_invoice.py index 88ed46bdc897797282f39f03e4544c46de48abf7..7de49c5016799d6a08c4aa3887d0ba290ebd9d06 100644 --- a/tests/test_meta_invoice.py +++ b/tests/test_meta_invoice.py @@ -37,6 +37,9 @@ NON_REQUIRED_ACCOUNTS = { TEST_KEY = 'invoice' +MISSING_MSG = f'{{}} missing {TEST_KEY}'.format +WRONG_TYPE_MSG = f'{{}} has wrong type of {TEST_KEY}: expected str but is a {{}}'.format + @pytest.fixture(scope='module') def hook(): config = testutil.TestConfig() @@ -77,13 +80,12 @@ def test_bad_type_values_on_postings(hook, acct1, acct2, value): (acct2, -25), (acct1, 25, {TEST_KEY: value}), ]) - expected_msg = "{} has wrong type of {}: expected str but is a {}".format( - acct1, - TEST_KEY, - type(value).__name__, - ) + expected = { + MISSING_MSG(acct1), + WRONG_TYPE_MSG(acct1, type(value).__name__), + } actual = {error.message for error in hook.run(txn)} - assert actual == {expected_msg} + assert actual == expected @pytest.mark.parametrize('acct1,acct2,value', testutil.combine_values( REQUIRED_ACCOUNTS, @@ -120,13 +122,12 @@ def test_bad_type_values_on_transaction(hook, acct1, acct2, value): (acct2, -25), (acct1, 25), ]) - expected_msg = "{} has wrong type of {}: expected str but is a {}".format( - acct1, - TEST_KEY, - type(value).__name__, - ) + expected = { + MISSING_MSG(acct1), + WRONG_TYPE_MSG(acct1, type(value).__name__), + } actual = {error.message for error in hook.run(txn)} - assert actual == {expected_msg} + assert actual == expected @pytest.mark.parametrize('acct1,acct2', testutil.combine_values( REQUIRED_ACCOUNTS, diff --git a/tests/test_meta_receipt.py b/tests/test_meta_receipt.py index 0871b60b5eec396691b3045fa886186829c0796c..b8cb2ca4a94ea012354b9f1744288bd2f71cadc4 100644 --- a/tests/test_meta_receipt.py +++ b/tests/test_meta_receipt.py @@ -44,7 +44,7 @@ class AccountForTesting(typing.NamedTuple): if self.fallback_meta is None or not include_fallback: rest = "" else: - rest = f" or {self.fallback_meta}" + rest = f"/{self.fallback_meta}" return f"{self.name} missing {TEST_KEY}{rest}" def wrong_type_message(self, wrong_value, key=TEST_KEY): @@ -206,7 +206,7 @@ def test_invalid_fallback_on_post(hook, test_acct, other_acct, value): )) def test_bad_type_fallback_on_post(hook, test_acct, other_acct, value): expected = { - test_acct.missing_message(False), + test_acct.missing_message(), test_acct.wrong_type_message(value, test_acct.fallback_meta), } check(hook, test_acct, other_acct, expected, @@ -237,7 +237,7 @@ def test_invalid_fallback_on_txn(hook, test_acct, other_acct, value): )) def test_bad_type_fallback_on_txn(hook, test_acct, other_acct, value): expected = { - test_acct.missing_message(False), + test_acct.missing_message(), test_acct.wrong_type_message(value, test_acct.fallback_meta), } check(hook, test_acct, other_acct, expected, @@ -259,7 +259,7 @@ def test_valid_check_id_on_post(hook, test_acct, other_acct, value): )) def test_invalid_check_id_on_post(hook, test_acct, other_acct, value): expected = { - test_acct.missing_message(False), + test_acct.missing_message(), f"{test_acct.name} has invalid {test_acct.fallback_meta}: {value}", } check(hook, test_acct, other_acct, expected, @@ -274,7 +274,7 @@ def test_bad_type_check_id_on_post(hook, test_acct, other_acct, value): if isinstance(value, decimal.Decimal): value = '' expected = { - test_acct.missing_message(False), + test_acct.missing_message(), test_acct.wrong_type_message(value, test_acct.fallback_meta), } check(hook, test_acct, other_acct, expected, @@ -296,7 +296,7 @@ def test_valid_check_id_on_txn(hook, test_acct, other_acct, value): )) def test_invalid_check_id_on_txn(hook, test_acct, other_acct, value): expected = { - test_acct.missing_message(False), + test_acct.missing_message(), f"{test_acct.name} has invalid {test_acct.fallback_meta}: {value}", } check(hook, test_acct, other_acct, expected, @@ -311,7 +311,7 @@ def test_bad_type_check_id_on_txn(hook, test_acct, other_acct, value): if isinstance(value, decimal.Decimal): value = '' expected = { - test_acct.missing_message(False), + test_acct.missing_message(), test_acct.wrong_type_message(value, test_acct.fallback_meta), } check(hook, test_acct, other_acct, expected, @@ -327,7 +327,6 @@ def test_fallback_not_accepted_on_other_accounts(hook, test_acct, other_acct, ke check(hook, test_acct, other_acct, {test_acct.missing_message()}, post_meta={key: value}) - @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS_WITH_LINK_FALLBACK, NOT_REQUIRED_ACCOUNTS,