Spaces:
Build error
Build error
Commit ·
dadf87a
1
Parent(s): 590ac0a
Upload folder using huggingface_hub
Browse files- app/api/routes_payments.py +21 -10
- app/core/usage_tracker.py +33 -14
- tests/conftest.py +3 -0
- tests/test_payments.py +32 -3
app/api/routes_payments.py
CHANGED
|
@@ -5,11 +5,11 @@ Payment endpoints – Stripe Checkout integration.
|
|
| 5 |
import logging
|
| 6 |
import os
|
| 7 |
import stripe
|
| 8 |
-
from fastapi import APIRouter, HTTPException
|
| 9 |
from pydantic import BaseModel
|
| 10 |
|
| 11 |
from app.core import usage_tracker
|
| 12 |
-
from app.core.usage_tracker import Tier
|
| 13 |
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
|
|
@@ -21,23 +21,34 @@ STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
|
|
| 21 |
|
| 22 |
|
| 23 |
class CheckoutRequest(BaseModel):
|
| 24 |
-
api_key: str
|
| 25 |
-
|
| 26 |
success_url: str
|
| 27 |
cancel_url: str
|
| 28 |
|
| 29 |
|
| 30 |
@router.post("/create-checkout-session")
|
| 31 |
-
async def create_checkout_session(
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
if not stripe.api_key:
|
| 34 |
raise HTTPException(status_code=500, detail="Stripe not configured")
|
| 35 |
if not usage_tracker.tracker:
|
| 36 |
raise HTTPException(status_code=503, detail="Usage tracking service not initialised")
|
| 37 |
|
| 38 |
-
|
| 39 |
-
tier = usage_tracker.tracker.get_tier(req.api_key)
|
| 40 |
-
if tier != Tier.FREE:
|
| 41 |
raise HTTPException(status_code=400,
|
| 42 |
detail="Only free tier keys can be upgraded")
|
| 43 |
|
|
@@ -48,7 +59,7 @@ async def create_checkout_session(req: CheckoutRequest):
|
|
| 48 |
# third party with no reason to ever see it. tenant_id is an opaque,
|
| 49 |
# non-secret row identifier and is exactly what the webhook needs to
|
| 50 |
# look up which tenant's keys to retier.
|
| 51 |
-
tenant_id =
|
| 52 |
|
| 53 |
try:
|
| 54 |
checkout_session = stripe.checkout.Session.create(
|
|
|
|
| 5 |
import logging
|
| 6 |
import os
|
| 7 |
import stripe
|
| 8 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 9 |
from pydantic import BaseModel
|
| 10 |
|
| 11 |
from app.core import usage_tracker
|
| 12 |
+
from app.core.usage_tracker import Tier, resolve_api_key_identity
|
| 13 |
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
class CheckoutRequest(BaseModel):
|
|
|
|
|
|
|
| 24 |
success_url: str
|
| 25 |
cancel_url: str
|
| 26 |
|
| 27 |
|
| 28 |
@router.post("/create-checkout-session")
|
| 29 |
+
async def create_checkout_session(
|
| 30 |
+
req: CheckoutRequest,
|
| 31 |
+
identity: dict = Depends(resolve_api_key_identity),
|
| 32 |
+
):
|
| 33 |
+
"""Create a Stripe Checkout session for the Pro tier.
|
| 34 |
+
|
| 35 |
+
Identity comes from `resolve_api_key_identity` (Authorization: Bearer
|
| 36 |
+
header, same pepper-HMAC lookup every other authenticated endpoint
|
| 37 |
+
uses) rather than from a caller-supplied field in the request body --
|
| 38 |
+
previously this endpoint took `api_key` as a JSON field with no
|
| 39 |
+
`Depends()` gate at all, so nothing distinguished "the caller proved
|
| 40 |
+
they hold this key" from "the caller typed this string into a
|
| 41 |
+
request." `enforce_quota` is deliberately not used here: a FREE-tier
|
| 42 |
+
caller who has exhausted their monthly quota must still be able to
|
| 43 |
+
reach this endpoint, since upgrading is often exactly what they are
|
| 44 |
+
trying to do.
|
| 45 |
+
"""
|
| 46 |
if not stripe.api_key:
|
| 47 |
raise HTTPException(status_code=500, detail="Stripe not configured")
|
| 48 |
if not usage_tracker.tracker:
|
| 49 |
raise HTTPException(status_code=503, detail="Usage tracking service not initialised")
|
| 50 |
|
| 51 |
+
if identity["tier"] != Tier.FREE:
|
|
|
|
|
|
|
| 52 |
raise HTTPException(status_code=400,
|
| 53 |
detail="Only free tier keys can be upgraded")
|
| 54 |
|
|
|
|
| 59 |
# third party with no reason to ever see it. tenant_id is an opaque,
|
| 60 |
# non-secret row identifier and is exactly what the webhook needs to
|
| 61 |
# look up which tenant's keys to retier.
|
| 62 |
+
tenant_id = identity["tenant_id"]
|
| 63 |
|
| 64 |
try:
|
| 65 |
checkout_session = stripe.checkout.Session.create(
|
app/core/usage_tracker.py
CHANGED
|
@@ -660,20 +660,30 @@ def update_key_tier_by_tenant_id(tenant_id: str, new_tier: Tier) -> bool:
|
|
| 660 |
return tracker.update_tier_by_tenant_id(tenant_id, new_tier)
|
| 661 |
|
| 662 |
|
| 663 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 664 |
"""
|
| 665 |
-
FastAPI dependency that
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
"""
|
| 667 |
if tracker is None:
|
| 668 |
raise HTTPException(status_code=503, detail="Usage tracking service not initialised.")
|
| 669 |
|
| 670 |
-
|
| 671 |
-
auth_header = request.headers.get("Authorization")
|
| 672 |
-
if auth_header and auth_header.startswith("Bearer "):
|
| 673 |
-
api_key = auth_header[7:]
|
| 674 |
-
else:
|
| 675 |
-
api_key = request.query_params.get("api_key")
|
| 676 |
-
|
| 677 |
if not api_key:
|
| 678 |
raise HTTPException(status_code=401, detail="Missing API key")
|
| 679 |
|
|
@@ -681,11 +691,6 @@ async def enforce_quota(request: Request, api_key: str = None):
|
|
| 681 |
if tier is None:
|
| 682 |
raise HTTPException(status_code=403, detail="Invalid or inactive API key")
|
| 683 |
|
| 684 |
-
remaining = tracker.get_remaining_quota(api_key, tier)
|
| 685 |
-
if remaining is not None and remaining <= 0:
|
| 686 |
-
raise HTTPException(status_code=429, detail="Monthly evaluation quota exceeded")
|
| 687 |
-
|
| 688 |
-
# Retrieve tenant_id
|
| 689 |
tenant_id = tracker.get_tenant_id(api_key)
|
| 690 |
if not tenant_id:
|
| 691 |
raise HTTPException(status_code=403, detail="API key not associated with a tenant")
|
|
@@ -694,4 +699,18 @@ async def enforce_quota(request: Request, api_key: str = None):
|
|
| 694 |
request.state.tier = tier
|
| 695 |
request.state.tenant_id = tenant_id
|
| 696 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 697 |
return {"api_key": api_key, "tier": tier, "tenant_id": tenant_id, "remaining": remaining}
|
|
|
|
| 660 |
return tracker.update_tier_by_tenant_id(tenant_id, new_tier)
|
| 661 |
|
| 662 |
|
| 663 |
+
def _extract_api_key(request: Request, api_key: str = None) -> str:
|
| 664 |
+
if api_key:
|
| 665 |
+
return api_key
|
| 666 |
+
auth_header = request.headers.get("Authorization")
|
| 667 |
+
if auth_header and auth_header.startswith("Bearer "):
|
| 668 |
+
return auth_header[7:]
|
| 669 |
+
return request.query_params.get("api_key")
|
| 670 |
+
|
| 671 |
+
|
| 672 |
+
async def resolve_api_key_identity(request: Request, api_key: str = None):
|
| 673 |
"""
|
| 674 |
+
FastAPI dependency that authenticates an API key and attaches tenant_id
|
| 675 |
+
to request state, without enforcing monthly quota.
|
| 676 |
+
|
| 677 |
+
Deliberately separate from `enforce_quota`: a caller whose quota is
|
| 678 |
+
already exhausted must still be able to reach an endpoint like
|
| 679 |
+
`/payments/create-checkout-session` (upgrading tier is often exactly
|
| 680 |
+
what a rate-limited caller is trying to do) -- gating that path behind
|
| 681 |
+
`enforce_quota` would 429 the one action that lets them fix it.
|
| 682 |
"""
|
| 683 |
if tracker is None:
|
| 684 |
raise HTTPException(status_code=503, detail="Usage tracking service not initialised.")
|
| 685 |
|
| 686 |
+
api_key = _extract_api_key(request, api_key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 687 |
if not api_key:
|
| 688 |
raise HTTPException(status_code=401, detail="Missing API key")
|
| 689 |
|
|
|
|
| 691 |
if tier is None:
|
| 692 |
raise HTTPException(status_code=403, detail="Invalid or inactive API key")
|
| 693 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 694 |
tenant_id = tracker.get_tenant_id(api_key)
|
| 695 |
if not tenant_id:
|
| 696 |
raise HTTPException(status_code=403, detail="API key not associated with a tenant")
|
|
|
|
| 699 |
request.state.tier = tier
|
| 700 |
request.state.tenant_id = tenant_id
|
| 701 |
|
| 702 |
+
return {"api_key": api_key, "tier": tier, "tenant_id": tenant_id}
|
| 703 |
+
|
| 704 |
+
|
| 705 |
+
async def enforce_quota(request: Request, api_key: str = None):
|
| 706 |
+
"""
|
| 707 |
+
FastAPI dependency that enforces quota and attaches tenant_id to request state.
|
| 708 |
+
"""
|
| 709 |
+
identity = await resolve_api_key_identity(request, api_key)
|
| 710 |
+
api_key, tier, tenant_id = identity["api_key"], identity["tier"], identity["tenant_id"]
|
| 711 |
+
|
| 712 |
+
remaining = tracker.get_remaining_quota(api_key, tier)
|
| 713 |
+
if remaining is not None and remaining <= 0:
|
| 714 |
+
raise HTTPException(status_code=429, detail="Monthly evaluation quota exceeded")
|
| 715 |
+
|
| 716 |
return {"api_key": api_key, "tier": tier, "tenant_id": tenant_id, "remaining": remaining}
|
tests/conftest.py
CHANGED
|
@@ -46,6 +46,9 @@ class MockTracker:
|
|
| 46 |
|
| 47 |
return 1000
|
| 48 |
|
|
|
|
|
|
|
|
|
|
| 49 |
def consume_quota_and_log(self, record, idempotency_key=None):
|
| 50 |
|
| 51 |
return (True, None)
|
|
|
|
| 46 |
|
| 47 |
return 1000
|
| 48 |
|
| 49 |
+
def get_tenant_id(self, api_key):
|
| 50 |
+
return "test-tenant"
|
| 51 |
+
|
| 52 |
def consume_quota_and_log(self, record, idempotency_key=None):
|
| 53 |
|
| 54 |
return (True, None)
|
tests/test_payments.py
CHANGED
|
@@ -28,8 +28,8 @@ def test_create_checkout_session_missing_stripe_key(monkeypatch):
|
|
| 28 |
monkeypatch.setattr("stripe.api_key", None)
|
| 29 |
response = client.post(
|
| 30 |
"/api/v1/payments/create-checkout-session",
|
|
|
|
| 31 |
json={
|
| 32 |
-
"api_key": "test_key",
|
| 33 |
"success_url": "https://example.com/success",
|
| 34 |
"cancel_url": "https://example.com/cancel"})
|
| 35 |
assert response.status_code == 500
|
|
@@ -45,8 +45,8 @@ def test_create_checkout_session_free_key(mock_stripe):
|
|
| 45 |
id="cs_test_123", url="https://checkout.stripe.com/pay")
|
| 46 |
response = client.post(
|
| 47 |
"/api/v1/payments/create-checkout-session",
|
|
|
|
| 48 |
json={
|
| 49 |
-
"api_key": "test_key",
|
| 50 |
"success_url": "https://example.com/success",
|
| 51 |
"cancel_url": "https://example.com/cancel"})
|
| 52 |
assert response.status_code == 200
|
|
@@ -60,10 +60,39 @@ def test_create_checkout_session_pro_key():
|
|
| 60 |
mock_tracker.get_tier.return_value = "pro"
|
| 61 |
response = client.post(
|
| 62 |
"/api/v1/payments/create-checkout-session",
|
|
|
|
| 63 |
json={
|
| 64 |
-
"api_key": "test_key",
|
| 65 |
"success_url": "https://example.com/success",
|
| 66 |
"cancel_url": "https://example.com/cancel"})
|
| 67 |
assert response.status_code == 400
|
| 68 |
assert "Only free tier keys can be upgraded" in response.json()[
|
| 69 |
"detail"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
monkeypatch.setattr("stripe.api_key", None)
|
| 29 |
response = client.post(
|
| 30 |
"/api/v1/payments/create-checkout-session",
|
| 31 |
+
headers={"Authorization": "Bearer test_key"},
|
| 32 |
json={
|
|
|
|
| 33 |
"success_url": "https://example.com/success",
|
| 34 |
"cancel_url": "https://example.com/cancel"})
|
| 35 |
assert response.status_code == 500
|
|
|
|
| 45 |
id="cs_test_123", url="https://checkout.stripe.com/pay")
|
| 46 |
response = client.post(
|
| 47 |
"/api/v1/payments/create-checkout-session",
|
| 48 |
+
headers={"Authorization": "Bearer test_key"},
|
| 49 |
json={
|
|
|
|
| 50 |
"success_url": "https://example.com/success",
|
| 51 |
"cancel_url": "https://example.com/cancel"})
|
| 52 |
assert response.status_code == 200
|
|
|
|
| 60 |
mock_tracker.get_tier.return_value = "pro"
|
| 61 |
response = client.post(
|
| 62 |
"/api/v1/payments/create-checkout-session",
|
| 63 |
+
headers={"Authorization": "Bearer test_key"},
|
| 64 |
json={
|
|
|
|
| 65 |
"success_url": "https://example.com/success",
|
| 66 |
"cancel_url": "https://example.com/cancel"})
|
| 67 |
assert response.status_code == 400
|
| 68 |
assert "Only free tier keys can be upgraded" in response.json()[
|
| 69 |
"detail"]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_create_checkout_session_requires_authentication(mock_stripe):
|
| 73 |
+
# Regression test: this endpoint used to take `api_key` as a plain JSON
|
| 74 |
+
# body field with no Depends() gate at all, so any caller could request
|
| 75 |
+
# a session for any tenant_id-bearing key string without proving they
|
| 76 |
+
# held it. No Authorization header (and no api_key in the body -- the
|
| 77 |
+
# field no longer exists on CheckoutRequest) must be rejected before
|
| 78 |
+
# ever reaching Stripe.
|
| 79 |
+
response = client.post(
|
| 80 |
+
"/api/v1/payments/create-checkout-session",
|
| 81 |
+
json={
|
| 82 |
+
"success_url": "https://example.com/success",
|
| 83 |
+
"cancel_url": "https://example.com/cancel"})
|
| 84 |
+
assert response.status_code == 401
|
| 85 |
+
mock_stripe.assert_not_called()
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_create_checkout_session_rejects_invalid_key(mock_stripe):
|
| 89 |
+
with patch("app.core.usage_tracker.tracker") as mock_tracker:
|
| 90 |
+
mock_tracker.get_tier.return_value = None
|
| 91 |
+
response = client.post(
|
| 92 |
+
"/api/v1/payments/create-checkout-session",
|
| 93 |
+
headers={"Authorization": "Bearer not-a-real-key"},
|
| 94 |
+
json={
|
| 95 |
+
"success_url": "https://example.com/success",
|
| 96 |
+
"cancel_url": "https://example.com/cancel"})
|
| 97 |
+
assert response.status_code == 403
|
| 98 |
+
mock_stripe.assert_not_called()
|