from .dates_add_days import days_in_month, format_iso_date, parse_iso_date, CivilDate from .dates_month_boundaries_types import MonthBoundaries def month_boundaries(iso: str) -> MonthBoundaries: """The first and last day of the month containing a date, and its length. All three are returned together because a caller needs them together - a billing period, a statement range, a pro-rata fraction - and because deriving ``end`` from ``start`` in caller code is where "31 January plus one month" bugs come from. ``end`` is the last day of the month, inclusive, not the 1st of the next month. Inclusive is what a statement prints and what a human checks against. """ date = parse_iso_date(iso) # February is the only month whose length is not a constant, and it follows # the full Gregorian rule: 2024 had 29 days, 1900 had 28 (a century year), # 2000 had 29 (divisible by 400). days = days_in_month(date.year, date.month) return MonthBoundaries( start=format_iso_date(CivilDate(date.year, date.month, 1)), end=format_iso_date(CivilDate(date.year, date.month, days)), days=days, ) def is_month_end(iso: str) -> bool: """True when the date is the last day of its month - a common billing trigger.""" date = parse_iso_date(iso) return date.day == days_in_month(date.year, date.month)