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)