diff --git a/conservancy_beancount/books.py b/conservancy_beancount/books.py
new file mode 100644
index 0000000..300bfac
--- /dev/null
+++ b/conservancy_beancount/books.py
@@ -0,0 +1,74 @@
+"""books - Tools for loading 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 .
+
+import datetime
+
+from typing import (
+ Iterable,
+ NamedTuple,
+ Optional,
+ Union,
+)
+
+class FiscalYear(NamedTuple):
+ month: int = 3
+ day: int = 1
+
+ def for_date(self, date: Optional[datetime.date]=None) -> int:
+ if date is None:
+ date = datetime.date.today()
+ if (date.month, date.day) < self:
+ return date.year - 1
+ else:
+ return date.year
+
+ def range(self,
+ from_fy: Union[int, datetime.date],
+ to_fy: Union[int, datetime.date, None]=None,
+ ) -> Iterable[int]:
+ """Return a range of fiscal years
+
+ Both arguments can be either a year (represented as an integer) or a
+ date. Dates will be converted into a year by calling for_date() on
+ them.
+
+ If the first argument is negative or below 1000, it will be treated as
+ an offset. You'll get a range of fiscal years between the second
+ argument offset by this amount.
+
+ If the second argument is omitted, it defaults to the current fiscal
+ year.
+
+ Note that unlike normal Python ranges, these ranges include the final
+ fiscal year.
+
+ Examples:
+
+ range(2015) # Iterate all fiscal years from 2015 to today, inclusive
+
+ range(-1) # Iterate the previous fiscal year and current fiscal year
+ """
+ if not isinstance(from_fy, int):
+ from_fy = self.for_date(from_fy)
+ if to_fy is None:
+ to_fy = self.for_date()
+ elif not isinstance(to_fy, int):
+ to_fy = self.for_date(to_fy - datetime.timedelta(days=1))
+ if from_fy < 1:
+ from_fy += to_fy
+ elif from_fy < 1000:
+ from_fy, to_fy = to_fy, from_fy + to_fy
+ return range(from_fy, to_fy + 1)
diff --git a/tests/test_books_fiscal_year.py b/tests/test_books_fiscal_year.py
new file mode 100644
index 0000000..9327ab0
--- /dev/null
+++ b/tests/test_books_fiscal_year.py
@@ -0,0 +1,136 @@
+"""test_books_fiscal_year - Unit tests for books.FiscalYear"""
+# 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 itertools
+
+import pytest
+
+from conservancy_beancount import books
+
+@pytest.fixture(scope='module')
+def conservancy_fy():
+ return books.FiscalYear(3, 1)
+
+@pytest.fixture(scope='module')
+def cy_fy():
+ return books.FiscalYear(1, 1)
+
+# Not bothering because it's too much trouble to override __new__
+# @pytest.mark.parametrize('month,day', [
+# (0, 0),
+# (2, 29),
+# (4, 31),
+# (13, 15),
+# (-1, 1),
+# (1, -5),
+# ])
+# def test_values_checked(month, day):
+# with pytest.raises(ValueError):
+# books.FiscalYear(month, day)
+
+def test_attribute_access():
+ fy = books.FiscalYear(2, 15)
+ assert fy.month == 2
+ assert fy.day == 15
+ with pytest.raises(AttributeError):
+ fy.year
+
+@pytest.mark.parametrize('month,day,expected', [
+ (1, 1, 2019),
+ (2, 15, 2019),
+ (2, 28, 2019),
+ (2, 29, 2019),
+ (3, 1, 2020),
+ (3, 9, 2020),
+ (6, 1, 2020),
+ (12, 31, 2020),
+])
+def test_for_date(conservancy_fy, month, day, expected):
+ date = datetime.date(2020, month, day)
+ assert conservancy_fy.for_date(date) == expected
+
+def test_for_date_default_today(cy_fy):
+ assert cy_fy.for_date() == datetime.date.today().year
+
+@pytest.mark.parametrize('begin_date,end_date,expected', [
+ ((2020, 3, 1), (2021, 2, 28), [2020]),
+ ((2020, 1, 1), (2020, 3, 1), [2019]),
+ ((2020, 1, 1), (2020, 3, 5), [2019, 2020]),
+ ((2019, 2, 1), (2020, 6, 1), [2018, 2019, 2020]),
+])
+def test_range_two_dates(conservancy_fy, begin_date, end_date, expected):
+ actual = list(conservancy_fy.range(datetime.date(*begin_date), datetime.date(*end_date)))
+ assert actual == expected
+
+@pytest.mark.parametrize('year_offset', range(3))
+def test_range_one_date(cy_fy, year_offset):
+ this_year = datetime.date.today().year
+ actual = list(cy_fy.range(datetime.date(this_year - year_offset, 1, 1)))
+ assert actual == list(range(this_year - year_offset, this_year + 1))
+
+@pytest.mark.parametrize('begin_year,end_year', [
+ (2006, 2020),
+ (2019, 2020),
+ (2020, 2020),
+])
+def test_range_two_years(conservancy_fy, begin_year, end_year):
+ actual = list(conservancy_fy.range(begin_year, end_year))
+ assert actual == list(range(begin_year, end_year + 1))
+
+@pytest.mark.parametrize('year_offset', range(3))
+def test_range_one_year(cy_fy, year_offset):
+ this_year = datetime.date.today().year
+ actual = list(cy_fy.range(this_year - year_offset))
+ assert actual == list(range(this_year - year_offset, this_year + 1))
+
+@pytest.mark.parametrize('year_offset,month_offset', itertools.product(
+ range(-3, 3),
+ [-1, 1]
+))
+def test_range_offset_and_date(conservancy_fy, year_offset, month_offset):
+ end_date = datetime.date(2020, conservancy_fy.month + month_offset, 10)
+ base_year = end_date.year
+ if month_offset < 0:
+ base_year -= 1
+ if year_offset < 0:
+ expected = range(base_year + year_offset, base_year + 1)
+ else:
+ expected = range(base_year, base_year + year_offset + 1)
+ actual = list(conservancy_fy.range(year_offset, end_date))
+ assert actual == list(expected)
+
+@pytest.mark.parametrize('year_offset,year', itertools.product(
+ range(-3, 3),
+ [2010, 2015],
+))
+def test_range_offset_and_year(conservancy_fy, year_offset, year):
+ if year_offset < 0:
+ expected = range(year + year_offset, year + 1)
+ else:
+ expected = range(year, year + year_offset + 1)
+ actual = list(conservancy_fy.range(year_offset, year))
+ assert actual == list(expected)
+
+@pytest.mark.parametrize('year_offset', range(-3, 3))
+def test_range_offset_only(cy_fy, year_offset):
+ year = datetime.date.today().year
+ if year_offset < 0:
+ expected = range(year + year_offset, year + 1)
+ else:
+ expected = range(year, year + year_offset + 1)
+ actual = list(cy_fy.range(year_offset))
+ assert actual == list(expected)