Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions app/public_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
14 changes: 10 additions & 4 deletions app/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from app.users import (
authenticate as password_login,
ensure_bootstrap_admin,
is_paid_plan,
register_user,
resolve_plan,
)
Expand Down Expand Up @@ -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,
{
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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"},
Expand Down
77 changes: 66 additions & 11 deletions app/stripe_billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

from app.auth import public_base_url

CHECKOUT_PLANS = frozenset({"pro", "portfolio_pro"})


def stripe_secret() -> str:
return (
Expand All @@ -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()
Expand All @@ -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",
Expand All @@ -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(
Expand All @@ -112,6 +155,7 @@ def create_checkout_session(
"url": obj.get("url"),
"raw_status": obj.get("status"),
"interval": iv,
"plan": plan_n,
}


Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand Down
12 changes: 10 additions & 2 deletions app/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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(
Expand All @@ -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()
Expand Down
1 change: 1 addition & 0 deletions docs/env/quantradar.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,5 @@ services:
sync: false
- key: STRIPE_PRICE_ID_YEARLY
sync: false
- key: STRIPE_PRICE_ID_PORTFOLIO_PRO_MONTHLY
sync: false
11 changes: 9 additions & 2 deletions static/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,15 @@ <h1 id="title">Sign in</h1>
const params = new URLSearchParams(location.search);
const next = params.get("next") || "";
const interval = params.get("interval") || "monthly";
const checkoutPlan = params.get("plan") || "";

function redirectAfterAuth() {
if (next === "checkout") {
location.href = "/pricing?authed=1&interval=" + encodeURIComponent(interval);
if (checkoutPlan === "portfolio_pro") {
location.href = "/pricing?authed=1&plan=portfolio_pro";
} else {
location.href = "/pricing?authed=1&interval=" + encodeURIComponent(interval);
}
return;
}
if (next === "live") {
Expand Down Expand Up @@ -107,7 +112,9 @@ <h1 id="title">Sign in</h1>

if (next === "checkout") {
document.getElementById("lede").textContent =
"Create an account to unlock Pro checkout. After sign-in you will return to pricing to complete payment.";
checkoutPlan === "portfolio_pro"
? "Create an account to unlock Portfolio Pro checkout. After sign-in you will return to pricing to complete payment."
: "Create an account to unlock Pro checkout. After sign-in you will return to pricing to complete payment.";
} else if (next === "live") {
document.getElementById("lede").textContent =
"Sign in to use the desk. Live needs Pro and a mounted charts engine (supporter plan until then).";
Expand Down
Loading
Loading