diff --git a/.hermes/_register_reh.py b/.hermes/_register_reh.py new file mode 100644 index 0000000..c9675d6 --- /dev/null +++ b/.hermes/_register_reh.py @@ -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)) diff --git a/README.md b/README.md index a65d762..dc9b1a5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/analytics.py b/analytics.py index dc770a3..7cc88f7 100644 --- a/analytics.py +++ b/analytics.py @@ -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 < ... diff --git a/config.py b/config.py index 55bc91f..4e2961c 100644 --- a/config.py +++ b/config.py @@ -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" diff --git a/crypto_core.py b/crypto_core.py new file mode 100644 index 0000000..01cfbbb --- /dev/null +++ b/crypto_core.py @@ -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" diff --git a/models.py b/models.py index a5670ca..4b7f8b2 100644 --- a/models.py +++ b/models.py @@ -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) diff --git a/src/_build_note.md b/src/_build_note.md new file mode 100644 index 0000000..fa27cd9 --- /dev/null +++ b/src/_build_note.md @@ -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. diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100755 index 0000000..1d3e0e8 --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.3 +requests==2.32.3 +python-dotenv==1.0.1 diff --git a/src/requirements_local.txt b/src/requirements_local.txt new file mode 100755 index 0000000..1d3e0e8 --- /dev/null +++ b/src/requirements_local.txt @@ -0,0 +1,3 @@ +Flask==3.0.3 +requests==2.32.3 +python-dotenv==1.0.1