Skip to content

Commit 9f1a1ea

Browse files
committed
Payments and JWT integration
1 parent a4f13e9 commit 9f1a1ea

14 files changed

+1758
-135
lines changed

.env.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ IS_PROD=false
1717
WHITELIST_ENABLED=false
1818
X_API_KEY=your_secure_api_key_here
1919

20+
# For /suggestions endpoint via chrome extension
21+
JWT_SECRET=your_jwt_secret_key_here
22+
2023
# =============================================================================
2124
# 🤖 AI MODELS (Required)
2225
# =============================================================================
@@ -96,6 +99,17 @@ JINA_API_KEY=
9699
# RapidAPI (for external API data and other services)
97100
RAPIDAPI_KEY=
98101

102+
# =============================================================================
103+
# 💳 PAYMENT INTEGRATION (Optional)
104+
# =============================================================================
105+
# Dodo Payments integration for user plan management
106+
107+
# Dodo Payments API key for customer and subscription lookups
108+
DODO_API_KEY=your_dodo_payments_api_key_here
109+
110+
# Product ID for PRO plan identification in Dodo Payments
111+
PRO_PLAN_PRODUCT_ID=your_pro_plan_product_id_here
112+
99113
# =============================================================================
100114
# 📊 MONITORING & OBSERVABILITY (Optional)
101115
# =============================================================================

mxtoai/api.py

Lines changed: 147 additions & 124 deletions
Large diffs are not rendered by default.

mxtoai/schemas.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,10 @@ class ToolName(str, Enum):
5050

5151

5252
# Enum for Rate Limit Plans
53-
class RateLimitPlan(Enum):
53+
class UserPlan(Enum):
54+
FREE = "free"
5455
BETA = "beta"
56+
PRO = "pro"
5557

5658

5759
class EmailAttachment(BaseModel):

mxtoai/user.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""
2+
User plan management and Dodo Payments integration.
3+
4+
This module provides functionality to determine user subscription plans
5+
by integrating with the Dodo Payments API.
6+
"""
7+
8+
import os
9+
from datetime import datetime
10+
from typing import Any
11+
12+
import httpx
13+
from dotenv import load_dotenv
14+
15+
from mxtoai._logging import get_logger
16+
from mxtoai.schemas import UserPlan
17+
18+
# Load environment variables
19+
load_dotenv()
20+
21+
# Configure logging
22+
logger = get_logger(__name__)
23+
24+
# Dodo Payments API configuration
25+
DODO_API_KEY = os.getenv("DODO_API_KEY")
26+
PRO_PLAN_PRODUCT_ID = os.getenv("PRO_PLAN_PRODUCT_ID")
27+
DODO_API_BASE_URL = "https://live.dodopayments.com"
28+
29+
# HTTP client timeout configuration
30+
REQUEST_TIMEOUT = 30.0
31+
32+
# HTTP status codes
33+
HTTP_OK = 200
34+
35+
36+
async def get_user_plan(email: str) -> UserPlan:
37+
"""
38+
Determine user plan based on Dodo Payments subscription status.
39+
40+
Args:
41+
email: User's email address
42+
43+
Returns:
44+
UserPlan: The user's subscription plan (PRO or BETA)
45+
46+
Note:
47+
Falls back to UserPlan.BETA on any errors or missing configuration.
48+
49+
"""
50+
# Check if Dodo API key is configured
51+
if not DODO_API_KEY:
52+
logger.warning("DODO_API_KEY not configured, falling back to BETA plan for all users")
53+
return UserPlan.BETA
54+
55+
try:
56+
# Step 1: Look up customer by email
57+
customer_id = await _get_customer_id_by_email(email)
58+
if not customer_id:
59+
logger.info(f"No customer found for email {email}, returning BETA plan")
60+
return UserPlan.BETA
61+
62+
# Step 2: Get active subscriptions for the customer
63+
latest_subscription = await _get_latest_active_subscription(customer_id)
64+
if not latest_subscription:
65+
logger.info(f"No active subscriptions found for customer {customer_id}, returning BETA plan")
66+
return UserPlan.BETA
67+
68+
# Step 3: Check if subscription matches PRO plan product ID
69+
product_id = latest_subscription.get("product_id")
70+
if PRO_PLAN_PRODUCT_ID and product_id == PRO_PLAN_PRODUCT_ID:
71+
logger.info(f"User {email} has PRO plan subscription (product_id: {product_id})")
72+
return UserPlan.PRO
73+
logger.info(
74+
f"User {email} subscription does not match PRO plan (product_id: {product_id}), returning BETA plan"
75+
)
76+
77+
except Exception as e:
78+
logger.error(f"Error determining user plan for {email}: {e}")
79+
logger.warning(f"Falling back to BETA plan for user {email} due to error")
80+
81+
return UserPlan.BETA
82+
83+
84+
async def _get_customer_id_by_email(email: str) -> str | None:
85+
"""
86+
Look up customer ID by email address using Dodo Payments API.
87+
88+
Args:
89+
email: Customer's email address
90+
91+
Returns:
92+
str | None: Customer ID if found, None otherwise
93+
94+
"""
95+
try:
96+
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
97+
response = await client.get(
98+
f"{DODO_API_BASE_URL}/customers",
99+
headers={"Authorization": f"Bearer {DODO_API_KEY}", "Content-Type": "application/json"},
100+
params={"email": email},
101+
)
102+
103+
if response.status_code == HTTP_OK:
104+
data = response.json()
105+
customers = data.get("items", [])
106+
107+
if customers:
108+
customer = customers[0] # Take the first matching customer
109+
customer_id = customer.get("customer_id")
110+
logger.debug(f"Found customer {customer_id} for email {email}")
111+
return customer_id
112+
logger.debug(f"No customers found for email {email}")
113+
return None
114+
logger.error(f"Dodo Payments API error for customer lookup: {response.status_code} - {response.text}")
115+
return None
116+
117+
except httpx.TimeoutException:
118+
logger.error(f"Timeout while looking up customer for email {email}")
119+
return None
120+
except Exception as e:
121+
logger.error(f"Error looking up customer for email {email}: {e}")
122+
return None
123+
124+
125+
async def _get_latest_active_subscription(customer_id: str) -> dict[str, Any] | None:
126+
"""
127+
Get the latest active subscription for a customer.
128+
129+
Args:
130+
customer_id: Customer's ID from Dodo Payments
131+
132+
Returns:
133+
dict | None: Latest active subscription data if found, None otherwise
134+
135+
"""
136+
try:
137+
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
138+
response = await client.get(
139+
f"{DODO_API_BASE_URL}/subscriptions",
140+
headers={"Authorization": f"Bearer {DODO_API_KEY}", "Content-Type": "application/json"},
141+
params={"customer_id": customer_id, "status": "active"},
142+
)
143+
144+
if response.status_code == HTTP_OK:
145+
data = response.json()
146+
subscriptions = data.get("items", [])
147+
148+
if subscriptions:
149+
# Sort by created_at to get the latest subscription
150+
sorted_subscriptions = sorted(
151+
subscriptions,
152+
key=lambda x: datetime.fromisoformat(x.get("created_at", "1970-01-01T00:00:00Z")),
153+
reverse=True,
154+
)
155+
latest_subscription = sorted_subscriptions[0]
156+
logger.debug(
157+
f"Found {len(subscriptions)} active subscriptions for customer {customer_id}, using latest: {latest_subscription.get('subscription_id')}"
158+
)
159+
return latest_subscription
160+
logger.debug(f"No active subscriptions found for customer {customer_id}")
161+
return None
162+
logger.error(f"Dodo Payments API error for subscription lookup: {response.status_code} - {response.text}")
163+
return None
164+
165+
except httpx.TimeoutException:
166+
logger.error(f"Timeout while looking up subscriptions for customer {customer_id}")
167+
return None
168+
except Exception as e:
169+
logger.error(f"Error looking up subscriptions for customer {customer_id}: {e}")
170+
return None

mxtoai/validators.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from mxtoai.config import MAX_ATTACHMENT_SIZE_MB, MAX_ATTACHMENTS_COUNT, MAX_TOTAL_ATTACHMENTS_SIZE_MB
1313
from mxtoai.dependencies import processing_instructions_resolver
1414
from mxtoai.email_sender import generate_message_id, send_email_reply
15-
from mxtoai.schemas import RateLimitPlan
15+
from mxtoai.schemas import UserPlan
1616
from mxtoai.whitelist import get_whitelist_signup_url, is_email_whitelisted, trigger_automatic_verification
1717

1818
logger = get_logger(__name__)
@@ -23,16 +23,26 @@
2323

2424
# Rate limit settings
2525
RATE_LIMITS_BY_PLAN = {
26-
RateLimitPlan.BETA: {
27-
"hour": {"limit": 20, "period_seconds": 3600, "expiry_seconds": 3600 * 2}, # 2hr expiry for 1hr window
28-
"day": {"limit": 50, "period_seconds": 86400, "expiry_seconds": 86400 + 3600}, # 25hr expiry for 24hr window
26+
UserPlan.BETA: {
27+
"hour": {"limit": 10, "period_seconds": 3600, "expiry_seconds": 3600 * 2}, # 2hr expiry for 1hr window
28+
"day": {"limit": 30, "period_seconds": 86400, "expiry_seconds": 86400 + 3600}, # 25hr expiry for 24hr window
2929
"month": {
30-
"limit": 300,
30+
"limit": 200,
3131
"period_seconds": 30 * 86400,
3232
"expiry_seconds": (30 * 86400) + 86400,
3333
}, # 31day expiry for 30day window
34-
}
34+
},
35+
UserPlan.PRO: {
36+
"hour": {"limit": 50, "period_seconds": 3600, "expiry_seconds": 3600 * 2}, # 2hr expiry for 1hr window
37+
"day": {"limit": 100, "period_seconds": 86400, "expiry_seconds": 86400 + 3600}, # 25hr expiry for 24hr window
38+
"month": {
39+
"limit": 1000,
40+
"period_seconds": 30 * 86400,
41+
"expiry_seconds": (30 * 86400) + 86400,
42+
},
43+
},
3544
}
45+
3646
RATE_LIMIT_PER_DOMAIN_HOUR = { # Consistent structure for domain limits
3747
"hour": {"limit": 50, "period_seconds": 3600, "expiry_seconds": 3600 * 2}
3848
}
@@ -182,7 +192,7 @@ async def send_rate_limit_rejection_email(
182192

183193

184194
async def validate_rate_limits(
185-
from_email: str, to: str, subject: str | None, message_id: str | None, plan: RateLimitPlan
195+
from_email: str, to: str, subject: str | None, message_id: str | None, plan: UserPlan
186196
) -> Response | None:
187197
"""
188198
Validate incoming email against defined rate limits based on the plan, using Redis.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ dependencies = [
5656
"httpx (>=0.27.0,<1.0.0)",
5757
"httpx-aiohttp (>=0.1.6,<0.2.0)",
5858
"transformers (>=4.53.1,<5.0.0)",
59+
"pyjwt[crypto] (>=2.8.0,<3.0.0)",
5960
]
6061

6162
[tool.ruff]

scripts/generate_test_jwt.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Generate a test JWT token for testing the /suggestions endpoint.
4+
"""
5+
6+
import os
7+
from datetime import datetime, timedelta, timezone
8+
9+
import jwt
10+
11+
# JWT configuration
12+
JWT_SECRET = os.getenv("JWT_SECRET", "test_secret_key_for_development_only")
13+
JWT_ALGORITHM = "HS256"
14+
15+
16+
def generate_test_jwt(email: str = "test@example.com", user_id: str = "test_user_123") -> str:
17+
"""Generate a test JWT token."""
18+
# Token expires in 1 hour
19+
exp = datetime.now(timezone.utc) + timedelta(hours=1)
20+
21+
payload = {
22+
"sub": user_id, # Subject (user ID)
23+
"email": email,
24+
"exp": int(exp.timestamp()),
25+
"iat": int(datetime.now(timezone.utc).timestamp()), # Issued at
26+
}
27+
28+
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
29+
30+
31+
if __name__ == "__main__":
32+
token = generate_test_jwt()

0 commit comments

Comments
 (0)