diff --git a/conservancy_beancount/plugin/__init__.py b/conservancy_beancount/plugin/__init__.py index 7c7f3687b7d46b020808951433556af5c8856b14..cc832113bc9a2129ced6aa7ed43c51b73ebd85e2 100644 --- a/conservancy_beancount/plugin/__init__.py +++ b/conservancy_beancount/plugin/__init__.py @@ -51,6 +51,7 @@ class HookRegistry: '.meta_expense_allocation': None, '.meta_income_type': None, '.meta_invoice': None, + '.meta_paypal_id': ['MetaPayPalID'], '.meta_project': None, '.meta_receipt': None, '.meta_receivable_documentation': None, diff --git a/conservancy_beancount/plugin/meta_paypal_id.py b/conservancy_beancount/plugin/meta_paypal_id.py new file mode 100644 index 0000000000000000000000000000000000000000..cf595f5bdd5e79e4c11d08a0c43e42fa2c0e619c --- /dev/null +++ b/conservancy_beancount/plugin/meta_paypal_id.py @@ -0,0 +1,52 @@ +"""meta_paypal_id - Validate paypal-id metadata""" +# Copyright © 2020 Brett Smith +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import re + +from . import core +from .. import data +from .. import errors as errormod +from ..beancount_types import ( + Transaction, +) + +class MetaPayPalID(core._PostingHook): + METADATA_KEY = 'paypal-id' + HOOK_GROUPS = frozenset(['metadata', METADATA_KEY]) + TXN_ID_RE = re.compile(r'^[A-Z0-9]{17}$') + INVOICE_ID_RE = re.compile(r'^INV2(?:-[A-Z0-9]{4}){4}$') + + def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool: + if post.account.is_under('Assets:PayPal'): + return True + elif post.account.is_under('Assets:Receivable'): + return self.METADATA_KEY in post.meta + else: + return False + + def post_run(self, txn: Transaction, post: data.Posting) -> errormod.Iter: + if post.account.is_under('Assets:Receivable'): + regexp = self.INVOICE_ID_RE + else: + regexp = self.TXN_ID_RE + value = post.meta.get(self.METADATA_KEY) + try: + # A bad argument type is okay because we catch the TypeError. + match = regexp.match(value) # type:ignore[arg-type] + except TypeError: + match = None + if match is None: + yield errormod.InvalidMetadataError(txn, self.METADATA_KEY, value, post) diff --git a/tests/test_meta_paypal_id.py b/tests/test_meta_paypal_id.py new file mode 100644 index 0000000000000000000000000000000000000000..c70d99ae50528a589a64ef9d0057209b0c7a0a17 --- /dev/null +++ b/tests/test_meta_paypal_id.py @@ -0,0 +1,189 @@ +"""Test validation of paypal-id metadata""" +# Copyright © 2020 Brett Smith +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import itertools + +import pytest + +from . import testutil + +from conservancy_beancount.plugin import meta_paypal_id + +VALID_TXN_IDS = { + # Basically normal ids. + 'A1234567890BCDEFG', + '12345HIJKLNMO6780', + # All numeric + '05678901234567890', + # All alphabetic + 'CDEFHSNOECRUHOECR', +} + +VALID_INVOICE_IDS = { + 'INV2-ABCD-1234-EFGH-6789', + # All numeric + 'INV2-1010-2020-3030-4567', + # All alphabetic + 'INV2-ABCD-EFGH-IJKL-MNOP', +} + +VALID_VALUES = VALID_TXN_IDS | VALID_INVOICE_IDS + +INVALID_VALUES = { + # Empty + '', + ' ', + # Punctuation and whitespace + 'Z12345-67890QRSTU', + 'Y12345.67890QRSTU', + 'X12345_67890QRSTU', + 'W12345 67890QRSTU', + 'INV2-ABCD.1234-EFGH-7890', + 'INV2-ABCD-1234_EFGH-7890', + 'INV2-ABCD-1234-EFGH 7890', + # Too short + 'Q1234567890RSTUV', + 'INV2-ABCD-1234-EFGH-789', + # Too long + 'V123456789012345WX', + 'INV2-ABCD-1234-EFGH-78900', + 'INV2-ABCD-1234-EFGH-7890-IJKL', + # Bad cadence + 'INV2-ABCD-1234-EFG-H7890', + 'INV2ABCD-123-EFG-456-789', + 'INV2ABCDEFGHIJKLMNOPQRST', +} + +ACCOUNTS = itertools.cycle([ + 'Assets:PayPal', + 'Assets:Receivable:Accounts', +]) + +TEST_KEY = 'paypal-id' +INVALID_MSG = f"{{}} has invalid {TEST_KEY}: {{}}".format +BAD_TYPE_MSG = f"{{}} has wrong type of {TEST_KEY}: expected str but is a {{}}".format + +@pytest.fixture(scope='module') +def hook(): + config = testutil.TestConfig() + return meta_paypal_id.MetaPayPalID(config) + +def paypal_account_for_id(paypal_id): + if paypal_id.startswith('INV'): + return 'Assets:Receivable:Accounts' + else: + return 'Assets:PayPal' + +@pytest.mark.parametrize('src_value', VALID_VALUES) +def test_valid_values_on_postings(hook, src_value): + txn = testutil.Transaction(postings=[ + ('Income:Donations', -25), + (paypal_account_for_id(src_value), 25, {TEST_KEY: src_value}), + ]) + assert not list(hook.run(txn)) + +@pytest.mark.parametrize('src_value', INVALID_VALUES) +def test_invalid_values_on_postings(hook, src_value): + acct = paypal_account_for_id(src_value) + txn = testutil.Transaction(postings=[ + ('Income:Donations', -25), + (acct, 25, {TEST_KEY: src_value}), + ]) + actual = {error.message for error in hook.run(txn)} + assert actual == {INVALID_MSG(acct, src_value)} + +@pytest.mark.parametrize('src_value,acct', testutil.combine_values( + testutil.NON_STRING_METADATA_VALUES, + ACCOUNTS, +)) +def test_bad_type_values_on_postings(hook, src_value, acct): + txn = testutil.Transaction(postings=[ + ('Income:Donations', -25), + (acct, 25, {TEST_KEY: src_value}), + ]) + actual = {error.message for error in hook.run(txn)} + assert actual == {BAD_TYPE_MSG(acct, type(src_value).__name__)} + +@pytest.mark.parametrize('src_value', VALID_VALUES) +def test_valid_values_on_transactions(hook, src_value): + txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[ + ('Income:Donations', -25), + (paypal_account_for_id(src_value), 25), + ]) + assert not list(hook.run(txn)) + +@pytest.mark.parametrize('src_value', INVALID_VALUES) +def test_invalid_values_on_transactions(hook, src_value): + acct = paypal_account_for_id(src_value) + txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[ + ('Income:Donations', -25), + (acct, 25), + ]) + actual = {error.message for error in hook.run(txn)} + assert actual == {INVALID_MSG(acct, src_value)} + +@pytest.mark.parametrize('src_value,acct', testutil.combine_values( + testutil.NON_STRING_METADATA_VALUES, + ACCOUNTS, +)) +def test_bad_type_values_on_transactions(hook, src_value, acct): + txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[ + ('Income:Donations', -25), + (acct, 25), + ]) + actual = {error.message for error in hook.run(txn)} + assert actual == {BAD_TYPE_MSG(acct, type(src_value).__name__)} + +@pytest.mark.parametrize('src_value', VALID_INVOICE_IDS) +def test_invoice_ids_not_accepted_for_non_accruals(hook, src_value): + acct = 'Assets:PayPal' + txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[ + ('Income:Donations', -25), + (acct, 25), + ]) + actual = {error.message for error in hook.run(txn)} + assert actual == {INVALID_MSG(acct, src_value)} + +@pytest.mark.parametrize('src_value', VALID_TXN_IDS) +def test_transaction_ids_not_accepted_for_accruals(hook, src_value): + acct = 'Assets:Receivable:Accounts' + txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[ + ('Income:Donations', -25), + (acct, 25), + ]) + actual = {error.message for error in hook.run(txn)} + assert actual == {INVALID_MSG(acct, src_value)} + +def test_required_for_assets_paypal(hook): + acct = 'Assets:PayPal' + txn = testutil.Transaction(postings=[ + ('Income:Donations', -35), + (acct, 35), + ]) + actual = {error.message for error in hook.run(txn)} + assert actual == {f"{acct} missing {TEST_KEY}"} + +@pytest.mark.parametrize('txn_id,inv_id', testutil.combine_values( + VALID_TXN_IDS, + VALID_INVOICE_IDS, +)) +def test_invoice_payment_transaction_ok(hook, txn_id, inv_id): + txn = testutil.Transaction(**{TEST_KEY: txn_id}, postings=[ + ('Assets:Receivable:Accounts', -100, {TEST_KEY: inv_id}), + ('Assets:PayPal', 97), + ('Expenses:BankingFees', 3), + ]) + assert not list(hook.run(txn))