diff --git a/conservancy_beancount/plugin/__init__.py b/conservancy_beancount/plugin/__init__.py
index 7c7f368..cc83211 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 0000000..cf595f5
--- /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 0000000..c70d99a
--- /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))