59 lines
1.8 KiB
Python
59 lines
1.8 KiB
Python
"""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 < ...
|