- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
289 lines
11 KiB
Python
289 lines
11 KiB
Python
"""Stripe Integration - credit card payments for x402 API credits.
|
|
|
|
Users buy credits with a card → we credit their API key → they use x402 tools.
|
|
The x402 payment protocol runs on our backend. The user never touches crypto.
|
|
|
|
Endpoints:
|
|
POST /api/v1/stripe/checkout - Create Stripe checkout session
|
|
POST /api/v1/stripe/webhook - Stripe webhook (payment confirmations)
|
|
GET /api/v1/stripe/portal - Customer portal (manage subscriptions)
|
|
GET /api/v1/credits/balance - Check remaining credits
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel
|
|
|
|
logger = logging.getLogger("stripe")
|
|
|
|
# Stripe keys from environment
|
|
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "")
|
|
STRIPE_PUBLISHABLE_KEY = os.getenv("STRIPE_PUBLISHABLE_KEY", "")
|
|
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "")
|
|
|
|
try:
|
|
import stripe as _stripe_check # noqa: F401
|
|
STRIPE_PACKAGE_INSTALLED = True
|
|
except ImportError:
|
|
STRIPE_PACKAGE_INSTALLED = False
|
|
|
|
STRIPE_ENABLED = bool(STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY and STRIPE_PACKAGE_INSTALLED)
|
|
|
|
# Credit packages (USD -> API credits)
|
|
CREDIT_PACKAGES = {
|
|
"starter": {"price_usd": 1000, "credits": 1000, "label": "Starter Pack - 1,000 credits"},
|
|
"growth": {"price_usd": 5000, "credits": 6000, "label": "Growth Pack - 6,000 credits (16% off)"},
|
|
"pro": {"price_usd": 10000, "credits": 14000, "label": "Pro Pack - 14,000 credits (28% off)"},
|
|
"enterprise": {"price_usd": 50000, "credits": 80000, "label": "Enterprise Pack - 80,000 credits (37% off)"},
|
|
}
|
|
|
|
# Monthly subscription tiers
|
|
SUBSCRIPTION_TIERS = {
|
|
"starter_monthly": {"price_usd": 2900, "credits_per_month": 1000, "label": "Starter - $29/mo"},
|
|
"pro_monthly": {"price_usd": 9900, "credits_per_month": 5000, "label": "Pro - $99/mo"},
|
|
"team_monthly": {"price_usd": 29900, "credits_per_month": 25000, "label": "Team - $299/mo"},
|
|
}
|
|
|
|
router = APIRouter(prefix="/api/v1", tags=["Stripe"])
|
|
|
|
|
|
class CheckoutRequest(BaseModel):
|
|
package: str = ""
|
|
tier: str = ""
|
|
success_url: str = ""
|
|
cancel_url: str = ""
|
|
api_key: str = ""
|
|
email: str = ""
|
|
|
|
|
|
class CreditEntry:
|
|
"""In-memory credit store - replace with Redis/Postgres in production."""
|
|
|
|
def __init__(self):
|
|
self._credits: dict[str, dict] = {}
|
|
|
|
def get_balance(self, api_key: str) -> int:
|
|
entry = self._credits.get(api_key, {"balance": 100}) # 100 free credits
|
|
return entry.get("balance", 0)
|
|
|
|
def add_credits(self, api_key: str, amount: int, source: str = "stripe"):
|
|
if api_key not in self._credits:
|
|
self._credits[api_key] = {"balance": 100, "total_purchased": 0, "created_at": time.time()}
|
|
self._credits[api_key]["balance"] += amount
|
|
self._credits[api_key]["total_purchased"] += amount
|
|
self._credits[api_key]["last_purchase"] = time.time()
|
|
logger.info(f"Credits added: {amount} to {api_key[:16]} via {source}")
|
|
|
|
def deduct(self, api_key: str, amount: int = 1) -> bool:
|
|
entry = self._credits.get(api_key)
|
|
if not entry or entry["balance"] < amount:
|
|
return False
|
|
entry["balance"] -= amount
|
|
return True
|
|
|
|
def spending(self, api_key: str) -> dict:
|
|
entry = self._credits.get(api_key, {"balance": 100, "total_purchased": 0})
|
|
return {
|
|
"balance": entry["balance"],
|
|
"total_purchased": entry["total_purchased"],
|
|
}
|
|
|
|
|
|
_credit_store = CreditEntry()
|
|
|
|
|
|
@router.post("/stripe/checkout")
|
|
async def create_checkout(req: CheckoutRequest):
|
|
"""Create a Stripe checkout session for credit purchase."""
|
|
if not STRIPE_ENABLED:
|
|
return JSONResponse(
|
|
status_code=503,
|
|
content={
|
|
"error": "Stripe not configured",
|
|
"note": "Add STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY to environment. Set STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY in environment",
|
|
"packages": {k: {"price_usd": v["price_usd"], "credits": v["credits"], "label": v["label"]} for k, v in CREDIT_PACKAGES.items()},
|
|
"subscriptions": {k: {"price_usd": v["price_usd"], "credits_per_month": v["credits_per_month"], "label": v["label"]} for k, v in SUBSCRIPTION_TIERS.items()},
|
|
},
|
|
)
|
|
|
|
try:
|
|
import stripe
|
|
stripe.api_key = STRIPE_SECRET_KEY
|
|
|
|
package = None
|
|
if req.package and req.package in CREDIT_PACKAGES:
|
|
package = CREDIT_PACKAGES[req.package]
|
|
elif req.tier and req.tier in SUBSCRIPTION_TIERS:
|
|
package = SUBSCRIPTION_TIERS[req.tier]
|
|
|
|
if not package:
|
|
packages_list = list(CREDIT_PACKAGES.keys()) + list(SUBSCRIPTION_TIERS.keys())
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"error": "Invalid package or tier", "available": packages_list},
|
|
)
|
|
|
|
is_subscription = req.tier in SUBSCRIPTION_TIERS
|
|
|
|
checkout_session = stripe.checkout.Session.create(
|
|
line_items=[
|
|
{
|
|
"price_data": {
|
|
"currency": "usd",
|
|
"product_data": {
|
|
"name": package["label"],
|
|
"description": f"{package.get('credits', package.get('credits_per_month', 0))} API credits for RugMunch Intelligence",
|
|
},
|
|
"unit_amount": package["price_usd"],
|
|
"recurring": {"interval": "month"} if is_subscription else None,
|
|
},
|
|
"quantity": 1,
|
|
}
|
|
],
|
|
mode="subscription" if is_subscription else "payment",
|
|
success_url=req.success_url or "https://rugmunch.io/credits?success=true",
|
|
cancel_url=req.cancel_url or "https://rugmunch.io/credits?canceled=true",
|
|
metadata={"api_key": req.api_key, "package": req.package or req.tier},
|
|
customer_email=req.email or None,
|
|
)
|
|
|
|
return {
|
|
"session_id": checkout_session.id,
|
|
"checkout_url": checkout_session.url,
|
|
"publishable_key": STRIPE_PUBLISHABLE_KEY,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Stripe checkout error: {e}")
|
|
return JSONResponse(status_code=500, content={"error": str(e)[:200]})
|
|
|
|
|
|
@router.post("/stripe/webhook")
|
|
async def stripe_webhook(request: Request):
|
|
"""Stripe webhook - called when payment succeeds."""
|
|
payload = await request.body()
|
|
sig_header = request.headers.get("stripe-signature", "")
|
|
|
|
if not STRIPE_ENABLED:
|
|
logger.warning("Stripe webhook received but Stripe not configured")
|
|
return {"received": True, "processed": False}
|
|
|
|
try:
|
|
import stripe
|
|
stripe.api_key = STRIPE_SECRET_KEY
|
|
|
|
if STRIPE_WEBHOOK_SECRET:
|
|
event = stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET)
|
|
else:
|
|
event = json.loads(payload)
|
|
|
|
if event.get("type") == "checkout.session.completed":
|
|
session = event["data"]["object"]
|
|
api_key = session.get("metadata", {}).get("api_key", "")
|
|
package_name = session.get("metadata", {}).get("package", "")
|
|
|
|
if package_name in CREDIT_PACKAGES:
|
|
credits = CREDIT_PACKAGES[package_name]["credits"]
|
|
elif package_name in SUBSCRIPTION_TIERS:
|
|
credits = SUBSCRIPTION_TIERS[package_name]["credits_per_month"]
|
|
else:
|
|
credits = 0
|
|
|
|
if api_key and credits > 0:
|
|
_credit_store.add_credits(api_key, credits)
|
|
logger.info(f"Stripe payment: {session.get('id','')} -> {credits} credits for {api_key[:16]}")
|
|
|
|
return {"received": True, "processed": True}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Stripe webhook error: {e}")
|
|
return JSONResponse(status_code=400, content={"error": str(e)[:200]})
|
|
|
|
|
|
@router.get("/stripe/packages")
|
|
async def list_packages():
|
|
"""List available credit packages and subscription tiers."""
|
|
return {
|
|
"packages": {k: {"price_usd": v["price_usd"], "credits": v["credits"], "label": v["label"]} for k, v in CREDIT_PACKAGES.items()},
|
|
"subscriptions": {k: {"price_usd": v["price_usd"], "credits_per_month": v["credits_per_month"], "label": v["label"]} for k, v in SUBSCRIPTION_TIERS.items()},
|
|
"stripe_configured": STRIPE_ENABLED,
|
|
"publishable_key": STRIPE_PUBLISHABLE_KEY or "",
|
|
"note": "Pay with credit card. No crypto wallet needed. Credits never expire.",
|
|
}
|
|
|
|
|
|
@router.get("/credits/balance")
|
|
async def check_balance(api_key: str = ""):
|
|
"""Check remaining credit balance for an API key."""
|
|
if not api_key:
|
|
return {"error": "api_key parameter required"}
|
|
spending = _credit_store.spending(api_key)
|
|
return {
|
|
"api_key": api_key[:16] + "...",
|
|
"balance": spending["balance"],
|
|
"total_purchased": spending["total_purchased"],
|
|
"free_tier": spending["total_purchased"] == 0,
|
|
}
|
|
|
|
|
|
@router.post("/credits/buy")
|
|
async def buy_credits(api_key: str = "", amount_usd: int = 1000):
|
|
"""Buy credits directly via API (wraps Stripe checkout).
|
|
|
|
In production, this creates a Stripe checkout session.
|
|
For testing, credits are added directly when STRIPE_ENABLED is False.
|
|
"""
|
|
if not api_key:
|
|
raise HTTPException(status_code=400, detail="api_key required")
|
|
|
|
if STRIPE_ENABLED:
|
|
import stripe
|
|
stripe.api_key = STRIPE_SECRET_KEY
|
|
session = stripe.checkout.Session.create(
|
|
line_items=[{
|
|
"price_data": {
|
|
"currency": "usd",
|
|
"product_data": {"name": "API Credits", "description": f"{amount_usd} credits"},
|
|
"unit_amount": amount_usd,
|
|
},
|
|
"quantity": 1,
|
|
}],
|
|
mode="payment",
|
|
metadata={"api_key": api_key, "manual_amount": str(amount_usd)},
|
|
)
|
|
return {"checkout_url": session.url, "session_id": session.id}
|
|
|
|
# Sandbox mode: credit directly
|
|
_credit_store.add_credits(api_key, amount_usd)
|
|
balance = _credit_store.get_balance(api_key)
|
|
return {
|
|
"credits_added": amount_usd,
|
|
"total_balance": balance,
|
|
"price_per_call": "$0.01",
|
|
"mode": "sandbox",
|
|
"note": "Stripe not configured - credits added in sandbox mode. Add STRIPE_SECRET_KEY for live payments.",
|
|
}
|
|
|
|
|
|
@router.get("/stripe/portal")
|
|
async def customer_portal(customer_id: str = ""):
|
|
"""Stripe customer portal for managing subscriptions."""
|
|
if not STRIPE_ENABLED or not customer_id:
|
|
return {
|
|
"available": STRIPE_ENABLED,
|
|
"note": "Manage your subscription at rugmunch.io/account",
|
|
}
|
|
try:
|
|
import stripe
|
|
stripe.api_key = STRIPE_SECRET_KEY
|
|
session = stripe.billing_portal.Session.create(customer=customer_id)
|
|
return {"portal_url": session.url}
|
|
except Exception as e:
|
|
return JSONResponse(status_code=500, content={"error": str(e)[:200]})
|