Spaces:
Runtime error
Runtime error
Commit ·
a7da6ce
1
Parent(s): 8e2a6eb
LETS GOOOOOOOOOOOOOOOOOOOOOOOOO
Browse files- .env.example +13 -0
- backend/app/services/stripe_billing.py +5 -3
- backend/tests/test_billing.py +2 -1
- backend/tests/test_stripe_billing.py +201 -0
- deploy/env.prod.example +13 -0
- frontend/src/api/endpoints.ts +1 -0
- frontend/src/components/UpgradeModal.tsx +7 -5
- frontend/src/hooks/useUpgrade.tsx +5 -1
- frontend/src/lib/highlight.tsx +91 -0
- frontend/src/styles/upgrade.css +2 -32
.env.example
CHANGED
|
@@ -6,6 +6,19 @@ API_URL=http://localhost:8000
|
|
| 6 |
CORS_ORIGINS=http://localhost:5173
|
| 7 |
INTERNAL_TOKEN=replace-with-random-token
|
| 8 |
BILLING_ENABLED=false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
# ---- Auth (JWT). Revocation bumps users.token_version, so tokens can be long lived.
|
| 11 |
JWT_SECRET=replace-with-64-random-chars
|
|
|
|
| 6 |
CORS_ORIGINS=http://localhost:5173
|
| 7 |
INTERNAL_TOKEN=replace-with-random-token
|
| 8 |
BILLING_ENABLED=false
|
| 9 |
+
# Stripe. Checkout, the customer portal and webhooks. Prices are found or created by lookup key
|
| 10 |
+
# (notestack_<plan>_<cycle>) from backend/app/services/plans.py; set STRIPE_PRICE_* to pin existing ones.
|
| 11 |
+
# Webhook endpoint: {API_URL}/api/billing/webhook with events checkout.session.completed,
|
| 12 |
+
# checkout.session.async_payment_succeeded, checkout.session.async_payment_failed,
|
| 13 |
+
# customer.subscription.created, customer.subscription.updated, customer.subscription.deleted,
|
| 14 |
+
# customer.subscription.paused, customer.subscription.resumed, invoice.paid, invoice.payment_failed,
|
| 15 |
+
# invoice.payment_action_required, charge.dispute.created.
|
| 16 |
+
STRIPE_SECRET_KEY=
|
| 17 |
+
STRIPE_WEBHOOK_SECRET=
|
| 18 |
+
STRIPE_PRICE_WRITER_MONTHLY=
|
| 19 |
+
STRIPE_PRICE_WRITER_ANNUAL=
|
| 20 |
+
STRIPE_PRICE_STUDIO_MONTHLY=
|
| 21 |
+
STRIPE_PRICE_STUDIO_ANNUAL=
|
| 22 |
|
| 23 |
# ---- Auth (JWT). Revocation bumps users.token_version, so tokens can be long lived.
|
| 24 |
JWT_SECRET=replace-with-64-random-chars
|
backend/app/services/stripe_billing.py
CHANGED
|
@@ -331,9 +331,11 @@ def handle_event(db: Session, event) -> str:
|
|
| 331 |
if kind.startswith("invoice."):
|
| 332 |
details = _get(_get(obj, "parent", {}), "subscription_details", {}) # newer API versions
|
| 333 |
sub_id = _get(obj, "subscription") or _get(details, "subscription")
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
|
|
|
|
|
|
| 337 |
if first and kind in {"invoice.payment_action_required", "invoice.payment_failed"}:
|
| 338 |
# The first invoice of a Checkout is handled on the Checkout page itself.
|
| 339 |
if _get(obj, "billing_reason") != "subscription_create":
|
|
|
|
| 331 |
if kind.startswith("invoice."):
|
| 332 |
details = _get(_get(obj, "parent", {}), "subscription_details", {}) # newer API versions
|
| 333 |
sub_id = _get(obj, "subscription") or _get(details, "subscription")
|
| 334 |
+
# Read the subscription from Stripe: its metadata names the workspace even if an earlier event was missed.
|
| 335 |
+
stripe_sub = sc.v1.subscriptions.retrieve(sub_id) if sub_id else None
|
| 336 |
+
ws = _workspace_for(db, stripe_sub, customer_id=_get(obj, "customer"))
|
| 337 |
+
if ws and stripe_sub is not None:
|
| 338 |
+
sync_subscription(db, ws, stripe_sub)
|
| 339 |
if first and kind in {"invoice.payment_action_required", "invoice.payment_failed"}:
|
| 340 |
# The first invoice of a Checkout is handled on the Checkout page itself.
|
| 341 |
if _get(obj, "billing_reason") != "subscription_create":
|
backend/tests/test_billing.py
CHANGED
|
@@ -52,7 +52,8 @@ def test_free_plan_nudges_and_out_of_fuel_402(client, auth, billing_on, db_sessi
|
|
| 52 |
|
| 53 |
|
| 54 |
def test_checkout_guards(client, auth, billing_on, db_session):
|
| 55 |
-
|
|
|
|
| 56 |
assert client.post("/api/billing/checkout", headers=auth, json={"plan": "free"}).status_code == 422
|
| 57 |
ws = db_session.query(Workspace).one()
|
| 58 |
apply_subscription(db_session, ws.id, plan="studio", status="active", customer_id="cus_1")
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
def test_checkout_guards(client, auth, billing_on, db_session):
|
| 55 |
+
r = client.post("/api/billing/checkout", headers=auth, json={"plan": "writer"})
|
| 56 |
+
assert r.status_code == 503 and r.json()["detail"]["code"] == "stripe_not_configured" # no STRIPE_SECRET_KEY
|
| 57 |
assert client.post("/api/billing/checkout", headers=auth, json={"plan": "free"}).status_code == 422
|
| 58 |
ws = db_session.query(Workspace).one()
|
| 59 |
apply_subscription(db_session, ws.id, plan="studio", status="active", customer_id="cus_1")
|
backend/tests/test_stripe_billing.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stripe webhooks and checkout with a fake Stripe: signatures, retries, out of order events, 3DS, grace."""
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import hmac
|
| 5 |
+
import json
|
| 6 |
+
import time
|
| 7 |
+
import uuid
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
from sqlalchemy import select
|
| 11 |
+
|
| 12 |
+
from app.config import settings
|
| 13 |
+
from app.models import Subscription, Workspace
|
| 14 |
+
from app.services import stripe_billing
|
| 15 |
+
from app.services.email import ConsoleEmailProvider
|
| 16 |
+
from app.services.plans import effective_plan
|
| 17 |
+
from tests.conftest import last_code
|
| 18 |
+
|
| 19 |
+
SECRET = "whsec_test_secret"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class FakeStripe:
|
| 23 |
+
"""Just enough of StripeClient.v1 for the billing flows."""
|
| 24 |
+
|
| 25 |
+
def __init__(self):
|
| 26 |
+
self.subs: dict[str, dict] = {}
|
| 27 |
+
self.calls: list[tuple] = []
|
| 28 |
+
self.pending = False # next plan change needs 3DS
|
| 29 |
+
outer = self
|
| 30 |
+
|
| 31 |
+
class Subs:
|
| 32 |
+
def retrieve(self, sid, params=None):
|
| 33 |
+
return outer.subs[sid]
|
| 34 |
+
|
| 35 |
+
def update(self, sid, params):
|
| 36 |
+
outer.calls.append(("update", sid, params))
|
| 37 |
+
sub = outer.subs[sid]
|
| 38 |
+
invoice = {"status": "open" if outer.pending else "paid",
|
| 39 |
+
"hosted_invoice_url": "https://invoice.stripe.test/i_1"}
|
| 40 |
+
if outer.pending:
|
| 41 |
+
return {**sub, "pending_update": {"subscription_items": params["items"]}, "latest_invoice": invoice}
|
| 42 |
+
sub["items"]["data"][0]["price"] = {"id": params["items"][0]["price"],
|
| 43 |
+
"lookup_key": "notestack_studio_monthly"}
|
| 44 |
+
return {**sub, "pending_update": None, "latest_invoice": invoice}
|
| 45 |
+
|
| 46 |
+
class Prices:
|
| 47 |
+
def list(self, params):
|
| 48 |
+
return type("L", (), {"data": [type("P", (), {"id": f"price_{params['lookup_keys'][0]}"})()]})()
|
| 49 |
+
|
| 50 |
+
class Customers:
|
| 51 |
+
def create(self, params, options=None):
|
| 52 |
+
outer.calls.append(("customer", params))
|
| 53 |
+
return type("C", (), {"id": "cus_1"})()
|
| 54 |
+
|
| 55 |
+
class Sessions:
|
| 56 |
+
def create(self, params):
|
| 57 |
+
outer.calls.append(("checkout", params))
|
| 58 |
+
return type("S", (), {"url": "https://checkout.stripe.test/s_1"})()
|
| 59 |
+
|
| 60 |
+
def retrieve(self, sid, params=None):
|
| 61 |
+
return outer.session
|
| 62 |
+
|
| 63 |
+
class V1:
|
| 64 |
+
subscriptions = Subs()
|
| 65 |
+
prices = Prices()
|
| 66 |
+
customers = Customers()
|
| 67 |
+
checkout = type("Checkout", (), {"sessions": Sessions()})()
|
| 68 |
+
|
| 69 |
+
self.v1 = V1()
|
| 70 |
+
self.session: dict = {}
|
| 71 |
+
|
| 72 |
+
def add_sub(self, sid, workspace_id, plan="writer", status="active", period_end=None):
|
| 73 |
+
self.subs[sid] = {
|
| 74 |
+
"id": sid, "customer": "cus_1", "status": status,
|
| 75 |
+
"metadata": {"workspace_id": str(workspace_id), "plan": plan},
|
| 76 |
+
"items": {"data": [{
|
| 77 |
+
"id": "si_1",
|
| 78 |
+
"price": {"id": f"price_{plan}", "lookup_key": f"notestack_{plan}_monthly"},
|
| 79 |
+
"current_period_end": period_end or int(time.time()) + 30 * 86400,
|
| 80 |
+
}]},
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@pytest.fixture()
|
| 85 |
+
def stripe_on(monkeypatch):
|
| 86 |
+
fake = FakeStripe()
|
| 87 |
+
monkeypatch.setattr(settings, "billing_enabled", True)
|
| 88 |
+
monkeypatch.setattr(settings, "stripe_secret_key", "sk_test_x")
|
| 89 |
+
monkeypatch.setattr(settings, "stripe_webhook_secret", SECRET)
|
| 90 |
+
monkeypatch.setattr(stripe_billing, "client", lambda: fake)
|
| 91 |
+
return fake
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@pytest.fixture()
|
| 95 |
+
def owner(client, db_session):
|
| 96 |
+
client.post("/api/auth/email/register/start", json={"email": "grace@example.com", "password": "stardust-42",
|
| 97 |
+
"name": "Grace Hopper"})
|
| 98 |
+
r = client.post("/api/auth/email/register/verify", json={"email": "grace@example.com", "code": last_code()})
|
| 99 |
+
ws = db_session.scalar(select(Workspace))
|
| 100 |
+
return {"Authorization": f"Bearer {r.json()['access_token']}"}, ws
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def send(client, event: dict, secret: str = SECRET):
|
| 104 |
+
payload = json.dumps(event)
|
| 105 |
+
t = int(time.time())
|
| 106 |
+
sig = hmac.new(secret.encode(), f"{t}.{payload}".encode(), hashlib.sha256).hexdigest()
|
| 107 |
+
return client.post("/api/billing/webhook", content=payload,
|
| 108 |
+
headers={"stripe-signature": f"t={t},v1={sig}", "content-type": "application/json"})
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def event(kind: str, obj: dict) -> dict:
|
| 112 |
+
return {"id": f"evt_{uuid.uuid4().hex}", "object": "event", "type": kind, "data": {"object": obj}}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def plan_of(db, ws) -> str:
|
| 116 |
+
db.expire_all()
|
| 117 |
+
return effective_plan(db, ws).id
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_webhook_rejects_bad_signature(client, stripe_on, owner):
|
| 121 |
+
r = send(client, event("customer.subscription.updated", {"id": "sub_x"}), secret="whsec_wrong")
|
| 122 |
+
assert r.status_code == 400
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_checkout_completed_then_retry_and_out_of_order(client, stripe_on, owner, db_session):
|
| 126 |
+
_, ws = owner
|
| 127 |
+
stripe_on.add_sub("sub_1", ws.id, plan="writer")
|
| 128 |
+
done = event("checkout.session.completed", {"mode": "subscription", "subscription": "sub_1",
|
| 129 |
+
"client_reference_id": str(ws.id)})
|
| 130 |
+
assert send(client, done).json()["result"] == "synced"
|
| 131 |
+
assert plan_of(db_session, ws) == "writer"
|
| 132 |
+
# Stripe retries the same delivery: still fine, nothing doubles.
|
| 133 |
+
assert send(client, done).status_code == 200
|
| 134 |
+
assert db_session.scalar(select(Subscription).where(Subscription.workspace_id == ws.id)).plan == "writer"
|
| 135 |
+
# An older "incomplete" payload arrives late: we re-read Stripe, which says active.
|
| 136 |
+
stale = {**stripe_on.subs["sub_1"], "status": "incomplete"}
|
| 137 |
+
send(client, event("customer.subscription.updated", stale))
|
| 138 |
+
assert plan_of(db_session, ws) == "writer"
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_past_due_keeps_plan_and_emails_once_then_cancel_drops_to_free(client, stripe_on, owner, db_session):
|
| 142 |
+
_, ws = owner
|
| 143 |
+
stripe_on.add_sub("sub_2", ws.id, plan="studio")
|
| 144 |
+
send(client, event("customer.subscription.created", stripe_on.subs["sub_2"]))
|
| 145 |
+
stripe_on.subs["sub_2"]["status"] = "past_due"
|
| 146 |
+
ConsoleEmailProvider.sent.clear()
|
| 147 |
+
failed = event("invoice.payment_failed", {
|
| 148 |
+
"subscription": "sub_2", "customer": "cus_1", "customer_email": "grace@example.com",
|
| 149 |
+
"billing_reason": "subscription_cycle", "hosted_invoice_url": "https://invoice.stripe.test/i_2"})
|
| 150 |
+
send(client, failed)
|
| 151 |
+
send(client, failed) # retried delivery
|
| 152 |
+
assert plan_of(db_session, ws) == "studio" # grace while Stripe retries
|
| 153 |
+
assert len([m for m in ConsoleEmailProvider.sent if "payment failed" in m.subject.lower()]) == 1
|
| 154 |
+
stripe_on.subs["sub_2"]["status"] = "canceled"
|
| 155 |
+
send(client, event("customer.subscription.deleted", stripe_on.subs["sub_2"]))
|
| 156 |
+
assert plan_of(db_session, ws) == "free"
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def test_renewal_needing_3ds_emails_confirm_link(client, stripe_on, owner, db_session):
|
| 160 |
+
_, ws = owner
|
| 161 |
+
stripe_on.add_sub("sub_3", ws.id)
|
| 162 |
+
ConsoleEmailProvider.sent.clear()
|
| 163 |
+
send(client, event("invoice.payment_action_required", {
|
| 164 |
+
"subscription": "sub_3", "customer": "cus_1", "customer_email": "grace@example.com",
|
| 165 |
+
"billing_reason": "subscription_cycle", "hosted_invoice_url": "https://invoice.stripe.test/i_3"}))
|
| 166 |
+
mail = [m for m in ConsoleEmailProvider.sent if "confirm" in m.subject.lower()]
|
| 167 |
+
assert len(mail) == 1 and "https://invoice.stripe.test/i_3" in mail[0].text
|
| 168 |
+
assert plan_of(db_session, ws) == "writer"
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def test_checkout_creates_one_customer_and_confirm_applies_plan(client, stripe_on, owner, db_session):
|
| 172 |
+
headers, ws = owner
|
| 173 |
+
r = client.post("/api/billing/checkout", json={"plan": "writer", "cycle": "annual"}, headers=headers)
|
| 174 |
+
assert r.json()["url"] == "https://checkout.stripe.test/s_1"
|
| 175 |
+
kinds = [c[0] for c in stripe_on.calls]
|
| 176 |
+
assert kinds == ["customer", "checkout"]
|
| 177 |
+
session = stripe_on.calls[1][1]
|
| 178 |
+
assert session["customer"] == "cus_1" and session["client_reference_id"] == str(ws.id)
|
| 179 |
+
assert "{CHECKOUT_SESSION_ID}" in session["success_url"]
|
| 180 |
+
assert session["line_items"][0]["price"] == "price_notestack_writer_annual"
|
| 181 |
+
# Back on the success page before any webhook: confirm applies the plan.
|
| 182 |
+
stripe_on.add_sub("sub_4", ws.id, plan="writer")
|
| 183 |
+
stripe_on.session = {"client_reference_id": str(ws.id), "status": "complete",
|
| 184 |
+
"subscription": stripe_on.subs["sub_4"]}
|
| 185 |
+
r = client.post("/api/billing/confirm", json={"session_id": "cs_1"}, headers=headers)
|
| 186 |
+
assert r.json()["plan"]["id"] == "writer"
|
| 187 |
+
# A second checkout while subscribed changes the plan in place instead of a second subscription.
|
| 188 |
+
r = client.post("/api/billing/checkout", json={"plan": "studio", "cycle": "monthly"}, headers=headers)
|
| 189 |
+
assert r.json()["url"].endswith("/app?checkout=success")
|
| 190 |
+
assert [c[0] for c in stripe_on.calls].count("customer") == 1
|
| 191 |
+
assert plan_of(db_session, ws) == "studio"
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def test_upgrade_needing_3ds_sends_user_to_invoice(client, stripe_on, owner, db_session):
|
| 195 |
+
headers, ws = owner
|
| 196 |
+
stripe_on.add_sub("sub_5", ws.id, plan="writer")
|
| 197 |
+
send(client, event("customer.subscription.created", stripe_on.subs["sub_5"]))
|
| 198 |
+
stripe_on.pending = True
|
| 199 |
+
r = client.post("/api/billing/checkout", json={"plan": "studio", "cycle": "monthly"}, headers=headers)
|
| 200 |
+
assert r.json()["url"] == "https://invoice.stripe.test/i_1"
|
| 201 |
+
assert plan_of(db_session, ws) == "writer" # switches only once the payment succeeds
|
deploy/env.prod.example
CHANGED
|
@@ -65,3 +65,16 @@ LINKEDIN_CLIENT_ID=
|
|
| 65 |
LINKEDIN_CLIENT_SECRET=
|
| 66 |
|
| 67 |
BILLING_ENABLED=false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
LINKEDIN_CLIENT_SECRET=
|
| 66 |
|
| 67 |
BILLING_ENABLED=false
|
| 68 |
+
# Stripe. Checkout, the customer portal and webhooks. Prices are found or created by lookup key
|
| 69 |
+
# (notestack_<plan>_<cycle>) from backend/app/services/plans.py; set STRIPE_PRICE_* to pin existing ones.
|
| 70 |
+
# Webhook endpoint: {API_URL}/api/billing/webhook with events checkout.session.completed,
|
| 71 |
+
# checkout.session.async_payment_succeeded, checkout.session.async_payment_failed,
|
| 72 |
+
# customer.subscription.created, customer.subscription.updated, customer.subscription.deleted,
|
| 73 |
+
# customer.subscription.paused, customer.subscription.resumed, invoice.paid, invoice.payment_failed,
|
| 74 |
+
# invoice.payment_action_required, charge.dispute.created.
|
| 75 |
+
STRIPE_SECRET_KEY=
|
| 76 |
+
STRIPE_WEBHOOK_SECRET=
|
| 77 |
+
STRIPE_PRICE_WRITER_MONTHLY=
|
| 78 |
+
STRIPE_PRICE_WRITER_ANNUAL=
|
| 79 |
+
STRIPE_PRICE_STUDIO_MONTHLY=
|
| 80 |
+
STRIPE_PRICE_STUDIO_ANNUAL=
|
frontend/src/api/endpoints.ts
CHANGED
|
@@ -187,4 +187,5 @@ export const billingApi = {
|
|
| 187 |
status: () => api<BillingStatus>("/api/billing/status"),
|
| 188 |
checkout: (plan: string, cycle: BillingCycle) => post<{ url: string }>("/api/billing/checkout", { plan, cycle }),
|
| 189 |
portal: () => post<{ url: string }>("/api/billing/portal"),
|
|
|
|
| 190 |
};
|
|
|
|
| 187 |
status: () => api<BillingStatus>("/api/billing/status"),
|
| 188 |
checkout: (plan: string, cycle: BillingCycle) => post<{ url: string }>("/api/billing/checkout", { plan, cycle }),
|
| 189 |
portal: () => post<{ url: string }>("/api/billing/portal"),
|
| 190 |
+
confirm: (sessionId: string) => post<BillingStatus>("/api/billing/confirm", { session_id: sessionId }),
|
| 191 |
};
|
frontend/src/components/UpgradeModal.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
|
|
| 3 |
import type { BillingCycle } from "../api/endpoints";
|
| 4 |
import type { Meter, PlanInfo } from "../api/types";
|
| 5 |
import { useUpgrade } from "../hooks/useUpgrade";
|
| 6 |
-
import { CheckIcon
|
| 7 |
import { annualMonthly, annualTotal, money } from "./PricingTiers";
|
| 8 |
|
| 9 |
const ORDER = ["free", "writer", "studio"];
|
|
@@ -62,9 +62,6 @@ function Scene({ mode }: { mode: "explore" | "limit" | "welcome" }) {
|
|
| 62 |
<circle cx="106" cy="76" r="3" className="upg-crater" />
|
| 63 |
<path d="M8 104a92 20 0 0 0 184 0" className="upg-ring front" />
|
| 64 |
</svg>
|
| 65 |
-
<span className="upg-rocket">
|
| 66 |
-
<RocketIcon size={22} />
|
| 67 |
-
</span>
|
| 68 |
</div>
|
| 69 |
</div>
|
| 70 |
);
|
|
@@ -93,6 +90,11 @@ export default function UpgradeModal() {
|
|
| 93 |
const [cycle, setCycle] = useState<BillingCycle>("annual");
|
| 94 |
const panel = useRef<HTMLDivElement>(null);
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
useEffect(() => {
|
| 97 |
if (!modal) return;
|
| 98 |
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
|
|
@@ -165,7 +167,7 @@ export default function UpgradeModal() {
|
|
| 165 |
<>
|
| 166 |
<div className="upg-cycle">
|
| 167 |
<div className="cycle-toggle" role="radiogroup" aria-label="Billing cycle">
|
| 168 |
-
{(["
|
| 169 |
<button key={c} type="button" role="radio" aria-checked={cycle === c} className={cycle === c ? "on" : ""} onClick={() => setCycle(c)}>
|
| 170 |
{c === "monthly" ? "Monthly" : "Annual"}
|
| 171 |
{c === "annual" && offers[0]?.annual_savings_pct > 0 && <span className="save mono">Save {offers[0].annual_savings_pct}%</span>}
|
|
|
|
| 3 |
import type { BillingCycle } from "../api/endpoints";
|
| 4 |
import type { Meter, PlanInfo } from "../api/types";
|
| 5 |
import { useUpgrade } from "../hooks/useUpgrade";
|
| 6 |
+
import { CheckIcon } from "./icons/Icons";
|
| 7 |
import { annualMonthly, annualTotal, money } from "./PricingTiers";
|
| 8 |
|
| 9 |
const ORDER = ["free", "writer", "studio"];
|
|
|
|
| 62 |
<circle cx="106" cy="76" r="3" className="upg-crater" />
|
| 63 |
<path d="M8 104a92 20 0 0 0 184 0" className="upg-ring front" />
|
| 64 |
</svg>
|
|
|
|
|
|
|
|
|
|
| 65 |
</div>
|
| 66 |
</div>
|
| 67 |
);
|
|
|
|
| 90 |
const [cycle, setCycle] = useState<BillingCycle>("annual");
|
| 91 |
const panel = useRef<HTMLDivElement>(null);
|
| 92 |
|
| 93 |
+
// Every open starts on annual.
|
| 94 |
+
useEffect(() => {
|
| 95 |
+
if (modal) setCycle("annual");
|
| 96 |
+
}, [modal]);
|
| 97 |
+
|
| 98 |
useEffect(() => {
|
| 99 |
if (!modal) return;
|
| 100 |
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
|
|
|
|
| 167 |
<>
|
| 168 |
<div className="upg-cycle">
|
| 169 |
<div className="cycle-toggle" role="radiogroup" aria-label="Billing cycle">
|
| 170 |
+
{(["annual", "monthly"] as const).map((c) => (
|
| 171 |
<button key={c} type="button" role="radio" aria-checked={cycle === c} className={cycle === c ? "on" : ""} onClick={() => setCycle(c)}>
|
| 172 |
{c === "monthly" ? "Monthly" : "Annual"}
|
| 173 |
{c === "annual" && offers[0]?.annual_savings_pct > 0 && <span className="save mono">Save {offers[0].annual_savings_pct}%</span>}
|
frontend/src/hooks/useUpgrade.tsx
CHANGED
|
@@ -107,10 +107,14 @@ export function UpgradeProvider({ children }: { children: ReactNode }) {
|
|
| 107 |
if (upgrade) setModal({ mode: "explore", highlight: upgrade === "1" ? null : upgrade });
|
| 108 |
if (checkoutResult === "success") {
|
| 109 |
setModal({ mode: "welcome" });
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
| 111 |
}
|
| 112 |
params.delete("upgrade");
|
| 113 |
params.delete("checkout");
|
|
|
|
| 114 |
const rest = params.toString();
|
| 115 |
navigate({ pathname: location.pathname, search: rest ? `?${rest}` : "" }, { replace: true });
|
| 116 |
openingDone.current = true;
|
|
|
|
| 107 |
if (upgrade) setModal({ mode: "explore", highlight: upgrade === "1" ? null : upgrade });
|
| 108 |
if (checkoutResult === "success") {
|
| 109 |
setModal({ mode: "welcome" });
|
| 110 |
+
// Apply the purchase now instead of waiting for Stripe's webhook, then show the new plan.
|
| 111 |
+
const sessionId = params.get("session_id");
|
| 112 |
+
if (sessionId) billingApi.confirm(sessionId).catch(() => undefined).finally(refresh);
|
| 113 |
+
else refresh();
|
| 114 |
}
|
| 115 |
params.delete("upgrade");
|
| 116 |
params.delete("checkout");
|
| 117 |
+
params.delete("session_id");
|
| 118 |
const rest = params.toString();
|
| 119 |
navigate({ pathname: location.pathname, search: rest ? `?${rest}` : "" }, { replace: true });
|
| 120 |
openingDone.current = true;
|
frontend/src/lib/highlight.tsx
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ReactNode } from "react";
|
| 2 |
+
|
| 3 |
+
/** Tiny syntax highlighter for chat code blocks: one tokenizing regex per language family, React spans
|
| 4 |
+
* out (never HTML). Good enough for reading, not a parser. Colors live in pages.css (.tok-*). */
|
| 5 |
+
|
| 6 |
+
const KEYWORDS: Record<string, string> = {
|
| 7 |
+
js: "async await break case catch class const continue default delete do else export extends false finally for from function if import in instanceof let new null of return static super switch this throw true try typeof undefined var void while yield interface type enum implements readonly as satisfies keyof public private protected",
|
| 8 |
+
py: "and as assert async await break class continue def del elif else except False finally for from global if import in is lambda None nonlocal not or pass raise return self True try while with yield match case",
|
| 9 |
+
sh: "if then else elif fi for while do done case esac in function return export local echo exit set unset source cd sudo",
|
| 10 |
+
sql: "select from where and or not insert into values update set delete create table index view join left right inner outer on group by order having limit offset as distinct union all null is in like between case when then else end primary key foreign references default returning with",
|
| 11 |
+
go: "break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var nil true false",
|
| 12 |
+
rust: "as async await break const continue crate else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while dyn Some None Ok Err",
|
| 13 |
+
c: "abstract boolean break byte case catch char class const continue default do double else enum extends final finally float for if implements import instanceof int interface long new null package private protected public return short static super switch this throw throws try void volatile while true false struct typedef unsigned sizeof include define namespace using template",
|
| 14 |
+
yaml: "true false null yes no on off",
|
| 15 |
+
css: "important media keyframes from to import supports",
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
const FAMILY: Record<string, string> = {
|
| 19 |
+
js: "js", jsx: "js", ts: "js", tsx: "js", javascript: "js", typescript: "js", mjs: "js", cjs: "js", json: "json",
|
| 20 |
+
py: "py", python: "py",
|
| 21 |
+
sh: "sh", bash: "sh", shell: "sh", zsh: "sh", console: "sh", powershell: "sh", ps1: "sh",
|
| 22 |
+
sql: "sql", postgres: "sql", sqlite: "sql",
|
| 23 |
+
go: "go", golang: "go",
|
| 24 |
+
rs: "rust", rust: "rust",
|
| 25 |
+
java: "c", c: "c", cpp: "c", "c++": "c", cs: "c", csharp: "c", kotlin: "c", swift: "c", php: "c",
|
| 26 |
+
yaml: "yaml", yml: "yaml", toml: "yaml", ini: "yaml",
|
| 27 |
+
css: "css", scss: "css",
|
| 28 |
+
html: "html", xml: "html", svg: "html", vue: "html",
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
const HASH_COMMENTS = new Set(["py", "sh", "yaml"]);
|
| 32 |
+
|
| 33 |
+
function tokenizer(family: string): RegExp {
|
| 34 |
+
if (family === "html") {
|
| 35 |
+
return /(<!--[\s\S]*?-->)|(<\/?[\w:-]+)|("[^"]*"|'[^']*')|([\w:-]+(?==))|(\/?>)/g;
|
| 36 |
+
}
|
| 37 |
+
const comment = HASH_COMMENTS.has(family)
|
| 38 |
+
? String.raw`#[^\n]*`
|
| 39 |
+
: family === "sql"
|
| 40 |
+
? String.raw`--[^\n]*|\/\*[\s\S]*?\*\/`
|
| 41 |
+
: String.raw`\/\/[^\n]*|\/\*[\s\S]*?\*\/`;
|
| 42 |
+
const strings = String.raw`"""[\s\S]*?"""|'''[\s\S]*?'''|` + "`(?:\\\\.|[^`\\\\])*`" + String.raw`|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'`;
|
| 43 |
+
return new RegExp(
|
| 44 |
+
`(${comment})|(${strings})|(\\b\\d[\\d_]*(?:\\.\\d+)?(?:e[+-]?\\d+)?\\b|\\b0x[\\da-f]+\\b)|([A-Za-z_$][\\w$]*)(?=\\s*\\()|([A-Za-z_$@][\\w$-]*)`,
|
| 45 |
+
"gi",
|
| 46 |
+
);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export function languageLabel(lang: string): string {
|
| 50 |
+
const l = lang.toLowerCase();
|
| 51 |
+
return l ? l : "text";
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export function highlight(code: string, lang: string): ReactNode[] {
|
| 55 |
+
const family = FAMILY[lang.toLowerCase()];
|
| 56 |
+
if (!family) return [code];
|
| 57 |
+
const out: ReactNode[] = [];
|
| 58 |
+
let last = 0;
|
| 59 |
+
let k = 0;
|
| 60 |
+
if (family === "json") {
|
| 61 |
+
for (const m of code.matchAll(/("(?:\\.|[^"\\])*")(\s*:)?|(\b-?\d+(?:\.\d+)?(?:e[+-]?\d+)?\b)|\b(true|false|null)\b/gi)) {
|
| 62 |
+
const i = m.index ?? 0;
|
| 63 |
+
if (i > last) out.push(code.slice(last, i));
|
| 64 |
+
if (m[1]) {
|
| 65 |
+
out.push(<span key={k++} className={m[2] ? "tok-prop" : "tok-str"}>{m[1]}</span>);
|
| 66 |
+
if (m[2]) out.push(m[2]);
|
| 67 |
+
} else out.push(<span key={k++} className={m[3] ? "tok-num" : "tok-kw"}>{m[0]}</span>);
|
| 68 |
+
last = i + m[0].length;
|
| 69 |
+
}
|
| 70 |
+
if (last < code.length) out.push(code.slice(last));
|
| 71 |
+
return out;
|
| 72 |
+
}
|
| 73 |
+
const words = new Set((KEYWORDS[family] ?? "").split(" "));
|
| 74 |
+
const re = tokenizer(family);
|
| 75 |
+
for (const m of code.matchAll(re)) {
|
| 76 |
+
const i = m.index ?? 0;
|
| 77 |
+
if (i > last) out.push(code.slice(last, i));
|
| 78 |
+
const [whole, a, b, c, d, e] = m;
|
| 79 |
+
let cls: string | null = null;
|
| 80 |
+
if (family === "html") cls = a ? "tok-com" : b ? "tok-kw" : c ? "tok-str" : d ? "tok-prop" : e ? "tok-kw" : null;
|
| 81 |
+
else if (a) cls = "tok-com";
|
| 82 |
+
else if (b) cls = "tok-str";
|
| 83 |
+
else if (c) cls = "tok-num";
|
| 84 |
+
else if (d) cls = words.has(family === "sql" ? d.toLowerCase() : d) ? "tok-kw" : "tok-fn";
|
| 85 |
+
else if (e) cls = words.has(family === "sql" ? e.toLowerCase() : e) ? "tok-kw" : /^[A-Z][A-Za-z0-9]+$/.test(e) ? "tok-type" : null;
|
| 86 |
+
out.push(cls ? <span key={k++} className={cls}>{whole}</span> : whole);
|
| 87 |
+
last = i + whole.length;
|
| 88 |
+
}
|
| 89 |
+
if (last < code.length) out.push(code.slice(last));
|
| 90 |
+
return out;
|
| 91 |
+
}
|
frontend/src/styles/upgrade.css
CHANGED
|
@@ -64,7 +64,7 @@
|
|
| 64 |
margin: 0;
|
| 65 |
}
|
| 66 |
|
| 67 |
-
/* Scene: drifting stars
|
| 68 |
.upg-scene {
|
| 69 |
position: relative;
|
| 70 |
height: 190px;
|
|
@@ -141,22 +141,6 @@
|
|
| 141 |
stroke: rgba(255, 255, 255, 0.15);
|
| 142 |
}
|
| 143 |
|
| 144 |
-
/* The rocket flies an ellipse around the planet (the ring's plane). */
|
| 145 |
-
.upg-rocket {
|
| 146 |
-
position: absolute;
|
| 147 |
-
top: 0;
|
| 148 |
-
left: 0;
|
| 149 |
-
display: grid;
|
| 150 |
-
place-items: center;
|
| 151 |
-
width: 26px;
|
| 152 |
-
height: 26px;
|
| 153 |
-
color: var(--white);
|
| 154 |
-
filter: drop-shadow(0 0 6px var(--blue));
|
| 155 |
-
offset-path: path("M -20 92 a 105 32 0 1 0 210 0 a 105 32 0 1 0 -210 0");
|
| 156 |
-
offset-rotate: 0deg;
|
| 157 |
-
animation: upg-fly 9s linear infinite;
|
| 158 |
-
}
|
| 159 |
-
|
| 160 |
/* Explore: warp streaks racing past */
|
| 161 |
.upg-warp span {
|
| 162 |
position: absolute;
|
|
@@ -189,17 +173,11 @@
|
|
| 189 |
.upg-warp span:nth-child(13) { --a: 337deg; animation-delay: 1.4s; }
|
| 190 |
.upg-warp span:nth-child(14) { --a: 355deg; animation-delay: 2s; }
|
| 191 |
|
| 192 |
-
/* Limit: the
|
| 193 |
.upg-limit .upg-planet {
|
| 194 |
filter: drop-shadow(0 0 10px var(--blue-20)) saturate(0.6);
|
| 195 |
}
|
| 196 |
|
| 197 |
-
.upg-limit .upg-rocket {
|
| 198 |
-
animation:
|
| 199 |
-
upg-fly 28s linear infinite,
|
| 200 |
-
upg-sputter 1.4s steps(2, jump-none) infinite;
|
| 201 |
-
}
|
| 202 |
-
|
| 203 |
/* Welcome: a burst of sparkles */
|
| 204 |
.upg-burst span {
|
| 205 |
position: absolute;
|
|
@@ -570,10 +548,6 @@
|
|
| 570 |
50% { transform: translateY(-5px); }
|
| 571 |
}
|
| 572 |
|
| 573 |
-
@keyframes upg-fly {
|
| 574 |
-
to { offset-distance: 100%; }
|
| 575 |
-
}
|
| 576 |
-
|
| 577 |
@keyframes upg-warp {
|
| 578 |
0% { opacity: 0; transform: rotate(var(--a)) translateX(10px) scaleX(0.2); }
|
| 579 |
30% { opacity: 1; }
|
|
@@ -585,10 +559,6 @@
|
|
| 585 |
100% { opacity: 0; transform: rotate(var(--a)) translateX(150px) scale(0.3); }
|
| 586 |
}
|
| 587 |
|
| 588 |
-
@keyframes upg-sputter {
|
| 589 |
-
50% { opacity: 0.35; }
|
| 590 |
-
}
|
| 591 |
-
|
| 592 |
@keyframes upg-blink {
|
| 593 |
50% { opacity: 0.3; }
|
| 594 |
}
|
|
|
|
| 64 |
margin: 0;
|
| 65 |
}
|
| 66 |
|
| 67 |
+
/* Scene: drifting stars and a ringed planet */
|
| 68 |
.upg-scene {
|
| 69 |
position: relative;
|
| 70 |
height: 190px;
|
|
|
|
| 141 |
stroke: rgba(255, 255, 255, 0.15);
|
| 142 |
}
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
/* Explore: warp streaks racing past */
|
| 145 |
.upg-warp span {
|
| 146 |
position: absolute;
|
|
|
|
| 173 |
.upg-warp span:nth-child(13) { --a: 337deg; animation-delay: 1.4s; }
|
| 174 |
.upg-warp span:nth-child(14) { --a: 355deg; animation-delay: 2s; }
|
| 175 |
|
| 176 |
+
/* Limit: the planet dims */
|
| 177 |
.upg-limit .upg-planet {
|
| 178 |
filter: drop-shadow(0 0 10px var(--blue-20)) saturate(0.6);
|
| 179 |
}
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
/* Welcome: a burst of sparkles */
|
| 182 |
.upg-burst span {
|
| 183 |
position: absolute;
|
|
|
|
| 548 |
50% { transform: translateY(-5px); }
|
| 549 |
}
|
| 550 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
@keyframes upg-warp {
|
| 552 |
0% { opacity: 0; transform: rotate(var(--a)) translateX(10px) scaleX(0.2); }
|
| 553 |
30% { opacity: 1; }
|
|
|
|
| 559 |
100% { opacity: 0; transform: rotate(var(--a)) translateX(150px) scale(0.3); }
|
| 560 |
}
|
| 561 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 562 |
@keyframes upg-blink {
|
| 563 |
50% { opacity: 0.3; }
|
| 564 |
}
|