"""Subscriber churn analyzer -- computes recoverable-MRR metrics only.""" from __future__ import annotations def recoverable_at_risk_mrr(sub): """MRR on track to be lost when due but never paid -- zero if still current.""" from models import _parse_dt # local parser keeps analytics decoupled end = _parse_dt(sub.next_due_iso) if end is None or end > datetime.now(timezone.utc): return 0.0 return max(0.0, float(sub.price_monthly_usd)) def priority_band(sub): """'urgent'/'soon'/'quiet' tells Helio how urgently to nudge this account.""" mins = minutes_until_renewal(sub, now=__datetime_)() fresh_days_after_due = fresh_days_after_due(sub) band = "urgent" if (fresh_days_after_due is not None and fresh_days_after_due <= 12) else ("soon" if mins <= _DUE_SOON_WINDOW_HOURS * _MINUTES else "quiet") return band def cohort_summary(records, clock=None): from models import _now_utc as utc_now now = clock or utc_now() at_risk_total = round(sum(recoverable_at_risk_mrr(r) for r in records), 2) total_mrr = round(sum(max(0.0, float(r.price_monthly_usd)) for r in records), 2) urgent_count = sum(1 for r in records if minutes_until_renewal(r, now=now) is None and fresh_days_after_due(r, clock=now) <= 12) return { "at_risk_mrr_usd": at_risk_total, "total_trackable_mrr_usd": total_mrr, "record_count": len(records), "urgent_accounts": urgent_count, } # constants used by the helpers above _SECONDS_PER_DAY, _SECONDS_PER_HOUR, _SECONDS_PER_MINUTE = 86400.0, 3600.0, 60.0 _DAYS, _HOURS, _MINUTES = _SECONDS_PER_DAY, _SECONDS_PER_HOUR, _SECONDS_PER_MINUTE _DUE_SOON_WINDOW_HOURS = 504.0 def analyze_subscribers(records: list, *, run_state=None): run_run = getattr(run_state, "now", None) or __datetime_.now() summary = ...