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

1""" 

2Billing and Subscription Routes 

3 

4Handles Polar checkout creation, subscription management, and billing portal access. 

5""" 

6 

7from fastapi import APIRouter, Depends, HTTPException 

8from pydantic import BaseModel 

9from typing import Dict, Any, Optional 

10from loguru import logger 

11 

12from ..middleware.auth import get_current_user, get_current_user_no_rate_limit 

13from ..services.polar_service import polar_service 

14 

15router = APIRouter(prefix="/v1/billing", tags=["billing"]) 

16 

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} 

23 

24 

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 

31 

32 

33class CheckoutResponse(BaseModel): 

34 """Checkout session response.""" 

35 checkout_url: str 

36 product_id: str 

37 tier: str 

38 

39 

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. 

47 

48 Args: 

49 request: Checkout details (tier to upgrade to) 

50 user: Current authenticated user 

51 

52 Returns: 

53 Checkout URL to redirect user to 

54 

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() 

65 

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 ) 

75 

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 ) 

85 

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) 

89 

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 ) 

98 

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}" 

102 

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 ) 

115 

116 logger.info( 

117 f"Created checkout session for user {user['id']}: tier={tier}, billing={billing}" 

118 ) 

119 

120 return CheckoutResponse( 

121 checkout_url=checkout_data.get("url"), 

122 product_id=price_id, # Return price_id for reference 

123 tier=tier 

124 ) 

125 

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 ) 

136 

137 

138@router.get("/subscription") 

139async def get_subscription(user: Dict = Depends(get_current_user)): 

140 """ 

141 Get current subscription details. 

142 

143 Returns: 

144 Current subscription info including tier, status, and limits 

145 """ 

146 subscription_id = user.get("polar_subscription_id") 

147 

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 } 

155 

156 try: 

157 # Get subscription from Polar 

158 subscription = await polar_service.get_subscription(subscription_id) 

159 

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 } 

168 

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 } 

177 

178 

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). 

183 

184 The subscription will remain active until the end of the current billing period. 

185 """ 

186 subscription_id = user.get("polar_subscription_id") 

187 

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 ) 

196 

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 ) 

203 

204 logger.info(f"User {user['id']} cancelled subscription {subscription_id}") 

205 

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 } 

212 

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 ) 

223 

224 

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. 

229 

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") 

241 

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 

247 

248 # Fallback to general Polar dashboard 

249 return { 

250 "url": "https://polar.sh/dashboard", 

251 "message": "Redirecting to Polar billing dashboard" 

252 } 

253 

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 ) 

264 

265 

266@router.get("/products") 

267async def list_products(): 

268 """ 

269 List all available subscription products. 

270 

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() 

277 

278 return { 

279 "products": products_response.get("items", []), 

280 "product_ids": POLAR_PRODUCT_IDS 

281 } 

282 

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 }