diff --git a/conservancy_beancount/plugin/__init__.py b/conservancy_beancount/plugin/__init__.py index 3aa9e9c1f7811bc0eef9f658aec4d359bd1686ba..9183c805745f81661d4f5779563bd3b5638827e5 100644 --- a/conservancy_beancount/plugin/__init__.py +++ b/conservancy_beancount/plugin/__init__.py @@ -67,6 +67,7 @@ class HookRegistry: '.meta_repo_links': None, '.meta_rt_links': ['MetaRTLinks'], '.meta_tax_implication': None, + '.txn_date': ['TransactionDate'], } def __init__(self) -> None: diff --git a/conservancy_beancount/plugin/txn_date.py b/conservancy_beancount/plugin/txn_date.py new file mode 100644 index 0000000000000000000000000000000000000000..dee5e00c385cc2241dba6c90d8c9d97e2d31b1ef --- /dev/null +++ b/conservancy_beancount/plugin/txn_date.py @@ -0,0 +1,49 @@ +"""txn_date.py - Validate transactions are entered in the right file by date""" +# 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 ..beancount_types import ( + Transaction, +) + +from . import core +from .. import config as configmod +from .. import errors as errormod +from .. import ranges + +class TransactionDate(core.TransactionHook): + def __init__(self, config: configmod.Config) -> None: + books_path = config.books_path() + if books_path is None: + raise errormod.ConfigurationError( + "books dir setting is required to check transaction dates", + ) + books_pat = re.escape(str(books_path)) + self.filename_re = re.compile(rf'^{books_pat}/(\d{{4,}})\.beancount$') + self.fy = config.fiscal_year_begin() + + def run(self, txn: Transaction) -> errormod.Iter: + match = self.filename_re.fullmatch(txn.meta.get('filename', '')) + if match is None: + return + file_fy = int(match.group(1)) + txn_fy = self.fy.for_date(txn.date) + if file_fy != txn_fy: + yield errormod.Error( + f"transaction dated in FY{txn_fy} entered in FY{file_fy} books", + txn, + ) diff --git a/setup.py b/setup.py index e16c8a1af62e4e3e6465ee6a199cfcdced911880..12965047eea23ac2d892fee4d5a6c6da280543d4 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup setup( name='conservancy_beancount', description="Plugin, library, and reports for reading Conservancy's books", - version='1.10.0', + version='1.11.0', author='Software Freedom Conservancy', author_email='info@sfconservancy.org', license='GNU AGPLv3+', diff --git a/tests/test_plugin_txn_date.py b/tests/test_plugin_txn_date.py new file mode 100644 index 0000000000000000000000000000000000000000..8c1c056457cf77d675e5f775942db1d64e6b47f5 --- /dev/null +++ b/tests/test_plugin_txn_date.py @@ -0,0 +1,79 @@ +"""test_txn_date.py - Unit tests for transaction date validation""" +# 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 datetime import date + +import pytest + +from . import testutil + +from conservancy_beancount import config as configmod +from conservancy_beancount import errors as errormod +from conservancy_beancount.plugin import txn_date as hookmod + +BOOKS_PATH = testutil.test_path('books') +CONFIG = testutil.TestConfig(books_path=BOOKS_PATH) +HOOK = hookmod.TransactionDate(CONFIG) + +@pytest.mark.parametrize('txn_date,fyear', [ + (date(2016, 1, 1), 2015), + (date(2016, 2, 29), 2015), + (date(2016, 3, 1), 2016), + (date(2016, 12, 31), 2016), + (date(2017, 2, 28), 2016), + (date(2017, 3, 1), 2017), +]) +def test_good_txn(txn_date, fyear): + filename = str(BOOKS_PATH / f'{fyear}.beancount') + txn = testutil.Transaction(date=txn_date, filename=filename, postings=[ + ('Assets:Cash', 5), + ('Income:Donations', -5), + ]) + assert not list(HOOK.run(txn)) + +@pytest.mark.parametrize('txn_date,fyear', [ + (date(2018, 1, 1), 2017), + (date(2018, 12, 31), 2018), + (date(2019, 3, 1), 2019), +]) +def test_bad_txn(txn_date, fyear): + filename = str(BOOKS_PATH / '2020.beancount') + txn = testutil.Transaction(date=txn_date, filename=filename, postings=[ + ('Assets:Cash', 5), + ('Income:Donations', -5), + ]) + errors = list(HOOK.run(txn)) + assert len(errors) == 1 + assert errors[0].message == f"transaction dated in FY{fyear} entered in FY2020 books" + +@pytest.mark.parametrize('path_s', [ + 'books/2020.beancount', + 'historical/2020.beancount', + 'definitions.beancount', +]) +def test_outer_transactions_not_checked(path_s): + txn_date = date(1900, 6, 15) + filename = str(BOOKS_PATH / path_s) + txn = testutil.Transaction(date=txn_date, filename=filename, postings=[ + ('Assets:Cash', 5), + ('Income:Donations', -5), + ]) + assert not list(HOOK.run(txn)) + +def test_error_without_books_path(): + config = configmod.Config() + with pytest.raises(errormod.ConfigurationError): + hookmod.TransactionDate(config) diff --git a/tests/testutil.py b/tests/testutil.py index def907546f82095bf0f4d7bedc94392732cbde31..298c40e64655c4d997e32c095005e94b93282103 100644 --- a/tests/testutil.py +++ b/tests/testutil.py @@ -255,10 +255,7 @@ class TestConfig: repo_path=None, rt_client=None, ): - if books_path is None: - self._books_loader = None - else: - self._books_loader = TestBooksLoader(books_path) + self._books_path = books_path self.fiscal_year = fiscal_year self._payment_threshold = Decimal(payment_threshold) self.repo_path = test_path(repo_path) @@ -269,7 +266,13 @@ class TestConfig: self._rt_wrapper = rtutil.RT(rt_client) def books_loader(self): - return self._books_loader + if self._books_path is None: + return None + else: + return TestBooksLoader(self._books_path) + + def books_path(self): + return self._books_path def books_repo(self): return None