"""Fiscal-year arithmetic — the single source of truth for "which months make up fiscal year N, and how far into it are we?". The org's fiscal year does NOT start in January. PingCAP's FY starts in **April** (FY2026 = 2026-04 … 2027-03), and the budget engine, run-rate forecast, and every "this fiscal year" aggregation MUST map a fiscal-year integer to that Apr→Mar window — not the calendar Jan→Dec window an ``int(period[:4])`` shortcut assumes. The start month is an admin-configurable platform setting (``finance.fiscal_year_start_month``, default 4) so a future legal entity on a different fiscal calendar can be onboarded without a code change. ``start_month=1`` degenerates to the calendar year, preserving the old behaviour for anyone who wants it. Design: the period math is a set of **pure** functions that take ``start_month`` explicitly so they stay trivially testable and free of a DB round-trip per call. Callers resolve the setting ONCE via :func:`get_fiscal_year_start_month` and thread the int through. Period strings are always ``"YYYY-MM"`` (the grain of ``finance_spend_facts.period``); month-only string comparison is lexical and width-stable, so ``lo <= period <= hi`` is correct across the calendar year boundary inside a single fiscal year. """ from __future__ import annotations from datetime import UTC, datetime from sqlalchemy.ext.asyncio import AsyncSession # Admin-configurable platform setting; absent → default to April. PLATFORM_SETTING_FY_START_MONTH = "finance.fiscal_year_start_month" DEFAULT_FY_START_MONTH = 4 def _coerce_start_month(value: object) -> int: """Coerce a stored setting value to a valid 1..12 month, else default. Never raises: a malformed setting must not take down every finance read. An out-of-band value silently falls back to the default so the platform keeps computing against a sane calendar. """ try: m = int(value) # type: ignore[arg-type] except (TypeError, ValueError): return DEFAULT_FY_START_MONTH if 1 <= m <= 12: return m return DEFAULT_FY_START_MONTH async def get_fiscal_year_start_month(db: AsyncSession) -> int: """Resolve the configured fiscal-year start month (1..12), default 4. Reads the ``finance.fiscal_year_start_month`` platform setting. Missing / malformed → :data:`DEFAULT_FY_START_MONTH`. Callers resolve this once and pass the int to the pure helpers below. """ from app.integrations.models import PlatformSetting row = await db.get(PlatformSetting, PLATFORM_SETTING_FY_START_MONTH) if row is None: return DEFAULT_FY_START_MONTH return _coerce_start_month(row.value) def _period(year: int, month: int) -> str: return f"{year:04d}-{month:02d}" def fy_bounds(fiscal_year: int, start_month: int) -> tuple[str, str]: """Return the inclusive ``(lo_period, hi_period)`` of a fiscal year. With ``start_month=4``: ``fy_bounds(2026, 4) == ("2026-04", "2027-03")``. With ``start_month=1``: ``fy_bounds(2026, 1) == ("2026-01", "2026-12")``. """ lo = _period(fiscal_year, start_month) if start_month == 1: return lo, _period(fiscal_year, 12) return lo, _period(fiscal_year + 1, start_month - 1) def periods_in_fy(fiscal_year: int, start_month: int) -> list[str]: """The 12 ordered ``YYYY-MM`` periods that make up the fiscal year.""" out: list[str] = [] y, m = fiscal_year, start_month for _ in range(12): out.append(_period(y, m)) m += 1 if m > 12: m = 1 y += 1 return out def fy_of_period(period: str, start_month: int) -> int: """Which fiscal year a ``YYYY-MM`` period belongs to. A period in a month before ``start_month`` rolls back into the prior fiscal year (e.g. with April start, ``2027-02`` is in FY2026). """ y, m = (int(p) for p in period.split("-")) if m >= start_month: return y return y - 1 def current_fiscal_year(start_month: int, now: datetime | None = None) -> int: """The fiscal year that contains 'now' (UTC).""" n = now or datetime.now(UTC) return fy_of_period(_period(n.year, n.month), start_month) def months_elapsed(fiscal_year: int, as_of_period: str, start_month: int) -> int: """How many months of ``fiscal_year`` have elapsed *through* a period. Counts the fiscal-year months from the start month up to and including ``as_of_period``. A period before the FY → 0; on/after the last month → 12. With April start, ``as_of=2026-04 → 1``, ``2026-06 → 3``, ``2027-03 → 12``. Used to YTD-prorate the budget and to annualise the run-rate forecast (``ytd_actual / elapsed * 12``). """ lo, hi = fy_bounds(fiscal_year, start_month) if as_of_period < lo: return 0 if as_of_period >= hi: return 12 ay, am = (int(p) for p in as_of_period.split("-")) # Month delta from the FY start; +1 because the start month itself is # the first elapsed month. delta = (ay - fiscal_year) * 12 + (am - start_month) return max(0, min(12, delta + 1)) def default_as_of_period( fiscal_year: int, start_month: int, now: datetime | None = None, ) -> str: """Default reporting period for a fiscal-year view. ``min(current period, FY-last-month)``. A past FY is fully elapsed (→ its last month); the current FY reports as of the current month (so the run-rate forecast is meaningful mid-year, not collapsed to the annual total); a future FY stays at the current month (→ 0 months elapsed). """ n = now or datetime.now(UTC) _, hi = fy_bounds(fiscal_year, start_month) cur = _period(n.year, n.month) return min(cur, hi) def fy_label(fiscal_year: int, start_month: int) -> str: """Human label, e.g. ``"FY27 (2026-04 ~ 2027-03)"``. **Named after the year the fiscal year ENDS in**, two digits — the way the company actually says it: the window 2026-04 ~ 2027-03 is "FY27", not "FY2026". Changed 2026-08-24 after the bounty budget page put "FY2026" next to a figure everyone in the room called FY27; one label formatter for the whole platform means finance and security cannot drift apart on this again. The stored ``fiscal_year`` integer is unchanged — it is still the START year (2026 for 2026-04 ~ 2027-03), which is what every row, FK and query already carries. Only the rendering moved, so there is no migration and no risk of re-bucketing historical money. A January start collapses to ``"FY26 (2026)"``: there the fiscal year equals the calendar year, so start and end year are the same. """ lo, hi = fy_bounds(fiscal_year, start_month) end_year = fiscal_year if start_month == 1 else fiscal_year + 1 short = f"FY{end_year % 100:02d}" if start_month == 1: return f"{short} ({fiscal_year})" return f"{short} ({lo} ~ {hi})" def fy_short_label(fiscal_year: int, start_month: int) -> str: """Just the name — ``"FY27"``. For pickers and table headers where the date range does not fit.""" end_year = fiscal_year if start_month == 1 else fiscal_year + 1 return f"FY{end_year % 100:02d}"