From 5f8e87bab8ef755f7473fca69e231507ecd38235 Mon Sep 17 00:00:00 2001 From: drjones agent Date: Sun, 20 Sep 2026 00:00:00 -0700 Subject: [PATCH] baseline: fragment scaffolding only (config/models/analytics stubs) --- .gitignore | 5 ++++ MIT LICENSE | 21 +++++++++++++ README.md | 63 ++++++++++++++++++++++++++++++++++++++ analytics.py | 44 +++++++++++++++++++++++++++ collector.py | 21 +++++++++++++ config.py | 35 ++++++++++++++++++++++ models.py | 78 ++++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 5 ++++ 8 files changed, 272 insertions(+) create mode 100644 .gitignore create mode 100644 MIT LICENSE create mode 100644 README.md create mode 100644 analytics.py create mode 100644 collector.py create mode 100644 config.py create mode 100644 models.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df77a63 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.py[cod] +data/*.db-journal +.env +data/state.snapshot.tmp diff --git a/MIT LICENSE b/MIT LICENSE new file mode 100644 index 0000000..2a5af3f --- /dev/null +++ b/MIT LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a65d762 --- /dev/null +++ b/README.md @@ -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) | + diff --git a/analytics.py b/analytics.py new file mode 100644 index 0000000..dc770a3 --- /dev/null +++ b/analytics.py @@ -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 = ... diff --git a/collector.py b/collector.py new file mode 100644 index 0000000..4efe770 --- /dev/null +++ b/collector.py @@ -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, + } diff --git a/config.py b/config.py new file mode 100644 index 0000000..55bc91f --- /dev/null +++ b/config.py @@ -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(...) + + + diff --git a/models.py b/models.py new file mode 100644 index 0000000..a5670ca --- /dev/null +++ b/models.py @@ -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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4e02988 --- /dev/null +++ b/requirements.txt @@ -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.