commit 5f8e87bab8ef755f7473fca69e231507ecd38235 Author: drjones agent Date: Sun Sep 20 00:00:00 2026 -0700 baseline: fragment scaffolding only (config/models/analytics stubs) 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.