baseline: fragment scaffolding only (config/models/analytics stubs)

This commit is contained in:
drjones agent
2026-09-20 00:00:00 -07:00
commit 5f8e87bab8
8 changed files with 272 additions and 0 deletions

78
models.py Normal file
View File

@@ -0,0 +1,78 @@
"""Data model for Recovery Helio subscriptions."""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
@dataclass
class Subscription:
"""One subscriber watched for recoverable churn."""
id: str = ""
customer: str = ""
email: str = ""
plan_name: str = ""
price_monthly_usd: float = 0.0
state: str = "tracked" # tracked -> pending -> critical -> recovered
next_due_iso: str = "" # ISO-8601 trial/renewal expiry
last_seen_iso: str = "" # ISO-8601 last successful probe tick
def new_subscription(*, customer="", email="", plan="Starter") -> Subscription:
return Subscription(
id=uuid.uuid4().hex[:12],
customer=customer,
email=email,
plan_name=plan,
price_monthly_usd=9.0,
)
_DAYS = 86400.0
_HOURS = 3600.0
_MINUTES = 60.0
def _parse_dt(value):
if not value or isinstance(value, bool):
return None
try:
dt = datetime.fromisoformat(str(value).strip())
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
except Exception:
pass
raw = str(value).strip()
for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
try:
dt = datetime.strptime(raw, fmt)
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
except ValueError:
continue
return None
def minutes_until_renewal(sub, now=None) -> float | None:
end = _parse_dt(sub.next_due_iso)
if end is None:
return None
when = now or datetime.now(timezone.utc)
return (end - when).total_seconds() / _MINUTES
def fresh_days_after_due(sub, clock=None) -> float | None:
end = _parse_dt(sub.next_due_iso)
if end is None:
return None
c = clock or datetime.now(timezone.utc)
seconds_since_due = max(0.0, (c - end).total_seconds())
return round(seconds_since_due / _DAYS, 2)
def hours_unseen(sub, clock=None) -> float | None:
ts = _parse_dt(sub.last_seen_iso)
if ts is None:
return None
c = clock or datetime.now(timezone.utc)
return max(0.0, (c - ts).total_seconds() / _HOURS)