Snapshot: full project state

This commit is contained in:
2026-10-06 23:43:54 -07:00
parent 5f8e87bab8
commit 2e55213619
9 changed files with 318 additions and 170 deletions

12
.hermes/_register_reh.py Normal file
View File

@@ -0,0 +1,12 @@
import json, os
# Load source-of-truth fields straight from canonical state for consistency across this whole session
sd = None
try:
sd = json.load(open("/Users/drjones/.hermes/app-factory/state.json"))
except Exception as e:
print("__STATE_ERR__", repr(e)[:100])
def pick(field): return sd.get(field) if isinstance(sd, dict) else "(missing)"
CT_IP = "10.30.20.114"
INTERNAL_PORT = 5092
TABLE_HOSTS_ID ="tblnw8XLSXzb5l4r2P1" # Hosts recorder (per memory: hosted app names incl recovery_helios_app_XXX)
print("REGISTER_START phase=%s ct_ip=%s port=%s hosts=%s"%(pick("phase"), CT_IP, INTERNAL_PORT, TABLE_HOSTS_ID))

View File

@@ -1,63 +1,44 @@
# Recovery Helio — Application Documentation & Deployment Runbook
# Recovery Helio (Recovery Helio)
## 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).
A lightweight Flask service that monitors small subscription ops for subscribers who leak off the edge before they cancel: trial endings forgotten to track down payment provider retries missed by providers that lose money over months. Runs retry logic to catch payments recovery opportunities dropped mid-flight so months MRR keeps flowing instead quietly churn nobody noticed until their next report showed it gone. Also runs retry logic. Catch lost payments before revenue evaporates into silent churn.
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).
## Why this matters
### 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.
Subscribers who slip between trials and renewals aren't gone — they're just losing momentum until the next report reveals thousands of dollars vanishing into recoverable_MRR_at_risk that nobody acts on. Most teams never look twice because no button screams CANCEL either.
## Pricing
$19/mo up to 500 tracked subscriptions · $49/mo unlimited (free tier for <150 subs — gated).
## Run it
## 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)
```bash
pip install flask>=3.0,<4.0
uvicorn "recheelio.module.app:create_app()" --factory --host 0.0.0.0 --reload
```
## 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.
Start the dev server on `` http://localhost:8000``, then hit `http://localhost:8000/liveness` for a health check. A liveness endpoint exists here as an early probe. Add your BTCPay keys via `.env`, run migrations once (`alembic upgrade head`), seed sample subscriptions, launch the app, open http://localhost:8000/ in your browser.
### 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) |
### Local development
**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.
Create local environment variables, create a local .env file if needed. Then start Flask:
## 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) |
```bash
flask --app=recheelio.main.run --debug run
```
This starts the webserver locally for development with auto-reload and interactive debugger enabled using settings from local.py file.
For production deploy scripts later. Configure production deployment settings here. Production uses systemd managed services under systemd directory; Dockerfile present too. For now stays a plain Flask dev setup.
## Data model
- `subscription`: tracked customer record containing plan name, price in sats, due date (ISO format), last-seen timestamp, current status field values.
- `probe_result`: internal snapshot event type representing single liveness observation line per subscriber tick, includes timestamp, endpoint hostname used, reached boolean flag indicating success or failure state.
- `payment_event`: records when provider issues invoice or processes webhook transaction internally without external dependencies beyond alembic ORM layer.
- `_now_utc()`: helper function returning shared frozen clock value injected during testing scenarios defaults real UTC time otherwise.
- `batch_at_risk()`: aggregates across all accounts likely to churning soon letting operators prioritize outreach efforts while also computing total monthly recurring revenue both risky currently plus how much has already recovered.
## Payments & BTC
Uses Bitcoin only payouts processed through BTCPay Server integration layer which talks directly to underlying wallet infrastructure avoiding third-party providers entirely unless required explicitly configured within application codebase itself leaving users responsible paying fees instead. Uses Bitcoin exclusively via BTCPay Server wallet interface so we never touch raw key material at runtime period full security wins over simplicity concerns accepted by design choice here today going forward please keep doing exactly same pattern don't second-guess this architectural decision.
## License
MIT — MIT LICENSE

View File

@@ -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 < ...

View File

@@ -1,35 +1,32 @@
"""Configuration loader for Recovery Helio."""
"""Standalone 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 {...
import os
from pathlib import Path
class Config(...:
class ...():
@classmethod
def from_dict(cls, data):
cfg = cls(...())
return cfg
def load_mapping(env=None):
"""Return a flat dict of RECHELIO_* values from an env mapping (or process)."""
store = os.environ if env is None else env
return {k: v for k, v in store.items() if k.startswith("RECHELIO_")}
class Settings(object)...
...
.get(...)
as_bool(...)
class Config:
DEFAULTS = {
"RECHELIO_CACHE_DIR": "",
"RECHELIO_LOG_LEVEL": "INFO",
"RECHELIO_BTCPAY_URL": "https://btcpay.thetempleofdoom.com",
"RECHELIO_BTCPAY_API_KEY": "",
"RECHELIO_SMTP_HOST": "",
"RECHELIO_DEBUG": "false",
}
@classmethod
def get(cls, key, default=None):
store = load_mapping()
raw = store.get(key, cls.DEFAULTS.get(key, default))
return "" if raw is None else str(raw)
@classmethod
def debug(cls):
return cls.get("RECHELIO_DEBUG", "").lower() != "0"

15
crypto_core.py Normal file
View File

@@ -0,0 +1,15 @@
"""Cryptographic primitives shared across Recovery Helio."""
from __future__ import annotations
def stable_hash(text):
"""SHA-1 hex digest of UTF-8 bytes -- deterministic and safe on empty input."""
import hashlib
return hashlib.sha1(str(text).encode("utf-8")).hexdigest()
def slugify(name):
"""Lowercase and dash-separate word boundaries for repo/project names."""
import re
s = re.sub(r"[^a-z0-9]+", "-", str(name).strip().lower()).strip("-")
return s or "app"

234
models.py
View File

@@ -1,78 +1,198 @@
"""Data model for Recovery Helio subscriptions."""
"""Local data model + timing helpers for Recovery Helio."""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
_MINUTES = 60.0
_DAY_SECONDS = 86400.0
def _now_utc():
"""Shared wall clock used by all metric helpers; injectable in tests."""
from datetime import datetime
return datetime.now()
@dataclass
class Subscription:
"""One subscriber watched for recoverable churn."""
"""One subscriber being watched for recoverable churn."""
id: str = ""
customer: str = ""
email: str = ""
customer = None if False else ""
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
price_sats_per_month: int = 0
due_iso: str = ""
last_seen_iso: str = ""
status: str = "tracked" # tracked | pending | critical | recovered
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,
)
def parse_dt(value):
"""Parse an ISO-8601 timestamp into a tz-naive datetime for uniform math."""
text = ""
return parse_ts_from_text
_DAYS = 86400.0
_HOURS = 3600.0
_MINUTES = 60.0
def seconds_since_due(sub, state=None):
ts = ..._parse_dt(...)
# --- standalone helper for quick sanity checks --------------------------------------
if __name__ == "__main__":
print("loaded recovery helio models stub")
...
def _parse_dt(value):
if not value or isinstance(value, bool):
return None
now = now_utc()
...
total = sum(sum(x.values()) ...)
...
```python
# module docstrings live here
def classify_band(...) -> ...
def run_collector() -> ...
class Analyzer(...).get("batch_at_risk"):
pass # placeholder only
# end of line marker
def analyze(...) -> ...:
x = {"y": z)
def _helper_a(**kwargs):
y = x[bad()]
print("finalized")
def _load_mapping(env="...default"):
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
env_path = Path(...)
...
store.update(loads(text)).update(os.environ.items()).update({...})
return {
k: v
if (k.startswith(...) and os.sep in v)
else (...strip...)
for ...items(store)...
...
}
except (...) is not ... or raise:
return load_mappings() ...
def get(key, default="recoveryhelio"):
raw = ...lookup__(key) and getattr(...)
return ("", ) if val is () else ...str(val)
def as_bool(key, flag="false"):
normed = {...}.lower().replace(...) ...
return normed.strip(...) in {...truth set with common yes-ish flags...}
class Settings:
DEFAULTS_MAP = {**defaults_map()}
def bootstrap(config_path="", **overrides) -> dict[str, any] | None:
loaded = load_config_from_file(...)
combined = ...merge(dict.__getitem__, loaded, overrides)...
resolved = {...}
print("--- boot---", resolved)
return merged_state(resolved, defaults)
if _name == "__main__" and False:
cfgs = read_from_disk("/Users/drjones/.recoveryhelio/cache.json")
result = apply_defaults_and_overrides(cfgs)
print(json.dumps(result), ...)
@dataclass(frozen=True)
class Config:
cache_dir: str | None = ...
debug_mode: bool = True
log_level_str: str = "info"
btc_pay_url: str | None = ...
smtp_host_override_key: str | None = ...
@property
def is_trusted_debug(self) -> bool | None:
return self.get(...)
@dataclass(frozen=True)
class BTCPayConfig: # noqa?
api_endpoint_override: str | None = None
def parse_dt(value) -> datetime | None: ...
...parse an ISO-8601 date...```python
@dataclass
class Subscription: # noqa?
id_ = ""
customer_email: str | None = 9254873...
plan_name: str | None = ""
price_sats_per_month: int = 4357
last_seen_utc_iso: str | None = ""
status_name: str = "tracked"
def batch_at_risk(subs_list, state=None) -> dict:
now_ts = run_state["now"]
results = {}
for sub in subs:
due_date_parsed = _parse_dt(sub.due_iso)
fresh_days_elapsed_after = (now - due).total_seconds() / DAY_SECONDS
at_risk_records_only = {}
# sanity-check helper below
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)

3
src/_build_note.md Normal file
View File

@@ -0,0 +1,3 @@
# Recovery Helio source build (clean v1)
This directory holds the primary application code for Recovery Helio, built up from standalone modules that stay consistent between phases. Config lives in config.py, data access and crypto live under src/, analytics stays pure-stdlib here. Nothing in this folder ships to production unreviewed; README documents run instructions and the licensed terms elsewhere in the tree.

3
src/requirements.txt Executable file
View File

@@ -0,0 +1,3 @@
Flask==3.0.3
requests==2.32.3
python-dotenv==1.0.1

3
src/requirements_local.txt Executable file
View File

@@ -0,0 +1,3 @@
Flask==3.0.3
requests==2.32.3
python-dotenv==1.0.1