diff --git a/conservancy_beancount/plugin/__init__.py b/conservancy_beancount/plugin/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/conservancy_beancount/plugin/core.py b/conservancy_beancount/plugin/core.py new file mode 100644 index 0000000000000000000000000000000000000000..7ff940eebe6043de1aa47c5d735b73a9ea2b617b --- /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 0000000000000000000000000000000000000000..59b852591fa698bb07cb31a61eb34bd0d7ee3346 --- /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 0000000000000000000000000000000000000000..68e2d9e67378434261294668f520b3cdeace4d86 --- /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 0000000000000000000000000000000000000000..b5edd5d6fd715088bb34adf16e690885d3e32957 --- /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 0000000000000000000000000000000000000000..47368a38726592fca11b1ad98976e3a9afff9013 --- /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)