Coverage for src/alprina_cli/api/routes/billing.py: 31%
75 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
1"""
2Billing and Subscription Routes
4Handles Polar checkout creation, subscription management, and billing portal access.
5"""
7from fastapi import APIRouter, Depends, HTTPException
8from pydantic import BaseModel
9from typing import Dict, Any, Optional
10from loguru import logger
12from ..middleware.auth import get_current_user, get_current_user_no_rate_limit
13from ..services.polar_service import polar_service
15router = APIRouter(prefix="/v1/billing", tags=["billing"])
17# Product IDs from Polar
18POLAR_PRODUCT_IDS = {
19 "free": "a1a52dd9-42ad-4c60-a87c-3cd99827f69e",
20 "developer": "68443920-6061-434f-880d-83d4efd50fde",
21 "pro": "fa25e85e-5295-4dd5-bdd9-5cb5cac15a0b"
22}
25class CheckoutRequest(BaseModel):
26 """Request to create a checkout session."""
27 product_tier: str # "developer", "pro", or "team"
28 billing_period: str = "monthly" # "monthly" or "annual"
29 success_url: Optional[str] = None
30 cancel_url: Optional[str] = None
33class CheckoutResponse(BaseModel):
34 """Checkout session response."""
35 checkout_url: str
36 product_id: str
37 tier: str
40@router.post("/create-checkout", response_model=CheckoutResponse)
41async def create_checkout(
42 request: CheckoutRequest,
43 user: Dict = Depends(get_current_user_no_rate_limit) # No rate limits on checkout!
44):
45 """
46 Create a Polar checkout session for subscription upgrade.
48 Args:
49 request: Checkout details (tier to upgrade to)
50 user: Current authenticated user
52 Returns:
53 Checkout URL to redirect user to
55 Example:
56 POST /v1/billing/create-checkout
57 {
58 "product_tier": "developer",
59 "success_url": "https://platform.alprina.com/billing/success"
60 }
61 """
62 # Validate tier
63 tier = request.product_tier.lower()
64 billing = request.billing_period.lower()
66 if tier not in ["developer", "pro", "team"]:
67 raise HTTPException(
68 status_code=400,
69 detail={
70 "error": "invalid_tier",
71 "message": f"Invalid tier '{tier}'. Must be 'developer', 'pro', or 'team'",
72 "valid_tiers": ["developer", "pro", "team"]
73 }
74 )
76 if billing not in ["monthly", "annual"]:
77 raise HTTPException(
78 status_code=400,
79 detail={
80 "error": "invalid_billing",
81 "message": f"Invalid billing period '{billing}'. Must be 'monthly' or 'annual'",
82 "valid_periods": ["monthly", "annual"]
83 }
84 )
86 # Get price ID (needed for new Polar API)
87 from ..config.polar_products import get_price_id
88 price_id = get_price_id(tier, billing)
90 if not price_id:
91 raise HTTPException(
92 status_code=500,
93 detail={
94 "error": "price_not_configured",
95 "message": f"Price ID not configured for tier '{tier}' with '{billing}' billing"
96 }
97 )
99 # Set default URLs if not provided
100 base_url = "https://alprina.com"
101 success_url = request.success_url or f"{base_url}/checkout/success?checkout_id={{CHECKOUT_ID}}&plan={tier}&billing={billing}"
103 try:
104 # Create checkout session with Polar (using new API format)
105 checkout_data = await polar_service.create_checkout_session(
106 product_price_id=price_id,
107 success_url=success_url,
108 customer_email=user.get("email"),
109 customer_metadata={
110 "user_id": str(user["id"]), # Convert UUID to string for JSON
111 "tier": tier,
112 "billing": billing
113 }
114 )
116 logger.info(
117 f"Created checkout session for user {user['id']}: tier={tier}, billing={billing}"
118 )
120 return CheckoutResponse(
121 checkout_url=checkout_data.get("url"),
122 product_id=price_id, # Return price_id for reference
123 tier=tier
124 )
126 except Exception as e:
127 logger.error(f"Failed to create checkout session: {e}")
128 raise HTTPException(
129 status_code=500,
130 detail={
131 "error": "checkout_creation_failed",
132 "message": "Failed to create checkout session. Please try again.",
133 "details": str(e)
134 }
135 )
138@router.get("/subscription")
139async def get_subscription(user: Dict = Depends(get_current_user)):
140 """
141 Get current subscription details.
143 Returns:
144 Current subscription info including tier, status, and limits
145 """
146 subscription_id = user.get("polar_subscription_id")
148 if not subscription_id:
149 return {
150 "tier": user.get("tier", "free"),
151 "subscription_status": "inactive",
152 "has_active_subscription": False,
153 "limits": polar_service.get_tier_limits(user.get("tier", "free"))
154 }
156 try:
157 # Get subscription from Polar
158 subscription = await polar_service.get_subscription(subscription_id)
160 return {
161 "tier": user.get("tier"),
162 "subscription_status": user.get("subscription_status"),
163 "has_active_subscription": True,
164 "subscription_id": subscription_id,
165 "subscription_data": subscription,
166 "limits": polar_service.get_tier_limits(user.get("tier", "free"))
167 }
169 except Exception as e:
170 logger.error(f"Failed to get subscription: {e}")
171 return {
172 "tier": user.get("tier", "free"),
173 "subscription_status": user.get("subscription_status", "unknown"),
174 "has_active_subscription": False,
175 "error": str(e)
176 }
179@router.post("/cancel-subscription")
180async def cancel_subscription(user: Dict = Depends(get_current_user)):
181 """
182 Cancel current subscription (at end of billing period).
184 The subscription will remain active until the end of the current billing period.
185 """
186 subscription_id = user.get("polar_subscription_id")
188 if not subscription_id:
189 raise HTTPException(
190 status_code=400,
191 detail={
192 "error": "no_active_subscription",
193 "message": "No active subscription to cancel"
194 }
195 )
197 try:
198 # Cancel subscription with Polar (at period end)
199 result = await polar_service.cancel_subscription(
200 subscription_id=subscription_id,
201 at_period_end=True
202 )
204 logger.info(f"User {user['id']} cancelled subscription {subscription_id}")
206 return {
207 "success": True,
208 "message": "Subscription cancelled. Access will continue until end of billing period.",
209 "subscription_id": subscription_id,
210 "cancellation_data": result
211 }
213 except Exception as e:
214 logger.error(f"Failed to cancel subscription: {e}")
215 raise HTTPException(
216 status_code=500,
217 detail={
218 "error": "cancellation_failed",
219 "message": "Failed to cancel subscription. Please try again.",
220 "details": str(e)
221 }
222 )
225@router.post("/customer-portal")
226async def create_customer_portal_session(user: Dict = Depends(get_current_user)):
227 """
228 Create a Polar customer portal session for subscription management.
230 Returns:
231 URL to redirect user to Polar's customer portal where they can:
232 - Update payment method
233 - View billing history
234 - Manage subscription
235 - Download invoices
236 """
237 try:
238 # For now, return the Polar customer portal URL
239 # In the future, we can create a session-specific URL with Polar API
240 polar_customer_id = user.get("polar_customer_id")
242 if polar_customer_id:
243 # TODO: Use Polar API to create a customer portal session
244 # portal_session = await polar_service.create_customer_portal_session(polar_customer_id)
245 # return {"url": portal_session.url}
246 pass
248 # Fallback to general Polar dashboard
249 return {
250 "url": "https://polar.sh/dashboard",
251 "message": "Redirecting to Polar billing dashboard"
252 }
254 except Exception as e:
255 logger.error(f"Failed to create customer portal session: {e}")
256 raise HTTPException(
257 status_code=500,
258 detail={
259 "error": "portal_creation_failed",
260 "message": "Failed to create customer portal session",
261 "details": str(e)
262 }
263 )
266@router.get("/products")
267async def list_products():
268 """
269 List all available subscription products.
271 Returns:
272 Available subscription tiers with pricing and features
273 """
274 try:
275 # Get products from Polar
276 products_response = await polar_service.list_products()
278 return {
279 "products": products_response.get("items", []),
280 "product_ids": POLAR_PRODUCT_IDS
281 }
283 except Exception as e:
284 logger.error(f"Failed to list products: {e}")
285 # Return hardcoded product info as fallback
286 return {
287 "products": [
288 {
289 "id": POLAR_PRODUCT_IDS["developer"],
290 "name": "Alprina Developer",
291 "price": "$29/month",
292 "features": [
293 "100 security scans per month",
294 "18 AI-powered security agents",
295 "Up to 500 files per scan",
296 "60 API requests per hour",
297 "HTML & PDF reports"
298 ]
299 },
300 {
301 "id": POLAR_PRODUCT_IDS["pro"],
302 "name": "Alprina Pro",
303 "price": "$99/month",
304 "features": [
305 "Unlimited security scans",
306 "All 18 AI-powered agents",
307 "Up to 5,000 files per scan",
308 "300 API requests per hour",
309 "Parallel agent execution",
310 "Sequential workflows",
311 "Coordinated agent chains",
312 "Advanced reports",
313 "Priority support"
314 ]
315 }
316 ],
317 "product_ids": POLAR_PRODUCT_IDS,
318 "error": "Could not fetch from Polar, using cached data"
319 }