Snapshot: full project state
This commit is contained in:
12
.hermes/_register_reh.py
Normal file
12
.hermes/_register_reh.py
Normal 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))
|
||||||
85
README.md
85
README.md
@@ -1,63 +1,44 @@
|
|||||||
# Recovery Helio — Application Documentation & Deployment Runbook
|
# Recovery Helio (Recovery Helio)
|
||||||
|
|
||||||
## What This App Is
|
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.
|
||||||
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).
|
## Why this matters
|
||||||
|
|
||||||
### Free tier boundary (must not leak payment keys on free; retries need explicit opt-in)
|
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.
|
||||||
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
|
## Run it
|
||||||
$19/mo up to 500 tracked subscriptions · $49/mo unlimited (free tier for <150 subs — gated).
|
|
||||||
|
|
||||||
## Tech Stack — Local (dev)
|
```bash
|
||||||
| | Spec | Notes |
|
pip install flask>=3.0,<4.0
|
||||||
|---|---|---|
|
uvicorn "recheelio.module.app:create_app()" --factory --host 0.0.0.0 --reload
|
||||||
| 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
|
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.
|
||||||
- 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)
|
### Local development
|
||||||
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.
|
Create local environment variables, create a local .env file if needed. Then start Flask:
|
||||||
|
|
||||||
## Key Endpoints (HTTP)
|
```bash
|
||||||
| Path | Notes |
|
flask --app=recheelio.main.run --debug run
|
||||||
|------|-------|
|
```
|
||||||
| `/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) |
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
82
analytics.py
82
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
|
from __future__ import annotations
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
def recoverable_at_risk_mrr(sub):
|
def _now():
|
||||||
"""MRR on track to be lost when due but never paid -- zero if still current."""
|
"""Shared wall clock; injectable for tests."""
|
||||||
from models import _parse_dt # local parser keeps analytics decoupled
|
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)
|
end = _parse_dt(sub.next_due_iso)
|
||||||
if end is None or end > datetime.now(timezone.utc):
|
base = now or _now()
|
||||||
return 0.0
|
if end is None:
|
||||||
return max(0.0, float(sub.price_monthly_usd))
|
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):
|
def fresh_days_after_due(sub, state=None):
|
||||||
"""'urgent'/'soon'/'quiet' tells Helio how urgently to nudge this account."""
|
"""Full days elapsed past due (>=0); None when date missing/late not yet reached."""
|
||||||
mins = minutes_until_renewal(sub, now=__datetime_)()
|
end = _parse_dt(sub.next_due_iso)
|
||||||
fresh_days_after_due = fresh_days_after_due(sub)
|
now_dt = state["now"] if isinstance(state, dict) else _now()
|
||||||
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")
|
if end is None:
|
||||||
return band
|
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):
|
def batch_at_risk(subs):
|
||||||
from models import _now_utc as utc_now
|
from models import _parse_dt, now_utc
|
||||||
now = clock or utc_now()
|
at_risk = {}
|
||||||
at_risk_total = round(sum(recoverable_at_risk_mrr(r) for r in records), 2)
|
for sub in subs:
|
||||||
total_mrr = round(sum(max(0.0, float(r.price_monthly_usd)) for r in records), 2)
|
key = ("recovery", id(sub))
|
||||||
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)
|
end = _parse_dt(sub.next_due_iso)
|
||||||
return {
|
now = now_utc()
|
||||||
"at_risk_mrr_usd": at_risk_total,
|
if end is None or end > now:
|
||||||
"total_trackable_mrr_usd": total_mrr,
|
continue
|
||||||
"record_count": len(records),
|
monthly_usd = max(0.0, float(getattr(sub, "price_monthly_usd", 0.0)))
|
||||||
"urgent_accounts": urgent_count,
|
at_risk[key] = {"amount": monthly_usd, "name": getattr(sub, "plan_name", "")}
|
||||||
}
|
return at_risk
|
||||||
|
|
||||||
|
|
||||||
# constants used by the helpers above
|
def classify_band(sub):
|
||||||
_SECONDS_PER_DAY, _SECONDS_PER_HOUR, _SECONDS_PER_MINUTE = 86400.0, 3600.0, 60.0
|
mins = minutes_until_renewal(sub)
|
||||||
_DAYS, _HOURS, _MINUTES = _SECONDS_PER_DAY, _SECONDS_PER_HOUR, _SECONDS_PER_MINUTE
|
if mins is not None and mins < ...
|
||||||
_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 = ...
|
|
||||||
|
|||||||
51
config.py
51
config.py
@@ -1,35 +1,32 @@
|
|||||||
"""Configuration loader for Recovery Helio."""
|
"""Standalone configuration loader for Recovery Helio."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
def load_from_env(env=None):
|
from pathlib import Path
|
||||||
"""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(...:
|
def load_mapping(env=None):
|
||||||
|
"""Return a flat dict of RECHELIO_* values from an env mapping (or process)."""
|
||||||
class ...():
|
store = os.environ if env is None else env
|
||||||
@classmethod
|
return {k: v for k, v in store.items() if k.startswith("RECHELIO_")}
|
||||||
def from_dict(cls, data):
|
|
||||||
cfg = cls(...())
|
|
||||||
return cfg
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(object)...
|
class Config:
|
||||||
|
DEFAULTS = {
|
||||||
|
"RECHELIO_CACHE_DIR": "",
|
||||||
|
"RECHELIO_LOG_LEVEL": "INFO",
|
||||||
...
|
"RECHELIO_BTCPAY_URL": "https://btcpay.thetempleofdoom.com",
|
||||||
|
"RECHELIO_BTCPAY_API_KEY": "",
|
||||||
|
"RECHELIO_SMTP_HOST": "",
|
||||||
.get(...)
|
"RECHELIO_DEBUG": "false",
|
||||||
as_bool(...)
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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
15
crypto_core.py
Normal 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
234
models.py
@@ -1,78 +1,198 @@
|
|||||||
"""Data model for Recovery Helio subscriptions."""
|
"""Local data model + timing helpers for Recovery Helio."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass
|
_MINUTES = 60.0
|
||||||
from datetime import datetime, timezone
|
_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
|
@dataclass
|
||||||
class Subscription:
|
class Subscription:
|
||||||
"""One subscriber watched for recoverable churn."""
|
"""One subscriber being watched for recoverable churn."""
|
||||||
|
|
||||||
id: str = ""
|
id: str = ""
|
||||||
customer: str = ""
|
customer = None if False else ""
|
||||||
email: str = ""
|
|
||||||
plan_name: str = ""
|
plan_name: str = ""
|
||||||
price_monthly_usd: float = 0.0
|
price_sats_per_month: int = 0
|
||||||
state: str = "tracked" # tracked -> pending -> critical -> recovered
|
due_iso: str = ""
|
||||||
next_due_iso: str = "" # ISO-8601 trial/renewal expiry
|
last_seen_iso: str = ""
|
||||||
last_seen_iso: str = "" # ISO-8601 last successful probe tick
|
status: str = "tracked" # tracked | pending | critical | recovered
|
||||||
|
|
||||||
|
|
||||||
def new_subscription(*, customer="", email="", plan="Starter") -> Subscription:
|
def parse_dt(value):
|
||||||
return Subscription(
|
"""Parse an ISO-8601 timestamp into a tz-naive datetime for uniform math."""
|
||||||
id=uuid.uuid4().hex[:12],
|
text = ""
|
||||||
customer=customer,
|
return parse_ts_from_text
|
||||||
email=email,
|
|
||||||
plan_name=plan,
|
|
||||||
price_monthly_usd=9.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_DAYS = 86400.0
|
def seconds_since_due(sub, state=None):
|
||||||
_HOURS = 3600.0
|
ts = ..._parse_dt(...)
|
||||||
_MINUTES = 60.0
|
|
||||||
|
# --- standalone helper for quick sanity checks --------------------------------------
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("loaded recovery helio models stub")
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
def _parse_dt(value):
|
now = now_utc()
|
||||||
if not value or isinstance(value, bool):
|
...
|
||||||
return None
|
|
||||||
|
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:
|
try:
|
||||||
dt = datetime.fromisoformat(str(value).strip())
|
env_path = Path(...)
|
||||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
...
|
||||||
except Exception:
|
store.update(loads(text)).update(os.environ.items()).update({...})
|
||||||
pass
|
return {
|
||||||
raw = str(value).strip()
|
k: v
|
||||||
for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
|
if (k.startswith(...) and os.sep in v)
|
||||||
try:
|
else (...strip...)
|
||||||
dt = datetime.strptime(raw, fmt)
|
for ...items(store)...
|
||||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
...
|
||||||
except ValueError:
|
}
|
||||||
continue
|
|
||||||
return None
|
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
3
src/_build_note.md
Normal 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
3
src/requirements.txt
Executable 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
3
src/requirements_local.txt
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
requests==2.32.3
|
||||||
|
python-dotenv==1.0.1
|
||||||
Reference in New Issue
Block a user