diff --git a/conservancy_beancount/plugin/__init__.py b/conservancy_beancount/plugin/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/conservancy_beancount/plugin/core.py b/conservancy_beancount/plugin/core.py
new file mode 100644
index 0000000..7ff940e
--- /dev/null
+++ b/conservancy_beancount/plugin/core.py
@@ -0,0 +1,54 @@
+"""Base classes for plugin checks"""
+# 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 .
+
+from . import errors as errormod
+
+class PostingChecker:
+ VALUES_ENUM = {}
+
+ def _meta_get(self, txn, post, key, default=None):
+ try:
+ return post.meta[key]
+ except (KeyError, TypeError):
+ return txn.meta.get(key, default)
+
+ def _meta_set(self, post, key, value):
+ if post.meta is None:
+ post.meta = {}
+ post.meta[key] = value
+
+ def _default_value(self, txn, post):
+ raise errormod.InvalidMetadataError(txn, post, self.METADATA_KEY)
+
+ def check(self, txn, post):
+ errors = []
+ source_value = self._meta_get(txn, post, self.METADATA_KEY)
+ set_value = source_value
+ if source_value is None:
+ try:
+ set_value = self._default_value(txn, post)
+ except errormod._BaseError as error:
+ errors.append(error)
+ else:
+ try:
+ set_value = self.VALUES_ENUM[source_value].value
+ except KeyError:
+ errors.append(errormod.InvalidMetadataError(
+ txn, post, self.METADATA_KEY, source_value,
+ ))
+ if not errors:
+ self._meta_set(post, self.METADATA_KEY, set_value)
+ return errors
diff --git a/conservancy_beancount/plugin/errors.py b/conservancy_beancount/plugin/errors.py
new file mode 100644
index 0000000..59b8525
--- /dev/null
+++ b/conservancy_beancount/plugin/errors.py
@@ -0,0 +1,34 @@
+"""Error classes for plugins to report problems in the books"""
+# 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 .
+
+class _BaseError(Exception):
+ def __init__(self, message, entry, source=None):
+ self.message = message
+ self.entry = entry
+ self.source = entry.meta if source is None else source
+
+
+class InvalidMetadataError(_BaseError):
+ def __init__(self, txn, post, key, value=None, source=None):
+ if value is None:
+ msg_fmt = "{post.account} missing {key}"
+ else:
+ msg_fmt = "{post.account} has invalid {key}: {value}"
+ super().__init__(
+ msg_fmt.format(post=post, key=key, value=value),
+ txn,
+ source,
+ )
diff --git a/conservancy_beancount/plugin/meta_expense_allocation.py b/conservancy_beancount/plugin/meta_expense_allocation.py
new file mode 100644
index 0000000..68e2d9e
--- /dev/null
+++ b/conservancy_beancount/plugin/meta_expense_allocation.py
@@ -0,0 +1,29 @@
+"""meta_expense_allocation - Validate expenseAllocation 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 enum
+
+from . import core
+
+class ExpenseAllocations(enum.Enum):
+ administration = 'administration'
+ fundraising = 'fundraising'
+ program = 'program'
+
+
+class MetaExpenseAllocation(core.PostingChecker):
+ METADATA_KEY = 'expenseAllocation'
+ VALUES_ENUM = ExpenseAllocations
diff --git a/tests/test_meta_expenseAllocation.py b/tests/test_meta_expenseAllocation.py
new file mode 100644
index 0000000..b5edd5d
--- /dev/null
+++ b/tests/test_meta_expenseAllocation.py
@@ -0,0 +1,40 @@
+"""Test handling of expenseAllocation 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_expense_allocation
+
+@pytest.mark.parametrize('value,value_ok', [
+ ('program', True),
+ ('administration', True),
+ ('fundraising', True),
+ ('invalid', False),
+ ('', False),
+])
+def test_validity_on_postings(value, value_ok):
+ txn = testutil.Transaction(postings=[
+ ('Assets:Cash', -25),
+ ('Expenses:General', 25, {'expenseAllocation': value}),
+ ])
+ checker = meta_expense_allocation.MetaExpenseAllocation()
+ errors = checker.check(txn, txn.postings[-1])
+ if value_ok:
+ assert not errors
+ else:
+ assert errors
diff --git a/tests/testutil.py b/tests/testutil.py
new file mode 100644
index 0000000..47368a3
--- /dev/null
+++ b/tests/testutil.py
@@ -0,0 +1,80 @@
+"""Mock Beancount objects for testing"""
+# 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 datetime
+
+import beancount.core.amount as bc_amount
+import beancount.core.data as bc_data
+
+from decimal import Decimal
+
+FY_START_DATE = datetime.date(2020, 3, 1)
+FY_MID_DATE = datetime.date(2020, 9, 1)
+
+def parse_date(s, fmt='%Y-%m-%d'):
+ return datetime.datetime.strptime(s, fmt).date()
+
+def Posting(account, number,
+ currency='USD', cost=None, price=None, flag=None,
+ **meta):
+ return bc_data.Posting(
+ account,
+ bc_amount.Amount(Decimal(number), currency),
+ cost,
+ price,
+ flag,
+ meta,
+ )
+
+class Transaction:
+ def __init__(self,
+ date=FY_MID_DATE, flag='*', payee=None,
+ narration='', tags=None, links=None, postings=None,
+ **meta):
+ if isinstance(date, str):
+ date = parse_date(date)
+ self.date = date
+ self.flag = flag
+ self.payee = payee
+ self.narration = narration
+ self.tags = set(tags or '')
+ self.links = set(links or '')
+ self.postings = []
+ self.meta = {
+ 'filename': '',
+ 'lineno': 0,
+ }
+ self.meta.update(meta)
+ for posting in postings:
+ self.add_posting(*posting)
+
+ def add_posting(self, arg, *args, **kwargs):
+ """Add a posting to this transaction. Use any of these forms:
+
+ txn.add_posting(account, number, …, kwarg=value, …)
+ txn.add_posting(account, number, …, posting_kwargs_dict)
+ txn.add_posting(posting_object)
+ """
+ if kwargs:
+ posting = Posting(arg, *args, **kwargs)
+ elif args:
+ if isinstance(args[-1], dict):
+ kwargs = args[-1]
+ args = args[:-1]
+ posting = Posting(arg, *args, **kwargs)
+ else:
+ posting = arg
+ self.postings.append(posting)