Coverage for src/alprina_cli/api/polar_meters.py: 31%

58 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2025-11-14 11:27 +0100

1""" 

2Polar Meter Integration for Usage-Based Billing 

3Reports credit consumption to Polar for automatic billing 

4""" 

5import os 

6import requests 

7from datetime import datetime 

8from typing import Optional, Dict, Any 

9import logging 

10 

11logger = logging.getLogger(__name__) 

12 

13class PolarMeterService: 

14 """Report usage to Polar meters for billing""" 

15 

16 POLAR_API_URL = "https://api.polar.sh" 

17 POLAR_ACCESS_TOKEN = os.getenv("POLAR_ACCESS_TOKEN") or os.getenv("POLAR_API_TOKEN") 

18 

19 # Credit costs for different operations 

20 CREDIT_COSTS = { 

21 # Scans - start simple (all = 1 credit) 

22 "scan_basic": 1, 

23 "scan_standard": 1, # Can adjust later: 5 credits 

24 "scan_deep": 1, # Can adjust later: 10 credits 

25 "scan_red_team": 1, 

26 "scan_blue_team": 1, 

27 "scan_owasp": 1, 

28 

29 # Future features (commented for now) 

30 # "chat_query": 0.5, 

31 # "chat_analysis": 2, 

32 # "report_html": 1, 

33 # "report_pdf": 2, 

34 # "api_call": 0.1, 

35 } 

36 

37 @classmethod 

38 async def report_credit_usage( 

39 cls, 

40 external_customer_id: str, 

41 credits: int, 

42 operation: str, 

43 metadata: Optional[Dict[str, Any]] = None 

44 ) -> bool: 

45 """ 

46 Report credit consumption to Polar meter using Events API. 

47 

48 Args: 

49 external_customer_id: Your internal customer ID (email or user_id) 

50 credits: Number of credits consumed 

51 operation: What operation consumed credits (e.g., "scan_basic") 

52 metadata: Additional context (optional) 

53 

54 Returns: 

55 True if successfully reported, False otherwise 

56 """ 

57 if not cls.POLAR_ACCESS_TOKEN: 

58 logger.error("POLAR_ACCESS_TOKEN not set") 

59 return False 

60 

61 if not external_customer_id: 

62 logger.error("No external_customer_id provided") 

63 return False 

64 

65 # Prepare event payload (matches Polar SDK format) 

66 event_metadata = { 

67 "credits": credits, # This field is summed by meter 

68 "operation": operation, 

69 } 

70 

71 # Add optional metadata 

72 if metadata: 

73 event_metadata.update(metadata) 

74 

75 payload = { 

76 "events": [ 

77 { 

78 "name": "credit_consumed", # Must match meter event name filter 

79 "external_customer_id": external_customer_id, # Your customer ID (snake_case!) 

80 "metadata": event_metadata 

81 } 

82 ] 

83 } 

84 

85 headers = { 

86 "Authorization": f"Bearer {cls.POLAR_ACCESS_TOKEN}", 

87 "Content-Type": "application/json" 

88 } 

89 

90 try: 

91 # Send to Polar Events Ingest API 

92 response = requests.post( 

93 f"{cls.POLAR_API_URL}/v1/events/ingest", 

94 json=payload, 

95 headers=headers, 

96 timeout=10 

97 ) 

98 

99 if response.status_code in [200, 201, 204]: 

100 logger.info(f"✅ Reported {credits} credits to Polar for {operation} (customer: {external_customer_id})") 

101 return True 

102 else: 

103 logger.error(f"❌ Polar API error: {response.status_code} - {response.text}") 

104 return False 

105 

106 except Exception as e: 

107 logger.error(f"❌ Failed to report to Polar: {str(e)}") 

108 return False 

109 

110 @classmethod 

111 def get_operation_cost(cls, operation: str) -> int: 

112 """Get credit cost for an operation""" 

113 return cls.CREDIT_COSTS.get(operation, 1) 

114 

115 @classmethod 

116 async def report_scan( 

117 cls, 

118 user_email: str, 

119 scan_type: str, 

120 target: str, 

121 user_id: str 

122 ) -> bool: 

123 """ 

124 Convenience method to report scan usage. 

125 

126 Args: 

127 user_email: User's email (used as external_customer_id) 

128 scan_type: Type of scan (e.g., "red_team", "owasp") 

129 target: Scan target 

130 user_id: User who ran the scan 

131 

132 Returns: 

133 True if reported successfully 

134 """ 

135 operation = f"scan_{scan_type}" 

136 credits = cls.get_operation_cost(operation) 

137 

138 metadata = { 

139 "scan_type": scan_type, 

140 "target": target, 

141 "user_id": user_id 

142 } 

143 

144 return await cls.report_credit_usage( 

145 external_customer_id=user_email, # Use email as customer identifier 

146 credits=credits, 

147 operation=operation, 

148 metadata=metadata 

149 ) 

150 

151 @classmethod 

152 async def get_customer_usage(cls, external_customer_id: str) -> Optional[Dict[str, Any]]: 

153 """ 

154 Get current usage for a customer from Polar. 

155 

156 Args: 

157 external_customer_id: Your internal customer ID (email) 

158 

159 Returns: 

160 { 

161 "consumed_units": 150, 

162 "credited_units": 100, 

163 "balance": -50 # Negative = overage 

164 } 

165 """ 

166 if not cls.POLAR_ACCESS_TOKEN: 

167 return None 

168 

169 headers = { 

170 "Authorization": f"Bearer {cls.POLAR_ACCESS_TOKEN}", 

171 } 

172 

173 try: 

174 response = requests.get( 

175 f"{cls.POLAR_API_URL}/v1/customer-meters/", 

176 params={"external_customer_id": external_customer_id}, 

177 headers=headers, 

178 timeout=10 

179 ) 

180 

181 if response.status_code == 200: 

182 data = response.json() 

183 if data.get("items"): 

184 meter = data["items"][0] # Get first meter 

185 return { 

186 "consumed_units": meter.get("consumed_units", 0), 

187 "credited_units": meter.get("credited_units", 0), 

188 "balance": meter.get("balance", 0), 

189 "overage": max(0, -meter.get("balance", 0)) 

190 } 

191 

192 return None 

193 

194 except Exception as e: 

195 logger.error(f"Failed to get customer usage: {str(e)}") 

196 return None