diff --git a/conservancy_beancount/plugin/meta_income_type.py b/conservancy_beancount/plugin/meta_income_type.py
new file mode 100644
index 0000000..7671d72
--- /dev/null
+++ b/conservancy_beancount/plugin/meta_income_type.py
@@ -0,0 +1,52 @@
+"""meta_income_type - Validate income-type 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 .
+
+from . import core
+from .. import data
+from .. import errors as errormod
+from ..beancount_types import (
+ MetaValueEnum,
+ Transaction,
+)
+
+class MetaIncomeType(core._NormalizePostingMetadataHook):
+ VALUES_ENUM = core.MetadataEnum('income-type', {
+ 'Donations',
+ 'Payable-Derecognition',
+ 'RBI',
+ 'UBTI',
+ })
+ DEFAULT_VALUES = {
+ 'Income:Donations': 'Donations',
+ 'Income:Honoraria': 'RBI',
+ 'Income:Interest': 'RBI',
+ 'Income:Interest:Dividend': 'RBI',
+ 'Income:Royalties': 'RBI',
+ 'Income:Sales': 'RBI',
+ 'Income:SoftwareDevelopment': 'RBI',
+ 'Income:TrademarkLicensing': 'RBI',
+ 'UnearnedIncome:Conferences:Registrations': 'RBI',
+ 'UnearnedIncome:MatchPledges': 'Donations',
+ }
+
+ def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool:
+ return post.account.is_under('Income:', 'UnearnedIncome:') is not None
+
+ def _default_value(self, txn: Transaction, post: data.Posting) -> MetaValueEnum:
+ try:
+ return self.DEFAULT_VALUES[post.account]
+ except KeyError:
+ raise errormod.InvalidMetadataError(txn, post, self.METADATA_KEY) from None
diff --git a/tests/test_meta_income_type.py b/tests/test_meta_income_type.py
new file mode 100644
index 0000000..dc31011
--- /dev/null
+++ b/tests/test_meta_income_type.py
@@ -0,0 +1,152 @@
+"""Test handling of income-type 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_income_type
+
+VALID_VALUES = {
+ 'Donations': 'Donations',
+ 'Payable-Derecognition': 'Payable-Derecognition',
+ 'RBI': 'RBI',
+ 'UBTI': 'UBTI',
+}
+
+INVALID_VALUES = {
+ 'Dontion',
+ 'Payble-Derecognitoin',
+ 'RIB',
+ 'UTBI',
+ '',
+}
+
+TEST_KEY = 'income-type'
+
+@pytest.mark.parametrize('src_value,set_value', VALID_VALUES.items())
+def test_valid_values_on_postings(src_value, set_value):
+ txn = testutil.Transaction(postings=[
+ ('Assets:Cash', 25),
+ ('Income:Other', -25, {TEST_KEY: src_value}),
+ ])
+ checker = meta_income_type.MetaIncomeType()
+ errors = list(checker.run(txn))
+ assert not errors
+ testutil.check_post_meta(txn, None, {TEST_KEY: set_value})
+
+@pytest.mark.parametrize('src_value', INVALID_VALUES)
+def test_invalid_values_on_postings(src_value):
+ txn = testutil.Transaction(postings=[
+ ('Assets:Cash', 25),
+ ('Income:Other', -25, {TEST_KEY: src_value}),
+ ])
+ checker = meta_income_type.MetaIncomeType()
+ errors = list(checker.run(txn))
+ assert errors
+ testutil.check_post_meta(txn, None, {TEST_KEY: src_value})
+
+@pytest.mark.parametrize('src_value,set_value', VALID_VALUES.items())
+def test_valid_values_on_transactions(src_value, set_value):
+ txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
+ ('Assets:Cash', 25),
+ ('Income:Other', -25),
+ ])
+ checker = meta_income_type.MetaIncomeType()
+ errors = list(checker.run(txn))
+ assert not errors
+ testutil.check_post_meta(txn, None, {TEST_KEY: set_value})
+
+@pytest.mark.parametrize('src_value', INVALID_VALUES)
+def test_invalid_values_on_transactions(src_value):
+ txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
+ ('Assets:Cash', 25),
+ ('Income:Other', -25),
+ ])
+ checker = meta_income_type.MetaIncomeType()
+ errors = list(checker.run(txn))
+ assert errors
+ testutil.check_post_meta(txn, None, None)
+
+@pytest.mark.parametrize('account', [
+ 'Accrued:AccountsReceivable',
+ 'Assets:Cash',
+ 'Expenses:General',
+ 'Liabilities:CreditCard',
+])
+def test_non_income_accounts_skipped(account):
+ meta = {TEST_KEY: 'RBI'}
+ txn = testutil.Transaction(postings=[
+ (account, 25),
+ ('Income:Other', -25, meta.copy()),
+ ])
+ checker = meta_income_type.MetaIncomeType()
+ errors = list(checker.run(txn))
+ assert not errors
+ testutil.check_post_meta(txn, None, meta)
+
+@pytest.mark.parametrize('account,set_value', [
+ ('Income:Donations', 'Donations'),
+ ('Income:Honoraria', 'RBI'),
+ ('Income:Interest', 'RBI'),
+ ('Income:Interest:Dividend', 'RBI'),
+ ('Income:Royalties', 'RBI'),
+ ('Income:Sales', 'RBI'),
+ ('Income:SoftwareDevelopment', 'RBI'),
+ ('Income:TrademarkLicensing', 'RBI'),
+ ('UnearnedIncome:Conferences:Registrations', 'RBI'),
+ ('UnearnedIncome:MatchPledges', 'Donations'),
+])
+def test_default_values(account, set_value):
+ txn = testutil.Transaction(postings=[
+ ('Assets:Cash', 25),
+ (account, -25),
+ ])
+ checker = meta_income_type.MetaIncomeType()
+ errors = list(checker.run(txn))
+ assert not errors
+ testutil.check_post_meta(txn, None, {TEST_KEY: set_value})
+
+@pytest.mark.parametrize('account', [
+ 'Income:Other',
+])
+def test_no_default_value(account):
+ txn = testutil.Transaction(postings=[
+ ('Assets:Cash', 25),
+ (account, -25),
+ ])
+ checker = meta_income_type.MetaIncomeType()
+ errors = list(checker.run(txn))
+ assert errors
+ testutil.check_post_meta(txn, None, None)
+
+@pytest.mark.parametrize('date,set_value', [
+ (testutil.EXTREME_FUTURE_DATE, None),
+ (testutil.FUTURE_DATE, 'Donations'),
+ (testutil.FY_START_DATE, 'Donations'),
+ (testutil.FY_MID_DATE, 'Donations'),
+ (testutil.PAST_DATE, None),
+])
+def test_default_value_set_in_date_range(date, set_value):
+ txn = testutil.Transaction(date=date, postings=[
+ ('Assets:Cash', 25),
+ ('Income:Donations', -25),
+ ])
+ checker = meta_income_type.MetaIncomeType()
+ errors = list(checker.run(txn))
+ assert not errors
+ expect_meta = None if set_value is None else {TEST_KEY: set_value}
+ testutil.check_post_meta(txn, None, expect_meta)