Snapshot: full project state
This commit is contained in:
82
analytics.py
82
analytics.py
@@ -1,44 +1,58 @@
|
||||
"""Subscriber churn analyzer -- computes recoverable-MRR metrics only."""
|
||||
"""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 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
|
||||
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)
|
||||
if end is None or end > datetime.now(timezone.utc):
|
||||
return 0.0
|
||||
return max(0.0, float(sub.price_monthly_usd))
|
||||
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 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 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 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,
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
# 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 = ...
|
||||
def classify_band(sub):
|
||||
mins = minutes_until_renewal(sub)
|
||||
if mins is not None and mins < ...
|
||||
|
||||
Reference in New Issue
Block a user