diff --git a/.env.example b/.env.example index b837576..dce01ed 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,7 @@ QUANTRADAR_STRIPE_SECRET_KEY= STRIPE_PRICE_ID= STRIPE_PRICE_ID_MONTHLY= STRIPE_PRICE_ID_YEARLY= +STRIPE_PRICE_ID_PORTFOLIO_PRO_MONTHLY= STRIPE_WEBHOOK_SECRET= # NEVER set on production hosts — skips webhook signature verify (unit tests only): # QUANTRADAR_STRIPE_WEBHOOK_TEST=1 diff --git a/app/public_surface.py b/app/public_surface.py index 45b3d7c..3cf34ce 100644 --- a/app/public_surface.py +++ b/app/public_surface.py @@ -13,6 +13,8 @@ from pathlib import Path from typing import Any +from app.users import is_paid_plan + def _basename(path: Any) -> str | None: if path is None: @@ -43,8 +45,7 @@ def _slim_sources(sources: Any) -> list[dict[str, str]]: def is_pro_live_audience(user: dict[str, Any] | None, result: dict[str, Any]) -> bool: if not user: return False - plan = str(user.get("plan") or "").strip().lower() - if plan != "pro": + if not is_paid_plan(str(user.get("plan") or "")): return False meta = result.get("meta") if isinstance(result.get("meta"), dict) else {} return str(meta.get("mode") or "").strip().lower() == "live" diff --git a/app/server.py b/app/server.py index 2712941..6747d23 100644 --- a/app/server.py +++ b/app/server.py @@ -33,6 +33,7 @@ from app.users import ( authenticate as password_login, ensure_bootstrap_admin, + is_paid_plan, register_user, resolve_plan, ) @@ -483,7 +484,7 @@ def _run_analyze( return if mode_norm == "live" and user: plan = str(user.get("plan") or "free").lower() - if plan != "pro": + if not is_paid_plan(plan): self._send( 403, { @@ -714,9 +715,11 @@ def do_GET(self) -> None: # noqa: N802 "webhook_configured": bool(stripe_billing.webhook_secret()), "checkout": "/api/billing/checkout", "intervals": ["monthly", "yearly"], + "plans": ["pro", "portfolio_pro"], "prices": { "monthly": "$29", "yearly": "$249", + "portfolio_pro_monthly": "$99", }, "live_available": bool(health.get("live_available")), "pro_value": health.get("pro_value"), @@ -1104,12 +1107,14 @@ def do_POST(self) -> None: # noqa: N802 except Exception: body = {} interval = str(body.get("interval") or "monthly") + plan = stripe_billing.normalize_checkout_plan(str(body.get("plan") or "pro")) try: - # Ignore client price_id — only server env Price IDs + interval. + # Ignore client price_id — only server env Price IDs + plan/interval. session = stripe_billing.create_checkout_session( customer_email=str(user.get("email") or "") or None, price_id=None, interval=interval, + plan=plan, ) except Exception as exc: self._send( @@ -1124,6 +1129,7 @@ def do_POST(self) -> None: # noqa: N802 interval=interval, email=str(user.get("email") or ""), ok=True, + extra={"checkout_plan": plan}, ) except Exception: pass @@ -1174,11 +1180,11 @@ def do_POST(self) -> None: # noqa: N802 if not result.get("ok"): self._send(422, {"ok": False, **result}) return - if result.get("action") == "plan_pro": + if result.get("action") in {"plan_pro", "plan_portfolio_pro"}: try: funnel.track( "pro_active", - plan="pro", + plan=str(result.get("plan") or "pro"), email=str(result.get("email") or ""), ok=True, extra={"source": "stripe_webhook"}, diff --git a/app/stripe_billing.py b/app/stripe_billing.py index 409390f..0ddeb33 100644 --- a/app/stripe_billing.py +++ b/app/stripe_billing.py @@ -14,6 +14,8 @@ from app.auth import public_base_url +CHECKOUT_PLANS = frozenset({"pro", "portfolio_pro"}) + def stripe_secret() -> str: return ( @@ -33,6 +35,24 @@ def webhook_secret() -> str: ) +def normalize_checkout_plan(plan: str | None) -> str: + plan_n = (plan or "pro").strip().lower().replace("-", "_") + if plan_n in {"portfolio", "portfolio_pro"}: + return "portfolio_pro" + return "pro" + + +def stripe_product_slug(plan: str) -> str: + return "quantradar_portfolio_pro" if plan == "portfolio_pro" else "quantradar_pro" + + +def plan_from_product(product: str | None) -> str: + slug = str(product or "").strip().lower() + if slug in {"quantradar_portfolio_pro", "portfolio_pro"}: + return "portfolio_pro" + return "pro" + + def price_id_for_interval(interval: str | None) -> str: """Resolve Stripe Price ID for monthly|yearly. Falls back to STRIPE_PRICE_ID.""" iv = (interval or "monthly").strip().lower() @@ -49,27 +69,48 @@ def price_id_for_interval(interval: str | None) -> str: ) +def price_id_for_plan(plan: str | None, interval: str | None = None) -> str: + """Resolve Stripe Price ID for checkout plan + interval.""" + plan_n = normalize_checkout_plan(plan) + if plan_n == "portfolio_pro": + return ( + os.environ.get("STRIPE_PRICE_ID_PORTFOLIO_PRO_MONTHLY", "").strip() + or os.environ.get("QUANTRADAR_STRIPE_PRICE_ID_PORTFOLIO_PRO_MONTHLY", "").strip() + ) + return price_id_for_interval(interval) + + def create_checkout_session( *, customer_email: str | None = None, price_id: str | None = None, interval: str | None = None, + plan: str | None = "pro", mode: str = "subscription", ) -> dict[str, Any]: - """Create a Stripe Checkout Session. Returns {id, url}.""" + """Create a Stripe Checkout Session. Returns {id, url, plan}.""" secret = stripe_secret() if not secret: raise RuntimeError("Stripe not configured") - price = (price_id or price_id_for_interval(interval)).strip() + plan_n = normalize_checkout_plan(plan) + if plan_n == "portfolio_pro": + iv = "monthly" + else: + iv = (interval or "monthly").strip().lower() + if iv in {"year", "yearly", "annual", "annually"}: + iv = "yearly" + else: + iv = "monthly" + price = (price_id or price_id_for_plan(plan_n, iv)).strip() if not price: + if plan_n == "portfolio_pro": + raise RuntimeError( + "Stripe Price ID not configured — set STRIPE_PRICE_ID_PORTFOLIO_PRO_MONTHLY" + ) raise RuntimeError( "Stripe Price ID not configured — set STRIPE_PRICE_ID_MONTHLY / STRIPE_PRICE_ID_YEARLY" ) - iv = (interval or "monthly").strip().lower() - if iv in {"year", "yearly", "annual", "annually"}: - iv = "yearly" - else: - iv = "monthly" + product = stripe_product_slug(plan_n) base = public_base_url() data: dict[str, str] = { "mode": "subscription", @@ -84,11 +125,13 @@ def create_checkout_session( data["client_reference_id"] = customer_email data["metadata[email]"] = customer_email data["metadata[interval]"] = iv - data["metadata[product]"] = "quantradar_pro" + data["metadata[plan]"] = plan_n + data["metadata[product]"] = product # Stripe does not copy session metadata onto the subscription — required for cancel→free. data["subscription_data[metadata][email]"] = customer_email data["subscription_data[metadata][interval]"] = iv - data["subscription_data[metadata][product]"] = "quantradar_pro" + data["subscription_data[metadata][plan]"] = plan_n + data["subscription_data[metadata][product]"] = product body = urllib.parse.urlencode(data).encode() req = urllib.request.Request( @@ -112,6 +155,7 @@ def create_checkout_session( "url": obj.get("url"), "raw_status": obj.get("status"), "interval": iv, + "plan": plan_n, } @@ -153,6 +197,14 @@ def _email_from_checkout_session(session: dict[str, Any]) -> str | None: return None +def _plan_from_metadata(meta: dict[str, Any] | None) -> str: + if not isinstance(meta, dict): + return "pro" + if meta.get("plan"): + return normalize_checkout_plan(str(meta.get("plan"))) + return plan_from_product(str(meta.get("product") or "")) + + def apply_webhook_event(event: dict[str, Any]) -> dict[str, Any]: """Apply plan changes from a verified Stripe event. Returns action summary.""" from app.users import set_plan @@ -174,15 +226,18 @@ def apply_webhook_event(event: dict[str, Any]) -> dict[str, Any]: "type": etype, "status": payment_status or "missing", } + meta = data_obj.get("metadata") if isinstance(data_obj.get("metadata"), dict) else {} + plan_n = _plan_from_metadata(meta) customer_id = data_obj.get("customer") if isinstance(customer_id, dict): customer_id = customer_id.get("id") user = set_plan( email, - "pro", + plan_n, stripe_customer_id=str(customer_id) if customer_id else None, ) - return {"ok": True, "action": "plan_pro", "email": email, "user": user, "type": etype} + action = "plan_portfolio_pro" if plan_n == "portfolio_pro" else "plan_pro" + return {"ok": True, "action": action, "email": email, "user": user, "type": etype, "plan": plan_n} if etype in { "customer.subscription.deleted", diff --git a/app/users.py b/app/users.py index 2b43867..e663e74 100644 --- a/app/users.py +++ b/app/users.py @@ -250,6 +250,14 @@ def count_users() -> int: return len(_load().get("users") or {}) +VALID_PLANS = frozenset({"free", "pro", "portfolio_pro"}) + + +def is_paid_plan(plan: str | None) -> bool: + """Return True for Stripe-paid tiers that unlock Pro desk privileges.""" + return str(plan or "").strip().lower() in {"pro", "portfolio_pro"} + + def resolve_plan(email: str | None, *, session_plan: str | None = None) -> str: """Plan SSOT is the users store only. @@ -265,7 +273,7 @@ def resolve_plan(email: str | None, *, session_plan: str | None = None) -> str: if not u: return "free" plan = str(u.get("plan") or "free").strip().lower() - return plan if plan in {"free", "pro"} else "free" + return plan if plan in VALID_PLANS else "free" def set_plan( @@ -279,7 +287,7 @@ def set_plan( if not _EMAIL_RE.match(email_n): raise ValueError("invalid email") plan_n = (plan or "free").strip().lower() - if plan_n not in {"free", "pro"}: + if plan_n not in VALID_PLANS: raise ValueError("invalid plan") with _LOCK: store = _load() diff --git a/docs/env/quantradar.env.example b/docs/env/quantradar.env.example index de790b0..f1c706f 100644 --- a/docs/env/quantradar.env.example +++ b/docs/env/quantradar.env.example @@ -12,6 +12,7 @@ SESSION_SECRET=replace-with-long-random-string # STRIPE_PRICE_ID= # STRIPE_PRICE_ID_MONTHLY= # STRIPE_PRICE_ID_YEARLY= +# STRIPE_PRICE_ID_PORTFOLIO_PRO_MONTHLY= # STRIPE_WEBHOOK_SECRET= # NEVER on prod — unit-test bypass for webhook signature: # QUANTRADAR_STRIPE_WEBHOOK_TEST=1 diff --git a/render.yaml b/render.yaml index 292627a..dd52cbc 100644 --- a/render.yaml +++ b/render.yaml @@ -39,3 +39,5 @@ services: sync: false - key: STRIPE_PRICE_ID_YEARLY sync: false + - key: STRIPE_PRICE_ID_PORTFOLIO_PRO_MONTHLY + sync: false diff --git a/static/login.html b/static/login.html index 2ca3ab6..cd4767f 100644 --- a/static/login.html +++ b/static/login.html @@ -71,10 +71,15 @@
QuantRadar
- Clear Free vs Pro. No fabricated user counts. On today’s production host, demos are frozen artifacts — - Pro is a supporter price; live auto-unlocks when charts are mounted. + Clear Free, Pro, and Portfolio Pro. No fabricated user counts. On today’s production host, demos are frozen artifacts — + paid plans are supporter prices; live auto-unlocks when charts are mounted. Checkout via Stripe when configured on the host.
@@ -38,7 +38,7 @@- Anchor: one avoided −8% on a $3k trade ≈ 8 months of Pro. Yearly $249 saves ~28% vs monthly. + Anchor: one avoided −8% on a $3k trade ≈ 8 months of Pro. Yearly $249 saves ~28% vs monthly when configured.
$99/mo
+Billed monthly only. Cancel anytime via Stripe.
++ Higher supporter tier — same desk access as Pro on this host today; portfolio monitoring and report workflow ship separately. +
+- No “Elite institutional” tier without a public track record. If we add a mid tier later, it will be - clearly scoped (batch limits / API lite) — not a prestige jump. + No “Elite institutional” tier without a public track record. Portfolio Pro is scoped to portfolio monitoring + and report workflow on the roadmap — not a prestige jump with fake features.
| Free | Pro | ||
|---|---|---|---|
| Free | Pro | Portfolio Pro | |
| Demo artifact | Yes | Yes | |
| Live fetch | No | Not on this host yet · auto-unlock when mounted | |
| Options as actionable | No | Only when live chain OK | |
| Pattern labels | If present in artifact | Same rules (+ live when mounted) | |
| Demo artifact | Yes | Yes | Yes |
| Live fetch | No | Not on this host yet · auto-unlock when mounted | Same as Pro |
| Options as actionable | No | Only when live chain OK | Same as Pro |
| Portfolio batch / reports | No | No | Roadmap — not sold as live today |
| Pattern labels | If present in artifact | Same rules (+ live when mounted) | Same as Pro |
It’s a supporter price. Anchoring check: one avoided −8% move on a $3k position ≈ eight months of Pro. Live is a promised unlock, not sold as available on this host today.
+Pro ($29/mo) is the standard supporter plan. Portfolio Pro ($99/mo, monthly only) is a higher supporter tier with the same desk access as Pro on this host today. It funds portfolio-scale monitoring and report workflow — those features ship separately and are not sold as available now.
+No. They are independent products. iOS Unlock is Apple In-App Purchase. Website Pro is Stripe. Neither receipt unlocks the other.
@@ -143,6 +164,7 @@