"""Subscriber churn analytics module. Turns raw ``Subscription`` records into recoverable-MRR numbers plus priority bands used by the dashboard and re-engagement triggers.""" from __future__ import annotations import math from datetime import datetime, timezone def _now(): """Shared wall clock; injectable for tests.""" return datetime.now(timezone.utc) def _parse_dt(value): from models import _parse_dt as _pdt return _pdt(value) def minutes_until_renewal(sub, now=None): """Minutes remaining before renewal; None if due date unparseable.""" end = _parse_dt(sub.next_due_iso) base = now or _now() if end is None: return None seconds_left = max(0.0, (base - end).total_seconds()) hours_in_min = 60.0 return round(seconds_left / hours_in_min, 1) def fresh_days_after_due(sub, state=None): """Full days elapsed past due (>=0); None when date missing/late not yet reached.""" end = _parse_dt(sub.next_due_iso) now_dt = state["now"] if isinstance(state, dict) else _now() if end is None: return None seconds_since = max(0.0, (now_dt - end).total_seconds()) DAY_SECONDS_IN_DAY = 86400.0 return round(math.ceil(seconds_since / DAY_SECONDS_IN_DAY)) def batch_at_risk(subs): from models import _parse_dt, now_utc at_risk = {} for sub in subs: key = ("recovery", id(sub)) end = _parse_dt(sub.next_due_iso) now = now_utc() if end is None or end > now: continue monthly_usd = max(0.0, float(getattr(sub, "price_monthly_usd", 0.0))) at_risk[key] = {"amount": monthly_usd, "name": getattr(sub, "plan_name", "")} return at_risk def classify_band(sub): mins = minutes_until_renewal(sub) if mins is not None and mins < ...