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

Sign in

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") { @@ -107,7 +112,9 @@

Sign in

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)."; diff --git a/static/pricing.html b/static/pricing.html index 9279bbd..8eda075 100644 --- a/static/pricing.html +++ b/static/pricing.html @@ -26,8 +26,8 @@

QuantRadar

Pricing

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

Pricing

- +

Free / Guest

@@ -66,11 +66,27 @@

Pro

  • Options only when live chain is OK (honesty gate)
  • - 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.

    +
    +

    Portfolio Pro

    +

    $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. +

    + + +

    +
    @@ -88,18 +104,19 @@

    iOS app — separate cash register

    What we do not sell (yet)

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

    Consistency table

    - + - - - - + + + + +
    FreePro
    FreeProPortfolio Pro
    Demo artifactYesYes
    Live fetchNoNot on this host yet · auto-unlock when mounted
    Options as actionableNoOnly when live chain OK
    Pattern labelsIf present in artifactSame rules (+ live when mounted)
    Demo artifactYesYesYes
    Live fetchNoNot on this host yet · auto-unlock when mountedSame as Pro
    Options as actionableNoOnly when live chain OKSame as Pro
    Portfolio batch / reportsNoNoRoadmap — not sold as live today
    Pattern labelsIf present in artifactSame rules (+ live when mounted)Same as Pro
    @@ -125,6 +142,10 @@

    FAQ

    Is $29/mo worth it before live exists?

    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.

    +
    + What is Portfolio Pro vs Pro? +

    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.

    +
    Does website Pro unlock the iOS app?

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

    FAQ

    const params = new URLSearchParams(location.search); if (params.get("interval") === "yearly") interval = "yearly"; + const checkoutPlan = params.get("plan") === "portfolio_pro" ? "portfolio_pro" : "pro"; function showBanner(kind, text) { const el = document.getElementById("banner"); @@ -175,10 +197,28 @@

    FAQ

    function syncCta() { const cta = document.getElementById("proCta"); const hint = document.getElementById("ctaHint"); - if (String(auth.plan).toLowerCase() === "pro") { + const portfolioCta = document.getElementById("portfolioCta"); + const portfolioHint = document.getElementById("portfolioHint"); + const userPlan = String(auth.plan).toLowerCase(); + + if (userPlan === "portfolio_pro") { + setCtaLabel(portfolioCta, "Portfolio Pro active — open desk"); + portfolioCta.onclick = () => { location.href = "/"; }; + portfolioHint.textContent = "You already have Portfolio Pro on this account."; + setCtaLabel(cta, "Pro included — open desk"); + cta.onclick = () => { location.href = "/"; }; + hint.textContent = "Portfolio Pro includes Pro desk access."; + return; + } + if (userPlan === "pro") { setCtaLabel(cta, "Pro active — open desk"); cta.onclick = () => { location.href = "/"; }; hint.textContent = "You already have Pro on this account."; + setCtaLabel(portfolioCta, "Upgrade to Portfolio Pro"); + portfolioCta.onclick = () => startCheckout("portfolio_pro"); + portfolioHint.textContent = auth.stripe + ? "Upgrade adds portfolio-scale roadmap support. Same desk access until batch features ship." + : "Stripe may be unavailable until keys are configured."; return; } if (!auth.authenticated) { @@ -189,27 +229,44 @@

    FAQ

    hint.textContent = auth.stripe ? "Sign in first, then Stripe Checkout opens." : "Sign in first. Stripe may be unavailable until keys are configured."; + setCtaLabel(portfolioCta, "Sign in · Portfolio Pro"); + portfolioCta.onclick = () => { + location.href = "/login?next=checkout&plan=portfolio_pro"; + }; + portfolioHint.textContent = auth.stripe + ? "Sign in first, then Stripe Checkout opens." + : "Sign in first. Stripe may be unavailable until keys are configured."; return; } setCtaLabel(cta, interval === "yearly" ? "Checkout · $249/yr" : "Checkout · $29/mo"); - cta.onclick = () => startCheckout(); + cta.onclick = () => startCheckout("pro"); hint.textContent = auth.stripe ? "Secure Stripe Checkout. Success returns you to the desk." : "Stripe is not configured on this host — CTA will show an honest error."; + setCtaLabel(portfolioCta, "Checkout · $99/mo"); + portfolioCta.onclick = () => startCheckout("portfolio_pro"); + portfolioHint.textContent = auth.stripe + ? "Secure Stripe Checkout. Monthly only." + : "Stripe is not configured on this host — CTA will show an honest error."; } - async function startCheckout() { + async function startCheckout(plan) { + const checkoutPlan = plan === "portfolio_pro" ? "portfolio_pro" : "pro"; try { const r = await fetch("/api/billing/checkout", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, - body: JSON.stringify({ interval }), + body: JSON.stringify({ interval, plan: checkoutPlan }), }); const j = await r.json(); if (j.url) { location.href = j.url; return; } if (j.error === "login_required") { - location.href = "/login?next=checkout&interval=" + encodeURIComponent(interval); + if (checkoutPlan === "portfolio_pro") { + location.href = "/login?next=checkout&plan=portfolio_pro"; + } else { + location.href = "/login?next=checkout&interval=" + encodeURIComponent(interval); + } return; } showBanner("warn", j.error_detail || j.error || "Checkout unavailable"); @@ -230,7 +287,7 @@

    FAQ

    showBanner("warn", "Checkout canceled. Pick a plan when you are ready."); } if (params.get("authed") === "1") { - showBanner("ok", "Signed in. Choose monthly or yearly, then Checkout."); + showBanner("ok", "Signed in. Choose a plan, then Checkout."); } syncIntervalUI(); @@ -239,15 +296,24 @@

    FAQ

    .then((r) => r.json()) .then((j) => { const note = document.getElementById("proValueNote"); - if (!note) return; - if (j.live_available) { - note.textContent = - j.pro_value_note || - "Charts engine is mounted on this host — Pro includes live analyze."; - } else { - note.textContent = - j.pro_value_note || - "Supporter plan on this host — paying does not enable live quotes until charts are mounted."; + const portfolioNote = document.getElementById("portfolioValueNote"); + if (note) { + if (j.live_available) { + note.textContent = + j.pro_value_note || + "Charts engine is mounted on this host — Pro includes live analyze."; + } else { + note.textContent = + j.pro_value_note || + "Supporter plan on this host — paying does not enable live quotes until charts are mounted."; + } + } + if (portfolioNote && !j.live_available) { + portfolioNote.textContent = + "Higher supporter tier — same desk access as Pro on this host today; portfolio monitoring and report workflow ship separately."; + } else if (portfolioNote && j.live_available) { + portfolioNote.textContent = + "Higher supporter tier — same live desk access as Pro today; portfolio batch and report workflow ship separately."; } }) .catch(() => {}); @@ -261,7 +327,7 @@

    FAQ

    stripe: !!j.stripe_checkout, }; syncCta(); - if (params.get("authed") === "1" && auth.authenticated && auth.stripe) { + if (params.get("authed") === "1" && auth.authenticated && auth.stripe && checkoutPlan === "portfolio_pro") { /* optional auto-start — keep manual click for clarity */ } }) diff --git a/tests/test_billing.py b/tests/test_billing.py index 9738059..508e9d0 100644 --- a/tests/test_billing.py +++ b/tests/test_billing.py @@ -43,6 +43,21 @@ def test_price_id_for_interval(self) -> None: self.assertEqual(stripe_billing.price_id_for_interval("yearly"), "price_y") self.assertEqual(stripe_billing.price_id_for_interval(None), "price_m") + def test_price_id_for_portfolio_pro(self) -> None: + with mock.patch.dict( + os.environ, + { + "STRIPE_PRICE_ID_PORTFOLIO_PRO_MONTHLY": "price_pp", + "STRIPE_PRICE_ID_MONTHLY": "price_m", + }, + clear=False, + ): + self.assertEqual( + stripe_billing.price_id_for_plan("portfolio_pro", "monthly"), + "price_pp", + ) + self.assertEqual(stripe_billing.normalize_checkout_plan("portfolio-pro"), "portfolio_pro") + class WebhookSigTests(unittest.TestCase): def test_verify_signature(self) -> None: @@ -127,15 +142,38 @@ def test_webhook_sets_pro(self) -> None: self.assertEqual(body.get("action"), "plan_pro") self.assertEqual(users_mod.resolve_plan("buyer@test.local"), "pro") - # status exposes pro when session email matches - token = authlib.mint_session(sub="b", email="buyer@test.local", plan="free") + def test_webhook_sets_portfolio_pro(self) -> None: + users_mod.register_user("portfolio@test.local", "password12", name="Portfolio") + self.assertEqual(users_mod.resolve_plan("portfolio@test.local"), "free") + event = { + "type": "checkout.session.completed", + "data": { + "object": { + "customer_email": "portfolio@test.local", + "payment_status": "paid", + "status": "complete", + "metadata": { + "email": "portfolio@test.local", + "product": "quantradar_portfolio_pro", + "plan": "portfolio_pro", + }, + } + }, + } + code, body = self._post("/api/billing/webhook", event) + self.assertEqual(code, 200, body) + self.assertTrue(body.get("ok"), body) + self.assertEqual(body.get("action"), "plan_portfolio_pro") + self.assertEqual(users_mod.resolve_plan("portfolio@test.local"), "portfolio_pro") + + token = authlib.mint_session(sub="pp", email="portfolio@test.local", plan="free") req = urllib.request.Request( self._url("/api/auth/status"), headers={"Cookie": f"{authlib.COOKIE_NAME}={token}"}, ) with urllib.request.urlopen(req, timeout=5) as r: status = json.loads(r.read().decode()) - self.assertEqual(status["user"]["plan"], "pro") + self.assertEqual(status["user"]["plan"], "portfolio_pro") def test_webhook_unpaid_rejected(self) -> None: event = { @@ -160,6 +198,8 @@ def test_billing_status_shape(self) -> None: self.assertTrue(body.get("ok")) self.assertIn("monthly", body.get("intervals") or []) self.assertEqual(body["prices"]["yearly"], "$249") + self.assertEqual(body["prices"]["portfolio_pro_monthly"], "$99") + self.assertIn("portfolio_pro", body.get("plans") or []) self.assertIn(body.get("pro_value"), {"supporter_until_mount", "live_ready"}) self.assertIn("live_available", body) self.assertTrue(body.get("pro_value_note")) @@ -254,6 +294,46 @@ def fake_urlopen(req, timeout=30): # noqa: ANN001 self.assertEqual(body.get("subscription_data[metadata][email]"), ["buyer@test.local"]) self.assertEqual(body.get("mode"), ["subscription"]) + def test_portfolio_pro_checkout_metadata(self) -> None: + captured: dict[str, Any] = {} + + class FakeResp: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self) -> bytes: + return json.dumps( + {"id": "cs_pp", "url": "https://checkout.stripe.com/pp", "status": "open"} + ).encode() + + def fake_urlopen(req, timeout=30): # noqa: ANN001 + captured["body"] = req.data.decode() if isinstance(req.data, bytes) else str(req.data) + return FakeResp() + + with mock.patch.dict( + os.environ, + { + "QUANTRADAR_STRIPE_SECRET_KEY": "sk_test_x", + "STRIPE_PRICE_ID_PORTFOLIO_PRO_MONTHLY": "price_pp", + "PUBLIC_BASE_URL": "https://quantradar.one", + }, + clear=False, + ): + with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): + out = stripe_billing.create_checkout_session( + customer_email="portfolio@test.local", + plan="portfolio_pro", + ) + self.assertEqual(out.get("plan"), "portfolio_pro") + body = urllib.parse.parse_qs(captured["body"]) + self.assertEqual(body.get("line_items[0][price]"), ["price_pp"]) + self.assertEqual(body.get("metadata[plan]"), ["portfolio_pro"]) + self.assertEqual(body.get("metadata[product]"), ["quantradar_portfolio_pro"]) + self.assertEqual(body.get("metadata[interval]"), ["monthly"]) + if __name__ == "__main__": unittest.main()