Repository: beancount/fava

Issue:
Fiscal Year cannot end in the next year of Calendar Year
In my country (Japan), FY2023 starts at 2023-04-01 and ends at 2024-03-31, but Fava option cannot describe this.
`custom "fava-option" "fiscal-year-end" "03-31"` defines FY2023 as `[2022-04-01, 2023-04-01)`, and this is not my intention.

Discussion:

Patch:
diff --git a/src/fava/help/options.md b/src/fava/help/options.md
index 0c716bdf..22361dce 100644
--- a/src/fava/help/options.md
+++ b/src/fava/help/options.md
@@ -78,13 +78,15 @@ Default: `12-31`
 The last day of the fiscal (financial or tax) period for accounting purposes in
 `%m-%d` format. Allows for the use of `FY2018`, `FY2018-Q3`, `fiscal_year` and
 `fiscal_quarter` in the time filter, and `FY2018` as the start date, end date,
-or both dates in a date range in the time filter.
+or both dates in a date range in the time filter. Month can be a value larger
+than `12` to have `FY2018` end in 2019 for example.
 
 Examples are:
 
-- `09-30` - US federal government
-- `06-30` - Australia / NZ
 - `04-05` - UK
+- `06-30` - Australia / NZ
+- `09-30` - US federal government
+- `15-31` - Japan
 
 See [Fiscal Year on WikiPedia](https://en.wikipedia.org/wiki/Fiscal_year) for
 more examples.
diff --git a/src/fava/util/date.py b/src/fava/util/date.py
index 8c63eef1..62c5791a 100644
--- a/src/fava/util/date.py
+++ b/src/fava/util/date.py
@@ -30,13 +30,13 @@ DAY_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
 WEEK_RE = re.compile(r"^(\d{4})-w(\d{2})$")
 
 # this matches a quarter like 2016-Q1 for the first quarter of 2016
-QUARTER_RE = re.compile(r"^(\d{4})-q(\d)$")
+QUARTER_RE = re.compile(r"^(\d{4})-q([1234])$")
 
 # this matches a financial year like FY2018 for the financial year ending 2018
 FY_RE = re.compile(r"^fy(\d{4})$")
 
 # this matches a quarter in a financial year like FY2018-Q2
-FY_QUARTER_RE = re.compile(r"^fy(\d{4})-q(\d)$")
+FY_QUARTER_RE = re.compile(r"^fy(\d{4})-q([1234])$")
 
 VARIABLE_RE = re.compile(
     r"\(?(fiscal_year|year|fiscal_quarter|quarter"
@@ -51,6 +51,32 @@ class FiscalYearEnd:
     month: int
     day: int
 
+    @property
+    def month_of_year(self) -> int:
+        """Actual month of the year."""
+        return (self.month - 1) % 12 + 1
+
+    @property
+    def year_offset(self) -> int:
+        """Number of years that this is offset into the future."""
+        return (self.month - 1) // 12
+
+    def has_quarters(self) -> bool:
+        """Whether this fiscal year end supports fiscal quarters."""
+        return (
+            datetime.date(2001, self.month_of_year, self.day) + ONE_DAY
+        ).day == 1
+
+
+class FyeHasNoQuartersError(ValueError):
+    """Only fiscal year that start on the first of a month have quarters."""
+
+    def __init__(self) -> None:
+        super().__init__(
+            "Cannot use fiscal quarter if fiscal year "
+            "does not start on first of the month"
+        )
+
 
 END_OF_YEAR = FiscalYearEnd(12, 31)
 
@@ -229,7 +255,7 @@ def local_today() -> datetime.date:
     return datetime.date.today()  # noqa: DTZ011
 
 
-def substitute(  # noqa: PLR0914
+def substitute(
     string: str,
     fye: FiscalYearEnd | None = None,
 ) -> str:
@@ -246,57 +272,44 @@ def substitute(  # noqa: PLR0914
     """
     # pylint: disable=too-many-locals
     today = local_today()
+    fye = fye or END_OF_YEAR
 
     for match in VARIABLE_RE.finditer(string):
         complete_match, interval, plusminus_, mod_ = match.group(0, 1, 2, 3)
         mod = int(mod_) if mod_ else 0
-        plusminus = 1 if plusminus_ == "+" else -1
+        offset = mod if plusminus_ == "+" else -mod
         if interval == "fiscal_year":
-            year = today.year
-            start, end = get_fiscal_period(year, fye)
-            if end and today >= end:
-                year += 1
-            year += plusminus * mod
-            string = string.replace(complete_match, f"FY{year}")
+            after_fye = (today.month, today.day) > (fye.month_of_year, fye.day)
+            year = today.year + (1 if after_fye else 0) - fye.year_offset
+            string = string.replace(complete_match, f"FY{year + offset}")
         if interval == "year":
-            year = today.year + plusminus * mod
-            string = string.replace(complete_match, str(year))
+            string = string.replace(complete_match, str(today.year + offset))
         if interval == "fiscal_quarter":
-            target = month_offset(today.replace(day=1), plusminus * mod * 3)
-            start, end = get_fiscal_period(target.year, fye)
-            if start and start.day != 1:
-                raise ValueError(
-                    "Cannot use fiscal_quarter if fiscal year "
-                    "does not start on first of the month",
-                )
-            if end and target >= end:
-                start = end
-            if start:
-                quarter = int(((target.month - start.month) % 12) / 3)
-                string = string.replace(
-                    complete_match,
-                    f"FY{start.year + 1}-Q{(quarter % 4) + 1}",
-                )
+            if not fye.has_quarters():
+                raise FyeHasNoQuartersError
+            target = month_offset(today.replace(day=1), offset * 3)
+            after_fye = (target.month) > (fye.month_of_year)
+            year = target.year + (1 if after_fye else 0) - fye.year_offset
+            quarter = ((target.month - fye.month_of_year - 1) // 3) % 4 + 1
+            string = string.replace(complete_match, f"FY{year}-Q{quarter}")
         if interval == "quarter":
             quarter_today = (today.month - 1) // 3 + 1
-            year = today.year + (quarter_today + plusminus * mod - 1) // 4
-            quarter = (quarter_today + plusminus * mod - 1) % 4 + 1
+            year = today.year + (quarter_today + offset - 1) // 4
+            quarter = (quarter_today + offset - 1) % 4 + 1
             string = string.replace(complete_match, f"{year}-Q{quarter}")
         if interval == "month":
-            year = today.year + (today.month + plusminus * mod - 1) // 12
-            month = (today.month + plusminus * mod - 1) % 12 + 1
+            year = today.year + (today.month + offset - 1) // 12
+            month = (today.month + offset - 1) % 12 + 1
             string = string.replace(complete_match, f"{year}-{month:02}")
         if interval == "week":
-            delta = timedelta(plusminus * mod * 7)
             string = string.replace(
                 complete_match,
-                (today + delta).strftime("%Y-W%W"),
+                (today + timedelta(offset * 7)).strftime("%Y-W%W"),
             )
         if interval == "day":
-            delta = timedelta(plusminus * mod)
             string = string.replace(
                 complete_match,
-                (today + delta).isoformat(),
+                (today + timedelta(offset)).isoformat(),
             )
     return string
 
@@ -404,11 +417,16 @@ def parse_fye_string(fye: str) -> FiscalYearEnd | None:
     Args:
         fye: The end of the fiscal year to parse.
     """
+    match = re.match(r"^(?P<month>\d{2})-(?P<day>\d{2})$", fye)
+    if not match:
+        return None
+    month = int(match.group("month"))
+    day = int(match.group("day"))
     try:
-        date = datetime.date.fromisoformat(f"2001-{fye}")
+        _ = datetime.date(2001, (month - 1) % 12 + 1, day)
+        return FiscalYearEnd(month, day)
     except ValueError:
         return None
-    return FiscalYearEnd(date.month, date.day)
 
 
 def get_fiscal_period(
@@ -430,34 +448,27 @@ def get_fiscal_period(
         A tuple (start, end) of dates.
 
     """
-    if fye is None:
-        start_date = datetime.date(year=year, month=1, day=1)
-    else:
-        start_date = datetime.date(
-            year=year - 1,
-            month=fye.month,
-            day=fye.day,
-        ) + timedelta(days=1)
-        # Special case 02-28 because of leap years
-        if fye.month == 2 and fye.day == 28:
-            start_date = start_date.replace(month=3, day=1)
+    fye = fye or END_OF_YEAR
+    start = (
+        datetime.date(year - 1 + fye.year_offset, fye.month_of_year, fye.day)
+        + ONE_DAY
+    )
+    # Special case 02-28 because of leap years
+    if fye.month_of_year == 2 and fye.day == 28:
+        start = start.replace(month=3, day=1)
 
     if quarter is None:
-        return start_date, start_date.replace(year=start_date.year + 1)
+        return start, start.replace(year=start.year + 1)
 
-    if start_date.day != 1:
-        # quarters make no sense in jurisdictions where period starts
-        # on a date (UK etc)
+    if not fye.has_quarters():
         return None, None
 
     if quarter < 1 or quarter > 4:
         return None, None
 
-    if quarter > 1:
-        start_date = month_offset(start_date, (quarter - 1) * 3)
+    start = month_offset(start, (quarter - 1) * 3)
 
-    end_date = month_offset(start_date, 3)
-    return start_date, end_date
+    return start, month_offset(start, 3)
 
 
 def days_in_daterange(


Test patch:
diff --git a/tests/test_core_attributes.py b/tests/test_core_attributes.py
index d6e0fdbc..63aacd40 100644
--- a/tests/test_core_attributes.py
+++ b/tests/test_core_attributes.py
@@ -37,6 +37,11 @@ def test_get_active_years(load_doc_entries: list[Directive]) -> None:
         "FY2012",
         "FY2011",
     ]
+    assert get_active_years(load_doc_entries, FiscalYearEnd(15, 31)) == [
+        "FY2012",
+        "FY2011",
+        "FY2010",
+    ]
 
 
 def test_payee_accounts(example_ledger: FavaLedger) -> None:
diff --git a/tests/test_util_date.py b/tests/test_util_date.py
index d96d1564..23aeb2d4 100644
--- a/tests/test_util_date.py
+++ b/tests/test_util_date.py
@@ -179,6 +179,17 @@ def test_substitute(string: str, output: str) -> None:
         ("06-30", "2018-02-02", "fiscal_quarter", "FY2018-Q3"),
         ("06-30", "2018-07-03", "fiscal_quarter-1", "FY2018-Q4"),
         ("06-30", "2018-07-03", "fiscal_quarter+6", "FY2020-Q3"),
+        ("15-31", "2018-02-02", "fiscal_year", "FY2017"),
+        ("15-31", "2018-05-02", "fiscal_year", "FY2018"),
+        ("15-31", "2018-05-02", "fiscal_year-1", "FY2017"),
+        ("15-31", "2018-02-02", "fiscal_year+6", "FY2023"),
+        ("15-31", "2018-05-02", "fiscal_year+6", "FY2024"),
+        ("15-31", "2018-02-02", "fiscal_quarter", "FY2017-Q4"),
+        ("15-31", "2018-05-02", "fiscal_quarter", "FY2018-Q1"),
+        ("15-31", "2018-08-02", "fiscal_quarter", "FY2018-Q2"),
+        ("15-31", "2018-11-02", "fiscal_quarter", "FY2018-Q3"),
+        ("15-31", "2018-05-02", "fiscal_quarter-1", "FY2017-Q4"),
+        ("15-31", "2018-05-02", "fiscal_quarter+6", "FY2019-Q3"),
         ("04-05", "2018-07-03", "fiscal_quarter", None),
     ],
 )
@@ -195,7 +206,7 @@ def test_fiscal_substitute(
         if output is None:
             with pytest.raises(
                 ValueError,
-                match="Cannot use fiscal_quarter if fiscal year",
+                match="Cannot use fiscal quarter if fiscal year",
             ):
                 substitute(string, fye)
         else:
@@ -329,6 +340,10 @@ def test_month_offset(
         # 28th February - consider leap years [FYE=02-28]
         (2016, None, "02-28", "2015-03-01", "2016-03-01"),
         (2017, None, "02-28", "2016-03-01", "2017-03-01"),
+        # 1st Apr (last year) - JP [FYE=15-31]
+        (2018, None, "15-31", "2018-04-01", "2019-04-01"),
+        (2018, 1, "15-31", "2018-04-01", "2018-07-01"),
+        (2018, 4, "15-31", "2019-01-01", "2019-04-01"),
         # None
         (2018, None, None, "2018-01-01", "2019-01-01"),
         # expected errors
@@ -355,6 +370,7 @@ def test_get_fiscal_period(
         ("12-31", 12, 31),
         ("06-30", 6, 30),
         ("02-28", 2, 28),
+        ("15-31", 15, 31),
     ],
 )
 def test_parse_fye_string(fye_str: str, month: int, day: int) -> None: