diff --git a/conservancy_beancount/plugin/__init__.py b/conservancy_beancount/plugin/__init__.py
index 3aa9e9c..9183c80 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 0000000..dee5e00
--- /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 e16c8a1..1296504 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 0000000..8c1c056
--- /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 def9075..298c40e 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