diff --git a/conservancy_beancount/errors.py b/conservancy_beancount/errors.py
index 7f331a49bfd5831759ed682009c185d58cd72e18..6d4611bbd9b1ad9cb4725b98acaf7a0cede9659f 100644
--- a/conservancy_beancount/errors.py
+++ b/conservancy_beancount/errors.py
@@ -65,3 +65,16 @@ class InvalidMetadataError(Error):
txn,
source,
)
+
+
+class InvalidEntityError(InvalidMetadataError):
+ def __init__(self, txn, post=None, key='entity', value=None, source=None):
+ if post is None:
+ srcname = 'transaction'
+ else:
+ srcname = post.account
+ if value is None:
+ msg = "{} missing entity".format(srcname)
+ else:
+ msg = "{} entity malformed: {}".format(srcname, value)
+ super(InvalidMetadataError, self).__init__(msg, txn, source)
diff --git a/conservancy_beancount/plugin/meta_entity.py b/conservancy_beancount/plugin/meta_entity.py
new file mode 100644
index 0000000000000000000000000000000000000000..2f3b7bf1437f5daba600af6b2bfb8bf055ad88f5
--- /dev/null
+++ b/conservancy_beancount/plugin/meta_entity.py
@@ -0,0 +1,59 @@
+"""meta_entity - Validate entity 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 config as configmod
+from .. import data
+from .. import errors as errormod
+from ..beancount_types import (
+ MetaValueEnum,
+ Transaction,
+)
+
+from typing import (
+ Any,
+ Dict,
+ Optional,
+ Set,
+)
+
+class MetaEntity(core.TransactionHook):
+ METADATA_KEY = 'entity'
+ HOOK_GROUPS = frozenset(['posting', 'metadata', METADATA_KEY])
+ ENTITY_RE = re.compile(r'^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$')
+
+ def run(self, txn: Transaction) -> errormod.Iter:
+ txn_entity = txn.meta.get(self.METADATA_KEY)
+ if txn_entity is None:
+ txn_entity_ok = None
+ elif isinstance(txn_entity, str):
+ txn_entity_ok = bool(self.ENTITY_RE.match(txn_entity))
+ else:
+ txn_entity_ok = False
+ if txn_entity_ok is False:
+ yield errormod.InvalidEntityError(txn, value=txn_entity)
+ for post in data.iter_postings(txn):
+ if post.account.is_under('Assets', 'Liabilities'):
+ continue
+ entity = post.meta.get(self.METADATA_KEY)
+ if entity is None:
+ yield errormod.InvalidEntityError(txn, post)
+ elif entity is txn_entity:
+ pass
+ elif not self.ENTITY_RE.match(entity):
+ yield errormod.InvalidEntityError(txn, post, value=entity)
diff --git a/tests/test_meta_entity.py b/tests/test_meta_entity.py
new file mode 100644
index 0000000000000000000000000000000000000000..c0902d29bf5f0919ac300aae33ce982bd7181198
--- /dev/null
+++ b/tests/test_meta_entity.py
@@ -0,0 +1,103 @@
+"""Test validation of entity 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 pytest
+
+from . import testutil
+
+from conservancy_beancount.plugin import meta_entity
+
+VALID_VALUES = {
+ 'Smith-Alex',
+ 'Company19',
+ 'boyd-danah',
+ 'B-van-der-A',
+}
+
+INVALID_VALUES = {
+ '-foo',
+ 'foo-',
+ '-',
+ 'Überentity',
+ 'Alex Smith',
+ ' ',
+ '',
+}
+
+TEST_KEY = 'entity'
+
+@pytest.fixture(scope='module')
+def hook():
+ config = testutil.TestConfig()
+ return meta_entity.MetaEntity(config)
+
+@pytest.mark.parametrize('src_value', VALID_VALUES)
+def test_valid_values_on_postings(hook, src_value):
+ txn = testutil.Transaction(postings=[
+ ('Assets:Cash', -25),
+ ('Expenses:General', 25, {TEST_KEY: src_value}),
+ ])
+ assert not any(hook.run(txn))
+
+@pytest.mark.parametrize('src_value', INVALID_VALUES)
+def test_invalid_values_on_postings(hook, src_value):
+ txn = testutil.Transaction(postings=[
+ ('Assets:Cash', -25),
+ ('Expenses:General', 25, {TEST_KEY: src_value}),
+ ])
+ errors = list(hook.run(txn))
+ assert len(errors) == 1
+ assert errors[0].message == "Expenses:General entity malformed: {}".format(src_value)
+
+@pytest.mark.parametrize('src_value', VALID_VALUES)
+def test_valid_values_on_transactions(hook, src_value):
+ txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
+ ('Assets:Cash', -25),
+ ('Expenses:General', 25),
+ ])
+ assert not any(hook.run(txn))
+
+@pytest.mark.parametrize('src_value', INVALID_VALUES)
+def test_invalid_values_on_transactions(hook, src_value):
+ txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
+ ('Assets:Cash', -25),
+ ('Expenses:General', 25),
+ ])
+ errors = list(hook.run(txn))
+ assert 1 <= len(errors) <= 2
+ assert all(error.message == "transaction entity malformed: {}".format(src_value)
+ for error in hook.run(txn))
+
+@pytest.mark.parametrize('account,required', [
+ ('Accrued:AccountsReceivable', True),
+ ('Assets:Cash', False),
+ ('Expenses:General', True),
+ ('Income:Donations', True),
+ ('Liabilities:CreditCard', False),
+ ('UnearnedIncome:Donations', True),
+])
+def test_which_accounts_required_on(hook, account, required):
+ txn = testutil.Transaction(postings=[
+ ('Assets:Checking', 25),
+ (account, 25),
+ ])
+ errors = list(hook.run(txn))
+ if not required:
+ assert not errors
+ else:
+ assert errors
+ assert any(error.message == "{} missing entity".format(account)
+ for error in errors)