baseline: fragment scaffolding only (config/models/analytics stubs)
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
data/*.db-journal
|
||||
.env
|
||||
data/state.snapshot.tmp
|
||||
21
MIT LICENSE
Normal file
21
MIT LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 drjones
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
63
README.md
Normal file
63
README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Recovery Helio — Application Documentation & Deployment Runbook
|
||||
|
||||
## What This App Is
|
||||
Star / theme: **Recovery Helios**. Churn copilot for tiny subscription paywalls. Watches each subscription sign its trial-end or renewal-peak window and runs fastest-recovering nudges *before* money bleeds into silent churn nobody noticed: a friendly re-engagement ping → then a discount-only retrial. Flags ~at-risk MRR near loss time and recovers programmatically (discount-only retrial only — NO unverified auto-charges). Agent-native UI. Metering settles over Bitcoin / BTCPay (**store_id: startapp** = 10.30.20.140). Works PAST Shopify (competitors locked platform-native here).
|
||||
|
||||
Core promise vs competitors (captured raw in raw-research-20260920.md Phase-1): one flat $19 Flask copilot for <500 subs that BOTH flags at-risk AND fires fastest-recovering nudge/offer out of the box (vs two-week Stripe/Braintree wiring + weekly friction).
|
||||
|
||||
### Free tier boundary (must not leak payment keys on free; retries need explicit opt-in)
|
||||
Free tier must not leak payment keys nor enable unverified auto-charges. All paid-tier retries require explicit customer opt-in (e.g. Continue-with-Google flow for IG/FB-like signups). BTC settlement ONLY via BTCPay; no gray-area token-driven charges beyond legit Stripe parallel option approved as policy.
|
||||
|
||||
## Pricing
|
||||
$19/mo up to 500 tracked subscriptions · $49/mo unlimited (free tier for <150 subs — gated).
|
||||
|
||||
## Tech Stack — Local (dev)
|
||||
| | Spec | Notes |
|
||||
|---|---|---|
|
||||
| Language | Python 3.8+ | `requirements.txt` declares deps; never `pip install -r requirements.txt --break-system-packages --force-reinstall` |
|
||||
| WSGI server | Werkzeug dev server (`python recovery_helio.py` / `Werkzeug.serving.run_simple`) | Debuggers only behind trusted env var; production gates disabled except tests |
|
||||
| Test framework | pytest (or `unittest` stdlib fallback) under `tests/` | `pytest --db-path=/tmp/reh_db.sqlite` example |
|
||||
| Config loader | `config.py` reads from disk, no inline flag soup | keep creds OUT of git / `.gitignore` lists them |
|
||||
| Git init tool | any GIT client version ≥ x.y.z installed system-wide (PATH lookup first, else home tools dir), NOT an inline `--`/`-e` bash heredoc piped straight into bash `-c` | prefer absolute local paths & `git` CLI commits, not `git add/commit/push` inline edits by LLM (policy rule) |
|
||||
|
||||
Runtime repo layout after this session's self-heal:
|
||||
```
|
||||
~/RecoveryHelio/README.md . git init -> bare clone at /home/ubuntu/repos/recovery-helio-app.git/.git
|
||||
~/RecoveryHelio/src/ app source files -> staging commit -> push to Gitea @ drjones@10.30.20.149:3000 (SSH) then deploy copy
|
||||
~/RecoveryHelio/tests/ runtime test suite (independent of web layer)
|
||||
~/RecoveryHelio/config.py
|
||||
~/RecoveryHelio/analytics.py
|
||||
~/RecoveryHelio/models.py
|
||||
~/RecoveryHelio/rules.py # validation rules (retrial/thresholds, thresholds for churn signals like trial-end/renewal-time-left)
|
||||
```
|
||||
|
||||
## Deployment (Production) Environment
|
||||
- Container image name: Docker registry `ghcr.io/drgn/recoveryhelios:vX.Y.Z`. Build with explicit `FROM`, pin pinned version tags (never latest/main).
|
||||
- Container id pattern: `recovery_helio_app_XXX` (XXX short uuid suffix for uniqueness per host).
|
||||
- Internal port: **9652**. Public: Cloudflare tunnel mapped to `.startapp.thetempleofdoom.com` → forwarded to CT internal IP + :9652.
|
||||
- Docker entrypoint CMD: docker-entrypoint shell script `/usr/local/bin/docker-entryposhyft.sh`? No — real entry cmd runs the containerized `src/recovery_helios` service on port 9652 and stays up until container stop/start via systemd unit **helios-retryal.service** or Docker compose (docker-compose.yml) with a fixed named volume (NOT anonymous volumes).
|
||||
- Container resource limits enforced in resources: max memory 1G, CPU share capped to avoid starving neighbors; healthcheck probe = HTTP call to local :9652 (no user interaction required for retryal flow).
|
||||
- Persistence: SQLite at `/data/.db` inside anonymous-vol-free persistent data dir (bind-mount from local storage path); never write DB secrets or credentials there. Never store plaintext PII.
|
||||
- Secrets management: env vars only (`GET_ENV_FROM_SECRETSTORE=true` style), no hardcoded password/token defaults in committed config.
|
||||
|
||||
### Health checks (self-service, no operator babysitting)
|
||||
Probe endpoints used by systemd/Docker HEALTHCHECK every so often:
|
||||
| Method+Path | Purpose | Auth expected | Allowed origin | Rate/IP | Max bytes |
|
||||
|---|---|---|---|---|---|
|
||||
| GET /api/v1/health | Service-level liveness probe (DB connected, last reconcile ≤ Nmin) | None | Public | generous but sane | minimal JSON |
|
||||
| POST /register | Register subscriber/trial window into store DB | session cookie ONLY | logged-in subscribers | one-per-subscriber | small |
|
||||
| GET /login | Return HTML login form | session cookie | subscribers | n/a | moderate |
|
||||
| GET /createinvoice | Fetch/create invoice for given plan | session cookie | subscribers | one-per-order-intent | moderate |
|
||||
| POST /webhook/btcpay | Accept BTCPay webhook (store :80), update subscription payment state | BTCPay server internal auth (server-side secret; NOT shared with public API clients) | private BTCPay servers only | throttled per webhook client | large allowed (full webhook payload) |
|
||||
|
||||
**Critical rule:** Always run a full end-to-end payment-purchase test after any change before claiming it works (invoice→payment→webhook→order complete→code delivered), not just invoice creation alone.
|
||||
|
||||
## Key Endpoints (HTTP)
|
||||
| Path | Notes |
|
||||
|------|-------|
|
||||
| `/api/v1/health` | Liveness |
|
||||
| `/register` | Session-only, register trial window |
|
||||
| `/login` | Returns HTML form |
|
||||
| `/createinvoice` | Invoice fetch/create (session) |
|
||||
| `/webhook/btcpay` | BTCPay → app payment-state sync (server-side) |
|
||||
|
||||
44
analytics.py
Normal file
44
analytics.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""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 = ...
|
||||
21
collector.py
Normal file
21
collector.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Liveness & reachability probes for Recovery Helio subscribers.
|
||||
|
||||
Each probe returns ``reached`` plus a short latency estimate -- nothing hard fails
|
||||
here, so the collector always keeps going through noisy subscriber endpoints."""
|
||||
|
||||
|
||||
def record_success(*, reached: bool, ms: int | None = None) -> dict:
|
||||
return {"reached": reached, "ms_estimated": max(0.0, float(ms) if isinstance(ms, (int, float)) else -1.0)}
|
||||
|
||||
|
||||
def summarize_hits(records):
|
||||
total = len(records) or 1
|
||||
hit = sum(1 for r in records if r.get("reached"))
|
||||
avg_ms = round(sum(float(r["ms_estimated"]) for r in records if r.get("reached") and
|
||||
isinstance(r["ms_estimated"], (int, float)) and r["ms_estimated"] >= 0.0) / total, 2)
|
||||
return {
|
||||
"probe_ticker": f"RHX-{total*7%5}", # compact ticker id for this collector tick
|
||||
"subscribers_total": total,
|
||||
"hits_now": hit,
|
||||
"avg_latency_ms": avg_ms,
|
||||
}
|
||||
35
config.py
Normal file
35
config.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Configuration loader for Recovery Helio."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def load_from_env(env=None):
|
||||
"""Read RECHELIO_* keys from an optional dict-like mapping (defaults if absent)."""
|
||||
store = env or {}
|
||||
|
||||
def _d(key="RECHELIO_", default=None):
|
||||
return store.get(...)
|
||||
|
||||
return {...
|
||||
|
||||
|
||||
class Config(...:
|
||||
|
||||
class ...():
|
||||
@classmethod
|
||||
def from_dict(cls, data):
|
||||
cfg = cls(...())
|
||||
return cfg
|
||||
|
||||
|
||||
class Settings(object)...
|
||||
|
||||
|
||||
|
||||
...
|
||||
|
||||
|
||||
.get(...)
|
||||
as_bool(...)
|
||||
|
||||
|
||||
|
||||
78
models.py
Normal file
78
models.py
Normal 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)
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Flask==3.0.3
|
||||
requests==2.32.3
|
||||
python-dotenv==1.0.1
|
||||
# No runtime DB driver required for v0.1.0 (uses SQLite-optional file cache),
|
||||
# keep deps minimal so `pip install` never fails on this host.
|
||||
Reference in New Issue
Block a user